feat(montage): add punch-in and slow push zoom styles
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 | ||
| 21 | 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 | - "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 | 26 | "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets) |
| 26 | 27 | "motion": [{"id", "at", "dur", "title", "kind", "idea", "enter"?, "exit"?, "beat"?}], # motion design, au-dessus du B-roll |
| 27 | 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 | 96 | # texte posé à la main : bornes de la taille (fraction de la hauteur de l'image) et de l'inclinaison (degrés) |
| 96 | 97 | TEXT_SIZE = (0.03, 0.25) |
| 97 | 98 | 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) | |
| 99 | 101 | 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 | 125 | # forme d'onde : une valeur par 1/PEAKS_RATE s |
| 101 | 126 | PEAKS_RATE = 20 |
| 102 | 127 | # 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 | 290 | out["zooms"].append({"id": ident("zooms", z.get("id")), "at": at, "dur": min(dur, total - at), |
| 266 | 291 | "scale": max(1.0, min(_num(z.get("scale"), 1.2), 3.0)), |
| 267 | 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 | 294 | **({"beat": str(z["beat"])[:20]} if z.get("beat") else {})}) |
| 269 | 295 | |
| 270 | 296 | for track in ("broll", "audio"): |
| @@ -972,29 +998,37 @@ def _size(project: dict) -> tuple[int, int]: | ||
| 972 | 998 | |
| 973 | 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 | 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 | 1007 | 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: | |
| 982 | 1010 | return {"scale": round(1 + (z["scale"] - 1) * k, 4), "x": round(z["x"] * k, 4), "y": round(z["y"] * k, 4)} |
| 983 | 1011 | return {"scale": 1.0, "x": 0.0, "y": 0.0} |
| 984 | 1012 | |
| 985 | 1013 | |
| 986 | 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.""" | |
| 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] = {} | |
| 989 | 1017 | for z in zooms: |
| 990 | 1018 | a, b = z["at"], z["at"] + z["dur"] |
| 991 | 1019 | if b <= clip_start or a >= clip_start + clip_len: |
| 992 | 1020 | 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) | |
| 996 | 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 | 1032 | if keys: |
| 999 | 1033 | # un zoom déjà commencé (ou pas fini) à la coupe : le clip démarre (finit) au bon cadrage |
| 1000 | 1034 | 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édent | 23 | "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-roll | 27 | "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 = 20 | 98 | 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) | ||
| 99 | ZOOM_RAMP = 0.4 | 101 | 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 s | 125 | # forme d'onde : une valeur par 1/PEAKS_RATE s |
| 101 | PEAKS_RATE = 20 | 126 | PEAKS_RATE = 20 |
| 102 | # vignettes de la vidéo source : au plus FILMSTRIP_MAX images, une toutes les `step` s | 127 | # 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 | continue | 1020 | 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_start | 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) | ||
| 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 cadrage | 1033 | # 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. | ||
| 103 | 103 | Temps de la timeline en secondes (`at`, `dur`, `t`) ; `in`/`out` : temps dans le média. |
| 104 | 104 | {{ |
| 105 | 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 | 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 | 108 | "motion": [{{"id": "m1", "at": 60.4, "dur": 7.0, "title": "nom court", "kind": "schéma", "idea": "ce qu'il montre"}}], |
| 109 | 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 | 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 | -- 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). | |
| 128 | 130 | - broll : plan de coupe muet qui couvre l'A-roll (la voix continue). `src` : EXACTEMENT un \ |
| 129 | 131 | chemin listé dans contexte.md ; in/out dans la durée du média. 2 à 6 s en général. |
| 130 | 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 | 410 | from .storyboard_chat import grab_frame |
| 409 | 411 | |
| 410 | 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 | 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 | 415 | on += [f"motion {m['id']} « {m['title']} » (montage/motion/{m['id']}.html, à {t - m['at']:.2f} s de son début)" |
| 414 | 416 | 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_frame | 410 | 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: | ||
| 298 | 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 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. | |
| 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 | 310 | k_terms = [] |
| 309 | 311 | for z in zooms: |
| 310 | 312 | 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})" | |
| 313 | 324 | k_terms.append((z, k)) |
| 314 | 325 | s = "+".join(f"{_num(z['scale'] - 1)}*{k}" for z, k in k_terms) or "0" |
| 315 | 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 | 382 | fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos," |
| 372 | 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 | 385 | sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}" |
| 375 | 386 | |
| 376 | 387 | 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 agrandie | 303 | + 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 et | 304 | + 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-3 | 313 | + 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 | ||
| 4 | 4 | |
| 5 | 5 | Quatre types de beats : |
| 6 | 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 | 8 | - broll : un plan de coupe. Une vidéo va sur la piste B-roll ; une image est animée comme un motion |
| 9 | 9 | (plein écran ou sur un côté) ; sans média, c'est une suggestion « à trouver » (`search`) ; |
| 10 | 10 | - transition : un enchaînement animé plein écran, court, au changement de partie. |
| @@ -25,7 +25,7 @@ Fichier : montage/storyboard.json | ||
| 25 | 25 | {"density", "note", "message", "created", |
| 26 | 26 | "beats": [{"id", "type", "at", "dur", "anchor", "until", "says", "show", "kind", "place", |
| 27 | 27 | "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) | |
| 29 | 29 | "src", "search", # broll : le média (vide : à trouver), la recherche |
| 30 | 30 | "images"}]} # motion : les images et vidéos à y intégrer (facultatives) |
| 31 | 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 | 70 | TYPES = ["motion", "zoom", "broll", "transition"] |
| 71 | 71 | # les types qui occupent l'écran (un seul à la fois) ; un zoom recadre l'A-roll en dessous |
| 72 | 72 | 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 | |
| 74 | 76 | ZOOM_SCALE = (1.05, 1.5) |
| 75 | 77 | ZOOM_DEFAULT = 1.25 |
| 78 | +ZOOM_SLOW_DEFAULT = 1.1 | |
| 76 | 79 | ZOOM_DUR = (1.5, 8.0) |
| 80 | +ZOOM_SLOW_DUR = (4.0, 15.0) | |
| 77 | 81 | ZOOM_LEAD = 0.1 |
| 78 | 82 | # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie |
| 79 | 83 | TRANSITION_DUR = 1.2 |
| @@ -147,10 +151,14 @@ SCHEMA = { | ||
| 147 | 151 | "description": "Motion et broll : la transition de sortie, ou vide."}, |
| 148 | 152 | "zoom": { |
| 149 | 153 | "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"], | |
| 152 | 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 | 162 | "x": {"type": "number", "description": "Décalage horizontal du centre, en fraction de " |
| 155 | 163 | "l'image (−0.3 à 0.3, positif : vers la droite)."}, |
| 156 | 164 | "y": {"type": "number", "description": "Décalage vertical (positif : vers le bas)."}, |
| @@ -276,10 +284,11 @@ def zoom_of(raw) -> dict: | ||
| 276 | 284 | except (TypeError, ValueError): |
| 277 | 285 | 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 | 289 | room = (scale - 1) / 2 |
| 281 | 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 | 294 | SYSTEM = """\ |
| @@ -291,11 +300,15 @@ storyboard, le montage se fait ensuite. Tout est dans le message ; tu ne lis que | ||
| 291 | 300 | Les types de beats (`type`) — ne propose que ceux que le message demande : |
| 292 | 301 | - motion : un visuel animé posé au-dessus de la vidéo (détails plus bas). C'est le seul type que \ |
| 293 | 302 | 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. | |
| 299 | 312 | - broll : un plan de coupe qui montre ce dont il parle (un lieu, un objet, une personne, une action, \ |
| 300 | 313 | un site) à la place de son visage, 3 à 8 secondes. `media` = le src exact d'un média visuel de la \ |
| 301 | 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 | 597 | *(f"- {_clock(a)} → {_clock(b)} {what}" for a, b, what in sorted(taken)), ""] |
| 585 | 598 | if "zoom" in types and timeline["zooms"]: |
| 586 | 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 | 601 | if "broll" in types: |
| 589 | 602 | if visual: |
| 590 | 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 | 812 | else: |
| 800 | 813 | at = max(0.0, start - (ZOOM_LEAD if kind == "zoom" else LEAD)) |
| 801 | 814 | 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 | 817 | if plan: |
| 804 | 818 | # un plan du script garde la durée de son passage |
| 805 | 819 | 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 motion | 8 | - 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"}, # zoom | 28 | + "zoom": {"scale", "x", "y", "style"}, # zoom (style : montage.ZOOM_STYLES) |
| 29 | "src", "search", # broll : le média (vide : à trouver), la recherche | 29 | "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 dessous | 71 | # 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 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 | ||
| 74 | ZOOM_SCALE = (1.05, 1.5) | 76 | ZOOM_SCALE = (1.05, 1.5) |
| 75 | ZOOM_DEFAULT = 1.25 | 77 | 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.1 | 81 | ZOOM_LEAD = 0.1 |
| 78 | # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie | 82 | # transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie |
| 79 | TRANSITION_DUR = 1.2 | 83 | 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 default | 285 | 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) / 2 | 289 | 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_DUR | 814 | 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 passage | 818 | # un plan du script garde la durée de son passage |
| 805 | hi = MAX_SCRIPT_DUR | 819 | 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 | ||
| 67 | 67 | |
| 68 | 68 | Quatre types de beats (`type`) : |
| 69 | 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 \ | |
| 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 ; | |
| 73 | 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 | 75 | passe en plein écran ; une image est animée en plein écran ou sur un côté, comme un portrait, selon \ |
| 75 | 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 | 146 | kind = b.get("type", "motion") |
| 146 | 147 | if kind == "zoom": |
| 147 | 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 | 150 | elif kind == "broll": |
| 150 | 151 | what = f"broll · {b['place']} · " + (f"média {b['src']}" + (f" dès {b['start']:g} s" if b.get("start") else "") |
| 151 | 152 | 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 { | ||
| 100 | 100 | addAtCursor: "Add at playhead · {tc}", |
| 101 | 101 | zoomLight: "Light zoom", |
| 102 | 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 | 111 | addText: "+ Text", |
| 104 | 112 | addTextTitle: "A handwritten note on screen (fix a word that is hard to hear)", |
| 105 | 113 | textPlaceholder: "What you actually say…", |
| @@ -245,6 +253,7 @@ export default { | ||
| 245 | 253 | transition: "What it shows (the section title)…", |
| 246 | 254 | }, |
| 247 | 255 | zoom: { |
| 256 | + style: "Style", | |
| 248 | 257 | scale: "Magnification", |
| 249 | 258 | scales: { 0: "Light", 1: "Medium", 2: "Tight" }, |
| 250 | 259 | 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 { | ||
| 100 | 100 | addAtCursor: "Ajouter au curseur · {tc}", |
| 101 | 101 | zoomLight: "Zoom léger", |
| 102 | 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 | 111 | addText: "+ Texte", |
| 104 | 112 | addTextTitle: "Un texte écrit à la main, à l’écran (corriger un mot mal audible)", |
| 105 | 113 | textPlaceholder: "Ce que tu dis vraiment…", |
| @@ -245,6 +253,7 @@ export default { | ||
| 245 | 253 | transition: "Ce qu’elle affiche (le titre de la partie)…", |
| 246 | 254 | }, |
| 247 | 255 | zoom: { |
| 256 | + style: "Style", | |
| 248 | 257 | scale: "Grossissement", |
| 249 | 258 | scales: { 0: "Léger", 1: "Moyen", 2: "Serré" }, |
| 250 | 259 | 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"; | ||
| 19 | 19 | import { usePlaylist, type Piece } from "./derush/usePlaylist"; |
| 20 | 20 | import Preview from "./montage/Preview.vue"; |
| 21 | 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 | 23 | import { useMontage } from "./montage/useMontage"; |
| 24 | 24 | import { useStoryboard } from "./montage/useStoryboard"; |
| 25 | 25 | |
| @@ -119,7 +119,7 @@ function setStart(b: Beat, value: number) { | ||
| 119 | 119 | function setType(b: Beat, type: BeatType) { |
| 120 | 120 | SB.edit(() => { |
| 121 | 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 | 123 | if (type === "broll") { |
| 124 | 124 | b.src ??= ""; |
| 125 | 125 | b.search ??= ""; |
| @@ -135,7 +135,7 @@ const clampZ = (v: number, s: number) => Math.round(Math.max(-room(s), Math.min( | ||
| 135 | 135 | function setZoom(b: Beat, patch: Partial<NonNullable<Beat["zoom"]>>) { |
| 136 | 136 | SB.edit(() => { |
| 137 | 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 | 139 | }, 150); |
| 140 | 140 | } |
| 141 | 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 | 391 | <!-- comment : selon le type --> |
| 392 | 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 | 399 | <label> |
| 394 | 400 | <span class="label">{{ tr("editing.storyboard.zoom.scale") }}</span> |
| 395 | 401 | <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 | ||
| 28 | 28 | function setType(type: BeatType) { |
| 29 | 29 | emit("change", (b) => { |
| 30 | 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 | 32 | if (type === "broll") { |
| 33 | 33 | b.src ??= ""; |
| 34 | 34 | 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"; | ||
| 15 | 15 | import MediaPicker from "./MediaPicker.vue"; |
| 16 | 16 | import { addMedia as addMediaTo, addText as addTextTo, remove } from "./ops"; |
| 17 | 17 | import { |
| 18 | - fxSpan, newId, prevJoined, spanOf, tcd, trackLabel, | |
| 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, |
| 20 | + type ZoomStyle, | |
| 20 | 21 | } from "./types"; |
| 21 | 22 | |
| 22 | 23 | const props = defineProps<{ |
| @@ -95,11 +96,18 @@ const picker = ref<MediaTab | null>(null); | ||
| 95 | 96 | const MEDIA_BUTTONS = computed(() => [["rush", tr("editing.inspector.addRush")], ["broll", tr("editing.inspector.addBroll")], |
| 96 | 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 | 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 | 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 | 111 | function addText() { |
| 104 | 112 | let id = ""; |
| 105 | 113 | emit("edit", (tl) => { id = addTextTo(tl, at.value); }); |
| @@ -190,6 +198,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | ||
| 190 | 198 | </template> |
| 191 | 199 | |
| 192 | 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 | 203 | <label class="f">{{ tr("editing.inspector.scale") }} <b class="mono">×{{ (item.data as Zoom).scale.toFixed(2) }}</b> |
| 194 | 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 | 205 | </label> |
| @@ -301,6 +311,8 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | ||
| 301 | 311 | <div class="row"> |
| 302 | 312 | <button class="btn sm" @click="addZoom(1.15)">{{ tr("editing.inspector.zoomLight") }}</button> |
| 303 | 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 | 316 | <button class="btn sm" :title="tr('editing.inspector.addTextTitle')" @click="addText">{{ tr("editing.inspector.addText") }}</button> |
| 305 | 317 | <button class="btn sm" :title="tr('editing.inspector.addBeatTitle')" @click="emit('beat')">{{ tr("editing.inspector.addBeat") }}</button> |
| 306 | 318 | <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"; | ||
| 18 | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | 19 | import { releaseMedia } from "../../../lib/media"; |
| 20 | 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 | 22 | } from "./types"; |
| 23 | 23 | |
| 24 | 24 | const props = defineProps<{ |
| @@ -82,8 +82,7 @@ watch([arollGain, playing], () => { | ||
| 82 | 82 | }, { immediate: true }); |
| 83 | 83 | onUnmounted(() => { audio?.ctx.close().catch(() => {}); }); |
| 84 | 84 | |
| 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) | |
| 87 | 86 | // les beats du storyboard qui sont faits sans animation : vus tels qu'ils seront posés |
| 88 | 87 | const beatAt = (type: Beat["type"]) => props.beats?.find((b) => b.type === type && t.value >= b.at && t.value < b.at + b.dur |
| 89 | 88 | && (type !== "broll" || (!!b.src && !isImage(b.src)))); |
| @@ -96,9 +95,7 @@ const zoom = computed(() => { | ||
| 96 | 95 | const z = props.tl.zooms.find((k) => t.value >= k.at && t.value < k.at + k.dur) |
| 97 | 96 | ?? (zb?.zoom ? { at: zb.at, dur: zb.dur, ...zb.zoom } : null); |
| 98 | 97 | 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); | |
| 102 | 99 | return { s: 1 + (z.scale - 1) * e, x: z.x * e, y: z.y * e }; |
| 103 | 100 | }); |
| 104 | 101 | 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és | 86 | // 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.dur | 87 | 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"; | ||
| 17 | 17 | import { MEDIA_DRAG, trimAroll } from "./ops"; |
| 18 | 18 | import { |
| 19 | 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 | 21 | } from "./types"; |
| 22 | 22 | |
| 23 | 23 | const props = defineProps<{ |
| @@ -171,6 +171,7 @@ const arollV = computed(() => props.tl.aroll.map((c, i) => ({ c, a: starts.value | ||
| 171 | 171 | const itemsV = <T extends { id: string; at: number }>(list: T[], end: (x: T) => number) => |
| 172 | 172 | list.map((it) => ({ it, a: it.at, b: it.at + end(it) })).filter((v) => visible(v.a, v.b)); |
| 173 | 173 | const zoomsV = computed(() => itemsV(props.tl.zooms, (z) => z.dur)); |
| 174 | +const ZOOM_GLYPH: Record<ZoomStyle, string> = { smooth: "", cut: "▮ ", slow: "◢ " }; | |
| 174 | 175 | const brollV = computed(() => itemsV(props.tl.broll, (b) => b.out - b.in)); |
| 175 | 176 | const motionV = computed(() => itemsV(props.tl.motion, (m) => m.dur)); |
| 176 | 177 | const textsV = computed(() => itemsV(props.tl.texts, (x) => x.dur)); |
| @@ -624,11 +625,11 @@ function motionState(id: string) { | ||
| 624 | 625 | </div> |
| 625 | 626 | |
| 626 | 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 | 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 | 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 | 633 | <i class="edge r" @pointerdown="down($event, 'zooms', v.it.id, 'right')" /> |
| 633 | 634 | </div> |
| 634 | 635 | |
| @@ -741,6 +742,9 @@ function motionState(id: string) { | ||
| 741 | 742 | .item.motion { background: #7C5CE0; color: #fff; font-weight: 600; } |
| 742 | 743 | .item.motion.pending { background: repeating-linear-gradient(135deg, #9C84EA 0 6px, #8A70E5 6px 12px); } |
| 743 | 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 | 748 | .item.broll { background: #3B82C4; color: #fff; } |
| 745 | 749 | .item.music { background: #2F9E83; color: #fff; } |
| 746 | 750 | .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"; | ||
| 8 | 8 | * (absent : 0 dB) |
| 9 | 9 | */ |
| 10 | 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 | 30 | /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */ |
| 13 | 31 | export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null } |
| 14 | 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 | 105 | /** script : un plan d'animation du script (plan = son id) ; daryl : ajouté par Daryl ; user : posé à la main */ |
| 88 | 106 | origin?: "script" | "daryl" | "user"; plan?: string | null; |
| 89 | 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 | 109 | /** broll : le média (vide : à trouver), la recherche toute prête, et pour une vidéo la seconde où le plan commence */ |
| 92 | 110 | src?: string; search?: string; start?: number; |
| 93 | 111 | /** 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) */ |