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