hlassiege/darylpublic Fork
⑂ 6fab937
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

feat(montage): pause the a-roll under a full-screen motion

hlassiege committed 2026-11-10T14:51:29+01:00 Browse files
6fab937 parent: 4be5ef8
modified backend/studio/modules/projects/montage.py +157 -9
@@ -13,6 +13,7 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
1313 montage/daryl/<demande>/ la copie de travail de Daryl pendant un tour du chat (timeline.json, contexte.md,
1414 insertions.json), fusionnée à la fin du tour avec la timeline, que le client continue
1515 de monter à la main (merge) ; plusieurs demandes tournent à la fois
16+ montage/pause.mp4 la vidéo noire et muette des pauses de l'A-roll (faite au premier besoin)
1617 montage/.cache/ forme d'onde et vignettes de la vidéo source
1718 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié
1819 dès qu'il entre dans la timeline
@@ -20,7 +21,7 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
2021 timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans le média) :
2122
2223 {
23- "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?}], # la vidéo source, bout à bout ; transition avec le précédent
24+ "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?, "pause"?}], # la vidéo source, bout à bout ; transition avec le précédent
2425 "zooms": [{"id", "at", "dur", "scale", "x", "y", "style"?, "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction) ;
2526 # style : smooth (défaut), cut ou slow (ZOOM_STYLES)
2627 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)
@@ -39,6 +40,9 @@ x, y : le centre du texte en fraction de l'image (0 à 1) ; size : la hauteur de
3940 de l'image ; color : « #RRGGBB » ; angle : en degrés (négatif : penché vers le haut à droite).
4041 `src` d'un clip de l'A-roll : absent pour la vidéo source, sinon un autre média inséré (un extrait, une archive)
4142 qui passe avec son propre son. `gain_db` d'un clip de l'A-roll : son volume (absent : 0 dB).
43+`pause` d'un clip de l'A-roll : l'id du motion plein écran qu'il porte. Le clip est une pause (PAUSE_SRC, noire et
44+muette) de la durée du motion, au même instant : la voix s'arrête le temps du motion, qui devient un plan de l'A-roll
45+(pause_motion, unpause_motion ; ops.pauseMotion dans l'interface).
4246 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »
4347 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).
4448 """
@@ -49,6 +53,7 @@ import json
4953 import os
5054 import re
5155 import shutil
56+import subprocess
5257 import time
5358 from array import array
5459 from collections.abc import Iterator
@@ -56,6 +61,7 @@ from contextlib import contextmanager
5661 from pathlib import Path
5762
5863 from ...core import jobs
64+from ...core.system import tool
5965 from ..animations import kit
6066 from . import library, media, service, transitions
6167 from . import timeline as tl
@@ -66,6 +72,9 @@ TIMELINE_FILE = "timeline.json"
6672 INSERTIONS_FILE = "insertions.json"
6773 MOTION_DIR = "motion"
6874 RENDERS_DIR = "renders"
75+# la pause sous un motion plein écran : une vidéo noire et muette au format de la source, dont chaque pause prend le début
76+PAUSE_SRC = f"{MONTAGE_DIR}/pause.mp4"
77+PAUSE_LENGTH = 120.0
6978 # la page qui rend un motion (montage_tasks._index) entre dans sa signature : à monter quand elle change,
7079 # les motions déjà rendus se refont
7180 MOTION_PAGE_VERSION = 2
@@ -250,6 +259,33 @@ def _ends(item: dict) -> dict:
250259 return {k: v for k in ("enter", "exit") if (v := transitions.clean(item.get(k)))}
251260
252261
262+def ensure_pause(pid: str) -> str | None:
263+ """La vidéo des pauses (PAUSE_SRC), faite au premier besoin au format de la source ; None sans source."""
264+ dest = service.project_dir(pid) / PAUSE_SRC
265+ if dest.is_file():
266+ return PAUSE_SRC
267+ source = _source(service.load(pid))
268+ if not source:
269+ return None
270+ info = media.probe(Path(source["path"]))
271+ size = f"{source.get('width') or 1920}x{source.get('height') or 1080}"
272+ rate = f"{info.get('fps_num') or 25}/{info.get('fps_den') or 1}"
273+ dest.parent.mkdir(parents=True, exist_ok=True)
274+ seed, tmp = dest.with_name(f".seed-{dest.name}"), dest.with_name(f".{dest.name}")
275+ # 2 s encodées puis répétées sans réencodage : moins d'une seconde, contre 30 pour tout encoder
276+ subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-f", "lavfi", "-i", f"color=c=black:s={size}:r={rate}",
277+ "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", "2",
278+ "-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage", "-pix_fmt", "yuv420p",
279+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
280+ "-c:a", "aac", "-f", "mp4", str(seed)], check=True, capture_output=True, timeout=120)
281+ subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-stream_loop", str(int(PAUSE_LENGTH / 2)), "-i", str(seed),
282+ "-c", "copy", "-t", str(PAUSE_LENGTH), "-f", "mp4", str(tmp)], check=True, capture_output=True,
283+ timeout=120)
284+ seed.unlink(missing_ok=True)
285+ tmp.replace(dest)
286+ return PAUSE_SRC
287+
288+
253289 def clean(pid: str, raw: dict, source_duration: float) -> dict:
254290 """Une timeline sûre : ids uniques, nombres, bornes, médias présents dans le projet."""
255291 out: dict = {t: [] for t in TRACKS}
@@ -274,14 +310,17 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict:
274310
275311 for c in raw.get("aroll") or []:
276312 # un clip inséré d'un autre média : borné par sa durée ; un média introuvable, le clip disparaît
277- src = _resolve_src(pid, str(c["src"])) if c.get("src") else None
313+ # une pause : la vidéo des pauses, faite au besoin
314+ pause = bool(c.get("src")) and str(c["src"]).replace("\\", "/").strip("/") == PAUSE_SRC
315+ src = (ensure_pause(pid) if pause else _resolve_src(pid, str(c["src"]))) if c.get("src") else None
278316 if c.get("src") and not src:
279317 continue
280318 a, b = max(0.0, _num(c.get("in"))), min(_num(c.get("out")), (length(src) if src else source_duration) or 1e9)
281319 gain = max(-60.0, min(_num(c.get("gain_db")), 12.0))
282320 if b - a >= 0.04:
283321 out["aroll"].append({"id": ident("aroll", c.get("id")), **({"src": src} if src else {}), "in": a, "out": b,
284- "transition": transitions.clean(c.get("transition")), **({"gain_db": gain} if gain else {})})
322+ "transition": transitions.clean(c.get("transition")), **({"gain_db": gain} if gain else {}),
323+ **({"pause": str(c["pause"])[:20]} if pause and c.get("pause") else {})})
285324 total = sum(c["out"] - c["in"] for c in out["aroll"])
286325
287326 for z in raw.get("zooms") or []:
@@ -420,6 +459,86 @@ def insert_aroll(timeline: dict, at: float, clip: dict) -> dict:
420459 return {"id": new["id"], "at": at, "length": length}
421460
422461
462+def _pause_of(timeline: dict, motion_id: str) -> int | None:
463+ return next((i for i, c in enumerate(timeline["aroll"]) if c.get("pause") == motion_id), None)
464+
465+
466+def _lift(timeline: dict, motion_id: str) -> tuple[dict | None, list[dict]]:
467+ """Retire de la timeline le motion et ses sons, le temps d'insérer ou de retirer sa pause."""
468+ m = next((x for x in timeline["motion"] if x["id"] == motion_id), None)
469+ own = [s for s in timeline["audio"] if s.get("motion") == motion_id]
470+ timeline["motion"] = [x for x in timeline["motion"] if x["id"] != motion_id]
471+ timeline["audio"] = [s for s in timeline["audio"] if s.get("motion") != motion_id]
472+ return m, own
473+
474+
475+def unpause_motion(timeline: dict, motion_id: str) -> dict | None:
476+ """
477+ Retire la pause d'un motion (comme ops.unpauseMotion) : l'A-roll se referme et ce qui suit remonte d'autant ; ce
478+ qui commençait dans la pause disparaît, sauf le motion et ses sons, qui restent où ils sont. Modifie `timeline` ;
479+ rend le décalage {at, length} (length négatif), ou None s'il n'a pas de pause.
480+ """
481+ i = _pause_of(timeline, motion_id)
482+ if i is None:
483+ return None
484+ start = sum(c["out"] - c["in"] for c in timeline["aroll"][:i])
485+ c = timeline["aroll"].pop(i)
486+ length = c["out"] - c["in"]
487+ end = start + length
488+ if i < len(timeline["aroll"]):
489+ timeline["aroll"][i]["transition"] = None
490+ m, own = _lift(timeline, motion_id)
491+ inside = lambda t: start + 1e-3 < t < end - 1e-3 # noqa: E731
492+ back = lambda t: round(t - length, 3) if t >= end - 1e-3 else t # noqa: E731
493+ for track in ("zooms", "broll", "motion", "audio", "texts"):
494+ timeline[track] = [{**x, "at": back(x["at"])} for x in timeline.get(track) or [] if not inside(x["at"])]
495+ timeline["markers"] = [{**k, "t": back(k["t"])} for k in timeline["markers"] if not inside(k["t"])]
496+ if m:
497+ timeline["motion"].append(m)
498+ timeline["audio"] += own
499+ return {"at": round(end, 3), "length": round(-length, 3)}
500+
501+
502+def drop_orphan_pauses(timeline: dict) -> list[dict]:
503+ """
504+ Retire les pauses dont le motion n'est plus dans la timeline (supprimé sans retirer sa pause, perdu à une fusion) :
505+ sans lui, ce n'est qu'un trou noir et muet au milieu de la voix. Modifie `timeline` ; rend les décalages.
506+ """
507+ motions = {m["id"] for m in timeline["motion"]}
508+ orphans = {c["pause"] for c in timeline["aroll"] if c.get("pause") and c["pause"] not in motions}
509+ shifts = []
510+ for mid in orphans:
511+ # un motion peut avoir laissé plusieurs morceaux de pause (une autre pause insérée au milieu)
512+ while shift := unpause_motion(timeline, mid):
513+ shifts.append(shift)
514+ return shifts
515+
516+
517+def pause_motion(timeline: dict, motion_id: str, src: str) -> dict | None:
518+ """
519+ Met l'A-roll en pause sous un motion plein écran : une pause (`src`, la vidéo des pauses) de la durée du motion,
520+ insérée à son début (insert_aroll : tout ce qui suit est décalé) ; le motion et ses sons restent posés dessus. Une
521+ pause déjà là est refaite (la durée du motion a pu changer). Modifie `timeline` ; rend les décalages à reporter
522+ sur le storyboard [{at, length}], ou None si le motion n'existe pas.
523+ """
524+ if not any(x["id"] == motion_id for x in timeline["motion"]):
525+ return None
526+ shifts = [s for s in [unpause_motion(timeline, motion_id)] if s]
527+ m = next(x for x in timeline["motion"] if x["id"] == motion_id)
528+ held = [(x, x["at"]) for x in [m, *(s for s in timeline["audio"] if s.get("motion") == motion_id)]]
529+ # le motion et ses sons mis hors d'atteinte le temps de l'insertion (ni coupés ni décalés ; leurs ids restent pris)
530+ for x, _ in held:
531+ x["at"] = -1e9
532+ done = insert_aroll(timeline, held[0][1], {"src": src, "in": 0.0, "out": min(m["dur"], PAUSE_LENGTH)})
533+ next(c for c in timeline["aroll"] if c["id"] == done["id"])["pause"] = motion_id
534+ # calé sur la coupe toute proche : le motion et ses sons la suivent
535+ delta = done["at"] - held[0][1]
536+ for x, at in held:
537+ x["at"] = round(max(0.0, at + delta), 3)
538+ # le beat du motion reste à son début : seuls ceux d'après suivent
539+ return shifts + [{"at": round(done["at"] + 0.01, 3), "length": done["length"]}]
540+
541+
423542 def _shift_beats(pid: str, at: float, length: float) -> None:
424543 """Décale de `length` les beats du storyboard qui commencent à partir de `at`."""
425544 from . import storyboard
@@ -454,8 +573,9 @@ def apply_insertions(pid: str, task: str) -> list[str]:
454573 Les clips que Daryl a demandé d'insérer pour la demande `task` (insertions.json dans son dossier de travail :
455574 [{src, at, in, out, gain_db?}], `at` en temps
456575 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier
457- au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll
458- est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.
576+ au premier pour que les temps restent justes ; puis les pauses sous un motion ({pause: id}, à poser ou à refaire
577+ quand sa durée change) et leurs retraits ({unpause: id}). Les beats du storyboard ne suivent qu'à la fin du tour,
578+ si son A-roll est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.
459579 """
460580 file = work_dir(pid, task) / INSERTIONS_FILE
461581 if not file.is_file():
@@ -472,7 +592,9 @@ def apply_insertions(pid: str, task: str) -> list[str]:
472592 duration = (_source(project) or {}).get("duration") or 0.0
473593 timeline = clean(pid, _read(work_file(pid, task)) or load(pid), duration)
474594 problems, shifts = [], []
475- for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))):
595+ wanted = [w for w in wanted if isinstance(w, dict)]
596+ clips = [w for w in wanted if not w.get("pause") and not w.get("unpause")]
597+ for w in sorted(clips, key=lambda w: -_num(w.get("at"))):
476598 src = _resolve_src(pid, str(w.get("src") or ""))
477599 if not src:
478600 problems.append(f"média introuvable : {w.get('src')}")
@@ -485,6 +607,21 @@ def apply_insertions(pid: str, task: str) -> list[str]:
485607 continue
486608 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})
487609 shifts.append({"at": done["at"], "length": done["length"]})
610+ # les pauses sous un motion, par son id (sa place suit les insertions faites juste avant)
611+ for w in wanted:
612+ if w.get("unpause"):
613+ done = unpause_motion(timeline, str(w["unpause"]))
614+ if not done:
615+ problems.append(f"pas de pause sous le motion {w['unpause']}")
616+ continue
617+ shifts.append(done)
618+ elif w.get("pause"):
619+ src = ensure_pause(pid)
620+ done = pause_motion(timeline, str(w["pause"]), src) if src else None
621+ if done is None:
622+ problems.append(f"motion introuvable pour la pause : {w['pause']}")
623+ continue
624+ shifts += done
488625 _dump(work_file(pid, task), clean(pid, timeline, duration))
489626 noted = work_dir(pid, task) / WORK_BASE / SHIFTS_FILE
490627 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []
@@ -718,6 +855,9 @@ def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]
718855 if changed:
719856 with _locked(pid):
720857 merged, notes, renamed = merge(base, load(pid), theirs)
858+ kept_theirs = _shape(merged["aroll"]) == _shape(theirs["aroll"])
859+ # une pause dont le motion a disparu (supprimé par l'un, gardé par l'autre) : le trou dans la voix se referme
860+ orphans = drop_orphan_pauses(merged)
721861 for old, new in renamed.items():
722862 comp = motion_dir(pid) / f"{old}.html"
723863 if comp.is_file():
@@ -726,11 +866,13 @@ def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]
726866 snapshot(pid, [p for p in before.values() if p.is_file()])
727867 _write(pid, clean(pid, merged, duration))
728868 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent
729- if _shape(merged["aroll"]) == _shape(theirs["aroll"]):
869+ if kept_theirs:
730870 for shift in shifts:
731871 shift_beats(pid, shift["at"], shift["length"])
732872 elif shifts:
733873 notes.append("le clip que j'ai inséré dans l'A-roll n'y est pas (ta version de l'A-roll est gardée) : redemande-le")
874+ for shift in orphans:
875+ shift_beats(pid, shift["at"], shift["length"])
734876 shutil.rmtree(work, ignore_errors=True)
735877 return changed, notes
736878
@@ -758,19 +900,25 @@ def _starts(aroll: list[dict]) -> list[float]:
758900 return out
759901
760902
903+def _media(c: dict) -> str:
904+ """Le média d'un clip de l'A-roll, pour retrouver un moment de la vidéo ; une pause est celle de son motion (toutes
905+ partagent la même vidéo noire, depuis son début : sans ça, ce qui est posé sur l'une se retrouverait sur la première)."""
906+ return f"{PAUSE_SRC}#{c['pause']}" if c.get("pause") else c.get("src") or ""
907+
908+
761909 def _anchor(aroll: list[dict], starts: list[float], t: float) -> tuple | None:
762910 """L'instant `t` de la timeline dans la vidéo : (média, temps dans le média, clip)."""
763911 if not aroll:
764912 return None
765913 k = max((i for i, s in enumerate(starts) if s <= t + 1e-6), default=0)
766914 c = aroll[k]
767- return (c.get("src") or "", round(c["in"] + min(max(t - starts[k], 0.0), c["out"] - c["in"]), 3), c["id"])
915+ return (_media(c), round(c["in"] + min(max(t - starts[k], 0.0), c["out"] - c["in"]), 3), c["id"])
768916
769917
770918 def _place(aroll: list[dict], starts: list[float], anchor: tuple) -> float | None:
771919 """L'instant de la timeline où passe ce moment de la vidéo (dans le même clip de préférence) ; None s'il est coupé."""
772920 src, s, cid = anchor
773- fits = [k for k, c in enumerate(aroll) if (c.get("src") or "") == src and c["in"] - 1e-3 <= s <= c["out"] + 1e-3]
921+ fits = [k for k, c in enumerate(aroll) if _media(c) == src and c["in"] - 1e-3 <= s <= c["out"] + 1e-3]
774922 if not fits:
775923 return None
776924 k = next((k for k in fits if aroll[k]["id"] == cid), fits[0])
@@ -13,6 +13,7 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
13 montage/daryl/<demande>/ la copie de travail de Daryl pendant un tour du chat (timeline.json, contexte.md,13 montage/daryl/<demande>/ la copie de travail de Daryl pendant un tour du chat (timeline.json, contexte.md,
14 insertions.json), fusionnée à la fin du tour avec la timeline, que le client continue14 insertions.json), fusionnée à la fin du tour avec la timeline, que le client continue
15 de monter à la main (merge) ; plusieurs demandes tournent à la fois15 de monter à la main (merge) ; plusieurs demandes tournent à la fois
16+ montage/pause.mp4 la vidéo noire et muette des pauses de l'A-roll (faite au premier besoin)
16 montage/.cache/ forme d'onde et vignettes de la vidéo source17 montage/.cache/ forme d'onde et vignettes de la vidéo source
17 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié18 assets/<catégorie>/ B-roll, musique, sons : un son de la bibliothèque y est copié
18 dès qu'il entre dans la timeline19 dès qu'il entre dans la timeline
@@ -20,7 +21,7 @@ Tout vit dans le dossier du projet, que Resolve lit tel quel (chemins absolus) :
20 timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans le média) :21 timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans le média) :
21 22
22 {23 {
23- "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?}], # la vidéo source, bout à bout ; transition avec le précédent24+ "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?, "pause"?}], # la vidéo source, bout à bout ; transition avec le précédent
24 "zooms": [{"id", "at", "dur", "scale", "x", "y", "style"?, "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction) ;25 "zooms": [{"id", "at", "dur", "scale", "x", "y", "style"?, "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction) ;
25 # style : smooth (défaut), cut ou slow (ZOOM_STYLES)26 # style : smooth (défaut), cut ou slow (ZOOM_STYLES)
26 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)27 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)
@@ -39,6 +40,9 @@ x, y : le centre du texte en fraction de l'image (0 à 1) ; size : la hauteur de
39 de l'image ; color : « #RRGGBB » ; angle : en degrés (négatif : penché vers le haut à droite).40 de l'image ; color : « #RRGGBB » ; angle : en degrés (négatif : penché vers le haut à droite).
40 `src` d'un clip de l'A-roll : absent pour la vidéo source, sinon un autre média inséré (un extrait, une archive)41 `src` d'un clip de l'A-roll : absent pour la vidéo source, sinon un autre média inséré (un extrait, une archive)
41 qui passe avec son propre son. `gain_db` d'un clip de l'A-roll : son volume (absent : 0 dB).42 qui passe avec son propre son. `gain_db` d'un clip de l'A-roll : son volume (absent : 0 dB).
43+`pause` d'un clip de l'A-roll : l'id du motion plein écran qu'il porte. Le clip est une pause (PAUSE_SRC, noire et
44+muette) de la durée du motion, au même instant : la voix s'arrête le temps du motion, qui devient un plan de l'A-roll
45+(pause_motion, unpause_motion ; ops.pauseMotion dans l'interface).
42 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »46 `src` : chemin relatif au projet (« assets/broll/x.mp4 »), ou « library/<catégorie>/<fichier> »
43 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).47 pour un média de la bibliothèque commune (copié dans le projet à l'enregistrement).
44 """48 """
@@ -49,6 +53,7 @@ import json
49 import os53 import os
50 import re54 import re
51 import shutil55 import shutil
56+import subprocess
52 import time57 import time
53 from array import array58 from array import array
54 from collections.abc import Iterator59 from collections.abc import Iterator
@@ -56,6 +61,7 @@ from contextlib import contextmanager
56 from pathlib import Path61 from pathlib import Path
57 62
58 from ...core import jobs63 from ...core import jobs
64+from ...core.system import tool
59 from ..animations import kit65 from ..animations import kit
60 from . import library, media, service, transitions66 from . import library, media, service, transitions
61 from . import timeline as tl67 from . import timeline as tl
@@ -66,6 +72,9 @@ TIMELINE_FILE = "timeline.json"
66 INSERTIONS_FILE = "insertions.json"72 INSERTIONS_FILE = "insertions.json"
67 MOTION_DIR = "motion"73 MOTION_DIR = "motion"
68 RENDERS_DIR = "renders"74 RENDERS_DIR = "renders"
75+# la pause sous un motion plein écran : une vidéo noire et muette au format de la source, dont chaque pause prend le début
76+PAUSE_SRC = f"{MONTAGE_DIR}/pause.mp4"
77+PAUSE_LENGTH = 120.0
69 # la page qui rend un motion (montage_tasks._index) entre dans sa signature : à monter quand elle change,78 # la page qui rend un motion (montage_tasks._index) entre dans sa signature : à monter quand elle change,
70 # les motions déjà rendus se refont79 # les motions déjà rendus se refont
71 MOTION_PAGE_VERSION = 280 MOTION_PAGE_VERSION = 2
@@ -250,6 +259,33 @@ def _ends(item: dict) -> dict:
250 return {k: v for k in ("enter", "exit") if (v := transitions.clean(item.get(k)))}259 return {k: v for k in ("enter", "exit") if (v := transitions.clean(item.get(k)))}
251 260
252 261
262+def ensure_pause(pid: str) -> str | None:
263+ """La vidéo des pauses (PAUSE_SRC), faite au premier besoin au format de la source ; None sans source."""
264+ dest = service.project_dir(pid) / PAUSE_SRC
265+ if dest.is_file():
266+ return PAUSE_SRC
267+ source = _source(service.load(pid))
268+ if not source:
269+ return None
270+ info = media.probe(Path(source["path"]))
271+ size = f"{source.get('width') or 1920}x{source.get('height') or 1080}"
272+ rate = f"{info.get('fps_num') or 25}/{info.get('fps_den') or 1}"
273+ dest.parent.mkdir(parents=True, exist_ok=True)
274+ seed, tmp = dest.with_name(f".seed-{dest.name}"), dest.with_name(f".{dest.name}")
275+ # 2 s encodées puis répétées sans réencodage : moins d'une seconde, contre 30 pour tout encoder
276+ subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-f", "lavfi", "-i", f"color=c=black:s={size}:r={rate}",
277+ "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", "2",
278+ "-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage", "-pix_fmt", "yuv420p",
279+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
280+ "-c:a", "aac", "-f", "mp4", str(seed)], check=True, capture_output=True, timeout=120)
281+ subprocess.run([tool("ffmpeg"), "-y", "-v", "error", "-stream_loop", str(int(PAUSE_LENGTH / 2)), "-i", str(seed),
282+ "-c", "copy", "-t", str(PAUSE_LENGTH), "-f", "mp4", str(tmp)], check=True, capture_output=True,
283+ timeout=120)
284+ seed.unlink(missing_ok=True)
285+ tmp.replace(dest)
286+ return PAUSE_SRC
287+
288+
253 def clean(pid: str, raw: dict, source_duration: float) -> dict:289 def clean(pid: str, raw: dict, source_duration: float) -> dict:
254 """Une timeline sûre : ids uniques, nombres, bornes, médias présents dans le projet."""290 """Une timeline sûre : ids uniques, nombres, bornes, médias présents dans le projet."""
255 out: dict = {t: [] for t in TRACKS}291 out: dict = {t: [] for t in TRACKS}
@@ -274,14 +310,17 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict:
274 310
275 for c in raw.get("aroll") or []:311 for c in raw.get("aroll") or []:
276 # un clip inséré d'un autre média : borné par sa durée ; un média introuvable, le clip disparaît312 # un clip inséré d'un autre média : borné par sa durée ; un média introuvable, le clip disparaît
277- src = _resolve_src(pid, str(c["src"])) if c.get("src") else None313+ # une pause : la vidéo des pauses, faite au besoin
314+ pause = bool(c.get("src")) and str(c["src"]).replace("\\", "/").strip("/") == PAUSE_SRC
315+ src = (ensure_pause(pid) if pause else _resolve_src(pid, str(c["src"]))) if c.get("src") else None
278 if c.get("src") and not src:316 if c.get("src") and not src:
279 continue317 continue
280 a, b = max(0.0, _num(c.get("in"))), min(_num(c.get("out")), (length(src) if src else source_duration) or 1e9)318 a, b = max(0.0, _num(c.get("in"))), min(_num(c.get("out")), (length(src) if src else source_duration) or 1e9)
281 gain = max(-60.0, min(_num(c.get("gain_db")), 12.0))319 gain = max(-60.0, min(_num(c.get("gain_db")), 12.0))
282 if b - a >= 0.04:320 if b - a >= 0.04:
283 out["aroll"].append({"id": ident("aroll", c.get("id")), **({"src": src} if src else {}), "in": a, "out": b,321 out["aroll"].append({"id": ident("aroll", c.get("id")), **({"src": src} if src else {}), "in": a, "out": b,
284- "transition": transitions.clean(c.get("transition")), **({"gain_db": gain} if gain else {})})322+ "transition": transitions.clean(c.get("transition")), **({"gain_db": gain} if gain else {}),
323+ **({"pause": str(c["pause"])[:20]} if pause and c.get("pause") else {})})
285 total = sum(c["out"] - c["in"] for c in out["aroll"])324 total = sum(c["out"] - c["in"] for c in out["aroll"])
286 325
287 for z in raw.get("zooms") or []:326 for z in raw.get("zooms") or []:
@@ -420,6 +459,86 @@ def insert_aroll(timeline: dict, at: float, clip: dict) -> dict:
420 return {"id": new["id"], "at": at, "length": length}459 return {"id": new["id"], "at": at, "length": length}
421 460
422 461
462+def _pause_of(timeline: dict, motion_id: str) -> int | None:
463+ return next((i for i, c in enumerate(timeline["aroll"]) if c.get("pause") == motion_id), None)
464+
465+
466+def _lift(timeline: dict, motion_id: str) -> tuple[dict | None, list[dict]]:
467+ """Retire de la timeline le motion et ses sons, le temps d'insérer ou de retirer sa pause."""
468+ m = next((x for x in timeline["motion"] if x["id"] == motion_id), None)
469+ own = [s for s in timeline["audio"] if s.get("motion") == motion_id]
470+ timeline["motion"] = [x for x in timeline["motion"] if x["id"] != motion_id]
471+ timeline["audio"] = [s for s in timeline["audio"] if s.get("motion") != motion_id]
472+ return m, own
473+
474+
475+def unpause_motion(timeline: dict, motion_id: str) -> dict | None:
476+ """
477+ Retire la pause d'un motion (comme ops.unpauseMotion) : l'A-roll se referme et ce qui suit remonte d'autant ; ce
478+ qui commençait dans la pause disparaît, sauf le motion et ses sons, qui restent où ils sont. Modifie `timeline` ;
479+ rend le décalage {at, length} (length négatif), ou None s'il n'a pas de pause.
480+ """
481+ i = _pause_of(timeline, motion_id)
482+ if i is None:
483+ return None
484+ start = sum(c["out"] - c["in"] for c in timeline["aroll"][:i])
485+ c = timeline["aroll"].pop(i)
486+ length = c["out"] - c["in"]
487+ end = start + length
488+ if i < len(timeline["aroll"]):
489+ timeline["aroll"][i]["transition"] = None
490+ m, own = _lift(timeline, motion_id)
491+ inside = lambda t: start + 1e-3 < t < end - 1e-3 # noqa: E731
492+ back = lambda t: round(t - length, 3) if t >= end - 1e-3 else t # noqa: E731
493+ for track in ("zooms", "broll", "motion", "audio", "texts"):
494+ timeline[track] = [{**x, "at": back(x["at"])} for x in timeline.get(track) or [] if not inside(x["at"])]
495+ timeline["markers"] = [{**k, "t": back(k["t"])} for k in timeline["markers"] if not inside(k["t"])]
496+ if m:
497+ timeline["motion"].append(m)
498+ timeline["audio"] += own
499+ return {"at": round(end, 3), "length": round(-length, 3)}
500+
501+
502+def drop_orphan_pauses(timeline: dict) -> list[dict]:
503+ """
504+ Retire les pauses dont le motion n'est plus dans la timeline (supprimé sans retirer sa pause, perdu à une fusion) :
505+ sans lui, ce n'est qu'un trou noir et muet au milieu de la voix. Modifie `timeline` ; rend les décalages.
506+ """
507+ motions = {m["id"] for m in timeline["motion"]}
508+ orphans = {c["pause"] for c in timeline["aroll"] if c.get("pause") and c["pause"] not in motions}
509+ shifts = []
510+ for mid in orphans:
511+ # un motion peut avoir laissé plusieurs morceaux de pause (une autre pause insérée au milieu)
512+ while shift := unpause_motion(timeline, mid):
513+ shifts.append(shift)
514+ return shifts
515+
516+
517+def pause_motion(timeline: dict, motion_id: str, src: str) -> dict | None:
518+ """
519+ Met l'A-roll en pause sous un motion plein écran : une pause (`src`, la vidéo des pauses) de la durée du motion,
520+ insérée à son début (insert_aroll : tout ce qui suit est décalé) ; le motion et ses sons restent posés dessus. Une
521+ pause déjà là est refaite (la durée du motion a pu changer). Modifie `timeline` ; rend les décalages à reporter
522+ sur le storyboard [{at, length}], ou None si le motion n'existe pas.
523+ """
524+ if not any(x["id"] == motion_id for x in timeline["motion"]):
525+ return None
526+ shifts = [s for s in [unpause_motion(timeline, motion_id)] if s]
527+ m = next(x for x in timeline["motion"] if x["id"] == motion_id)
528+ held = [(x, x["at"]) for x in [m, *(s for s in timeline["audio"] if s.get("motion") == motion_id)]]
529+ # le motion et ses sons mis hors d'atteinte le temps de l'insertion (ni coupés ni décalés ; leurs ids restent pris)
530+ for x, _ in held:
531+ x["at"] = -1e9
532+ done = insert_aroll(timeline, held[0][1], {"src": src, "in": 0.0, "out": min(m["dur"], PAUSE_LENGTH)})
533+ next(c for c in timeline["aroll"] if c["id"] == done["id"])["pause"] = motion_id
534+ # calé sur la coupe toute proche : le motion et ses sons la suivent
535+ delta = done["at"] - held[0][1]
536+ for x, at in held:
537+ x["at"] = round(max(0.0, at + delta), 3)
538+ # le beat du motion reste à son début : seuls ceux d'après suivent
539+ return shifts + [{"at": round(done["at"] + 0.01, 3), "length": done["length"]}]
540+
541+
423 def _shift_beats(pid: str, at: float, length: float) -> None:542 def _shift_beats(pid: str, at: float, length: float) -> None:
424 """Décale de `length` les beats du storyboard qui commencent à partir de `at`."""543 """Décale de `length` les beats du storyboard qui commencent à partir de `at`."""
425 from . import storyboard544 from . import storyboard
@@ -454,8 +573,9 @@ def apply_insertions(pid: str, task: str) -> list[str]:
454 Les clips que Daryl a demandé d'insérer pour la demande `task` (insertions.json dans son dossier de travail :573 Les clips que Daryl a demandé d'insérer pour la demande `task` (insertions.json dans son dossier de travail :
455 [{src, at, in, out, gain_db?}], `at` en temps574 [{src, at, in, out, gain_db?}], `at` en temps
456 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier575 de la timeline au moment où il écrit le fichier) : insérés dans sa copie de travail l'un après l'autre, du dernier
457- au premier pour que les temps restent justes. Les beats du storyboard ne suivent qu'à la fin du tour, si son A-roll576+ au premier pour que les temps restent justes ; puis les pauses sous un motion ({pause: id}, à poser ou à refaire
458- est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.577+ quand sa durée change) et leurs retraits ({unpause: id}). Les beats du storyboard ne suivent qu'à la fin du tour,
578+ si son A-roll est gardé (finish_work). Le fichier est effacé ; rend ce qui n'a pas pu l'être.
459 """579 """
460 file = work_dir(pid, task) / INSERTIONS_FILE580 file = work_dir(pid, task) / INSERTIONS_FILE
461 if not file.is_file():581 if not file.is_file():
@@ -472,7 +592,9 @@ def apply_insertions(pid: str, task: str) -> list[str]:
472 duration = (_source(project) or {}).get("duration") or 0.0592 duration = (_source(project) or {}).get("duration") or 0.0
473 timeline = clean(pid, _read(work_file(pid, task)) or load(pid), duration)593 timeline = clean(pid, _read(work_file(pid, task)) or load(pid), duration)
474 problems, shifts = [], []594 problems, shifts = [], []
475- for w in sorted((w for w in wanted if isinstance(w, dict)), key=lambda w: -_num(w.get("at"))):595+ wanted = [w for w in wanted if isinstance(w, dict)]
596+ clips = [w for w in wanted if not w.get("pause") and not w.get("unpause")]
597+ for w in sorted(clips, key=lambda w: -_num(w.get("at"))):
476 src = _resolve_src(pid, str(w.get("src") or ""))598 src = _resolve_src(pid, str(w.get("src") or ""))
477 if not src:599 if not src:
478 problems.append(f"média introuvable : {w.get('src')}")600 problems.append(f"média introuvable : {w.get('src')}")
@@ -485,6 +607,21 @@ def apply_insertions(pid: str, task: str) -> list[str]:
485 continue607 continue
486 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})608 done = insert_aroll(timeline, _num(w.get("at")), {"src": src, "in": a, "out": b, "gain_db": _num(w.get("gain_db"))})
487 shifts.append({"at": done["at"], "length": done["length"]})609 shifts.append({"at": done["at"], "length": done["length"]})
610+ # les pauses sous un motion, par son id (sa place suit les insertions faites juste avant)
611+ for w in wanted:
612+ if w.get("unpause"):
613+ done = unpause_motion(timeline, str(w["unpause"]))
614+ if not done:
615+ problems.append(f"pas de pause sous le motion {w['unpause']}")
616+ continue
617+ shifts.append(done)
618+ elif w.get("pause"):
619+ src = ensure_pause(pid)
620+ done = pause_motion(timeline, str(w["pause"]), src) if src else None
621+ if done is None:
622+ problems.append(f"motion introuvable pour la pause : {w['pause']}")
623+ continue
624+ shifts += done
488 _dump(work_file(pid, task), clean(pid, timeline, duration))625 _dump(work_file(pid, task), clean(pid, timeline, duration))
489 noted = work_dir(pid, task) / WORK_BASE / SHIFTS_FILE626 noted = work_dir(pid, task) / WORK_BASE / SHIFTS_FILE
490 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []627 before = json.loads(noted.read_text(encoding="utf-8")) if noted.is_file() else []
@@ -718,6 +855,9 @@ def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]
718 if changed:855 if changed:
719 with _locked(pid):856 with _locked(pid):
720 merged, notes, renamed = merge(base, load(pid), theirs)857 merged, notes, renamed = merge(base, load(pid), theirs)
858+ kept_theirs = _shape(merged["aroll"]) == _shape(theirs["aroll"])
859+ # une pause dont le motion a disparu (supprimé par l'un, gardé par l'autre) : le trou dans la voix se referme
860+ orphans = drop_orphan_pauses(merged)
721 for old, new in renamed.items():861 for old, new in renamed.items():
722 comp = motion_dir(pid) / f"{old}.html"862 comp = motion_dir(pid) / f"{old}.html"
723 if comp.is_file():863 if comp.is_file():
@@ -726,11 +866,13 @@ def finish_work(pid: str, task: str, touched: set[str]) -> tuple[bool, list[str]
726 snapshot(pid, [p for p in before.values() if p.is_file()])866 snapshot(pid, [p for p in before.values() if p.is_file()])
727 _write(pid, clean(pid, merged, duration))867 _write(pid, clean(pid, merged, duration))
728 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent868 # ses insertions dans l'A-roll sont gardées : les beats du storyboard les suivent
729- if _shape(merged["aroll"]) == _shape(theirs["aroll"]):869+ if kept_theirs:
730 for shift in shifts:870 for shift in shifts:
731 shift_beats(pid, shift["at"], shift["length"])871 shift_beats(pid, shift["at"], shift["length"])
732 elif shifts:872 elif shifts:
733 notes.append("le clip que j'ai inséré dans l'A-roll n'y est pas (ta version de l'A-roll est gardée) : redemande-le")873 notes.append("le clip que j'ai inséré dans l'A-roll n'y est pas (ta version de l'A-roll est gardée) : redemande-le")
874+ for shift in orphans:
875+ shift_beats(pid, shift["at"], shift["length"])
734 shutil.rmtree(work, ignore_errors=True)876 shutil.rmtree(work, ignore_errors=True)
735 return changed, notes877 return changed, notes
736 878
@@ -758,19 +900,25 @@ def _starts(aroll: list[dict]) -> list[float]:
758 return out900 return out
759 901
760 902
903+def _media(c: dict) -> str:
904+ """Le média d'un clip de l'A-roll, pour retrouver un moment de la vidéo ; une pause est celle de son motion (toutes
905+ partagent la même vidéo noire, depuis son début : sans ça, ce qui est posé sur l'une se retrouverait sur la première)."""
906+ return f"{PAUSE_SRC}#{c['pause']}" if c.get("pause") else c.get("src") or ""
907+
908+
761 def _anchor(aroll: list[dict], starts: list[float], t: float) -> tuple | None:909 def _anchor(aroll: list[dict], starts: list[float], t: float) -> tuple | None:
762 """L'instant `t` de la timeline dans la vidéo : (média, temps dans le média, clip)."""910 """L'instant `t` de la timeline dans la vidéo : (média, temps dans le média, clip)."""
763 if not aroll:911 if not aroll:
764 return None912 return None
765 k = max((i for i, s in enumerate(starts) if s <= t + 1e-6), default=0)913 k = max((i for i, s in enumerate(starts) if s <= t + 1e-6), default=0)
766 c = aroll[k]914 c = aroll[k]
767- return (c.get("src") or "", round(c["in"] + min(max(t - starts[k], 0.0), c["out"] - c["in"]), 3), c["id"])915+ return (_media(c), round(c["in"] + min(max(t - starts[k], 0.0), c["out"] - c["in"]), 3), c["id"])
768 916
769 917
770 def _place(aroll: list[dict], starts: list[float], anchor: tuple) -> float | None:918 def _place(aroll: list[dict], starts: list[float], anchor: tuple) -> float | None:
771 """L'instant de la timeline où passe ce moment de la vidéo (dans le même clip de préférence) ; None s'il est coupé."""919 """L'instant de la timeline où passe ce moment de la vidéo (dans le même clip de préférence) ; None s'il est coupé."""
772 src, s, cid = anchor920 src, s, cid = anchor
773- fits = [k for k, c in enumerate(aroll) if (c.get("src") or "") == src and c["in"] - 1e-3 <= s <= c["out"] + 1e-3]921+ fits = [k for k, c in enumerate(aroll) if _media(c) == src and c["in"] - 1e-3 <= s <= c["out"] + 1e-3]
774 if not fits:922 if not fits:
775 return None923 return None
776 k = next((k for k in fits if aroll[k]["id"] == cid), fits[0])924 k = next((k for k in fits if aroll[k]["id"] == cid), fits[0])
modified backend/studio/modules/projects/montage_tasks.py +20 -1
@@ -123,6 +123,24 @@ insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqu
123123 RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
124124 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \
125125 par exemple) en temps de la timeline à jour.
126+- Pause sous un motion plein écran : un motion plein écran qui marque un temps (transition de chapitre, \
127+respiration entre deux parties) se pose PAR DÉFAUT en pause : l'A-roll s'arrête (image noire, silence) le temps \
128+du motion, qui devient un plan à part entière, puis la voix reprend. Le spectateur souffle et réfléchit, au lieu \
129+que la voix continue sous le motion. Un motion plein écran qui illustre la phrase en train d'être dite reste \
130+au-dessus de la voix, sans pause. Pour la poser : place d'abord le motion dans {work}/timeline.json, son `at` \
131+dans le silence juste après la DERNIÈRE PHRASE du passage, une pensée finie (jamais au milieu d'un mot ni d'une \
132+phrase : l'A-roll y est coupé, la voix s'arrête net). Lis les phrases autour dans contexte.md : une chute ou \
133+une conclusion courte qui suit (« Et ça, c'est pas bon. ») reste avant la pause, la voix reprend sur la première \
134+phrase de la partie suivante. Durée : 2,5 s au moins, 3 à 4 s pour un titre à lire (en dessous, on ne lit rien \
135+et on ne voit pas l'animation). Puis \
136+écris {work}/insertions.json : [{{"pause": "<id du motion>"}}]. Le studio insère dans l'A-roll une pause de la \
137+durée du motion à son `at` et décale tout ce qui suit ; le motion et ses sons restent posés dessus. RELIS \
138+ensuite {work}/timeline.json. Dans l'A-roll, une pause est un clip `"src": "montage/pause.mp4", "pause": \
139+"<id>"` : n'y touche pas à la main et ne déplace pas son motion. Si tu changes la durée d'un motion en pause, \
140+réécris [{{"pause": "<id>"}}] (la pause est refaite à la bonne durée) ; pour la retirer : [{{"unpause": "<id>"}}]. \
141+Pour supprimer un motion en pause : d'abord [{{"unpause": "<id>"}}], puis RELIS la timeline et retire le motion \
142+(sinon il reste un trou noir et muet dans la voix). \
143+La musique de la pause se fait plus tard, à l'habillage musical : n'en mets pas.
126144 - zooms : recadrage de l'A-roll. `style` : absent = smooth (montée et descente adoucies de 0,4 s), \
127145 "cut" (le cadre saute d'un coup à l'entrée et à la sortie : aparté face caméra, chute, réaction), "slow" \
128146 (poussée lente à vitesse constante sur toute la durée, retour net à la fin : explication posée, tension ; \
@@ -210,7 +228,8 @@ def write_context(pid: str, selection: dict | None, path: Path) -> None:
210228 inserted, cursor = [], 0.0
211229 for c in timeline["aroll"]:
212230 if c.get("src"):
213- inserted.append(f"- {c['id']} · {_mmss(cursor)} → {_mmss(cursor + c['out'] - c['in'])} · {c['src']}"
231+ what = f"pause sous le motion {c['pause']}" if c.get("pause") else c["src"]
232+ inserted.append(f"- {c['id']} · {_mmss(cursor)} → {_mmss(cursor + c['out'] - c['in'])} · {what}"
214233 + (f" · {c['gain_db']:+.0f} dB" if c.get("gain_db") else ""))
215234 cursor += c["out"] - c["in"]
216235 if inserted:
@@ -123,6 +123,24 @@ insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqu
123 RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \123 RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
124 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \124 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \
125 par exemple) en temps de la timeline à jour.125 par exemple) en temps de la timeline à jour.
126+- Pause sous un motion plein écran : un motion plein écran qui marque un temps (transition de chapitre, \
127+respiration entre deux parties) se pose PAR DÉFAUT en pause : l'A-roll s'arrête (image noire, silence) le temps \
128+du motion, qui devient un plan à part entière, puis la voix reprend. Le spectateur souffle et réfléchit, au lieu \
129+que la voix continue sous le motion. Un motion plein écran qui illustre la phrase en train d'être dite reste \
130+au-dessus de la voix, sans pause. Pour la poser : place d'abord le motion dans {work}/timeline.json, son `at` \
131+dans le silence juste après la DERNIÈRE PHRASE du passage, une pensée finie (jamais au milieu d'un mot ni d'une \
132+phrase : l'A-roll y est coupé, la voix s'arrête net). Lis les phrases autour dans contexte.md : une chute ou \
133+une conclusion courte qui suit (« Et ça, c'est pas bon. ») reste avant la pause, la voix reprend sur la première \
134+phrase de la partie suivante. Durée : 2,5 s au moins, 3 à 4 s pour un titre à lire (en dessous, on ne lit rien \
135+et on ne voit pas l'animation). Puis \
136+écris {work}/insertions.json : [{{"pause": "<id du motion>"}}]. Le studio insère dans l'A-roll une pause de la \
137+durée du motion à son `at` et décale tout ce qui suit ; le motion et ses sons restent posés dessus. RELIS \
138+ensuite {work}/timeline.json. Dans l'A-roll, une pause est un clip `"src": "montage/pause.mp4", "pause": \
139+"<id>"` : n'y touche pas à la main et ne déplace pas son motion. Si tu changes la durée d'un motion en pause, \
140+réécris [{{"pause": "<id>"}}] (la pause est refaite à la bonne durée) ; pour la retirer : [{{"unpause": "<id>"}}]. \
141+Pour supprimer un motion en pause : d'abord [{{"unpause": "<id>"}}], puis RELIS la timeline et retire le motion \
142+(sinon il reste un trou noir et muet dans la voix). \
143+La musique de la pause se fait plus tard, à l'habillage musical : n'en mets pas.
126 - zooms : recadrage de l'A-roll. `style` : absent = smooth (montée et descente adoucies de 0,4 s), \144 - zooms : recadrage de l'A-roll. `style` : absent = smooth (montée et descente adoucies de 0,4 s), \
127 "cut" (le cadre saute d'un coup à l'entrée et à la sortie : aparté face caméra, chute, réaction), "slow" \145 "cut" (le cadre saute d'un coup à l'entrée et à la sortie : aparté face caméra, chute, réaction), "slow" \
128 (poussée lente à vitesse constante sur toute la durée, retour net à la fin : explication posée, tension ; \146 (poussée lente à vitesse constante sur toute la durée, retour net à la fin : explication posée, tension ; \
@@ -210,7 +228,8 @@ def write_context(pid: str, selection: dict | None, path: Path) -> None:
210 inserted, cursor = [], 0.0228 inserted, cursor = [], 0.0
211 for c in timeline["aroll"]:229 for c in timeline["aroll"]:
212 if c.get("src"):230 if c.get("src"):
213- inserted.append(f"- {c['id']} · {_mmss(cursor)} → {_mmss(cursor + c['out'] - c['in'])} · {c['src']}"231+ what = f"pause sous le motion {c['pause']}" if c.get("pause") else c["src"]
232+ inserted.append(f"- {c['id']} · {_mmss(cursor)} → {_mmss(cursor + c['out'] - c['in'])} · {what}"
214 + (f" · {c['gain_db']:+.0f} dB" if c.get("gain_db") else ""))233 + (f" · {c['gain_db']:+.0f} dB" if c.get("gain_db") else ""))
215 cursor += c["out"] - c["in"]234 cursor += c["out"] - c["in"]
216 if inserted:235 if inserted:
modified web/src/i18n/locales/en/editing.ts +9 -0
@@ -4,6 +4,9 @@ export default {
44 nothingToCut: "Nothing to cut here",
55 insertNotVideo: "Only a video can go into the A-roll",
66 inserted: "“{name}” inserted into the A-roll at {tc}; everything after moved along",
7+ pauseClip: "Pause · {motion}",
8+ paused: "The A-roll pauses under the motion: everything after moved along by {dur}",
9+ unpaused: "Pause removed: the voice runs under the motion again",
710 range: "Range {from} → {to}",
811 exportedWithIssues: "Exported with {n} warning: {list} | Exported with {n} warnings: {list}",
912 exported: "{name} exported to the project folder",
@@ -61,6 +64,12 @@ export default {
6164 sourceVideo: "Source video",
6265 inSource: "{from} → {to} in the source · {dur}",
6366 arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.",
67+ pauseClipHint: "The A-roll stops here (black frame, silence) for the full-screen motion on top. Its length follows the motion’s; deleting the pause leaves the motion over the voice.",
68+ pauseAroll: {
69+ label: "Pause the A-roll",
70+ title: "The voice stops for the length of the motion, which becomes a shot of its own (everything after moves along)",
71+ on: "The voice stops under this motion. It no longer moves; dragging its end lengthens or shortens the pause.",
72+ },
6473 insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. A transition with its neighbours holds its first or last frame if it is used whole.",
6574 insertAroll: "→ A-roll",
6675 insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)",
@@ -4,6 +4,9 @@ export default {
4 nothingToCut: "Nothing to cut here",4 nothingToCut: "Nothing to cut here",
5 insertNotVideo: "Only a video can go into the A-roll",5 insertNotVideo: "Only a video can go into the A-roll",
6 inserted: "“{name}” inserted into the A-roll at {tc}; everything after moved along",6 inserted: "“{name}” inserted into the A-roll at {tc}; everything after moved along",
7+ pauseClip: "Pause · {motion}",
8+ paused: "The A-roll pauses under the motion: everything after moved along by {dur}",
9+ unpaused: "Pause removed: the voice runs under the motion again",
7 range: "Range {from} → {to}",10 range: "Range {from} → {to}",
8 exportedWithIssues: "Exported with {n} warning: {list} | Exported with {n} warnings: {list}",11 exportedWithIssues: "Exported with {n} warning: {list} | Exported with {n} warnings: {list}",
9 exported: "{name} exported to the project folder",12 exported: "{name} exported to the project folder",
@@ -61,6 +64,12 @@ export default {
61 sourceVideo: "Source video",64 sourceVideo: "Source video",
62 inSource: "{from} → {to} in the source · {dur}",65 inSource: "{from} → {to} in the source · {dur}",
63 arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.",66 arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.",
67+ pauseClipHint: "The A-roll stops here (black frame, silence) for the full-screen motion on top. Its length follows the motion’s; deleting the pause leaves the motion over the voice.",
68+ pauseAroll: {
69+ label: "Pause the A-roll",
70+ title: "The voice stops for the length of the motion, which becomes a shot of its own (everything after moves along)",
71+ on: "The voice stops under this motion. It no longer moves; dragging its end lengthens or shortens the pause.",
72+ },
64 insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. A transition with its neighbours holds its first or last frame if it is used whole.",73 insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. A transition with its neighbours holds its first or last frame if it is used whole.",
65 insertAroll: "→ A-roll",74 insertAroll: "→ A-roll",
66 insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)",75 insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)",
modified web/src/i18n/locales/fr/editing.ts +9 -0
@@ -4,6 +4,9 @@ export default {
44 nothingToCut: "Rien à couper ici",
55 insertNotVideo: "Seule une vidéo peut entrer dans l’A-roll",
66 inserted: "« {name} » inséré dans l’A-roll à {tc}, la suite est décalée",
7+ pauseClip: "Pause · {motion}",
8+ paused: "L’A-roll est en pause sous le motion : la suite est décalée de {dur}",
9+ unpaused: "Pause retirée : la voix reprend sous le motion",
710 range: "Plage {from} → {to}",
811 exportedWithIssues: "Exporté avec {n} réserve : {list} | Exporté avec {n} réserves : {list}",
912 exported: "{name} exporté dans le dossier du projet",
@@ -61,6 +64,12 @@ export default {
6164 sourceVideo: "Vidéo source",
6265 inSource: "{from} → {to} dans la source · {dur}",
6366 arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.",
67+ pauseClipHint: "L’A-roll s’arrête ici (image noire, silence) le temps du motion plein écran posé dessus. Sa durée suit celle du motion ; supprimer la pause laisse le motion au-dessus de la voix.",
68+ pauseAroll: {
69+ label: "Pause dans l’A-roll",
70+ title: "La voix s’arrête le temps du motion, qui devient un plan à part entière (la suite est décalée)",
71+ on: "La voix s’arrête sous ce motion. Il ne se déplace plus ; tirer sa fin allonge ou raccourcit la pause.",
72+ },
6473 insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Une transition avec ses voisins tient sa première ou sa dernière image s’il est utilisé en entier.",
6574 insertAroll: "→ A-roll",
6675 insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)",
@@ -4,6 +4,9 @@ export default {
4 nothingToCut: "Rien à couper ici",4 nothingToCut: "Rien à couper ici",
5 insertNotVideo: "Seule une vidéo peut entrer dans l’A-roll",5 insertNotVideo: "Seule une vidéo peut entrer dans l’A-roll",
6 inserted: "« {name} » inséré dans l’A-roll à {tc}, la suite est décalée",6 inserted: "« {name} » inséré dans l’A-roll à {tc}, la suite est décalée",
7+ pauseClip: "Pause · {motion}",
8+ paused: "L’A-roll est en pause sous le motion : la suite est décalée de {dur}",
9+ unpaused: "Pause retirée : la voix reprend sous le motion",
7 range: "Plage {from} → {to}",10 range: "Plage {from} → {to}",
8 exportedWithIssues: "Exporté avec {n} réserve : {list} | Exporté avec {n} réserves : {list}",11 exportedWithIssues: "Exporté avec {n} réserve : {list} | Exporté avec {n} réserves : {list}",
9 exported: "{name} exporté dans le dossier du projet",12 exported: "{name} exporté dans le dossier du projet",
@@ -61,6 +64,12 @@ export default {
61 sourceVideo: "Vidéo source",64 sourceVideo: "Vidéo source",
62 inSource: "{from} → {to} dans la source · {dur}",65 inSource: "{from} → {to} dans la source · {dur}",
63 arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.",66 arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.",
67+ pauseClipHint: "L’A-roll s’arrête ici (image noire, silence) le temps du motion plein écran posé dessus. Sa durée suit celle du motion ; supprimer la pause laisse le motion au-dessus de la voix.",
68+ pauseAroll: {
69+ label: "Pause dans l’A-roll",
70+ title: "La voix s’arrête le temps du motion, qui devient un plan à part entière (la suite est décalée)",
71+ on: "La voix s’arrête sous ce motion. Il ne se déplace plus ; tirer sa fin allonge ou raccourcit la pause.",
72+ },
64 insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Une transition avec ses voisins tient sa première ou sa dernière image s’il est utilisé en entier.",73 insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Une transition avec ses voisins tient sa première ou sa dernière image s’il est utilisé en entier.",
65 insertAroll: "→ A-roll",74 insertAroll: "→ A-roll",
66 insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)",75 insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)",
modified web/src/project/steps/MontageStep.vue +18 -2
@@ -21,7 +21,7 @@ import ImageSearchQueue from "./montage/ImageSearchQueue.vue";
2121 import Inspector from "./montage/Inspector.vue";
2222 import MonteurChat from "./montage/MonteurChat.vue";
2323 import TaskQueue from "./montage/TaskQueue.vue";
24-import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";
24+import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, pauseMotion, remove, unpauseMotion, type Shift } from "./montage/ops";
2525 import Preview from "./montage/Preview.vue";
2626 import StylePicker from "./montage/StylePicker.vue";
2727 import Timeline from "./montage/Timeline.vue";
@@ -95,6 +95,21 @@ async function insertClip(m: AvailableMedia, at: number) {
9595 await SB.load();
9696 }
9797
98+/**
99+ * Met l'A-roll en pause sous un motion (on) ou retire sa pause : la suite est décalée d'autant, beats du storyboard
100+ * compris (relu ensuite).
101+ */
102+async function setPause(id: string, on: boolean) {
103+ let shifts: Shift[] = [];
104+ M.edit((x) => { shifts = on ? pauseMotion(x, id) : [unpauseMotion(x, id)].filter((v): v is Shift => !!v); }, 0);
105+ if (!shifts.length) return;
106+ for (const s of shifts) M.shiftBeats(s.at, s.length);
107+ const len = shifts[shifts.length - 1].length;
108+ toast(on ? tr("editing.paused", { dur: `${len.toFixed(1)} s` }) : tr("editing.unpaused"));
109+ await M.flush();
110+ await SB.load();
111+}
112+
98113 /** Un média lâché sur la timeline : dans l'A-roll sur sa piste, sinon posé au-dessus (B-roll, son). */
99114 function dropMedia(m: AvailableMedia, at: number, aroll: boolean) {
100115 if (aroll) return insertClip(m, at);
@@ -381,7 +396,8 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
381396 :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" />
382397 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"
383398 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"
384- @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()" />
399+ @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()"
400+ @pause="setPause" />
385401 </div>
386402
387403 <div class="toolbar">
@@ -21,7 +21,7 @@ import ImageSearchQueue from "./montage/ImageSearchQueue.vue";
21 import Inspector from "./montage/Inspector.vue";21 import Inspector from "./montage/Inspector.vue";
22 import MonteurChat from "./montage/MonteurChat.vue";22 import MonteurChat from "./montage/MonteurChat.vue";
23 import TaskQueue from "./montage/TaskQueue.vue";23 import TaskQueue from "./montage/TaskQueue.vue";
24-import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";24+import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, pauseMotion, remove, unpauseMotion, type Shift } from "./montage/ops";
25 import Preview from "./montage/Preview.vue";25 import Preview from "./montage/Preview.vue";
26 import StylePicker from "./montage/StylePicker.vue";26 import StylePicker from "./montage/StylePicker.vue";
27 import Timeline from "./montage/Timeline.vue";27 import Timeline from "./montage/Timeline.vue";
@@ -95,6 +95,21 @@ async function insertClip(m: AvailableMedia, at: number) {
95 await SB.load();95 await SB.load();
96 }96 }
97 97
98+/**
99+ * Met l'A-roll en pause sous un motion (on) ou retire sa pause : la suite est décalée d'autant, beats du storyboard
100+ * compris (relu ensuite).
101+ */
102+async function setPause(id: string, on: boolean) {
103+ let shifts: Shift[] = [];
104+ M.edit((x) => { shifts = on ? pauseMotion(x, id) : [unpauseMotion(x, id)].filter((v): v is Shift => !!v); }, 0);
105+ if (!shifts.length) return;
106+ for (const s of shifts) M.shiftBeats(s.at, s.length);
107+ const len = shifts[shifts.length - 1].length;
108+ toast(on ? tr("editing.paused", { dur: `${len.toFixed(1)} s` }) : tr("editing.unpaused"));
109+ await M.flush();
110+ await SB.load();
111+}
112+
98 /** Un média lâché sur la timeline : dans l'A-roll sur sa piste, sinon posé au-dessus (B-roll, son). */113 /** Un média lâché sur la timeline : dans l'A-roll sur sa piste, sinon posé au-dessus (B-roll, son). */
99 function dropMedia(m: AvailableMedia, at: number, aroll: boolean) {114 function dropMedia(m: AvailableMedia, at: number, aroll: boolean) {
100 if (aroll) return insertClip(m, at);115 if (aroll) return insertClip(m, at);
@@ -381,7 +396,8 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
381 :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" />396 :image-popup="imagePopup" @image-popup="(tab: ImageTab | null) => (imagePopup = tab)" />
382 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"397 <Inspector v-else v-model:selection="selection" class="insp" :tl="tl" :view="view" :available="M.available.value"
383 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"398 :t="t" :total="M.total.value" @edit="edit" @comment="comment" @seek="seek" @beat="addBeat"
384- @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()" />399+ @insert="(m: AvailableMedia) => insertClip(m, t)" @media="M.loadAvailable()" @rerender="M.retryRenders()"
400+ @pause="setPause" />
385 </div>401 </div>
386 402
387 <div class="toolbar">403 <div class="toolbar">
modified web/src/project/steps/montage/Inspector.vue +29 -10
@@ -13,7 +13,7 @@ import { post } from "../../../lib/api";
1313 import { duration as fmtDur } from "../../../lib/format";
1414 import { toast } from "../../../lib/toast";
1515 import MediaPicker from "./MediaPicker.vue";
16-import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops";
16+import { addMedia as addMediaTo, addText as addTextTo, pauseOf, remove } from "./ops";
1717 import {
1818 fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, ZOOM_STYLES,
1919 type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom,
@@ -23,7 +23,11 @@ import {
2323 const props = defineProps<{
2424 tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number;
2525 }>();
26-const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [] }>();
26+const emit = defineEmits<{
27+ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [];
28+ /** mettre l'A-roll en pause sous un motion (on), ou la retirer */
29+ pause: [string, boolean];
30+}>();
2731 const sel = defineModel<Selection | null>("selection", { required: true });
2832 const { t: tr } = useI18n();
2933
@@ -88,6 +92,8 @@ function del() {
8892 }
8993 const nameOf = (src: string) => props.view.media[src]?.name ?? src.split("/").pop() ?? src;
9094 const motionState = computed(() => (item.value?.track === "motion" ? props.view.motions[item.value.data.id] : null));
95+const paused = computed(() => (item.value?.track === "motion" ? !!pauseOf(props.tl, item.value.data.id) : false));
96+const pausedMotion = (id: string) => props.tl.motion.find((m) => m.id === id)?.title || id;
9197
9298 // --- ajouter au curseur
9399 const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5)));
@@ -184,14 +190,20 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
184190 </div>
185191 <fieldset>
186192 <template v-if="item.track === 'aroll'">
187- <span class="t">{{ (item.data as ArollClip).src ? nameOf((item.data as ArollClip).src!) : tr("editing.inspector.sourceVideo") }}</span>
188- <span class="mono sub">{{ tr("editing.inspector.inSource", { from: tcd((item.data as ArollClip).in), to: tcd((item.data as ArollClip).out), dur: fmtDur((item.data as ArollClip).out - (item.data as ArollClip).in) }) }}</span>
189- <label class="f">{{ tr("editing.inspector.volume") }} <b class="mono">{{ fmtGain((item.data as ArollClip).gain_db) }}</b>
190- <input type="range" min="-30" max="12" step="1" :value="(item.data as ArollClip).gain_db ?? 0"
191- @input="set<ArollClip>((x) => { const v = num($event); if (v) x.gain_db = v; else delete x.gain_db; })"
192- @dblclick="set<ArollClip>((x) => { delete x.gain_db; })">
193- </label>
194- <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p>
193+ <template v-if="(item.data as ArollClip).pause">
194+ <span class="t">{{ tr("editing.pauseClip", { motion: pausedMotion((item.data as ArollClip).pause!) }) }}</span>
195+ <p class="hint">{{ tr("editing.inspector.pauseClipHint") }}</p>
196+ </template>
197+ <template v-else>
198+ <span class="t">{{ (item.data as ArollClip).src ? nameOf((item.data as ArollClip).src!) : tr("editing.inspector.sourceVideo") }}</span>
199+ <span class="mono sub">{{ tr("editing.inspector.inSource", { from: tcd((item.data as ArollClip).in), to: tcd((item.data as ArollClip).out), dur: fmtDur((item.data as ArollClip).out - (item.data as ArollClip).in) }) }}</span>
200+ <label class="f">{{ tr("editing.inspector.volume") }} <b class="mono">{{ fmtGain((item.data as ArollClip).gain_db) }}</b>
201+ <input type="range" min="-30" max="12" step="1" :value="(item.data as ArollClip).gain_db ?? 0"
202+ @input="set<ArollClip>((x) => { const v = num($event); if (v) x.gain_db = v; else delete x.gain_db; })"
203+ @dblclick="set<ArollClip>((x) => { delete x.gain_db; })">
204+ </label>
205+ <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p>
206+ </template>
195207 <TransitionPicker v-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')"
196208 :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" />
197209 <p class="hint">{{ tr("editing.inspector.arollHint") }}</p>
@@ -224,6 +236,11 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
224236 <template v-else-if="item.track === 'motion'">
225237 <input class="input" :value="(item.data as Motion).title" :placeholder="tr('common.name')" @change="set<Motion>((x) => { x.title = ($event.target as HTMLInputElement).value; })">
226238 <span v-if="(item.data as Motion).kind" class="sub">{{ (item.data as Motion).kind }}</span>
239+ <label class="check" :title="tr('editing.inspector.pauseAroll.title')">
240+ <input type="checkbox" :checked="paused" @change="emit('pause', item.data.id, ($event.target as HTMLInputElement).checked)">
241+ {{ tr("editing.inspector.pauseAroll.label") }}
242+ </label>
243+ <p v-if="paused" class="hint">{{ tr("editing.inspector.pauseAroll.on") }}</p>
227244 <p v-if="(item.data as Motion).idea" class="idea">{{ (item.data as Motion).idea }}</p>
228245 <div class="ends">
229246 <TransitionPicker :title="joined ? tr('transitions.joinPrev') : tr('transitions.enter')" :hint="joined ? tr('transitions.enterHint') : undefined"
@@ -341,6 +358,8 @@ fieldset { border: none; margin: 0; padding: 0; display: flex; flex-direction: c
341358 .f { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); }
342359 .f b { font-weight: 500; color: var(--ink); }
343360 .f input[type="range"] { accent-color: var(--ink); }
361+.check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink); cursor: pointer; }
362+.check input { accent-color: var(--ink); margin: 0; }
344363 .input { height: 36px; padding: 0 10px; font-size: 13.5px; }
345364 .input.area { height: auto; padding: 8px 10px; resize: vertical; font-family: inherit; line-height: 1.4; }
346365 .swatch { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--line-strong); cursor: pointer; padding: 0; }
@@ -13,7 +13,7 @@ import { post } from "../../../lib/api";
13 import { duration as fmtDur } from "../../../lib/format";13 import { duration as fmtDur } from "../../../lib/format";
14 import { toast } from "../../../lib/toast";14 import { toast } from "../../../lib/toast";
15 import MediaPicker from "./MediaPicker.vue";15 import MediaPicker from "./MediaPicker.vue";
16-import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops";16+import { addMedia as addMediaTo, addText as addTextTo, pauseOf, remove } from "./ops";
17 import {17 import {
18 fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, ZOOM_STYLES,18 fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, ZOOM_STYLES,
19 type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom,19 type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom,
@@ -23,7 +23,11 @@ import {
23 const props = defineProps<{23 const props = defineProps<{
24 tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number;24 tl: Timeline; view: MontageView; available: AvailableMedia[]; t: number; total: number;
25 }>();25 }>();
26-const emit = defineEmits<{ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [] }>();26+const emit = defineEmits<{
27+ edit: [(t: Timeline) => void]; comment: [string?]; seek: [number]; insert: [AvailableMedia]; media: []; beat: []; rerender: [];
28+ /** mettre l'A-roll en pause sous un motion (on), ou la retirer */
29+ pause: [string, boolean];
30+}>();
27 const sel = defineModel<Selection | null>("selection", { required: true });31 const sel = defineModel<Selection | null>("selection", { required: true });
28 const { t: tr } = useI18n();32 const { t: tr } = useI18n();
29 33
@@ -88,6 +92,8 @@ function del() {
88 }92 }
89 const nameOf = (src: string) => props.view.media[src]?.name ?? src.split("/").pop() ?? src;93 const nameOf = (src: string) => props.view.media[src]?.name ?? src.split("/").pop() ?? src;
90 const motionState = computed(() => (item.value?.track === "motion" ? props.view.motions[item.value.data.id] : null));94 const motionState = computed(() => (item.value?.track === "motion" ? props.view.motions[item.value.data.id] : null));
95+const paused = computed(() => (item.value?.track === "motion" ? !!pauseOf(props.tl, item.value.data.id) : false));
96+const pausedMotion = (id: string) => props.tl.motion.find((m) => m.id === id)?.title || id;
91 97
92 // --- ajouter au curseur98 // --- ajouter au curseur
93 const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5)));99 const at = computed(() => Math.min(props.t, Math.max(0, props.total - 0.5)));
@@ -184,14 +190,20 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
184 </div>190 </div>
185 <fieldset>191 <fieldset>
186 <template v-if="item.track === 'aroll'">192 <template v-if="item.track === 'aroll'">
187- <span class="t">{{ (item.data as ArollClip).src ? nameOf((item.data as ArollClip).src!) : tr("editing.inspector.sourceVideo") }}</span>193+ <template v-if="(item.data as ArollClip).pause">
188- <span class="mono sub">{{ tr("editing.inspector.inSource", { from: tcd((item.data as ArollClip).in), to: tcd((item.data as ArollClip).out), dur: fmtDur((item.data as ArollClip).out - (item.data as ArollClip).in) }) }}</span>194+ <span class="t">{{ tr("editing.pauseClip", { motion: pausedMotion((item.data as ArollClip).pause!) }) }}</span>
189- <label class="f">{{ tr("editing.inspector.volume") }} <b class="mono">{{ fmtGain((item.data as ArollClip).gain_db) }}</b>195+ <p class="hint">{{ tr("editing.inspector.pauseClipHint") }}</p>
190- <input type="range" min="-30" max="12" step="1" :value="(item.data as ArollClip).gain_db ?? 0"196+ </template>
191- @input="set<ArollClip>((x) => { const v = num($event); if (v) x.gain_db = v; else delete x.gain_db; })"197+ <template v-else>
192- @dblclick="set<ArollClip>((x) => { delete x.gain_db; })">198+ <span class="t">{{ (item.data as ArollClip).src ? nameOf((item.data as ArollClip).src!) : tr("editing.inspector.sourceVideo") }}</span>
193- </label>199+ <span class="mono sub">{{ tr("editing.inspector.inSource", { from: tcd((item.data as ArollClip).in), to: tcd((item.data as ArollClip).out), dur: fmtDur((item.data as ArollClip).out - (item.data as ArollClip).in) }) }}</span>
194- <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p>200+ <label class="f">{{ tr("editing.inspector.volume") }} <b class="mono">{{ fmtGain((item.data as ArollClip).gain_db) }}</b>
201+ <input type="range" min="-30" max="12" step="1" :value="(item.data as ArollClip).gain_db ?? 0"
202+ @input="set<ArollClip>((x) => { const v = num($event); if (v) x.gain_db = v; else delete x.gain_db; })"
203+ @dblclick="set<ArollClip>((x) => { delete x.gain_db; })">
204+ </label>
205+ <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p>
206+ </template>
195 <TransitionPicker v-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')"207 <TransitionPicker v-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')"
196 :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" />208 :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" />
197 <p class="hint">{{ tr("editing.inspector.arollHint") }}</p>209 <p class="hint">{{ tr("editing.inspector.arollHint") }}</p>
@@ -224,6 +236,11 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
224 <template v-else-if="item.track === 'motion'">236 <template v-else-if="item.track === 'motion'">
225 <input class="input" :value="(item.data as Motion).title" :placeholder="tr('common.name')" @change="set<Motion>((x) => { x.title = ($event.target as HTMLInputElement).value; })">237 <input class="input" :value="(item.data as Motion).title" :placeholder="tr('common.name')" @change="set<Motion>((x) => { x.title = ($event.target as HTMLInputElement).value; })">
226 <span v-if="(item.data as Motion).kind" class="sub">{{ (item.data as Motion).kind }}</span>238 <span v-if="(item.data as Motion).kind" class="sub">{{ (item.data as Motion).kind }}</span>
239+ <label class="check" :title="tr('editing.inspector.pauseAroll.title')">
240+ <input type="checkbox" :checked="paused" @change="emit('pause', item.data.id, ($event.target as HTMLInputElement).checked)">
241+ {{ tr("editing.inspector.pauseAroll.label") }}
242+ </label>
243+ <p v-if="paused" class="hint">{{ tr("editing.inspector.pauseAroll.on") }}</p>
227 <p v-if="(item.data as Motion).idea" class="idea">{{ (item.data as Motion).idea }}</p>244 <p v-if="(item.data as Motion).idea" class="idea">{{ (item.data as Motion).idea }}</p>
228 <div class="ends">245 <div class="ends">
229 <TransitionPicker :title="joined ? tr('transitions.joinPrev') : tr('transitions.enter')" :hint="joined ? tr('transitions.enterHint') : undefined"246 <TransitionPicker :title="joined ? tr('transitions.joinPrev') : tr('transitions.enter')" :hint="joined ? tr('transitions.enterHint') : undefined"
@@ -341,6 +358,8 @@ fieldset { border: none; margin: 0; padding: 0; display: flex; flex-direction: c
341 .f { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); }358 .f { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: var(--muted); }
342 .f b { font-weight: 500; color: var(--ink); }359 .f b { font-weight: 500; color: var(--ink); }
343 .f input[type="range"] { accent-color: var(--ink); }360 .f input[type="range"] { accent-color: var(--ink); }
361+.check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink); cursor: pointer; }
362+.check input { accent-color: var(--ink); margin: 0; }
344 .input { height: 36px; padding: 0 10px; font-size: 13.5px; }363 .input { height: 36px; padding: 0 10px; font-size: 13.5px; }
345 .input.area { height: auto; padding: 8px 10px; resize: vertical; font-family: inherit; line-height: 1.4; }364 .input.area { height: auto; padding: 8px 10px; resize: vertical; font-family: inherit; line-height: 1.4; }
346 .swatch { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--line-strong); cursor: pointer; padding: 0; }365 .swatch { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--line-strong); cursor: pointer; padding: 0; }
modified web/src/project/steps/montage/Timeline.vue +22 -7
@@ -14,9 +14,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
1414 import { useI18n } from "vue-i18n";
1515 import { useAltWheel } from "../../../lib/composables";
1616 import { fxName } from "../../../lib/transitions";
17-import { MEDIA_DRAG, trimAroll } from "./ops";
17+import { MEDIA_DRAG, pauseOf, setMotionDur, trimAroll } from "./ops";
1818 import {
19- clipStarts, clone, fxMax, type AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,
19+ clipStarts, clone, fxMax, type ArollClip, type AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,
2020 type Track, type ZoomStyle,
2121 } from "./types";
2222
@@ -207,6 +207,10 @@ const isFxSel = (track: Track, id: string, fx: FxEnd) => !!sel.value && "item" i
207207 && sel.value.item.track === track && sel.value.item.id === id && sel.value.item.fx === fx;
208208
209209 const nameOf = (src: string) => props.media[src]?.name ?? src.split("/").pop() ?? src;
210+/** Le nom d'un clip de l'A-roll inséré : son média, ou la pause et son motion. */
211+const clipName = (c: ArollClip) => (c.pause
212+ ? tr("editing.pauseClip", { motion: props.tl.motion.find((m) => m.id === c.pause)?.title || c.pause })
213+ : c.src ? nameOf(c.src) : undefined);
210214
211215 // --- vignettes de la vidéo source, sur chaque clip
212216 const TILE_H = 44;
@@ -392,6 +396,8 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) {
392396 const dt = t - d.t0;
393397 if (d.track === "aroll") {
394398 const o = d.origin.aroll.find((c) => c.id === d.id)!;
399+ // une pause a la durée de son motion : on la change par lui
400+ if (o.pause) return;
395401 const limit = o.src ? props.media[o.src]?.duration ?? Infinity : props.sourceDuration;
396402 if (d.mode === "left") trimAroll(tl, d.id, "in", o.in + dt, limit);
397403 else if (d.mode === "right") trimAroll(tl, d.id, "out", o.out + dt, limit);
@@ -406,6 +412,11 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) {
406412 const it = list.find((k) => k.id === d.id)!;
407413 const o = (d.origin[d.track] as typeof list).find((k) => k.id === d.id)!;
408414 const len = o.dur ?? (o.out! - o.in!);
415+ // un motion en pause est un plan de l'A-roll : il ne se déplace pas, tirer sa fin allonge ou raccourcit sa pause
416+ if (d.track === "motion" && pauseOf(d.origin, d.id)) {
417+ if (d.mode === "right") setMotionDur(tl, d.id, Math.max(0.3, Math.round((len + dt) * 20) / 20));
418+ return;
419+ }
409420 const limit = "src" in o ? props.media[(o as { src: string }).src]?.duration ?? Infinity : Infinity;
410421 if (d.mode === "move") {
411422 let at = o.at + dt;
@@ -643,12 +654,14 @@ function motionState(id: string) {
643654 </div>
644655
645656 <!-- A-roll : la vidéo source, avec ses vignettes -->
646- <div v-for="v in arollV" :key="v.c.id" class="clip" :class="{ on: isSel('aroll', v.c.id), ext: !!v.c.src }"
657+ <div v-for="v in arollV" :key="v.c.id" class="clip" :class="{ on: isSel('aroll', v.c.id), ext: !!v.c.src, pause: !!v.c.pause }"
647658 :style="{ top: `${tops.aroll}px`, height: `${rowH.aroll}px`, left: `${x(v.a)}px`, width: `${x(v.b - v.a)}px` }"
648- :title="v.c.src ? nameOf(v.c.src) : undefined" @pointerdown="down($event, 'aroll', v.c.id)" @dblclick="goTo(v.a)">
649- <span v-if="v.c.src">{{ nameOf(v.c.src) }}</span>
650- <i class="edge l" @pointerdown="down($event, 'aroll', v.c.id, 'left')" />
651- <i class="edge r" @pointerdown="down($event, 'aroll', v.c.id, 'right')" />
659+ :title="clipName(v.c)" @pointerdown="down($event, 'aroll', v.c.id)" @dblclick="goTo(v.a)">
660+ <span v-if="v.c.src">{{ clipName(v.c) }}</span>
661+ <template v-if="!v.c.pause">
662+ <i class="edge l" @pointerdown="down($event, 'aroll', v.c.id, 'left')" />
663+ <i class="edge r" @pointerdown="down($event, 'aroll', v.c.id, 'right')" />
664+ </template>
652665 </div>
653666 <div class="strip" :style="{ top: `${tops.aroll + 4}px` }">
654667 <span v-for="k in tiles" :key="k.key" class="tile"
@@ -757,6 +770,8 @@ function motionState(id: string) {
757770 .gain { position: absolute; transform: translateX(-100%); z-index: 2; white-space: nowrap; font-size: 9.5px; font-weight: 600; color: #fff;
758771 background: rgba(0, 0, 0, .55); border-radius: 2px; padding: 0 4px; pointer-events: none; }
759772 .clip.ext.on { background: repeating-linear-gradient(135deg, #8A4FBF 0 8px, #7D45B0 8px 16px); }
773+/* une pause sous un motion plein écran : la voix s'arrête, l'image est noire */
774+.clip.pause, .clip.pause.on { background: repeating-linear-gradient(135deg, #1B1B1D 0 8px, #2A2140 8px 16px); color: #C9BBF5; }
760775 .strip { position: absolute; left: 0; height: 0; pointer-events: none; z-index: 1; }
761776 .tile { position: absolute; top: 0; background-repeat: no-repeat; opacity: .92; }
762777 .pending-media { position: absolute; font-size: 10.5px; color: var(--off); z-index: 2; pointer-events: none; }
@@ -14,9 +14,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
14 import { useI18n } from "vue-i18n";14 import { useI18n } from "vue-i18n";
15 import { useAltWheel } from "../../../lib/composables";15 import { useAltWheel } from "../../../lib/composables";
16 import { fxName } from "../../../lib/transitions";16 import { fxName } from "../../../lib/transitions";
17-import { MEDIA_DRAG, trimAroll } from "./ops";17+import { MEDIA_DRAG, pauseOf, setMotionDur, trimAroll } from "./ops";
18 import {18 import {
19- clipStarts, clone, fxMax, type AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,19+ clipStarts, clone, fxMax, type ArollClip, type AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,
20 type Track, type ZoomStyle,20 type Track, type ZoomStyle,
21 } from "./types";21 } from "./types";
22 22
@@ -207,6 +207,10 @@ const isFxSel = (track: Track, id: string, fx: FxEnd) => !!sel.value && "item" i
207 && sel.value.item.track === track && sel.value.item.id === id && sel.value.item.fx === fx;207 && sel.value.item.track === track && sel.value.item.id === id && sel.value.item.fx === fx;
208 208
209 const nameOf = (src: string) => props.media[src]?.name ?? src.split("/").pop() ?? src;209 const nameOf = (src: string) => props.media[src]?.name ?? src.split("/").pop() ?? src;
210+/** Le nom d'un clip de l'A-roll inséré : son média, ou la pause et son motion. */
211+const clipName = (c: ArollClip) => (c.pause
212+ ? tr("editing.pauseClip", { motion: props.tl.motion.find((m) => m.id === c.pause)?.title || c.pause })
213+ : c.src ? nameOf(c.src) : undefined);
210 214
211 // --- vignettes de la vidéo source, sur chaque clip215 // --- vignettes de la vidéo source, sur chaque clip
212 const TILE_H = 44;216 const TILE_H = 44;
@@ -392,6 +396,8 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) {
392 const dt = t - d.t0;396 const dt = t - d.t0;
393 if (d.track === "aroll") {397 if (d.track === "aroll") {
394 const o = d.origin.aroll.find((c) => c.id === d.id)!;398 const o = d.origin.aroll.find((c) => c.id === d.id)!;
399+ // une pause a la durée de son motion : on la change par lui
400+ if (o.pause) return;
395 const limit = o.src ? props.media[o.src]?.duration ?? Infinity : props.sourceDuration;401 const limit = o.src ? props.media[o.src]?.duration ?? Infinity : props.sourceDuration;
396 if (d.mode === "left") trimAroll(tl, d.id, "in", o.in + dt, limit);402 if (d.mode === "left") trimAroll(tl, d.id, "in", o.in + dt, limit);
397 else if (d.mode === "right") trimAroll(tl, d.id, "out", o.out + dt, limit);403 else if (d.mode === "right") trimAroll(tl, d.id, "out", o.out + dt, limit);
@@ -406,6 +412,11 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) {
406 const it = list.find((k) => k.id === d.id)!;412 const it = list.find((k) => k.id === d.id)!;
407 const o = (d.origin[d.track] as typeof list).find((k) => k.id === d.id)!;413 const o = (d.origin[d.track] as typeof list).find((k) => k.id === d.id)!;
408 const len = o.dur ?? (o.out! - o.in!);414 const len = o.dur ?? (o.out! - o.in!);
415+ // un motion en pause est un plan de l'A-roll : il ne se déplace pas, tirer sa fin allonge ou raccourcit sa pause
416+ if (d.track === "motion" && pauseOf(d.origin, d.id)) {
417+ if (d.mode === "right") setMotionDur(tl, d.id, Math.max(0.3, Math.round((len + dt) * 20) / 20));
418+ return;
419+ }
409 const limit = "src" in o ? props.media[(o as { src: string }).src]?.duration ?? Infinity : Infinity;420 const limit = "src" in o ? props.media[(o as { src: string }).src]?.duration ?? Infinity : Infinity;
410 if (d.mode === "move") {421 if (d.mode === "move") {
411 let at = o.at + dt;422 let at = o.at + dt;
@@ -643,12 +654,14 @@ function motionState(id: string) {
643 </div>654 </div>
644 655
645 <!-- A-roll : la vidéo source, avec ses vignettes -->656 <!-- A-roll : la vidéo source, avec ses vignettes -->
646- <div v-for="v in arollV" :key="v.c.id" class="clip" :class="{ on: isSel('aroll', v.c.id), ext: !!v.c.src }"657+ <div v-for="v in arollV" :key="v.c.id" class="clip" :class="{ on: isSel('aroll', v.c.id), ext: !!v.c.src, pause: !!v.c.pause }"
647 :style="{ top: `${tops.aroll}px`, height: `${rowH.aroll}px`, left: `${x(v.a)}px`, width: `${x(v.b - v.a)}px` }"658 :style="{ top: `${tops.aroll}px`, height: `${rowH.aroll}px`, left: `${x(v.a)}px`, width: `${x(v.b - v.a)}px` }"
648- :title="v.c.src ? nameOf(v.c.src) : undefined" @pointerdown="down($event, 'aroll', v.c.id)" @dblclick="goTo(v.a)">659+ :title="clipName(v.c)" @pointerdown="down($event, 'aroll', v.c.id)" @dblclick="goTo(v.a)">
649- <span v-if="v.c.src">{{ nameOf(v.c.src) }}</span>660+ <span v-if="v.c.src">{{ clipName(v.c) }}</span>
650- <i class="edge l" @pointerdown="down($event, 'aroll', v.c.id, 'left')" />661+ <template v-if="!v.c.pause">
651- <i class="edge r" @pointerdown="down($event, 'aroll', v.c.id, 'right')" />662+ <i class="edge l" @pointerdown="down($event, 'aroll', v.c.id, 'left')" />
663+ <i class="edge r" @pointerdown="down($event, 'aroll', v.c.id, 'right')" />
664+ </template>
652 </div>665 </div>
653 <div class="strip" :style="{ top: `${tops.aroll + 4}px` }">666 <div class="strip" :style="{ top: `${tops.aroll + 4}px` }">
654 <span v-for="k in tiles" :key="k.key" class="tile"667 <span v-for="k in tiles" :key="k.key" class="tile"
@@ -757,6 +770,8 @@ function motionState(id: string) {
757 .gain { position: absolute; transform: translateX(-100%); z-index: 2; white-space: nowrap; font-size: 9.5px; font-weight: 600; color: #fff;770 .gain { position: absolute; transform: translateX(-100%); z-index: 2; white-space: nowrap; font-size: 9.5px; font-weight: 600; color: #fff;
758 background: rgba(0, 0, 0, .55); border-radius: 2px; padding: 0 4px; pointer-events: none; }771 background: rgba(0, 0, 0, .55); border-radius: 2px; padding: 0 4px; pointer-events: none; }
759 .clip.ext.on { background: repeating-linear-gradient(135deg, #8A4FBF 0 8px, #7D45B0 8px 16px); }772 .clip.ext.on { background: repeating-linear-gradient(135deg, #8A4FBF 0 8px, #7D45B0 8px 16px); }
773+/* une pause sous un motion plein écran : la voix s'arrête, l'image est noire */
774+.clip.pause, .clip.pause.on { background: repeating-linear-gradient(135deg, #1B1B1D 0 8px, #2A2140 8px 16px); color: #C9BBF5; }
760 .strip { position: absolute; left: 0; height: 0; pointer-events: none; z-index: 1; }775 .strip { position: absolute; left: 0; height: 0; pointer-events: none; z-index: 1; }
761 .tile { position: absolute; top: 0; background-repeat: no-repeat; opacity: .92; }776 .tile { position: absolute; top: 0; background-repeat: no-repeat; opacity: .92; }
762 .pending-media { position: absolute; font-size: 10.5px; color: var(--off); z-index: 2; pointer-events: none; }777 .pending-media { position: absolute; font-size: 10.5px; color: var(--off); z-index: 2; pointer-events: none; }
modified web/src/project/steps/montage/ops.ts +78 -3
@@ -3,7 +3,7 @@
33 * L'A-roll est la piste principale : couper ou raccourcir un clip décale tout ce qui suit
44 * (ripple), et les éléments posés au-dessus suivent leur passage, comme dans Resolve.
55 */
6-import { clipStarts, newId, totalOf, type AvailableMedia, type Broll, type Sound, type Timeline, type Track } from "./types";
6+import { clipStarts, newId, PAUSE_SRC, totalOf, type AvailableMedia, type Broll, type Sound, type Timeline, type Track } from "./types";
77
88 const MIN = 0.1;
99
@@ -26,7 +26,8 @@ export function ripple(tl: Timeline, from: number, delta: number): void {
2626 export function bladeAroll(tl: Timeline, t: number): string | null {
2727 const starts = clipStarts(tl);
2828 const i = tl.aroll.findIndex((c, k) => t > starts[k] + MIN && t < starts[k] + c.out - c.in - MIN);
29- if (i < 0) return null;
29+ // une pause ne se coupe pas : elle suit son motion
30+ if (i < 0 || tl.aroll[i].pause) return null;
3031 const c = tl.aroll[i], at = c.in + (t - starts[i]);
3132 const id = newId(tl, "a");
3233 // la seconde moitié garde le média et le volume du clip coupé
@@ -117,21 +118,95 @@ export function bladeItem(tl: Timeline, track: Track, id: string, t: number): st
117118 return null;
118119 }
119120
120-/** Supprime un élément ; un clip de l'A-roll est retiré avec raccord (ripple). */
121+/**
122+ * Supprime un élément ; un clip de l'A-roll est retiré avec raccord (ripple). Une pause retirée laisse son motion
123+ * au-dessus de la voix ; un motion supprimé emporte sa pause.
124+ */
121125 export function remove(tl: Timeline, track: Track, id: string): void {
122126 if (track === "aroll") {
123127 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);
124128 if (i < 0 || tl.aroll.length < 2) return;
125129 const c = tl.aroll[i], len = c.out - c.in;
130+ if (c.pause && tl.motion.some((m) => m.id === c.pause)) {
131+ unpauseMotion(tl, c.pause);
132+ return;
133+ }
126134 tl.aroll.splice(i, 1);
127135 ripple(tl, starts[i] + len, -len);
128136 return;
129137 }
138+ if (track === "motion") unpauseMotion(tl, id);
130139 (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id);
131140 // les sons d'un motion partent avec lui
132141 if (track === "motion") tl.audio = tl.audio.filter((s) => s.motion !== id);
133142 }
134143
144+/** Un décalage de la timeline, que les beats du storyboard suivent (useMontage.shiftBeats). */
145+export interface Shift { at: number; length: number }
146+
147+/** La pause qui porte le motion `id`, s'il en a une. */
148+export const pauseOf = (tl: Timeline, id: string) => tl.aroll.find((c) => c.pause === id) ?? null;
149+
150+/**
151+ * Retire la pause d'un motion (comme montage.unpause_motion) : l'A-roll se referme et ce qui suit remonte ; ce qui
152+ * commençait dans la pause disparaît, sauf le motion et ses sons, qui restent où ils sont. Rend le décalage.
153+ */
154+export function unpauseMotion(tl: Timeline, id: string): Shift | null {
155+ const i = tl.aroll.findIndex((c) => c.pause === id);
156+ if (i < 0) return null;
157+ const start = clipStarts(tl)[i], c = tl.aroll[i], len = c.out - c.in;
158+ const m = tl.motion.find((x) => x.id === id), own = tl.audio.filter((s) => s.motion === id);
159+ tl.motion = tl.motion.filter((x) => x.id !== id);
160+ tl.audio = tl.audio.filter((s) => s.motion !== id);
161+ tl.aroll.splice(i, 1);
162+ if (tl.aroll[i]) tl.aroll[i].transition = null;
163+ ripple(tl, start + len, -len);
164+ if (m) tl.motion.push(m);
165+ tl.audio.push(...own);
166+ return { at: round(start + len), length: round(-len) };
167+}
168+
169+/**
170+ * Met l'A-roll en pause sous un motion plein écran (comme montage.pause_motion) : une pause noire et muette de la
171+ * durée du motion, insérée à son début ; tout ce qui suit est décalé, le motion et ses sons restent posés dessus.
172+ * Une pause déjà là est refaite (la durée du motion a pu changer). Rend les décalages à reporter sur le storyboard.
173+ */
174+export function pauseMotion(tl: Timeline, id: string): Shift[] {
175+ if (!tl.motion.some((x) => x.id === id)) return [];
176+ const shifts: Shift[] = [];
177+ const undone = unpauseMotion(tl, id);
178+ if (undone) shifts.push(undone);
179+ const m = tl.motion.find((x) => x.id === id)!;
180+ const held = new Map([m, ...tl.audio.filter((s) => s.motion === id)].map((x) => [x.id, x.at]));
181+ const park = (at: (old: number) => number) => {
182+ for (const x of [...tl.motion, ...tl.audio]) if (held.has(x.id)) x.at = at(held.get(x.id)!);
183+ };
184+ // le motion et ses sons hors d'atteinte le temps de l'insertion : ni coupés ni décalés
185+ park(() => -1e9);
186+ const done = insertAroll(tl, held.get(id)!, { src: PAUSE_SRC, in: 0, out: m.dur });
187+ tl.aroll.find((c) => c.id === done.id)!.pause = id;
188+ // calé sur la coupe toute proche : le motion et ses sons la suivent
189+ const delta = done.at - held.get(id)!;
190+ park((at) => round(Math.max(0, at + delta)));
191+ // le beat du motion reste à son début : seuls ceux d'après suivent
192+ shifts.push({ at: round(done.at + 0.01), length: round(m.dur) });
193+ return shifts;
194+}
195+
196+/** Change la durée d'un motion ; sa pause, s'il en a une, suit (avec ripple). */
197+export function setMotionDur(tl: Timeline, id: string, dur: number): void {
198+ const m = tl.motion.find((x) => x.id === id);
199+ if (!m) return;
200+ m.dur = round(dur);
201+ const p = pauseOf(tl, id);
202+ if (!p) return;
203+ // ses sons restent où ils sont (une pause raccourcie effacerait ceux posés dans sa fin)
204+ const own = tl.audio.filter((s) => s.motion === id);
205+ tl.audio = tl.audio.filter((s) => s.motion !== id);
206+ trimAroll(tl, p.id, "out", p.in + m.dur, Infinity);
207+ tl.audio.push(...own);
208+}
209+
135210 /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */
136211 export function trimAroll(tl: Timeline, id: string, edge: "in" | "out", value: number, limit: number): void {
137212 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);
@@ -3,7 +3,7 @@
3 * L'A-roll est la piste principale : couper ou raccourcir un clip décale tout ce qui suit3 * L'A-roll est la piste principale : couper ou raccourcir un clip décale tout ce qui suit
4 * (ripple), et les éléments posés au-dessus suivent leur passage, comme dans Resolve.4 * (ripple), et les éléments posés au-dessus suivent leur passage, comme dans Resolve.
5 */5 */
6-import { clipStarts, newId, totalOf, type AvailableMedia, type Broll, type Sound, type Timeline, type Track } from "./types";6+import { clipStarts, newId, PAUSE_SRC, totalOf, type AvailableMedia, type Broll, type Sound, type Timeline, type Track } from "./types";
7 7
8 const MIN = 0.1;8 const MIN = 0.1;
9 9
@@ -26,7 +26,8 @@ export function ripple(tl: Timeline, from: number, delta: number): void {
26 export function bladeAroll(tl: Timeline, t: number): string | null {26 export function bladeAroll(tl: Timeline, t: number): string | null {
27 const starts = clipStarts(tl);27 const starts = clipStarts(tl);
28 const i = tl.aroll.findIndex((c, k) => t > starts[k] + MIN && t < starts[k] + c.out - c.in - MIN);28 const i = tl.aroll.findIndex((c, k) => t > starts[k] + MIN && t < starts[k] + c.out - c.in - MIN);
29- if (i < 0) return null;29+ // une pause ne se coupe pas : elle suit son motion
30+ if (i < 0 || tl.aroll[i].pause) return null;
30 const c = tl.aroll[i], at = c.in + (t - starts[i]);31 const c = tl.aroll[i], at = c.in + (t - starts[i]);
31 const id = newId(tl, "a");32 const id = newId(tl, "a");
32 // la seconde moitié garde le média et le volume du clip coupé33 // la seconde moitié garde le média et le volume du clip coupé
@@ -117,21 +118,95 @@ export function bladeItem(tl: Timeline, track: Track, id: string, t: number): st
117 return null;118 return null;
118 }119 }
119 120
120-/** Supprime un élément ; un clip de l'A-roll est retiré avec raccord (ripple). */121+/**
122+ * Supprime un élément ; un clip de l'A-roll est retiré avec raccord (ripple). Une pause retirée laisse son motion
123+ * au-dessus de la voix ; un motion supprimé emporte sa pause.
124+ */
121 export function remove(tl: Timeline, track: Track, id: string): void {125 export function remove(tl: Timeline, track: Track, id: string): void {
122 if (track === "aroll") {126 if (track === "aroll") {
123 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);127 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);
124 if (i < 0 || tl.aroll.length < 2) return;128 if (i < 0 || tl.aroll.length < 2) return;
125 const c = tl.aroll[i], len = c.out - c.in;129 const c = tl.aroll[i], len = c.out - c.in;
130+ if (c.pause && tl.motion.some((m) => m.id === c.pause)) {
131+ unpauseMotion(tl, c.pause);
132+ return;
133+ }
126 tl.aroll.splice(i, 1);134 tl.aroll.splice(i, 1);
127 ripple(tl, starts[i] + len, -len);135 ripple(tl, starts[i] + len, -len);
128 return;136 return;
129 }137 }
138+ if (track === "motion") unpauseMotion(tl, id);
130 (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id);139 (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id);
131 // les sons d'un motion partent avec lui140 // les sons d'un motion partent avec lui
132 if (track === "motion") tl.audio = tl.audio.filter((s) => s.motion !== id);141 if (track === "motion") tl.audio = tl.audio.filter((s) => s.motion !== id);
133 }142 }
134 143
144+/** Un décalage de la timeline, que les beats du storyboard suivent (useMontage.shiftBeats). */
145+export interface Shift { at: number; length: number }
146+
147+/** La pause qui porte le motion `id`, s'il en a une. */
148+export const pauseOf = (tl: Timeline, id: string) => tl.aroll.find((c) => c.pause === id) ?? null;
149+
150+/**
151+ * Retire la pause d'un motion (comme montage.unpause_motion) : l'A-roll se referme et ce qui suit remonte ; ce qui
152+ * commençait dans la pause disparaît, sauf le motion et ses sons, qui restent où ils sont. Rend le décalage.
153+ */
154+export function unpauseMotion(tl: Timeline, id: string): Shift | null {
155+ const i = tl.aroll.findIndex((c) => c.pause === id);
156+ if (i < 0) return null;
157+ const start = clipStarts(tl)[i], c = tl.aroll[i], len = c.out - c.in;
158+ const m = tl.motion.find((x) => x.id === id), own = tl.audio.filter((s) => s.motion === id);
159+ tl.motion = tl.motion.filter((x) => x.id !== id);
160+ tl.audio = tl.audio.filter((s) => s.motion !== id);
161+ tl.aroll.splice(i, 1);
162+ if (tl.aroll[i]) tl.aroll[i].transition = null;
163+ ripple(tl, start + len, -len);
164+ if (m) tl.motion.push(m);
165+ tl.audio.push(...own);
166+ return { at: round(start + len), length: round(-len) };
167+}
168+
169+/**
170+ * Met l'A-roll en pause sous un motion plein écran (comme montage.pause_motion) : une pause noire et muette de la
171+ * durée du motion, insérée à son début ; tout ce qui suit est décalé, le motion et ses sons restent posés dessus.
172+ * Une pause déjà là est refaite (la durée du motion a pu changer). Rend les décalages à reporter sur le storyboard.
173+ */
174+export function pauseMotion(tl: Timeline, id: string): Shift[] {
175+ if (!tl.motion.some((x) => x.id === id)) return [];
176+ const shifts: Shift[] = [];
177+ const undone = unpauseMotion(tl, id);
178+ if (undone) shifts.push(undone);
179+ const m = tl.motion.find((x) => x.id === id)!;
180+ const held = new Map([m, ...tl.audio.filter((s) => s.motion === id)].map((x) => [x.id, x.at]));
181+ const park = (at: (old: number) => number) => {
182+ for (const x of [...tl.motion, ...tl.audio]) if (held.has(x.id)) x.at = at(held.get(x.id)!);
183+ };
184+ // le motion et ses sons hors d'atteinte le temps de l'insertion : ni coupés ni décalés
185+ park(() => -1e9);
186+ const done = insertAroll(tl, held.get(id)!, { src: PAUSE_SRC, in: 0, out: m.dur });
187+ tl.aroll.find((c) => c.id === done.id)!.pause = id;
188+ // calé sur la coupe toute proche : le motion et ses sons la suivent
189+ const delta = done.at - held.get(id)!;
190+ park((at) => round(Math.max(0, at + delta)));
191+ // le beat du motion reste à son début : seuls ceux d'après suivent
192+ shifts.push({ at: round(done.at + 0.01), length: round(m.dur) });
193+ return shifts;
194+}
195+
196+/** Change la durée d'un motion ; sa pause, s'il en a une, suit (avec ripple). */
197+export function setMotionDur(tl: Timeline, id: string, dur: number): void {
198+ const m = tl.motion.find((x) => x.id === id);
199+ if (!m) return;
200+ m.dur = round(dur);
201+ const p = pauseOf(tl, id);
202+ if (!p) return;
203+ // ses sons restent où ils sont (une pause raccourcie effacerait ceux posés dans sa fin)
204+ const own = tl.audio.filter((s) => s.motion === id);
205+ tl.audio = tl.audio.filter((s) => s.motion !== id);
206+ trimAroll(tl, p.id, "out", p.in + m.dur, Infinity);
207+ tl.audio.push(...own);
208+}
209+
135 /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */210 /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */
136 export function trimAroll(tl: Timeline, id: string, edge: "in" | "out", value: number, limit: number): void {211 export function trimAroll(tl: Timeline, id: string, edge: "in" | "out", value: number, limit: number): void {
137 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);212 const starts = clipStarts(tl), i = tl.aroll.findIndex((c) => c.id === id);
modified web/src/project/steps/montage/types.ts +5 -2
@@ -5,9 +5,12 @@ import type { Fx } from "../../../lib/transitions";
55 /**
66 * transition : avec le clip précédent (null : coupe franche) ; src : absent pour la vidéo source, sinon un autre
77 * média inséré dans l'A-roll (un extrait, une archive), qui passe avec son propre son ; gain_db : son volume
8- * (absent : 0 dB)
8+ * (absent : 0 dB) ; pause : le motion plein écran que ce clip porte (src = PAUSE_SRC, noir et muet, de la durée du
9+ * motion) : la voix s'arrête le temps du motion, qui devient un plan de l'A-roll
910 */
10-export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number }
11+export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number; pause?: string }
12+/** La vidéo noire et muette des pauses (backend montage.PAUSE_SRC), faite par le serveur au premier besoin. */
13+export const PAUSE_SRC = "montage/pause.mp4";
1114 /**
1215 * Le style d'un zoom (backend montage.ZOOM_STYLES) : smooth monte et descend en ZOOM_RAMP (adouci), cut saute
1316 * d'un coup (aparté face cam), slow pousse lentement sur toute sa durée puis revient d'un coup.
@@ -5,9 +5,12 @@ import type { Fx } from "../../../lib/transitions";
5 /**5 /**
6 * transition : avec le clip précédent (null : coupe franche) ; src : absent pour la vidéo source, sinon un autre6 * transition : avec le clip précédent (null : coupe franche) ; src : absent pour la vidéo source, sinon un autre
7 * média inséré dans l'A-roll (un extrait, une archive), qui passe avec son propre son ; gain_db : son volume7 * média inséré dans l'A-roll (un extrait, une archive), qui passe avec son propre son ; gain_db : son volume
8- * (absent : 0 dB)8+ * (absent : 0 dB) ; pause : le motion plein écran que ce clip porte (src = PAUSE_SRC, noir et muet, de la durée du
9+ * motion) : la voix s'arrête le temps du motion, qui devient un plan de l'A-roll
9 */10 */
10-export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number }11+export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number; pause?: string }
12+/** La vidéo noire et muette des pauses (backend montage.PAUSE_SRC), faite par le serveur au premier besoin. */
13+export const PAUSE_SRC = "montage/pause.mp4";
11 /**14 /**
12 * Le style d'un zoom (backend montage.ZOOM_STYLES) : smooth monte et descend en ZOOM_RAMP (adouci), cut saute15 * Le style d'un zoom (backend montage.ZOOM_STYLES) : smooth monte et descend en ZOOM_RAMP (adouci), cut saute
13 * d'un coup (aparté face cam), slow pousse lentement sur toute sa durée puis revient d'un coup.16 * d'un coup (aparté face cam), slow pousse lentement sur toute sa durée puis revient d'un coup.