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

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

Download ZIP

feat(montage): 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.
hlassiege committed 2026-10-11T17:38:55+02:00 Browse files
d975f4e parent: bced829
modified backend/studio/modules/projects/beat_anim.py +117 -9
@@ -1,10 +1,17 @@
11 """
22 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,
44 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).
711 """
12+import json
13+import re
14+
815 from ...core import jobs
916 from ...core.tasks import TaskContext
1017 from ..settings.service import language_name
@@ -13,18 +20,47 @@ from . import montage, montage_tasks, service, storyboard
1320 SLOTS = 3
1421 KINDS = [f"project_beat_anim_{i}" for i in range(SLOTS)]
1522
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+
1649 SYSTEM = """\
1750 Tu es Daryl, motion designer d'un créateur de vidéos YouTube éducatives. Tu écris UNE composition \
1851 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.
2154
2255 {rules}
2356
57+{sound}
58+
2459 # Comment tu travailles
2560 - Lis montage/motion/style.css (la charte) et le modèle du kit indiqué s'il y en a un, sinon un ou deux \
2661 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.
2864 - Ta réponse finale : en {lang}, une ou deux phrases sur ce que tu as fait."""
2965
3066
@@ -88,10 +124,79 @@ def prompt(pid: str, beat: dict, motion: dict) -> str:
88124 "", storyboard.brief(pid, [mid]), "",
89125 "Ce qu'il dit pendant le beat, mot à mot (instant du mot en secondes depuis le début de la "
90126 "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."]
92135 return "\n".join(parts)
93136
94137
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+
95200 def run(ctx: TaskContext) -> None:
96201 from .tasks import ask
97202
@@ -108,9 +213,12 @@ def run(ctx: TaskContext) -> None:
108213 state["activity"] = "lit la charte"
109214 ctx.save(force=True)
110215 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")
112218 if not target.is_file() or target.stat().st_mtime == before:
113219 raise RuntimeError("Daryl n'a pas écrit la composition." + (f" Il dit : {out['text'][:300]}" if out["text"] else ""))
114220 state["reply"] = out["text"].strip()
115221 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 est2 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, le4 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 tournent5+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 jobs15 from ...core import jobs
9 from ...core.tasks import TaskContext16 from ...core.tasks import TaskContext
10 from ..settings.service import language_name17 from ..settings.service import language_name
@@ -13,18 +20,47 @@ from . import montage, montage_tasks, service, storyboard
13 SLOTS = 320 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 travailles59 # 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 ask201 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"] = None221 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
2424 "zooms": [{"id", "at", "dur", "scale", "x", "y", "beat"?}], # zoom sur l'A-roll ; x, y : décalage du centre (fraction)
2525 "broll": [{"id", "src", "at", "in", "out", "enter"?, "exit"?, "beat"?}], # plans de coupe, au-dessus de l'A-roll (muets)
2626 "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"?}],
2828 "texts": [{"id", "at", "dur", "text", "x", "y", "size", "color", "angle"}], # texte écrit à la main, au-dessus de tout
2929 "markers": [{"id", "t", "text", "kind": "chapter" | "note", "by": "user" | "ai", "done"}]
3030 }
3131
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).
3334 `transition`, `enter`, `exit` : une transition de la bibliothèque {"fx", "dur"} (transitions.py), ou null / absent
3435 pour une coupe franche.
3536 `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:
241242 if track == "audio":
242243 entry["gain_db"] = max(-60.0, min(_num(item.get("gain_db")), 12.0))
243244 entry["role"] = "music" if item.get("role") == "music" else "sfx"
245+ if item.get("motion"):
246+ entry["motion"] = str(item["motion"])[:20]
244247 out[track].append(entry)
245248
246249 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-roll26 "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 tout28 "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 / absent34 `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:
11561156 out["placed"].append(b["id"])
11571157 changed = True
11581158 continue
1159+ mid = None
11591160 if kind == "broll" and media_kind(b["src"]) == "video":
11601161 src = montage._resolve_src(pid, b["src"])
11611162 if not src:
@@ -1187,7 +1188,9 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
11871188 timeline["audio"].append({"id": next_id("audio"), "src": sound["src"],
11881189 "at": round(max(0.0, b["at"] - SFX_LEAD), 3), "in": 0.0,
11891190 "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 {})})
11911194 out["placed"].append(b["id"])
11921195 changed = True
11931196 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 = True1157 changed = True
1158 continue1158 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 = True1195 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) {
412412 if (o.dur != null) it.dur = round(end - o.at);
413413 else it.out = round(Math.min(limit, o.in! + end - o.at));
414414 }
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+ }
415422 }
416423
417424 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 {
128128 return;
129129 }
130130 (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);
131133 }
132134
133135 /** 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
1212 /** enter / exit : la transition depuis et vers ce qui est dessous (absente : coupe franche) */
1313 export interface Broll { id: string; src: string; at: number; in: number; out: number; enter?: Fx | null; exit?: Fx | null }
1414 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 }
1617 /**
1718 * Un texte écrit à la main, à l'écran (corriger un mot mal audible) : x, y le centre du texte (fraction de l'image),
1819 * 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.