hlassiege/darylpublic Fork
⑂ f0eb0eb
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(derush): multiple timelines, nesting and a thumbnail media pool

The rough cut can now have several timelines: cut the whole shoot at
once, or one sequence at a time. The main timeline stays the one that
is exported and becomes the source video, and any timeline can be
nested into another (live link: editing the nested one updates the
parent). Cycles are refused on both sides.

The Rushes tab becomes a media pool, like Filmora / Resolve: rushes as
thumbnails sorted by shooting time, timelines as items, import with the
browser file picker or drag and drop, "+" or drag onto the timeline,
and a button to lay the selected rushes (or all) by shooting time.
Imported rushes no longer land on the timelines by themselves.

Projects saved with the single `derush.clips` list are upgraded on load.
hlassiege committed 2026-10-12T10:27:24+02:00 Browse files
f0eb0eb parent: e615554
modified backend/studio/modules/projects/api.py +1 -1
@@ -895,7 +895,7 @@ def rush_peaks(pid: str, rush_id: str):
895895 def get_derush(pid: str):
896896 _check(pid)
897897 project = service.load(pid)
898- return {**runtime.analysis(project, edited=False), "clips": runtime.clips(project)}
898+ return {**runtime.analysis(project, edited=False), "clips": runtime.own_clips(project, project["derush"]["main"])}
899899
900900
901901 class AutoDerushRequest(BaseModel):
@@ -895,7 +895,7 @@ def rush_peaks(pid: str, rush_id: str):
895 def get_derush(pid: str):895 def get_derush(pid: str):
896 _check(pid)896 _check(pid)
897 project = service.load(pid)897 project = service.load(pid)
898- return {**runtime.analysis(project, edited=False), "clips": runtime.clips(project)}898+ return {**runtime.analysis(project, edited=False), "clips": runtime.own_clips(project, project["derush"]["main"])}
899 899
900 900
901 class AutoDerushRequest(BaseModel):901 class AutoDerushRequest(BaseModel):
modified backend/studio/modules/projects/runtime.py +42 -20
@@ -152,14 +152,12 @@ def _rush_entry(path: Path) -> dict:
152152
153153
154154 def add_rushes(project: dict, paths: list[Path]) -> None:
155- """Nouveaux rushes : leur transcription part tout de suite (file GPU)."""
155+ """Nouveaux rushes : leur transcription part tout de suite (file GPU). Ils arrivent dans la médiathèque,
156+ pas dans les timelines (sauf la globale tant qu'elle découle des rushes)."""
156157 for path in paths:
157158 rush = _rush_entry(path)
158159 project["rushes"].append(rush)
159160 transcribe_rush(project, rush)
160- if project["derush"].get("clips") is not None and rush.get("duration"):
161- project["derush"]["clips"].append({"id": "c" + uuid.uuid4().hex[:8], "rush": rush["id"],
162- "in": 0.0, "out": rush["duration"]})
163161 service.start_step(project, "tournage")
164162 service.start_step(project, "derush")
165163 if project["rush_sort"] == "Heure":
@@ -183,8 +181,9 @@ def remove_rush(project: dict, rush_id: str) -> None:
183181 prefix = rush_id + ":"
184182 project["derush"]["cuts"] = {k: v for k, v in project["derush"]["cuts"].items() if not k.startswith(prefix)}
185183 project["derush"].get("splits", {}).pop(rush_id, None)
186- if project["derush"].get("clips") is not None:
187- project["derush"]["clips"] = [c for c in project["derush"]["clips"] if c["rush"] != rush_id]
184+ for tl in project["derush"]["timelines"]:
185+ if tl["clips"] is not None:
186+ tl["clips"] = [c for c in tl["clips"] if c.get("rush") != rush_id]
188187 path = Path(rush["path"])
189188 # le fichier n'est supprimé que s'il a été copié dans le projet
190189 if project_dir(project["id"]) in path.parents:
@@ -278,7 +277,6 @@ def sort_rushes(project: dict, mode: str) -> None:
278277 if s["type"] == "speech" and s.get("sec") and s["rush"] not in first:
279278 first[s["rush"]] = int(s["sec"][1:])
280279 project["rushes"].sort(key=lambda r: first.get(r["id"], 10_000))
281- order_clips(project)
282280
283281
284282 # --- Dérush ------------------------------------------------------------------------
@@ -310,13 +308,21 @@ def analysis(project: dict, edited: bool = True) -> dict:
310308 [{"rush": r["id"], "status": "done", "progress": 1.0} for r in project["rushes"]]}
311309
312310
313-def clips(project: dict) -> list[dict]:
314- """La timeline du dérush : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture.
315- Tant que l'utilisateur n'y a pas touché, elle découle des anciennes coupes (ou des rushes entiers)."""
311+def timeline(project: dict, tid: str) -> dict | None:
312+ return next((t for t in project["derush"]["timelines"] if t["id"] == tid), None)
313+
314+
315+def own_clips(project: dict, tid: str) -> list[dict]:
316+ """Les bouts d'une timeline, tels qu'on les édite : de rushes ({rush}) ou de timelines imbriquées ({tl}).
317+ Tant que l'utilisateur n'a pas touché à la globale, elle découle des anciennes coupes (ou des rushes entiers)."""
316318 known = {r["id"] for r in project["rushes"]}
317- stored = project["derush"].get("clips")
318- if stored is not None:
319- return [c for c in stored if c["rush"] in known]
319+ tl = timeline(project, tid)
320+ if tl is None:
321+ return []
322+ if tl["clips"] is not None:
323+ return [c for c in tl["clips"] if c.get("tl") or c.get("rush") in known]
324+ if tid != project["derush"]["main"]:
325+ return []
320326 found = analysis(project)
321327 if found["ready"]:
322328 return [{"id": f"c{i}", "rush": r["rush"], "in": r["start"], "out": r["end"]}
@@ -325,13 +331,29 @@ def clips(project: dict) -> list[dict]:
325331 for i, r in enumerate(project["rushes"]) if r.get("duration")]
326332
327333
328-def order_clips(project: dict) -> None:
329- """Après un changement d'ordre des rushes : les bouts suivent, rush par rush."""
330- stored = project["derush"].get("clips")
331- if stored is None:
332- return
333- rank = {r["id"]: i for i, r in enumerate(project["rushes"])}
334- stored.sort(key=lambda c: rank.get(c["rush"], len(rank)))
334+def flatten(project: dict, tid: str, seen: frozenset = frozenset()) -> list[dict]:
335+ """Les bouts de rushes lus par une timeline, ses timelines imbriquées dépliées (sur la plage [in, out] de chacune)."""
336+ out: list[dict] = []
337+ for c in own_clips(project, tid):
338+ if c.get("rush"):
339+ out.append(c)
340+ continue
341+ if c["tl"] in seen or c["tl"] == tid:
342+ continue
343+ cursor = 0.0
344+ for sub in flatten(project, c["tl"], seen | {tid}):
345+ length = sub["out"] - sub["in"]
346+ a, b = max(c["in"], cursor), min(c["out"], cursor + length)
347+ if b - a > 0.001:
348+ out.append({"id": c["id"], "rush": sub["rush"], "in": round(sub["in"] + a - cursor, 3),
349+ "out": round(sub["in"] + b - cursor, 3)})
350+ cursor += length
351+ return out
352+
353+
354+def clips(project: dict) -> list[dict]:
355+ """La timeline globale du dérush, dépliée : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture."""
356+ return flatten(project, project["derush"]["main"])
335357
336358
337359 def ranges(project: dict) -> list[dict]:
@@ -152,14 +152,12 @@ def _rush_entry(path: Path) -> dict:
152 152
153 153
154 def add_rushes(project: dict, paths: list[Path]) -> None:154 def add_rushes(project: dict, paths: list[Path]) -> None:
155- """Nouveaux rushes : leur transcription part tout de suite (file GPU)."""155+ """Nouveaux rushes : leur transcription part tout de suite (file GPU). Ils arrivent dans la médiathèque,
156+ pas dans les timelines (sauf la globale tant qu'elle découle des rushes)."""
156 for path in paths:157 for path in paths:
157 rush = _rush_entry(path)158 rush = _rush_entry(path)
158 project["rushes"].append(rush)159 project["rushes"].append(rush)
159 transcribe_rush(project, rush)160 transcribe_rush(project, rush)
160- if project["derush"].get("clips") is not None and rush.get("duration"):
161- project["derush"]["clips"].append({"id": "c" + uuid.uuid4().hex[:8], "rush": rush["id"],
162- "in": 0.0, "out": rush["duration"]})
163 service.start_step(project, "tournage")161 service.start_step(project, "tournage")
164 service.start_step(project, "derush")162 service.start_step(project, "derush")
165 if project["rush_sort"] == "Heure":163 if project["rush_sort"] == "Heure":
@@ -183,8 +181,9 @@ def remove_rush(project: dict, rush_id: str) -> None:
183 prefix = rush_id + ":"181 prefix = rush_id + ":"
184 project["derush"]["cuts"] = {k: v for k, v in project["derush"]["cuts"].items() if not k.startswith(prefix)}182 project["derush"]["cuts"] = {k: v for k, v in project["derush"]["cuts"].items() if not k.startswith(prefix)}
185 project["derush"].get("splits", {}).pop(rush_id, None)183 project["derush"].get("splits", {}).pop(rush_id, None)
186- if project["derush"].get("clips") is not None:184+ for tl in project["derush"]["timelines"]:
187- project["derush"]["clips"] = [c for c in project["derush"]["clips"] if c["rush"] != rush_id]185+ if tl["clips"] is not None:
186+ tl["clips"] = [c for c in tl["clips"] if c.get("rush") != rush_id]
188 path = Path(rush["path"])187 path = Path(rush["path"])
189 # le fichier n'est supprimé que s'il a été copié dans le projet188 # le fichier n'est supprimé que s'il a été copié dans le projet
190 if project_dir(project["id"]) in path.parents:189 if project_dir(project["id"]) in path.parents:
@@ -278,7 +277,6 @@ def sort_rushes(project: dict, mode: str) -> None:
278 if s["type"] == "speech" and s.get("sec") and s["rush"] not in first:277 if s["type"] == "speech" and s.get("sec") and s["rush"] not in first:
279 first[s["rush"]] = int(s["sec"][1:])278 first[s["rush"]] = int(s["sec"][1:])
280 project["rushes"].sort(key=lambda r: first.get(r["id"], 10_000))279 project["rushes"].sort(key=lambda r: first.get(r["id"], 10_000))
281- order_clips(project)
282 280
283 281
284 # --- Dérush ------------------------------------------------------------------------282 # --- Dérush ------------------------------------------------------------------------
@@ -310,13 +308,21 @@ def analysis(project: dict, edited: bool = True) -> dict:
310 [{"rush": r["id"], "status": "done", "progress": 1.0} for r in project["rushes"]]}308 [{"rush": r["id"], "status": "done", "progress": 1.0} for r in project["rushes"]]}
311 309
312 310
313-def clips(project: dict) -> list[dict]:311+def timeline(project: dict, tid: str) -> dict | None:
314- """La timeline du dérush : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture.312+ return next((t for t in project["derush"]["timelines"] if t["id"] == tid), None)
315- Tant que l'utilisateur n'y a pas touché, elle découle des anciennes coupes (ou des rushes entiers)."""313+
314+
315+def own_clips(project: dict, tid: str) -> list[dict]:
316+ """Les bouts d'une timeline, tels qu'on les édite : de rushes ({rush}) ou de timelines imbriquées ({tl}).
317+ Tant que l'utilisateur n'a pas touché à la globale, elle découle des anciennes coupes (ou des rushes entiers)."""
316 known = {r["id"] for r in project["rushes"]}318 known = {r["id"] for r in project["rushes"]}
317- stored = project["derush"].get("clips")319+ tl = timeline(project, tid)
318- if stored is not None:320+ if tl is None:
319- return [c for c in stored if c["rush"] in known]321+ return []
322+ if tl["clips"] is not None:
323+ return [c for c in tl["clips"] if c.get("tl") or c.get("rush") in known]
324+ if tid != project["derush"]["main"]:
325+ return []
320 found = analysis(project)326 found = analysis(project)
321 if found["ready"]:327 if found["ready"]:
322 return [{"id": f"c{i}", "rush": r["rush"], "in": r["start"], "out": r["end"]}328 return [{"id": f"c{i}", "rush": r["rush"], "in": r["start"], "out": r["end"]}
@@ -325,13 +331,29 @@ def clips(project: dict) -> list[dict]:
325 for i, r in enumerate(project["rushes"]) if r.get("duration")]331 for i, r in enumerate(project["rushes"]) if r.get("duration")]
326 332
327 333
328-def order_clips(project: dict) -> None:334+def flatten(project: dict, tid: str, seen: frozenset = frozenset()) -> list[dict]:
329- """Après un changement d'ordre des rushes : les bouts suivent, rush par rush."""335+ """Les bouts de rushes lus par une timeline, ses timelines imbriquées dépliées (sur la plage [in, out] de chacune)."""
330- stored = project["derush"].get("clips")336+ out: list[dict] = []
331- if stored is None:337+ for c in own_clips(project, tid):
332- return338+ if c.get("rush"):
333- rank = {r["id"]: i for i, r in enumerate(project["rushes"])}339+ out.append(c)
334- stored.sort(key=lambda c: rank.get(c["rush"], len(rank)))340+ continue
341+ if c["tl"] in seen or c["tl"] == tid:
342+ continue
343+ cursor = 0.0
344+ for sub in flatten(project, c["tl"], seen | {tid}):
345+ length = sub["out"] - sub["in"]
346+ a, b = max(c["in"], cursor), min(c["out"], cursor + length)
347+ if b - a > 0.001:
348+ out.append({"id": c["id"], "rush": sub["rush"], "in": round(sub["in"] + a - cursor, 3),
349+ "out": round(sub["in"] + b - cursor, 3)})
350+ cursor += length
351+ return out
352+
353+
354+def clips(project: dict) -> list[dict]:
355+ """La timeline globale du dérush, dépliée : des bouts de rushes {id, rush, in, out}, dans l'ordre de lecture."""
356+ return flatten(project, project["derush"]["main"])
335 357
336 358
337 def ranges(project: dict) -> list[dict]:359 def ranges(project: dict) -> list[dict]:
modified backend/studio/modules/projects/service.py +61 -12
@@ -74,6 +74,7 @@ STEPS = ("angle", "titre", "vignette", "script", "roast", "tournage", "derush",
7474 NEXT = {"transcript": ("storyboard", "selection"), "son": ("rendu",), "soustitres": ("rendu",)}
7575 DURATIONS = ("< 5 min", "5–10 min", "10–20 min", "20–40 min", "40 min +")
7676
77+MAIN_TIMELINE = "main"
7778 VIDEO_EXTENSIONS = ("mov", "mp4", "mxf", "mkv", "avi", "webm", "m4v", "mts")
7879
7980 _UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
@@ -107,7 +108,23 @@ def exists(pid: str) -> bool:
107108
108109
109110 def load(pid: str) -> dict:
110- return json.loads((project_dir(pid) / PROJECT_FILE).read_text(encoding="utf-8"))
111+ project = json.loads((project_dir(pid) / PROJECT_FILE).read_text(encoding="utf-8"))
112+ _upgrade_derush(project)
113+ return project
114+
115+
116+def empty_derush() -> dict:
117+ """timelines : les timelines du dérush ({id, name, clips}) ; `main` : la timeline globale, celle qu'on
118+ exporte et qui devient la vidéo source. `clips` à None : la timeline découle des rushes (voir runtime.clips)."""
119+ return {"cuts": {}, "splits": {}, "timelines": [{"id": MAIN_TIMELINE, "name": "", "clips": None}], "main": MAIN_TIMELINE}
120+
121+
122+def _upgrade_derush(project: dict) -> None:
123+ """Avant les timelines multiples, le dérush n'avait qu'une liste de bouts (derush.clips)."""
124+ edits = project.get("derush")
125+ if edits is not None and "timelines" not in edits:
126+ edits["timelines"] = [{"id": MAIN_TIMELINE, "name": "", "clips": edits.pop("clips", None)}]
127+ edits["main"] = MAIN_TIMELINE
111128
112129
113130 def _write(project: dict) -> None:
@@ -222,7 +239,7 @@ def create(mode: str, title: str, brief: dict, pid: str | None = None) -> dict:
222239 "research": [_source_from_link(link) for link in brief["links"]],
223240 "shots": [],
224241 "rushes": [], "rush_sort": "Script",
225- "derush": {"cuts": {}, "splits": {}, "clips": None},
242+ "derush": empty_derush(),
226243 "source": None,
227244 "assets": empty_assets(),
228245 "jobs": {}, "copies": [],
@@ -292,7 +309,7 @@ def storage(pid: str) -> dict[str, int]:
292309
293310 # ce que l'interface peut remplacer tel quel
294311 EDITABLE = {"title", "status", "brief", "angle_sel", "titles", "thumbs", "thumb_sel", "research", "shots",
295- "rush_sort", "rush_order", "cuts", "splits", "clips", "capture", "opened_at", "publish"}
312+ "rush_sort", "rush_order", "cuts", "splits", "timelines", "capture", "opened_at", "publish"}
296313
297314
298315 def patch(pid: str, changes: dict) -> dict:
@@ -303,21 +320,14 @@ def patch(pid: str, changes: dict) -> dict:
303320 for key, value in changes.items():
304321 if key in ("cuts", "splits"):
305322 project["derush"][key] = value
306- elif key == "clips":
307- known = {r["id"] for r in project["rushes"]}
308- project["derush"]["clips"] = [
309- {"id": str(c["id"]), "rush": c["rush"], "in": round(float(c["in"]), 3), "out": round(float(c["out"]), 3)}
310- for c in value if c.get("rush") in known and float(c["out"]) > float(c["in"])]
323+ elif key == "timelines":
324+ project["derush"]["timelines"] = _clean_timelines(project, value)
311325 elif key == "capture":
312326 project.setdefault("assets", empty_assets())["capture"] = value
313327 elif key == "rush_order":
314328 by_id = {r["id"]: r for r in project["rushes"]}
315329 ordered = [by_id.pop(i) for i in value if i in by_id]
316330 project["rushes"] = ordered + list(by_id.values())
317- # les bouts de la timeline suivent l'ordre des rushes (sauf s'ils arrivent avec)
318- if project["derush"].get("clips") is not None and "clips" not in changes:
319- rank = {r["id"]: i for i, r in enumerate(project["rushes"])}
320- project["derush"]["clips"].sort(key=lambda c: rank.get(c["rush"], len(rank)))
321331 elif key == "titles" and sum(1 for t in value if t.get("pick")) > MAX_TITLES:
322332 raise ValueError(f"{MAX_TITLES} titres retenus au plus")
323333 elif key == "title":
@@ -335,6 +345,45 @@ def patch(pid: str, changes: dict) -> dict:
335345 return project
336346
337347
348+def _clean_timelines(project: dict, value: list) -> list[dict]:
349+ """Les timelines envoyées par l'interface : bouts de rushes connus ({rush}) ou timelines imbriquées ({tl}),
350+ sans boucle (une timeline ne peut pas se contenir, même de loin). La globale ne disparaît jamais."""
351+ rushes = {r["id"] for r in project["rushes"]}
352+ ids = [str(t["id"]) for t in value]
353+ out: list[dict] = []
354+ for tl in value:
355+ clips = tl.get("clips")
356+ if clips is not None:
357+ kept = []
358+ for c in clips:
359+ a, b = round(float(c["in"]), 3), round(float(c["out"]), 3)
360+ if b <= a:
361+ continue
362+ if c.get("rush") in rushes:
363+ kept.append({"id": str(c["id"]), "rush": c["rush"], "in": a, "out": b})
364+ elif c.get("tl") in ids and c["tl"] != tl["id"]:
365+ kept.append({"id": str(c["id"]), "tl": c["tl"], "in": a, "out": b})
366+ clips = kept
367+ out.append({"id": str(tl["id"]), "name": str(tl.get("name") or "").strip(), "clips": clips})
368+ main = project["derush"]["main"]
369+ if main not in ids:
370+ out.insert(0, next(t for t in project["derush"]["timelines"] if t["id"] == main))
371+ # une imbrication qui ferait boucle est retirée
372+ by_id = {t["id"]: t for t in out}
373+
374+ def reaches(start: str, target: str, seen: set[str]) -> bool:
375+ for c in by_id[start]["clips"] or []:
376+ sub = c.get("tl")
377+ if sub == target or (sub and sub not in seen and reaches(sub, target, seen | {sub})):
378+ return True
379+ return False
380+
381+ for tl in out:
382+ if tl["clips"]:
383+ tl["clips"] = [c for c in tl["clips"] if not (c.get("tl") and reaches(c["tl"], tl["id"], {c["tl"]}))]
384+ return out
385+
386+
338387 def complete_step(project: dict, step: str, done: bool) -> None:
339388 """« Marquer comme terminé » : les étapes suivantes passent en cours."""
340389 status = project["status"]
@@ -74,6 +74,7 @@ STEPS = ("angle", "titre", "vignette", "script", "roast", "tournage", "derush",
74 NEXT = {"transcript": ("storyboard", "selection"), "son": ("rendu",), "soustitres": ("rendu",)}74 NEXT = {"transcript": ("storyboard", "selection"), "son": ("rendu",), "soustitres": ("rendu",)}
75 DURATIONS = ("< 5 min", "5–10 min", "10–20 min", "20–40 min", "40 min +")75 DURATIONS = ("< 5 min", "5–10 min", "10–20 min", "20–40 min", "40 min +")
76 76
77+MAIN_TIMELINE = "main"
77 VIDEO_EXTENSIONS = ("mov", "mp4", "mxf", "mkv", "avi", "webm", "m4v", "mts")78 VIDEO_EXTENSIONS = ("mov", "mp4", "mxf", "mkv", "avi", "webm", "m4v", "mts")
78 79
79 _UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')80 _UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
@@ -107,7 +108,23 @@ def exists(pid: str) -> bool:
107 108
108 109
109 def load(pid: str) -> dict:110 def load(pid: str) -> dict:
110- return json.loads((project_dir(pid) / PROJECT_FILE).read_text(encoding="utf-8"))111+ project = json.loads((project_dir(pid) / PROJECT_FILE).read_text(encoding="utf-8"))
112+ _upgrade_derush(project)
113+ return project
114+
115+
116+def empty_derush() -> dict:
117+ """timelines : les timelines du dérush ({id, name, clips}) ; `main` : la timeline globale, celle qu'on
118+ exporte et qui devient la vidéo source. `clips` à None : la timeline découle des rushes (voir runtime.clips)."""
119+ return {"cuts": {}, "splits": {}, "timelines": [{"id": MAIN_TIMELINE, "name": "", "clips": None}], "main": MAIN_TIMELINE}
120+
121+
122+def _upgrade_derush(project: dict) -> None:
123+ """Avant les timelines multiples, le dérush n'avait qu'une liste de bouts (derush.clips)."""
124+ edits = project.get("derush")
125+ if edits is not None and "timelines" not in edits:
126+ edits["timelines"] = [{"id": MAIN_TIMELINE, "name": "", "clips": edits.pop("clips", None)}]
127+ edits["main"] = MAIN_TIMELINE
111 128
112 129
113 def _write(project: dict) -> None:130 def _write(project: dict) -> None:
@@ -222,7 +239,7 @@ def create(mode: str, title: str, brief: dict, pid: str | None = None) -> dict:
222 "research": [_source_from_link(link) for link in brief["links"]],239 "research": [_source_from_link(link) for link in brief["links"]],
223 "shots": [],240 "shots": [],
224 "rushes": [], "rush_sort": "Script",241 "rushes": [], "rush_sort": "Script",
225- "derush": {"cuts": {}, "splits": {}, "clips": None},242+ "derush": empty_derush(),
226 "source": None,243 "source": None,
227 "assets": empty_assets(),244 "assets": empty_assets(),
228 "jobs": {}, "copies": [],245 "jobs": {}, "copies": [],
@@ -292,7 +309,7 @@ def storage(pid: str) -> dict[str, int]:
292 309
293 # ce que l'interface peut remplacer tel quel310 # ce que l'interface peut remplacer tel quel
294 EDITABLE = {"title", "status", "brief", "angle_sel", "titles", "thumbs", "thumb_sel", "research", "shots",311 EDITABLE = {"title", "status", "brief", "angle_sel", "titles", "thumbs", "thumb_sel", "research", "shots",
295- "rush_sort", "rush_order", "cuts", "splits", "clips", "capture", "opened_at", "publish"}312+ "rush_sort", "rush_order", "cuts", "splits", "timelines", "capture", "opened_at", "publish"}
296 313
297 314
298 def patch(pid: str, changes: dict) -> dict:315 def patch(pid: str, changes: dict) -> dict:
@@ -303,21 +320,14 @@ def patch(pid: str, changes: dict) -> dict:
303 for key, value in changes.items():320 for key, value in changes.items():
304 if key in ("cuts", "splits"):321 if key in ("cuts", "splits"):
305 project["derush"][key] = value322 project["derush"][key] = value
306- elif key == "clips":323+ elif key == "timelines":
307- known = {r["id"] for r in project["rushes"]}324+ project["derush"]["timelines"] = _clean_timelines(project, value)
308- project["derush"]["clips"] = [
309- {"id": str(c["id"]), "rush": c["rush"], "in": round(float(c["in"]), 3), "out": round(float(c["out"]), 3)}
310- for c in value if c.get("rush") in known and float(c["out"]) > float(c["in"])]
311 elif key == "capture":325 elif key == "capture":
312 project.setdefault("assets", empty_assets())["capture"] = value326 project.setdefault("assets", empty_assets())["capture"] = value
313 elif key == "rush_order":327 elif key == "rush_order":
314 by_id = {r["id"]: r for r in project["rushes"]}328 by_id = {r["id"]: r for r in project["rushes"]}
315 ordered = [by_id.pop(i) for i in value if i in by_id]329 ordered = [by_id.pop(i) for i in value if i in by_id]
316 project["rushes"] = ordered + list(by_id.values())330 project["rushes"] = ordered + list(by_id.values())
317- # les bouts de la timeline suivent l'ordre des rushes (sauf s'ils arrivent avec)
318- if project["derush"].get("clips") is not None and "clips" not in changes:
319- rank = {r["id"]: i for i, r in enumerate(project["rushes"])}
320- project["derush"]["clips"].sort(key=lambda c: rank.get(c["rush"], len(rank)))
321 elif key == "titles" and sum(1 for t in value if t.get("pick")) > MAX_TITLES:331 elif key == "titles" and sum(1 for t in value if t.get("pick")) > MAX_TITLES:
322 raise ValueError(f"{MAX_TITLES} titres retenus au plus")332 raise ValueError(f"{MAX_TITLES} titres retenus au plus")
323 elif key == "title":333 elif key == "title":
@@ -335,6 +345,45 @@ def patch(pid: str, changes: dict) -> dict:
335 return project345 return project
336 346
337 347
348+def _clean_timelines(project: dict, value: list) -> list[dict]:
349+ """Les timelines envoyées par l'interface : bouts de rushes connus ({rush}) ou timelines imbriquées ({tl}),
350+ sans boucle (une timeline ne peut pas se contenir, même de loin). La globale ne disparaît jamais."""
351+ rushes = {r["id"] for r in project["rushes"]}
352+ ids = [str(t["id"]) for t in value]
353+ out: list[dict] = []
354+ for tl in value:
355+ clips = tl.get("clips")
356+ if clips is not None:
357+ kept = []
358+ for c in clips:
359+ a, b = round(float(c["in"]), 3), round(float(c["out"]), 3)
360+ if b <= a:
361+ continue
362+ if c.get("rush") in rushes:
363+ kept.append({"id": str(c["id"]), "rush": c["rush"], "in": a, "out": b})
364+ elif c.get("tl") in ids and c["tl"] != tl["id"]:
365+ kept.append({"id": str(c["id"]), "tl": c["tl"], "in": a, "out": b})
366+ clips = kept
367+ out.append({"id": str(tl["id"]), "name": str(tl.get("name") or "").strip(), "clips": clips})
368+ main = project["derush"]["main"]
369+ if main not in ids:
370+ out.insert(0, next(t for t in project["derush"]["timelines"] if t["id"] == main))
371+ # une imbrication qui ferait boucle est retirée
372+ by_id = {t["id"]: t for t in out}
373+
374+ def reaches(start: str, target: str, seen: set[str]) -> bool:
375+ for c in by_id[start]["clips"] or []:
376+ sub = c.get("tl")
377+ if sub == target or (sub and sub not in seen and reaches(sub, target, seen | {sub})):
378+ return True
379+ return False
380+
381+ for tl in out:
382+ if tl["clips"]:
383+ tl["clips"] = [c for c in tl["clips"] if not (c.get("tl") and reaches(c["tl"], tl["id"], {c["tl"]}))]
384+ return out
385+
386+
338 def complete_step(project: dict, step: str, done: bool) -> None:387 def complete_step(project: dict, step: str, done: bool) -> None:
339 """« Marquer comme terminé » : les étapes suivantes passent en cours."""388 """« Marquer comme terminé » : les étapes suivantes passent en cours."""
340 status = project["status"]389 status = project["status"]
modified web/src/i18n/locales/en/derush.ts +30 -19
@@ -3,7 +3,7 @@ export default {
33 ready: "{raw} of footage → {kept} kept",
44 raw: "no clips · {raw} | 1 clip · {raw} | {n} clips · {raw}",
55 },
6- empty: "No clips yet. Import them on the right to do the rough cut here.",
6+ empty: "No clips yet. Import them into the media pool to do the rough cut here.",
77 transcript: "Transcript",
88 hint: "Click: go to the word · select text then Delete to remove it",
99 untranscribed: "untranscribed sound · {d} s",
@@ -48,31 +48,41 @@ export default {
4848 retake: "Bad take",
4949 cut: "Cut",
5050 },
51- panel: {
52- title: "Clips",
51+ pool: {
5352 import: "Import",
54- order: "Order",
5553 sorts: {
56- script: "Script",
5754 time: "Shooting time",
58- manual: "Manual",
55+ name: "Name",
5956 },
60- note: "Clips play in this order. The shot is detected from the script text.",
61- transcribingShort: "transcribing…",
62- shotNotDetected: "shot not detected",
63- error: "error",
64- queued: "queued",
65- transcription: "Transcribing {p}%",
57+ placeAll: "Add all to the timeline",
58+ placeSelected: "Add {n} clip to the timeline | Add {n} clip to the timeline | Add {n} clips to the timeline",
59+ placeTitle: "Add the selected clips (or all of them) at the end of the open timeline, by shooting time",
60+ newTimeline: "+ Timeline",
61+ newTimelineTitle: "New timeline, with the selected clips (or empty)",
62+ timelines: "Timelines",
63+ rushes: "Clips · {n}",
64+ openNow: "Open timeline",
65+ openTimeline: "Open this timeline · drag it onto the open timeline to nest it",
66+ nest: "Nest this timeline at the end of the open timeline",
67+ drop: "Import media",
6668 shotAt: "shot at {time}",
67- removeConfirm: "Remove?",
68- removeTitle: "Remove this clip from the project",
69+ queued: "queued",
70+ error: "error",
71+ used: "Already on the open timeline",
6972 retranscribe: "Transcribe this clip again (cuts are kept)",
70- moveUp: "Move up",
71- moveDown: "Move down",
73+ removeConfirm: "Remove?",
74+ removeTitle: "Remove this clip from the project (and from every timeline)",
75+ append: "Add this whole clip at the end of the open timeline",
7276 importing: "Importing {p}%",
73- drop: "Drop the memory card files here",
74- append: "+ timeline",
75- appendTitle: "Add this whole clip at the end of the timeline",
77+ },
78+ timelines: {
79+ main: "Main",
80+ global: "global",
81+ defaultName: "Timeline {n}",
82+ newTitle: "New empty timeline",
83+ renameTitle: "Double-click to rename",
84+ removeConfirm: "Delete?",
85+ removeTitle: "Delete this timeline (and where it is nested in others)",
7686 },
7787 auto: {
7888 intro: "Daryl reads every clip and proposes a full rough cut: silences, stutters, bad takes, false starts and on-set remarks. Nothing is cut until you apply his proposals.",
@@ -131,6 +141,7 @@ export default {
131141 },
132142 timeline: {
133143 trim: "Drag to trim or extend (Alt: no snapping)",
144+ nested: "Nested timeline “{name}” · double-click to open it",
134145 },
135146 shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel zoom",
136147 };
@@ -3,7 +3,7 @@ export default {
3 ready: "{raw} of footage → {kept} kept",3 ready: "{raw} of footage → {kept} kept",
4 raw: "no clips · {raw} | 1 clip · {raw} | {n} clips · {raw}",4 raw: "no clips · {raw} | 1 clip · {raw} | {n} clips · {raw}",
5 },5 },
6- empty: "No clips yet. Import them on the right to do the rough cut here.",6+ empty: "No clips yet. Import them into the media pool to do the rough cut here.",
7 transcript: "Transcript",7 transcript: "Transcript",
8 hint: "Click: go to the word · select text then Delete to remove it",8 hint: "Click: go to the word · select text then Delete to remove it",
9 untranscribed: "untranscribed sound · {d} s",9 untranscribed: "untranscribed sound · {d} s",
@@ -48,31 +48,41 @@ export default {
48 retake: "Bad take",48 retake: "Bad take",
49 cut: "Cut",49 cut: "Cut",
50 },50 },
51- panel: {51+ pool: {
52- title: "Clips",
53 import: "Import",52 import: "Import",
54- order: "Order",
55 sorts: {53 sorts: {
56- script: "Script",
57 time: "Shooting time",54 time: "Shooting time",
58- manual: "Manual",55+ name: "Name",
59 },56 },
60- note: "Clips play in this order. The shot is detected from the script text.",57+ placeAll: "Add all to the timeline",
61- transcribingShort: "transcribing…",58+ placeSelected: "Add {n} clip to the timeline | Add {n} clip to the timeline | Add {n} clips to the timeline",
62- shotNotDetected: "shot not detected",59+ placeTitle: "Add the selected clips (or all of them) at the end of the open timeline, by shooting time",
63- error: "error",60+ newTimeline: "+ Timeline",
64- queued: "queued",61+ newTimelineTitle: "New timeline, with the selected clips (or empty)",
65- transcription: "Transcribing {p}%",62+ timelines: "Timelines",
63+ rushes: "Clips · {n}",
64+ openNow: "Open timeline",
65+ openTimeline: "Open this timeline · drag it onto the open timeline to nest it",
66+ nest: "Nest this timeline at the end of the open timeline",
67+ drop: "Import media",
66 shotAt: "shot at {time}",68 shotAt: "shot at {time}",
67- removeConfirm: "Remove?",69+ queued: "queued",
68- removeTitle: "Remove this clip from the project",70+ error: "error",
71+ used: "Already on the open timeline",
69 retranscribe: "Transcribe this clip again (cuts are kept)",72 retranscribe: "Transcribe this clip again (cuts are kept)",
70- moveUp: "Move up",73+ removeConfirm: "Remove?",
71- moveDown: "Move down",74+ removeTitle: "Remove this clip from the project (and from every timeline)",
75+ append: "Add this whole clip at the end of the open timeline",
72 importing: "Importing {p}%",76 importing: "Importing {p}%",
73- drop: "Drop the memory card files here",77+ },
74- append: "+ timeline",78+ timelines: {
75- appendTitle: "Add this whole clip at the end of the timeline",79+ main: "Main",
80+ global: "global",
81+ defaultName: "Timeline {n}",
82+ newTitle: "New empty timeline",
83+ renameTitle: "Double-click to rename",
84+ removeConfirm: "Delete?",
85+ removeTitle: "Delete this timeline (and where it is nested in others)",
76 },86 },
77 auto: {87 auto: {
78 intro: "Daryl reads every clip and proposes a full rough cut: silences, stutters, bad takes, false starts and on-set remarks. Nothing is cut until you apply his proposals.",88 intro: "Daryl reads every clip and proposes a full rough cut: silences, stutters, bad takes, false starts and on-set remarks. Nothing is cut until you apply his proposals.",
@@ -131,6 +141,7 @@ export default {
131 },141 },
132 timeline: {142 timeline: {
133 trim: "Drag to trim or extend (Alt: no snapping)",143 trim: "Drag to trim or extend (Alt: no snapping)",
144+ nested: "Nested timeline “{name}” · double-click to open it",
134 },145 },
135 shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel zoom",146 shortcuts: "Drag a piece to move it · drag its edges to trim · B blade, A select · Delete remove · S split here · ← → frame (Shift: second) · ↑ ↓ prev. / next piece · I / O range · Ctrl + Z undo · Ctrl + wheel zoom",
136 };147 };
modified web/src/i18n/locales/fr/derush.ts +30 -19
@@ -3,7 +3,7 @@ export default {
33 ready: "{raw} de rushes → {kept} gardées",
44 raw: "aucun rush · {raw} | {n} rush · {raw} | {n} rushes · {raw}",
55 },
6- empty: "Aucun rush pour l’instant. Importe-les à droite pour les dérusher ici.",
6+ empty: "Aucun rush pour l’instant. Importe-les dans la médiathèque pour les dérusher ici.",
77 transcript: "Transcript",
88 hint: "Clic : aller au mot · sélectionne du texte puis Suppr pour le retirer",
99 untranscribed: "son non transcrit · {d} s",
@@ -48,31 +48,41 @@ export default {
4848 retake: "Prise ratée",
4949 cut: "Coupé",
5050 },
51- panel: {
52- title: "Rushes",
51+ pool: {
5352 import: "Importer",
54- order: "Ordre",
5553 sorts: {
56- script: "Script",
5754 time: "Heure de tournage",
58- manual: "Manuel",
55+ name: "Nom",
5956 },
60- note: "Les rushes sont lus dans cet ordre. Le plan est détecté d’après le texte du script.",
61- transcribingShort: "transcription…",
62- shotNotDetected: "plan non détecté",
63- error: "erreur",
64- queued: "en attente",
65- transcription: "Transcription {p} %",
57+ placeAll: "Tout poser sur la timeline",
58+ placeSelected: "Poser {n} rush sur la timeline | Poser {n} rush sur la timeline | Poser {n} rushes sur la timeline",
59+ placeTitle: "Ajoute les rushes sélectionnés (sinon tous) à la fin de la timeline ouverte, par heure de tournage",
60+ newTimeline: "+ Timeline",
61+ newTimelineTitle: "Nouvelle timeline, avec les rushes sélectionnés (sinon vide)",
62+ timelines: "Timelines",
63+ rushes: "Rushes · {n}",
64+ openNow: "Timeline ouverte",
65+ openTimeline: "Ouvrir cette timeline · glisse-la sur la timeline ouverte pour l’y imbriquer",
66+ nest: "Imbriquer cette timeline à la fin de la timeline ouverte",
67+ drop: "Importer un média",
6668 shotAt: "tourné à {time}",
67- removeConfirm: "Retirer ?",
68- removeTitle: "Retirer ce rush du projet",
69+ queued: "en attente",
70+ error: "erreur",
71+ used: "Déjà sur la timeline ouverte",
6972 retranscribe: "Refaire la transcription de ce rush (les coupes sont gardées)",
70- moveUp: "Monter",
71- moveDown: "Descendre",
73+ removeConfirm: "Retirer ?",
74+ removeTitle: "Retirer ce rush du projet (et de toutes les timelines)",
75+ append: "Ajouter ce rush entier à la fin de la timeline ouverte",
7276 importing: "Import {p} %",
73- drop: "Glisse les fichiers de la carte mémoire ici",
74- append: "+ timeline",
75- appendTitle: "Ajouter ce rush entier à la fin de la timeline",
77+ },
78+ timelines: {
79+ main: "Principale",
80+ global: "globale",
81+ defaultName: "Timeline {n}",
82+ newTitle: "Nouvelle timeline vide",
83+ renameTitle: "Double-clic pour renommer",
84+ removeConfirm: "Supprimer ?",
85+ removeTitle: "Supprimer cette timeline (et ses imbrications dans les autres)",
7686 },
7787 auto: {
7888 intro: "Daryl lit tous les rushes et propose un dérush complet : silences, bégaiements, prises ratées, faux départs et indications de tournage. Rien n’est coupé tant que tu n’as pas appliqué ses propositions.",
@@ -131,6 +141,7 @@ export default {
131141 },
132142 timeline: {
133143 trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)",
144+ nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir",
134145 },
135146 shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette zoom",
136147 };
@@ -3,7 +3,7 @@ export default {
3 ready: "{raw} de rushes → {kept} gardées",3 ready: "{raw} de rushes → {kept} gardées",
4 raw: "aucun rush · {raw} | {n} rush · {raw} | {n} rushes · {raw}",4 raw: "aucun rush · {raw} | {n} rush · {raw} | {n} rushes · {raw}",
5 },5 },
6- empty: "Aucun rush pour l’instant. Importe-les à droite pour les dérusher ici.",6+ empty: "Aucun rush pour l’instant. Importe-les dans la médiathèque pour les dérusher ici.",
7 transcript: "Transcript",7 transcript: "Transcript",
8 hint: "Clic : aller au mot · sélectionne du texte puis Suppr pour le retirer",8 hint: "Clic : aller au mot · sélectionne du texte puis Suppr pour le retirer",
9 untranscribed: "son non transcrit · {d} s",9 untranscribed: "son non transcrit · {d} s",
@@ -48,31 +48,41 @@ export default {
48 retake: "Prise ratée",48 retake: "Prise ratée",
49 cut: "Coupé",49 cut: "Coupé",
50 },50 },
51- panel: {51+ pool: {
52- title: "Rushes",
53 import: "Importer",52 import: "Importer",
54- order: "Ordre",
55 sorts: {53 sorts: {
56- script: "Script",
57 time: "Heure de tournage",54 time: "Heure de tournage",
58- manual: "Manuel",55+ name: "Nom",
59 },56 },
60- note: "Les rushes sont lus dans cet ordre. Le plan est détecté d’après le texte du script.",57+ placeAll: "Tout poser sur la timeline",
61- transcribingShort: "transcription…",58+ placeSelected: "Poser {n} rush sur la timeline | Poser {n} rush sur la timeline | Poser {n} rushes sur la timeline",
62- shotNotDetected: "plan non détecté",59+ placeTitle: "Ajoute les rushes sélectionnés (sinon tous) à la fin de la timeline ouverte, par heure de tournage",
63- error: "erreur",60+ newTimeline: "+ Timeline",
64- queued: "en attente",61+ newTimelineTitle: "Nouvelle timeline, avec les rushes sélectionnés (sinon vide)",
65- transcription: "Transcription {p} %",62+ timelines: "Timelines",
63+ rushes: "Rushes · {n}",
64+ openNow: "Timeline ouverte",
65+ openTimeline: "Ouvrir cette timeline · glisse-la sur la timeline ouverte pour l’y imbriquer",
66+ nest: "Imbriquer cette timeline à la fin de la timeline ouverte",
67+ drop: "Importer un média",
66 shotAt: "tourné à {time}",68 shotAt: "tourné à {time}",
67- removeConfirm: "Retirer ?",69+ queued: "en attente",
68- removeTitle: "Retirer ce rush du projet",70+ error: "erreur",
71+ used: "Déjà sur la timeline ouverte",
69 retranscribe: "Refaire la transcription de ce rush (les coupes sont gardées)",72 retranscribe: "Refaire la transcription de ce rush (les coupes sont gardées)",
70- moveUp: "Monter",73+ removeConfirm: "Retirer ?",
71- moveDown: "Descendre",74+ removeTitle: "Retirer ce rush du projet (et de toutes les timelines)",
75+ append: "Ajouter ce rush entier à la fin de la timeline ouverte",
72 importing: "Import {p} %",76 importing: "Import {p} %",
73- drop: "Glisse les fichiers de la carte mémoire ici",77+ },
74- append: "+ timeline",78+ timelines: {
75- appendTitle: "Ajouter ce rush entier à la fin de la timeline",79+ main: "Principale",
80+ global: "globale",
81+ defaultName: "Timeline {n}",
82+ newTitle: "Nouvelle timeline vide",
83+ renameTitle: "Double-clic pour renommer",
84+ removeConfirm: "Supprimer ?",
85+ removeTitle: "Supprimer cette timeline (et ses imbrications dans les autres)",
76 },86 },
77 auto: {87 auto: {
78 intro: "Daryl lit tous les rushes et propose un dérush complet : silences, bégaiements, prises ratées, faux départs et indications de tournage. Rien n’est coupé tant que tu n’as pas appliqué ses propositions.",88 intro: "Daryl lit tous les rushes et propose un dérush complet : silences, bégaiements, prises ratées, faux départs et indications de tournage. Rien n’est coupé tant que tu n’as pas appliqué ses propositions.",
@@ -131,6 +141,7 @@ export default {
131 },141 },
132 timeline: {142 timeline: {
133 trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)",143 trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)",
144+ nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir",
134 },145 },
135 shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette zoom",146 shortcuts: "Glisse un bout pour le déplacer · tire ses bords pour le rogner · B lame, A sélection · Suppr supprimer · S couper ici · ← → image (Maj : seconde) · ↑ ↓ bout préc. / suiv. · I / O plage · Ctrl + Z annuler · Ctrl + molette zoom",
136 };147 };
modified web/src/project/steps/DerushStep.vue +82 -23
@@ -1,21 +1,23 @@
11 <script setup lang="ts">
22 /**
33 * Dérush, façon logiciel de montage : le lecteur, le transcript de la timeline (où l'on retire phrases,
4- * mots et silences), la liste des rushes ou le dérush proposé par Daryl, et la timeline : des bouts de rushes qu'on déplace, coupe,
5- * rogne ou supprime.
4+ * mots et silences), la médiathèque (rushes et timelines) ou le dérush proposé par Daryl, et la timeline : des bouts
5+ * de rushes qu'on déplace, coupe, rogne ou supprime. Plusieurs timelines : on dérushe tout d'un coup ou séquence par
6+ * séquence, et la globale (celle qu'on exporte) peut contenir les autres.
67 */
7-import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
8+import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
89 import { useI18n } from "vue-i18n";
910 import { useRouter } from "vue-router";
1011 import { api } from "../../lib/api";
1112 import { mmss } from "../../lib/format";
1213 import { toast } from "../../lib/toast";
14+import ConfirmX from "../../components/ConfirmX.vue";
1315 import { act, failed, running, store } from "../store";
14-import type { AutoCut, DerushClip, DerushSeg } from "../types";
16+import type { AutoCut, DerushClip, DerushSeg, TimelineClip } from "../types";
1517 import AutoDerush from "./derush/AutoDerush.vue";
1618 import { clipAt, clipLen, move, offsets, remove, removeRange, removeRushRanges, splitAt, timeOf, trim, type Selection } from "./derush/clips";
19+import MediaPool from "./derush/MediaPool.vue";
1720 import PlayerFrame from "./derush/PlayerFrame.vue";
18-import RushPanel from "./derush/RushPanel.vue";
1921 import Timeline, { type Peaks } from "./derush/Timeline.vue";
2022 import Transcript from "./derush/Transcript.vue";
2123 import { useDerush } from "./derush/useDerush";
@@ -26,24 +28,56 @@ const router = useRouter();
2628 const project = computed(() => store.project!);
2729 const base = computed(() => `/api/projects/${project.value.id}`);
2830 const derush = useDerush(project);
29-const { analysis, ready, segsByRush, clips, kept, transcribing, progress } = derush;
30-
31-const names = computed(() => Object.fromEntries(project.value.rushes.map((r) => [r.id, r.name])));
32-const durations = computed(() => Object.fromEntries(project.value.rushes.map((r) => [r.id, r.duration ?? 0])));
31+const { analysis, ready, segsByRush, clips, flat, kept, transcribing, progress } = derush;
32+
33+/** Noms et durées des rushes et des timelines (leurs ids ne se croisent pas). */
34+const names = computed(() => ({
35+ ...Object.fromEntries(project.value.rushes.map((r) => [r.id, r.name])),
36+ ...Object.fromEntries(derush.timelines.value.map((k) => [k.id, derush.nameOf(k.id)])),
37+}));
38+const durations = computed(() => ({
39+ ...Object.fromEntries(project.value.rushes.map((r) => [r.id, r.duration ?? 0])),
40+ ...Object.fromEntries(derush.timelines.value.map((k) => [k.id, derush.lengthOf(k.id)])),
41+}));
42+/** Les bouts de rushes posés directement sur la timeline ouverte (ceux des timelines imbriquées se retouchent chez elles). */
43+const rushClips = computed(() => clips.value.filter((c): c is DerushClip => !!c.rush));
3344 const PALETTE = ["#3A3B3D", "#2E5E7E", "#5B4B7D", "#2F6B57", "#7A4F31", "#6E3049", "#4D5A2B", "#36507A"];
3445 const colors = computed(() => Object.fromEntries(project.value.rushes.map((r, i) => [r.id, PALETTE[i % PALETTE.length]])));
3546 const media = (rush: string) => `${base.value}/rushes/${rush}/media`;
3647
37-const pieces = computed<Piece[]>(() => clips.value.map((c) => ({ src: media(c.rush), start: c.in, end: c.out, on: true })));
48+const pieces = computed<Piece[]>(() => flat.value.map((c) => ({ src: media(c.rush), start: c.in, end: c.out, on: true })));
3849 const player = usePlaylist(pieces);
3950 const selection = ref<Selection>(null);
4051 const tool = ref<"select" | "blade">("select");
4152 const tl = ref<InstanceType<typeof Timeline> | null>(null);
4253 const tab = ref<"transcript" | "rushes" | "daryl">("transcript");
4354
55+// --- timelines : onglets au-dessus de la timeline, comme les séquences d'un logiciel de montage
56+function openTimeline(id: string) {
57+ derush.current.value = id;
58+}
59+watch(derush.current, () => {
60+ selection.value = null;
61+ player.seek(0);
62+ nextTick(() => tl.value?.fit());
63+});
64+const renaming = ref<string | null>(null);
65+function rename(id: string, e: Event) {
66+ const name = (e.target as HTMLInputElement).value;
67+ renaming.value = null;
68+ if (name.trim() && name.trim() !== derush.nameOf(id)) derush.rename(id, name);
69+}
70+function focusInput({ el }: { el: HTMLInputElement }) {
71+ el.focus();
72+ el.select();
73+}
74+function newTimeline() {
75+ derush.create(t("derush.timelines.defaultName", { n: derush.timelines.value.length + 1 }));
76+}
77+
4478 /** La phrase sous la tête de lecture, en sous-titre. */
4579 const caption = computed(() => {
46- const hit = clipAt(clips.value, player.t.value);
80+ const hit = clipAt(flat.value, player.t.value);
4781 if (!hit) return "";
4882 const s = segsByRush.value[hit.c.rush]?.find((k) => k.start <= hit.at && hit.at < k.end);
4983 return s && s.type !== "sil" ? s.text : "";
@@ -83,7 +117,7 @@ const hasSel = computed(() => !!selection.value);
83117 * Recale la tête de lecture après le retrait des plages `cut` de la timeline : dans une plage retirée,
84118 * elle revient à son début (là où reprend la suite) ; après, elle suit le contenu qui a glissé.
85119 */
86-function cutAndFollow(cut: [number, number][], fn: (list: typeof clips.value) => typeof clips.value) {
120+function cutAndFollow(cut: [number, number][], fn: (list: TimelineClip[]) => TimelineClip[]) {
87121 const here = player.t.value;
88122 let to = here;
89123 for (const [a, b] of cut) {
@@ -116,7 +150,7 @@ function onMove(ids: string[], before: string | null) {
116150
117151 function onTrim(id: string, side: "in" | "out", value: number) {
118152 const c = clips.value.find((k) => k.id === id);
119- if (c) derush.edit((list) => trim(list, id, side, value, durations.value[c.rush] || c.out));
153+ if (c) derush.edit((list) => trim(list, id, side, value, durations.value[c.rush ?? c.tl ?? ""] || c.out));
120154 }
121155
122156 function onSplit(at: number) {
@@ -128,7 +162,7 @@ function present(type: DerushSeg["type"]) {
128162 const out: Record<string, [number, number][]> = {};
129163 let n = 0, dur = 0;
130164 for (const [rush, segs] of Object.entries(segsByRush.value)) {
131- const mine = clips.value.filter((c) => c.rush === rush);
165+ const mine = rushClips.value.filter((c) => c.rush === rush);
132166 for (const s of segs) {
133167 if (s.type !== type) continue;
134168 const inside = mine.reduce((a, c) => a + Math.max(0, Math.min(c.out, s.end) - Math.max(c.in, s.start)), 0);
@@ -165,14 +199,14 @@ function applyAuto(cuts: AutoCut[]) {
165199 derush.edit((list) => {
166200 const before = new Map(list.map((c) => [c.id, c]));
167201 const next = Object.entries(byRush).reduce((acc, [rush, r]) => removeRushRanges(acc, rush, r), list);
168- const same = (c: DerushClip) => before.get(c.id)?.in === c.in && before.get(c.id)?.out === c.out;
202+ const same = (c: TimelineClip) => before.get(c.id)?.in === c.in && before.get(c.id)?.out === c.out;
169203 return next.filter((c) => clipLen(c) >= MIN_KEEP || same(c));
170204 });
171205 }
172206
173207 /** Écoute un passage d'un rush : un peu avant, pour entendre ce qui précède la coupe. */
174208 function seekRush(rush: string, t: number) {
175- const at = timeOf(clips.value, rush, t);
209+ const at = timeOf(flat.value, rush, t);
176210 if (at == null) return;
177211 player.seek(Math.max(0, at - 1));
178212 if (!player.playing.value) player.toggle();
@@ -252,7 +286,7 @@ async function validate() {
252286 <template>
253287 <div v-if="!project.rushes.length" class="empty">
254288 <div class="dashed-box">{{ t("derush.empty") }}</div>
255- <div class="side"><RushPanel /></div>
289+ <MediaPool :derush="derush" @open="openTimeline" />
256290 </div>
257291
258292 <div v-else class="derush">
@@ -269,7 +303,7 @@ async function validate() {
269303 </div>
270304 <div class="pane-body">
271305 <template v-if="tab === 'transcript'">
272- <Transcript v-if="ready" v-model:selection="selection" :clips="clips" :segs="segsByRush" :player="player"
306+ <Transcript v-if="ready" v-model:selection="selection" :clips="flat" :segs="segsByRush" :player="player"
273307 :names="names" @remove="removeSpan" />
274308 <div v-else-if="transcribing" class="state">
275309 <span class="state-label">{{ t("derush.transcribing", { p: progress }) }}</span>
@@ -280,13 +314,27 @@ async function validate() {
280314 <button class="btn dark gen" @click="act('/rushes/transcribe')">{{ t("derush.generate") }}</button>
281315 </div>
282316 </template>
283- <AutoDerush v-else-if="tab === 'daryl'" :clips="clips" :names="names" :ready="ready"
317+ <AutoDerush v-else-if="tab === 'daryl'" :clips="rushClips" :names="names" :ready="ready"
284318 @apply="applyAuto" @seek="seekRush" />
285- <div v-else class="scroll"><RushPanel embedded @append="derush.append" /></div>
319+ <div v-else class="scroll"><MediaPool :derush="derush" @open="openTimeline" /></div>
286320 </div>
287321 </div>
288322 </div>
289323
324+ <div class="seqs">
325+ <div v-for="k in derush.timelines.value" :key="k.id" class="seq" :class="{ on: k.id === derush.current.value }"
326+ :title="t('derush.timelines.renameTitle')" @click="openTimeline(k.id)" @dblclick="renaming = k.id">
327+ <input v-if="renaming === k.id" class="seq-input" :value="derush.nameOf(k.id)"
328+ @click.stop @keydown.enter="($event.target as HTMLInputElement).blur()" @keydown.esc="renaming = null"
329+ @blur="rename(k.id, $event)" @vue:mounted="focusInput">
330+ <span v-else>{{ derush.nameOf(k.id) }}</span>
331+ <span v-if="k.id === derush.mainId.value" class="seq-main">{{ t("derush.timelines.global") }}</span>
332+ <ConfirmX v-else :label="t('derush.timelines.removeConfirm')" :title="t('derush.timelines.removeTitle')"
333+ @confirm="derush.drop(k.id)" />
334+ </div>
335+ <button class="seq-add" :title="t('derush.timelines.newTitle')" @click="newTimeline">+</button>
336+ </div>
337+
290338 <div class="toolbar">
291339 <div class="group">
292340 <button class="btn sm" :class="{ on: tool === 'select' }" :title="`${t('derush.tools.select')} (A)`" @click="tool = 'select'">{{ t("derush.tools.select") }}</button>
@@ -314,9 +362,9 @@ async function validate() {
314362 </div>
315363 </div>
316364
317- <Timeline ref="tl" v-model:selection="selection" :clips="clips" :player="player" :names="names" :colors="colors"
365+ <Timeline ref="tl" v-model:selection="selection" :clips="clips" :flat="flat" :player="player" :names="names" :colors="colors"
318366 :durations="durations" :segs="segsByRush" :peaks="peaks" :tool="tool"
319- @move="onMove" @trim="onTrim" @split="onSplit" />
367+ @move="onMove" @trim="onTrim" @split="onSplit" @insert="derush.add" @open="openTimeline" />
320368
321369 <div class="footer">
322370 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>
@@ -354,7 +402,7 @@ async function validate() {
354402 <style scoped>
355403 .empty { padding: 20px 32px 32px; display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; }
356404 .empty .dashed-box { flex: 1 1 520px; }
357-.empty .side { flex: 0 1 320px; min-width: 260px; }
405+.empty > :last-child { flex: 2 1 480px; min-width: 300px; }
358406
359407 .derush { padding: 16px 24px 24px 32px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
360408 .top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: stretch; }
@@ -379,6 +427,17 @@ async function validate() {
379427 .progress.bar { width: 260px; }
380428 .btn.gen { height: 38px; padding: 0 18px; font-size: 13.5px; }
381429
430+.seqs { display: flex; align-items: flex-end; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: -4px; flex-wrap: wrap; }
431+.seq {
432+ display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; font-size: 12.5px; color: var(--muted);
433+ border: 1px solid transparent; border-bottom: none; border-radius: 4px 4px 0 0; cursor: pointer; margin-bottom: -1px;
434+}
435+.seq:hover:not(.on) { color: var(--ink); }
436+.seq.on { color: var(--ink); font-weight: 600; background: #fff; border-color: var(--line); }
437+.seq-main { font-size: 10px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red); color: #fff; }
438+.seq-input { width: 140px; height: 22px; font: inherit; padding: 0 4px; border: 1px solid var(--line-strong); border-radius: 3px; }
439+.seq-add { height: 26px; width: 26px; margin-bottom: 2px; border: 1px dashed var(--dash); border-radius: 3px; background: none; cursor: pointer; color: var(--muted); }
440+.seq-add:hover { border-color: var(--ink); color: var(--ink); }
382441 .toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 4px; }
383442 .toolbar .group { display: flex; gap: 4px; }
384443 .btn.sm { height: 28px; padding: 0 10px; font-size: 12.5px; }
@@ -1,21 +1,23 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 /**2 /**
3 * Dérush, façon logiciel de montage : le lecteur, le transcript de la timeline (où l'on retire phrases,3 * Dérush, façon logiciel de montage : le lecteur, le transcript de la timeline (où l'on retire phrases,
4- * mots et silences), la liste des rushes ou le dérush proposé par Daryl, et la timeline : des bouts de rushes qu'on déplace, coupe,4+ * mots et silences), la médiathèque (rushes et timelines) ou le dérush proposé par Daryl, et la timeline : des bouts
5- * rogne ou supprime.5+ * de rushes qu'on déplace, coupe, rogne ou supprime. Plusieurs timelines : on dérushe tout d'un coup ou séquence par
6+ * séquence, et la globale (celle qu'on exporte) peut contenir les autres.
6 */7 */
7-import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";8+import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
8 import { useI18n } from "vue-i18n";9 import { useI18n } from "vue-i18n";
9 import { useRouter } from "vue-router";10 import { useRouter } from "vue-router";
10 import { api } from "../../lib/api";11 import { api } from "../../lib/api";
11 import { mmss } from "../../lib/format";12 import { mmss } from "../../lib/format";
12 import { toast } from "../../lib/toast";13 import { toast } from "../../lib/toast";
14+import ConfirmX from "../../components/ConfirmX.vue";
13 import { act, failed, running, store } from "../store";15 import { act, failed, running, store } from "../store";
14-import type { AutoCut, DerushClip, DerushSeg } from "../types";16+import type { AutoCut, DerushClip, DerushSeg, TimelineClip } from "../types";
15 import AutoDerush from "./derush/AutoDerush.vue";17 import AutoDerush from "./derush/AutoDerush.vue";
16 import { clipAt, clipLen, move, offsets, remove, removeRange, removeRushRanges, splitAt, timeOf, trim, type Selection } from "./derush/clips";18 import { clipAt, clipLen, move, offsets, remove, removeRange, removeRushRanges, splitAt, timeOf, trim, type Selection } from "./derush/clips";
19+import MediaPool from "./derush/MediaPool.vue";
17 import PlayerFrame from "./derush/PlayerFrame.vue";20 import PlayerFrame from "./derush/PlayerFrame.vue";
18-import RushPanel from "./derush/RushPanel.vue";
19 import Timeline, { type Peaks } from "./derush/Timeline.vue";21 import Timeline, { type Peaks } from "./derush/Timeline.vue";
20 import Transcript from "./derush/Transcript.vue";22 import Transcript from "./derush/Transcript.vue";
21 import { useDerush } from "./derush/useDerush";23 import { useDerush } from "./derush/useDerush";
@@ -26,24 +28,56 @@ const router = useRouter();
26 const project = computed(() => store.project!);28 const project = computed(() => store.project!);
27 const base = computed(() => `/api/projects/${project.value.id}`);29 const base = computed(() => `/api/projects/${project.value.id}`);
28 const derush = useDerush(project);30 const derush = useDerush(project);
29-const { analysis, ready, segsByRush, clips, kept, transcribing, progress } = derush;31+const { analysis, ready, segsByRush, clips, flat, kept, transcribing, progress } = derush;
30-32+
31-const names = computed(() => Object.fromEntries(project.value.rushes.map((r) => [r.id, r.name])));33+/** Noms et durées des rushes et des timelines (leurs ids ne se croisent pas). */
32-const durations = computed(() => Object.fromEntries(project.value.rushes.map((r) => [r.id, r.duration ?? 0])));34+const names = computed(() => ({
35+ ...Object.fromEntries(project.value.rushes.map((r) => [r.id, r.name])),
36+ ...Object.fromEntries(derush.timelines.value.map((k) => [k.id, derush.nameOf(k.id)])),
37+}));
38+const durations = computed(() => ({
39+ ...Object.fromEntries(project.value.rushes.map((r) => [r.id, r.duration ?? 0])),
40+ ...Object.fromEntries(derush.timelines.value.map((k) => [k.id, derush.lengthOf(k.id)])),
41+}));
42+/** Les bouts de rushes posés directement sur la timeline ouverte (ceux des timelines imbriquées se retouchent chez elles). */
43+const rushClips = computed(() => clips.value.filter((c): c is DerushClip => !!c.rush));
33 const PALETTE = ["#3A3B3D", "#2E5E7E", "#5B4B7D", "#2F6B57", "#7A4F31", "#6E3049", "#4D5A2B", "#36507A"];44 const PALETTE = ["#3A3B3D", "#2E5E7E", "#5B4B7D", "#2F6B57", "#7A4F31", "#6E3049", "#4D5A2B", "#36507A"];
34 const colors = computed(() => Object.fromEntries(project.value.rushes.map((r, i) => [r.id, PALETTE[i % PALETTE.length]])));45 const colors = computed(() => Object.fromEntries(project.value.rushes.map((r, i) => [r.id, PALETTE[i % PALETTE.length]])));
35 const media = (rush: string) => `${base.value}/rushes/${rush}/media`;46 const media = (rush: string) => `${base.value}/rushes/${rush}/media`;
36 47
37-const pieces = computed<Piece[]>(() => clips.value.map((c) => ({ src: media(c.rush), start: c.in, end: c.out, on: true })));48+const pieces = computed<Piece[]>(() => flat.value.map((c) => ({ src: media(c.rush), start: c.in, end: c.out, on: true })));
38 const player = usePlaylist(pieces);49 const player = usePlaylist(pieces);
39 const selection = ref<Selection>(null);50 const selection = ref<Selection>(null);
40 const tool = ref<"select" | "blade">("select");51 const tool = ref<"select" | "blade">("select");
41 const tl = ref<InstanceType<typeof Timeline> | null>(null);52 const tl = ref<InstanceType<typeof Timeline> | null>(null);
42 const tab = ref<"transcript" | "rushes" | "daryl">("transcript");53 const tab = ref<"transcript" | "rushes" | "daryl">("transcript");
43 54
55+// --- timelines : onglets au-dessus de la timeline, comme les séquences d'un logiciel de montage
56+function openTimeline(id: string) {
57+ derush.current.value = id;
58+}
59+watch(derush.current, () => {
60+ selection.value = null;
61+ player.seek(0);
62+ nextTick(() => tl.value?.fit());
63+});
64+const renaming = ref<string | null>(null);
65+function rename(id: string, e: Event) {
66+ const name = (e.target as HTMLInputElement).value;
67+ renaming.value = null;
68+ if (name.trim() && name.trim() !== derush.nameOf(id)) derush.rename(id, name);
69+}
70+function focusInput({ el }: { el: HTMLInputElement }) {
71+ el.focus();
72+ el.select();
73+}
74+function newTimeline() {
75+ derush.create(t("derush.timelines.defaultName", { n: derush.timelines.value.length + 1 }));
76+}
77+
44 /** La phrase sous la tête de lecture, en sous-titre. */78 /** La phrase sous la tête de lecture, en sous-titre. */
45 const caption = computed(() => {79 const caption = computed(() => {
46- const hit = clipAt(clips.value, player.t.value);80+ const hit = clipAt(flat.value, player.t.value);
47 if (!hit) return "";81 if (!hit) return "";
48 const s = segsByRush.value[hit.c.rush]?.find((k) => k.start <= hit.at && hit.at < k.end);82 const s = segsByRush.value[hit.c.rush]?.find((k) => k.start <= hit.at && hit.at < k.end);
49 return s && s.type !== "sil" ? s.text : "";83 return s && s.type !== "sil" ? s.text : "";
@@ -83,7 +117,7 @@ const hasSel = computed(() => !!selection.value);
83 * Recale la tête de lecture après le retrait des plages `cut` de la timeline : dans une plage retirée,117 * Recale la tête de lecture après le retrait des plages `cut` de la timeline : dans une plage retirée,
84 * elle revient à son début (là où reprend la suite) ; après, elle suit le contenu qui a glissé.118 * elle revient à son début (là où reprend la suite) ; après, elle suit le contenu qui a glissé.
85 */119 */
86-function cutAndFollow(cut: [number, number][], fn: (list: typeof clips.value) => typeof clips.value) {120+function cutAndFollow(cut: [number, number][], fn: (list: TimelineClip[]) => TimelineClip[]) {
87 const here = player.t.value;121 const here = player.t.value;
88 let to = here;122 let to = here;
89 for (const [a, b] of cut) {123 for (const [a, b] of cut) {
@@ -116,7 +150,7 @@ function onMove(ids: string[], before: string | null) {
116 150
117 function onTrim(id: string, side: "in" | "out", value: number) {151 function onTrim(id: string, side: "in" | "out", value: number) {
118 const c = clips.value.find((k) => k.id === id);152 const c = clips.value.find((k) => k.id === id);
119- if (c) derush.edit((list) => trim(list, id, side, value, durations.value[c.rush] || c.out));153+ if (c) derush.edit((list) => trim(list, id, side, value, durations.value[c.rush ?? c.tl ?? ""] || c.out));
120 }154 }
121 155
122 function onSplit(at: number) {156 function onSplit(at: number) {
@@ -128,7 +162,7 @@ function present(type: DerushSeg["type"]) {
128 const out: Record<string, [number, number][]> = {};162 const out: Record<string, [number, number][]> = {};
129 let n = 0, dur = 0;163 let n = 0, dur = 0;
130 for (const [rush, segs] of Object.entries(segsByRush.value)) {164 for (const [rush, segs] of Object.entries(segsByRush.value)) {
131- const mine = clips.value.filter((c) => c.rush === rush);165+ const mine = rushClips.value.filter((c) => c.rush === rush);
132 for (const s of segs) {166 for (const s of segs) {
133 if (s.type !== type) continue;167 if (s.type !== type) continue;
134 const inside = mine.reduce((a, c) => a + Math.max(0, Math.min(c.out, s.end) - Math.max(c.in, s.start)), 0);168 const inside = mine.reduce((a, c) => a + Math.max(0, Math.min(c.out, s.end) - Math.max(c.in, s.start)), 0);
@@ -165,14 +199,14 @@ function applyAuto(cuts: AutoCut[]) {
165 derush.edit((list) => {199 derush.edit((list) => {
166 const before = new Map(list.map((c) => [c.id, c]));200 const before = new Map(list.map((c) => [c.id, c]));
167 const next = Object.entries(byRush).reduce((acc, [rush, r]) => removeRushRanges(acc, rush, r), list);201 const next = Object.entries(byRush).reduce((acc, [rush, r]) => removeRushRanges(acc, rush, r), list);
168- const same = (c: DerushClip) => before.get(c.id)?.in === c.in && before.get(c.id)?.out === c.out;202+ const same = (c: TimelineClip) => before.get(c.id)?.in === c.in && before.get(c.id)?.out === c.out;
169 return next.filter((c) => clipLen(c) >= MIN_KEEP || same(c));203 return next.filter((c) => clipLen(c) >= MIN_KEEP || same(c));
170 });204 });
171 }205 }
172 206
173 /** Écoute un passage d'un rush : un peu avant, pour entendre ce qui précède la coupe. */207 /** Écoute un passage d'un rush : un peu avant, pour entendre ce qui précède la coupe. */
174 function seekRush(rush: string, t: number) {208 function seekRush(rush: string, t: number) {
175- const at = timeOf(clips.value, rush, t);209+ const at = timeOf(flat.value, rush, t);
176 if (at == null) return;210 if (at == null) return;
177 player.seek(Math.max(0, at - 1));211 player.seek(Math.max(0, at - 1));
178 if (!player.playing.value) player.toggle();212 if (!player.playing.value) player.toggle();
@@ -252,7 +286,7 @@ async function validate() {
252 <template>286 <template>
253 <div v-if="!project.rushes.length" class="empty">287 <div v-if="!project.rushes.length" class="empty">
254 <div class="dashed-box">{{ t("derush.empty") }}</div>288 <div class="dashed-box">{{ t("derush.empty") }}</div>
255- <div class="side"><RushPanel /></div>289+ <MediaPool :derush="derush" @open="openTimeline" />
256 </div>290 </div>
257 291
258 <div v-else class="derush">292 <div v-else class="derush">
@@ -269,7 +303,7 @@ async function validate() {
269 </div>303 </div>
270 <div class="pane-body">304 <div class="pane-body">
271 <template v-if="tab === 'transcript'">305 <template v-if="tab === 'transcript'">
272- <Transcript v-if="ready" v-model:selection="selection" :clips="clips" :segs="segsByRush" :player="player"306+ <Transcript v-if="ready" v-model:selection="selection" :clips="flat" :segs="segsByRush" :player="player"
273 :names="names" @remove="removeSpan" />307 :names="names" @remove="removeSpan" />
274 <div v-else-if="transcribing" class="state">308 <div v-else-if="transcribing" class="state">
275 <span class="state-label">{{ t("derush.transcribing", { p: progress }) }}</span>309 <span class="state-label">{{ t("derush.transcribing", { p: progress }) }}</span>
@@ -280,13 +314,27 @@ async function validate() {
280 <button class="btn dark gen" @click="act('/rushes/transcribe')">{{ t("derush.generate") }}</button>314 <button class="btn dark gen" @click="act('/rushes/transcribe')">{{ t("derush.generate") }}</button>
281 </div>315 </div>
282 </template>316 </template>
283- <AutoDerush v-else-if="tab === 'daryl'" :clips="clips" :names="names" :ready="ready"317+ <AutoDerush v-else-if="tab === 'daryl'" :clips="rushClips" :names="names" :ready="ready"
284 @apply="applyAuto" @seek="seekRush" />318 @apply="applyAuto" @seek="seekRush" />
285- <div v-else class="scroll"><RushPanel embedded @append="derush.append" /></div>319+ <div v-else class="scroll"><MediaPool :derush="derush" @open="openTimeline" /></div>
286 </div>320 </div>
287 </div>321 </div>
288 </div>322 </div>
289 323
324+ <div class="seqs">
325+ <div v-for="k in derush.timelines.value" :key="k.id" class="seq" :class="{ on: k.id === derush.current.value }"
326+ :title="t('derush.timelines.renameTitle')" @click="openTimeline(k.id)" @dblclick="renaming = k.id">
327+ <input v-if="renaming === k.id" class="seq-input" :value="derush.nameOf(k.id)"
328+ @click.stop @keydown.enter="($event.target as HTMLInputElement).blur()" @keydown.esc="renaming = null"
329+ @blur="rename(k.id, $event)" @vue:mounted="focusInput">
330+ <span v-else>{{ derush.nameOf(k.id) }}</span>
331+ <span v-if="k.id === derush.mainId.value" class="seq-main">{{ t("derush.timelines.global") }}</span>
332+ <ConfirmX v-else :label="t('derush.timelines.removeConfirm')" :title="t('derush.timelines.removeTitle')"
333+ @confirm="derush.drop(k.id)" />
334+ </div>
335+ <button class="seq-add" :title="t('derush.timelines.newTitle')" @click="newTimeline">+</button>
336+ </div>
337+
290 <div class="toolbar">338 <div class="toolbar">
291 <div class="group">339 <div class="group">
292 <button class="btn sm" :class="{ on: tool === 'select' }" :title="`${t('derush.tools.select')} (A)`" @click="tool = 'select'">{{ t("derush.tools.select") }}</button>340 <button class="btn sm" :class="{ on: tool === 'select' }" :title="`${t('derush.tools.select')} (A)`" @click="tool = 'select'">{{ t("derush.tools.select") }}</button>
@@ -314,9 +362,9 @@ async function validate() {
314 </div>362 </div>
315 </div>363 </div>
316 364
317- <Timeline ref="tl" v-model:selection="selection" :clips="clips" :player="player" :names="names" :colors="colors"365+ <Timeline ref="tl" v-model:selection="selection" :clips="clips" :flat="flat" :player="player" :names="names" :colors="colors"
318 :durations="durations" :segs="segsByRush" :peaks="peaks" :tool="tool"366 :durations="durations" :segs="segsByRush" :peaks="peaks" :tool="tool"
319- @move="onMove" @trim="onTrim" @split="onSplit" />367+ @move="onMove" @trim="onTrim" @split="onSplit" @insert="derush.add" @open="openTimeline" />
320 368
321 <div class="footer">369 <div class="footer">
322 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>370 <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span>
@@ -354,7 +402,7 @@ async function validate() {
354 <style scoped>402 <style scoped>
355 .empty { padding: 20px 32px 32px; display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; }403 .empty { padding: 20px 32px 32px; display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; }
356 .empty .dashed-box { flex: 1 1 520px; }404 .empty .dashed-box { flex: 1 1 520px; }
357-.empty .side { flex: 0 1 320px; min-width: 260px; }405+.empty > :last-child { flex: 2 1 480px; min-width: 300px; }
358 406
359 .derush { padding: 16px 24px 24px 32px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }407 .derush { padding: 16px 24px 24px 32px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
360 .top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: stretch; }408 .top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: stretch; }
@@ -379,6 +427,17 @@ async function validate() {
379 .progress.bar { width: 260px; }427 .progress.bar { width: 260px; }
380 .btn.gen { height: 38px; padding: 0 18px; font-size: 13.5px; }428 .btn.gen { height: 38px; padding: 0 18px; font-size: 13.5px; }
381 429
430+.seqs { display: flex; align-items: flex-end; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: -4px; flex-wrap: wrap; }
431+.seq {
432+ display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; font-size: 12.5px; color: var(--muted);
433+ border: 1px solid transparent; border-bottom: none; border-radius: 4px 4px 0 0; cursor: pointer; margin-bottom: -1px;
434+}
435+.seq:hover:not(.on) { color: var(--ink); }
436+.seq.on { color: var(--ink); font-weight: 600; background: #fff; border-color: var(--line); }
437+.seq-main { font-size: 10px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red); color: #fff; }
438+.seq-input { width: 140px; height: 22px; font: inherit; padding: 0 4px; border: 1px solid var(--line-strong); border-radius: 3px; }
439+.seq-add { height: 26px; width: 26px; margin-bottom: 2px; border: 1px dashed var(--dash); border-radius: 3px; background: none; cursor: pointer; color: var(--muted); }
440+.seq-add:hover { border-color: var(--ink); color: var(--ink); }
382 .toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 4px; }441 .toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 4px; }
383 .toolbar .group { display: flex; gap: 4px; }442 .toolbar .group { display: flex; gap: 4px; }
384 .btn.sm { height: 28px; padding: 0 10px; font-size: 12.5px; }443 .btn.sm { height: 28px; padding: 0 10px; font-size: 12.5px; }
added web/src/project/steps/derush/MediaPool.vue +261 -0
new file mode 100644
@@ -0,0 +1,261 @@
1+<script setup lang="ts">
2+/**
3+ * La médiathèque du dérush, comme dans un logiciel de montage : les rushes en vignettes (triés par heure de
4+ * tournage) et les timelines. On importe en glissant les fichiers ou par le bouton ; on pose un rush ou une
5+ * timeline sur la timeline ouverte avec « + » ou en le glissant dessus. Ctrl / Maj + clic : plusieurs rushes.
6+ */
7+import { computed, ref } from "vue";
8+import { useI18n } from "vue-i18n";
9+import ConfirmX from "../../../components/ConfirmX.vue";
10+import Seg from "../../../components/Seg.vue";
11+import { mmss } from "../../../lib/format";
12+import { toast } from "../../../lib/toast";
13+import { droppedFiles, upload } from "../../../lib/upload";
14+import { act, refresh, store } from "../../store";
15+import type { Rush } from "../../types";
16+import { MEDIA_TYPE, type MediaItem, type useDerush } from "./useDerush";
17+
18+const props = defineProps<{ derush: ReturnType<typeof useDerush> }>();
19+const emit = defineEmits<{ open: [tl: string] }>();
20+const { t } = useI18n();
21+const project = computed(() => store.project!);
22+const base = computed(() => `/api/projects/${project.value.id}`);
23+const d = props.derush;
24+
25+// --- rushes, triés --------------------------------------------------------------------
26+
27+type Sort = "time" | "name";
28+const sort = ref<Sort>("time");
29+const SORTS = computed(() => [["time", t("derush.pool.sorts.time")], ["name", t("derush.pool.sorts.name")]] as [Sort, string][]);
30+const byTime = (a: Rush, b: Rush) => (a.shot_at ?? "").localeCompare(b.shot_at ?? "") || a.name.localeCompare(b.name);
31+const rushes = computed(() => [...project.value.rushes].sort(sort.value === "time" ? byTime
32+ : (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })));
33+
34+/** Les rushes déjà lus par la timeline ouverte (coche, comme dans Filmora). */
35+const used = computed(() => new Set(d.flat.value.map((c) => c.rush)));
36+
37+/** Les sections du script dites dans chaque rush (d'après le transcript). */
38+const sections = computed(() => {
39+ const a = d.analysis.value, out: Record<string, string[]> = {};
40+ if (!a?.ready) return out;
41+ for (const s of a.segs) {
42+ if (!s.sec) continue;
43+ const label = a.sections.find((k) => k.id === s.sec)?.label ?? s.sec;
44+ if (!(out[s.rush] ??= []).includes(label)) out[s.rush].push(label);
45+ }
46+ return out;
47+});
48+
49+const tiles = computed(() => rushes.value.map((r) => {
50+ const tr = project.value.runtime.rush_transcripts[r.id];
51+ const status = tr?.status ?? "none";
52+ const busy = !["done", "error", "cancelled", "none"].includes(status);
53+ const lines = [r.name, r.time && t("derush.pool.shotAt", { time: r.time }), sections.value[r.id]?.join(", ")];
54+ return {
55+ rush: r, busy, error: status === "error",
56+ bar: busy ? tr?.progress ?? 0 : 0,
57+ badge: status === "queued" ? t("derush.pool.queued") : busy ? `${Math.round((tr?.progress ?? 0) * 100)} %` : mmss(r.duration),
58+ title: lines.filter(Boolean).join("\n"),
59+ };
60+}));
61+
62+// --- sélection et pose sur la timeline ---------------------------------------------------
63+
64+const selected = ref<string[]>([]);
65+function pick(e: MouseEvent, id: string) {
66+ if (e.shiftKey && selected.value.length) {
67+ const ids = rushes.value.map((r) => r.id), a = ids.indexOf(selected.value[selected.value.length - 1]), b = ids.indexOf(id);
68+ selected.value = [...new Set([...selected.value, ...ids.slice(Math.min(a, b), Math.max(a, b) + 1)])];
69+ } else if (e.ctrlKey || e.metaKey) {
70+ selected.value = selected.value.includes(id) ? selected.value.filter((k) => k !== id) : [...selected.value, id];
71+ } else selected.value = selected.value.length === 1 && selected.value[0] === id ? [] : [id];
72+}
73+
74+/** Les rushes sélectionnés (sinon tous), par heure de tournage, au bout de la timeline ouverte. */
75+const toPlace = computed(() => {
76+ const list = project.value.rushes.filter((r) => !selected.value.length || selected.value.includes(r.id));
77+ return [...list].sort(byTime);
78+});
79+function placeByTime() {
80+ d.add(toPlace.value.map((r) => ({ rush: r.id })));
81+ selected.value = [];
82+}
83+
84+function dragRush(e: DragEvent, id: string) {
85+ const ids = selected.value.includes(id) ? rushes.value.filter((r) => selected.value.includes(r.id)).map((r) => r.id) : [id];
86+ setDrag(e, ids.map((rush) => ({ rush })));
87+}
88+function setDrag(e: DragEvent, items: MediaItem[]) {
89+ e.dataTransfer?.setData(MEDIA_TYPE, JSON.stringify(items));
90+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
91+}
92+
93+// --- timelines ---------------------------------------------------------------------------
94+
95+const timelines = computed(() => d.timelines.value.map((k) => ({
96+ id: k.id, name: d.nameOf(k.id), main: k.id === d.mainId.value, open: k.id === d.current.value,
97+ length: d.lengthOf(k.id), nestable: d.canNest(k.id),
98+})));
99+
100+/** Nouvelle timeline : avec les rushes sélectionnés (par heure de tournage), sinon vide. */
101+function newTimeline() {
102+ const n = d.timelines.value.length + 1;
103+ const list = selected.value.length
104+ ? toPlace.value.filter((r) => r.duration).map((r) => ({ id: `c${Math.random().toString(36).slice(2, 10)}`, rush: r.id, in: 0, out: r.duration! }))
105+ : [];
106+ emit("open", d.create(t("derush.timelines.defaultName", { n }), list));
107+ selected.value = [];
108+}
109+
110+// --- import ------------------------------------------------------------------------------
111+
112+const importing = computed(() => project.value.runtime.importing.filter((f) => f.target === "rushes"));
113+/** Fichiers en cours d'envoi au serveur. */
114+const uploads = ref<{ name: string; pct: number }[]>([]);
115+const over = ref(false);
116+const input = ref<HTMLInputElement | null>(null);
117+
118+async function send(files: File[]): Promise<void> {
119+ if (!files.length) return;
120+ uploads.value.push(...files.map((f) => ({ name: f.name, pct: 0 })));
121+ const mine = uploads.value.slice(-files.length);
122+ try {
123+ await upload(`${base.value}/rushes/upload`, files, "files", {}, (f) => {
124+ for (const u of mine) u.pct = Math.round(f * 100);
125+ });
126+ await refresh();
127+ } catch (err) {
128+ toast((err as Error).message, true);
129+ } finally {
130+ uploads.value = uploads.value.filter((u) => !mine.includes(u));
131+ }
132+}
133+
134+function onPick(e: Event) {
135+ const el = e.target as HTMLInputElement;
136+ send(Array.from(el.files ?? []));
137+ el.value = "";
138+}
139+
140+/** Des fichiers glissés depuis l'explorateur (pas une vignette de la médiathèque). */
141+function onDragOver(e: DragEvent) {
142+ if (!e.dataTransfer?.types.includes("Files")) return;
143+ e.preventDefault();
144+ over.value = true;
145+}
146+function onDrop(e: DragEvent) {
147+ over.value = false;
148+ send(droppedFiles(e));
149+}
150+</script>
151+
152+<template>
153+ <div class="pool" :class="{ over }" @dragover="onDragOver" @dragleave.self="over = false" @drop.prevent="onDrop">
154+ <div class="bar">
155+ <button class="btn sm dark" @click="input?.click()">{{ t("derush.pool.import") }}</button>
156+ <input ref="input" type="file" multiple hidden accept="video/*,.mov,.mp4,.mxf,.mkv,.mts,.m4v,.avi,.webm" @change="onPick">
157+ <Seg v-model="sort" :options="SORTS" size="sm" />
158+ <div class="spacer" />
159+ <button class="btn sm" :disabled="!toPlace.length" :title="t('derush.pool.placeTitle')" @click="placeByTime">
160+ {{ selected.length ? t("derush.pool.placeSelected", { n: selected.length }) : t("derush.pool.placeAll") }}
161+ </button>
162+ <button class="btn sm" :title="t('derush.pool.newTimelineTitle')" @click="newTimeline">{{ t("derush.pool.newTimeline") }}</button>
163+ </div>
164+
165+ <div class="eyebrow">{{ t("derush.pool.timelines") }}</div>
166+ <div class="grid">
167+ <div v-for="k in timelines" :key="k.id" class="tile" :class="{ open: k.open }" :draggable="k.nestable"
168+ :title="k.open ? t('derush.pool.openNow') : t('derush.pool.openTimeline')"
169+ @dragstart="setDrag($event, [{ tl: k.id }])" @click="emit('open', k.id)">
170+ <div class="thumb seq">
171+ <span class="seq-icon">⧉</span>
172+ <span v-if="k.main" class="main-badge">{{ t("derush.timelines.global") }}</span>
173+ <span class="dur mono">{{ mmss(k.length) }}</span>
174+ <button v-if="k.nestable" class="plus" :title="t('derush.pool.nest')" @click.stop="d.add([{ tl: k.id }])">+</button>
175+ </div>
176+ <div class="name">{{ k.name }}</div>
177+ </div>
178+ </div>
179+
180+ <div class="eyebrow">{{ t("derush.pool.rushes", { n: rushes.length }) }}</div>
181+ <div class="grid">
182+ <div class="tile add" @click="input?.click()">
183+ <div class="thumb">+</div>
184+ <div class="name">{{ t("derush.pool.drop") }}</div>
185+ </div>
186+ <div v-for="k in tiles" :key="k.rush.id" class="tile" :class="{ on: selected.includes(k.rush.id) }" draggable="true"
187+ :title="k.title" @dragstart="dragRush($event, k.rush.id)" @click="pick($event, k.rush.id)">
188+ <div class="thumb">
189+ <img :src="`${base}/rushes/${k.rush.id}/thumb`" alt="" loading="lazy" draggable="false"
190+ @error="($event.target as HTMLImageElement).style.visibility = 'hidden'">
191+ <span class="dur mono" :class="{ error: k.error }">{{ k.error ? t("derush.pool.error") : k.badge }}</span>
192+ <span v-if="used.has(k.rush.id)" class="check" :title="t('derush.pool.used')">✓</span>
193+ <div class="tools" @click.stop>
194+ <button :disabled="k.busy" :title="t('derush.pool.retranscribe')" @click="act(`/rushes/${k.rush.id}/transcribe`)">↻</button>
195+ <ConfirmX :label="t('derush.pool.removeConfirm')" :title="t('derush.pool.removeTitle')"
196+ @confirm="act(`/rushes/${k.rush.id}`, undefined, 'DELETE')" />
197+ </div>
198+ <button class="plus" :title="t('derush.pool.append')" @click.stop="d.add([{ rush: k.rush.id }])">+</button>
199+ <div v-if="k.busy" class="progress red"><div :style="{ width: `${k.bar * 100}%` }" /></div>
200+ </div>
201+ <div class="name mono">{{ k.rush.name }}</div>
202+ <div v-if="k.rush.time" class="time mono">{{ k.rush.time }}</div>
203+ </div>
204+ <div v-for="(f, j) in [...importing.map((x) => ({ name: x.name, pct: x.pct })), ...uploads]" :key="`up-${j}-${f.name}`" class="tile">
205+ <div class="thumb pending">
206+ <span class="dur mono">{{ t("derush.pool.importing", { p: f.pct }) }}</span>
207+ <div class="progress red"><div :style="{ width: `${f.pct}%` }" /></div>
208+ </div>
209+ <div class="name mono">{{ f.name }}</div>
210+ </div>
211+ </div>
212+ </div>
213+</template>
214+
215+<style scoped>
216+.pool { display: flex; flex-direction: column; gap: 10px; min-height: 100%; border-radius: 4px; }
217+.pool.over { outline: 2px dashed var(--ink); outline-offset: -2px; background: var(--bg-softer); }
218+.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
219+.spacer { flex: 1; }
220+.eyebrow { padding-top: 4px; }
221+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px 10px; }
222+
223+.tile { display: flex; flex-direction: column; gap: 4px; min-width: 0; cursor: pointer; }
224+.thumb {
225+ position: relative; aspect-ratio: 16 / 9; border-radius: 4px; overflow: hidden; background: #2B2C2E;
226+ display: flex; align-items: center; justify-content: center; color: #fff; outline: 2px solid transparent; outline-offset: 1px;
227+}
228+.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
229+.tile.on .thumb { outline-color: var(--red); }
230+.tile.open .thumb { outline-color: #3B82C4; }
231+.tile.add .thumb { background: var(--bg-soft); color: var(--muted); font-size: 28px; border: 1px dashed var(--dash); }
232+.tile.add:hover .thumb { border-color: var(--ink); color: var(--ink); }
233+.thumb.seq { background: repeating-linear-gradient(135deg, #2B3440 0 10px, #323C49 10px 20px); }
234+.thumb.pending { background: var(--bg-soft); }
235+.seq-icon { font-size: 26px; opacity: .8; }
236+.main-badge { position: absolute; top: 5px; left: 5px; font-size: 10.5px; padding: 1px 6px; border-radius: 3px; background: var(--red); color: #fff; }
237+.dur {
238+ position: absolute; left: 5px; bottom: 5px; font-size: 10.5px; padding: 1px 5px; border-radius: 3px;
239+ background: rgba(0, 0, 0, .65); color: #fff;
240+}
241+.thumb.pending .dur { background: none; color: var(--muted); position: static; }
242+.dur.error { background: var(--red); }
243+.check {
244+ position: absolute; right: 5px; bottom: 5px; width: 18px; height: 18px; border-radius: 50%;
245+ background: #3E9E6E; color: #fff; font-size: 11px; display: flex; align-items: center; justify-content: center;
246+}
247+.plus {
248+ position: absolute; right: 5px; bottom: 5px; width: 24px; height: 24px; border-radius: 50%; border: none; cursor: pointer;
249+ background: #fff; color: var(--ink); font-size: 16px; line-height: 1; display: none;
250+}
251+.tile:hover .plus { display: block; }
252+.tile:hover .check { display: none; }
253+.tools { position: absolute; top: 4px; right: 4px; display: none; gap: 2px; align-items: center; padding: 1px 3px; border-radius: 3px; background: rgba(255, 255, 255, .92); }
254+.tile:hover .tools { display: flex; }
255+.tools button:not(.armed):not(.x-btn) { border: none; background: none; cursor: pointer; font-size: 13px; color: var(--ink); padding: 0 3px; }
256+.tools button:disabled { color: var(--dash); cursor: default; }
257+.thumb .progress { position: absolute; left: 0; right: 0; bottom: 0; }
258+.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259+.name.mono { font-size: 11.5px; }
260+.time { font-size: 10.5px; color: var(--faint); margin-top: -3px; }
261+</style>
new file mode 100644
@@ -0,0 +1,261 @@
1+<script setup lang="ts">
2+/**
3+ * La médiathèque du dérush, comme dans un logiciel de montage : les rushes en vignettes (triés par heure de
4+ * tournage) et les timelines. On importe en glissant les fichiers ou par le bouton ; on pose un rush ou une
5+ * timeline sur la timeline ouverte avec « + » ou en le glissant dessus. Ctrl / Maj + clic : plusieurs rushes.
6+ */
7+import { computed, ref } from "vue";
8+import { useI18n } from "vue-i18n";
9+import ConfirmX from "../../../components/ConfirmX.vue";
10+import Seg from "../../../components/Seg.vue";
11+import { mmss } from "../../../lib/format";
12+import { toast } from "../../../lib/toast";
13+import { droppedFiles, upload } from "../../../lib/upload";
14+import { act, refresh, store } from "../../store";
15+import type { Rush } from "../../types";
16+import { MEDIA_TYPE, type MediaItem, type useDerush } from "./useDerush";
17+
18+const props = defineProps<{ derush: ReturnType<typeof useDerush> }>();
19+const emit = defineEmits<{ open: [tl: string] }>();
20+const { t } = useI18n();
21+const project = computed(() => store.project!);
22+const base = computed(() => `/api/projects/${project.value.id}`);
23+const d = props.derush;
24+
25+// --- rushes, triés --------------------------------------------------------------------
26+
27+type Sort = "time" | "name";
28+const sort = ref<Sort>("time");
29+const SORTS = computed(() => [["time", t("derush.pool.sorts.time")], ["name", t("derush.pool.sorts.name")]] as [Sort, string][]);
30+const byTime = (a: Rush, b: Rush) => (a.shot_at ?? "").localeCompare(b.shot_at ?? "") || a.name.localeCompare(b.name);
31+const rushes = computed(() => [...project.value.rushes].sort(sort.value === "time" ? byTime
32+ : (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })));
33+
34+/** Les rushes déjà lus par la timeline ouverte (coche, comme dans Filmora). */
35+const used = computed(() => new Set(d.flat.value.map((c) => c.rush)));
36+
37+/** Les sections du script dites dans chaque rush (d'après le transcript). */
38+const sections = computed(() => {
39+ const a = d.analysis.value, out: Record<string, string[]> = {};
40+ if (!a?.ready) return out;
41+ for (const s of a.segs) {
42+ if (!s.sec) continue;
43+ const label = a.sections.find((k) => k.id === s.sec)?.label ?? s.sec;
44+ if (!(out[s.rush] ??= []).includes(label)) out[s.rush].push(label);
45+ }
46+ return out;
47+});
48+
49+const tiles = computed(() => rushes.value.map((r) => {
50+ const tr = project.value.runtime.rush_transcripts[r.id];
51+ const status = tr?.status ?? "none";
52+ const busy = !["done", "error", "cancelled", "none"].includes(status);
53+ const lines = [r.name, r.time && t("derush.pool.shotAt", { time: r.time }), sections.value[r.id]?.join(", ")];
54+ return {
55+ rush: r, busy, error: status === "error",
56+ bar: busy ? tr?.progress ?? 0 : 0,
57+ badge: status === "queued" ? t("derush.pool.queued") : busy ? `${Math.round((tr?.progress ?? 0) * 100)} %` : mmss(r.duration),
58+ title: lines.filter(Boolean).join("\n"),
59+ };
60+}));
61+
62+// --- sélection et pose sur la timeline ---------------------------------------------------
63+
64+const selected = ref<string[]>([]);
65+function pick(e: MouseEvent, id: string) {
66+ if (e.shiftKey && selected.value.length) {
67+ const ids = rushes.value.map((r) => r.id), a = ids.indexOf(selected.value[selected.value.length - 1]), b = ids.indexOf(id);
68+ selected.value = [...new Set([...selected.value, ...ids.slice(Math.min(a, b), Math.max(a, b) + 1)])];
69+ } else if (e.ctrlKey || e.metaKey) {
70+ selected.value = selected.value.includes(id) ? selected.value.filter((k) => k !== id) : [...selected.value, id];
71+ } else selected.value = selected.value.length === 1 && selected.value[0] === id ? [] : [id];
72+}
73+
74+/** Les rushes sélectionnés (sinon tous), par heure de tournage, au bout de la timeline ouverte. */
75+const toPlace = computed(() => {
76+ const list = project.value.rushes.filter((r) => !selected.value.length || selected.value.includes(r.id));
77+ return [...list].sort(byTime);
78+});
79+function placeByTime() {
80+ d.add(toPlace.value.map((r) => ({ rush: r.id })));
81+ selected.value = [];
82+}
83+
84+function dragRush(e: DragEvent, id: string) {
85+ const ids = selected.value.includes(id) ? rushes.value.filter((r) => selected.value.includes(r.id)).map((r) => r.id) : [id];
86+ setDrag(e, ids.map((rush) => ({ rush })));
87+}
88+function setDrag(e: DragEvent, items: MediaItem[]) {
89+ e.dataTransfer?.setData(MEDIA_TYPE, JSON.stringify(items));
90+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
91+}
92+
93+// --- timelines ---------------------------------------------------------------------------
94+
95+const timelines = computed(() => d.timelines.value.map((k) => ({
96+ id: k.id, name: d.nameOf(k.id), main: k.id === d.mainId.value, open: k.id === d.current.value,
97+ length: d.lengthOf(k.id), nestable: d.canNest(k.id),
98+})));
99+
100+/** Nouvelle timeline : avec les rushes sélectionnés (par heure de tournage), sinon vide. */
101+function newTimeline() {
102+ const n = d.timelines.value.length + 1;
103+ const list = selected.value.length
104+ ? toPlace.value.filter((r) => r.duration).map((r) => ({ id: `c${Math.random().toString(36).slice(2, 10)}`, rush: r.id, in: 0, out: r.duration! }))
105+ : [];
106+ emit("open", d.create(t("derush.timelines.defaultName", { n }), list));
107+ selected.value = [];
108+}
109+
110+// --- import ------------------------------------------------------------------------------
111+
112+const importing = computed(() => project.value.runtime.importing.filter((f) => f.target === "rushes"));
113+/** Fichiers en cours d'envoi au serveur. */
114+const uploads = ref<{ name: string; pct: number }[]>([]);
115+const over = ref(false);
116+const input = ref<HTMLInputElement | null>(null);
117+
118+async function send(files: File[]): Promise<void> {
119+ if (!files.length) return;
120+ uploads.value.push(...files.map((f) => ({ name: f.name, pct: 0 })));
121+ const mine = uploads.value.slice(-files.length);
122+ try {
123+ await upload(`${base.value}/rushes/upload`, files, "files", {}, (f) => {
124+ for (const u of mine) u.pct = Math.round(f * 100);
125+ });
126+ await refresh();
127+ } catch (err) {
128+ toast((err as Error).message, true);
129+ } finally {
130+ uploads.value = uploads.value.filter((u) => !mine.includes(u));
131+ }
132+}
133+
134+function onPick(e: Event) {
135+ const el = e.target as HTMLInputElement;
136+ send(Array.from(el.files ?? []));
137+ el.value = "";
138+}
139+
140+/** Des fichiers glissés depuis l'explorateur (pas une vignette de la médiathèque). */
141+function onDragOver(e: DragEvent) {
142+ if (!e.dataTransfer?.types.includes("Files")) return;
143+ e.preventDefault();
144+ over.value = true;
145+}
146+function onDrop(e: DragEvent) {
147+ over.value = false;
148+ send(droppedFiles(e));
149+}
150+</script>
151+
152+<template>
153+ <div class="pool" :class="{ over }" @dragover="onDragOver" @dragleave.self="over = false" @drop.prevent="onDrop">
154+ <div class="bar">
155+ <button class="btn sm dark" @click="input?.click()">{{ t("derush.pool.import") }}</button>
156+ <input ref="input" type="file" multiple hidden accept="video/*,.mov,.mp4,.mxf,.mkv,.mts,.m4v,.avi,.webm" @change="onPick">
157+ <Seg v-model="sort" :options="SORTS" size="sm" />
158+ <div class="spacer" />
159+ <button class="btn sm" :disabled="!toPlace.length" :title="t('derush.pool.placeTitle')" @click="placeByTime">
160+ {{ selected.length ? t("derush.pool.placeSelected", { n: selected.length }) : t("derush.pool.placeAll") }}
161+ </button>
162+ <button class="btn sm" :title="t('derush.pool.newTimelineTitle')" @click="newTimeline">{{ t("derush.pool.newTimeline") }}</button>
163+ </div>
164+
165+ <div class="eyebrow">{{ t("derush.pool.timelines") }}</div>
166+ <div class="grid">
167+ <div v-for="k in timelines" :key="k.id" class="tile" :class="{ open: k.open }" :draggable="k.nestable"
168+ :title="k.open ? t('derush.pool.openNow') : t('derush.pool.openTimeline')"
169+ @dragstart="setDrag($event, [{ tl: k.id }])" @click="emit('open', k.id)">
170+ <div class="thumb seq">
171+ <span class="seq-icon">⧉</span>
172+ <span v-if="k.main" class="main-badge">{{ t("derush.timelines.global") }}</span>
173+ <span class="dur mono">{{ mmss(k.length) }}</span>
174+ <button v-if="k.nestable" class="plus" :title="t('derush.pool.nest')" @click.stop="d.add([{ tl: k.id }])">+</button>
175+ </div>
176+ <div class="name">{{ k.name }}</div>
177+ </div>
178+ </div>
179+
180+ <div class="eyebrow">{{ t("derush.pool.rushes", { n: rushes.length }) }}</div>
181+ <div class="grid">
182+ <div class="tile add" @click="input?.click()">
183+ <div class="thumb">+</div>
184+ <div class="name">{{ t("derush.pool.drop") }}</div>
185+ </div>
186+ <div v-for="k in tiles" :key="k.rush.id" class="tile" :class="{ on: selected.includes(k.rush.id) }" draggable="true"
187+ :title="k.title" @dragstart="dragRush($event, k.rush.id)" @click="pick($event, k.rush.id)">
188+ <div class="thumb">
189+ <img :src="`${base}/rushes/${k.rush.id}/thumb`" alt="" loading="lazy" draggable="false"
190+ @error="($event.target as HTMLImageElement).style.visibility = 'hidden'">
191+ <span class="dur mono" :class="{ error: k.error }">{{ k.error ? t("derush.pool.error") : k.badge }}</span>
192+ <span v-if="used.has(k.rush.id)" class="check" :title="t('derush.pool.used')">✓</span>
193+ <div class="tools" @click.stop>
194+ <button :disabled="k.busy" :title="t('derush.pool.retranscribe')" @click="act(`/rushes/${k.rush.id}/transcribe`)">↻</button>
195+ <ConfirmX :label="t('derush.pool.removeConfirm')" :title="t('derush.pool.removeTitle')"
196+ @confirm="act(`/rushes/${k.rush.id}`, undefined, 'DELETE')" />
197+ </div>
198+ <button class="plus" :title="t('derush.pool.append')" @click.stop="d.add([{ rush: k.rush.id }])">+</button>
199+ <div v-if="k.busy" class="progress red"><div :style="{ width: `${k.bar * 100}%` }" /></div>
200+ </div>
201+ <div class="name mono">{{ k.rush.name }}</div>
202+ <div v-if="k.rush.time" class="time mono">{{ k.rush.time }}</div>
203+ </div>
204+ <div v-for="(f, j) in [...importing.map((x) => ({ name: x.name, pct: x.pct })), ...uploads]" :key="`up-${j}-${f.name}`" class="tile">
205+ <div class="thumb pending">
206+ <span class="dur mono">{{ t("derush.pool.importing", { p: f.pct }) }}</span>
207+ <div class="progress red"><div :style="{ width: `${f.pct}%` }" /></div>
208+ </div>
209+ <div class="name mono">{{ f.name }}</div>
210+ </div>
211+ </div>
212+ </div>
213+</template>
214+
215+<style scoped>
216+.pool { display: flex; flex-direction: column; gap: 10px; min-height: 100%; border-radius: 4px; }
217+.pool.over { outline: 2px dashed var(--ink); outline-offset: -2px; background: var(--bg-softer); }
218+.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
219+.spacer { flex: 1; }
220+.eyebrow { padding-top: 4px; }
221+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px 10px; }
222+
223+.tile { display: flex; flex-direction: column; gap: 4px; min-width: 0; cursor: pointer; }
224+.thumb {
225+ position: relative; aspect-ratio: 16 / 9; border-radius: 4px; overflow: hidden; background: #2B2C2E;
226+ display: flex; align-items: center; justify-content: center; color: #fff; outline: 2px solid transparent; outline-offset: 1px;
227+}
228+.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
229+.tile.on .thumb { outline-color: var(--red); }
230+.tile.open .thumb { outline-color: #3B82C4; }
231+.tile.add .thumb { background: var(--bg-soft); color: var(--muted); font-size: 28px; border: 1px dashed var(--dash); }
232+.tile.add:hover .thumb { border-color: var(--ink); color: var(--ink); }
233+.thumb.seq { background: repeating-linear-gradient(135deg, #2B3440 0 10px, #323C49 10px 20px); }
234+.thumb.pending { background: var(--bg-soft); }
235+.seq-icon { font-size: 26px; opacity: .8; }
236+.main-badge { position: absolute; top: 5px; left: 5px; font-size: 10.5px; padding: 1px 6px; border-radius: 3px; background: var(--red); color: #fff; }
237+.dur {
238+ position: absolute; left: 5px; bottom: 5px; font-size: 10.5px; padding: 1px 5px; border-radius: 3px;
239+ background: rgba(0, 0, 0, .65); color: #fff;
240+}
241+.thumb.pending .dur { background: none; color: var(--muted); position: static; }
242+.dur.error { background: var(--red); }
243+.check {
244+ position: absolute; right: 5px; bottom: 5px; width: 18px; height: 18px; border-radius: 50%;
245+ background: #3E9E6E; color: #fff; font-size: 11px; display: flex; align-items: center; justify-content: center;
246+}
247+.plus {
248+ position: absolute; right: 5px; bottom: 5px; width: 24px; height: 24px; border-radius: 50%; border: none; cursor: pointer;
249+ background: #fff; color: var(--ink); font-size: 16px; line-height: 1; display: none;
250+}
251+.tile:hover .plus { display: block; }
252+.tile:hover .check { display: none; }
253+.tools { position: absolute; top: 4px; right: 4px; display: none; gap: 2px; align-items: center; padding: 1px 3px; border-radius: 3px; background: rgba(255, 255, 255, .92); }
254+.tile:hover .tools { display: flex; }
255+.tools button:not(.armed):not(.x-btn) { border: none; background: none; cursor: pointer; font-size: 13px; color: var(--ink); padding: 0 3px; }
256+.tools button:disabled { color: var(--dash); cursor: default; }
257+.thumb .progress { position: absolute; left: 0; right: 0; bottom: 0; }
258+.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259+.name.mono { font-size: 11.5px; }
260+.time { font-size: 10.5px; color: var(--faint); margin-top: -3px; }
261+</style>
deleted web/src/project/steps/derush/RushPanel.vue +0 -246
deleted file mode 100644
@@ -1,246 +0,0 @@
1-<script setup lang="ts">
2-/** Colonne Rushes du dérush : import (même après coup), ordre de lecture, transcription et plans détectés. */
3-import { computed, ref, watch } from "vue";
4-import { useI18n } from "vue-i18n";
5-import ConfirmX from "../../../components/ConfirmX.vue";
6-import Seg from "../../../components/Seg.vue";
7-import { api } from "../../../lib/api";
8-import { mmss } from "../../../lib/format";
9-import { toast } from "../../../lib/toast";
10-import { droppedFiles, upload } from "../../../lib/upload";
11-import { act, refresh, store, update } from "../../store";
12-import type { DerushAnalysis, Project, Rush } from "../../types";
13-
14-defineProps<{ embedded?: boolean }>();
15-const emit = defineEmits<{ append: [rush: string] }>();
16-const { t } = useI18n();
17-const project = computed(() => store.project!);
18-const base = computed(() => `/api/projects/${project.value.id}`);
19-
20-// --- ordre --------------------------------------------------------------------------
21-
22-type Sort = Project["rush_sort"];
23-const SORTS = computed(() => [
24- ["Script", t("derush.panel.sorts.script")],
25- ["Heure", t("derush.panel.sorts.time")],
26- ["Manuel", t("derush.panel.sorts.manual")],
27-] as [Sort, string][]);
28-
29-/** « Manuel » ne se choisit pas : il s'applique dès qu'on déplace un rush. */
30-const sort = computed<Sort>({
31- get: () => project.value.rush_sort,
32- set: (mode) => {
33- if (mode !== "Manuel" && mode !== project.value.rush_sort) act("/rushes/sort", { mode });
34- },
35-});
36-
37-function move(i: number, d: number): void {
38- const ids = project.value.rushes.map((r) => r.id), j = i + d;
39- if (j < 0 || j >= ids.length) return;
40- [ids[i], ids[j]] = [ids[j], ids[i]];
41- update({ rush_order: ids, rush_sort: "Manuel" });
42-}
43-
44-/** Glisser-déposer d'une carte sur une autre : le rush prend sa place. */
45-const dragFrom = ref<number | null>(null);
46-const dragOver = ref<number | null>(null);
47-function dropOn(i: number): void {
48- const from = dragFrom.value;
49- dragFrom.value = dragOver.value = null;
50- if (from == null || from === i) return;
51- const ids = project.value.rushes.map((r) => r.id);
52- const [id] = ids.splice(from, 1);
53- ids.splice(i, 0, id);
54- update({ rush_order: ids, rush_sort: "Manuel" });
55-}
56-
57-function retranscribe(r: Rush): void {
58- act(`/rushes/${r.id}/transcribe`);
59-}
60-
61-function remove(r: Rush): void {
62- act(`/rushes/${r.id}`, undefined, "DELETE");
63-}
64-
65-// --- plans détectés (dérush), une fois tout transcrit --------------------------------
66-
67-const derush = ref<DerushAnalysis | null>(null);
68-const transcribed = computed(() => {
69- const p = project.value;
70- return p.rushes.length > 0 && p.rushes.every((r) => p.runtime.rush_transcripts[r.id]?.status === "done");
71-});
72-
73-watch(
74- () => (transcribed.value
75- ? project.value.rushes.map((r) => r.id).sort().join() + project.value.runtime.docs["script.md"]
76- : ""),
77- async (key) => {
78- if (!key) {
79- derush.value = null;
80- return;
81- }
82- try {
83- derush.value = await api<DerushAnalysis>(`${base.value}/derush`);
84- } catch {
85- derush.value = null;
86- }
87- },
88- { immediate: true },
89-);
90-
91-/** Libellés des sections du script dites dans ce rush. */
92-function sectionsOf(rid: string): string[] {
93- const d = derush.value;
94- if (!d) return [];
95- const ids = [...new Set(d.segs.filter((s) => s.rush === rid && s.sec).map((s) => s.sec as string))];
96- return ids.map((id) => d.sections.find((s) => s.id === id)?.label ?? id);
97-}
98-
99-const cards = computed(() => project.value.rushes.map((r, i) => {
100- const tr = project.value.runtime.rush_transcripts[r.id];
101- const status = tr?.status ?? "none";
102- const busy = !["done", "error", "cancelled", "none"].includes(status);
103- const secs = sectionsOf(r.id);
104- const detected = secs.length ? secs.join(", ")
105- : busy ? t("derush.panel.transcribingShort") : t("derush.panel.shotNotDetected");
106- return {
107- rush: r,
108- status: status === "error" ? t("derush.panel.error") : status === "queued" ? t("derush.panel.queued")
109- : busy ? t("derush.panel.transcription", { p: Math.round((tr?.progress ?? 0) * 100) }) : mmss(r.duration),
110- meta: [r.time && t("derush.panel.shotAt", { time: r.time }), detected].filter(Boolean).join(" · "),
111- bar: status === "done" ? 1 : busy ? tr?.progress ?? 0 : 0,
112- busy,
113- first: i === 0,
114- last: i === project.value.rushes.length - 1,
115- };
116-}));
117-
118-// --- import --------------------------------------------------------------------------
119-
120-const importing = computed(() => project.value.runtime.importing.filter((f) => f.target === "rushes"));
121-/** Fichiers glissés en cours d'envoi au serveur. */
122-const uploads = ref<{ name: string; pct: number }[]>([]);
123-const over = ref(false);
124-
125-function browse(): void {
126- act("/rushes/browse");
127-}
128-
129-async function onDrop(e: DragEvent): Promise<void> {
130- over.value = false;
131- const files = droppedFiles(e);
132- if (!files.length) return;
133- uploads.value.push(...files.map((f) => ({ name: f.name, pct: 0 })));
134- const mine = uploads.value.slice(-files.length);
135- try {
136- await upload(`${base.value}/rushes/upload`, files, "files", {}, (f) => {
137- for (const u of mine) u.pct = Math.round(f * 100);
138- });
139- await refresh();
140- } catch (err) {
141- toast((err as Error).message, true);
142- } finally {
143- uploads.value = uploads.value.filter((u) => !mine.includes(u));
144- }
145-}
146-
147-const pad = (n: number) => String(n).padStart(2, "0");
148-</script>
149-
150-<template>
151- <div class="rushes">
152- <div class="head">
153- <span v-if="!embedded" class="eyebrow">{{ t("derush.panel.title") }}</span>
154- <div v-else class="spacer" />
155- <button class="btn dark" @click="browse">{{ t("derush.panel.import") }}</button>
156- </div>
157- <div class="order">
158- <div class="order-row">
159- <span>{{ t("derush.panel.order") }}</span>
160- <Seg v-model="sort" :options="SORTS" size="sm" />
161- </div>
162- <span class="note">{{ t("derush.panel.note") }}</span>
163- </div>
164-
165- <div v-for="(c, i) in cards" :key="c.rush.id" class="card" draggable="true"
166- :class="{ dragging: dragFrom === i, over: dragOver === i && dragFrom !== i }"
167- @dragstart="dragFrom = i" @dragend="dragFrom = dragOver = null"
168- @dragover="dragFrom != null && ($event.preventDefault(), dragOver = i)" @drop.prevent="dropOn(i)">
169- <div class="line1">
170- <span class="name-wrap">
171- <span class="idx mono">{{ pad(i + 1) }}</span>
172- <span class="name mono" :title="c.rush.name">{{ c.rush.name }}</span>
173- </span>
174- <span class="status">{{ c.status }}</span>
175- <ConfirmX :label="t('derush.panel.removeConfirm')" :title="t('derush.panel.removeTitle')" @confirm="remove(c.rush)" />
176- </div>
177- <div class="line2">
178- <span class="meta">{{ c.meta }}</span>
179- <div class="moves">
180- <button v-if="embedded" class="add" :title="t('derush.panel.appendTitle')" @click="emit('append', c.rush.id)">{{ t("derush.panel.append") }}</button>
181- <button :disabled="c.busy" :title="t('derush.panel.retranscribe')" @click="retranscribe(c.rush)">↻</button>
182- <button :disabled="c.first" :title="t('derush.panel.moveUp')" @click="move(i, -1)">↑</button>
183- <button :disabled="c.last" :title="t('derush.panel.moveDown')" @click="move(i, 1)">↓</button>
184- </div>
185- </div>
186- <div class="progress"><div :style="{ width: `${c.bar * 100}%` }" /></div>
187- </div>
188-
189- <div v-for="(f, j) in importing" :key="`imp-${f.name}`" class="card">
190- <div class="line1">
191- <span class="name-wrap">
192- <span class="idx mono">{{ pad(cards.length + j + 1) }}</span>
193- <span class="name mono" :title="f.name">{{ f.name }}</span>
194- </span>
195- <span class="status">{{ f.error ? t("derush.panel.error") : t("derush.panel.importing", { p: f.pct }) }}</span>
196- </div>
197- <div class="progress red"><div :style="{ width: `${f.pct}%` }" /></div>
198- </div>
199-
200- <div v-for="(u, j) in uploads" :key="`up-${j}-${u.name}`" class="card">
201- <div class="line1">
202- <span class="name-wrap">
203- <span class="idx mono">{{ pad(cards.length + importing.length + j + 1) }}</span>
204- <span class="name mono" :title="u.name">{{ u.name }}</span>
205- </span>
206- <span class="status">{{ t("derush.panel.importing", { p: u.pct }) }}</span>
207- </div>
208- <div class="progress red"><div :style="{ width: `${u.pct}%` }" /></div>
209- </div>
210-
211- <div class="drop-zone" :class="{ over }" @click="browse" @dragover.prevent="over = true"
212- @dragleave="over = false" @drop.prevent="onDrop">
213- {{ t("derush.panel.drop") }}
214- </div>
215- </div>
216-</template>
217-
218-<style scoped>
219-.rushes { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
220-.head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 4px; }
221-.head .btn { padding: 0 14px; }
222-.order { display: flex; flex-direction: column; gap: 6px; }
223-.order-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
224-.note { font-size: 12px; color: var(--faint); line-height: 1.45; }
225-.card { display: flex; flex-direction: column; gap: 7px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; cursor: grab; background: #fff; }
226-.card.dragging { opacity: .4; }
227-.card.over { border-color: #3B82C4; box-shadow: 0 -3px 0 #3B82C4; }
228-.line1 { display: flex; align-items: baseline; gap: 8px; font-size: 13.5px; }
229-.name-wrap { display: flex; gap: 8px; min-width: 0; flex: 1; }
230-.idx { font-size: 11.5px; color: var(--faint); }
231-.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232-.status { color: var(--muted); flex-shrink: 0; }
233-.line1 > :last-child { align-self: center; }
234-.line2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
235-.meta { font-size: 12.5px; color: var(--muted); min-width: 0; }
236-.moves { display: flex; gap: 4px; flex-shrink: 0; }
237-.moves button {
238- width: 26px; height: 26px; padding: 0; border: 1px solid var(--line-strong); border-radius: 3px;
239- background: #fff; font-size: 12px; cursor: pointer; color: var(--ink);
240-}
241-.moves button:hover:not(:disabled) { border-color: var(--faint); }
242-.moves button:disabled { color: var(--dash); cursor: default; }
243-.moves button.add { width: auto; padding: 0 8px; font-size: 11.5px; }
244-.progress { background: var(--line-soft); }
245-.drop-zone { padding: 24px; }
246-</style>
deleted file mode 100644
@@ -1,246 +0,0 @@
1-<script setup lang="ts">
2-/** Colonne Rushes du dérush : import (même après coup), ordre de lecture, transcription et plans détectés. */
3-import { computed, ref, watch } from "vue";
4-import { useI18n } from "vue-i18n";
5-import ConfirmX from "../../../components/ConfirmX.vue";
6-import Seg from "../../../components/Seg.vue";
7-import { api } from "../../../lib/api";
8-import { mmss } from "../../../lib/format";
9-import { toast } from "../../../lib/toast";
10-import { droppedFiles, upload } from "../../../lib/upload";
11-import { act, refresh, store, update } from "../../store";
12-import type { DerushAnalysis, Project, Rush } from "../../types";
13-
14-defineProps<{ embedded?: boolean }>();
15-const emit = defineEmits<{ append: [rush: string] }>();
16-const { t } = useI18n();
17-const project = computed(() => store.project!);
18-const base = computed(() => `/api/projects/${project.value.id}`);
19-
20-// --- ordre --------------------------------------------------------------------------
21-
22-type Sort = Project["rush_sort"];
23-const SORTS = computed(() => [
24- ["Script", t("derush.panel.sorts.script")],
25- ["Heure", t("derush.panel.sorts.time")],
26- ["Manuel", t("derush.panel.sorts.manual")],
27-] as [Sort, string][]);
28-
29-/** « Manuel » ne se choisit pas : il s'applique dès qu'on déplace un rush. */
30-const sort = computed<Sort>({
31- get: () => project.value.rush_sort,
32- set: (mode) => {
33- if (mode !== "Manuel" && mode !== project.value.rush_sort) act("/rushes/sort", { mode });
34- },
35-});
36-
37-function move(i: number, d: number): void {
38- const ids = project.value.rushes.map((r) => r.id), j = i + d;
39- if (j < 0 || j >= ids.length) return;
40- [ids[i], ids[j]] = [ids[j], ids[i]];
41- update({ rush_order: ids, rush_sort: "Manuel" });
42-}
43-
44-/** Glisser-déposer d'une carte sur une autre : le rush prend sa place. */
45-const dragFrom = ref<number | null>(null);
46-const dragOver = ref<number | null>(null);
47-function dropOn(i: number): void {
48- const from = dragFrom.value;
49- dragFrom.value = dragOver.value = null;
50- if (from == null || from === i) return;
51- const ids = project.value.rushes.map((r) => r.id);
52- const [id] = ids.splice(from, 1);
53- ids.splice(i, 0, id);
54- update({ rush_order: ids, rush_sort: "Manuel" });
55-}
56-
57-function retranscribe(r: Rush): void {
58- act(`/rushes/${r.id}/transcribe`);
59-}
60-
61-function remove(r: Rush): void {
62- act(`/rushes/${r.id}`, undefined, "DELETE");
63-}
64-
65-// --- plans détectés (dérush), une fois tout transcrit --------------------------------
66-
67-const derush = ref<DerushAnalysis | null>(null);
68-const transcribed = computed(() => {
69- const p = project.value;
70- return p.rushes.length > 0 && p.rushes.every((r) => p.runtime.rush_transcripts[r.id]?.status === "done");
71-});
72-
73-watch(
74- () => (transcribed.value
75- ? project.value.rushes.map((r) => r.id).sort().join() + project.value.runtime.docs["script.md"]
76- : ""),
77- async (key) => {
78- if (!key) {
79- derush.value = null;
80- return;
81- }
82- try {
83- derush.value = await api<DerushAnalysis>(`${base.value}/derush`);
84- } catch {
85- derush.value = null;
86- }
87- },
88- { immediate: true },
89-);
90-
91-/** Libellés des sections du script dites dans ce rush. */
92-function sectionsOf(rid: string): string[] {
93- const d = derush.value;
94- if (!d) return [];
95- const ids = [...new Set(d.segs.filter((s) => s.rush === rid && s.sec).map((s) => s.sec as string))];
96- return ids.map((id) => d.sections.find((s) => s.id === id)?.label ?? id);
97-}
98-
99-const cards = computed(() => project.value.rushes.map((r, i) => {
100- const tr = project.value.runtime.rush_transcripts[r.id];
101- const status = tr?.status ?? "none";
102- const busy = !["done", "error", "cancelled", "none"].includes(status);
103- const secs = sectionsOf(r.id);
104- const detected = secs.length ? secs.join(", ")
105- : busy ? t("derush.panel.transcribingShort") : t("derush.panel.shotNotDetected");
106- return {
107- rush: r,
108- status: status === "error" ? t("derush.panel.error") : status === "queued" ? t("derush.panel.queued")
109- : busy ? t("derush.panel.transcription", { p: Math.round((tr?.progress ?? 0) * 100) }) : mmss(r.duration),
110- meta: [r.time && t("derush.panel.shotAt", { time: r.time }), detected].filter(Boolean).join(" · "),
111- bar: status === "done" ? 1 : busy ? tr?.progress ?? 0 : 0,
112- busy,
113- first: i === 0,
114- last: i === project.value.rushes.length - 1,
115- };
116-}));
117-
118-// --- import --------------------------------------------------------------------------
119-
120-const importing = computed(() => project.value.runtime.importing.filter((f) => f.target === "rushes"));
121-/** Fichiers glissés en cours d'envoi au serveur. */
122-const uploads = ref<{ name: string; pct: number }[]>([]);
123-const over = ref(false);
124-
125-function browse(): void {
126- act("/rushes/browse");
127-}
128-
129-async function onDrop(e: DragEvent): Promise<void> {
130- over.value = false;
131- const files = droppedFiles(e);
132- if (!files.length) return;
133- uploads.value.push(...files.map((f) => ({ name: f.name, pct: 0 })));
134- const mine = uploads.value.slice(-files.length);
135- try {
136- await upload(`${base.value}/rushes/upload`, files, "files", {}, (f) => {
137- for (const u of mine) u.pct = Math.round(f * 100);
138- });
139- await refresh();
140- } catch (err) {
141- toast((err as Error).message, true);
142- } finally {
143- uploads.value = uploads.value.filter((u) => !mine.includes(u));
144- }
145-}
146-
147-const pad = (n: number) => String(n).padStart(2, "0");
148-</script>
149-
150-<template>
151- <div class="rushes">
152- <div class="head">
153- <span v-if="!embedded" class="eyebrow">{{ t("derush.panel.title") }}</span>
154- <div v-else class="spacer" />
155- <button class="btn dark" @click="browse">{{ t("derush.panel.import") }}</button>
156- </div>
157- <div class="order">
158- <div class="order-row">
159- <span>{{ t("derush.panel.order") }}</span>
160- <Seg v-model="sort" :options="SORTS" size="sm" />
161- </div>
162- <span class="note">{{ t("derush.panel.note") }}</span>
163- </div>
164-
165- <div v-for="(c, i) in cards" :key="c.rush.id" class="card" draggable="true"
166- :class="{ dragging: dragFrom === i, over: dragOver === i && dragFrom !== i }"
167- @dragstart="dragFrom = i" @dragend="dragFrom = dragOver = null"
168- @dragover="dragFrom != null && ($event.preventDefault(), dragOver = i)" @drop.prevent="dropOn(i)">
169- <div class="line1">
170- <span class="name-wrap">
171- <span class="idx mono">{{ pad(i + 1) }}</span>
172- <span class="name mono" :title="c.rush.name">{{ c.rush.name }}</span>
173- </span>
174- <span class="status">{{ c.status }}</span>
175- <ConfirmX :label="t('derush.panel.removeConfirm')" :title="t('derush.panel.removeTitle')" @confirm="remove(c.rush)" />
176- </div>
177- <div class="line2">
178- <span class="meta">{{ c.meta }}</span>
179- <div class="moves">
180- <button v-if="embedded" class="add" :title="t('derush.panel.appendTitle')" @click="emit('append', c.rush.id)">{{ t("derush.panel.append") }}</button>
181- <button :disabled="c.busy" :title="t('derush.panel.retranscribe')" @click="retranscribe(c.rush)">↻</button>
182- <button :disabled="c.first" :title="t('derush.panel.moveUp')" @click="move(i, -1)">↑</button>
183- <button :disabled="c.last" :title="t('derush.panel.moveDown')" @click="move(i, 1)">↓</button>
184- </div>
185- </div>
186- <div class="progress"><div :style="{ width: `${c.bar * 100}%` }" /></div>
187- </div>
188-
189- <div v-for="(f, j) in importing" :key="`imp-${f.name}`" class="card">
190- <div class="line1">
191- <span class="name-wrap">
192- <span class="idx mono">{{ pad(cards.length + j + 1) }}</span>
193- <span class="name mono" :title="f.name">{{ f.name }}</span>
194- </span>
195- <span class="status">{{ f.error ? t("derush.panel.error") : t("derush.panel.importing", { p: f.pct }) }}</span>
196- </div>
197- <div class="progress red"><div :style="{ width: `${f.pct}%` }" /></div>
198- </div>
199-
200- <div v-for="(u, j) in uploads" :key="`up-${j}-${u.name}`" class="card">
201- <div class="line1">
202- <span class="name-wrap">
203- <span class="idx mono">{{ pad(cards.length + importing.length + j + 1) }}</span>
204- <span class="name mono" :title="u.name">{{ u.name }}</span>
205- </span>
206- <span class="status">{{ t("derush.panel.importing", { p: u.pct }) }}</span>
207- </div>
208- <div class="progress red"><div :style="{ width: `${u.pct}%` }" /></div>
209- </div>
210-
211- <div class="drop-zone" :class="{ over }" @click="browse" @dragover.prevent="over = true"
212- @dragleave="over = false" @drop.prevent="onDrop">
213- {{ t("derush.panel.drop") }}
214- </div>
215- </div>
216-</template>
217-
218-<style scoped>
219-.rushes { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
220-.head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 4px; }
221-.head .btn { padding: 0 14px; }
222-.order { display: flex; flex-direction: column; gap: 6px; }
223-.order-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
224-.note { font-size: 12px; color: var(--faint); line-height: 1.45; }
225-.card { display: flex; flex-direction: column; gap: 7px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; cursor: grab; background: #fff; }
226-.card.dragging { opacity: .4; }
227-.card.over { border-color: #3B82C4; box-shadow: 0 -3px 0 #3B82C4; }
228-.line1 { display: flex; align-items: baseline; gap: 8px; font-size: 13.5px; }
229-.name-wrap { display: flex; gap: 8px; min-width: 0; flex: 1; }
230-.idx { font-size: 11.5px; color: var(--faint); }
231-.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232-.status { color: var(--muted); flex-shrink: 0; }
233-.line1 > :last-child { align-self: center; }
234-.line2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
235-.meta { font-size: 12.5px; color: var(--muted); min-width: 0; }
236-.moves { display: flex; gap: 4px; flex-shrink: 0; }
237-.moves button {
238- width: 26px; height: 26px; padding: 0; border: 1px solid var(--line-strong); border-radius: 3px;
239- background: #fff; font-size: 12px; cursor: pointer; color: var(--ink);
240-}
241-.moves button:hover:not(:disabled) { border-color: var(--faint); }
242-.moves button:disabled { color: var(--dash); cursor: default; }
243-.moves button.add { width: auto; padding: 0 8px; font-size: 11.5px; }
244-.progress { background: var(--line-soft); }
245-.drop-zone { padding: 24px; }
246-</style>
modified web/src/project/steps/derush/Timeline.vue +46 -12
@@ -5,18 +5,21 @@
55 * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage.
66 * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque
77 * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche.
8+ * Une timeline imbriquée est un seul bloc (double-clic : on l'ouvre). On y dépose rushes et timelines de la médiathèque.
89 */
910 import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
1011 import { useI18n } from "vue-i18n";
1112 import { mmss } from "../../../lib/format";
12-import type { DerushClip, DerushSeg } from "../../types";
13+import type { DerushClip, DerushSeg, TimelineClip } from "../../types";
1314 import { clipLen, offsets, type Selection } from "./clips";
15+import { MEDIA_TYPE, type MediaItem } from "./useDerush";
1416 import type { Playlist } from "./usePlaylist";
1517
1618 export interface Peaks { rate: number; peaks: number[] }
1719
1820 const props = defineProps<{
19- clips: DerushClip[]; player: Playlist; names: Record<string, string>; colors: Record<string, string>;
21+ /** les bouts de la timeline (rushes et timelines imbriquées) ; `flat` : les bouts de rushes qu'elle lit */
22+ clips: TimelineClip[]; flat: DerushClip[]; player: Playlist; names: Record<string, string>; colors: Record<string, string>;
2023 durations: Record<string, number>; segs: Record<string, DerushSeg[]>; peaks: Record<string, Peaks | undefined>;
2124 tool: "select" | "blade";
2225 }>();
@@ -24,6 +27,8 @@ const emit = defineEmits<{
2427 move: [ids: string[], before: string | null];
2528 trim: [id: string, side: "in" | "out", value: number];
2629 split: [t: number];
30+ insert: [items: MediaItem[], before: string | null];
31+ open: [tl: string];
2732 }>();
2833 const sel = defineModel<Selection>("selection", { required: true });
2934 const { t: tr } = useI18n();
@@ -121,7 +126,7 @@ const clipsV = computed(() => props.clips.map((c, i) => {
121126 return { c, i, a, b };
122127 }).filter((k) => k.b >= v0.value && k.a <= v1.value).map((k) => {
123128 const marks: Mark[] = [];
124- for (const s of props.segs[k.c.rush] ?? []) {
129+ for (const s of props.segs[k.c.rush ?? ""] ?? []) {
125130 if (s.type === "sil" || s.end <= k.c.in || s.start >= k.c.out) continue;
126131 const sa = k.a + Math.max(s.start, k.c.in) - k.c.in, sb = k.a + Math.min(s.end, k.c.out) - k.c.in;
127132 if (sb < v0.value || sa > v1.value) continue;
@@ -143,7 +148,7 @@ function drawWave() {
143148 g.setTransform(dpr, 0, 0, dpr, 0, 0);
144149 g.clearRect(0, 0, w, h);
145150 g.fillStyle = "#3E9E6E";
146- const cs = props.clips, o = starts.value;
151+ const cs = props.flat, o = offsets(cs);
147152 let i = 0;
148153 for (let px = 0; px < w; px++) {
149154 const d = (scrollX.value + px) / pps.value;
@@ -163,7 +168,7 @@ function redraw() {
163168 cancelAnimationFrame(raf);
164169 raf = requestAnimationFrame(drawWave);
165170 }
166-watch([() => props.peaks, pps, scrollX, viewW, () => props.clips], redraw);
171+watch([() => props.peaks, pps, scrollX, viewW, () => props.flat], redraw);
167172 onMounted(redraw);
168173
169174 // --- souris
@@ -222,11 +227,11 @@ function downEdge(e: PointerEvent, i: number, side: "in" | "out") {
222227 if (e.button !== 0 || props.tool === "blade") return;
223228 e.stopPropagation();
224229 const c = props.clips[i], pts: number[] = [];
225- for (const s of props.segs[c.rush] ?? []) {
230+ for (const s of props.segs[c.rush ?? ""] ?? []) {
226231 pts.push(s.start, s.end);
227232 for (const w of s.words) pts.push(w[0], w[1]);
228233 }
229- const dur = props.durations[c.rush] ?? c.out;
234+ const dur = props.durations[c.rush ?? c.tl ?? ""] ?? c.out;
230235 const edge = side === "in" ? c.in : c.out;
231236 drag = { kind: "trim", i, side, edge, d0: timeAt(e), to: edge, pts,
232237 min: side === "in" ? 0 : c.in + 0.05, max: side === "in" ? c.out - 0.05 : dur };
@@ -295,6 +300,26 @@ function onUp() {
295300 }
296301 }
297302
303+// --- dépôt depuis la médiathèque : avant le bout dont le milieu est après la souris
304+const dropAt = ref<{ before: string | null; at: number } | null>(null);
305+function placeAt(e: DragEvent) {
306+ const d = timeAt(e), k = props.clips.findIndex((c, i) => starts.value[i] + clipLen(c) / 2 > d);
307+ return k < 0 ? { before: null, at: total.value } : { before: props.clips[k].id, at: starts.value[k] };
308+}
309+function onDragOver(e: DragEvent) {
310+ if (!e.dataTransfer?.types.includes(MEDIA_TYPE)) return;
311+ e.preventDefault();
312+ e.dataTransfer.dropEffect = "copy";
313+ dropAt.value = placeAt(e);
314+}
315+function onDrop(e: DragEvent) {
316+ const raw = e.dataTransfer?.getData(MEDIA_TYPE);
317+ dropAt.value = null;
318+ if (!raw) return;
319+ e.preventDefault();
320+ emit("insert", JSON.parse(raw) as MediaItem[], placeAt(e).before);
321+}
322+
298323 const fmtDelta = (s: number) => `${s > 0 ? "+" : "−"}${Math.abs(s).toFixed(2)} s`;
299324 const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
300325 </script>
@@ -307,7 +332,8 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
307332 <div class="lab" :style="{ top: `${top.a}px`, height: `${A}px` }">A1</div>
308333 </div>
309334 <div ref="scroller" class="scroller" :style="{ height: `${height + 12}px` }"
310- @wheel="onWheel" @scroll="scrollX = ($event.target as HTMLElement).scrollLeft">
335+ @wheel="onWheel" @scroll="scrollX = ($event.target as HTMLElement).scrollLeft"
336+ @dragover="onDragOver" @dragleave="dropAt = null" @drop="onDrop">
311337 <div class="content" :style="{ width: `${width}px`, height: `${height}px` }"
312338 @pointerdown="downTrack" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp">
313339 <!-- règle -->
@@ -323,17 +349,24 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
323349
324350 <!-- bouts de rushes -->
325351 <div v-for="k in clipsV" :key="k.c.id" class="clip"
326- :class="{ on: selIds.has(k.c.id), moving: moving?.ids.includes(k.c.id) }"
352+ :class="{ on: selIds.has(k.c.id), moving: moving?.ids.includes(k.c.id), nested: !!k.c.tl }"
327353 :style="{ top: `${top.v}px`, height: `${V}px`, left: `${x(k.a)}px`, width: `${Math.max(2, x(k.b - k.a))}px`,
328- background: colors[k.c.rush], transform: moving?.ids.includes(k.c.id) ? `translateX(${x(moving.dx)}px)` : '' }"
329- :title="`${names[k.c.rush]} · ${mmss(k.c.in)} → ${mmss(k.c.out)}`" @pointerdown="downClip($event, k.c.id)" @dblclick="tool !== 'blade' && player.seek(k.a)">
330- <span v-if="x(k.b - k.a) > 24" class="name">{{ shortName(names[k.c.rush] ?? k.c.rush) }} <small>{{ mmss(k.c.in) }}</small></span>
354+ background: k.c.tl ? undefined : colors[k.c.rush!], transform: moving?.ids.includes(k.c.id) ? `translateX(${x(moving.dx)}px)` : '' }"
355+ :title="k.c.tl ? tr('derush.timeline.nested', { name: names[k.c.tl] }) : `${names[k.c.rush!]} · ${mmss(k.c.in)} → ${mmss(k.c.out)}`"
356+ @pointerdown="downClip($event, k.c.id)"
357+ @dblclick="tool !== 'blade' && (k.c.tl ? emit('open', k.c.tl) : player.seek(k.a))">
358+ <span v-if="x(k.b - k.a) > 24" class="name">
359+ <template v-if="k.c.tl">⧉ {{ names[k.c.tl] }}</template>
360+ <template v-else>{{ shortName(names[k.c.rush!] ?? k.c.rush) }}</template>
361+ <small> {{ mmss(k.c.in) }}</small>
362+ </span>
331363 <span v-for="m in k.marks" :key="m.key" class="mark" :class="m.type" :style="{ left: `${m.left}px`, width: `${m.w}px` }">
332364 <em v-if="m.w > 50">{{ m.text }}</em>
333365 </span>
334366 <i class="edge l" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'in')" />
335367 <i class="edge r" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'out')" />
336368 </div>
369+ <div v-if="dropAt" class="drop-at" :style="{ left: `${x(dropAt.at)}px`, top: `${top.v - 2}px`, height: `${V + A + GAP + 4}px` }" />
337370 <div v-if="moving" class="drop-at" :style="{ left: `${x(moving.at)}px`, top: `${top.v - 2}px`, height: `${V + A + GAP + 4}px` }" />
338371
339372 <canvas ref="canvas" class="wave" :style="{ top: `${top.a}px`, left: `${scrollX}px`, width: `${viewW}px`, height: `${A}px` }" />
@@ -373,6 +406,7 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
373406 position: absolute; box-sizing: border-box; overflow: hidden; border-radius: 4px; cursor: grab; z-index: 2;
374407 border: 1px solid rgba(255, 255, 255, .9); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25);
375408 }
409+.clip.nested { background: repeating-linear-gradient(135deg, #2B3440 0 10px, #323C49 10px 20px); }
376410 .clip.on { box-shadow: inset 0 0 0 2px var(--red), 0 0 0 1px var(--red); z-index: 3; }
377411 .clip.moving { opacity: .75; cursor: grabbing; z-index: 6; box-shadow: 0 6px 16px rgba(0, 0, 0, .25); }
378412 .name {
@@ -5,18 +5,21 @@
5 * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage.5 * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage.
6 * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque6 * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque
7 * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche.7 * la parole (les trous sont des silences, les pointillés du son non transcrit) ; zoomé, le texte des phrases s'affiche.
8+ * Une timeline imbriquée est un seul bloc (double-clic : on l'ouvre). On y dépose rushes et timelines de la médiathèque.
8 */9 */
9 import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";10 import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
10 import { useI18n } from "vue-i18n";11 import { useI18n } from "vue-i18n";
11 import { mmss } from "../../../lib/format";12 import { mmss } from "../../../lib/format";
12-import type { DerushClip, DerushSeg } from "../../types";13+import type { DerushClip, DerushSeg, TimelineClip } from "../../types";
13 import { clipLen, offsets, type Selection } from "./clips";14 import { clipLen, offsets, type Selection } from "./clips";
15+import { MEDIA_TYPE, type MediaItem } from "./useDerush";
14 import type { Playlist } from "./usePlaylist";16 import type { Playlist } from "./usePlaylist";
15 17
16 export interface Peaks { rate: number; peaks: number[] }18 export interface Peaks { rate: number; peaks: number[] }
17 19
18 const props = defineProps<{20 const props = defineProps<{
19- clips: DerushClip[]; player: Playlist; names: Record<string, string>; colors: Record<string, string>;21+ /** les bouts de la timeline (rushes et timelines imbriquées) ; `flat` : les bouts de rushes qu'elle lit */
22+ clips: TimelineClip[]; flat: DerushClip[]; player: Playlist; names: Record<string, string>; colors: Record<string, string>;
20 durations: Record<string, number>; segs: Record<string, DerushSeg[]>; peaks: Record<string, Peaks | undefined>;23 durations: Record<string, number>; segs: Record<string, DerushSeg[]>; peaks: Record<string, Peaks | undefined>;
21 tool: "select" | "blade";24 tool: "select" | "blade";
22 }>();25 }>();
@@ -24,6 +27,8 @@ const emit = defineEmits<{
24 move: [ids: string[], before: string | null];27 move: [ids: string[], before: string | null];
25 trim: [id: string, side: "in" | "out", value: number];28 trim: [id: string, side: "in" | "out", value: number];
26 split: [t: number];29 split: [t: number];
30+ insert: [items: MediaItem[], before: string | null];
31+ open: [tl: string];
27 }>();32 }>();
28 const sel = defineModel<Selection>("selection", { required: true });33 const sel = defineModel<Selection>("selection", { required: true });
29 const { t: tr } = useI18n();34 const { t: tr } = useI18n();
@@ -121,7 +126,7 @@ const clipsV = computed(() => props.clips.map((c, i) => {
121 return { c, i, a, b };126 return { c, i, a, b };
122 }).filter((k) => k.b >= v0.value && k.a <= v1.value).map((k) => {127 }).filter((k) => k.b >= v0.value && k.a <= v1.value).map((k) => {
123 const marks: Mark[] = [];128 const marks: Mark[] = [];
124- for (const s of props.segs[k.c.rush] ?? []) {129+ for (const s of props.segs[k.c.rush ?? ""] ?? []) {
125 if (s.type === "sil" || s.end <= k.c.in || s.start >= k.c.out) continue;130 if (s.type === "sil" || s.end <= k.c.in || s.start >= k.c.out) continue;
126 const sa = k.a + Math.max(s.start, k.c.in) - k.c.in, sb = k.a + Math.min(s.end, k.c.out) - k.c.in;131 const sa = k.a + Math.max(s.start, k.c.in) - k.c.in, sb = k.a + Math.min(s.end, k.c.out) - k.c.in;
127 if (sb < v0.value || sa > v1.value) continue;132 if (sb < v0.value || sa > v1.value) continue;
@@ -143,7 +148,7 @@ function drawWave() {
143 g.setTransform(dpr, 0, 0, dpr, 0, 0);148 g.setTransform(dpr, 0, 0, dpr, 0, 0);
144 g.clearRect(0, 0, w, h);149 g.clearRect(0, 0, w, h);
145 g.fillStyle = "#3E9E6E";150 g.fillStyle = "#3E9E6E";
146- const cs = props.clips, o = starts.value;151+ const cs = props.flat, o = offsets(cs);
147 let i = 0;152 let i = 0;
148 for (let px = 0; px < w; px++) {153 for (let px = 0; px < w; px++) {
149 const d = (scrollX.value + px) / pps.value;154 const d = (scrollX.value + px) / pps.value;
@@ -163,7 +168,7 @@ function redraw() {
163 cancelAnimationFrame(raf);168 cancelAnimationFrame(raf);
164 raf = requestAnimationFrame(drawWave);169 raf = requestAnimationFrame(drawWave);
165 }170 }
166-watch([() => props.peaks, pps, scrollX, viewW, () => props.clips], redraw);171+watch([() => props.peaks, pps, scrollX, viewW, () => props.flat], redraw);
167 onMounted(redraw);172 onMounted(redraw);
168 173
169 // --- souris174 // --- souris
@@ -222,11 +227,11 @@ function downEdge(e: PointerEvent, i: number, side: "in" | "out") {
222 if (e.button !== 0 || props.tool === "blade") return;227 if (e.button !== 0 || props.tool === "blade") return;
223 e.stopPropagation();228 e.stopPropagation();
224 const c = props.clips[i], pts: number[] = [];229 const c = props.clips[i], pts: number[] = [];
225- for (const s of props.segs[c.rush] ?? []) {230+ for (const s of props.segs[c.rush ?? ""] ?? []) {
226 pts.push(s.start, s.end);231 pts.push(s.start, s.end);
227 for (const w of s.words) pts.push(w[0], w[1]);232 for (const w of s.words) pts.push(w[0], w[1]);
228 }233 }
229- const dur = props.durations[c.rush] ?? c.out;234+ const dur = props.durations[c.rush ?? c.tl ?? ""] ?? c.out;
230 const edge = side === "in" ? c.in : c.out;235 const edge = side === "in" ? c.in : c.out;
231 drag = { kind: "trim", i, side, edge, d0: timeAt(e), to: edge, pts,236 drag = { kind: "trim", i, side, edge, d0: timeAt(e), to: edge, pts,
232 min: side === "in" ? 0 : c.in + 0.05, max: side === "in" ? c.out - 0.05 : dur };237 min: side === "in" ? 0 : c.in + 0.05, max: side === "in" ? c.out - 0.05 : dur };
@@ -295,6 +300,26 @@ function onUp() {
295 }300 }
296 }301 }
297 302
303+// --- dépôt depuis la médiathèque : avant le bout dont le milieu est après la souris
304+const dropAt = ref<{ before: string | null; at: number } | null>(null);
305+function placeAt(e: DragEvent) {
306+ const d = timeAt(e), k = props.clips.findIndex((c, i) => starts.value[i] + clipLen(c) / 2 > d);
307+ return k < 0 ? { before: null, at: total.value } : { before: props.clips[k].id, at: starts.value[k] };
308+}
309+function onDragOver(e: DragEvent) {
310+ if (!e.dataTransfer?.types.includes(MEDIA_TYPE)) return;
311+ e.preventDefault();
312+ e.dataTransfer.dropEffect = "copy";
313+ dropAt.value = placeAt(e);
314+}
315+function onDrop(e: DragEvent) {
316+ const raw = e.dataTransfer?.getData(MEDIA_TYPE);
317+ dropAt.value = null;
318+ if (!raw) return;
319+ e.preventDefault();
320+ emit("insert", JSON.parse(raw) as MediaItem[], placeAt(e).before);
321+}
322+
298 const fmtDelta = (s: number) => `${s > 0 ? "+" : "−"}${Math.abs(s).toFixed(2)} s`;323 const fmtDelta = (s: number) => `${s > 0 ? "+" : "−"}${Math.abs(s).toFixed(2)} s`;
299 const shortName = (n: string) => n.replace(/\.[^.]+$/, "");324 const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
300 </script>325 </script>
@@ -307,7 +332,8 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
307 <div class="lab" :style="{ top: `${top.a}px`, height: `${A}px` }">A1</div>332 <div class="lab" :style="{ top: `${top.a}px`, height: `${A}px` }">A1</div>
308 </div>333 </div>
309 <div ref="scroller" class="scroller" :style="{ height: `${height + 12}px` }"334 <div ref="scroller" class="scroller" :style="{ height: `${height + 12}px` }"
310- @wheel="onWheel" @scroll="scrollX = ($event.target as HTMLElement).scrollLeft">335+ @wheel="onWheel" @scroll="scrollX = ($event.target as HTMLElement).scrollLeft"
336+ @dragover="onDragOver" @dragleave="dropAt = null" @drop="onDrop">
311 <div class="content" :style="{ width: `${width}px`, height: `${height}px` }"337 <div class="content" :style="{ width: `${width}px`, height: `${height}px` }"
312 @pointerdown="downTrack" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp">338 @pointerdown="downTrack" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp">
313 <!-- règle -->339 <!-- règle -->
@@ -323,17 +349,24 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
323 349
324 <!-- bouts de rushes -->350 <!-- bouts de rushes -->
325 <div v-for="k in clipsV" :key="k.c.id" class="clip"351 <div v-for="k in clipsV" :key="k.c.id" class="clip"
326- :class="{ on: selIds.has(k.c.id), moving: moving?.ids.includes(k.c.id) }"352+ :class="{ on: selIds.has(k.c.id), moving: moving?.ids.includes(k.c.id), nested: !!k.c.tl }"
327 :style="{ top: `${top.v}px`, height: `${V}px`, left: `${x(k.a)}px`, width: `${Math.max(2, x(k.b - k.a))}px`,353 :style="{ top: `${top.v}px`, height: `${V}px`, left: `${x(k.a)}px`, width: `${Math.max(2, x(k.b - k.a))}px`,
328- background: colors[k.c.rush], transform: moving?.ids.includes(k.c.id) ? `translateX(${x(moving.dx)}px)` : '' }"354+ background: k.c.tl ? undefined : colors[k.c.rush!], transform: moving?.ids.includes(k.c.id) ? `translateX(${x(moving.dx)}px)` : '' }"
329- :title="`${names[k.c.rush]} · ${mmss(k.c.in)} → ${mmss(k.c.out)}`" @pointerdown="downClip($event, k.c.id)" @dblclick="tool !== 'blade' && player.seek(k.a)">355+ :title="k.c.tl ? tr('derush.timeline.nested', { name: names[k.c.tl] }) : `${names[k.c.rush!]} · ${mmss(k.c.in)} → ${mmss(k.c.out)}`"
330- <span v-if="x(k.b - k.a) > 24" class="name">{{ shortName(names[k.c.rush] ?? k.c.rush) }} <small>{{ mmss(k.c.in) }}</small></span>356+ @pointerdown="downClip($event, k.c.id)"
357+ @dblclick="tool !== 'blade' && (k.c.tl ? emit('open', k.c.tl) : player.seek(k.a))">
358+ <span v-if="x(k.b - k.a) > 24" class="name">
359+ <template v-if="k.c.tl">⧉ {{ names[k.c.tl] }}</template>
360+ <template v-else>{{ shortName(names[k.c.rush!] ?? k.c.rush) }}</template>
361+ <small> {{ mmss(k.c.in) }}</small>
362+ </span>
331 <span v-for="m in k.marks" :key="m.key" class="mark" :class="m.type" :style="{ left: `${m.left}px`, width: `${m.w}px` }">363 <span v-for="m in k.marks" :key="m.key" class="mark" :class="m.type" :style="{ left: `${m.left}px`, width: `${m.w}px` }">
332 <em v-if="m.w > 50">{{ m.text }}</em>364 <em v-if="m.w > 50">{{ m.text }}</em>
333 </span>365 </span>
334 <i class="edge l" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'in')" />366 <i class="edge l" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'in')" />
335 <i class="edge r" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'out')" />367 <i class="edge r" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'out')" />
336 </div>368 </div>
369+ <div v-if="dropAt" class="drop-at" :style="{ left: `${x(dropAt.at)}px`, top: `${top.v - 2}px`, height: `${V + A + GAP + 4}px` }" />
337 <div v-if="moving" class="drop-at" :style="{ left: `${x(moving.at)}px`, top: `${top.v - 2}px`, height: `${V + A + GAP + 4}px` }" />370 <div v-if="moving" class="drop-at" :style="{ left: `${x(moving.at)}px`, top: `${top.v - 2}px`, height: `${V + A + GAP + 4}px` }" />
338 371
339 <canvas ref="canvas" class="wave" :style="{ top: `${top.a}px`, left: `${scrollX}px`, width: `${viewW}px`, height: `${A}px` }" />372 <canvas ref="canvas" class="wave" :style="{ top: `${top.a}px`, left: `${scrollX}px`, width: `${viewW}px`, height: `${A}px` }" />
@@ -373,6 +406,7 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, "");
373 position: absolute; box-sizing: border-box; overflow: hidden; border-radius: 4px; cursor: grab; z-index: 2;406 position: absolute; box-sizing: border-box; overflow: hidden; border-radius: 4px; cursor: grab; z-index: 2;
374 border: 1px solid rgba(255, 255, 255, .9); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25);407 border: 1px solid rgba(255, 255, 255, .9); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25);
375 }408 }
409+.clip.nested { background: repeating-linear-gradient(135deg, #2B3440 0 10px, #323C49 10px 20px); }
376 .clip.on { box-shadow: inset 0 0 0 2px var(--red), 0 0 0 1px var(--red); z-index: 3; }410 .clip.on { box-shadow: inset 0 0 0 2px var(--red), 0 0 0 1px var(--red); z-index: 3; }
377 .clip.moving { opacity: .75; cursor: grabbing; z-index: 6; box-shadow: 0 6px 16px rgba(0, 0, 0, .25); }411 .clip.moving { opacity: .75; cursor: grabbing; z-index: 6; box-shadow: 0 6px 16px rgba(0, 0, 0, .25); }
378 .name {412 .name {
modified web/src/project/steps/derush/clips.ts +52 -15
@@ -1,24 +1,28 @@
11 /**
2- * La timeline du dérush : une liste de bouts de rushes ({rush, in, out}) lus dans l'ordre.
3- * Opérations sans état : chacune rend une nouvelle liste. Les temps « t » sont ceux de la timeline.
2+ * Une timeline du dérush : une liste de bouts lus dans l'ordre, de rushes ({rush, in, out}) ou d'autres
3+ * timelines imbriquées ({tl, in, out}). Opérations sans état : chacune rend une nouvelle liste.
4+ * Les temps « t » sont ceux de la timeline.
45 */
5-import type { DerushClip } from "../../types";
6+import type { DerushClip, DerushTimeline, TimelineClip } from "../../types";
7+
8+/** Ce que les opérations demandent d'un bout. */
9+type Span = { id: string; in: number; out: number; rush?: string };
610
711 /** En dessous, un bout n'est pas gardé. */
812 const MIN = 0.04;
913 const r3 = (n: number) => Math.round(n * 1000) / 1000;
1014
11-export const clipLen = (c: DerushClip) => c.out - c.in;
15+export const clipLen = (c: Span) => c.out - c.in;
1216 export const newId = () => `c${Math.random().toString(36).slice(2, 10)}`;
1317
1418 /** Début de chaque bout sur la timeline. */
15-export function offsets(clips: DerushClip[]): number[] {
19+export function offsets(clips: Span[]): number[] {
1620 let a = 0;
1721 return clips.map((c) => { const o = a; a += clipLen(c); return o; });
1822 }
1923
2024 /** Le bout sous `t` et l'instant correspondant dans son rush. */
21-export function clipAt(clips: DerushClip[], t: number) {
25+export function clipAt<T extends Span>(clips: T[], t: number) {
2226 const o = offsets(clips);
2327 for (let i = clips.length - 1; i >= 0; i--) {
2428 if (o[i] <= t) return { i, c: clips[i], at: clips[i].in + Math.min(t - o[i], clipLen(clips[i])) };
@@ -27,7 +31,7 @@ export function clipAt(clips: DerushClip[], t: number) {
2731 }
2832
2933 /** L'instant `t` d'un rush sur la timeline : dans le bout qui le contient, sinon au début du bout suivant de ce rush. */
30-export function timeOf(clips: DerushClip[], rush: string, t: number): number | null {
34+export function timeOf(clips: Span[], rush: string, t: number): number | null {
3135 const o = offsets(clips);
3236 let next: number | null = null, gap = Infinity;
3337 clips.forEach((c, k) => {
@@ -39,7 +43,7 @@ export function timeOf(clips: DerushClip[], rush: string, t: number): number | n
3943 }
4044
4145 /** Coupe le bout sous `t` en deux. */
42-export function splitAt(clips: DerushClip[], t: number): DerushClip[] {
46+export function splitAt<T extends Span>(clips: T[], t: number): T[] {
4347 const o = offsets(clips);
4448 const i = clips.findIndex((c, k) => o[k] + MIN < t && t < o[k] + clipLen(c) - MIN);
4549 if (i < 0) return clips;
@@ -48,8 +52,8 @@ export function splitAt(clips: DerushClip[], t: number): DerushClip[] {
4852 }
4953
5054 /** Retire la plage [a, b] de la timeline (les bouts touchés sont recoupés). */
51-export function removeRange(clips: DerushClip[], a: number, b: number): DerushClip[] {
52- const o = offsets(clips), out: DerushClip[] = [];
55+export function removeRange<T extends Span>(clips: T[], a: number, b: number): T[] {
56+ const o = offsets(clips), out: T[] = [];
5357 clips.forEach((c, k) => {
5458 const s = o[k], e = s + clipLen(c);
5559 if (e <= a || s >= b) { out.push(c); return; }
@@ -60,11 +64,11 @@ export function removeRange(clips: DerushClip[], a: number, b: number): DerushCl
6064 }
6165
6266 /** Retire des plages d'un rush ([début, fin] en secondes du rush) de tous les bouts de ce rush. */
63-export function removeRushRanges(clips: DerushClip[], rush: string, ranges: [number, number][]): DerushClip[] {
67+export function removeRushRanges<T extends Span>(clips: T[], rush: string, ranges: [number, number][]): T[] {
6468 const sorted = [...ranges].sort((x, y) => x[0] - y[0]);
6569 return clips.flatMap((c) => {
6670 if (c.rush !== rush) return [c];
67- const parts: DerushClip[] = [];
71+ const parts: T[] = [];
6872 let from = c.in;
6973 for (const [a, b] of sorted) {
7074 if (b <= from || a >= c.out) continue;
@@ -76,19 +80,19 @@ export function removeRushRanges(clips: DerushClip[], rush: string, ranges: [num
7680 });
7781 }
7882
79-export function remove(clips: DerushClip[], ids: string[]): DerushClip[] {
83+export function remove<T extends Span>(clips: T[], ids: string[]): T[] {
8084 return clips.filter((c) => !ids.includes(c.id));
8185 }
8286
8387 /** Déplace des bouts juste avant le bout `before` (à la fin s'il est nul). */
84-export function move(clips: DerushClip[], ids: string[], before: string | null): DerushClip[] {
88+export function move<T extends Span>(clips: T[], ids: string[], before: string | null): T[] {
8589 const moving = clips.filter((c) => ids.includes(c.id)), rest = clips.filter((c) => !ids.includes(c.id));
8690 const at = before ? rest.findIndex((c) => c.id === before) : -1;
8791 return at < 0 ? [...rest, ...moving] : [...rest.slice(0, at), ...moving, ...rest.slice(at)];
8892 }
8993
9094 /** Déplace le début (`in`) ou la fin (`out`) d'un bout dans son rush. */
91-export function trim(clips: DerushClip[], id: string, side: "in" | "out", value: number, duration: number): DerushClip[] {
95+export function trim<T extends Span>(clips: T[], id: string, side: "in" | "out", value: number, duration: number): T[] {
9296 return clips.map((c) => {
9397 if (c.id !== id) return c;
9498 return side === "in"
@@ -97,5 +101,38 @@ export function trim(clips: DerushClip[], id: string, side: "in" | "out", value:
97101 });
98102 }
99103
104+/** Insère des bouts juste avant le bout `before` (à la fin s'il est nul). */
105+export function insert<T extends Span>(clips: T[], added: T[], before: string | null): T[] {
106+ const at = before ? clips.findIndex((c) => c.id === before) : -1;
107+ return at < 0 ? [...clips, ...added] : [...clips.slice(0, at), ...added, ...clips.slice(at)];
108+}
109+
110+/**
111+ * Les bouts de rushes que lit une timeline, ses timelines imbriquées dépliées sur leur plage [in, out].
112+ * Chaque bout déplié garde l'id du bout imbriqué dont il vient (la sélection et le transcript le suivent).
113+ * `own` : les bouts d'une timeline tels qu'on les édite.
114+ */
115+export function flatten(own: (id: string) => TimelineClip[], id: string, seen: string[] = []): DerushClip[] {
116+ const out: DerushClip[] = [];
117+ for (const c of own(id)) {
118+ if (c.rush) { out.push(c as DerushClip); continue; }
119+ if (!c.tl || c.tl === id || seen.includes(c.tl)) continue;
120+ let cursor = 0;
121+ for (const sub of flatten(own, c.tl, [...seen, id])) {
122+ const len = clipLen(sub), a = Math.max(c.in, cursor), b = Math.min(c.out, cursor + len);
123+ if (b - a > 0.001) out.push({ id: c.id, rush: sub.rush, in: r3(sub.in + a - cursor), out: r3(sub.in + b - cursor) });
124+ cursor += len;
125+ }
126+ }
127+ return out;
128+}
129+
130+/** La timeline `id` contient-elle `target`, même de loin ? (l'y imbriquer ferait une boucle) */
131+export function contains(timelines: DerushTimeline[], id: string, target: string, seen: string[] = []): boolean {
132+ if (id === target) return true;
133+ const tl = timelines.find((k) => k.id === id);
134+ return (tl?.clips ?? []).some((c) => !!c.tl && !seen.includes(c.tl) && contains(timelines, c.tl, target, [...seen, id]));
135+}
136+
100137 /** La sélection : des bouts, ou une plage de la timeline. */
101138 export type Selection = { clips: string[] } | { range: [number, number] } | null;
@@ -1,24 +1,28 @@
1 /**1 /**
2- * La timeline du dérush : une liste de bouts de rushes ({rush, in, out}) lus dans l'ordre.2+ * Une timeline du dérush : une liste de bouts lus dans l'ordre, de rushes ({rush, in, out}) ou d'autres
3- * Opérations sans état : chacune rend une nouvelle liste. Les temps « t » sont ceux de la timeline.3+ * timelines imbriquées ({tl, in, out}). Opérations sans état : chacune rend une nouvelle liste.
4+ * Les temps « t » sont ceux de la timeline.
4 */5 */
5-import type { DerushClip } from "../../types";6+import type { DerushClip, DerushTimeline, TimelineClip } from "../../types";
7+
8+/** Ce que les opérations demandent d'un bout. */
9+type Span = { id: string; in: number; out: number; rush?: string };
6 10
7 /** En dessous, un bout n'est pas gardé. */11 /** En dessous, un bout n'est pas gardé. */
8 const MIN = 0.04;12 const MIN = 0.04;
9 const r3 = (n: number) => Math.round(n * 1000) / 1000;13 const r3 = (n: number) => Math.round(n * 1000) / 1000;
10 14
11-export const clipLen = (c: DerushClip) => c.out - c.in;15+export const clipLen = (c: Span) => c.out - c.in;
12 export const newId = () => `c${Math.random().toString(36).slice(2, 10)}`;16 export const newId = () => `c${Math.random().toString(36).slice(2, 10)}`;
13 17
14 /** Début de chaque bout sur la timeline. */18 /** Début de chaque bout sur la timeline. */
15-export function offsets(clips: DerushClip[]): number[] {19+export function offsets(clips: Span[]): number[] {
16 let a = 0;20 let a = 0;
17 return clips.map((c) => { const o = a; a += clipLen(c); return o; });21 return clips.map((c) => { const o = a; a += clipLen(c); return o; });
18 }22 }
19 23
20 /** Le bout sous `t` et l'instant correspondant dans son rush. */24 /** Le bout sous `t` et l'instant correspondant dans son rush. */
21-export function clipAt(clips: DerushClip[], t: number) {25+export function clipAt<T extends Span>(clips: T[], t: number) {
22 const o = offsets(clips);26 const o = offsets(clips);
23 for (let i = clips.length - 1; i >= 0; i--) {27 for (let i = clips.length - 1; i >= 0; i--) {
24 if (o[i] <= t) return { i, c: clips[i], at: clips[i].in + Math.min(t - o[i], clipLen(clips[i])) };28 if (o[i] <= t) return { i, c: clips[i], at: clips[i].in + Math.min(t - o[i], clipLen(clips[i])) };
@@ -27,7 +31,7 @@ export function clipAt(clips: DerushClip[], t: number) {
27 }31 }
28 32
29 /** L'instant `t` d'un rush sur la timeline : dans le bout qui le contient, sinon au début du bout suivant de ce rush. */33 /** L'instant `t` d'un rush sur la timeline : dans le bout qui le contient, sinon au début du bout suivant de ce rush. */
30-export function timeOf(clips: DerushClip[], rush: string, t: number): number | null {34+export function timeOf(clips: Span[], rush: string, t: number): number | null {
31 const o = offsets(clips);35 const o = offsets(clips);
32 let next: number | null = null, gap = Infinity;36 let next: number | null = null, gap = Infinity;
33 clips.forEach((c, k) => {37 clips.forEach((c, k) => {
@@ -39,7 +43,7 @@ export function timeOf(clips: DerushClip[], rush: string, t: number): number | n
39 }43 }
40 44
41 /** Coupe le bout sous `t` en deux. */45 /** Coupe le bout sous `t` en deux. */
42-export function splitAt(clips: DerushClip[], t: number): DerushClip[] {46+export function splitAt<T extends Span>(clips: T[], t: number): T[] {
43 const o = offsets(clips);47 const o = offsets(clips);
44 const i = clips.findIndex((c, k) => o[k] + MIN < t && t < o[k] + clipLen(c) - MIN);48 const i = clips.findIndex((c, k) => o[k] + MIN < t && t < o[k] + clipLen(c) - MIN);
45 if (i < 0) return clips;49 if (i < 0) return clips;
@@ -48,8 +52,8 @@ export function splitAt(clips: DerushClip[], t: number): DerushClip[] {
48 }52 }
49 53
50 /** Retire la plage [a, b] de la timeline (les bouts touchés sont recoupés). */54 /** Retire la plage [a, b] de la timeline (les bouts touchés sont recoupés). */
51-export function removeRange(clips: DerushClip[], a: number, b: number): DerushClip[] {55+export function removeRange<T extends Span>(clips: T[], a: number, b: number): T[] {
52- const o = offsets(clips), out: DerushClip[] = [];56+ const o = offsets(clips), out: T[] = [];
53 clips.forEach((c, k) => {57 clips.forEach((c, k) => {
54 const s = o[k], e = s + clipLen(c);58 const s = o[k], e = s + clipLen(c);
55 if (e <= a || s >= b) { out.push(c); return; }59 if (e <= a || s >= b) { out.push(c); return; }
@@ -60,11 +64,11 @@ export function removeRange(clips: DerushClip[], a: number, b: number): DerushCl
60 }64 }
61 65
62 /** Retire des plages d'un rush ([début, fin] en secondes du rush) de tous les bouts de ce rush. */66 /** Retire des plages d'un rush ([début, fin] en secondes du rush) de tous les bouts de ce rush. */
63-export function removeRushRanges(clips: DerushClip[], rush: string, ranges: [number, number][]): DerushClip[] {67+export function removeRushRanges<T extends Span>(clips: T[], rush: string, ranges: [number, number][]): T[] {
64 const sorted = [...ranges].sort((x, y) => x[0] - y[0]);68 const sorted = [...ranges].sort((x, y) => x[0] - y[0]);
65 return clips.flatMap((c) => {69 return clips.flatMap((c) => {
66 if (c.rush !== rush) return [c];70 if (c.rush !== rush) return [c];
67- const parts: DerushClip[] = [];71+ const parts: T[] = [];
68 let from = c.in;72 let from = c.in;
69 for (const [a, b] of sorted) {73 for (const [a, b] of sorted) {
70 if (b <= from || a >= c.out) continue;74 if (b <= from || a >= c.out) continue;
@@ -76,19 +80,19 @@ export function removeRushRanges(clips: DerushClip[], rush: string, ranges: [num
76 });80 });
77 }81 }
78 82
79-export function remove(clips: DerushClip[], ids: string[]): DerushClip[] {83+export function remove<T extends Span>(clips: T[], ids: string[]): T[] {
80 return clips.filter((c) => !ids.includes(c.id));84 return clips.filter((c) => !ids.includes(c.id));
81 }85 }
82 86
83 /** Déplace des bouts juste avant le bout `before` (à la fin s'il est nul). */87 /** Déplace des bouts juste avant le bout `before` (à la fin s'il est nul). */
84-export function move(clips: DerushClip[], ids: string[], before: string | null): DerushClip[] {88+export function move<T extends Span>(clips: T[], ids: string[], before: string | null): T[] {
85 const moving = clips.filter((c) => ids.includes(c.id)), rest = clips.filter((c) => !ids.includes(c.id));89 const moving = clips.filter((c) => ids.includes(c.id)), rest = clips.filter((c) => !ids.includes(c.id));
86 const at = before ? rest.findIndex((c) => c.id === before) : -1;90 const at = before ? rest.findIndex((c) => c.id === before) : -1;
87 return at < 0 ? [...rest, ...moving] : [...rest.slice(0, at), ...moving, ...rest.slice(at)];91 return at < 0 ? [...rest, ...moving] : [...rest.slice(0, at), ...moving, ...rest.slice(at)];
88 }92 }
89 93
90 /** Déplace le début (`in`) ou la fin (`out`) d'un bout dans son rush. */94 /** Déplace le début (`in`) ou la fin (`out`) d'un bout dans son rush. */
91-export function trim(clips: DerushClip[], id: string, side: "in" | "out", value: number, duration: number): DerushClip[] {95+export function trim<T extends Span>(clips: T[], id: string, side: "in" | "out", value: number, duration: number): T[] {
92 return clips.map((c) => {96 return clips.map((c) => {
93 if (c.id !== id) return c;97 if (c.id !== id) return c;
94 return side === "in"98 return side === "in"
@@ -97,5 +101,38 @@ export function trim(clips: DerushClip[], id: string, side: "in" | "out", value:
97 });101 });
98 }102 }
99 103
104+/** Insère des bouts juste avant le bout `before` (à la fin s'il est nul). */
105+export function insert<T extends Span>(clips: T[], added: T[], before: string | null): T[] {
106+ const at = before ? clips.findIndex((c) => c.id === before) : -1;
107+ return at < 0 ? [...clips, ...added] : [...clips.slice(0, at), ...added, ...clips.slice(at)];
108+}
109+
110+/**
111+ * Les bouts de rushes que lit une timeline, ses timelines imbriquées dépliées sur leur plage [in, out].
112+ * Chaque bout déplié garde l'id du bout imbriqué dont il vient (la sélection et le transcript le suivent).
113+ * `own` : les bouts d'une timeline tels qu'on les édite.
114+ */
115+export function flatten(own: (id: string) => TimelineClip[], id: string, seen: string[] = []): DerushClip[] {
116+ const out: DerushClip[] = [];
117+ for (const c of own(id)) {
118+ if (c.rush) { out.push(c as DerushClip); continue; }
119+ if (!c.tl || c.tl === id || seen.includes(c.tl)) continue;
120+ let cursor = 0;
121+ for (const sub of flatten(own, c.tl, [...seen, id])) {
122+ const len = clipLen(sub), a = Math.max(c.in, cursor), b = Math.min(c.out, cursor + len);
123+ if (b - a > 0.001) out.push({ id: c.id, rush: sub.rush, in: r3(sub.in + a - cursor), out: r3(sub.in + b - cursor) });
124+ cursor += len;
125+ }
126+ }
127+ return out;
128+}
129+
130+/** La timeline `id` contient-elle `target`, même de loin ? (l'y imbriquer ferait une boucle) */
131+export function contains(timelines: DerushTimeline[], id: string, target: string, seen: string[] = []): boolean {
132+ if (id === target) return true;
133+ const tl = timelines.find((k) => k.id === id);
134+ return (tl?.clips ?? []).some((c) => !!c.tl && !seen.includes(c.tl) && contains(timelines, c.tl, target, [...seen, id]));
135+}
136+
100 /** La sélection : des bouts, ou une plage de la timeline. */137 /** La sélection : des bouts, ou une plage de la timeline. */
101 export type Selection = { clips: string[] } | { range: [number, number] } | null;138 export type Selection = { clips: string[] } | { range: [number, number] } | null;
modified web/src/project/steps/derush/useDerush.ts +99 -33
@@ -4,8 +4,15 @@ import { t } from "../../../i18n";
44 import { api } from "../../../lib/api";
55 import { toast } from "../../../lib/toast";
66 import { update } from "../../store";
7-import type { DerushAnalysis, DerushClip, DerushSeg, Project } from "../../types";
8-import { clipLen, newId } from "./clips";
7+import type { DerushAnalysis, DerushSeg, DerushTimeline, Project, TimelineClip } from "../../types";
8+import { clipLen, contains, flatten, insert, newId } from "./clips";
9+
10+/** Ce qu'on pose depuis la médiathèque : un rush entier ou une timeline. */
11+export interface MediaItem { rush?: string; tl?: string }
12+/** Le type des données glissées de la médiathèque vers la timeline. */
13+export const MEDIA_TYPE = "application/x-studio-media";
14+
15+const r3 = (n: number) => Math.round(n * 1000) / 1000;
916
1017 const FINISHED = ["done", "error", "cancelled", "none"];
1118
@@ -37,49 +44,104 @@ export function useDerush(project: Ref<Project>) {
3744 return out;
3845 });
3946
40- /** La timeline : celle de l'utilisateur, sinon celle déduite par le serveur (anciennes coupes, rushes entiers). */
41- const clips = computed<DerushClip[]>(() => {
42- const known = new Set(project.value.rushes.map((r) => r.id));
43- const list = project.value.derush.clips ?? analysis.value?.clips
44- ?? project.value.rushes.filter((r) => r.duration).map((r) => ({ id: r.id, rush: r.id, in: 0, out: r.duration! }));
45- return list.filter((c) => known.has(c.rush));
46- });
47- const kept = computed(() => clips.value.reduce((a, c) => a + clipLen(c), 0));
47+ // --- timelines : la globale (celle qu'on exporte) et celles qu'on monte à part, rush par rush ou séquence par séquence
48+ const timelines = computed(() => project.value.derush.timelines);
49+ const mainId = computed(() => project.value.derush.main);
50+ /** La timeline ouverte. */
51+ const current = ref(mainId.value);
52+ watch(timelines, (list) => { if (!list.some((k) => k.id === current.value)) current.value = mainId.value; });
53+
54+ /** Les bouts d'une timeline, tels qu'on les édite ; la globale jamais retouchée découle des rushes (serveur). */
55+ function own(id: string): TimelineClip[] {
56+ const tl = timelines.value.find((k) => k.id === id);
57+ if (!tl) return [];
58+ const rushes = new Set(project.value.rushes.map((r) => r.id)), ids = new Set(timelines.value.map((k) => k.id));
59+ const list: TimelineClip[] = tl.clips ?? (id !== mainId.value ? [] : analysis.value?.clips
60+ ?? project.value.rushes.filter((r) => r.duration).map((r) => ({ id: r.id, rush: r.id, in: 0, out: r.duration! })));
61+ return list.filter((c) => (c.tl ? ids.has(c.tl) : rushes.has(c.rush!)));
62+ }
63+ /** La timeline ouverte : ses bouts (rushes et timelines imbriquées), et les bouts de rushes qu'elle lit. */
64+ /** La durée lue par une timeline. */
65+ const lengthOf = (id: string) => flatten(own, id).reduce((a, c) => a + clipLen(c), 0);
66+ // une timeline imbriquée raccourcie depuis : son bout s'arrête là où elle s'arrête
67+ const clips = computed(() => own(current.value).flatMap((c) => {
68+ if (!c.tl) return [c];
69+ const out = Math.min(c.out, r3(lengthOf(c.tl)));
70+ return out - c.in < 0.04 ? [] : [out === c.out ? c : { ...c, out }];
71+ }));
72+ const flat = computed(() => flatten(own, current.value));
73+ const kept = computed(() => flat.value.reduce((a, c) => a + clipLen(c), 0));
74+ const nameOf = (id: string) => timelines.value.find((k) => k.id === id)?.name
75+ || (id === mainId.value ? t("derush.timelines.main") : id);
76+ /** Peut-on imbriquer `id` dans la timeline ouverte ? (pas elle-même, ni une qui la contient déjà) */
77+ const canNest = (id: string) => !contains(timelines.value, id, current.value);
4878
49- // --- retouches, avec annuler / rétablir
50- const past: DerushClip[][] = [], future: DerushClip[][] = [];
79+ function save(list: DerushTimeline[], delay = 0) {
80+ update({ timelines: list }, delay);
81+ }
82+ const put = (id: string, list: TimelineClip[]) =>
83+ save(timelines.value.map((k) => (k.id === id ? { ...k, clips: list } : k)), 300);
84+
85+ // --- retouches, avec annuler / rétablir (qui rouvrent la timeline retouchée)
86+ type Step = { tl: string; clips: TimelineClip[] };
87+ const past: Step[] = [], future: Step[] = [];
5188 const history = ref(0);
52- const put = (list: DerushClip[]) => update({ clips: list }, 300);
53- /** Applique une retouche à la timeline. */
54- function edit(fn: (clips: DerushClip[]) => DerushClip[]) {
89+ /** Applique une retouche à la timeline ouverte. */
90+ function edit(fn: (clips: TimelineClip[]) => TimelineClip[]) {
5591 const before = clips.value, next = fn(before);
5692 if (next === before) return;
57- past.push(before);
93+ past.push({ tl: current.value, clips: before });
5894 if (past.length > 200) past.shift();
5995 future.length = 0;
6096 history.value++;
61- put(next);
62- }
63- function undo() {
64- const s = past.pop();
65- if (!s) return;
66- future.push(clips.value);
67- put(s);
68- history.value++;
97+ put(current.value, next);
6998 }
70- function redo() {
71- const s = future.pop();
99+ function restore(from: Step[], to: Step[]) {
100+ const s = from.pop();
72101 if (!s) return;
73- past.push(clips.value);
74- put(s);
102+ to.push({ tl: s.tl, clips: own(s.tl) });
103+ put(s.tl, s.clips);
104+ current.value = s.tl;
75105 history.value++;
76106 }
107+ const undo = () => restore(past, future);
108+ const redo = () => restore(future, past);
77109 const canUndo = computed(() => history.value >= 0 && past.length > 0);
78110 const canRedo = computed(() => history.value >= 0 && future.length > 0);
79- /** Remet un rush entier au bout de la timeline. */
80- function append(rush: string) {
81- const r = project.value.rushes.find((k) => k.id === rush);
82- if (r?.duration) edit((list) => [...list, { id: newId(), rush, in: 0, out: r.duration! }]);
111+
112+ /** Un rush entier ou une timeline, prêt à poser (null : vide, ou l'imbriquer ferait une boucle). */
113+ function clipOf(item: MediaItem): TimelineClip | null {
114+ if (item.rush) {
115+ const d = project.value.rushes.find((k) => k.id === item.rush)?.duration;
116+ return d ? { id: newId(), rush: item.rush, in: 0, out: d } : null;
117+ }
118+ const d = item.tl && canNest(item.tl) ? lengthOf(item.tl) : 0;
119+ return d > 0 ? { id: newId(), tl: item.tl, in: 0, out: r3(d) } : null;
120+ }
121+ /** Pose des rushes entiers ou des timelines avant le bout `before` (à la fin s'il est nul). */
122+ function add(items: MediaItem[], before: string | null = null) {
123+ const added = items.map(clipOf).filter((c): c is TimelineClip => !!c);
124+ if (added.length) edit((list) => insert(list, added, before));
125+ }
126+
127+ /** Nouvelle timeline (vide, ou avec ces bouts), qui s'ouvre. La globale est figée : elle ne suit plus les rushes. */
128+ function create(name: string, list: TimelineClip[] = []) {
129+ const id = `t${Math.random().toString(36).slice(2, 8)}`;
130+ const frozen = timelines.value.map((k) => (k.clips === null ? { ...k, clips: own(k.id) } : k));
131+ save([...frozen, { id, name, clips: list }]);
132+ current.value = id;
133+ return id;
134+ }
135+ function rename(id: string, name: string) {
136+ save(timelines.value.map((k) => (k.id === id ? { ...k, name: name.trim() } : k)));
137+ }
138+ /** Supprime une timeline (jamais la globale), et ses imbrications dans les autres. */
139+ function drop(id: string) {
140+ if (id === mainId.value) return;
141+ save(timelines.value.filter((k) => k.id !== id)
142+ .map((k) => (k.clips?.some((c) => c.tl === id) ? { ...k, clips: k.clips.filter((c) => c.tl !== id) } : k)));
143+ for (const stack of [past, future]) stack.splice(0, stack.length, ...stack.filter((s) => s.tl !== id));
144+ history.value++;
83145 }
84146
85147 /** Transcriptions des rushes en cours, et leur avancement moyen (%). */
@@ -91,5 +153,9 @@ export function useDerush(project: Ref<Project>) {
91153 return all.length ? Math.round(sum / all.length * 100) : 0;
92154 });
93155
94- return { analysis, ready, segsByRush, clips, kept, transcribing, progress, edit, undo, redo, canUndo, canRedo, append };
156+ return {
157+ analysis, ready, segsByRush, transcribing, progress,
158+ timelines, mainId, current, clips, flat, kept, lengthOf, nameOf, canNest,
159+ edit, undo, redo, canUndo, canRedo, add, create, rename, drop,
160+ };
95161 }
@@ -4,8 +4,15 @@ import { t } from "../../../i18n";
4 import { api } from "../../../lib/api";4 import { api } from "../../../lib/api";
5 import { toast } from "../../../lib/toast";5 import { toast } from "../../../lib/toast";
6 import { update } from "../../store";6 import { update } from "../../store";
7-import type { DerushAnalysis, DerushClip, DerushSeg, Project } from "../../types";7+import type { DerushAnalysis, DerushSeg, DerushTimeline, Project, TimelineClip } from "../../types";
8-import { clipLen, newId } from "./clips";8+import { clipLen, contains, flatten, insert, newId } from "./clips";
9+
10+/** Ce qu'on pose depuis la médiathèque : un rush entier ou une timeline. */
11+export interface MediaItem { rush?: string; tl?: string }
12+/** Le type des données glissées de la médiathèque vers la timeline. */
13+export const MEDIA_TYPE = "application/x-studio-media";
14+
15+const r3 = (n: number) => Math.round(n * 1000) / 1000;
9 16
10 const FINISHED = ["done", "error", "cancelled", "none"];17 const FINISHED = ["done", "error", "cancelled", "none"];
11 18
@@ -37,49 +44,104 @@ export function useDerush(project: Ref<Project>) {
37 return out;44 return out;
38 });45 });
39 46
40- /** La timeline : celle de l'utilisateur, sinon celle déduite par le serveur (anciennes coupes, rushes entiers). */47+ // --- timelines : la globale (celle qu'on exporte) et celles qu'on monte à part, rush par rush ou séquence par séquence
41- const clips = computed<DerushClip[]>(() => {48+ const timelines = computed(() => project.value.derush.timelines);
42- const known = new Set(project.value.rushes.map((r) => r.id));49+ const mainId = computed(() => project.value.derush.main);
43- const list = project.value.derush.clips ?? analysis.value?.clips50+ /** La timeline ouverte. */
44- ?? project.value.rushes.filter((r) => r.duration).map((r) => ({ id: r.id, rush: r.id, in: 0, out: r.duration! }));51+ const current = ref(mainId.value);
45- return list.filter((c) => known.has(c.rush));52+ watch(timelines, (list) => { if (!list.some((k) => k.id === current.value)) current.value = mainId.value; });
46- });53+
47- const kept = computed(() => clips.value.reduce((a, c) => a + clipLen(c), 0));54+ /** Les bouts d'une timeline, tels qu'on les édite ; la globale jamais retouchée découle des rushes (serveur). */
55+ function own(id: string): TimelineClip[] {
56+ const tl = timelines.value.find((k) => k.id === id);
57+ if (!tl) return [];
58+ const rushes = new Set(project.value.rushes.map((r) => r.id)), ids = new Set(timelines.value.map((k) => k.id));
59+ const list: TimelineClip[] = tl.clips ?? (id !== mainId.value ? [] : analysis.value?.clips
60+ ?? project.value.rushes.filter((r) => r.duration).map((r) => ({ id: r.id, rush: r.id, in: 0, out: r.duration! })));
61+ return list.filter((c) => (c.tl ? ids.has(c.tl) : rushes.has(c.rush!)));
62+ }
63+ /** La timeline ouverte : ses bouts (rushes et timelines imbriquées), et les bouts de rushes qu'elle lit. */
64+ /** La durée lue par une timeline. */
65+ const lengthOf = (id: string) => flatten(own, id).reduce((a, c) => a + clipLen(c), 0);
66+ // une timeline imbriquée raccourcie depuis : son bout s'arrête là où elle s'arrête
67+ const clips = computed(() => own(current.value).flatMap((c) => {
68+ if (!c.tl) return [c];
69+ const out = Math.min(c.out, r3(lengthOf(c.tl)));
70+ return out - c.in < 0.04 ? [] : [out === c.out ? c : { ...c, out }];
71+ }));
72+ const flat = computed(() => flatten(own, current.value));
73+ const kept = computed(() => flat.value.reduce((a, c) => a + clipLen(c), 0));
74+ const nameOf = (id: string) => timelines.value.find((k) => k.id === id)?.name
75+ || (id === mainId.value ? t("derush.timelines.main") : id);
76+ /** Peut-on imbriquer `id` dans la timeline ouverte ? (pas elle-même, ni une qui la contient déjà) */
77+ const canNest = (id: string) => !contains(timelines.value, id, current.value);
48 78
49- // --- retouches, avec annuler / rétablir79+ function save(list: DerushTimeline[], delay = 0) {
50- const past: DerushClip[][] = [], future: DerushClip[][] = [];80+ update({ timelines: list }, delay);
81+ }
82+ const put = (id: string, list: TimelineClip[]) =>
83+ save(timelines.value.map((k) => (k.id === id ? { ...k, clips: list } : k)), 300);
84+
85+ // --- retouches, avec annuler / rétablir (qui rouvrent la timeline retouchée)
86+ type Step = { tl: string; clips: TimelineClip[] };
87+ const past: Step[] = [], future: Step[] = [];
51 const history = ref(0);88 const history = ref(0);
52- const put = (list: DerushClip[]) => update({ clips: list }, 300);89+ /** Applique une retouche à la timeline ouverte. */
53- /** Applique une retouche à la timeline. */90+ function edit(fn: (clips: TimelineClip[]) => TimelineClip[]) {
54- function edit(fn: (clips: DerushClip[]) => DerushClip[]) {
55 const before = clips.value, next = fn(before);91 const before = clips.value, next = fn(before);
56 if (next === before) return;92 if (next === before) return;
57- past.push(before);93+ past.push({ tl: current.value, clips: before });
58 if (past.length > 200) past.shift();94 if (past.length > 200) past.shift();
59 future.length = 0;95 future.length = 0;
60 history.value++;96 history.value++;
61- put(next);97+ put(current.value, next);
62- }
63- function undo() {
64- const s = past.pop();
65- if (!s) return;
66- future.push(clips.value);
67- put(s);
68- history.value++;
69 }98 }
70- function redo() {99+ function restore(from: Step[], to: Step[]) {
71- const s = future.pop();100+ const s = from.pop();
72 if (!s) return;101 if (!s) return;
73- past.push(clips.value);102+ to.push({ tl: s.tl, clips: own(s.tl) });
74- put(s);103+ put(s.tl, s.clips);
104+ current.value = s.tl;
75 history.value++;105 history.value++;
76 }106 }
107+ const undo = () => restore(past, future);
108+ const redo = () => restore(future, past);
77 const canUndo = computed(() => history.value >= 0 && past.length > 0);109 const canUndo = computed(() => history.value >= 0 && past.length > 0);
78 const canRedo = computed(() => history.value >= 0 && future.length > 0);110 const canRedo = computed(() => history.value >= 0 && future.length > 0);
79- /** Remet un rush entier au bout de la timeline. */111+
80- function append(rush: string) {112+ /** Un rush entier ou une timeline, prêt à poser (null : vide, ou l'imbriquer ferait une boucle). */
81- const r = project.value.rushes.find((k) => k.id === rush);113+ function clipOf(item: MediaItem): TimelineClip | null {
82- if (r?.duration) edit((list) => [...list, { id: newId(), rush, in: 0, out: r.duration! }]);114+ if (item.rush) {
115+ const d = project.value.rushes.find((k) => k.id === item.rush)?.duration;
116+ return d ? { id: newId(), rush: item.rush, in: 0, out: d } : null;
117+ }
118+ const d = item.tl && canNest(item.tl) ? lengthOf(item.tl) : 0;
119+ return d > 0 ? { id: newId(), tl: item.tl, in: 0, out: r3(d) } : null;
120+ }
121+ /** Pose des rushes entiers ou des timelines avant le bout `before` (à la fin s'il est nul). */
122+ function add(items: MediaItem[], before: string | null = null) {
123+ const added = items.map(clipOf).filter((c): c is TimelineClip => !!c);
124+ if (added.length) edit((list) => insert(list, added, before));
125+ }
126+
127+ /** Nouvelle timeline (vide, ou avec ces bouts), qui s'ouvre. La globale est figée : elle ne suit plus les rushes. */
128+ function create(name: string, list: TimelineClip[] = []) {
129+ const id = `t${Math.random().toString(36).slice(2, 8)}`;
130+ const frozen = timelines.value.map((k) => (k.clips === null ? { ...k, clips: own(k.id) } : k));
131+ save([...frozen, { id, name, clips: list }]);
132+ current.value = id;
133+ return id;
134+ }
135+ function rename(id: string, name: string) {
136+ save(timelines.value.map((k) => (k.id === id ? { ...k, name: name.trim() } : k)));
137+ }
138+ /** Supprime une timeline (jamais la globale), et ses imbrications dans les autres. */
139+ function drop(id: string) {
140+ if (id === mainId.value) return;
141+ save(timelines.value.filter((k) => k.id !== id)
142+ .map((k) => (k.clips?.some((c) => c.tl === id) ? { ...k, clips: k.clips.filter((c) => c.tl !== id) } : k)));
143+ for (const stack of [past, future]) stack.splice(0, stack.length, ...stack.filter((s) => s.tl !== id));
144+ history.value++;
83 }145 }
84 146
85 /** Transcriptions des rushes en cours, et leur avancement moyen (%). */147 /** Transcriptions des rushes en cours, et leur avancement moyen (%). */
@@ -91,5 +153,9 @@ export function useDerush(project: Ref<Project>) {
91 return all.length ? Math.round(sum / all.length * 100) : 0;153 return all.length ? Math.round(sum / all.length * 100) : 0;
92 });154 });
93 155
94- return { analysis, ready, segsByRush, clips, kept, transcribing, progress, edit, undo, redo, canUndo, canRedo, append };156+ return {
157+ analysis, ready, segsByRush, transcribing, progress,
158+ timelines, mainId, current, clips, flat, kept, lengthOf, nameOf, canNest,
159+ edit, undo, redo, canUndo, canRedo, add, create, rename, drop,
160+ };
95 }161 }
modified web/src/project/store.ts +1 -5
@@ -31,15 +31,11 @@ export async function loadList(): Promise<void> {
3131
3232 function applyPatch(p: Project, changes: ProjectPatch): void {
3333 for (const [key, value] of Object.entries(changes)) {
34- if (key === "cuts" || key === "splits" || key === "clips") p.derush = { ...p.derush, [key]: value };
34+ if (key === "cuts" || key === "splits" || key === "timelines") p.derush = { ...p.derush, [key]: value };
3535 else if (key === "capture") p.assets = { ...p.assets, capture: value as Project["assets"]["capture"] };
3636 else if (key === "rush_order") {
3737 const order = value as string[];
3838 p.rushes = [...p.rushes].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
39- // les bouts de la timeline du dérush suivent, comme côté serveur
40- if (p.derush.clips && !("clips" in changes)) {
41- p.derush = { ...p.derush, clips: [...p.derush.clips].sort((a, b) => order.indexOf(a.rush) - order.indexOf(b.rush)) };
42- }
4339 } else (p as any)[key] = value;
4440 }
4541 }
@@ -31,15 +31,11 @@ export async function loadList(): Promise<void> {
31 31
32 function applyPatch(p: Project, changes: ProjectPatch): void {32 function applyPatch(p: Project, changes: ProjectPatch): void {
33 for (const [key, value] of Object.entries(changes)) {33 for (const [key, value] of Object.entries(changes)) {
34- if (key === "cuts" || key === "splits" || key === "clips") p.derush = { ...p.derush, [key]: value };34+ if (key === "cuts" || key === "splits" || key === "timelines") p.derush = { ...p.derush, [key]: value };
35 else if (key === "capture") p.assets = { ...p.assets, capture: value as Project["assets"]["capture"] };35 else if (key === "capture") p.assets = { ...p.assets, capture: value as Project["assets"]["capture"] };
36 else if (key === "rush_order") {36 else if (key === "rush_order") {
37 const order = value as string[];37 const order = value as string[];
38 p.rushes = [...p.rushes].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));38 p.rushes = [...p.rushes].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
39- // les bouts de la timeline du dérush suivent, comme côté serveur
40- if (p.derush.clips && !("clips" in changes)) {
41- p.derush = { ...p.derush, clips: [...p.derush.clips].sort((a, b) => order.indexOf(a.rush) - order.indexOf(b.rush)) };
42- }
43 } else (p as any)[key] = value;39 } else (p as any)[key] = value;
44 }40 }
45 }41 }
modified web/src/project/types.ts +12 -3
@@ -154,7 +154,7 @@ export interface Project {
154154 research: ResearchSource[];
155155 shots: Shot[];
156156 rushes: Rush[]; rush_sort: "Script" | "Heure" | "Manuel";
157- derush: { cuts: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; clips?: DerushClip[] | null };
157+ derush: { cuts: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; timelines: DerushTimeline[]; main: string };
158158 source: Source | null;
159159 assets: Assets;
160160 jobs: Record<string, string>;
@@ -183,11 +183,20 @@ export interface ProjectStorage {
183183 /** Ce qui se modifie directement (PATCH). */
184184 export type ProjectPatch = Partial<Pick<Project,
185185 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &
186- { cuts?: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; clips?: DerushClip[]; rush_order?: string[]; capture?: Record<string, boolean>; opened_at?: number };
186+ { cuts?: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; timelines?: DerushTimeline[]; rush_order?: string[]; capture?: Record<string, boolean>; opened_at?: number };
187187
188-/** Un bout de la timeline du dérush : la plage [in, out] d'un rush, en secondes. */
188+/** Un bout de rush lu par la timeline du dérush : la plage [in, out] du rush, en secondes. */
189189 export interface DerushClip { id: string; rush: string; in: number; out: number }
190190
191+/** Un bout d'une timeline tel qu'on l'édite : un rush (`rush`) ou une autre timeline imbriquée (`tl`), sur [in, out]. */
192+export interface TimelineClip { id: string; rush?: string; tl?: string; in: number; out: number }
193+
194+/**
195+ * Une timeline du dérush ; `name` vide : nom par défaut. `clips` null : la globale découle encore des rushes
196+ * (voir backend runtime.own_clips).
197+ */
198+export interface DerushTimeline { id: string; name: string; clips: TimelineClip[] | null }
199+
191200 /** Un mot du transcript d'un rush : [début, fin, texte], en secondes du rush. */
192201 export type DerushWord = [number, number, string];
193202
@@ -154,7 +154,7 @@ export interface Project {
154 research: ResearchSource[];154 research: ResearchSource[];
155 shots: Shot[];155 shots: Shot[];
156 rushes: Rush[]; rush_sort: "Script" | "Heure" | "Manuel";156 rushes: Rush[]; rush_sort: "Script" | "Heure" | "Manuel";
157- derush: { cuts: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; clips?: DerushClip[] | null };157+ derush: { cuts: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; timelines: DerushTimeline[]; main: string };
158 source: Source | null;158 source: Source | null;
159 assets: Assets;159 assets: Assets;
160 jobs: Record<string, string>;160 jobs: Record<string, string>;
@@ -183,11 +183,20 @@ export interface ProjectStorage {
183 /** Ce qui se modifie directement (PATCH). */183 /** Ce qui se modifie directement (PATCH). */
184 export type ProjectPatch = Partial<Pick<Project,184 export type ProjectPatch = Partial<Pick<Project,
185 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &185 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &
186- { cuts?: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; clips?: DerushClip[]; rush_order?: string[]; capture?: Record<string, boolean>; opened_at?: number };186+ { cuts?: Record<string, "cut" | "keep">; splits?: Record<string, number[]>; timelines?: DerushTimeline[]; rush_order?: string[]; capture?: Record<string, boolean>; opened_at?: number };
187 187
188-/** Un bout de la timeline du dérush : la plage [in, out] d'un rush, en secondes. */188+/** Un bout de rush lu par la timeline du dérush : la plage [in, out] du rush, en secondes. */
189 export interface DerushClip { id: string; rush: string; in: number; out: number }189 export interface DerushClip { id: string; rush: string; in: number; out: number }
190 190
191+/** Un bout d'une timeline tel qu'on l'édite : un rush (`rush`) ou une autre timeline imbriquée (`tl`), sur [in, out]. */
192+export interface TimelineClip { id: string; rush?: string; tl?: string; in: number; out: number }
193+
194+/**
195+ * Une timeline du dérush ; `name` vide : nom par défaut. `clips` null : la globale découle encore des rushes
196+ * (voir backend runtime.own_clips).
197+ */
198+export interface DerushTimeline { id: string; name: string; clips: TimelineClip[] | null }
199+
191 /** Un mot du transcript d'un rush : [début, fin, texte], en secondes du rush. */200 /** Un mot du transcript d'un rush : [début, fin, texte], en secondes du rush. */
192 export type DerushWord = [number, number, string];201 export type DerushWord = [number, number, string];
193 202