hlassiege/darylpublic Fork
⑂ 1949c7a
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(montage): add punch-in and slow push zoom styles

hlassiege committed 2026-11-05T15:00:03+01:00 Browse files
1949c7a parent: ba1a9ba
modified backend/studio/modules/projects/montage.py +47 -13
@@ -21,7 +21,8 @@ timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans
2121
2222 {
2323 "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?}], # la vidéo source, bout à bout ; transition avec le précédent
24- "zooms": [{"id", "at", "dur", "scale", "x", "y", "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction)
24+ "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)
2526 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)
2627 "motion": [{"id", "at", "dur", "title", "kind", "idea", "enter"?, "exit"?, "beat"?}], # motion design, au-dessus du B-roll
2728 "audio": [{"id", "src", "at", "in", "out", "gain_db", "role": "sfx" | "music", "motion"?}],
@@ -95,8 +96,32 @@ PREFIX = {"aroll": "a", "zooms": "z", "broll": "b", "motion": "m", "audio": "s",
9596 # texte posé à la main : bornes de la taille (fraction de la hauteur de l'image) et de l'inclinaison (degrés)
9697 TEXT_SIZE = (0.03, 0.25)
9798 TEXT_ANGLE = 20
98-# zoom : montée et descente progressives aux bords
99+# zoom, selon son style : smooth monte et descend en ZOOM_RAMP (adouci), cut saute d'un coup (aparté face cam),
100+# slow pousse lentement sur toute sa durée puis revient d'un coup (comme une coupe)
99101 ZOOM_RAMP = 0.4
102+ZOOM_STYLES = ("smooth", "cut", "slow")
103+
104+
105+def zoom_ramps(z: dict) -> tuple[float, float]:
106+ """La montée et la descente d'un zoom, en secondes (0 : instantanée)."""
107+ style = z.get("style") or "smooth"
108+ if style == "cut":
109+ return 0.0, 0.0
110+ if style == "slow":
111+ return z["dur"], 0.0
112+ ramp = min(ZOOM_RAMP, z["dur"] / 3)
113+ return ramp, ramp
114+
115+
116+def zoom_amount(z: dict, t: float) -> float:
117+ """Où en est le zoom à l'instant `t`, de 0 (pas de zoom) à 1 (tout le grossissement) : [at, at + dur[."""
118+ a, b = z["at"], z["at"] + z["dur"]
119+ if not a <= t < b:
120+ return 0.0
121+ rise, fall = zoom_ramps(z)
122+ k = min(1.0, (t - a) / rise if rise else 1.0, (b - t) / fall if fall else 1.0)
123+ # smooth : adouci (smoothstep) ; slow : vitesse constante, comme une poussée de caméra
124+ return k if (z.get("style") or "smooth") == "slow" else k * k * (3 - 2 * k)
100125 # forme d'onde : une valeur par 1/PEAKS_RATE s
101126 PEAKS_RATE = 20
102127 # vignettes de la vidéo source : au plus FILMSTRIP_MAX images, une toutes les `step` s
@@ -265,6 +290,7 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict:
265290 out["zooms"].append({"id": ident("zooms", z.get("id")), "at": at, "dur": min(dur, total - at),
266291 "scale": max(1.0, min(_num(z.get("scale"), 1.2), 3.0)),
267292 "x": max(-0.5, min(_num(z.get("x")), 0.5)), "y": max(-0.5, min(_num(z.get("y")), 0.5)),
293+ **({"style": z["style"]} if z.get("style") in ZOOM_STYLES[1:] else {}),
268294 **({"beat": str(z["beat"])[:20]} if z.get("beat") else {})})
269295
270296 for track in ("broll", "audio"):
@@ -972,29 +998,37 @@ def _size(project: dict) -> tuple[int, int]:
972998
973999 # --- Export FCPXML -------------------------------------------------------------------------
9741000
1001+# un saut de zoom (style cut, retour d'un slow) : sur une image, Resolve interpolant entre deux images clés
1002+ZOOM_STEP = 0.04
1003+
1004+
9751005 def _zoom_at(zooms: list[dict], t: float) -> dict:
976- """Le recadrage à l'instant `t` de la timeline (montée et descente linéaires)."""
1006+ """Le recadrage à l'instant `t` de la timeline."""
9771007 for z in zooms:
978- a, b = z["at"], z["at"] + z["dur"]
979- if a <= t <= b:
980- ramp = min(ZOOM_RAMP, z["dur"] / 3)
981- k = min(1.0, (t - a) / ramp, (b - t) / ramp) if ramp else 1.0
1008+ k = zoom_amount(z, t)
1009+ if k:
9821010 return {"scale": round(1 + (z["scale"] - 1) * k, 4), "x": round(z["x"] * k, 4), "y": round(z["y"] * k, 4)}
9831011 return {"scale": 1.0, "x": 0.0, "y": 0.0}
9841012
9851013
9861014 def _zoom_keys(zooms: list[dict], clip_start: float, clip_len: float) -> list[dict]:
987- """Les zooms qui touchent un clip, en images clés relatives au début du clip."""
988- keys: list[dict] = []
1015+ """Les zooms qui touchent un clip, en images clés relatives au début du clip (interpolées en ligne droite)."""
1016+ keys: dict[float, dict] = {}
9891017 for z in zooms:
9901018 a, b = z["at"], z["at"] + z["dur"]
9911019 if b <= clip_start or a >= clip_start + clip_len:
9921020 continue
993- ramp = min(ZOOM_RAMP, z["dur"] / 3)
994- for t in (a, a + ramp, b - ramp, b):
995- local = t - clip_start
1021+ rise, fall = zoom_ramps(z)
1022+ times = {a, a + rise, b - fall, b}
1023+ if not rise:
1024+ times.add(a - ZOOM_STEP)
1025+ if not fall:
1026+ times.add(b - ZOOM_STEP)
1027+ for t in times:
1028+ local = round(t - clip_start, 3)
9961029 if 0 < local < clip_len:
997- keys.append({"t": round(local, 3), **_zoom_at(zooms, t)})
1030+ keys[local] = {"t": local, **_zoom_at(zooms, t)}
1031+ keys = [keys[t] for t in sorted(keys)]
9981032 if keys:
9991033 # un zoom déjà commencé (ou pas fini) à la coupe : le clip démarre (finit) au bon cadrage
10001034 keys.insert(0, {"t": 0.0, **_zoom_at(zooms, clip_start)})
@@ -21,7 +21,8 @@ timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans
21 21
22 {22 {
23 "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?}], # la vidéo source, bout à bout ; transition avec le précédent23 "aroll": [{"id", "src"?, "in", "out", "transition", "gain_db"?}], # la vidéo source, bout à bout ; transition avec le précédent
24- "zooms": [{"id", "at", "dur", "scale", "x", "y", "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction)24+ "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)
25 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)26 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)
26 "motion": [{"id", "at", "dur", "title", "kind", "idea", "enter"?, "exit"?, "beat"?}], # motion design, au-dessus du B-roll27 "motion": [{"id", "at", "dur", "title", "kind", "idea", "enter"?, "exit"?, "beat"?}], # motion design, au-dessus du B-roll
27 "audio": [{"id", "src", "at", "in", "out", "gain_db", "role": "sfx" | "music", "motion"?}],28 "audio": [{"id", "src", "at", "in", "out", "gain_db", "role": "sfx" | "music", "motion"?}],
@@ -95,8 +96,32 @@ PREFIX = {"aroll": "a", "zooms": "z", "broll": "b", "motion": "m", "audio": "s",
95 # texte posé à la main : bornes de la taille (fraction de la hauteur de l'image) et de l'inclinaison (degrés)96 # texte posé à la main : bornes de la taille (fraction de la hauteur de l'image) et de l'inclinaison (degrés)
96 TEXT_SIZE = (0.03, 0.25)97 TEXT_SIZE = (0.03, 0.25)
97 TEXT_ANGLE = 2098 TEXT_ANGLE = 20
98-# zoom : montée et descente progressives aux bords99+# zoom, selon son style : smooth monte et descend en ZOOM_RAMP (adouci), cut saute d'un coup (aparté face cam),
100+# slow pousse lentement sur toute sa durée puis revient d'un coup (comme une coupe)
99 ZOOM_RAMP = 0.4101 ZOOM_RAMP = 0.4
102+ZOOM_STYLES = ("smooth", "cut", "slow")
103+
104+
105+def zoom_ramps(z: dict) -> tuple[float, float]:
106+ """La montée et la descente d'un zoom, en secondes (0 : instantanée)."""
107+ style = z.get("style") or "smooth"
108+ if style == "cut":
109+ return 0.0, 0.0
110+ if style == "slow":
111+ return z["dur"], 0.0
112+ ramp = min(ZOOM_RAMP, z["dur"] / 3)
113+ return ramp, ramp
114+
115+
116+def zoom_amount(z: dict, t: float) -> float:
117+ """Où en est le zoom à l'instant `t`, de 0 (pas de zoom) à 1 (tout le grossissement) : [at, at + dur[."""
118+ a, b = z["at"], z["at"] + z["dur"]
119+ if not a <= t < b:
120+ return 0.0
121+ rise, fall = zoom_ramps(z)
122+ k = min(1.0, (t - a) / rise if rise else 1.0, (b - t) / fall if fall else 1.0)
123+ # smooth : adouci (smoothstep) ; slow : vitesse constante, comme une poussée de caméra
124+ return k if (z.get("style") or "smooth") == "slow" else k * k * (3 - 2 * k)
100 # forme d'onde : une valeur par 1/PEAKS_RATE s125 # forme d'onde : une valeur par 1/PEAKS_RATE s
101 PEAKS_RATE = 20126 PEAKS_RATE = 20
102 # vignettes de la vidéo source : au plus FILMSTRIP_MAX images, une toutes les `step` s127 # vignettes de la vidéo source : au plus FILMSTRIP_MAX images, une toutes les `step` s
@@ -265,6 +290,7 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict:
265 out["zooms"].append({"id": ident("zooms", z.get("id")), "at": at, "dur": min(dur, total - at),290 out["zooms"].append({"id": ident("zooms", z.get("id")), "at": at, "dur": min(dur, total - at),
266 "scale": max(1.0, min(_num(z.get("scale"), 1.2), 3.0)),291 "scale": max(1.0, min(_num(z.get("scale"), 1.2), 3.0)),
267 "x": max(-0.5, min(_num(z.get("x")), 0.5)), "y": max(-0.5, min(_num(z.get("y")), 0.5)),292 "x": max(-0.5, min(_num(z.get("x")), 0.5)), "y": max(-0.5, min(_num(z.get("y")), 0.5)),
293+ **({"style": z["style"]} if z.get("style") in ZOOM_STYLES[1:] else {}),
268 **({"beat": str(z["beat"])[:20]} if z.get("beat") else {})})294 **({"beat": str(z["beat"])[:20]} if z.get("beat") else {})})
269 295
270 for track in ("broll", "audio"):296 for track in ("broll", "audio"):
@@ -972,29 +998,37 @@ def _size(project: dict) -> tuple[int, int]:
972 998
973 # --- Export FCPXML -------------------------------------------------------------------------999 # --- Export FCPXML -------------------------------------------------------------------------
974 1000
1001+# un saut de zoom (style cut, retour d'un slow) : sur une image, Resolve interpolant entre deux images clés
1002+ZOOM_STEP = 0.04
1003+
1004+
975 def _zoom_at(zooms: list[dict], t: float) -> dict:1005 def _zoom_at(zooms: list[dict], t: float) -> dict:
976- """Le recadrage à l'instant `t` de la timeline (montée et descente linéaires)."""1006+ """Le recadrage à l'instant `t` de la timeline."""
977 for z in zooms:1007 for z in zooms:
978- a, b = z["at"], z["at"] + z["dur"]1008+ k = zoom_amount(z, t)
979- if a <= t <= b:1009+ if k:
980- ramp = min(ZOOM_RAMP, z["dur"] / 3)
981- k = min(1.0, (t - a) / ramp, (b - t) / ramp) if ramp else 1.0
982 return {"scale": round(1 + (z["scale"] - 1) * k, 4), "x": round(z["x"] * k, 4), "y": round(z["y"] * k, 4)}1010 return {"scale": round(1 + (z["scale"] - 1) * k, 4), "x": round(z["x"] * k, 4), "y": round(z["y"] * k, 4)}
983 return {"scale": 1.0, "x": 0.0, "y": 0.0}1011 return {"scale": 1.0, "x": 0.0, "y": 0.0}
984 1012
985 1013
986 def _zoom_keys(zooms: list[dict], clip_start: float, clip_len: float) -> list[dict]:1014 def _zoom_keys(zooms: list[dict], clip_start: float, clip_len: float) -> list[dict]:
987- """Les zooms qui touchent un clip, en images clés relatives au début du clip."""1015+ """Les zooms qui touchent un clip, en images clés relatives au début du clip (interpolées en ligne droite)."""
988- keys: list[dict] = []1016+ keys: dict[float, dict] = {}
989 for z in zooms:1017 for z in zooms:
990 a, b = z["at"], z["at"] + z["dur"]1018 a, b = z["at"], z["at"] + z["dur"]
991 if b <= clip_start or a >= clip_start + clip_len:1019 if b <= clip_start or a >= clip_start + clip_len:
992 continue1020 continue
993- ramp = min(ZOOM_RAMP, z["dur"] / 3)1021+ rise, fall = zoom_ramps(z)
994- for t in (a, a + ramp, b - ramp, b):1022+ times = {a, a + rise, b - fall, b}
995- local = t - clip_start1023+ if not rise:
1024+ times.add(a - ZOOM_STEP)
1025+ if not fall:
1026+ times.add(b - ZOOM_STEP)
1027+ for t in times:
1028+ local = round(t - clip_start, 3)
996 if 0 < local < clip_len:1029 if 0 < local < clip_len:
997- keys.append({"t": round(local, 3), **_zoom_at(zooms, t)})1030+ keys[local] = {"t": local, **_zoom_at(zooms, t)}
1031+ keys = [keys[t] for t in sorted(keys)]
998 if keys:1032 if keys:
999 # un zoom déjà commencé (ou pas fini) à la coupe : le clip démarre (finit) au bon cadrage1033 # un zoom déjà commencé (ou pas fini) à la coupe : le clip démarre (finit) au bon cadrage
1000 keys.insert(0, {"t": 0.0, **_zoom_at(zooms, clip_start)})1034 keys.insert(0, {"t": 0.0, **_zoom_at(zooms, clip_start)})
modified backend/studio/modules/projects/montage_tasks.py +6 -4
@@ -103,7 +103,7 @@ et l'efface aussitôt écrit.
103103 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.
104104 {{
105105 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],
106- "zooms": [{{"id": "z1", "at": 31.2, "dur": 4.5, "scale": 1.25, "x": 0, "y": -0.05}}],
106+ "zooms": [{{"id": "z1", "at": 31.2, "dur": 4.5, "scale": 1.25, "x": 0, "y": -0.05}}, {{"id": "z2", "at": 88.0, "dur": 2.0, "scale": 1.3, "x": 0, "y": 0, "style": "cut"}}],
107107 "broll": [{{"id": "b1", "src": "assets/broll/ville.mp4", "at": 42.0, "in": 3.0, "out": 7.5, "enter": {{"fx": "wipeleft", "dur": 0.5}}}}],
108108 "motion": [{{"id": "m1", "at": 60.4, "dur": 7.0, "title": "nom court", "kind": "schéma", "idea": "ce qu'il montre"}}],
109109 "audio": [{{"id": "s1", "src": "library/sfx/whoosh.wav", "at": 60.2, "in": 0, "out": 1.1, "gain_db": -10, "role": "sfx"}}],
@@ -123,8 +123,10 @@ insère le clip et décale tout ce qui suit (zooms, B-roll, motions, sons, marqu
123123 RELIS {work}/timeline.json (elle a changé) avant toute autre modification : le clip y est, avec son id ; les temps \
124124 d'après sont décalés de sa durée. Ajoute alors ce qui l'accompagne (un whoosh à l'entrée et à la sortie, \
125125 par exemple) en temps de la timeline à jour.
126-- zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \
127-scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).
126+- 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" \
128+(poussée lente à vitesse constante sur toute la durée, retour net à la fin : explication posée, tension ; \
129+5 à 15 s, scale 1.05 à 1.15). scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).
128130 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \
129131 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.
130132 - motion : `id` nouveau, pris dans les ids qui te sont réservés pour cette demande, dans l'ordre : \
@@ -408,7 +410,7 @@ def _frame_scope(pid: str, t: float, image: str | None) -> str:
408410 from .storyboard_chat import grab_frame
409411
410412 timeline = montage.load(pid)
411- on = [f"zoom {z['id']} ×{z['scale']:.2f}" for z in timeline["zooms"] if z["at"] <= t < z["at"] + z["dur"]]
413+ on = [f"zoom {z['id']} {z.get('style', 'smooth')} ×{z['scale']:.2f}" for z in timeline["zooms"] if z["at"] <= t < z["at"] + z["dur"]]
412414 on += [f"B-roll {b['id']} ({b['src']})" for b in timeline["broll"] if b["at"] <= t < b["at"] + b["out"] - b["in"]]
413415 on += [f"motion {m['id']} « {m['title']} » (montage/motion/{m['id']}.html, à {t - m['at']:.2f} s de son début)"
414416 for m in timeline["motion"] if m["at"] <= t < m["at"] + m["dur"]]
@@ -103,7 +103,7 @@ et l'efface aussitôt écrit.
103 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.103 Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média.
104 {{104 {{
105 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],105 "aroll": [{{"id": "a1", "in": 0, "out": 264.25, "transition": null}}, {{"id": "a2", "src": "assets/broll/extrait.mp4", "in": 0, "out": 3.9, "transition": null, "gain_db": -3}}, {{"id": "a3", "in": 264.25, "out": 754.2, "transition": null}}],
106- "zooms": [{{"id": "z1", "at": 31.2, "dur": 4.5, "scale": 1.25, "x": 0, "y": -0.05}}],106+ "zooms": [{{"id": "z1", "at": 31.2, "dur": 4.5, "scale": 1.25, "x": 0, "y": -0.05}}, {{"id": "z2", "at": 88.0, "dur": 2.0, "scale": 1.3, "x": 0, "y": 0, "style": "cut"}}],
107 "broll": [{{"id": "b1", "src": "assets/broll/ville.mp4", "at": 42.0, "in": 3.0, "out": 7.5, "enter": {{"fx": "wipeleft", "dur": 0.5}}}}],107 "broll": [{{"id": "b1", "src": "assets/broll/ville.mp4", "at": 42.0, "in": 3.0, "out": 7.5, "enter": {{"fx": "wipeleft", "dur": 0.5}}}}],
108 "motion": [{{"id": "m1", "at": 60.4, "dur": 7.0, "title": "nom court", "kind": "schéma", "idea": "ce qu'il montre"}}],108 "motion": [{{"id": "m1", "at": 60.4, "dur": 7.0, "title": "nom court", "kind": "schéma", "idea": "ce qu'il montre"}}],
109 "audio": [{{"id": "s1", "src": "library/sfx/whoosh.wav", "at": 60.2, "in": 0, "out": 1.1, "gain_db": -10, "role": "sfx"}}],109 "audio": [{{"id": "s1", "src": "library/sfx/whoosh.wav", "at": 60.2, "in": 0, "out": 1.1, "gain_db": -10, "role": "sfx"}}],
@@ -123,8 +123,10 @@ 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-- zooms : recadrage progressif de l'A-roll (montée et descente de 0,4 s faites à l'export). \126+- zooms : recadrage de l'A-roll. `style` : absent = smooth (montée et descente adoucies de 0,4 s), \
127-scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).127+"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 ; \
129+5 à 15 s, scale 1.05 à 1.15). scale 1.1 à 1.4 ; x, y : décalage du centre en fraction de l'image (−0.5 à 0.5).
128 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \130 - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \
129 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.131 chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général.
130 - motion : `id` nouveau, pris dans les ids qui te sont réservés pour cette demande, dans l'ordre : \132 - motion : `id` nouveau, pris dans les ids qui te sont réservés pour cette demande, dans l'ordre : \
@@ -408,7 +410,7 @@ def _frame_scope(pid: str, t: float, image: str | None) -> str:
408 from .storyboard_chat import grab_frame410 from .storyboard_chat import grab_frame
409 411
410 timeline = montage.load(pid)412 timeline = montage.load(pid)
411- on = [f"zoom {z['id']} ×{z['scale']:.2f}" for z in timeline["zooms"] if z["at"] <= t < z["at"] + z["dur"]]413+ on = [f"zoom {z['id']} {z.get('style', 'smooth')} ×{z['scale']:.2f}" for z in timeline["zooms"] if z["at"] <= t < z["at"] + z["dur"]]
412 on += [f"B-roll {b['id']} ({b['src']})" for b in timeline["broll"] if b["at"] <= t < b["at"] + b["out"] - b["in"]]414 on += [f"B-roll {b['id']} ({b['src']})" for b in timeline["broll"] if b["at"] <= t < b["at"] + b["out"] - b["in"]]
413 on += [f"motion {m['id']} « {m['title']} » (montage/motion/{m['id']}.html, à {t - m['at']:.2f} s de son début)"415 on += [f"motion {m['id']} « {m['title']} » (montage/motion/{m['id']}.html, à {t - m['at']:.2f} s de son début)"
414 for m in timeline["motion"] if m["at"] <= t < m["at"] + m["dur"]]416 for m in timeline["motion"] if m["at"] <= t < m["at"] + m["dur"]]
modified backend/studio/modules/projects/render.py +19 -8
@@ -298,18 +298,29 @@ def _num(x: float) -> str:
298298 return f"{x:.6f}".rstrip("0").rstrip(".") or "0"
299299
300300
301-def zoom_filter(zooms: list[dict], start: int, fps: Fraction) -> str:
301+def zoom_filter(zooms: list[dict], start: int, end: int, fps: Fraction) -> str:
302302 """
303- Le recadrage des zooms (perspective, image par image), comme l'aperçu : l'image est agrandie
304- de `scale` autour de son centre puis décalée de −x, −y (en fraction de l'image) ; montée et
305- descente de ZOOM_RAMP. Le cadre reste dans l'image.
303+ Le recadrage des zooms qui touchent les images [start, end[ (perspective, image par image), comme
304+ l'aperçu : l'image est agrandie de `scale` autour de son centre puis décalée de −x, −y (en fraction
305+ de l'image), selon la courbe de son style (montage.zoom_amount). Le cadre reste dans l'image.
306306 """
307- t = f"((in+{start})*{fps.denominator}/{fps.numerator})"
307+ # `in` compte les images à partir de 1 dans perspective
308+ t = f"((in-1+{start})*{fps.denominator}/{fps.numerator})"
309+ first, last = float(Fraction(start) / fps), float(Fraction(end) / fps)
308310 k_terms = []
309311 for z in zooms:
310312 a, b = z["at"], z["at"] + z["dur"]
311- ramp = min(montage.ZOOM_RAMP, z["dur"] / 3) or 1e-3
312- k = f"between({t},{_num(a)},{_num(b)})*clip(min(({t}-{_num(a)})/{_num(ramp)},({_num(b)}-{t})/{_num(ramp)}),0,1)"
313+ if b <= first or a >= last:
314+ continue
315+ rise, fall = montage.zoom_ramps(z)
316+ terms = [*([f"({t}-{_num(a)})/{_num(rise)}"] if rise else []), *([f"({_num(b)}-{t})/{_num(fall)}"] if fall else [])]
317+ inside = f"gte({t},{_num(a)})*lt({t},{_num(b)})"
318+ if not terms:
319+ k = inside
320+ else:
321+ ramp = f"clip({terms[0] if len(terms) == 1 else f'min({terms[0]},{terms[1]})'},0,1)"
322+ # smooth : adouci (smoothstep) ; slow : vitesse constante
323+ k = f"{inside}*{ramp}" if z.get("style") == "slow" else f"{inside}*{ramp}*{ramp}*(3-2*{ramp})"
313324 k_terms.append((z, k))
314325 s = "+".join(f"{_num(z['scale'] - 1)}*{k}" for z, k in k_terms) or "0"
315326 x = "+".join(f"{_num(z['x'])}*{k}" for z, k in k_terms) or "0"
@@ -370,7 +381,7 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
370381
371382 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
372383 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
373- zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""
384+ zoom = (zoom_filter(p["zooms"], seg["start"], seg["end"], fps) + ",") if seg["zoom"] else ""
374385 sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}"
375386
376387 def voice(n: int, clip: dict, label: str, count: int = samples, after: str = "") -> str:
@@ -298,18 +298,29 @@ def _num(x: float) -> str:
298 return f"{x:.6f}".rstrip("0").rstrip(".") or "0"298 return f"{x:.6f}".rstrip("0").rstrip(".") or "0"
299 299
300 300
301-def zoom_filter(zooms: list[dict], start: int, fps: Fraction) -> str:301+def zoom_filter(zooms: list[dict], start: int, end: int, fps: Fraction) -> str:
302 """302 """
303- Le recadrage des zooms (perspective, image par image), comme l'aperçu : l'image est agrandie303+ Le recadrage des zooms qui touchent les images [start, end[ (perspective, image par image), comme
304- de `scale` autour de son centre puis décalée de −x, −y (en fraction de l'image) ; montée et304+ l'aperçu : l'image est agrandie de `scale` autour de son centre puis décalée de −x, −y (en fraction
305- descente de ZOOM_RAMP. Le cadre reste dans l'image.305+ de l'image), selon la courbe de son style (montage.zoom_amount). Le cadre reste dans l'image.
306 """306 """
307- t = f"((in+{start})*{fps.denominator}/{fps.numerator})"307+ # `in` compte les images à partir de 1 dans perspective
308+ t = f"((in-1+{start})*{fps.denominator}/{fps.numerator})"
309+ first, last = float(Fraction(start) / fps), float(Fraction(end) / fps)
308 k_terms = []310 k_terms = []
309 for z in zooms:311 for z in zooms:
310 a, b = z["at"], z["at"] + z["dur"]312 a, b = z["at"], z["at"] + z["dur"]
311- ramp = min(montage.ZOOM_RAMP, z["dur"] / 3) or 1e-3313+ if b <= first or a >= last:
312- k = f"between({t},{_num(a)},{_num(b)})*clip(min(({t}-{_num(a)})/{_num(ramp)},({_num(b)}-{t})/{_num(ramp)}),0,1)"314+ continue
315+ rise, fall = montage.zoom_ramps(z)
316+ terms = [*([f"({t}-{_num(a)})/{_num(rise)}"] if rise else []), *([f"({_num(b)}-{t})/{_num(fall)}"] if fall else [])]
317+ inside = f"gte({t},{_num(a)})*lt({t},{_num(b)})"
318+ if not terms:
319+ k = inside
320+ else:
321+ ramp = f"clip({terms[0] if len(terms) == 1 else f'min({terms[0]},{terms[1]})'},0,1)"
322+ # smooth : adouci (smoothstep) ; slow : vitesse constante
323+ k = f"{inside}*{ramp}" if z.get("style") == "slow" else f"{inside}*{ramp}*{ramp}*(3-2*{ramp})"
313 k_terms.append((z, k))324 k_terms.append((z, k))
314 s = "+".join(f"{_num(z['scale'] - 1)}*{k}" for z, k in k_terms) or "0"325 s = "+".join(f"{_num(z['scale'] - 1)}*{k}" for z, k in k_terms) or "0"
315 x = "+".join(f"{_num(z['x'])}*{k}" for z, k in k_terms) or "0"326 x = "+".join(f"{_num(z['x'])}*{k}" for z, k in k_terms) or "0"
@@ -370,7 +381,7 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
370 381
371 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"382 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
372 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")383 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
373- zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""384+ zoom = (zoom_filter(p["zooms"], seg["start"], seg["end"], fps) + ",") if seg["zoom"] else ""
374 sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}"385 sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}"
375 386
376 def voice(n: int, clip: dict, label: str, count: int = samples, after: str = "") -> str:387 def voice(n: int, clip: dict, label: str, count: int = samples, after: str = "") -> str:
modified backend/studio/modules/projects/storyboard.py +29 -15
@@ -4,7 +4,7 @@ Storyboard de l'habillage de la vidéo longue : Daryl propose d'abord une liste
44
55 Quatre types de beats :
66 - motion : un visuel animé (HyperFrames) au-dessus de la vidéo, à sa place à l'écran ;
7-- zoom : un recadrage progressif de l'A-roll (piste zooms), posé tel quel ;
7+- zoom : un recadrage de l'A-roll (piste zooms), posé tel quel : progressif (smooth), net (cut) ou lent (slow) ;
88 - broll : un plan de coupe. Une vidéo va sur la piste B-roll ; une image est animée comme un motion
99 (plein écran ou sur un côté) ; sans média, c'est une suggestion « à trouver » (`search`) ;
1010 - transition : un enchaînement animé plein écran, court, au changement de partie.
@@ -25,7 +25,7 @@ Fichier : montage/storyboard.json
2525 {"density", "note", "message", "created",
2626 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",
2727 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",
28- "zoom": {"scale", "x", "y"}, # zoom
28+ "zoom": {"scale", "x", "y", "style"}, # zoom (style : montage.ZOOM_STYLES)
2929 "src", "search", # broll : le média (vide : à trouver), la recherche
3030 "images"}]} # motion : les images et vidéos à y intégrer (facultatives)
3131 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :
@@ -70,10 +70,14 @@ SEARCH_BEFORE, SEARCH_AFTER, UNTIL_SPAN = 20, 200, 80
7070 TYPES = ["motion", "zoom", "broll", "transition"]
7171 # les types qui occupent l'écran (un seul à la fois) ; un zoom recadre l'A-roll en dessous
7272 SCREEN = ("motion", "broll", "transition")
73-# zoom : grossissement, décalage du centre, durée ; il arrive à peine avant le mot
73+# zoom : style, grossissement, décalage du centre, durée ; il arrive à peine avant le mot. Un zoom lent (slow)
74+# grossit peu, sur plus longtemps
75+ZOOM_STYLES = montage.ZOOM_STYLES
7476 ZOOM_SCALE = (1.05, 1.5)
7577 ZOOM_DEFAULT = 1.25
78+ZOOM_SLOW_DEFAULT = 1.1
7679 ZOOM_DUR = (1.5, 8.0)
80+ZOOM_SLOW_DUR = (4.0, 15.0)
7781 ZOOM_LEAD = 0.1
7882 # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie
7983 TRANSITION_DUR = 1.2
@@ -147,10 +151,14 @@ SCHEMA = {
147151 "description": "Motion et broll : la transition de sortie, ou vide."},
148152 "zoom": {
149153 "type": "object",
150- "description": "Zoom seulement : le grossissement et le décalage du centre.",
151- "required": ["scale", "x", "y"],
154+ "description": "Zoom seulement : le style, le grossissement et le décalage du centre.",
155+ "required": ["style", "scale", "x", "y"],
152156 "properties": {
153- "scale": {"type": "number", "description": "1.12 (léger) à 1.35 (serré)."},
157+ "style": {"type": "string", "enum": list(ZOOM_STYLES),
158+ "description": "smooth : montée et descente douces ; cut : saut net (aparté, "
159+ "chute) ; slow : poussée lente sur toute la durée."},
160+ "scale": {"type": "number", "description": "smooth / cut : 1.12 (léger) à 1.35 (serré) ; "
161+ "slow : 1.05 à 1.15."},
154162 "x": {"type": "number", "description": "Décalage horizontal du centre, en fraction de "
155163 "l'image (−0.3 à 0.3, positif : vers la droite)."},
156164 "y": {"type": "number", "description": "Décalage vertical (positif : vers le bas)."},
@@ -276,10 +284,11 @@ def zoom_of(raw) -> dict:
276284 except (TypeError, ValueError):
277285 return default
278286
279- scale = max(ZOOM_SCALE[0], min(num("scale", ZOOM_DEFAULT), ZOOM_SCALE[1]))
287+ style = raw.get("style") if raw.get("style") in ZOOM_STYLES else "smooth"
288+ scale = max(ZOOM_SCALE[0], min(num("scale", ZOOM_SLOW_DEFAULT if style == "slow" else ZOOM_DEFAULT), ZOOM_SCALE[1]))
280289 room = (scale - 1) / 2
281290 return {"scale": round(scale, 3), "x": round(max(-room, min(num("x", 0.0), room)), 3),
282- "y": round(max(-room, min(num("y", 0.0), room)), 3)}
291+ "y": round(max(-room, min(num("y", 0.0), room)), 3), "style": style}
283292
284293
285294 SYSTEM = """\
@@ -291,11 +300,15 @@ storyboard, le montage se fait ensuite. Tout est dans le message ; tu ne lis que
291300 Les types de beats (`type`) — ne propose que ceux que le message demande :
292301 - motion : un visuel animé posé au-dessus de la vidéo (détails plus bas). C'est le seul type que \
293302 compte la densité demandée.
294-- zoom : un recadrage progressif sur lui, pour appuyer une phrase forte, une chute, une confidence, \
295-une émotion. `zoom` : `scale` de 1.12 (léger) à 1.35 (serré), `x` / `y` pour garder son visage au \
296-centre du cadre (regarde les images de la vidéo). 2 à 6 secondes, environ un toutes les 20 à 40 \
297-secondes, jamais pendant un motion plein écran, un B-roll ou une transition (il peut accompagner un \
298-motion sur le côté ou en bandeau). `show` : ce qu'il appuie, en quelques mots.
303+- zoom : un recadrage sur lui, pour appuyer une phrase forte, une chute, une confidence, une émotion. \
304+`zoom.style` : `smooth` (montée et descente douces, le cas courant), `cut` (le cadre saute d'un coup, \
305+sans mouvement : un aparté face caméra, une chute, une réaction, un « bref »), `slow` (une poussée lente \
306+sur toute la durée puis retour net : une explication posée, une confidence, une tension qui monte). \
307+`scale` : smooth / cut de 1.12 (léger) à 1.35 (serré), slow de 1.05 à 1.15 ; `x` / `y` pour garder son \
308+visage au centre du cadre (regarde les images de la vidéo). smooth / cut 2 à 6 secondes, slow 5 à 15 \
309+secondes ; varie les styles. Environ un toutes les 20 à 40 secondes, jamais pendant un motion plein \
310+écran, un B-roll ou une transition (il peut accompagner un motion sur le côté ou en bandeau). `show` : \
311+ce qu'il appuie, en quelques mots.
299312 - broll : un plan de coupe qui montre ce dont il parle (un lieu, un objet, une personne, une action, \
300313 un site) à la place de son visage, 3 à 8 secondes. `media` = le src exact d'un média visuel de la \
301314 liste quand l'un d'eux convient (d'après son nom et sa description) : une vidéo passe en plein écran ; \
@@ -584,7 +597,7 @@ def prompt(project: dict, timeline: dict, lines: list[dict], total: float, densi
584597 *(f"- {_clock(a)} → {_clock(b)} {what}" for a, b, what in sorted(taken)), ""]
585598 if "zoom" in types and timeline["zooms"]:
586599 parts += ["Zooms déjà dans la timeline (pas d'autre zoom dessus) :",
587- *(f"- {_clock(z['at'])} → {_clock(z['at'] + z['dur'])} ×{z['scale']:.2f}" for z in timeline["zooms"]), ""]
600+ *(f"- {_clock(z['at'])} → {_clock(z['at'] + z['dur'])} {z.get('style', 'smooth')} ×{z['scale']:.2f}" for z in timeline["zooms"]), ""]
588601 if "broll" in types:
589602 if visual:
590603 parts += ["Les médias visuels disponibles (`media` = le src exact) :",
@@ -799,7 +812,8 @@ def make_beat(spec: dict, words: list[cues.Word], a: int, b: int | None, total:
799812 else:
800813 at = max(0.0, start - (ZOOM_LEAD if kind == "zoom" else LEAD))
801814 end = words[b].end + TAIL if b is not None else at + DEFAULT_DUR
802- lo, hi = {"zoom": ZOOM_DUR, "broll": BROLL_DUR}.get(kind, (MIN_DUR, MAX_DUR))
815+ slow = kind == "zoom" and zoom_of(spec.get("zoom"))["style"] == "slow"
816+ lo, hi = {"zoom": ZOOM_SLOW_DUR if slow else ZOOM_DUR, "broll": BROLL_DUR}.get(kind, (MIN_DUR, MAX_DUR))
803817 if plan:
804818 # un plan du script garde la durée de son passage
805819 hi = MAX_SCRIPT_DUR
@@ -4,7 +4,7 @@ Storyboard de l'habillage de la vidéo longue : Daryl propose d'abord une liste
4 4
5 Quatre types de beats :5 Quatre types de beats :
6 - motion : un visuel animé (HyperFrames) au-dessus de la vidéo, à sa place à l'écran ;6 - motion : un visuel animé (HyperFrames) au-dessus de la vidéo, à sa place à l'écran ;
7-- zoom : un recadrage progressif de l'A-roll (piste zooms), posé tel quel ;7+- zoom : un recadrage de l'A-roll (piste zooms), posé tel quel : progressif (smooth), net (cut) ou lent (slow) ;
8 - broll : un plan de coupe. Une vidéo va sur la piste B-roll ; une image est animée comme un motion8 - broll : un plan de coupe. Une vidéo va sur la piste B-roll ; une image est animée comme un motion
9 (plein écran ou sur un côté) ; sans média, c'est une suggestion « à trouver » (`search`) ;9 (plein écran ou sur un côté) ; sans média, c'est une suggestion « à trouver » (`search`) ;
10 - transition : un enchaînement animé plein écran, court, au changement de partie.10 - transition : un enchaînement animé plein écran, court, au changement de partie.
@@ -25,7 +25,7 @@ Fichier : montage/storyboard.json
25 {"density", "note", "message", "created",25 {"density", "note", "message", "created",
26 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",26 "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place",
27 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",27 "motion": {"enter", "hold", "exit"}, "sfx", "ref", "why", "on",
28- "zoom": {"scale", "x", "y"}, # zoom28+ "zoom": {"scale", "x", "y", "style"}, # zoom (style : montage.ZOOM_STYLES)
29 "src", "search", # broll : le média (vide : à trouver), la recherche29 "src", "search", # broll : le média (vide : à trouver), la recherche
30 "images"}]} # motion : les images et vidéos à y intégrer (facultatives)30 "images"}]} # motion : les images et vidéos à y intégrer (facultatives)
31 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :31 `at` / `dur` en secondes de la timeline. Les contrôles (`check`) sont refaits à chaque lecture :
@@ -70,10 +70,14 @@ SEARCH_BEFORE, SEARCH_AFTER, UNTIL_SPAN = 20, 200, 80
70 TYPES = ["motion", "zoom", "broll", "transition"]70 TYPES = ["motion", "zoom", "broll", "transition"]
71 # les types qui occupent l'écran (un seul à la fois) ; un zoom recadre l'A-roll en dessous71 # les types qui occupent l'écran (un seul à la fois) ; un zoom recadre l'A-roll en dessous
72 SCREEN = ("motion", "broll", "transition")72 SCREEN = ("motion", "broll", "transition")
73-# zoom : grossissement, décalage du centre, durée ; il arrive à peine avant le mot73+# zoom : style, grossissement, décalage du centre, durée ; il arrive à peine avant le mot. Un zoom lent (slow)
74+# grossit peu, sur plus longtemps
75+ZOOM_STYLES = montage.ZOOM_STYLES
74 ZOOM_SCALE = (1.05, 1.5)76 ZOOM_SCALE = (1.05, 1.5)
75 ZOOM_DEFAULT = 1.2577 ZOOM_DEFAULT = 1.25
78+ZOOM_SLOW_DEFAULT = 1.1
76 ZOOM_DUR = (1.5, 8.0)79 ZOOM_DUR = (1.5, 8.0)
80+ZOOM_SLOW_DUR = (4.0, 15.0)
77 ZOOM_LEAD = 0.181 ZOOM_LEAD = 0.1
78 # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie82 # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie
79 TRANSITION_DUR = 1.283 TRANSITION_DUR = 1.2
@@ -147,10 +151,14 @@ SCHEMA = {
147 "description": "Motion et broll : la transition de sortie, ou vide."},151 "description": "Motion et broll : la transition de sortie, ou vide."},
148 "zoom": {152 "zoom": {
149 "type": "object",153 "type": "object",
150- "description": "Zoom seulement : le grossissement et le décalage du centre.",154+ "description": "Zoom seulement : le style, le grossissement et le décalage du centre.",
151- "required": ["scale", "x", "y"],155+ "required": ["style", "scale", "x", "y"],
152 "properties": {156 "properties": {
153- "scale": {"type": "number", "description": "1.12 (léger) à 1.35 (serré)."},157+ "style": {"type": "string", "enum": list(ZOOM_STYLES),
158+ "description": "smooth : montée et descente douces ; cut : saut net (aparté, "
159+ "chute) ; slow : poussée lente sur toute la durée."},
160+ "scale": {"type": "number", "description": "smooth / cut : 1.12 (léger) à 1.35 (serré) ; "
161+ "slow : 1.05 à 1.15."},
154 "x": {"type": "number", "description": "Décalage horizontal du centre, en fraction de "162 "x": {"type": "number", "description": "Décalage horizontal du centre, en fraction de "
155 "l'image (−0.3 à 0.3, positif : vers la droite)."},163 "l'image (−0.3 à 0.3, positif : vers la droite)."},
156 "y": {"type": "number", "description": "Décalage vertical (positif : vers le bas)."},164 "y": {"type": "number", "description": "Décalage vertical (positif : vers le bas)."},
@@ -276,10 +284,11 @@ def zoom_of(raw) -> dict:
276 except (TypeError, ValueError):284 except (TypeError, ValueError):
277 return default285 return default
278 286
279- scale = max(ZOOM_SCALE[0], min(num("scale", ZOOM_DEFAULT), ZOOM_SCALE[1]))287+ style = raw.get("style") if raw.get("style") in ZOOM_STYLES else "smooth"
288+ scale = max(ZOOM_SCALE[0], min(num("scale", ZOOM_SLOW_DEFAULT if style == "slow" else ZOOM_DEFAULT), ZOOM_SCALE[1]))
280 room = (scale - 1) / 2289 room = (scale - 1) / 2
281 return {"scale": round(scale, 3), "x": round(max(-room, min(num("x", 0.0), room)), 3),290 return {"scale": round(scale, 3), "x": round(max(-room, min(num("x", 0.0), room)), 3),
282- "y": round(max(-room, min(num("y", 0.0), room)), 3)}291+ "y": round(max(-room, min(num("y", 0.0), room)), 3), "style": style}
283 292
284 293
285 SYSTEM = """\294 SYSTEM = """\
@@ -291,11 +300,15 @@ storyboard, le montage se fait ensuite. Tout est dans le message ; tu ne lis que
291 Les types de beats (`type`) — ne propose que ceux que le message demande :300 Les types de beats (`type`) — ne propose que ceux que le message demande :
292 - motion : un visuel animé posé au-dessus de la vidéo (détails plus bas). C'est le seul type que \301 - motion : un visuel animé posé au-dessus de la vidéo (détails plus bas). C'est le seul type que \
293 compte la densité demandée.302 compte la densité demandée.
294-- zoom : un recadrage progressif sur lui, pour appuyer une phrase forte, une chute, une confidence, \303+- zoom : un recadrage sur lui, pour appuyer une phrase forte, une chute, une confidence, une émotion. \
295-une émotion. `zoom` : `scale` de 1.12 (léger) à 1.35 (serré), `x` / `y` pour garder son visage au \304+`zoom.style` : `smooth` (montée et descente douces, le cas courant), `cut` (le cadre saute d'un coup, \
296-centre du cadre (regarde les images de la vidéo). 2 à 6 secondes, environ un toutes les 20 à 40 \305+sans mouvement : un aparté face caméra, une chute, une réaction, un « bref »), `slow` (une poussée lente \
297-secondes, jamais pendant un motion plein écran, un B-roll ou une transition (il peut accompagner un \306+sur toute la durée puis retour net : une explication posée, une confidence, une tension qui monte). \
298-motion sur le côté ou en bandeau). `show` : ce qu'il appuie, en quelques mots.307+`scale` : smooth / cut de 1.12 (léger) à 1.35 (serré), slow de 1.05 à 1.15 ; `x` / `y` pour garder son \
308+visage au centre du cadre (regarde les images de la vidéo). smooth / cut 2 à 6 secondes, slow 5 à 15 \
309+secondes ; varie les styles. Environ un toutes les 20 à 40 secondes, jamais pendant un motion plein \
310+écran, un B-roll ou une transition (il peut accompagner un motion sur le côté ou en bandeau). `show` : \
311+ce qu'il appuie, en quelques mots.
299 - broll : un plan de coupe qui montre ce dont il parle (un lieu, un objet, une personne, une action, \312 - broll : un plan de coupe qui montre ce dont il parle (un lieu, un objet, une personne, une action, \
300 un site) à la place de son visage, 3 à 8 secondes. `media` = le src exact d'un média visuel de la \313 un site) à la place de son visage, 3 à 8 secondes. `media` = le src exact d'un média visuel de la \
301 liste quand l'un d'eux convient (d'après son nom et sa description) : une vidéo passe en plein écran ; \314 liste quand l'un d'eux convient (d'après son nom et sa description) : une vidéo passe en plein écran ; \
@@ -584,7 +597,7 @@ def prompt(project: dict, timeline: dict, lines: list[dict], total: float, densi
584 *(f"- {_clock(a)} → {_clock(b)} {what}" for a, b, what in sorted(taken)), ""]597 *(f"- {_clock(a)} → {_clock(b)} {what}" for a, b, what in sorted(taken)), ""]
585 if "zoom" in types and timeline["zooms"]:598 if "zoom" in types and timeline["zooms"]:
586 parts += ["Zooms déjà dans la timeline (pas d'autre zoom dessus) :",599 parts += ["Zooms déjà dans la timeline (pas d'autre zoom dessus) :",
587- *(f"- {_clock(z['at'])} → {_clock(z['at'] + z['dur'])} ×{z['scale']:.2f}" for z in timeline["zooms"]), ""]600+ *(f"- {_clock(z['at'])} → {_clock(z['at'] + z['dur'])} {z.get('style', 'smooth')} ×{z['scale']:.2f}" for z in timeline["zooms"]), ""]
588 if "broll" in types:601 if "broll" in types:
589 if visual:602 if visual:
590 parts += ["Les médias visuels disponibles (`media` = le src exact) :",603 parts += ["Les médias visuels disponibles (`media` = le src exact) :",
@@ -799,7 +812,8 @@ def make_beat(spec: dict, words: list[cues.Word], a: int, b: int | None, total:
799 else:812 else:
800 at = max(0.0, start - (ZOOM_LEAD if kind == "zoom" else LEAD))813 at = max(0.0, start - (ZOOM_LEAD if kind == "zoom" else LEAD))
801 end = words[b].end + TAIL if b is not None else at + DEFAULT_DUR814 end = words[b].end + TAIL if b is not None else at + DEFAULT_DUR
802- lo, hi = {"zoom": ZOOM_DUR, "broll": BROLL_DUR}.get(kind, (MIN_DUR, MAX_DUR))815+ slow = kind == "zoom" and zoom_of(spec.get("zoom"))["style"] == "slow"
816+ lo, hi = {"zoom": ZOOM_SLOW_DUR if slow else ZOOM_DUR, "broll": BROLL_DUR}.get(kind, (MIN_DUR, MAX_DUR))
803 if plan:817 if plan:
804 # un plan du script garde la durée de son passage818 # un plan du script garde la durée de son passage
805 hi = MAX_SCRIPT_DUR819 hi = MAX_SCRIPT_DUR
modified backend/studio/modules/projects/storyboard_chat.py +5 -4
@@ -67,9 +67,10 @@ l'image change, et ce qu'on y voit. Rien n'est composé ici : tu proposes, il va
6767
6868 Quatre types de beats (`type`) :
6969 - motion : un visuel animé au-dessus de la vidéo, à sa `place` ;
70-- zoom : un recadrage progressif sur lui (`zoom` : `scale` 1.12 léger à 1.35 serré, `x` / `y` pour \
71-garder son visage au centre du cadre), 2 à 6 s, pour appuyer une phrase forte ; jamais sous un \
72-visuel plein écran ;
70+- zoom : un recadrage sur lui pour appuyer une phrase forte (`zoom` : `style` smooth = montée et descente \
71+douces, cut = saut net pour un aparté face caméra ou une chute, slow = poussée lente sur toute la durée ; \
72+`scale` 1.12 léger à 1.35 serré, 1.05 à 1.15 en slow ; `x` / `y` pour garder son visage au centre du \
73+cadre), 2 à 6 s (slow : 5 à 15 s) ; jamais sous un visuel plein écran ;
7374 - broll : un plan de coupe, 3 à 8 s. `media` = le src exact d'un média visuel de la liste (une vidéo \
7475 passe en plein écran ; une image est animée en plein écran ou sur un côté, comme un portrait, selon \
7576 `place`) ; si aucun ne convient, `media` vide et `search` = la recherche à faire pour le trouver ; pour une vidéo, \
@@ -145,7 +146,7 @@ def _beat_line(b: dict, composed: dict) -> str:
145146 kind = b.get("type", "motion")
146147 if kind == "zoom":
147148 z = b.get("zoom") or {}
148- what = f"zoom ×{z.get('scale', 1):.2f} (x {z.get('x', 0):+.2f}, y {z.get('y', 0):+.2f})"
149+ what = f"zoom {z.get('style', 'smooth')} ×{z.get('scale', 1):.2f} (x {z.get('x', 0):+.2f}, y {z.get('y', 0):+.2f})"
149150 elif kind == "broll":
150151 what = f"broll · {b['place']} · " + (f"média {b['src']}" + (f" dès {b['start']:g} s" if b.get("start") else "")
151152 if b.get("src") else f"à trouver : {b.get('search') or '?'}")
@@ -67,9 +67,10 @@ l'image change, et ce qu'on y voit. Rien n'est composé ici : tu proposes, il va
67 67
68 Quatre types de beats (`type`) :68 Quatre types de beats (`type`) :
69 - motion : un visuel animé au-dessus de la vidéo, à sa `place` ;69 - motion : un visuel animé au-dessus de la vidéo, à sa `place` ;
70-- zoom : un recadrage progressif sur lui (`zoom` : `scale` 1.12 léger à 1.35 serré, `x` / `y` pour \70+- zoom : un recadrage sur lui pour appuyer une phrase forte (`zoom` : `style` smooth = montée et descente \
71-garder son visage au centre du cadre), 2 à 6 s, pour appuyer une phrase forte ; jamais sous un \71+douces, cut = saut net pour un aparté face caméra ou une chute, slow = poussée lente sur toute la durée ; \
72-visuel plein écran ;72+`scale` 1.12 léger à 1.35 serré, 1.05 à 1.15 en slow ; `x` / `y` pour garder son visage au centre du \
73+cadre), 2 à 6 s (slow : 5 à 15 s) ; jamais sous un visuel plein écran ;
73 - broll : un plan de coupe, 3 à 8 s. `media` = le src exact d'un média visuel de la liste (une vidéo \74 - broll : un plan de coupe, 3 à 8 s. `media` = le src exact d'un média visuel de la liste (une vidéo \
74 passe en plein écran ; une image est animée en plein écran ou sur un côté, comme un portrait, selon \75 passe en plein écran ; une image est animée en plein écran ou sur un côté, comme un portrait, selon \
75 `place`) ; si aucun ne convient, `media` vide et `search` = la recherche à faire pour le trouver ; pour une vidéo, \76 `place`) ; si aucun ne convient, `media` vide et `search` = la recherche à faire pour le trouver ; pour une vidéo, \
@@ -145,7 +146,7 @@ def _beat_line(b: dict, composed: dict) -> str:
145 kind = b.get("type", "motion")146 kind = b.get("type", "motion")
146 if kind == "zoom":147 if kind == "zoom":
147 z = b.get("zoom") or {}148 z = b.get("zoom") or {}
148- what = f"zoom ×{z.get('scale', 1):.2f} (x {z.get('x', 0):+.2f}, y {z.get('y', 0):+.2f})"149+ what = f"zoom {z.get('style', 'smooth')} ×{z.get('scale', 1):.2f} (x {z.get('x', 0):+.2f}, y {z.get('y', 0):+.2f})"
149 elif kind == "broll":150 elif kind == "broll":
150 what = f"broll · {b['place']} · " + (f"média {b['src']}" + (f" dès {b['start']:g} s" if b.get("start") else "")151 what = f"broll · {b['place']} · " + (f"média {b['src']}" + (f" dès {b['start']:g} s" if b.get("start") else "")
151 if b.get("src") else f"à trouver : {b.get('search') or '?'}")152 if b.get("src") else f"à trouver : {b.get('search') or '?'}")
modified web/src/i18n/locales/en/editing.ts +9 -0
@@ -100,6 +100,14 @@ export default {
100100 addAtCursor: "Add at playhead · {tc}",
101101 zoomLight: "Light zoom",
102102 zoomStrong: "Strong zoom",
103+ zoomCut: "Punch-in",
104+ zoomSlow: "Slow push",
105+ zoomStyles: { smooth: "Smooth", cut: "Punch-in", slow: "Slow push" },
106+ zoomStyleHint: {
107+ smooth: "Eases in and out (0.4 s): to stress a line.",
108+ cut: "The frame jumps at once, in and out: an aside to camera, a punchline, a reaction.",
109+ slow: "Pushes in slowly over its whole length, then snaps back: a calm explanation, rising tension. Keep it subtle (×1.05 to ×1.15).",
110+ },
103111 addText: "+ Text",
104112 addTextTitle: "A handwritten note on screen (fix a word that is hard to hear)",
105113 textPlaceholder: "What you actually say…",
@@ -245,6 +253,7 @@ export default {
245253 transition: "What it shows (the section title)…",
246254 },
247255 zoom: {
256+ style: "Style",
248257 scale: "Magnification",
249258 scales: { 0: "Light", 1: "Medium", 2: "Tight" },
250259 frame: "Kept frame",
@@ -100,6 +100,14 @@ export default {
100 addAtCursor: "Add at playhead · {tc}",100 addAtCursor: "Add at playhead · {tc}",
101 zoomLight: "Light zoom",101 zoomLight: "Light zoom",
102 zoomStrong: "Strong zoom",102 zoomStrong: "Strong zoom",
103+ zoomCut: "Punch-in",
104+ zoomSlow: "Slow push",
105+ zoomStyles: { smooth: "Smooth", cut: "Punch-in", slow: "Slow push" },
106+ zoomStyleHint: {
107+ smooth: "Eases in and out (0.4 s): to stress a line.",
108+ cut: "The frame jumps at once, in and out: an aside to camera, a punchline, a reaction.",
109+ slow: "Pushes in slowly over its whole length, then snaps back: a calm explanation, rising tension. Keep it subtle (×1.05 to ×1.15).",
110+ },
103 addText: "+ Text",111 addText: "+ Text",
104 addTextTitle: "A handwritten note on screen (fix a word that is hard to hear)",112 addTextTitle: "A handwritten note on screen (fix a word that is hard to hear)",
105 textPlaceholder: "What you actually say…",113 textPlaceholder: "What you actually say…",
@@ -245,6 +253,7 @@ export default {
245 transition: "What it shows (the section title)…",253 transition: "What it shows (the section title)…",
246 },254 },
247 zoom: {255 zoom: {
256+ style: "Style",
248 scale: "Magnification",257 scale: "Magnification",
249 scales: { 0: "Light", 1: "Medium", 2: "Tight" },258 scales: { 0: "Light", 1: "Medium", 2: "Tight" },
250 frame: "Kept frame",259 frame: "Kept frame",
modified web/src/i18n/locales/fr/editing.ts +9 -0
@@ -100,6 +100,14 @@ export default {
100100 addAtCursor: "Ajouter au curseur · {tc}",
101101 zoomLight: "Zoom léger",
102102 zoomStrong: "Zoom fort",
103+ zoomCut: "Zoom net",
104+ zoomSlow: "Zoom lent",
105+ zoomStyles: { smooth: "Progressif", cut: "Net", slow: "Lent" },
106+ zoomStyleHint: {
107+ smooth: "Monte et redescend en douceur (0,4 s) : pour appuyer une phrase.",
108+ cut: "Le cadre saute d’un coup, à l’entrée comme à la sortie : un aparté face cam, une chute, une réaction.",
109+ slow: "Pousse lentement sur toute sa durée, puis revient d’un coup : une explication posée, une tension qui monte. Garde-le discret (×1,05 à ×1,15).",
110+ },
103111 addText: "+ Texte",
104112 addTextTitle: "Un texte écrit à la main, à l’écran (corriger un mot mal audible)",
105113 textPlaceholder: "Ce que tu dis vraiment…",
@@ -245,6 +253,7 @@ export default {
245253 transition: "Ce qu’elle affiche (le titre de la partie)…",
246254 },
247255 zoom: {
256+ style: "Style",
248257 scale: "Grossissement",
249258 scales: { 0: "Léger", 1: "Moyen", 2: "Serré" },
250259 frame: "Cadre gardé",
@@ -100,6 +100,14 @@ export default {
100 addAtCursor: "Ajouter au curseur · {tc}",100 addAtCursor: "Ajouter au curseur · {tc}",
101 zoomLight: "Zoom léger",101 zoomLight: "Zoom léger",
102 zoomStrong: "Zoom fort",102 zoomStrong: "Zoom fort",
103+ zoomCut: "Zoom net",
104+ zoomSlow: "Zoom lent",
105+ zoomStyles: { smooth: "Progressif", cut: "Net", slow: "Lent" },
106+ zoomStyleHint: {
107+ smooth: "Monte et redescend en douceur (0,4 s) : pour appuyer une phrase.",
108+ cut: "Le cadre saute d’un coup, à l’entrée comme à la sortie : un aparté face cam, une chute, une réaction.",
109+ slow: "Pousse lentement sur toute sa durée, puis revient d’un coup : une explication posée, une tension qui monte. Garde-le discret (×1,05 à ×1,15).",
110+ },
103 addText: "+ Texte",111 addText: "+ Texte",
104 addTextTitle: "Un texte écrit à la main, à l’écran (corriger un mot mal audible)",112 addTextTitle: "Un texte écrit à la main, à l’écran (corriger un mot mal audible)",
105 textPlaceholder: "Ce que tu dis vraiment…",113 textPlaceholder: "Ce que tu dis vraiment…",
@@ -245,6 +253,7 @@ export default {
245 transition: "Ce qu’elle affiche (le titre de la partie)…",253 transition: "Ce qu’elle affiche (le titre de la partie)…",
246 },254 },
247 zoom: {255 zoom: {
256+ style: "Style",
248 scale: "Grossissement",257 scale: "Grossissement",
249 scales: { 0: "Léger", 1: "Moyen", 2: "Serré" },258 scales: { 0: "Léger", 1: "Moyen", 2: "Serré" },
250 frame: "Cadre gardé",259 frame: "Cadre gardé",
modified web/src/project/steps/StoryboardStep.vue +9 -3
@@ -19,7 +19,7 @@ import { act, store } from "../store";
1919 import { usePlaylist, type Piece } from "./derush/usePlaylist";
2020 import Preview from "./montage/Preview.vue";
2121 import StoryboardChat from "./storyboard/StoryboardChat.vue";
22-import { BEAT_TYPES, isImage, tcd, TYPE_COLOR, type Beat, type BeatType, type BeatWarning } from "./montage/types";
22+import { BEAT_TYPES, isImage, tcd, TYPE_COLOR, ZOOM_STYLES, type Beat, type BeatType, type BeatWarning, type ZoomStyle } from "./montage/types";
2323 import { useMontage } from "./montage/useMontage";
2424 import { useStoryboard } from "./montage/useStoryboard";
2525
@@ -119,7 +119,7 @@ function setStart(b: Beat, value: number) {
119119 function setType(b: Beat, type: BeatType) {
120120 SB.edit(() => {
121121 b.type = type;
122- if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0 };
122+ if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0, style: "smooth" };
123123 if (type === "broll") {
124124 b.src ??= "";
125125 b.search ??= "";
@@ -135,7 +135,7 @@ const clampZ = (v: number, s: number) => Math.round(Math.max(-room(s), Math.min(
135135 function setZoom(b: Beat, patch: Partial<NonNullable<Beat["zoom"]>>) {
136136 SB.edit(() => {
137137 const z = { ...(b.zoom ?? { scale: 1.25, x: 0, y: 0 }), ...patch };
138- b.zoom = { scale: z.scale, x: clampZ(z.x, z.scale), y: clampZ(z.y, z.scale) };
138+ b.zoom = { scale: z.scale, x: clampZ(z.x, z.scale), y: clampZ(z.y, z.scale), style: z.style ?? "smooth" };
139139 }, 150);
140140 }
141141 /** le cadre gardé, dans l'image entière (comme l'aperçu : agrandi autour du centre, puis décalé) */
@@ -390,6 +390,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
390390
391391 <!-- comment : selon le type -->
392392 <div v-if="b.type === 'zoom'" class="how">
393+ <label :title="tr(`editing.inspector.zoomStyleHint.${b.zoom?.style ?? 'smooth'}`)">
394+ <span class="label">{{ tr("editing.storyboard.zoom.style") }}</span>
395+ <select :value="b.zoom?.style ?? 'smooth'" @change="setZoom(b, { style: ($event.target as HTMLSelectElement).value as ZoomStyle })">
396+ <option v-for="s in ZOOM_STYLES" :key="s" :value="s">{{ tr(`editing.inspector.zoomStyles.${s}`) }}</option>
397+ </select>
398+ </label>
393399 <label>
394400 <span class="label">{{ tr("editing.storyboard.zoom.scale") }}</span>
395401 <select :value="b.zoom?.scale" @change="setZoom(b, { scale: +($event.target as HTMLSelectElement).value })">
@@ -19,7 +19,7 @@ import { act, store } from "../store";
19 import { usePlaylist, type Piece } from "./derush/usePlaylist";19 import { usePlaylist, type Piece } from "./derush/usePlaylist";
20 import Preview from "./montage/Preview.vue";20 import Preview from "./montage/Preview.vue";
21 import StoryboardChat from "./storyboard/StoryboardChat.vue";21 import StoryboardChat from "./storyboard/StoryboardChat.vue";
22-import { BEAT_TYPES, isImage, tcd, TYPE_COLOR, type Beat, type BeatType, type BeatWarning } from "./montage/types";22+import { BEAT_TYPES, isImage, tcd, TYPE_COLOR, ZOOM_STYLES, type Beat, type BeatType, type BeatWarning, type ZoomStyle } from "./montage/types";
23 import { useMontage } from "./montage/useMontage";23 import { useMontage } from "./montage/useMontage";
24 import { useStoryboard } from "./montage/useStoryboard";24 import { useStoryboard } from "./montage/useStoryboard";
25 25
@@ -119,7 +119,7 @@ function setStart(b: Beat, value: number) {
119 function setType(b: Beat, type: BeatType) {119 function setType(b: Beat, type: BeatType) {
120 SB.edit(() => {120 SB.edit(() => {
121 b.type = type;121 b.type = type;
122- if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0 };122+ if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0, style: "smooth" };
123 if (type === "broll") {123 if (type === "broll") {
124 b.src ??= "";124 b.src ??= "";
125 b.search ??= "";125 b.search ??= "";
@@ -135,7 +135,7 @@ const clampZ = (v: number, s: number) => Math.round(Math.max(-room(s), Math.min(
135 function setZoom(b: Beat, patch: Partial<NonNullable<Beat["zoom"]>>) {135 function setZoom(b: Beat, patch: Partial<NonNullable<Beat["zoom"]>>) {
136 SB.edit(() => {136 SB.edit(() => {
137 const z = { ...(b.zoom ?? { scale: 1.25, x: 0, y: 0 }), ...patch };137 const z = { ...(b.zoom ?? { scale: 1.25, x: 0, y: 0 }), ...patch };
138- b.zoom = { scale: z.scale, x: clampZ(z.x, z.scale), y: clampZ(z.y, z.scale) };138+ b.zoom = { scale: z.scale, x: clampZ(z.x, z.scale), y: clampZ(z.y, z.scale), style: z.style ?? "smooth" };
139 }, 150);139 }, 150);
140 }140 }
141 /** le cadre gardé, dans l'image entière (comme l'aperçu : agrandi autour du centre, puis décalé) */141 /** le cadre gardé, dans l'image entière (comme l'aperçu : agrandi autour du centre, puis décalé) */
@@ -390,6 +390,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
390 390
391 <!-- comment : selon le type -->391 <!-- comment : selon le type -->
392 <div v-if="b.type === 'zoom'" class="how">392 <div v-if="b.type === 'zoom'" class="how">
393+ <label :title="tr(`editing.inspector.zoomStyleHint.${b.zoom?.style ?? 'smooth'}`)">
394+ <span class="label">{{ tr("editing.storyboard.zoom.style") }}</span>
395+ <select :value="b.zoom?.style ?? 'smooth'" @change="setZoom(b, { style: ($event.target as HTMLSelectElement).value as ZoomStyle })">
396+ <option v-for="s in ZOOM_STYLES" :key="s" :value="s">{{ tr(`editing.inspector.zoomStyles.${s}`) }}</option>
397+ </select>
398+ </label>
393 <label>399 <label>
394 <span class="label">{{ tr("editing.storyboard.zoom.scale") }}</span>400 <span class="label">{{ tr("editing.storyboard.zoom.scale") }}</span>
395 <select :value="b.zoom?.scale" @change="setZoom(b, { scale: +($event.target as HTMLSelectElement).value })">401 <select :value="b.zoom?.scale" @change="setZoom(b, { scale: +($event.target as HTMLSelectElement).value })">
modified web/src/project/steps/montage/BeatPanel.vue +1 -1
@@ -28,7 +28,7 @@ const placeable = computed(() => props.beat.type === "zoom" || (props.beat.type
2828 function setType(type: BeatType) {
2929 emit("change", (b) => {
3030 b.type = type;
31- if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0 };
31+ if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0, style: "smooth" };
3232 if (type === "broll") {
3333 b.src ??= "";
3434 b.search ??= "";
@@ -28,7 +28,7 @@ const placeable = computed(() => props.beat.type === "zoom" || (props.beat.type
28 function setType(type: BeatType) {28 function setType(type: BeatType) {
29 emit("change", (b) => {29 emit("change", (b) => {
30 b.type = type;30 b.type = type;
31- if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0 };31+ if (type === "zoom") b.zoom ??= { scale: 1.25, x: 0, y: 0, style: "smooth" };
32 if (type === "broll") {32 if (type === "broll") {
33 b.src ??= "";33 b.src ??= "";
34 b.search ??= "";34 b.search ??= "";
modified web/src/project/steps/montage/Inspector.vue +15 -3
@@ -15,8 +15,9 @@ import { toast } from "../../../lib/toast";
1515 import MediaPicker from "./MediaPicker.vue";
1616 import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops";
1717 import {
18- fxSpan, newId, prevJoined, spanOf, tcd, trackLabel,
18+ fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, ZOOM_STYLES,
1919 type AvailableMedia, type ArollClip, type Broll, type MediaTab, type Marker, type MontageView, type Motion, type Selection, type Sound, type TextNote, type Timeline, type Zoom,
20+ type ZoomStyle,
2021 } from "./types";
2122
2223 const props = defineProps<{
@@ -95,11 +96,18 @@ const picker = ref<MediaTab | null>(null);
9596 const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")],
9697 ["sfx", tr("editing.inspector.addSfx")], ["musique", tr("editing.inspector.addMusic")]] as const);
9798
98-function addZoom(scale: number) {
99+function addZoom(scale: number, style: ZoomStyle = "smooth", dur = 3) {
99100 let id = "";
100- emit("edit", (tl) => { id = newId(tl, "z"); tl.zooms.push({ id, at: round(at.value), dur: 3, scale, x: 0, y: 0 }); });
101+ emit("edit", (tl) => {
102+ id = newId(tl, "z");
103+ tl.zooms.push({ id, at: round(at.value), dur, scale, x: 0, y: 0, ...(style === "smooth" ? {} : { style }) });
104+ });
101105 if (id) sel.value = { item: { track: "zooms", id } };
102106 }
107+const zoomStyles = computed(() => ZOOM_STYLES.map((s) => [s, tr(`editing.inspector.zoomStyles.${s}`)] as const));
108+function setZoomStyle(style: ZoomStyle) {
109+ set<Zoom>((x) => { if (style === "smooth") delete x.style; else x.style = style; });
110+}
103111 function addText() {
104112 let id = "";
105113 emit("edit", (tl) => { id = addTextTo(tl, at.value); });
@@ -190,6 +198,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
190198 </template>
191199
192200 <template v-else-if="item.track === 'zooms'">
201+ <Seg size="sm" :options="zoomStyles" :model-value="(item.data as Zoom).style ?? 'smooth'" @update:model-value="setZoomStyle" />
202+ <p class="hint">{{ tr(`editing.inspector.zoomStyleHint.${(item.data as Zoom).style ?? "smooth"}`) }}</p>
193203 <label class="f">{{ tr("editing.inspector.scale") }} <b class="mono">×{{ (item.data as Zoom).scale.toFixed(2) }}</b>
194204 <input type="range" min="1.05" max="2" step="0.01" :value="(item.data as Zoom).scale" @input="set<Zoom>((x) => { x.scale = num($event); })">
195205 </label>
@@ -301,6 +311,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
301311 <div class="row">
302312 <button class="btn sm" @click="addZoom(1.15)">{{ tr("editing.inspector.zoomLight") }}</button>
303313 <button class="btn sm" @click="addZoom(1.35)">{{ tr("editing.inspector.zoomStrong") }}</button>
314+ <button class="btn sm" :title="tr('editing.inspector.zoomStyleHint.cut')" @click="addZoom(1.3, 'cut', 2)">{{ tr("editing.inspector.zoomCut") }}</button>
315+ <button class="btn sm" :title="tr('editing.inspector.zoomStyleHint.slow')" @click="addZoom(1.1, 'slow', 8)">{{ tr("editing.inspector.zoomSlow") }}</button>
304316 <button class="btn sm" :title="tr('editing.inspector.addTextTitle')" @click="addText">{{ tr("editing.inspector.addText") }}</button>
305317 <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button>
306318 <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button>
@@ -15,8 +15,9 @@ 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, remove } from "./ops";
17 import {17 import {
18- fxSpan, newId, prevJoined, spanOf, tcd, trackLabel,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,
20+ type ZoomStyle,
20 } from "./types";21 } from "./types";
21 22
22 const props = defineProps<{23 const props = defineProps<{
@@ -95,11 +96,18 @@ const picker = ref<MediaTab | null>(null);
95 const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")],96 const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")],
96 ["sfx", tr("editing.inspector.addSfx")], ["musique", tr("editing.inspector.addMusic")]] as const);97 ["sfx", tr("editing.inspector.addSfx")], ["musique", tr("editing.inspector.addMusic")]] as const);
97 98
98-function addZoom(scale: number) {99+function addZoom(scale: number, style: ZoomStyle = "smooth", dur = 3) {
99 let id = "";100 let id = "";
100- emit("edit", (tl) => { id = newId(tl, "z"); tl.zooms.push({ id, at: round(at.value), dur: 3, scale, x: 0, y: 0 }); });101+ emit("edit", (tl) => {
102+ id = newId(tl, "z");
103+ tl.zooms.push({ id, at: round(at.value), dur, scale, x: 0, y: 0, ...(style === "smooth" ? {} : { style }) });
104+ });
101 if (id) sel.value = { item: { track: "zooms", id } };105 if (id) sel.value = { item: { track: "zooms", id } };
102 }106 }
107+const zoomStyles = computed(() => ZOOM_STYLES.map((s) => [s, tr(`editing.inspector.zoomStyles.${s}`)] as const));
108+function setZoomStyle(style: ZoomStyle) {
109+ set<Zoom>((x) => { if (style === "smooth") delete x.style; else x.style = style; });
110+}
103 function addText() {111 function addText() {
104 let id = "";112 let id = "";
105 emit("edit", (tl) => { id = addTextTo(tl, at.value); });113 emit("edit", (tl) => { id = addTextTo(tl, at.value); });
@@ -190,6 +198,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
190 </template>198 </template>
191 199
192 <template v-else-if="item.track === 'zooms'">200 <template v-else-if="item.track === 'zooms'">
201+ <Seg size="sm" :options="zoomStyles" :model-value="(item.data as Zoom).style ?? 'smooth'" @update:model-value="setZoomStyle" />
202+ <p class="hint">{{ tr(`editing.inspector.zoomStyleHint.${(item.data as Zoom).style ?? "smooth"}`) }}</p>
193 <label class="f">{{ tr("editing.inspector.scale") }} <b class="mono">×{{ (item.data as Zoom).scale.toFixed(2) }}</b>203 <label class="f">{{ tr("editing.inspector.scale") }} <b class="mono">×{{ (item.data as Zoom).scale.toFixed(2) }}</b>
194 <input type="range" min="1.05" max="2" step="0.01" :value="(item.data as Zoom).scale" @input="set<Zoom>((x) => { x.scale = num($event); })">204 <input type="range" min="1.05" max="2" step="0.01" :value="(item.data as Zoom).scale" @input="set<Zoom>((x) => { x.scale = num($event); })">
195 </label>205 </label>
@@ -301,6 +311,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu
301 <div class="row">311 <div class="row">
302 <button class="btn sm" @click="addZoom(1.15)">{{ tr("editing.inspector.zoomLight") }}</button>312 <button class="btn sm" @click="addZoom(1.15)">{{ tr("editing.inspector.zoomLight") }}</button>
303 <button class="btn sm" @click="addZoom(1.35)">{{ tr("editing.inspector.zoomStrong") }}</button>313 <button class="btn sm" @click="addZoom(1.35)">{{ tr("editing.inspector.zoomStrong") }}</button>
314+ <button class="btn sm" :title="tr('editing.inspector.zoomStyleHint.cut')" @click="addZoom(1.3, 'cut', 2)">{{ tr("editing.inspector.zoomCut") }}</button>
315+ <button class="btn sm" :title="tr('editing.inspector.zoomStyleHint.slow')" @click="addZoom(1.1, 'slow', 8)">{{ tr("editing.inspector.zoomSlow") }}</button>
304 <button class="btn sm" :title="tr('editing.inspector.addTextTitle')" @click="addText">{{ tr("editing.inspector.addText") }}</button>316 <button class="btn sm" :title="tr('editing.inspector.addTextTitle')" @click="addText">{{ tr("editing.inspector.addText") }}</button>
305 <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button>317 <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button>
306 <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button>318 <button class="btn sm" @click="addChapter">{{ tr("editing.inspector.addChapter") }}</button>
modified web/src/project/steps/montage/Preview.vue +3 -6
@@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions";
1818 import LayerVideo from "./LayerVideo.vue";
1919 import { releaseMedia } from "../../../lib/media";
2020 import {
21- type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,
21+ type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, zoomAmount,
2222 } from "./types";
2323
2424 const props = defineProps<{
@@ -82,8 +82,7 @@ watch([arollGain, playing], () => {
8282 }, { immediate: true });
8383 onUnmounted(() => { audio?.ctx.close().catch(() => {}); });
8484
85-// --- zoom de l'A-roll (montée et descente progressives, comme à l'export)
86-const RAMP = 0.4;
85+// --- zoom de l'A-roll (sa courbe selon son style, comme à l'export)
8786 // les beats du storyboard qui sont faits sans animation : vus tels qu'ils seront posés
8887 const beatAt = (type: Beat["type"]) => props.beats?.find((b) => b.type === type && t.value >= b.at && t.value < b.at + b.dur
8988 && (type !== "broll" || (!!b.src && !isImage(b.src))));
@@ -96,9 +95,7 @@ const zoom = computed(() => {
9695 const z = props.tl.zooms.find((k) => t.value >= k.at && t.value < k.at + k.dur)
9796 ?? (zb?.zoom ? { at: zb.at, dur: zb.dur, ...zb.zoom } : null);
9897 if (!z) return { s: 1, x: 0, y: 0 };
99- const ramp = Math.min(RAMP, z.dur / 3);
100- const k = Math.min(1, (t.value - z.at) / ramp, (z.at + z.dur - t.value) / ramp);
101- const e = k * k * (3 - 2 * k);
98+ const e = zoomAmount(z, t.value);
10299 return { s: 1 + (z.scale - 1) * e, x: z.x * e, y: z.y * e };
103100 });
104101 const videoStyle = computed(() => ({
@@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions";
18 import LayerVideo from "./LayerVideo.vue";18 import LayerVideo from "./LayerVideo.vue";
19 import { releaseMedia } from "../../../lib/media";19 import { releaseMedia } from "../../../lib/media";
20 import {20 import {
21- type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual,21+ type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, zoomAmount,
22 } from "./types";22 } from "./types";
23 23
24 const props = defineProps<{24 const props = defineProps<{
@@ -82,8 +82,7 @@ watch([arollGain, playing], () => {
82 }, { immediate: true });82 }, { immediate: true });
83 onUnmounted(() => { audio?.ctx.close().catch(() => {}); });83 onUnmounted(() => { audio?.ctx.close().catch(() => {}); });
84 84
85-// --- zoom de l'A-roll (montée et descente progressives, comme à l'export)85+// --- zoom de l'A-roll (sa courbe selon son style, comme à l'export)
86-const RAMP = 0.4;
87 // les beats du storyboard qui sont faits sans animation : vus tels qu'ils seront posés86 // les beats du storyboard qui sont faits sans animation : vus tels qu'ils seront posés
88 const beatAt = (type: Beat["type"]) => props.beats?.find((b) => b.type === type && t.value >= b.at && t.value < b.at + b.dur87 const beatAt = (type: Beat["type"]) => props.beats?.find((b) => b.type === type && t.value >= b.at && t.value < b.at + b.dur
89 && (type !== "broll" || (!!b.src && !isImage(b.src))));88 && (type !== "broll" || (!!b.src && !isImage(b.src))));
@@ -96,9 +95,7 @@ const zoom = computed(() => {
96 const z = props.tl.zooms.find((k) => t.value >= k.at && t.value < k.at + k.dur)95 const z = props.tl.zooms.find((k) => t.value >= k.at && t.value < k.at + k.dur)
97 ?? (zb?.zoom ? { at: zb.at, dur: zb.dur, ...zb.zoom } : null);96 ?? (zb?.zoom ? { at: zb.at, dur: zb.dur, ...zb.zoom } : null);
98 if (!z) return { s: 1, x: 0, y: 0 };97 if (!z) return { s: 1, x: 0, y: 0 };
99- const ramp = Math.min(RAMP, z.dur / 3);98+ const e = zoomAmount(z, t.value);
100- const k = Math.min(1, (t.value - z.at) / ramp, (z.at + z.dur - t.value) / ramp);
101- const e = k * k * (3 - 2 * k);
102 return { s: 1 + (z.scale - 1) * e, x: z.x * e, y: z.y * e };99 return { s: 1 + (z.scale - 1) * e, x: z.x * e, y: z.y * e };
103 });100 });
104 const videoStyle = computed(() => ({101 const videoStyle = computed(() => ({
modified web/src/project/steps/montage/Timeline.vue +8 -4
@@ -17,7 +17,7 @@ import { fxName } from "../../../lib/transitions";
1717 import { MEDIA_DRAG, trimAroll } from "./ops";
1818 import {
1919 clipStarts, clone, fxMax, type AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,
20- type Track,
20+ type Track, type ZoomStyle,
2121 } from "./types";
2222
2323 const props = defineProps<{
@@ -171,6 +171,7 @@ const arollV = computed(() => props.tl.aroll.map((c, i) => ({ c, a: starts.value
171171 const itemsV = <T extends { id: string; at: number }>(list: T[], end: (x: T) => number) =>
172172 list.map((it) => ({ it, a: it.at, b: it.at + end(it) })).filter((v) => visible(v.a, v.b));
173173 const zoomsV = computed(() => itemsV(props.tl.zooms, (z) => z.dur));
174+const ZOOM_GLYPH: Record<ZoomStyle, string> = { smooth: "", cut: "▮ ", slow: "◢ " };
174175 const brollV = computed(() => itemsV(props.tl.broll, (b) => b.out - b.in));
175176 const motionV = computed(() => itemsV(props.tl.motion, (m) => m.dur));
176177 const textsV = computed(() => itemsV(props.tl.texts, (x) => x.dur));
@@ -624,11 +625,11 @@ function motionState(id: string) {
624625 </div>
625626
626627 <!-- zooms -->
627- <div v-for="v in zoomsV" :key="v.it.id" class="item zoom" :class="{ on: isSel('zooms', v.it.id) }"
628+ <div v-for="v in zoomsV" :key="v.it.id" class="item zoom" :class="[v.it.style ?? 'smooth', { on: isSel('zooms', v.it.id) }]"
628629 :style="{ top: `${tops.zooms + 2}px`, height: `${rowH.zooms - 4}px`, left: `${x(v.a)}px`, width: `${Math.max(3, x(v.b - v.a))}px` }"
629- :title="`Zoom ×${v.it.scale}`" @pointerdown="down($event, 'zooms', v.it.id)" @dblclick="goTo(v.a)">
630+ :title="`Zoom ${tr(`editing.inspector.zoomStyles.${v.it.style ?? 'smooth'}`)} ×${v.it.scale}`" @pointerdown="down($event, 'zooms', v.it.id)" @dblclick="goTo(v.a)">
630631 <i class="edge l" @pointerdown="down($event, 'zooms', v.it.id, 'left')" />
631- <span>×{{ v.it.scale.toFixed(2) }}</span>
632+ <span>{{ ZOOM_GLYPH[v.it.style ?? "smooth"] }}×{{ v.it.scale.toFixed(2) }}</span>
632633 <i class="edge r" @pointerdown="down($event, 'zooms', v.it.id, 'right')" />
633634 </div>
634635
@@ -741,6 +742,9 @@ function motionState(id: string) {
741742 .item.motion { background: #7C5CE0; color: #fff; font-weight: 600; }
742743 .item.motion.pending { background: repeating-linear-gradient(135deg, #9C84EA 0 6px, #8A70E5 6px 12px); }
743744 .item.zoom { background: #E8B02F; color: var(--ink); font-weight: 600; }
745+/* la forme du zoom : net (plus sombre), lent (une rampe qui monte jusqu'au bout) */
746+.item.zoom.cut { background: #D9971E; }
747+.item.zoom.slow { background: linear-gradient(90deg, #F6DFA6, #E8B02F); }
744748 .item.broll { background: #3B82C4; color: #fff; }
745749 .item.music { background: #2F9E83; color: #fff; }
746750 .item.sfx { background: #D9663B; color: #fff; }
@@ -17,7 +17,7 @@ import { fxName } from "../../../lib/transitions";
17 import { MEDIA_DRAG, trimAroll } from "./ops";17 import { MEDIA_DRAG, 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 AvailableMedia, fxSpan, prevJoined, spanOf, type Broll, type FxEnd, type Motion, type MontageView, type Selection, type Timeline,
20- type Track,20+ type Track, type ZoomStyle,
21 } from "./types";21 } from "./types";
22 22
23 const props = defineProps<{23 const props = defineProps<{
@@ -171,6 +171,7 @@ const arollV = computed(() => props.tl.aroll.map((c, i) => ({ c, a: starts.value
171 const itemsV = <T extends { id: string; at: number }>(list: T[], end: (x: T) => number) =>171 const itemsV = <T extends { id: string; at: number }>(list: T[], end: (x: T) => number) =>
172 list.map((it) => ({ it, a: it.at, b: it.at + end(it) })).filter((v) => visible(v.a, v.b));172 list.map((it) => ({ it, a: it.at, b: it.at + end(it) })).filter((v) => visible(v.a, v.b));
173 const zoomsV = computed(() => itemsV(props.tl.zooms, (z) => z.dur));173 const zoomsV = computed(() => itemsV(props.tl.zooms, (z) => z.dur));
174+const ZOOM_GLYPH: Record<ZoomStyle, string> = { smooth: "", cut: "▮ ", slow: "◢ " };
174 const brollV = computed(() => itemsV(props.tl.broll, (b) => b.out - b.in));175 const brollV = computed(() => itemsV(props.tl.broll, (b) => b.out - b.in));
175 const motionV = computed(() => itemsV(props.tl.motion, (m) => m.dur));176 const motionV = computed(() => itemsV(props.tl.motion, (m) => m.dur));
176 const textsV = computed(() => itemsV(props.tl.texts, (x) => x.dur));177 const textsV = computed(() => itemsV(props.tl.texts, (x) => x.dur));
@@ -624,11 +625,11 @@ function motionState(id: string) {
624 </div>625 </div>
625 626
626 <!-- zooms -->627 <!-- zooms -->
627- <div v-for="v in zoomsV" :key="v.it.id" class="item zoom" :class="{ on: isSel('zooms', v.it.id) }"628+ <div v-for="v in zoomsV" :key="v.it.id" class="item zoom" :class="[v.it.style ?? 'smooth', { on: isSel('zooms', v.it.id) }]"
628 :style="{ top: `${tops.zooms + 2}px`, height: `${rowH.zooms - 4}px`, left: `${x(v.a)}px`, width: `${Math.max(3, x(v.b - v.a))}px` }"629 :style="{ top: `${tops.zooms + 2}px`, height: `${rowH.zooms - 4}px`, left: `${x(v.a)}px`, width: `${Math.max(3, x(v.b - v.a))}px` }"
629- :title="`Zoom ×${v.it.scale}`" @pointerdown="down($event, 'zooms', v.it.id)" @dblclick="goTo(v.a)">630+ :title="`Zoom ${tr(`editing.inspector.zoomStyles.${v.it.style ?? 'smooth'}`)} ×${v.it.scale}`" @pointerdown="down($event, 'zooms', v.it.id)" @dblclick="goTo(v.a)">
630 <i class="edge l" @pointerdown="down($event, 'zooms', v.it.id, 'left')" />631 <i class="edge l" @pointerdown="down($event, 'zooms', v.it.id, 'left')" />
631- <span>×{{ v.it.scale.toFixed(2) }}</span>632+ <span>{{ ZOOM_GLYPH[v.it.style ?? "smooth"] }}×{{ v.it.scale.toFixed(2) }}</span>
632 <i class="edge r" @pointerdown="down($event, 'zooms', v.it.id, 'right')" />633 <i class="edge r" @pointerdown="down($event, 'zooms', v.it.id, 'right')" />
633 </div>634 </div>
634 635
@@ -741,6 +742,9 @@ function motionState(id: string) {
741 .item.motion { background: #7C5CE0; color: #fff; font-weight: 600; }742 .item.motion { background: #7C5CE0; color: #fff; font-weight: 600; }
742 .item.motion.pending { background: repeating-linear-gradient(135deg, #9C84EA 0 6px, #8A70E5 6px 12px); }743 .item.motion.pending { background: repeating-linear-gradient(135deg, #9C84EA 0 6px, #8A70E5 6px 12px); }
743 .item.zoom { background: #E8B02F; color: var(--ink); font-weight: 600; }744 .item.zoom { background: #E8B02F; color: var(--ink); font-weight: 600; }
745+/* la forme du zoom : net (plus sombre), lent (une rampe qui monte jusqu'au bout) */
746+.item.zoom.cut { background: #D9971E; }
747+.item.zoom.slow { background: linear-gradient(90deg, #F6DFA6, #E8B02F); }
744 .item.broll { background: #3B82C4; color: #fff; }748 .item.broll { background: #3B82C4; color: #fff; }
745 .item.music { background: #2F9E83; color: #fff; }749 .item.music { background: #2F9E83; color: #fff; }
746 .item.sfx { background: #D9663B; color: #fff; }750 .item.sfx { background: #D9663B; color: #fff; }
modified web/src/project/steps/montage/types.ts +20 -2
@@ -8,7 +8,25 @@ import type { Fx } from "../../../lib/transitions";
88 * (absent : 0 dB)
99 */
1010 export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number }
11-export interface Zoom { id: string; at: number; dur: number; scale: number; x: number; y: number }
11+/**
12+ * 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.
14+ */
15+export type ZoomStyle = "smooth" | "cut" | "slow";
16+export const ZOOM_STYLES: ZoomStyle[] = ["smooth", "cut", "slow"];
17+export interface Zoom { id: string; at: number; dur: number; scale: number; x: number; y: number; style?: ZoomStyle }
18+const ZOOM_RAMP = 0.4;
19+/** Où en est un zoom à l'instant `t`, de 0 à 1 (backend montage.zoom_amount, et le rendu). */
20+export function zoomAmount(z: { at: number; dur: number; style?: ZoomStyle }, t: number): number {
21+ const a = z.at, b = z.at + z.dur;
22+ if (t < a || t >= b) return 0;
23+ const style = z.style ?? "smooth";
24+ const ramp = Math.min(ZOOM_RAMP, z.dur / 3);
25+ const [rise, fall] = style === "cut" ? [0, 0] : style === "slow" ? [z.dur, 0] : [ramp, ramp];
26+ const k = Math.min(1, rise ? (t - a) / rise : 1, fall ? (b - t) / fall : 1);
27+ // smooth : adouci (smoothstep) ; slow : vitesse constante, comme une poussée de caméra
28+ return style === "slow" ? k : k * k * (3 - 2 * k);
29+}
1230 /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */
1331 export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null }
1432 export interface Motion { id: string; at: number; dur: number; title: string; kind: string; idea: string; enter?: Fx | null; exit?: Fx | null }
@@ -87,7 +105,7 @@ export interface Beat {
87105 /** script : un plan d'animation du script (plan = son id) ; daryl : ajouté par Daryl ; user : posé à la main */
88106 origin?: "script" | "daryl" | "user"; plan?: string | null;
89107 /** zoom : grossissement et décalage du centre (fraction de l'image) */
90- zoom?: { scale: number; x: number; y: number };
108+ zoom?: { scale: number; x: number; y: number; style?: ZoomStyle };
91109 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */
92110 src?: string; search?: string; start?: number;
93111 /** motion : les images et vidéos à intégrer à l'animation (facultatives) */
@@ -8,7 +8,25 @@ import type { Fx } from "../../../lib/transitions";
8 * (absent : 0 dB)8 * (absent : 0 dB)
9 */9 */
10 export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number }10 export interface ArollClip { id: string; src?: string; in: number; out: number; transition: Fx | null; gain_db?: number }
11-export interface Zoom { id: string; at: number; dur: number; scale: number; x: number; y: number }11+/**
12+ * 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.
14+ */
15+export type ZoomStyle = "smooth" | "cut" | "slow";
16+export const ZOOM_STYLES: ZoomStyle[] = ["smooth", "cut", "slow"];
17+export interface Zoom { id: string; at: number; dur: number; scale: number; x: number; y: number; style?: ZoomStyle }
18+const ZOOM_RAMP = 0.4;
19+/** Où en est un zoom à l'instant `t`, de 0 à 1 (backend montage.zoom_amount, et le rendu). */
20+export function zoomAmount(z: { at: number; dur: number; style?: ZoomStyle }, t: number): number {
21+ const a = z.at, b = z.at + z.dur;
22+ if (t < a || t >= b) return 0;
23+ const style = z.style ?? "smooth";
24+ const ramp = Math.min(ZOOM_RAMP, z.dur / 3);
25+ const [rise, fall] = style === "cut" ? [0, 0] : style === "slow" ? [z.dur, 0] : [ramp, ramp];
26+ const k = Math.min(1, rise ? (t - a) / rise : 1, fall ? (b - t) / fall : 1);
27+ // smooth : adouci (smoothstep) ; slow : vitesse constante, comme une poussée de caméra
28+ return style === "slow" ? k : k * k * (3 - 2 * k);
29+}
12 /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */30 /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */
13 export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null }31 export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null }
14 export interface Motion { id: string; at: number; dur: number; title: string; kind: string; idea: string; enter?: Fx | null; exit?: Fx | null }32 export interface Motion { id: string; at: number; dur: number; title: string; kind: string; idea: string; enter?: Fx | null; exit?: Fx | null }
@@ -87,7 +105,7 @@ export interface Beat {
87 /** script : un plan d'animation du script (plan = son id) ; daryl : ajouté par Daryl ; user : posé à la main */105 /** script : un plan d'animation du script (plan = son id) ; daryl : ajouté par Daryl ; user : posé à la main */
88 origin?: "script" | "daryl" | "user"; plan?: string | null;106 origin?: "script" | "daryl" | "user"; plan?: string | null;
89 /** zoom : grossissement et décalage du centre (fraction de l'image) */107 /** zoom : grossissement et décalage du centre (fraction de l'image) */
90- zoom?: { scale: number; x: number; y: number };108+ zoom?: { scale: number; x: number; y: number; style?: ZoomStyle };
91 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */109 /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */
92 src?: string; search?: string; start?: number;110 src?: string; search?: string; start?: number;
93 /** motion : les images et vidéos à intégrer à l'animation (facultatives) */111 /** motion : les images et vidéos à intégrer à l'animation (facultatives) */