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.
f0eb0eb parent: e615554 modified
backend/studio/modules/projects/api.py +1 -1 | @@ -895,7 +895,7 @@ def rush_peaks(pid: str, rush_id: str): | ||
| 895 | 895 | def get_derush(pid: str): |
| 896 | 896 | _check(pid) |
| 897 | 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 | 901 | 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: | ||
| 152 | 152 | |
| 153 | 153 | |
| 154 | 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 | 157 | for path in paths: |
| 157 | 158 | rush = _rush_entry(path) |
| 158 | 159 | project["rushes"].append(rush) |
| 159 | 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 | 161 | service.start_step(project, "tournage") |
| 164 | 162 | service.start_step(project, "derush") |
| 165 | 163 | if project["rush_sort"] == "Heure": |
| @@ -183,8 +181,9 @@ def remove_rush(project: dict, rush_id: str) -> None: | ||
| 183 | 181 | prefix = rush_id + ":" |
| 184 | 182 | project["derush"]["cuts"] = {k: v for k, v in project["derush"]["cuts"].items() if not k.startswith(prefix)} |
| 185 | 183 | 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] | |
| 188 | 187 | path = Path(rush["path"]) |
| 189 | 188 | # le fichier n'est supprimé que s'il a été copié dans le projet |
| 190 | 189 | if project_dir(project["id"]) in path.parents: |
| @@ -278,7 +277,6 @@ def sort_rushes(project: dict, mode: str) -> None: | ||
| 278 | 277 | if s["type"] == "speech" and s.get("sec") and s["rush"] not in first: |
| 279 | 278 | first[s["rush"]] = int(s["sec"][1:]) |
| 280 | 279 | project["rushes"].sort(key=lambda r: first.get(r["id"], 10_000)) |
| 281 | - order_clips(project) | |
| 282 | 280 | |
| 283 | 281 | |
| 284 | 282 | # --- Dérush ------------------------------------------------------------------------ |
| @@ -310,13 +308,21 @@ def analysis(project: dict, edited: bool = True) -> dict: | ||
| 310 | 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]: | |
| 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).""" | |
| 316 | 318 | 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 [] | |
| 320 | 326 | found = analysis(project) |
| 321 | 327 | if found["ready"]: |
| 322 | 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 | 331 | for i, r in enumerate(project["rushes"]) if r.get("duration")] |
| 326 | 332 | |
| 327 | 333 | |
| 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"]) | |
| 335 | 357 | |
| 336 | 358 | |
| 337 | 359 | 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 projet | 188 | # 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 | - return | 338 | + 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", | ||
| 74 | 74 | NEXT = {"transcript": ("storyboard", "selection"), "son": ("rendu",), "soustitres": ("rendu",)} |
| 75 | 75 | DURATIONS = ("< 5 min", "5–10 min", "10–20 min", "20–40 min", "40 min +") |
| 76 | 76 | |
| 77 | +MAIN_TIMELINE = "main" | |
| 77 | 78 | VIDEO_EXTENSIONS = ("mov", "mp4", "mxf", "mkv", "avi", "webm", "m4v", "mts") |
| 78 | 79 | |
| 79 | 80 | _UNSAFE_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') |
| @@ -107,7 +108,23 @@ def exists(pid: str) -> bool: | ||
| 107 | 108 | |
| 108 | 109 | |
| 109 | 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 | 130 | def _write(project: dict) -> None: |
| @@ -222,7 +239,7 @@ def create(mode: str, title: str, brief: dict, pid: str | None = None) -> dict: | ||
| 222 | 239 | "research": [_source_from_link(link) for link in brief["links"]], |
| 223 | 240 | "shots": [], |
| 224 | 241 | "rushes": [], "rush_sort": "Script", |
| 225 | - "derush": {"cuts": {}, "splits": {}, "clips": None}, | |
| 242 | + "derush": empty_derush(), | |
| 226 | 243 | "source": None, |
| 227 | 244 | "assets": empty_assets(), |
| 228 | 245 | "jobs": {}, "copies": [], |
| @@ -292,7 +309,7 @@ def storage(pid: str) -> dict[str, int]: | ||
| 292 | 309 | |
| 293 | 310 | # ce que l'interface peut remplacer tel quel |
| 294 | 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 | 315 | def patch(pid: str, changes: dict) -> dict: |
| @@ -303,21 +320,14 @@ def patch(pid: str, changes: dict) -> dict: | ||
| 303 | 320 | for key, value in changes.items(): |
| 304 | 321 | if key in ("cuts", "splits"): |
| 305 | 322 | 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) | |
| 311 | 325 | elif key == "capture": |
| 312 | 326 | project.setdefault("assets", empty_assets())["capture"] = value |
| 313 | 327 | elif key == "rush_order": |
| 314 | 328 | by_id = {r["id"]: r for r in project["rushes"]} |
| 315 | 329 | ordered = [by_id.pop(i) for i in value if i in by_id] |
| 316 | 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 | 331 | elif key == "titles" and sum(1 for t in value if t.get("pick")) > MAX_TITLES: |
| 322 | 332 | raise ValueError(f"{MAX_TITLES} titres retenus au plus") |
| 323 | 333 | elif key == "title": |
| @@ -335,6 +345,45 @@ def patch(pid: str, changes: dict) -> dict: | ||
| 335 | 345 | 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 | 387 | def complete_step(project: dict, step: str, done: bool) -> None: |
| 339 | 388 | """« Marquer comme terminé » : les étapes suivantes passent en cours.""" |
| 340 | 389 | 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 quel | 310 | # 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] = value | 322 | 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"] = value | 326 | 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 project | 345 | 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 { | ||
| 3 | 3 | ready: "{raw} of footage → {kept} kept", |
| 4 | 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 | 7 | transcript: "Transcript", |
| 8 | 8 | hint: "Click: go to the word · select text then Delete to remove it", |
| 9 | 9 | untranscribed: "untranscribed sound · {d} s", |
| @@ -48,31 +48,41 @@ export default { | ||
| 48 | 48 | retake: "Bad take", |
| 49 | 49 | cut: "Cut", |
| 50 | 50 | }, |
| 51 | - panel: { | |
| 52 | - title: "Clips", | |
| 51 | + pool: { | |
| 53 | 52 | import: "Import", |
| 54 | - order: "Order", | |
| 55 | 53 | sorts: { |
| 56 | - script: "Script", | |
| 57 | 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.", | |
| 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", | |
| 66 | 68 | 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", | |
| 69 | 72 | 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", | |
| 72 | 76 | 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)", | |
| 76 | 86 | }, |
| 77 | 87 | auto: { |
| 78 | 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 | 142 | timeline: { |
| 133 | 143 | trim: "Drag to trim or extend (Alt: no snapping)", |
| 144 | + nested: "Nested timeline “{name}” · double-click to open it", | |
| 134 | 145 | }, |
| 135 | 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 | }; |
| @@ -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 { | ||
| 3 | 3 | ready: "{raw} de rushes → {kept} gardées", |
| 4 | 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 | 7 | transcript: "Transcript", |
| 8 | 8 | hint: "Clic : aller au mot · sélectionne du texte puis Suppr pour le retirer", |
| 9 | 9 | untranscribed: "son non transcrit · {d} s", |
| @@ -48,31 +48,41 @@ export default { | ||
| 48 | 48 | retake: "Prise ratée", |
| 49 | 49 | cut: "Coupé", |
| 50 | 50 | }, |
| 51 | - panel: { | |
| 52 | - title: "Rushes", | |
| 51 | + pool: { | |
| 53 | 52 | import: "Importer", |
| 54 | - order: "Ordre", | |
| 55 | 53 | sorts: { |
| 56 | - script: "Script", | |
| 57 | 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.", | |
| 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", | |
| 66 | 68 | 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", | |
| 69 | 72 | 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", | |
| 72 | 76 | 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)", | |
| 76 | 86 | }, |
| 77 | 87 | auto: { |
| 78 | 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 | 142 | timeline: { |
| 133 | 143 | trim: "Tire pour rogner ou rallonger (Alt : sans aimantation)", |
| 144 | + nested: "Timeline imbriquée « {name} » · double-clic pour l’ouvrir", | |
| 134 | 145 | }, |
| 135 | 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 | }; |
| @@ -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 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | 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, | |
| 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. | |
| 6 | 7 | */ |
| 7 | -import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from "vue"; | |
| 8 | +import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from "vue"; | |
| 8 | 9 | import { useI18n } from "vue-i18n"; |
| 9 | 10 | import { useRouter } from "vue-router"; |
| 10 | 11 | import { api } from "../../lib/api"; |
| 11 | 12 | import { mmss } from "../../lib/format"; |
| 12 | 13 | import { toast } from "../../lib/toast"; |
| 14 | +import ConfirmX from "../../components/ConfirmX.vue"; | |
| 13 | 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 | 17 | import AutoDerush from "./derush/AutoDerush.vue"; |
| 16 | 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 | 20 | import PlayerFrame from "./derush/PlayerFrame.vue"; |
| 18 | -import RushPanel from "./derush/RushPanel.vue"; | |
| 19 | 21 | import Timeline, { type Peaks } from "./derush/Timeline.vue"; |
| 20 | 22 | import Transcript from "./derush/Transcript.vue"; |
| 21 | 23 | import { useDerush } from "./derush/useDerush"; |
| @@ -26,24 +28,56 @@ const router = useRouter(); | ||
| 26 | 28 | const project = computed(() => store.project!); |
| 27 | 29 | const base = computed(() => `/api/projects/${project.value.id}`); |
| 28 | 30 | 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)); | |
| 33 | 44 | const PALETTE = ["#3A3B3D", "#2E5E7E", "#5B4B7D", "#2F6B57", "#7A4F31", "#6E3049", "#4D5A2B", "#36507A"]; |
| 34 | 45 | const colors = computed(() => Object.fromEntries(project.value.rushes.map((r, i) => [r.id, PALETTE[i % PALETTE.length]]))); |
| 35 | 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 | 49 | const player = usePlaylist(pieces); |
| 39 | 50 | const selection = ref<Selection>(null); |
| 40 | 51 | const tool = ref<"select" | "blade">("select"); |
| 41 | 52 | const tl = ref<InstanceType<typeof Timeline> | null>(null); |
| 42 | 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 | 78 | /** La phrase sous la tête de lecture, en sous-titre. */ |
| 45 | 79 | const caption = computed(() => { |
| 46 | - const hit = clipAt(clips.value, player.t.value); | |
| 80 | + const hit = clipAt(flat.value, player.t.value); | |
| 47 | 81 | if (!hit) return ""; |
| 48 | 82 | const s = segsByRush.value[hit.c.rush]?.find((k) => k.start <= hit.at && hit.at < k.end); |
| 49 | 83 | return s && s.type !== "sil" ? s.text : ""; |
| @@ -83,7 +117,7 @@ const hasSel = computed(() => !!selection.value); | ||
| 83 | 117 | * Recale la tête de lecture après le retrait des plages `cut` de la timeline : dans une plage retirée, |
| 84 | 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 | 121 | const here = player.t.value; |
| 88 | 122 | let to = here; |
| 89 | 123 | for (const [a, b] of cut) { |
| @@ -116,7 +150,7 @@ function onMove(ids: string[], before: string | null) { | ||
| 116 | 150 | |
| 117 | 151 | function onTrim(id: string, side: "in" | "out", value: number) { |
| 118 | 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 | 156 | function onSplit(at: number) { |
| @@ -128,7 +162,7 @@ function present(type: DerushSeg["type"]) { | ||
| 128 | 162 | const out: Record<string, [number, number][]> = {}; |
| 129 | 163 | let n = 0, dur = 0; |
| 130 | 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 | 166 | for (const s of segs) { |
| 133 | 167 | if (s.type !== type) continue; |
| 134 | 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 | 199 | derush.edit((list) => { |
| 166 | 200 | const before = new Map(list.map((c) => [c.id, c])); |
| 167 | 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 | 203 | return next.filter((c) => clipLen(c) >= MIN_KEEP || same(c)); |
| 170 | 204 | }); |
| 171 | 205 | } |
| 172 | 206 | |
| 173 | 207 | /** Écoute un passage d'un rush : un peu avant, pour entendre ce qui précède la coupe. */ |
| 174 | 208 | function seekRush(rush: string, t: number) { |
| 175 | - const at = timeOf(clips.value, rush, t); | |
| 209 | + const at = timeOf(flat.value, rush, t); | |
| 176 | 210 | if (at == null) return; |
| 177 | 211 | player.seek(Math.max(0, at - 1)); |
| 178 | 212 | if (!player.playing.value) player.toggle(); |
| @@ -252,7 +286,7 @@ async function validate() { | ||
| 252 | 286 | <template> |
| 253 | 287 | <div v-if="!project.rushes.length" class="empty"> |
| 254 | 288 | <div class="dashed-box">{{ t("derush.empty") }}</div> |
| 255 | - <div class="side"><RushPanel /></div> | |
| 289 | + <MediaPool :derush="derush" @open="openTimeline" /> | |
| 256 | 290 | </div> |
| 257 | 291 | |
| 258 | 292 | <div v-else class="derush"> |
| @@ -269,7 +303,7 @@ async function validate() { | ||
| 269 | 303 | </div> |
| 270 | 304 | <div class="pane-body"> |
| 271 | 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 | 307 | :names="names" @remove="removeSpan" /> |
| 274 | 308 | <div v-else-if="transcribing" class="state"> |
| 275 | 309 | <span class="state-label">{{ t("derush.transcribing", { p: progress }) }}</span> |
| @@ -280,13 +314,27 @@ async function validate() { | ||
| 280 | 314 | <button class="btn dark gen" @click="act('/rushes/transcribe')">{{ t("derush.generate") }}</button> |
| 281 | 315 | </div> |
| 282 | 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 | 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 | 320 | </div> |
| 287 | 321 | </div> |
| 288 | 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 | 338 | <div class="toolbar"> |
| 291 | 339 | <div class="group"> |
| 292 | 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 | 362 | </div> |
| 315 | 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 | 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 | 369 | <div class="footer"> |
| 322 | 370 | <span class="keys" :title="t('derush.shortcuts')">{{ t("derush.shortcuts") }}</span> |
| @@ -354,7 +402,7 @@ async function validate() { | ||
| 354 | 402 | <style scoped> |
| 355 | 403 | .empty { padding: 20px 32px 32px; display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; } |
| 356 | 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 | 407 | .derush { padding: 16px 24px 24px 32px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } |
| 360 | 408 | .top { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: stretch; } |
| @@ -379,6 +427,17 @@ async function validate() { | ||
| 379 | 427 | .progress.bar { width: 260px; } |
| 380 | 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 | 441 | .toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 4px; } |
| 383 | 442 | .toolbar .group { display: flex; gap: 4px; } |
| 384 | 443 | .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 @@ | ||
| 5 | 5 | * phrases et aux mots, Alt pour s'en passer), la lame le coupe en deux. Glisser sur A1 sélectionne une plage. |
| 6 | 6 | * Ctrl + molette zoome autour de la souris, la molette fait défiler. Dans chaque bout, une bande marque |
| 7 | 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 | 10 | import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; |
| 10 | 11 | import { useI18n } from "vue-i18n"; |
| 11 | 12 | import { mmss } from "../../../lib/format"; |
| 12 | -import type { DerushClip, DerushSeg } from "../../types"; | |
| 13 | +import type { DerushClip, DerushSeg, TimelineClip } from "../../types"; | |
| 13 | 14 | import { clipLen, offsets, type Selection } from "./clips"; |
| 15 | +import { MEDIA_TYPE, type MediaItem } from "./useDerush"; | |
| 14 | 16 | import type { Playlist } from "./usePlaylist"; |
| 15 | 17 | |
| 16 | 18 | export interface Peaks { rate: number; peaks: number[] } |
| 17 | 19 | |
| 18 | 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 | 23 | durations: Record<string, number>; segs: Record<string, DerushSeg[]>; peaks: Record<string, Peaks | undefined>; |
| 21 | 24 | tool: "select" | "blade"; |
| 22 | 25 | }>(); |
| @@ -24,6 +27,8 @@ const emit = defineEmits<{ | ||
| 24 | 27 | move: [ids: string[], before: string | null]; |
| 25 | 28 | trim: [id: string, side: "in" | "out", value: number]; |
| 26 | 29 | split: [t: number]; |
| 30 | + insert: [items: MediaItem[], before: string | null]; | |
| 31 | + open: [tl: string]; | |
| 27 | 32 | }>(); |
| 28 | 33 | const sel = defineModel<Selection>("selection", { required: true }); |
| 29 | 34 | const { t: tr } = useI18n(); |
| @@ -121,7 +126,7 @@ const clipsV = computed(() => props.clips.map((c, i) => { | ||
| 121 | 126 | return { c, i, a, b }; |
| 122 | 127 | }).filter((k) => k.b >= v0.value && k.a <= v1.value).map((k) => { |
| 123 | 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 | 130 | if (s.type === "sil" || s.end <= k.c.in || s.start >= k.c.out) continue; |
| 126 | 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 | 132 | if (sb < v0.value || sa > v1.value) continue; |
| @@ -143,7 +148,7 @@ function drawWave() { | ||
| 143 | 148 | g.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 144 | 149 | g.clearRect(0, 0, w, h); |
| 145 | 150 | g.fillStyle = "#3E9E6E"; |
| 146 | - const cs = props.clips, o = starts.value; | |
| 151 | + const cs = props.flat, o = offsets(cs); | |
| 147 | 152 | let i = 0; |
| 148 | 153 | for (let px = 0; px < w; px++) { |
| 149 | 154 | const d = (scrollX.value + px) / pps.value; |
| @@ -163,7 +168,7 @@ function redraw() { | ||
| 163 | 168 | cancelAnimationFrame(raf); |
| 164 | 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 | 172 | onMounted(redraw); |
| 168 | 173 | |
| 169 | 174 | // --- souris |
| @@ -222,11 +227,11 @@ function downEdge(e: PointerEvent, i: number, side: "in" | "out") { | ||
| 222 | 227 | if (e.button !== 0 || props.tool === "blade") return; |
| 223 | 228 | e.stopPropagation(); |
| 224 | 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 | 231 | pts.push(s.start, s.end); |
| 227 | 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 | 235 | const edge = side === "in" ? c.in : c.out; |
| 231 | 236 | drag = { kind: "trim", i, side, edge, d0: timeAt(e), to: edge, pts, |
| 232 | 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 | 323 | const fmtDelta = (s: number) => `${s > 0 ? "+" : "−"}${Math.abs(s).toFixed(2)} s`; |
| 299 | 324 | const shortName = (n: string) => n.replace(/\.[^.]+$/, ""); |
| 300 | 325 | </script> |
| @@ -307,7 +332,8 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, ""); | ||
| 307 | 332 | <div class="lab" :style="{ top: `${top.a}px`, height: `${A}px` }">A1</div> |
| 308 | 333 | </div> |
| 309 | 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 | 337 | <div class="content" :style="{ width: `${width}px`, height: `${height}px` }" |
| 312 | 338 | @pointerdown="downTrack" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp"> |
| 313 | 339 | <!-- règle --> |
| @@ -323,17 +349,24 @@ const shortName = (n: string) => n.replace(/\.[^.]+$/, ""); | ||
| 323 | 349 | |
| 324 | 350 | <!-- bouts de rushes --> |
| 325 | 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 | 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)` : '' }" | |
| 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> | |
| 331 | 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 | 364 | <em v-if="m.w > 50">{{ m.text }}</em> |
| 333 | 365 | </span> |
| 334 | 366 | <i class="edge l" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'in')" /> |
| 335 | 367 | <i class="edge r" :title="tr('derush.timeline.trim')" @pointerdown="downEdge($event, k.i, 'out')" /> |
| 336 | 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 | 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 | 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 | 406 | position: absolute; box-sizing: border-box; overflow: hidden; border-radius: 4px; cursor: grab; z-index: 2; |
| 374 | 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 | 410 | .clip.on { box-shadow: inset 0 0 0 2px var(--red), 0 0 0 1px var(--red); z-index: 3; } |
| 377 | 411 | .clip.moving { opacity: .75; cursor: grabbing; z-index: 6; box-shadow: 0 6px 16px rgba(0, 0, 0, .25); } |
| 378 | 412 | .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 marque | 6 | * 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 | // --- souris | 174 | // --- 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 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 | 11 | /** En dessous, un bout n'est pas gardé. */ |
| 8 | 12 | const MIN = 0.04; |
| 9 | 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 | 16 | export const newId = () => `c${Math.random().toString(36).slice(2, 10)}`; |
| 13 | 17 | |
| 14 | 18 | /** Début de chaque bout sur la timeline. */ |
| 15 | -export function offsets(clips: DerushClip[]): number[] { | |
| 19 | +export function offsets(clips: Span[]): number[] { | |
| 16 | 20 | let a = 0; |
| 17 | 21 | return clips.map((c) => { const o = a; a += clipLen(c); return o; }); |
| 18 | 22 | } |
| 19 | 23 | |
| 20 | 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 | 26 | const o = offsets(clips); |
| 23 | 27 | for (let i = clips.length - 1; i >= 0; i--) { |
| 24 | 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 | 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 | 35 | const o = offsets(clips); |
| 32 | 36 | let next: number | null = null, gap = Infinity; |
| 33 | 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 | 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 | 47 | const o = offsets(clips); |
| 44 | 48 | const i = clips.findIndex((c, k) => o[k] + MIN < t && t < o[k] + clipLen(c) - MIN); |
| 45 | 49 | if (i < 0) return clips; |
| @@ -48,8 +52,8 @@ export function splitAt(clips: DerushClip[], t: number): DerushClip[] { | ||
| 48 | 52 | } |
| 49 | 53 | |
| 50 | 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[] { | |
| 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[] = []; | |
| 53 | 57 | clips.forEach((c, k) => { |
| 54 | 58 | const s = o[k], e = s + clipLen(c); |
| 55 | 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 | 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 | 68 | const sorted = [...ranges].sort((x, y) => x[0] - y[0]); |
| 65 | 69 | return clips.flatMap((c) => { |
| 66 | 70 | if (c.rush !== rush) return [c]; |
| 67 | - const parts: DerushClip[] = []; | |
| 71 | + const parts: T[] = []; | |
| 68 | 72 | let from = c.in; |
| 69 | 73 | for (const [a, b] of sorted) { |
| 70 | 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 | 84 | return clips.filter((c) => !ids.includes(c.id)); |
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 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 | 89 | const moving = clips.filter((c) => ids.includes(c.id)), rest = clips.filter((c) => !ids.includes(c.id)); |
| 86 | 90 | const at = before ? rest.findIndex((c) => c.id === before) : -1; |
| 87 | 91 | return at < 0 ? [...rest, ...moving] : [...rest.slice(0, at), ...moving, ...rest.slice(at)]; |
| 88 | 92 | } |
| 89 | 93 | |
| 90 | 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 | 96 | return clips.map((c) => { |
| 93 | 97 | if (c.id !== id) return c; |
| 94 | 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 | 137 | /** La sélection : des bouts, ou une plage de la timeline. */ |
| 101 | 138 | 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"; | ||
| 4 | 4 | import { api } from "../../../lib/api"; |
| 5 | 5 | import { toast } from "../../../lib/toast"; |
| 6 | 6 | 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; | |
| 9 | 16 | |
| 10 | 17 | const FINISHED = ["done", "error", "cancelled", "none"]; |
| 11 | 18 | |
| @@ -37,49 +44,104 @@ export function useDerush(project: Ref<Project>) { | ||
| 37 | 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). */ | |
| 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); | |
| 48 | 78 | |
| 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[] = []; | |
| 51 | 88 | 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[]) { | |
| 55 | 91 | const before = clips.value, next = fn(before); |
| 56 | 92 | if (next === before) return; |
| 57 | - past.push(before); | |
| 93 | + past.push({ tl: current.value, clips: before }); | |
| 58 | 94 | if (past.length > 200) past.shift(); |
| 59 | 95 | future.length = 0; |
| 60 | 96 | 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); | |
| 69 | 98 | } |
| 70 | - function redo() { | |
| 71 | - const s = future.pop(); | |
| 99 | + function restore(from: Step[], to: Step[]) { | |
| 100 | + const s = from.pop(); | |
| 72 | 101 | 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; | |
| 75 | 105 | history.value++; |
| 76 | 106 | } |
| 107 | + const undo = () => restore(past, future); | |
| 108 | + const redo = () => restore(future, past); | |
| 77 | 109 | const canUndo = computed(() => history.value >= 0 && past.length > 0); |
| 78 | 110 | 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++; | |
| 83 | 145 | } |
| 84 | 146 | |
| 85 | 147 | /** Transcriptions des rushes en cours, et leur avancement moyen (%). */ |
| @@ -91,5 +153,9 @@ export function useDerush(project: Ref<Project>) { | ||
| 91 | 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 | } |
| @@ -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?.clips | 50 | + /** 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établir | 79 | + 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> { | ||
| 31 | 31 | |
| 32 | 32 | function applyPatch(p: Project, changes: ProjectPatch): void { |
| 33 | 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 | 35 | else if (key === "capture") p.assets = { ...p.assets, capture: value as Project["assets"]["capture"] }; |
| 36 | 36 | else if (key === "rush_order") { |
| 37 | 37 | const order = value as string[]; |
| 38 | 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 | 39 | } else (p as any)[key] = value; |
| 44 | 40 | } |
| 45 | 41 | } |
| @@ -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 { | ||
| 154 | 154 | research: ResearchSource[]; |
| 155 | 155 | shots: Shot[]; |
| 156 | 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 | 158 | source: Source | null; |
| 159 | 159 | assets: Assets; |
| 160 | 160 | jobs: Record<string, string>; |
| @@ -183,11 +183,20 @@ export interface ProjectStorage { | ||
| 183 | 183 | /** Ce qui se modifie directement (PATCH). */ |
| 184 | 184 | export type ProjectPatch = Partial<Pick<Project, |
| 185 | 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 | 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 | 200 | /** Un mot du transcript d'un rush : [début, fin, texte], en secondes du rush. */ |
| 192 | 201 | export type DerushWord = [number, number, string]; |
| 193 | 202 | |
| @@ -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 | ||