hlassiege/darylpublic Fork
⑂ ef15d3a
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(storyboard): lay part transitions as a-roll pauses

hlassiege committed 2026-11-10T14:51:30+01:00 Browse files
ef15d3a parent: 6fab937
modified backend/studio/modules/projects/storyboard.py +78 -7
@@ -79,8 +79,9 @@ ZOOM_SLOW_DEFAULT = 1.1
7979 ZOOM_DUR = (1.5, 8.0)
8080 ZOOM_SLOW_DUR = (4.0, 15.0)
8181 ZOOM_LEAD = 0.1
82-# transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie
83-TRANSITION_DUR = 1.2
82+# transition : juste avant les premiers mots de la nouvelle partie ; posée en pause (la voix s'arrête dessous), elle
83+# dure assez pour lire son titre et voir son animation (à 1,2 s, on ne lit rien)
84+TRANSITION_DUR = 3.0
8485 TRANSITION_LEAD = 0.9
8586 # un beat posé à la main : sa durée par défaut, et sa durée minimale
8687 HAND_DUR = 3.0
@@ -316,9 +317,16 @@ une image est mise en scène, en plein écran (`place` full) ou sur un côté co
316317 `right`) : dans `motion`, des gestes nets calés sur ses mots (ce qui s'isole, se surligne, se détache, \
317318 au mot qui le nomme), pas « fondu + lent zoom ». Sinon `media` vide, `search` = la recherche à faire pour le trouver (mots-clés concrets, en \
318319 {lang}) et `show` = le plan idéal ; l'auteur le trouvera ou le tournera.
319-- transition : un enchaînement animé plein écran, court, au changement de partie ou de chapitre, \
320-une par changement au plus. `anchor` = les premiers mots de la nouvelle partie ; `show` = ce qu'elle \
321-affiche (le titre de la partie, en quelques mots) ; `until` = les mêmes mots que `anchor`.
320+- transition : un carton animé plein écran au changement de partie ou de chapitre, une par \
321+changement au plus. La vidéo s'arrête dessous (image noire, voix coupée) environ 3 secondes, le temps \
322+de lire le titre, puis la voix reprend sur `anchor`. `anchor` = les premiers mots de la PREMIÈRE \
323+PHRASE de la nouvelle partie, au début de cette phrase ; `show` = ce qu'elle affiche (le titre de la \
324+partie, en quelques mots) ; `until` = les mêmes mots que `anchor`. Ce qui précède l'ancre doit être \
325+une pensée finie : la phrase d'avant se termine (point, point d'interrogation) et clôt la partie \
326+précédente. Lis les deux ou trois phrases autour : une chute, un commentaire ou une conclusion \
327+courte qui suit (« Et ça, c'est pas bon. », « Voilà. », « Bref, ça marchait. ») appartient encore à \
328+la partie précédente, la transition vient APRÈS elle. Jamais au milieu d'une phrase ni entre une \
329+question et sa réponse, une idée et son explication ; sans frontière nette, pas de transition.
322330 `kind`, `anim` et `motion` ne servent qu'aux motions (et `place` aux motions et aux images) ; `sfx` \
323331 aux motions, B-roll et transitions.
324332
@@ -1187,6 +1195,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
11871195
11881196 out = {"motions": [], "placed": [], "skipped": [], "missing": []}
11891197 changed = False
1198+ # les transitions posées : l'A-roll s'arrête sous chacune (une pause), une fois tout posé
1199+ pauses: list[tuple[str, dict]] = []
11901200 for b in board["beats"]:
11911201 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
11921202 continue
@@ -1237,6 +1247,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
12371247 "kind": "image" if kind == "broll" else "transition" if kind == "transition"
12381248 else b["kind"], "idea": b["show"], **_ends(b), "beat": b["id"]})
12391249 out["motions"].append(mid)
1250+ if kind == "transition":
1251+ pauses.append((mid, b))
12401252 sound = sfx.get(b.get("sfx") or "")
12411253 if sound:
12421254 timeline["audio"].append({"id": next_id("audio"), "src": sound["src"],
@@ -1247,13 +1259,64 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
12471259 **({"motion": mid} if mid else {})})
12481260 out["placed"].append(b["id"])
12491261 changed = True
1262+ shifts = _pause_transitions(pid, timeline, pauses)
12501263 if changed:
12511264 for track in ("zooms", "broll", "motion", "audio"):
12521265 timeline[track].sort(key=lambda x: x["at"])
1253- montage.save(pid, timeline)
1266+ montage.save(pid, timeline, shifts=shifts)
12541267 return out
12551268
12561269
1270+def _gap_cut(words: list[cues.Word], aroll: list[dict], at: float, anchor: str) -> float:
1271+ """
1272+ Où couper l'A-roll pour la transition posée à `at` : dans le silence juste avant le premier mot de la nouvelle
1273+ partie (`anchor`, retrouvé près de `at`), sur une coupe du dérush s'il y en a une dans ce silence, sinon en son
1274+ milieu. Jamais dans le plus long silence des environs : c'est souvent celui d'avant une chute qui clôt la partie
1275+ précédente (« …les compromis qui sont faits. ‖ Et ça, c'est pas bon. »), que la pause couperait de sa phrase.
1276+ Sans ancre retrouvée : `at` + TRANSITION_LEAD (là où make_beat a vu le premier mot).
1277+ """
1278+ toks, owner = autoderush._token_index(words)
1279+ near = next((i for i, o in enumerate(owner) if words[o].start >= at - 1.0), len(toks))
1280+ i = _find(toks, autoderush._tokens(anchor), near - 5, near + 40, near)
1281+ k = owner[i] if i is not None and abs(words[owner[i]].start - at - TRANSITION_LEAD) < 3.0 else None
1282+ if k is None:
1283+ k = next((n for n, w in enumerate(words) if w.start >= at + TRANSITION_LEAD - 0.3), None)
1284+ if k is None:
1285+ return at
1286+ lo = words[k - 1].end if k else 0.0
1287+ hi = words[k].start
1288+ cuts, cursor = [], 0.0
1289+ for c in aroll:
1290+ cuts.append(cursor)
1291+ cursor += c["out"] - c["in"]
1292+ inside = [c for c in cuts if lo - 0.02 <= c <= hi + 0.02]
1293+ return round(min(inside, key=lambda c: abs(c - (lo + hi) / 2)) if inside else (lo + hi) / 2, 3)
1294+
1295+
1296+def _pause_transitions(pid: str, timeline: dict, pauses: list[tuple[str, dict]]) -> list[dict]:
1297+ """
1298+ Les transitions de partie qui viennent d'être posées deviennent des plans de l'A-roll : chacune est calée au milieu
1299+ du silence entre les deux parties, et l'A-roll s'arrête sous elle (montage.pause_motion) ; la voix reprend après.
1300+ Modifie `timeline` ; rend les décalages à reporter sur les beats du storyboard.
1301+ """
1302+ if not pauses or not (src := montage.ensure_pause(pid)):
1303+ return []
1304+ words = timeline_words(service.load(pid), timeline)
1305+ shifts: list[dict] = []
1306+ # de la dernière à la première : une pause ne décale pas celles d'avant
1307+ for mid, b in sorted(pauses, key=lambda p: -p[1]["at"]):
1308+ m = next(x for x in timeline["motion"] if x["id"] == mid)
1309+ delta = _gap_cut(words, timeline["aroll"], m["at"], b.get("anchor") or "") - m["at"]
1310+ m["at"] = round(m["at"] + delta, 3)
1311+ for s in timeline["audio"]:
1312+ if s.get("motion") == mid:
1313+ s["at"] = round(max(0.0, s["at"] + delta), 3)
1314+ done = montage.pause_motion(timeline, mid, src) or []
1315+ # le beat de la transition reste où il est ; ceux d'après suivent la pause
1316+ shifts += [{**s, "at": max(s["at"], round(b["at"] + 0.01, 3))} for s in done]
1317+ return shifts
1318+
1319+
12571320 # au-delà, une image agrandie se voit floue à l'écran
12581321 MAX_UPSCALE = 1.15
12591322
@@ -1351,7 +1414,15 @@ def brief(pid: str, ids: list[str]) -> str:
13511414 lines = [f"### {m['id']} · {m['at']:.2f} → {m['at'] + m['dur']:.2f} s ({m['dur']:.1f} s)",
13521415 f"- Il dit : « {b['says']} »",
13531416 f"- On montre : {b['show']}"]
1354- if kind == "transition":
1417+ if kind == "transition" and any(c.get("pause") == m["id"] for c in timeline["aroll"]):
1418+ lines.append("- Transition plein écran entre deux parties de la vidéo, en PAUSE : la voix s'est tue et "
1419+ "l'A-roll est arrêté dessous (image noire, silence) tout le temps du motion, qui est un plan à "
1420+ "part entière. Couvre tout le cadre de la première à la dernière image (pas de marge, le fond "
1421+ "opaque est là dès le début : sinon on voit du noir) ; ce qu'elle affiche se lit d'un coup "
1422+ "d'œil et a le temps de respirer : le spectateur marque un temps avant la partie suivante. "
1423+ "Le titre est en place et lisible tôt (avant le premier tiers) puis reste fixe au moins "
1424+ "1,5 s avant de sortir : pas d'animation qui court jusqu'à la dernière image.")
1425+ elif kind == "transition":
13551426 lines.append(f"- Transition plein écran entre deux parties de la vidéo : l'écran est entièrement couvert "
13561427 f"au milieu (vers {m['dur'] / 2:.2f} s), entrée et sortie rapides et franches ; ce qu'elle "
13571428 "affiche se lit d'un coup d'œil. Ici pas de marge : elle couvre tout le cadre.")
@@ -79,8 +79,9 @@ ZOOM_SLOW_DEFAULT = 1.1
79 ZOOM_DUR = (1.5, 8.0)79 ZOOM_DUR = (1.5, 8.0)
80 ZOOM_SLOW_DUR = (4.0, 15.0)80 ZOOM_SLOW_DUR = (4.0, 15.0)
81 ZOOM_LEAD = 0.181 ZOOM_LEAD = 0.1
82-# transition : courte, l'écran couvert au milieu, juste avant les premiers mots de la nouvelle partie82+# transition : juste avant les premiers mots de la nouvelle partie ; posée en pause (la voix s'arrête dessous), elle
83-TRANSITION_DUR = 1.283+# dure assez pour lire son titre et voir son animation (à 1,2 s, on ne lit rien)
84+TRANSITION_DUR = 3.0
84 TRANSITION_LEAD = 0.985 TRANSITION_LEAD = 0.9
85 # un beat posé à la main : sa durée par défaut, et sa durée minimale86 # un beat posé à la main : sa durée par défaut, et sa durée minimale
86 HAND_DUR = 3.087 HAND_DUR = 3.0
@@ -316,9 +317,16 @@ une image est mise en scène, en plein écran (`place` full) ou sur un côté co
316 `right`) : dans `motion`, des gestes nets calés sur ses mots (ce qui s'isole, se surligne, se détache, \317 `right`) : dans `motion`, des gestes nets calés sur ses mots (ce qui s'isole, se surligne, se détache, \
317 au mot qui le nomme), pas « fondu + lent zoom ». Sinon `media` vide, `search` = la recherche à faire pour le trouver (mots-clés concrets, en \318 au mot qui le nomme), pas « fondu + lent zoom ». Sinon `media` vide, `search` = la recherche à faire pour le trouver (mots-clés concrets, en \
318 {lang}) et `show` = le plan idéal ; l'auteur le trouvera ou le tournera.319 {lang}) et `show` = le plan idéal ; l'auteur le trouvera ou le tournera.
319-- transition : un enchaînement animé plein écran, court, au changement de partie ou de chapitre, \320+- transition : un carton animé plein écran au changement de partie ou de chapitre, une par \
320-une par changement au plus. `anchor` = les premiers mots de la nouvelle partie ; `show` = ce qu'elle \321+changement au plus. La vidéo s'arrête dessous (image noire, voix coupée) environ 3 secondes, le temps \
321-affiche (le titre de la partie, en quelques mots) ; `until` = les mêmes mots que `anchor`.322+de lire le titre, puis la voix reprend sur `anchor`. `anchor` = les premiers mots de la PREMIÈRE \
323+PHRASE de la nouvelle partie, au début de cette phrase ; `show` = ce qu'elle affiche (le titre de la \
324+partie, en quelques mots) ; `until` = les mêmes mots que `anchor`. Ce qui précède l'ancre doit être \
325+une pensée finie : la phrase d'avant se termine (point, point d'interrogation) et clôt la partie \
326+précédente. Lis les deux ou trois phrases autour : une chute, un commentaire ou une conclusion \
327+courte qui suit (« Et ça, c'est pas bon. », « Voilà. », « Bref, ça marchait. ») appartient encore à \
328+la partie précédente, la transition vient APRÈS elle. Jamais au milieu d'une phrase ni entre une \
329+question et sa réponse, une idée et son explication ; sans frontière nette, pas de transition.
322 `kind`, `anim` et `motion` ne servent qu'aux motions (et `place` aux motions et aux images) ; `sfx` \330 `kind`, `anim` et `motion` ne servent qu'aux motions (et `place` aux motions et aux images) ; `sfx` \
323 aux motions, B-roll et transitions.331 aux motions, B-roll et transitions.
324 332
@@ -1187,6 +1195,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1187 1195
1188 out = {"motions": [], "placed": [], "skipped": [], "missing": []}1196 out = {"motions": [], "placed": [], "skipped": [], "missing": []}
1189 changed = False1197 changed = False
1198+ # les transitions posées : l'A-roll s'arrête sous chacune (une pause), une fois tout posé
1199+ pauses: list[tuple[str, dict]] = []
1190 for b in board["beats"]:1200 for b in board["beats"]:
1191 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:1201 if not b.get("on") or (only is not None and b["id"] not in only) or b["id"] in written:
1192 continue1202 continue
@@ -1237,6 +1247,8 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1237 "kind": "image" if kind == "broll" else "transition" if kind == "transition"1247 "kind": "image" if kind == "broll" else "transition" if kind == "transition"
1238 else b["kind"], "idea": b["show"], **_ends(b), "beat": b["id"]})1248 else b["kind"], "idea": b["show"], **_ends(b), "beat": b["id"]})
1239 out["motions"].append(mid)1249 out["motions"].append(mid)
1250+ if kind == "transition":
1251+ pauses.append((mid, b))
1240 sound = sfx.get(b.get("sfx") or "")1252 sound = sfx.get(b.get("sfx") or "")
1241 if sound:1253 if sound:
1242 timeline["audio"].append({"id": next_id("audio"), "src": sound["src"],1254 timeline["audio"].append({"id": next_id("audio"), "src": sound["src"],
@@ -1247,13 +1259,64 @@ def compose(pid: str, only: list[str] | None = None) -> dict:
1247 **({"motion": mid} if mid else {})})1259 **({"motion": mid} if mid else {})})
1248 out["placed"].append(b["id"])1260 out["placed"].append(b["id"])
1249 changed = True1261 changed = True
1262+ shifts = _pause_transitions(pid, timeline, pauses)
1250 if changed:1263 if changed:
1251 for track in ("zooms", "broll", "motion", "audio"):1264 for track in ("zooms", "broll", "motion", "audio"):
1252 timeline[track].sort(key=lambda x: x["at"])1265 timeline[track].sort(key=lambda x: x["at"])
1253- montage.save(pid, timeline)1266+ montage.save(pid, timeline, shifts=shifts)
1254 return out1267 return out
1255 1268
1256 1269
1270+def _gap_cut(words: list[cues.Word], aroll: list[dict], at: float, anchor: str) -> float:
1271+ """
1272+ Où couper l'A-roll pour la transition posée à `at` : dans le silence juste avant le premier mot de la nouvelle
1273+ partie (`anchor`, retrouvé près de `at`), sur une coupe du dérush s'il y en a une dans ce silence, sinon en son
1274+ milieu. Jamais dans le plus long silence des environs : c'est souvent celui d'avant une chute qui clôt la partie
1275+ précédente (« …les compromis qui sont faits. ‖ Et ça, c'est pas bon. »), que la pause couperait de sa phrase.
1276+ Sans ancre retrouvée : `at` + TRANSITION_LEAD (là où make_beat a vu le premier mot).
1277+ """
1278+ toks, owner = autoderush._token_index(words)
1279+ near = next((i for i, o in enumerate(owner) if words[o].start >= at - 1.0), len(toks))
1280+ i = _find(toks, autoderush._tokens(anchor), near - 5, near + 40, near)
1281+ k = owner[i] if i is not None and abs(words[owner[i]].start - at - TRANSITION_LEAD) < 3.0 else None
1282+ if k is None:
1283+ k = next((n for n, w in enumerate(words) if w.start >= at + TRANSITION_LEAD - 0.3), None)
1284+ if k is None:
1285+ return at
1286+ lo = words[k - 1].end if k else 0.0
1287+ hi = words[k].start
1288+ cuts, cursor = [], 0.0
1289+ for c in aroll:
1290+ cuts.append(cursor)
1291+ cursor += c["out"] - c["in"]
1292+ inside = [c for c in cuts if lo - 0.02 <= c <= hi + 0.02]
1293+ return round(min(inside, key=lambda c: abs(c - (lo + hi) / 2)) if inside else (lo + hi) / 2, 3)
1294+
1295+
1296+def _pause_transitions(pid: str, timeline: dict, pauses: list[tuple[str, dict]]) -> list[dict]:
1297+ """
1298+ Les transitions de partie qui viennent d'être posées deviennent des plans de l'A-roll : chacune est calée au milieu
1299+ du silence entre les deux parties, et l'A-roll s'arrête sous elle (montage.pause_motion) ; la voix reprend après.
1300+ Modifie `timeline` ; rend les décalages à reporter sur les beats du storyboard.
1301+ """
1302+ if not pauses or not (src := montage.ensure_pause(pid)):
1303+ return []
1304+ words = timeline_words(service.load(pid), timeline)
1305+ shifts: list[dict] = []
1306+ # de la dernière à la première : une pause ne décale pas celles d'avant
1307+ for mid, b in sorted(pauses, key=lambda p: -p[1]["at"]):
1308+ m = next(x for x in timeline["motion"] if x["id"] == mid)
1309+ delta = _gap_cut(words, timeline["aroll"], m["at"], b.get("anchor") or "") - m["at"]
1310+ m["at"] = round(m["at"] + delta, 3)
1311+ for s in timeline["audio"]:
1312+ if s.get("motion") == mid:
1313+ s["at"] = round(max(0.0, s["at"] + delta), 3)
1314+ done = montage.pause_motion(timeline, mid, src) or []
1315+ # le beat de la transition reste où il est ; ceux d'après suivent la pause
1316+ shifts += [{**s, "at": max(s["at"], round(b["at"] + 0.01, 3))} for s in done]
1317+ return shifts
1318+
1319+
1257 # au-delà, une image agrandie se voit floue à l'écran1320 # au-delà, une image agrandie se voit floue à l'écran
1258 MAX_UPSCALE = 1.151321 MAX_UPSCALE = 1.15
1259 1322
@@ -1351,7 +1414,15 @@ def brief(pid: str, ids: list[str]) -> str:
1351 lines = [f"### {m['id']} · {m['at']:.2f} → {m['at'] + m['dur']:.2f} s ({m['dur']:.1f} s)",1414 lines = [f"### {m['id']} · {m['at']:.2f} → {m['at'] + m['dur']:.2f} s ({m['dur']:.1f} s)",
1352 f"- Il dit : « {b['says']} »",1415 f"- Il dit : « {b['says']} »",
1353 f"- On montre : {b['show']}"]1416 f"- On montre : {b['show']}"]
1354- if kind == "transition":1417+ if kind == "transition" and any(c.get("pause") == m["id"] for c in timeline["aroll"]):
1418+ lines.append("- Transition plein écran entre deux parties de la vidéo, en PAUSE : la voix s'est tue et "
1419+ "l'A-roll est arrêté dessous (image noire, silence) tout le temps du motion, qui est un plan à "
1420+ "part entière. Couvre tout le cadre de la première à la dernière image (pas de marge, le fond "
1421+ "opaque est là dès le début : sinon on voit du noir) ; ce qu'elle affiche se lit d'un coup "
1422+ "d'œil et a le temps de respirer : le spectateur marque un temps avant la partie suivante. "
1423+ "Le titre est en place et lisible tôt (avant le premier tiers) puis reste fixe au moins "
1424+ "1,5 s avant de sortir : pas d'animation qui court jusqu'à la dernière image.")
1425+ elif kind == "transition":
1355 lines.append(f"- Transition plein écran entre deux parties de la vidéo : l'écran est entièrement couvert "1426 lines.append(f"- Transition plein écran entre deux parties de la vidéo : l'écran est entièrement couvert "
1356 f"au milieu (vers {m['dur'] / 2:.2f} s), entrée et sortie rapides et franches ; ce qu'elle "1427 f"au milieu (vers {m['dur'] / 2:.2f} s), entrée et sortie rapides et franches ; ce qu'elle "
1357 "affiche se lit d'un coup d'œil. Ici pas de marge : elle couvre tout le cadre.")1428 "affiche se lit d'un coup d'œil. Ici pas de marge : elle couvre tout le cadre.")
modified backend/studio/modules/projects/storyboard_chat.py +6 -2
@@ -75,8 +75,12 @@ cadre), 2 à 6 s (slow : 5 à 15 s) ; jamais sous un visuel plein écran ;
7575 passe en plein écran ; une image est animée en plein écran ou sur un côté, comme un portrait, selon \
7676 `place`) ; si aucun ne convient, `media` vide et `search` = la recherche à faire pour le trouver ; pour une vidéo, \
7777 `start` = la seconde du média où le plan commence, s'il désigne un passage précis ;
78-- transition : un enchaînement animé plein écran, court, au changement de partie ; `anchor` = les \
79-premiers mots de la nouvelle partie, `show` = ce qu'elle affiche.
78+- transition : un carton animé plein écran au changement de partie ; la vidéo s'arrête dessous \
79+(image noire, voix coupée) environ 3 secondes, le temps de lire le titre, puis la voix reprend sur \
80+`anchor`. `anchor` = les premiers mots de la PREMIÈRE PHRASE de la nouvelle partie, `show` = ce \
81+qu'elle affiche. Ce qui précède doit être une pensée finie : une chute ou une courte conclusion qui \
82+suit (« Et ça, c'est pas bon. ») appartient encore à la partie précédente, la transition vient après \
83+elle ; jamais au milieu d'une phrase, ni entre une question et sa réponse.
8084
8185 L'auteur pose aussi des beats à la main sur la timeline (marqués « posé par l'auteur ») : un passage \
8286 et sa consigne, souvent courte (« une animation ici », « un B-roll »). Quand il te le demande, précise-la \
@@ -75,8 +75,12 @@ cadre), 2 à 6 s (slow : 5 à 15 s) ; jamais sous un visuel plein écran ;
75 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 \
76 `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, \
77 `start` = la seconde du média où le plan commence, s'il désigne un passage précis ;77 `start` = la seconde du média où le plan commence, s'il désigne un passage précis ;
78-- transition : un enchaînement animé plein écran, court, au changement de partie ; `anchor` = les \78+- transition : un carton animé plein écran au changement de partie ; la vidéo s'arrête dessous \
79-premiers mots de la nouvelle partie, `show` = ce qu'elle affiche.79+(image noire, voix coupée) environ 3 secondes, le temps de lire le titre, puis la voix reprend sur \
80+`anchor`. `anchor` = les premiers mots de la PREMIÈRE PHRASE de la nouvelle partie, `show` = ce \
81+qu'elle affiche. Ce qui précède doit être une pensée finie : une chute ou une courte conclusion qui \
82+suit (« Et ça, c'est pas bon. ») appartient encore à la partie précédente, la transition vient après \
83+elle ; jamais au milieu d'une phrase, ni entre une question et sa réponse.
80 84
81 L'auteur pose aussi des beats à la main sur la timeline (marqués « posé par l'auteur ») : un passage \85 L'auteur pose aussi des beats à la main sur la timeline (marqués « posé par l'auteur ») : un passage \
82 et sa consigne, souvent courte (« une animation ici », « un B-roll »). Quand il te le demande, précise-la \86 et sa consigne, souvent courte (« une animation ici », « un B-roll »). Quand il te le demande, précise-la \