hlassiege/darylpublic Fork
⑂ b810874
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(montage): stop duplicating items when timeline saves overlap

A save sent with a stale base made the server merge an item the client had just added as if it had been added on both sides, keeping two copies. Saves now run one after another, the server returns the base for edits made during a save, and a poll started before an edit no longer overwrites it.
hlassiege committed 2026-10-13T15:24:13+02:00 Browse files
b810874 parent: 0b150d0
modified backend/studio/modules/projects/api.py +3 -2
@@ -1427,10 +1427,11 @@ def put_montage_style(pid: str, values: dict):
14271427 def put_montage(pid: str, req: MontageRequest):
14281428 _check(pid)
14291429 montage.prepare(pid)
1430- montage.save(pid, req.timeline, shifts=req.beats_shift, base=req.rev)
1430+ base = montage.save_edit(pid, req.timeline, req.beats_shift, req.rev)
14311431 with service.editing(pid) as p:
14321432 service.start_step(p, "montage")
1433- return _montage_view(pid)
1433+ # la base des retouches faites à l'écran pendant l'enregistrement
1434+ return {**_montage_view(pid), "base": base}
14341435
14351436
14361437 @router.post("/{pid}/montage/undo")
@@ -1427,10 +1427,11 @@ def put_montage_style(pid: str, values: dict):
1427 def put_montage(pid: str, req: MontageRequest):1427 def put_montage(pid: str, req: MontageRequest):
1428 _check(pid)1428 _check(pid)
1429 montage.prepare(pid)1429 montage.prepare(pid)
1430- montage.save(pid, req.timeline, shifts=req.beats_shift, base=req.rev)1430+ base = montage.save_edit(pid, req.timeline, req.beats_shift, req.rev)
1431 with service.editing(pid) as p:1431 with service.editing(pid) as p:
1432 service.start_step(p, "montage")1432 service.start_step(p, "montage")
1433- return _montage_view(pid)1433+ # la base des retouches faites à l'écran pendant l'enregistrement
1434+ return {**_montage_view(pid), "base": base}
1434 1435
1435 1436
1436 @router.post("/{pid}/montage/undo")1437 @router.post("/{pid}/montage/undo")
modified backend/studio/modules/projects/montage.py +23 -2
@@ -496,7 +496,11 @@ def rev(pid: str) -> str:
496496 path = root(pid) / TIMELINE_FILE
497497 if not path.is_file():
498498 return ""
499- data = path.read_bytes()
499+ return _keep(pid, path.read_bytes())
500+
501+
502+def _keep(pid: str, data: bytes) -> str:
503+ """Garde une version de la timeline parmi les versions connues ; rend son empreinte."""
500504 key = hashlib.sha1(data).hexdigest()[:16]
501505 copy = root(pid) / REVS_DIR / f"{key}.json"
502506 if not copy.is_file():
@@ -578,12 +582,28 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
578582 l'A-roll qu'elle contient ([{at, length}]), que les beats du storyboard suivent. `base` : l'empreinte de la version
579583 retouchée ; si la timeline a changé depuis (le tour de Daryl vient d'y être fusionné), la retouche y est fusionnée.
580584 """
585+ return _save(pid, raw, keep_history, shifts, base)[0]
586+
587+
588+def save_edit(pid: str, raw: dict, shifts: list[dict] | None, base: str | None) -> str:
589+ """
590+ Comme `save`, pour une retouche de l'interface ; rend l'empreinte de la version reçue (nettoyée) : la base des
591+ retouches faites à l'écran pendant l'enregistrement. Ce n'est pas celle de la version écrite si un tour de Daryl
592+ y a été fusionné : sinon ces retouches sembleraient défaire ce tour, et un élément ajouté entre-temps semblerait
593+ ajouté des deux côtés (et serait doublé).
594+ """
595+ return _save(pid, raw, True, shifts, base)[1]
596+
597+
598+def _save(pid: str, raw: dict, keep_history: bool, shifts: list[dict] | None, base: str | None) -> tuple[dict, str]:
581599 project = service.load(pid)
582600 duration = (_source(project) or {}).get("duration") or 0.0
583601 cleaned = clean(pid, raw, duration)
584602 with _locked(pid):
585603 older = _read(root(pid) / REVS_DIR / f"{base}.json") if base and base != rev(pid) else None
604+ mine = None
586605 if older is not None:
606+ mine = _keep(pid, json.dumps(cleaned, ensure_ascii=False, indent=1).encode("utf-8"))
587607 cleaned = clean(pid, merge(older, cleaned, load(pid))[0], duration)
588608 if keep_history:
589609 snapshot(pid)
@@ -591,8 +611,9 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
591611 for shift in shifts or []:
592612 # sans historique, rien à défaire : on décale seulement
593613 (shift_beats if keep_history else _shift_beats)(pid, _num(shift.get("at")), _num(shift.get("length")))
614+ mine = mine or rev(pid)
594615 after_change(pid)
595- return cleaned
616+ return cleaned, mine
596617
597618
598619 # --- La copie de travail de Daryl ------------------------------------------------------------
@@ -496,7 +496,11 @@ def rev(pid: str) -> str:
496 path = root(pid) / TIMELINE_FILE496 path = root(pid) / TIMELINE_FILE
497 if not path.is_file():497 if not path.is_file():
498 return ""498 return ""
499- data = path.read_bytes()499+ return _keep(pid, path.read_bytes())
500+
501+
502+def _keep(pid: str, data: bytes) -> str:
503+ """Garde une version de la timeline parmi les versions connues ; rend son empreinte."""
500 key = hashlib.sha1(data).hexdigest()[:16]504 key = hashlib.sha1(data).hexdigest()[:16]
501 copy = root(pid) / REVS_DIR / f"{key}.json"505 copy = root(pid) / REVS_DIR / f"{key}.json"
502 if not copy.is_file():506 if not copy.is_file():
@@ -578,12 +582,28 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
578 l'A-roll qu'elle contient ([{at, length}]), que les beats du storyboard suivent. `base` : l'empreinte de la version582 l'A-roll qu'elle contient ([{at, length}]), que les beats du storyboard suivent. `base` : l'empreinte de la version
579 retouchée ; si la timeline a changé depuis (le tour de Daryl vient d'y être fusionné), la retouche y est fusionnée.583 retouchée ; si la timeline a changé depuis (le tour de Daryl vient d'y être fusionné), la retouche y est fusionnée.
580 """584 """
585+ return _save(pid, raw, keep_history, shifts, base)[0]
586+
587+
588+def save_edit(pid: str, raw: dict, shifts: list[dict] | None, base: str | None) -> str:
589+ """
590+ Comme `save`, pour une retouche de l'interface ; rend l'empreinte de la version reçue (nettoyée) : la base des
591+ retouches faites à l'écran pendant l'enregistrement. Ce n'est pas celle de la version écrite si un tour de Daryl
592+ y a été fusionné : sinon ces retouches sembleraient défaire ce tour, et un élément ajouté entre-temps semblerait
593+ ajouté des deux côtés (et serait doublé).
594+ """
595+ return _save(pid, raw, True, shifts, base)[1]
596+
597+
598+def _save(pid: str, raw: dict, keep_history: bool, shifts: list[dict] | None, base: str | None) -> tuple[dict, str]:
581 project = service.load(pid)599 project = service.load(pid)
582 duration = (_source(project) or {}).get("duration") or 0.0600 duration = (_source(project) or {}).get("duration") or 0.0
583 cleaned = clean(pid, raw, duration)601 cleaned = clean(pid, raw, duration)
584 with _locked(pid):602 with _locked(pid):
585 older = _read(root(pid) / REVS_DIR / f"{base}.json") if base and base != rev(pid) else None603 older = _read(root(pid) / REVS_DIR / f"{base}.json") if base and base != rev(pid) else None
604+ mine = None
586 if older is not None:605 if older is not None:
606+ mine = _keep(pid, json.dumps(cleaned, ensure_ascii=False, indent=1).encode("utf-8"))
587 cleaned = clean(pid, merge(older, cleaned, load(pid))[0], duration)607 cleaned = clean(pid, merge(older, cleaned, load(pid))[0], duration)
588 if keep_history:608 if keep_history:
589 snapshot(pid)609 snapshot(pid)
@@ -591,8 +611,9 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No
591 for shift in shifts or []:611 for shift in shifts or []:
592 # sans historique, rien à défaire : on décale seulement612 # sans historique, rien à défaire : on décale seulement
593 (shift_beats if keep_history else _shift_beats)(pid, _num(shift.get("at")), _num(shift.get("length")))613 (shift_beats if keep_history else _shift_beats)(pid, _num(shift.get("at")), _num(shift.get("length")))
614+ mine = mine or rev(pid)
594 after_change(pid)615 after_change(pid)
595- return cleaned616+ return cleaned, mine
596 617
597 618
598 # --- La copie de travail de Daryl ------------------------------------------------------------619 # --- La copie de travail de Daryl ------------------------------------------------------------
modified web/src/project/steps/montage/useMontage.ts +25 -2
@@ -32,8 +32,13 @@ export function useMontage(pid: string) {
3232 }
3333 }
3434
35+ /** change à chaque retouche : une relecture partie avant ne la défait pas en revenant */
36+ let edits = 0;
37+
3538 async function load(force = false) {
36- receive(await api<MontageView>(base), force);
39+ const at = edits;
40+ const v = await api<MontageView>(base);
41+ if (force || at === edits) receive(v, force);
3742 }
3843
3944 let timer: ReturnType<typeof setTimeout> | undefined;
@@ -53,6 +58,7 @@ export function useMontage(pid: string) {
5358
5459 /** Enregistre la timeline (regroupe les modifications rapprochées). */
5560 function save(delay = 500) {
61+ edits++;
5662 dirty.value = true;
5763 clearTimeout(saveTimer);
5864 saveTimer = setTimeout(flush, delay);
@@ -66,18 +72,35 @@ export function useMontage(pid: string) {
6672 shifts.push({ at, length });
6773 }
6874
75+ /** l'enregistrement en cours : le suivant part après, de la version qu'il a écrite */
76+ let saving: Promise<void> | null = null;
77+
6978 async function flush() {
7079 clearTimeout(saveTimer);
80+ while (saving) await saving;
7181 if (!dirty.value || !tl.value) return;
82+ saving = put();
83+ try {
84+ await saving;
85+ } finally {
86+ saving = null;
87+ }
88+ }
89+
90+ async function put() {
91+ if (!tl.value) return;
7292 const sent = clone(tl.value);
7393 const beats_shift = shifts;
7494 shifts = [];
7595 try {
76- const v = await putJson<MontageView>(base, { timeline: sent, beats_shift, rev });
96+ const v = await putJson<MontageView & { base: string }>(base, { timeline: sent, beats_shift, rev });
7797 // rien n'a bougé depuis l'envoi : la version du serveur (nettoyée) fait foi
7898 const same = JSON.stringify(sent) === JSON.stringify(tl.value);
7999 if (same) dirty.value = false;
80100 receive(v, same);
101+ // retouché pendant l'envoi : la suite part de ce qui a été envoyé (sinon un élément ajouté depuis l'ancienne
102+ // base semblerait ajouté des deux côtés, et serait doublé)
103+ if (!same) rev = v.base;
81104 } catch (e) {
82105 toast((e as Error).message, true);
83106 dirty.value = false;
@@ -32,8 +32,13 @@ export function useMontage(pid: string) {
32 }32 }
33 }33 }
34 34
35+ /** change à chaque retouche : une relecture partie avant ne la défait pas en revenant */
36+ let edits = 0;
37+
35 async function load(force = false) {38 async function load(force = false) {
36- receive(await api<MontageView>(base), force);39+ const at = edits;
40+ const v = await api<MontageView>(base);
41+ if (force || at === edits) receive(v, force);
37 }42 }
38 43
39 let timer: ReturnType<typeof setTimeout> | undefined;44 let timer: ReturnType<typeof setTimeout> | undefined;
@@ -53,6 +58,7 @@ export function useMontage(pid: string) {
53 58
54 /** Enregistre la timeline (regroupe les modifications rapprochées). */59 /** Enregistre la timeline (regroupe les modifications rapprochées). */
55 function save(delay = 500) {60 function save(delay = 500) {
61+ edits++;
56 dirty.value = true;62 dirty.value = true;
57 clearTimeout(saveTimer);63 clearTimeout(saveTimer);
58 saveTimer = setTimeout(flush, delay);64 saveTimer = setTimeout(flush, delay);
@@ -66,18 +72,35 @@ export function useMontage(pid: string) {
66 shifts.push({ at, length });72 shifts.push({ at, length });
67 }73 }
68 74
75+ /** l'enregistrement en cours : le suivant part après, de la version qu'il a écrite */
76+ let saving: Promise<void> | null = null;
77+
69 async function flush() {78 async function flush() {
70 clearTimeout(saveTimer);79 clearTimeout(saveTimer);
80+ while (saving) await saving;
71 if (!dirty.value || !tl.value) return;81 if (!dirty.value || !tl.value) return;
82+ saving = put();
83+ try {
84+ await saving;
85+ } finally {
86+ saving = null;
87+ }
88+ }
89+
90+ async function put() {
91+ if (!tl.value) return;
72 const sent = clone(tl.value);92 const sent = clone(tl.value);
73 const beats_shift = shifts;93 const beats_shift = shifts;
74 shifts = [];94 shifts = [];
75 try {95 try {
76- const v = await putJson<MontageView>(base, { timeline: sent, beats_shift, rev });96+ const v = await putJson<MontageView & { base: string }>(base, { timeline: sent, beats_shift, rev });
77 // rien n'a bougé depuis l'envoi : la version du serveur (nettoyée) fait foi97 // rien n'a bougé depuis l'envoi : la version du serveur (nettoyée) fait foi
78 const same = JSON.stringify(sent) === JSON.stringify(tl.value);98 const same = JSON.stringify(sent) === JSON.stringify(tl.value);
79 if (same) dirty.value = false;99 if (same) dirty.value = false;
80 receive(v, same);100 receive(v, same);
101+ // retouché pendant l'envoi : la suite part de ce qui a été envoyé (sinon un élément ajouté depuis l'ancienne
102+ // base semblerait ajouté des deux côtés, et serait doublé)
103+ if (!same) rev = v.base;
81 } catch (e) {104 } catch (e) {
82 toast((e as Error).message, true);105 toast((e as Error).message, true);
83 dirty.value = false;106 dirty.value = false;