feat(montage): let Daryl sound-design the motions he animates
Daryl now writes a sound cue list in each composition (a JSON sfx block timed on its GSAP timeline), picked from the common and project sound libraries with their descriptions and default gains. At the end of the task the studio places these cues in the timeline, replacing the sounds linked to that motion. Sounds can be linked to a motion: they follow it when it is moved or its start is trimmed, and are removed with it. The storyboard's entry sound is linked too, so Daryl's sound design replaces it.
d975f4e parent: bced829 modified
backend/studio/modules/projects/beat_anim.py +117 -9 | @@ -1,10 +1,17 @@ | ||
| 1 | 1 | """ |
| 2 | 2 | Créer l'animation d'un beat du storyboard : une tâche par beat, à part du chat du monteur. Le motion est |
| 3 | -déjà posé dans la timeline (place, durée, son : `storyboard.compose`) ; Daryl n'écrit que sa composition, | |
| 3 | +déjà posé dans la timeline (place, durée, son d'entrée : `storyboard.compose`) ; Daryl n'écrit que sa composition, | |
| 4 | 4 | montage/motion/<id>.html, avec juste ce qu'il lui faut (la fiche du beat, les mots et leurs instants, le |
| 5 | -contrat, la charte, le modèle du kit). Chaque beat écrit un fichier différent : plusieurs tâches tournent | |
| 6 | -en parallèle, SLOTS au plus (une file par place), les suivantes attendent. | |
| 5 | +contrat, la charte, le modèle du kit, les sons de la bibliothèque). Chaque beat écrit un fichier différent : plusieurs | |
| 6 | +tâches tournent en parallèle, SLOTS au plus (une file par place), les suivantes attendent. | |
| 7 | + | |
| 8 | +Le sound design se fait en même temps que l'animation : Daryl note dans sa composition les sons calés sur sa | |
| 9 | +timeline GSAP (un bloc JSON `<script type="application/json" id="sfx">`), et le studio les pose dans la timeline à | |
| 10 | +la fin de la tâche, à la place des sons du motion (place_sounds). | |
| 7 | 11 | """ |
| 12 | +import json | |
| 13 | +import re | |
| 14 | + | |
| 8 | 15 | from ...core import jobs |
| 9 | 16 | from ...core.tasks import TaskContext |
| 10 | 17 | from ..settings.service import language_name |
| @@ -13,18 +20,47 @@ from . import montage, montage_tasks, service, storyboard | ||
| 13 | 20 | SLOTS = 3 |
| 14 | 21 | KINDS = [f"project_beat_anim_{i}" for i in range(SLOTS)] |
| 15 | 22 | |
| 23 | +SOUND_RULES = """\ | |
| 24 | +# Le sound design de ta composition | |
| 25 | +Tu fais aussi le son du motion, en même temps que l'animation : c'est toi qui connais ses instants. \ | |
| 26 | +Note les sons dans la composition, dans un bloc à part (le rendu l'ignore) : | |
| 27 | +<script type="application/json" id="sfx"> | |
| 28 | +[{"t": 1.19, "src": "library/sfx/whoosh-2.wav", "gain_db": -24, "why": "la fenêtre monte"}, | |
| 29 | + {"t": 2.66, "src": "library/sfx/es-crayon-circle.wav", "in": 0, "out": 0.7, "gain_db": -20, "why": "noms entourés"}] | |
| 30 | +</script> | |
| 31 | +- `t` : l'instant où le son DÉMARRE, en secondes depuis le début de la composition (peut être légèrement \ | |
| 32 | +négatif). Un son frappe un peu après son début : la description donne son attaque (« attaque à 0,14 s », \ | |
| 33 | +« pic à 2,66 s ») ; pour qu'il frappe sur l'événement, t = instant de l'événement − attaque. | |
| 34 | +- `src` : EXACTEMENT un son de la liste qui t'est donnée. `in` / `out` : le passage du fichier à jouer \ | |
| 35 | +(par défaut tout le fichier) ; respecte les coupes que donne la description. `gain_db` : pars du volume \ | |
| 36 | +par défaut du son, ajuste de quelques dB au plus. | |
| 37 | +- Un son par événement qui compte (un élément qui arrive, se pose, frappe, se transforme), pas un son par \ | |
| 38 | +mouvement. Le silence est un choix : une composition calme peut n'avoir que deux ou trois sons, voire aucun \ | |
| 39 | +(bloc vide : []). | |
| 40 | +- Discret : la voix passe avant tout. Les volumes restent bas ; une nappe éventuelle, très bas, jamais plus \ | |
| 41 | +longue que la composition. | |
| 42 | +- Un seul point d'orgue fort par composition (impact, tampon), à l'instant clé ; superposer deux sons \ | |
| 43 | +(un impact sourd + un son d'objet) le rend plus riche. | |
| 44 | +- Un son jamais plus long que ce qu'il accompagne : coupe-le avec `out`. | |
| 45 | +- Un whoosh accompagne une entrée ou un mouvement de caméra ; préfère whoosh.wav et whoosh-2.wav. | |
| 46 | +- Varie les volumes de 1 à 2 dB quand un même son se répète. | |
| 47 | +- Ce bloc remplace les sons du motion dans la timeline à chaque fois que tu écris la composition.""" | |
| 48 | + | |
| 16 | 49 | SYSTEM = """\ |
| 17 | 50 | Tu es Daryl, motion designer d'un créateur de vidéos YouTube éducatives. Tu écris UNE composition \ |
| 18 | 51 | HyperFrames (HTML + GSAP), un visuel animé posé au-dessus de la vidéo pendant qu'il parle, à partir \ |
| 19 | -d'un beat de storyboard qu'il a validé. Suis « on montre » au plus près ; si quelque chose est \ | |
| 20 | -impossible, fais au plus près et dis-le. | |
| 52 | +d'un beat de storyboard qu'il a validé, et son sound design. Suis « on montre » au plus près ; si \ | |
| 53 | +quelque chose est impossible, fais au plus près et dis-le. | |
| 21 | 54 | |
| 22 | 55 | {rules} |
| 23 | 56 | |
| 57 | +{sound} | |
| 58 | + | |
| 24 | 59 | # Comment tu travailles |
| 25 | 60 | - Lis montage/motion/style.css (la charte) et le modèle du kit indiqué s'il y en a un, sinon un ou deux \ |
| 26 | 61 | exemples de montage/motion/exemples/ pour le contrat technique et le niveau attendu. |
| 27 | -- Écris montage/motion/<id>.html, et aucun autre fichier. | |
| 62 | +- Écris montage/motion/<id>.html, et aucun autre fichier. Son bloc de sons vient en dernier, une fois \ | |
| 63 | +l'animation finie : relis ta timeline GSAP pour en caler les instants. | |
| 28 | 64 | - Ta réponse finale : en {lang}, une ou deux phrases sur ce que tu as fait.""" |
| 29 | 65 | |
| 30 | 66 | |
| @@ -88,10 +124,79 @@ def prompt(pid: str, beat: dict, motion: dict) -> str: | ||
| 88 | 124 | "", storyboard.brief(pid, [mid]), "", |
| 89 | 125 | "Ce qu'il dit pendant le beat, mot à mot (instant du mot en secondes depuis le début de la " |
| 90 | 126 | "composition ; négatif : juste avant) :", |
| 91 | - " ".join(f"{w.text.strip()}@{w.start - start:.2f}" for w in spoken if w.text.strip())] | |
| 127 | + " ".join(f"{w.text.strip()}@{w.start - start:.2f}" for w in spoken if w.text.strip()), ""] | |
| 128 | + palette = sounds(pid) | |
| 129 | + if palette: | |
| 130 | + parts += ["Les sons disponibles pour le sound design (`src` exact · durée · volume par défaut · description) :", | |
| 131 | + *(f"- {s['src']} · {s['duration']:.2f} s · {s['gain_db']:g} dB" + (f" · {s['description']}" if s["description"] else "") | |
| 132 | + for s in palette)] | |
| 133 | + else: | |
| 134 | + parts += ["Aucun son disponible : écris un bloc de sons vide."] | |
| 92 | 135 | return "\n".join(parts) |
| 93 | 136 | |
| 94 | 137 | |
| 138 | +# --- Le sound design du motion ----------------------------------------------------------------- | |
| 139 | + | |
| 140 | +SFX_BLOCK = re.compile(r'<script[^>]*\bid=["\']sfx["\'][^>]*>(.*?)</script>', re.S | re.I) | |
| 141 | +SFX_GAIN = -20.0 | |
| 142 | + | |
| 143 | + | |
| 144 | +def sounds(pid: str) -> list[dict]: | |
| 145 | + """Les sons que Daryl peut employer : ceux de la bibliothèque (décrits), puis ceux du projet qui n'en sont pas une copie. | |
| 146 | + Chacun {src, duration, gain_db, description}.""" | |
| 147 | + sfx = [m for m in montage.available(pid) if m["category"] == "sfx"] | |
| 148 | + common = {m["name"] for m in sfx if m["where"] == "bibliothèque"} | |
| 149 | + return [{"src": m["src"], "duration": m["duration"] or 0.0, "gain_db": m.get("gain_db", SFX_GAIN), | |
| 150 | + "description": m.get("description", "")} | |
| 151 | + for m in sorted(sfx, key=lambda m: m["where"] != "bibliothèque") | |
| 152 | + if m["where"] == "bibliothèque" or m["name"] not in common] | |
| 153 | + | |
| 154 | + | |
| 155 | +def cues(pid: str, mid: str) -> list[dict] | None: | |
| 156 | + """Les sons notés dans montage/motion/<mid>.html ; None si elle n'en note pas (ou pas lisiblement).""" | |
| 157 | + path = montage.motion_dir(pid) / f"{mid}.html" | |
| 158 | + found = SFX_BLOCK.search(path.read_text(encoding="utf-8")) if path.is_file() else None | |
| 159 | + if not found: | |
| 160 | + return None | |
| 161 | + try: | |
| 162 | + raw = json.loads(found.group(1).strip() or "[]") | |
| 163 | + except json.JSONDecodeError: | |
| 164 | + return None | |
| 165 | + return [c for c in raw if isinstance(c, dict)] if isinstance(raw, list) else None | |
| 166 | + | |
| 167 | + | |
| 168 | +def place_sounds(pid: str, mid: str) -> int | None: | |
| 169 | + """Pose dans la timeline les sons notés dans la composition du motion `mid`, à la place de ceux qui lui sont | |
| 170 | + rattachés (son ancien sound design, le son d'entrée du storyboard). Rend le nombre de sons posés ; None si la | |
| 171 | + composition n'en note pas : ses sons restent alors tels quels.""" | |
| 172 | + noted = cues(pid, mid) | |
| 173 | + if noted is None: | |
| 174 | + return None | |
| 175 | + timeline = montage.load(pid) | |
| 176 | + motion = next((m for m in timeline["motion"] if m["id"] == mid), None) | |
| 177 | + if not motion: | |
| 178 | + return None | |
| 179 | + placed = [] | |
| 180 | + for c in noted: | |
| 181 | + try: | |
| 182 | + t = float(c.get("t", 0)) | |
| 183 | + gain = float(c.get("gain_db", SFX_GAIN)) | |
| 184 | + a = max(0.0, float(c.get("in") or 0)) | |
| 185 | + b = float(c["out"]) if c.get("out") is not None else None | |
| 186 | + except (TypeError, ValueError): | |
| 187 | + continue | |
| 188 | + # un son peut démarrer juste avant la composition, pas après sa fin | |
| 189 | + if not -1.0 <= t < motion["dur"]: | |
| 190 | + continue | |
| 191 | + # sans `out` : tout le fichier (borné par sa durée au nettoyage) | |
| 192 | + placed.append({"src": str(c.get("src") or ""), "at": round(max(0.0, motion["at"] + t), 3), "in": a, | |
| 193 | + "out": b if b is not None else a + 60, "gain_db": gain, "role": "sfx", "motion": mid}) | |
| 194 | + timeline["audio"] = [s for s in timeline["audio"] if s.get("motion") != mid] + placed | |
| 195 | + # nettoyée à l'enregistrement : un son inconnu disparaît, un son de la bibliothèque est copié dans le projet | |
| 196 | + saved = montage.save(pid, timeline) | |
| 197 | + return sum(1 for s in saved["audio"] if s.get("motion") == mid) | |
| 198 | + | |
| 199 | + | |
| 95 | 200 | def run(ctx: TaskContext) -> None: |
| 96 | 201 | from .tasks import ask |
| 97 | 202 | |
| @@ -108,9 +213,12 @@ def run(ctx: TaskContext) -> None: | ||
| 108 | 213 | state["activity"] = "lit la charte" |
| 109 | 214 | ctx.save(force=True) |
| 110 | 215 | out = ask(ctx, prompt(pid, beat, motion), |
| 111 | - SYSTEM.format(rules=montage_tasks.MOTION_RULES, lang=language_name()), "Read,Write,Edit,Glob") | |
| 216 | + SYSTEM.format(rules=montage_tasks.MOTION_RULES, sound=SOUND_RULES, lang=language_name()), | |
| 217 | + "Read,Write,Edit,Glob") | |
| 112 | 218 | if not target.is_file() or target.stat().st_mtime == before: |
| 113 | 219 | raise RuntimeError("Daryl n'a pas écrit la composition." + (f" Il dit : {out['text'][:300]}" if out["text"] else "")) |
| 114 | 220 | state["reply"] = out["text"].strip() |
| 115 | 221 | state["activity"] = None |
| 116 | - montage.after_change(pid) | |
| 222 | + # ses sons entrent dans la timeline (l'enregistrement relance aussi le rendu et l'export) | |
| 223 | + if place_sounds(pid, mid) is None: | |
| 224 | + montage.after_change(pid) | |
| @@ -1,10 +1,17 @@ | |||
| 1 | """ | 1 | """ |
| 2 | Créer l'animation d'un beat du storyboard : une tâche par beat, à part du chat du monteur. Le motion est | 2 | Créer l'animation d'un beat du storyboard : une tâche par beat, à part du chat du monteur. Le motion est |
| 3 | -déjà posé dans la timeline (place, durée, son : `storyboard.compose`) ; Daryl n'écrit que sa composition, | 3 | +déjà posé dans la timeline (place, durée, son d'entrée : `storyboard.compose`) ; Daryl n'écrit que sa composition, |
| 4 | montage/motion/<id>.html, avec juste ce qu'il lui faut (la fiche du beat, les mots et leurs instants, le | 4 | montage/motion/<id>.html, avec juste ce qu'il lui faut (la fiche du beat, les mots et leurs instants, le |
| 5 | -contrat, la charte, le modèle du kit). Chaque beat écrit un fichier différent : plusieurs tâches tournent | 5 | +contrat, la charte, le modèle du kit, les sons de la bibliothèque). Chaque beat écrit un fichier différent : plusieurs |
| 6 | -en parallèle, SLOTS au plus (une file par place), les suivantes attendent. | 6 | +tâches tournent en parallèle, SLOTS au plus (une file par place), les suivantes attendent. |
| 7 | + | ||
| 8 | +Le sound design se fait en même temps que l'animation : Daryl note dans sa composition les sons calés sur sa | ||
| 9 | +timeline GSAP (un bloc JSON `<script type="application/json" id="sfx">`), et le studio les pose dans la timeline à | ||
| 10 | +la fin de la tâche, à la place des sons du motion (place_sounds). | ||
| 7 | """ | 11 | """ |
| 12 | +import json | ||
| 13 | +import re | ||
| 14 | + | ||
| 8 | from ...core import jobs | 15 | from ...core import jobs |
| 9 | from ...core.tasks import TaskContext | 16 | from ...core.tasks import TaskContext |
| 10 | from ..settings.service import language_name | 17 | from ..settings.service import language_name |
| @@ -13,18 +20,47 @@ from . import montage, montage_tasks, service, storyboard | |||
| 13 | SLOTS = 3 | 20 | SLOTS = 3 |
| 14 | KINDS = [f"project_beat_anim_{i}" for i in range(SLOTS)] | 21 | KINDS = [f"project_beat_anim_{i}" for i in range(SLOTS)] |
| 15 | 22 | ||
| 23 | +SOUND_RULES = """\ | ||
| 24 | +# Le sound design de ta composition | ||
| 25 | +Tu fais aussi le son du motion, en même temps que l'animation : c'est toi qui connais ses instants. \ | ||
| 26 | +Note les sons dans la composition, dans un bloc à part (le rendu l'ignore) : | ||
| 27 | +<script type="application/json" id="sfx"> | ||
| 28 | +[{"t": 1.19, "src": "library/sfx/whoosh-2.wav", "gain_db": -24, "why": "la fenêtre monte"}, | ||
| 29 | + {"t": 2.66, "src": "library/sfx/es-crayon-circle.wav", "in": 0, "out": 0.7, "gain_db": -20, "why": "noms entourés"}] | ||
| 30 | +</script> | ||
| 31 | +- `t` : l'instant où le son DÉMARRE, en secondes depuis le début de la composition (peut être légèrement \ | ||
| 32 | +négatif). Un son frappe un peu après son début : la description donne son attaque (« attaque à 0,14 s », \ | ||
| 33 | +« pic à 2,66 s ») ; pour qu'il frappe sur l'événement, t = instant de l'événement − attaque. | ||
| 34 | +- `src` : EXACTEMENT un son de la liste qui t'est donnée. `in` / `out` : le passage du fichier à jouer \ | ||
| 35 | +(par défaut tout le fichier) ; respecte les coupes que donne la description. `gain_db` : pars du volume \ | ||
| 36 | +par défaut du son, ajuste de quelques dB au plus. | ||
| 37 | +- Un son par événement qui compte (un élément qui arrive, se pose, frappe, se transforme), pas un son par \ | ||
| 38 | +mouvement. Le silence est un choix : une composition calme peut n'avoir que deux ou trois sons, voire aucun \ | ||
| 39 | +(bloc vide : []). | ||
| 40 | +- Discret : la voix passe avant tout. Les volumes restent bas ; une nappe éventuelle, très bas, jamais plus \ | ||
| 41 | +longue que la composition. | ||
| 42 | +- Un seul point d'orgue fort par composition (impact, tampon), à l'instant clé ; superposer deux sons \ | ||
| 43 | +(un impact sourd + un son d'objet) le rend plus riche. | ||
| 44 | +- Un son jamais plus long que ce qu'il accompagne : coupe-le avec `out`. | ||
| 45 | +- Un whoosh accompagne une entrée ou un mouvement de caméra ; préfère whoosh.wav et whoosh-2.wav. | ||
| 46 | +- Varie les volumes de 1 à 2 dB quand un même son se répète. | ||
| 47 | +- Ce bloc remplace les sons du motion dans la timeline à chaque fois que tu écris la composition.""" | ||
| 48 | + | ||
| 16 | SYSTEM = """\ | 49 | SYSTEM = """\ |
| 17 | Tu es Daryl, motion designer d'un créateur de vidéos YouTube éducatives. Tu écris UNE composition \ | 50 | Tu es Daryl, motion designer d'un créateur de vidéos YouTube éducatives. Tu écris UNE composition \ |
| 18 | HyperFrames (HTML + GSAP), un visuel animé posé au-dessus de la vidéo pendant qu'il parle, à partir \ | 51 | HyperFrames (HTML + GSAP), un visuel animé posé au-dessus de la vidéo pendant qu'il parle, à partir \ |
| 19 | -d'un beat de storyboard qu'il a validé. Suis « on montre » au plus près ; si quelque chose est \ | 52 | +d'un beat de storyboard qu'il a validé, et son sound design. Suis « on montre » au plus près ; si \ |
| 20 | -impossible, fais au plus près et dis-le. | 53 | +quelque chose est impossible, fais au plus près et dis-le. |
| 21 | 54 | ||
| 22 | {rules} | 55 | {rules} |
| 23 | 56 | ||
| 57 | +{sound} | ||
| 58 | + | ||
| 24 | # Comment tu travailles | 59 | # Comment tu travailles |
| 25 | - Lis montage/motion/style.css (la charte) et le modèle du kit indiqué s'il y en a un, sinon un ou deux \ | 60 | - Lis montage/motion/style.css (la charte) et le modèle du kit indiqué s'il y en a un, sinon un ou deux \ |
| 26 | exemples de montage/motion/exemples/ pour le contrat technique et le niveau attendu. | 61 | exemples de montage/motion/exemples/ pour le contrat technique et le niveau attendu. |
| 27 | -- Écris montage/motion/<id>.html, et aucun autre fichier. | 62 | +- Écris montage/motion/<id>.html, et aucun autre fichier. Son bloc de sons vient en dernier, une fois \ |
| 63 | +l'animation finie : relis ta timeline GSAP pour en caler les instants. | ||
| 28 | - Ta réponse finale : en {lang}, une ou deux phrases sur ce que tu as fait.""" | 64 | - Ta réponse finale : en {lang}, une ou deux phrases sur ce que tu as fait.""" |
| 29 | 65 | ||
| 30 | 66 | ||
| @@ -88,10 +124,79 @@ def prompt(pid: str, beat: dict, motion: dict) -> str: | |||
| 88 | "", storyboard.brief(pid, [mid]), "", | 124 | "", storyboard.brief(pid, [mid]), "", |
| 89 | "Ce qu'il dit pendant le beat, mot à mot (instant du mot en secondes depuis le début de la " | 125 | "Ce qu'il dit pendant le beat, mot à mot (instant du mot en secondes depuis le début de la " |
| 90 | "composition ; négatif : juste avant) :", | 126 | "composition ; négatif : juste avant) :", |
| 91 | - " ".join(f"{w.text.strip()}@{w.start - start:.2f}" for w in spoken if w.text.strip())] | 127 | + " ".join(f"{w.text.strip()}@{w.start - start:.2f}" for w in spoken if w.text.strip()), ""] |
| 128 | + palette = sounds(pid) | ||
| 129 | + if palette: | ||
| 130 | + parts += ["Les sons disponibles pour le sound design (`src` exact · durée · volume par défaut · description) :", | ||
| 131 | + *(f"- {s['src']} · {s['duration']:.2f} s · {s['gain_db']:g} dB" + (f" · {s['description']}" if s["description"] else "") | ||
| 132 | + for s in palette)] | ||
| 133 | + else: | ||
| 134 | + parts += ["Aucun son disponible : écris un bloc de sons vide."] | ||
| 92 | return "\n".join(parts) | 135 | return "\n".join(parts) |
| 93 | 136 | ||
| 94 | 137 | ||
| 138 | +# --- Le sound design du motion ----------------------------------------------------------------- | ||
| 139 | + | ||
| 140 | +SFX_BLOCK = re.compile(r'<script[^>]*\bid=["\']sfx["\'][^>]*>(.*?)</script>', re.S | re.I) | ||
| 141 | +SFX_GAIN = -20.0 | ||
| 142 | + | ||
| 143 | + | ||
| 144 | +def sounds(pid: str) -> list[dict]: | ||
| 145 | + """Les sons que Daryl peut employer : ceux de la bibliothèque (décrits), puis ceux du projet qui n'en sont pas une copie. | ||
| 146 | + Chacun {src, duration, gain_db, description}.""" | ||
| 147 | + sfx = [m for m in montage.available(pid) if m["category"] == "sfx"] | ||
| 148 | + common = {m["name"] for m in sfx if m["where"] == "bibliothèque"} | ||
| 149 | + return [{"src": m["src"], "duration": m["duration"] or 0.0, "gain_db": m.get("gain_db", SFX_GAIN), | ||
| 150 | + "description": m.get("description", "")} | ||
| 151 | + for m in sorted(sfx, key=lambda m: m["where"] != "bibliothèque") | ||
| 152 | + if m["where"] == "bibliothèque" or m["name"] not in common] | ||
| 153 | + | ||
| 154 | + | ||
| 155 | +def cues(pid: str, mid: str) -> list[dict] | None: | ||
| 156 | + """Les sons notés dans montage/motion/<mid>.html ; None si elle n'en note pas (ou pas lisiblement).""" | ||
| 157 | + path = montage.motion_dir(pid) / f"{mid}.html" | ||
| 158 | + found = SFX_BLOCK.search(path.read_text(encoding="utf-8")) if path.is_file() else None | ||
| 159 | + if not found: | ||
| 160 | + return None | ||
| 161 | + try: | ||
| 162 | + raw = json.loads(found.group(1).strip() or "[]") | ||
| 163 | + except json.JSONDecodeError: | ||
| 164 | + return None | ||
| 165 | + return [c for c in raw if isinstance(c, dict)] if isinstance(raw, list) else None | ||
| 166 | + | ||
| 167 | + | ||
| 168 | +def place_sounds(pid: str, mid: str) -> int | None: | ||
| 169 | + """Pose dans la timeline les sons notés dans la composition du motion `mid`, à la place de ceux qui lui sont | ||
| 170 | + rattachés (son ancien sound design, le son d'entrée du storyboard). Rend le nombre de sons posés ; None si la | ||
| 171 | + composition n'en note pas : ses sons restent alors tels quels.""" | ||
| 172 | + noted = cues(pid, mid) | ||
| 173 | + if noted is None: | ||
| 174 | + return None | ||
| 175 | + timeline = montage.load(pid) | ||
| 176 | + motion = next((m for m in timeline["motion"] if m["id"] == mid), None) | ||
| 177 | + if not motion: | ||
| 178 | + return None | ||
| 179 | + placed = [] | ||
| 180 | + for c in noted: | ||
| 181 | + try: | ||
| 182 | + t = float(c.get("t", 0)) | ||
| 183 | + gain = float(c.get("gain_db", SFX_GAIN)) | ||
| 184 | + a = max(0.0, float(c.get("in") or 0)) | ||
| 185 | + b = float(c["out"]) if c.get("out") is not None else None | ||
| 186 | + except (TypeError, ValueError): | ||
| 187 | + continue | ||
| 188 | + # un son peut démarrer juste avant la composition, pas après sa fin | ||
| 189 | + if not -1.0 <= t < motion["dur"]: | ||
| 190 | + continue | ||
| 191 | + # sans `out` : tout le fichier (borné par sa durée au nettoyage) | ||
| 192 | + placed.append({"src": str(c.get("src") or ""), "at": round(max(0.0, motion["at"] + t), 3), "in": a, | ||
| 193 | + "out": b if b is not None else a + 60, "gain_db": gain, "role": "sfx", "motion": mid}) | ||
| 194 | + timeline["audio"] = [s for s in timeline["audio"] if s.get("motion") != mid] + placed | ||
| 195 | + # nettoyée à l'enregistrement : un son inconnu disparaît, un son de la bibliothèque est copié dans le projet | ||
| 196 | + saved = montage.save(pid, timeline) | ||
| 197 | + return sum(1 for s in saved["audio"] if s.get("motion") == mid) | ||
| 198 | + | ||
| 199 | + | ||
| 95 | def run(ctx: TaskContext) -> None: | 200 | def run(ctx: TaskContext) -> None: |
| 96 | from .tasks import ask | 201 | from .tasks import ask |
| 97 | 202 | ||
| @@ -108,9 +213,12 @@ def run(ctx: TaskContext) -> None: | |||
| 108 | state["activity"] = "lit la charte" | 213 | state["activity"] = "lit la charte" |
| 109 | ctx.save(force=True) | 214 | ctx.save(force=True) |
| 110 | out = ask(ctx, prompt(pid, beat, motion), | 215 | out = ask(ctx, prompt(pid, beat, motion), |
| 111 | - SYSTEM.format(rules=montage_tasks.MOTION_RULES, lang=language_name()), "Read,Write,Edit,Glob") | 216 | + SYSTEM.format(rules=montage_tasks.MOTION_RULES, sound=SOUND_RULES, lang=language_name()), |
| 217 | + "Read,Write,Edit,Glob") | ||
| 112 | if not target.is_file() or target.stat().st_mtime == before: | 218 | if not target.is_file() or target.stat().st_mtime == before: |
| 113 | raise RuntimeError("Daryl n'a pas écrit la composition." + (f" Il dit : {out['text'][:300]}" if out["text"] else "")) | 219 | raise RuntimeError("Daryl n'a pas écrit la composition." + (f" Il dit : {out['text'][:300]}" if out["text"] else "")) |
| 114 | state["reply"] = out["text"].strip() | 220 | state["reply"] = out["text"].strip() |
| 115 | state["activity"] = None | 221 | state["activity"] = None |
| 116 | - montage.after_change(pid) | 222 | + # ses sons entrent dans la timeline (l'enregistrement relance aussi le rendu et l'export) |
| 223 | + if place_sounds(pid, mid) is None: | ||
| 224 | + montage.after_change(pid) | ||
modified
backend/studio/modules/projects/montage.py +5 -2 | @@ -24,12 +24,13 @@ timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans | ||
| 24 | 24 | "zooms": [{"id", "at", "dur", "scale", "x", "y", "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction) |
| 25 | 25 | "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets) |
| 26 | 26 | "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"}], | |
| 27 | + "audio": [{"id", "src", "at", "in", "out", "gain_db", "role": "sfx" | "music", "motion"?}], | |
| 28 | 28 | "texts": [{"id", "at", "dur", "text", "x", "y", "size", "color", "angle"}], # texte écrit à la main, au-dessus de tout |
| 29 | 29 | "markers": [{"id", "t", "text", "kind": "chapter" | "note", "by": "user" | "ai", "done"}] |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | -`beat` : le beat du storyboard dont vient l'élément. | |
| 32 | +`beat` : le beat du storyboard dont vient l'élément. `motion` d'un son : le motion dont il fait le sound design ; il | |
| 33 | +le suit quand on le déplace et il est remplacé quand Daryl réécrit les sons du motion (beat_anim.place_sounds). | |
| 33 | 34 | `transition`, `enter`, `exit` : une transition de la bibliothèque {"fx", "dur"} (transitions.py), ou null / absent |
| 34 | 35 | pour une coupe franche. |
| 35 | 36 | `texts` : une note écrite par le client (corriger une phrase mal audible, préciser un mot), en écriture manuscrite ; |
| @@ -241,6 +242,8 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict: | ||
| 241 | 242 | if track == "audio": |
| 242 | 243 | entry["gain_db"] = max(-60.0, min(_num(item.get("gain_db")), 12.0)) |
| 243 | 244 | entry["role"] = "music" if item.get("role") == "music" else "sfx" |
| 245 | + if item.get("motion"): | |
| 246 | + entry["motion"] = str(item["motion"])[:20] | |
| 244 | 247 | out[track].append(entry) |
| 245 | 248 | |
| 246 | 249 | for m in raw.get("motion") or []: |
| @@ -24,12 +24,13 @@ timeline.json (temps de la timeline en secondes, sauf `in` / `out` : temps dans | |||
| 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", "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction) |
| 25 | "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets) | 25 | "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 | 26 | "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"}], | 27 | + "audio": [{"id", "src", "at", "in", "out", "gain_db", "role": "sfx" | "music", "motion"?}], |
| 28 | "texts": [{"id", "at", "dur", "text", "x", "y", "size", "color", "angle"}], # texte écrit à la main, au-dessus de tout | 28 | "texts": [{"id", "at", "dur", "text", "x", "y", "size", "color", "angle"}], # texte écrit à la main, au-dessus de tout |
| 29 | "markers": [{"id", "t", "text", "kind": "chapter" | "note", "by": "user" | "ai", "done"}] | 29 | "markers": [{"id", "t", "text", "kind": "chapter" | "note", "by": "user" | "ai", "done"}] |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | -`beat` : le beat du storyboard dont vient l'élément. | 32 | +`beat` : le beat du storyboard dont vient l'élément. `motion` d'un son : le motion dont il fait le sound design ; il |
| 33 | +le suit quand on le déplace et il est remplacé quand Daryl réécrit les sons du motion (beat_anim.place_sounds). | ||
| 33 | `transition`, `enter`, `exit` : une transition de la bibliothèque {"fx", "dur"} (transitions.py), ou null / absent | 34 | `transition`, `enter`, `exit` : une transition de la bibliothèque {"fx", "dur"} (transitions.py), ou null / absent |
| 34 | pour une coupe franche. | 35 | pour une coupe franche. |
| 35 | `texts` : une note écrite par le client (corriger une phrase mal audible, préciser un mot), en écriture manuscrite ; | 36 | `texts` : une note écrite par le client (corriger une phrase mal audible, préciser un mot), en écriture manuscrite ; |
| @@ -241,6 +242,8 @@ def clean(pid: str, raw: dict, source_duration: float) -> dict: | |||
| 241 | if track == "audio": | 242 | if track == "audio": |
| 242 | entry["gain_db"] = max(-60.0, min(_num(item.get("gain_db")), 12.0)) | 243 | entry["gain_db"] = max(-60.0, min(_num(item.get("gain_db")), 12.0)) |
| 243 | entry["role"] = "music" if item.get("role") == "music" else "sfx" | 244 | entry["role"] = "music" if item.get("role") == "music" else "sfx" |
| 245 | + if item.get("motion"): | ||
| 246 | + entry["motion"] = str(item["motion"])[:20] | ||
| 244 | out[track].append(entry) | 247 | out[track].append(entry) |
| 245 | 248 | ||
| 246 | for m in raw.get("motion") or []: | 249 | for m in raw.get("motion") or []: |
modified
backend/studio/modules/projects/storyboard.py +4 -1 | @@ -1156,6 +1156,7 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1156 | 1156 | out["placed"].append(b["id"]) |
| 1157 | 1157 | changed = True |
| 1158 | 1158 | continue |
| 1159 | + mid = None | |
| 1159 | 1160 | if kind == "broll" and media_kind(b["src"]) == "video": |
| 1160 | 1161 | src = montage._resolve_src(pid, b["src"]) |
| 1161 | 1162 | if not src: |
| @@ -1187,7 +1188,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | ||
| 1187 | 1188 | timeline["audio"].append({"id": next_id("audio"), "src": sound["src"], |
| 1188 | 1189 | "at": round(max(0.0, b["at"] - SFX_LEAD), 3), "in": 0.0, |
| 1189 | 1190 | "out": round(min(sound["duration"] or 1.0, b["dur"]), 3), |
| 1190 | - "gain_db": sound.get("gain_db", SFX_GAIN), "role": "sfx"}) | |
| 1191 | + "gain_db": sound.get("gain_db", SFX_GAIN), "role": "sfx", | |
| 1192 | + # le son d'entrée d'un motion : remplacé si Daryl en fait le sound design | |
| 1193 | + **({"motion": mid} if mid else {})}) | |
| 1191 | 1194 | out["placed"].append(b["id"]) |
| 1192 | 1195 | changed = True |
| 1193 | 1196 | if changed: |
| @@ -1156,6 +1156,7 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | |||
| 1156 | out["placed"].append(b["id"]) | 1156 | out["placed"].append(b["id"]) |
| 1157 | changed = True | 1157 | changed = True |
| 1158 | continue | 1158 | continue |
| 1159 | + mid = None | ||
| 1159 | if kind == "broll" and media_kind(b["src"]) == "video": | 1160 | if kind == "broll" and media_kind(b["src"]) == "video": |
| 1160 | src = montage._resolve_src(pid, b["src"]) | 1161 | src = montage._resolve_src(pid, b["src"]) |
| 1161 | if not src: | 1162 | if not src: |
| @@ -1187,7 +1188,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict: | |||
| 1187 | timeline["audio"].append({"id": next_id("audio"), "src": sound["src"], | 1188 | timeline["audio"].append({"id": next_id("audio"), "src": sound["src"], |
| 1188 | "at": round(max(0.0, b["at"] - SFX_LEAD), 3), "in": 0.0, | 1189 | "at": round(max(0.0, b["at"] - SFX_LEAD), 3), "in": 0.0, |
| 1189 | "out": round(min(sound["duration"] or 1.0, b["dur"]), 3), | 1190 | "out": round(min(sound["duration"] or 1.0, b["dur"]), 3), |
| 1190 | - "gain_db": sound.get("gain_db", SFX_GAIN), "role": "sfx"}) | 1191 | + "gain_db": sound.get("gain_db", SFX_GAIN), "role": "sfx", |
| 1192 | + # le son d'entrée d'un motion : remplacé si Daryl en fait le sound design | ||
| 1193 | + **({"motion": mid} if mid else {})}) | ||
| 1191 | out["placed"].append(b["id"]) | 1194 | out["placed"].append(b["id"]) |
| 1192 | changed = True | 1195 | changed = True |
| 1193 | if changed: | 1196 | if changed: |
modified
web/src/project/steps/montage/Timeline.vue +7 -0 | @@ -412,6 +412,13 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) { | ||
| 412 | 412 | if (o.dur != null) it.dur = round(end - o.at); |
| 413 | 413 | else it.out = round(Math.min(limit, o.in! + end - o.at)); |
| 414 | 414 | } |
| 415 | + // un motion déplacé, ou tiré par son début (sa composition démarre avec lui), emmène ses sons | |
| 416 | + if (d.track === "motion" && it.at !== o.at) { | |
| 417 | + for (const s of tl.audio) { | |
| 418 | + const so = s.motion === d.id ? d.origin.audio.find((x) => x.id === s.id) : undefined; | |
| 419 | + if (so) s.at = round(Math.max(0, so.at + it.at - o.at)); | |
| 420 | + } | |
| 421 | + } | |
| 415 | 422 | } |
| 416 | 423 | |
| 417 | 424 | function move(e: PointerEvent) { |
| @@ -412,6 +412,13 @@ function apply(tl: Timeline, d: NonNullable<typeof drag>, t: number) { | |||
| 412 | if (o.dur != null) it.dur = round(end - o.at); | 412 | if (o.dur != null) it.dur = round(end - o.at); |
| 413 | else it.out = round(Math.min(limit, o.in! + end - o.at)); | 413 | else it.out = round(Math.min(limit, o.in! + end - o.at)); |
| 414 | } | 414 | } |
| 415 | + // un motion déplacé, ou tiré par son début (sa composition démarre avec lui), emmène ses sons | ||
| 416 | + if (d.track === "motion" && it.at !== o.at) { | ||
| 417 | + for (const s of tl.audio) { | ||
| 418 | + const so = s.motion === d.id ? d.origin.audio.find((x) => x.id === s.id) : undefined; | ||
| 419 | + if (so) s.at = round(Math.max(0, so.at + it.at - o.at)); | ||
| 420 | + } | ||
| 421 | + } | ||
| 415 | } | 422 | } |
| 416 | 423 | ||
| 417 | function move(e: PointerEvent) { | 424 | function move(e: PointerEvent) { |
modified
web/src/project/steps/montage/ops.ts +2 -0 | @@ -128,6 +128,8 @@ export function remove(tl: Timeline, track: Track, id: string): void { | ||
| 128 | 128 | return; |
| 129 | 129 | } |
| 130 | 130 | (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id); |
| 131 | + // les sons d'un motion partent avec lui | |
| 132 | + if (track === "motion") tl.audio = tl.audio.filter((s) => s.motion !== id); | |
| 131 | 133 | } |
| 132 | 134 | |
| 133 | 135 | /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */ |
| @@ -128,6 +128,8 @@ export function remove(tl: Timeline, track: Track, id: string): void { | |||
| 128 | return; | 128 | return; |
| 129 | } | 129 | } |
| 130 | (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id); | 130 | (tl[track] as { id: string }[]) = (tl[track] as { id: string }[]).filter((x) => x.id !== id); |
| 131 | + // les sons d'un motion partent avec lui | ||
| 132 | + if (track === "motion") tl.audio = tl.audio.filter((s) => s.motion !== id); | ||
| 131 | } | 133 | } |
| 132 | 134 | ||
| 133 | /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */ | 135 | /** Change les bornes d'un clip de l'A-roll (dans son média : `limit`, sa durée), avec ripple. */ |
modified
web/src/project/steps/montage/types.ts +2 -1 | @@ -12,7 +12,8 @@ export interface Zoom { id: string; at: number; dur: number; scale: number; x: n | ||
| 12 | 12 | /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */ |
| 13 | 13 | export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null } |
| 14 | 14 | export interface Motion { id: string; at: number; dur: number; title: string; kind: string; idea: string; enter?: Fx | null; exit?: Fx | null } |
| 15 | -export interface Sound { id: string; src: string; at: number; in: number; out: number; gain_db: number; role: "sfx" | "music" } | |
| 15 | +/** `motion` : le motion dont le son fait le sound design ; il le suit quand on le déplace */ | |
| 16 | +export interface Sound { id: string; src: string; at: number; in: number; out: number; gain_db: number; role: "sfx" | "music"; motion?: string } | |
| 16 | 17 | /** |
| 17 | 18 | * Un texte écrit à la main, à l'écran (corriger un mot mal audible) : x, y le centre du texte (fraction de l'image), |
| 18 | 19 | * size la hauteur de la police (fraction de la hauteur de l'image), angle en degrés. |
| @@ -12,7 +12,8 @@ export interface Zoom { id: string; at: number; dur: number; scale: number; x: n | |||
| 12 | /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */ | 12 | /** 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 } | 13 | 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 } | 14 | export interface Motion { id: string; at: number; dur: number; title: string; kind: string; idea: string; enter?: Fx | null; exit?: Fx | null } |
| 15 | -export interface Sound { id: string; src: string; at: number; in: number; out: number; gain_db: number; role: "sfx" | "music" } | 15 | +/** `motion` : le motion dont le son fait le sound design ; il le suit quand on le déplace */ |
| 16 | +export interface Sound { id: string; src: string; at: number; in: number; out: number; gain_db: number; role: "sfx" | "music"; motion?: string } | ||
| 16 | /** | 17 | /** |
| 17 | * Un texte écrit à la main, à l'écran (corriger un mot mal audible) : x, y le centre du texte (fraction de l'image), | 18 | * Un texte écrit à la main, à l'écran (corriger un mot mal audible) : x, y le centre du texte (fraction de l'image), |
| 18 | * size la hauteur de la police (fraction de la hauteur de l'image), angle en degrés. | 19 | * size la hauteur de la police (fraction de la hauteur de l'image), angle en degrés. |