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.
b810874 parent: 0b150d0 modified
backend/studio/modules/projects/api.py +3 -2 | @@ -1427,10 +1427,11 @@ def put_montage_style(pid: str, values: dict): | ||
| 1427 | 1427 | def put_montage(pid: str, req: MontageRequest): |
| 1428 | 1428 | _check(pid) |
| 1429 | 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 | 1431 | with service.editing(pid) as p: |
| 1432 | 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 | 1437 | @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: | ||
| 496 | 496 | path = root(pid) / TIMELINE_FILE |
| 497 | 497 | if not path.is_file(): |
| 498 | 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 | 504 | key = hashlib.sha1(data).hexdigest()[:16] |
| 501 | 505 | copy = root(pid) / REVS_DIR / f"{key}.json" |
| 502 | 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 | 582 | l'A-roll qu'elle contient ([{at, length}]), que les beats du storyboard suivent. `base` : l'empreinte de la version |
| 579 | 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 | 599 | project = service.load(pid) |
| 582 | 600 | duration = (_source(project) or {}).get("duration") or 0.0 |
| 583 | 601 | cleaned = clean(pid, raw, duration) |
| 584 | 602 | with _locked(pid): |
| 585 | 603 | older = _read(root(pid) / REVS_DIR / f"{base}.json") if base and base != rev(pid) else None |
| 604 | + mine = None | |
| 586 | 605 | if older is not None: |
| 606 | + mine = _keep(pid, json.dumps(cleaned, ensure_ascii=False, indent=1).encode("utf-8")) | |
| 587 | 607 | cleaned = clean(pid, merge(older, cleaned, load(pid))[0], duration) |
| 588 | 608 | if keep_history: |
| 589 | 609 | snapshot(pid) |
| @@ -591,8 +611,9 @@ def save(pid: str, raw: dict, keep_history: bool = True, shifts: list[dict] | No | ||
| 591 | 611 | for shift in shifts or []: |
| 592 | 612 | # sans historique, rien à défaire : on décale seulement |
| 593 | 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 | 615 | after_change(pid) |
| 595 | - return cleaned | |
| 616 | + return cleaned, mine | |
| 596 | 617 | |
| 597 | 618 | |
| 598 | 619 | # --- La copie de travail de Daryl ------------------------------------------------------------ |
| @@ -496,7 +496,11 @@ def rev(pid: str) -> str: | |||
| 496 | path = root(pid) / TIMELINE_FILE | 496 | 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 version | 582 | 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.0 | 600 | 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 None | 603 | 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 seulement | 612 | # 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 cleaned | 616 | + 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) { | ||
| 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 | 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 | 44 | let timer: ReturnType<typeof setTimeout> | undefined; |
| @@ -53,6 +58,7 @@ export function useMontage(pid: string) { | ||
| 53 | 58 | |
| 54 | 59 | /** Enregistre la timeline (regroupe les modifications rapprochées). */ |
| 55 | 60 | function save(delay = 500) { |
| 61 | + edits++; | |
| 56 | 62 | dirty.value = true; |
| 57 | 63 | clearTimeout(saveTimer); |
| 58 | 64 | saveTimer = setTimeout(flush, delay); |
| @@ -66,18 +72,35 @@ export function useMontage(pid: string) { | ||
| 66 | 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 | 78 | async function flush() { |
| 70 | 79 | clearTimeout(saveTimer); |
| 80 | + while (saving) await saving; | |
| 71 | 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 | 92 | const sent = clone(tl.value); |
| 73 | 93 | const beats_shift = shifts; |
| 74 | 94 | shifts = []; |
| 75 | 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 | 97 | // rien n'a bougé depuis l'envoi : la version du serveur (nettoyée) fait foi |
| 78 | 98 | const same = JSON.stringify(sent) === JSON.stringify(tl.value); |
| 79 | 99 | if (same) dirty.value = false; |
| 80 | 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 | 104 | } catch (e) { |
| 82 | 105 | toast((e as Error).message, true); |
| 83 | 106 | 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 foi | 97 | // 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; |