hlassiege/darylpublic Fork
⑂ 15da6b5
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

fix(render): split the final pass into chunks with a separate sound mix

A single ffmpeg pass with every B-roll, motion and sound as an input grew to 14 GB and hung. Overlays and texts are now encoded in one-minute chunks cut between shots, the sounds mixed once on their own, then everything is joined without re-encoding the picture. ffmpeg is stopped with an error when it stops making progress.
hlassiege committed 2026-10-11T21:35:11+02:00 Browse files
15da6b5 parent: 2886ebe
modified backend/studio/modules/projects/render.py +139 -27
@@ -6,21 +6,25 @@ Deux passes :
66 enchaîné autour d'une coupe), à son volume, recadrée par les zooms comme dans l'aperçu, encodée à part en qualité haute ;
77 les morceaux sont recollés sans réencodage ;
88 2. par-dessus, le B-roll puis les motions (.mov avec transparence), puis les textes écrits à la main
9- (dessinés par ffmpeg, dans la police de l'aperçu), et sous la voix les sons et la musique : un seul
10- encodage final.
9+ (dessinés par ffmpeg, dans la police de l'aperçu) : encodés une fois, par morceaux d'une minute coupés
10+ entre deux plans ; sous la voix, les sons et la musique, mixés à part sur toute la durée ; le tout recollé
11+ sans réencodage de l'image.
1112
1213 Tout tourne à la cadence de la vidéo source et les bornes des morceaux tombent sur des images :
1314 rien ne glisse d'un morceau à l'autre.
1415
1516 rendu/<qualité>.mp4 le dernier rendu de chaque qualité
16- rendu/.tmp-<job>/ les morceaux, effacés à la fin
17+ rendu/.tmp-<job>/ les morceaux et le mix, effacés à la fin
1718 """
1819 import hashlib
1920 import json
21+import math
2022 import os
2123 import re
2224 import shutil
2325 import subprocess
26+import threading
27+import time
2428 from fractions import Fraction
2529 from pathlib import Path
2630 from typing import Callable
@@ -34,8 +38,13 @@ RENDER_DIR = "rendu"
3438 KIND = "project_long_render"
3539 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}
3640 AUDIO_RATE = 48000
37-# part de la première passe (A-roll) dans l'avancement
41+# part de la première passe (A-roll) puis du mix des sons dans l'avancement
3842 AROLL_SHARE = 0.5
43+MIX_SHARE = 0.05
44+# la passe finale par morceaux de cette durée (s), à peu près : chacun n'ouvre que les médias qui le concernent
45+CHUNK_SECONDS = 60
46+# ffmpeg arrêté s'il n'avance plus depuis ce temps (s) : un job en erreur plutôt qu'un job qui tourne à vide
47+STALL_SECONDS = 300
3948 # ce que `ffmpeg -progress` écrit : tout le reste est un message d'erreur
4049 # les textes : l'écriture manuscrite de Windows, celle de l'aperçu (font-family « Ink Free »), avec un contour
4150 # noir (fraction de la taille du texte) et un fondu à l'entrée et à la sortie
@@ -329,6 +338,7 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
329338 "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]]
330339 if seg["fade"] is not None:
331340 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]
341+
332342 prep = f"fps={rate},setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}"
333343 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
334344 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
@@ -402,13 +412,54 @@ def overlay_groups(overlays: list[dict], fps: Fraction) -> list[list[dict]]:
402412 return groups
403413
404414
405-def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
406- """Le B-roll, les motions et les sons par-dessus l'A-roll, encodés une fois pour toutes."""
415+def _group_span(group: list[dict]) -> tuple[float, float]:
416+ """Le temps qu'occupe un groupe de plans sur la timeline, prolongations comprises."""
417+ return group[0]["at"] - group[0]["pre"], max(o["at"] + o["len"] + o["post"] for o in group)
418+
419+
420+def chunks(p: dict, total: int) -> list[tuple[int, int]]:
421+ """
422+ La passe finale en morceaux d'environ CHUNK_SECONDS, bornes en images de la timeline, coupés là où rien
423+ n'est posé sur l'A-roll (ni plan, ni texte) : chaque morceau n'ouvre que les quelques médias qui le
424+ concernent. En un seul bloc, des centaines d'entrées font gonfler ffmpeg jusqu'à se bloquer.
425+ """
426+ fps = Fraction(p["fps_num"], p["fps_den"])
427+ spans = [_group_span(g) for g in overlay_groups(p["overlays"], fps)]
428+ spans += [(x["at"], x["at"] + x["dur"]) for x in p.get("texts") or []]
429+ # une image de marge de part et d'autre : une coupe tombe au plus près sur le bord d'un plan
430+ busy = sorted((math.floor(a * fps) - 1, math.ceil(b * fps) + 1) for a, b in spans)
431+ size = round(CHUNK_SECONDS * fps)
432+ out, start = [], 0
433+ while start < total:
434+ cut = start + size
435+ for a, b in busy:
436+ if a < cut < b:
437+ cut = b
438+ if total - cut < size / 2:
439+ cut = total
440+ out.append((start, cut))
441+ start = cut
442+ return out
443+
444+
445+def chunk_args(p: dict, aroll: Path, span: tuple[int, int], dest: Path, script: Path) -> list[str]:
446+ """
447+ Un morceau de la passe finale : le B-roll, les motions et les textes par-dessus l'A-roll, sans le son
448+ (mixé à part, sur toute la durée : mix_args).
449+ """
407450 fps = Fraction(p["fps_num"], p["fps_den"])
408451 rate = f"{p['fps_num']}/{p['fps_den']}"
409452 w, h = p["width"], p["height"]
410- args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-i", str(aroll)]
411- graph, video, sounds = [], "[0:v]", ["[0:a]"]
453+ first, last = span
454+ t0, frames = float(first / fps), last - first
455+ # lu un peu avant, puis coupé à la demi-image près : l'A-roll est horodaté à la milliseconde
456+ seek = max(0.0, t0 - 1)
457+ lo, hi = float((first - Fraction(1, 2)) / fps) - seek, float((last - Fraction(1, 2)) / fps) - seek
458+ args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
459+ "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-i", str(aroll)]
460+ # puis recalé sur la cadence exacte : des horodatages arrondis font perdre une image à l'encodage
461+ graph = [f"[0:v]trim=start={max(lo, 0):.6f}:end={hi:.6f},setpts=N*{p['fps_den']}/{p['fps_num']}/TB[base]"]
462+ video = "[base]"
412463 index = 1
413464 # xfade veut deux entrées de même format, même cadence, même base de temps
414465 norm = f"settb=expr={p['fps_den']}/{p['fps_num']},fps={rate}"
@@ -422,7 +473,9 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
422473 return f"[c{clear}]"
423474
424475 for g, group in enumerate(overlay_groups(p["overlays"], fps)):
425- group = [o for o in group if media.probe(Path(o["path"])).get("width")]
476+ if not first <= round(_group_span(group)[0] * fps) < last:
477+ continue
478+ group = [{**o, "at": o["at"] - t0} for o in group if media.probe(Path(o["path"])).get("width")]
426479 if not group:
427480 continue
428481 plain = len(group) == 1 and not group[0]["enter"] and not group[0]["exit"]
@@ -454,26 +507,38 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
454507 stream, length = f"[x{index}]", offset + total
455508 index += 1
456509 if not plain:
457- first, last = group[0], group[-1]
458- if first["enter"]:
459- d = first["enter"]["dur"]
460- graph.append(f"{transparent(d)}{stream}xfade=transition={first['enter']['fx']}:"
510+ head, tail = group[0], group[-1]
511+ if head["enter"]:
512+ d = head["enter"]["dur"]
513+ graph.append(f"{transparent(d)}{stream}xfade=transition={head['enter']['fx']}:"
461514 f"duration={d:.6f}:offset=0[e{g}]")
462515 stream = f"[e{g}]"
463- if last["exit"]:
464- d = last["exit"]["dur"]
465- graph.append(f"{stream}{transparent(d)}xfade=transition={last['exit']['fx']}:"
516+ if tail["exit"]:
517+ d = tail["exit"]["dur"]
518+ graph.append(f"{stream}{transparent(d)}xfade=transition={tail['exit']['fx']}:"
466519 f"duration={d:.6f}:offset={length - d:.6f}[q{g}]")
467520 stream = f"[q{g}]"
468521 graph.append(f"{stream}setpts=PTS-STARTPTS+{start:.6f}/TB[o{g}]")
469522 graph.append(f"{video}[o{g}]overlay=eof_action=pass:format=auto[v{g}]")
470523 video = f"[v{g}]"
471524 for n, x in enumerate(p.get("texts") or []):
472- graph.append(text_layer(x, n, w, h, rate, p["font"], script.parent))
525+ if not first <= round(x["at"] * fps) < last:
526+ continue
527+ graph.append(text_layer({**x, "at": x["at"] - t0}, n, w, h, rate, p["font"], script.parent))
473528 # le texte est dessiné au centre d'un calque transparent de la taille de l'image, qu'on décale à sa place
474529 graph.append(f"{video}[t{n}]overlay=x={round((x['x'] - 0.5) * w)}:y={round((x['y'] - 0.5) * h)}:"
475530 f"eof_action=pass:format=auto[w{n}]")
476531 video = f"[w{n}]"
532+ graph.append(f"{video}format=yuv420p[vout]")
533+ script.write_text(";\n".join(graph), encoding="utf-8")
534+ return args + ["-filter_complex_script", str(script), "-map", "[vout]", "-frames:v", str(frames), "-r", rate,
535+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", str(dest)]
536+
537+
538+def mix_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
539+ """Les sons et la musique sous la voix de l'A-roll, sur toute la durée, en une passe (audio seul : rapide)."""
540+ args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-i", str(aroll)]
541+ graph, sounds, index = [], ["[0:a]"], 1
477542 # un fichier son n'est ouvert qu'une fois, puis découpé dans le graphe : un même whoosh placé des dizaines de
478543 # fois ferait sinon autant d'entrées, et la ligne de commande dépasserait la limite de Windows (32 767 caractères)
479544 uses: dict[str, list[dict]] = {}
@@ -494,15 +559,22 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
494559 f"volume={s['gain_db']}dB,adelay=delays={round(s['at'] * 1000)}:all=1{label}")
495560 sounds.append(label)
496561 index += 1
497- graph.append(f"{video}format=yuv420p[vout]")
498562 if len(sounds) > 1:
499563 graph.append(f"{''.join(sounds)}amix=inputs={len(sounds)}:duration=first:normalize=0:dropout_transition=0[aout]")
500564 else:
501565 graph.append("[0:a]anull[aout]")
502566 script.write_text(";\n".join(graph), encoding="utf-8")
503- return args + ["-filter_complex_script", str(script), "-map", "[vout]", "-map", "[aout]", "-r", rate,
504- "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
505- "-c:a", "aac", "-b:a", "320k", "-ar", str(AUDIO_RATE), "-movflags", "+faststart", str(dest)]
567+ # en flottant : ce qui dépasse 0 dB n'est écrêté qu'à l'encodage final
568+ return args + ["-filter_complex_script", str(script), "-map", "[aout]", "-c:a", "pcm_f32le",
569+ "-ar", str(AUDIO_RATE), str(dest)]
570+
571+
572+def mux_args(listing: Path, mix: Path, dest: Path) -> list[str]:
573+ """Les morceaux recollés sans réencodage, avec le son mixé : le .mp4 final."""
574+ return [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
575+ "-f", "concat", "-safe", "0", "-i", str(listing), "-i", str(mix), "-map", "0:v", "-map", "1:a",
576+ "-c:v", "copy", "-c:a", "aac", "-b:a", "320k", "-ar", str(AUDIO_RATE), "-movflags", "+faststart",
577+ str(dest)]
506578
507579
508580 def _filter_path(path: Path) -> str:
@@ -532,20 +604,40 @@ def text_layer(x: dict, n: int, w: int, h: int, rate: str, font: str, folder: Pa
532604
533605
534606 def _run(args: list[str], on_time: Callable[[float], None], check_cancel: Callable[[], None]) -> None:
535- """ffmpeg avec son avancement (-progress) ; arrêté net si le job est annulé."""
607+ """
608+ ffmpeg avec son avancement (-progress) ; arrêté net si le job est annulé, ou s'il n'avance plus depuis
609+ STALL_SECONDS (il continue d'écrire son avancement, figé : on surveille le temps produit).
610+ """
536611 proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
537612 text=True, encoding="utf-8", errors="replace")
538613 tail: list[str] = []
614+ moved = time.monotonic()
615+ stalled = threading.Event()
616+
617+ def watch() -> None:
618+ while proc.poll() is None:
619+ if time.monotonic() - moved > STALL_SECONDS:
620+ stalled.set()
621+ proc.kill()
622+ return
623+ time.sleep(5)
624+
625+ threading.Thread(target=watch, daemon=True).start()
539626 try:
627+ done = -1
540628 for line in proc.stdout:
541629 check_cancel()
542630 line = line.strip()
543631 key, _, value = line.partition("=")
544632 if key == "out_time_us" and value.isdigit():
633+ if int(value) > done:
634+ done, moved = int(value), time.monotonic()
545635 on_time(int(value) / 1e6)
546636 elif line and key not in PROGRESS_KEYS and not key.startswith("stream_"):
547637 # le reste : les messages d'erreur de ffmpeg
548638 tail = (tail + [line])[-10:]
639+ if stalled.is_set():
640+ raise RuntimeError(f"ffmpeg n'avance plus depuis {STALL_SECONDS // 60} min : arrêté")
549641 if proc.wait() != 0:
550642 raise RuntimeError("ffmpeg : " + " | ".join(tail)[-400:])
551643 finally:
@@ -554,6 +646,12 @@ def _run(args: list[str], on_time: Callable[[float], None], check_cancel: Callab
554646 proc.wait()
555647
556648
649+def _listing(path: Path, files: list[Path]) -> Path:
650+ """La liste des morceaux à recoller, pour le démuxeur concat de ffmpeg."""
651+ path.write_text("".join(f"file '{f.as_posix()}'\n" for f in files), encoding="utf-8")
652+ return path
653+
654+
557655 def run(ctx: TaskContext) -> None:
558656 p = ctx.payload
559657 fps = Fraction(p["fps_num"], p["fps_den"])
@@ -585,16 +683,30 @@ def run(ctx: TaskContext) -> None:
585683
586684 _run(segment_args(p, seg, file), on_time, ctx.check_cancel)
587685 files.append(file)
588- listing = tmp / "aroll.txt"
589- listing.write_text("".join(f"file '{f.as_posix()}'\n" for f in files), encoding="utf-8")
686+ listing = _listing(tmp / "aroll.txt", files)
590687 aroll = tmp / "aroll.mkv"
591688 progress(AROLL_SHARE, "assemblage de l'A-roll")
592689 _run([tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-f", "concat", "-safe", "0",
593690 "-i", str(listing), "-c", "copy", str(aroll)], lambda t: None, ctx.check_cancel)
691+ mix = tmp / "mix.wav"
692+ _run(mix_args(p, aroll, mix, tmp / "mix.txt"),
693+ lambda t: progress(AROLL_SHARE + min(t / duration, 1.0) * MIX_SHARE, "mix des sons"), ctx.check_cancel)
694+ parts = chunks(p, total)
695+ done_share = AROLL_SHARE + MIX_SHARE
696+ files = []
697+ for i, span in enumerate(parts):
698+ file = tmp / f"final-{i:04d}.mkv"
699+ done = span[0] / fps
700+
701+ def on_time(t: float, done=done) -> None:
702+ progress(done_share + min(float(done) + t, duration) / duration * (1 - done_share),
703+ f"B-roll, motions et textes · morceau {i + 1}/{len(parts)}")
704+
705+ _run(chunk_args(p, aroll, span, file, tmp / f"final-{i:04d}.txt"), on_time, ctx.check_cancel)
706+ files.append(file)
594707 partial = tmp / "final.mp4"
595- _run(final_args(p, aroll, partial, tmp / "final.txt"),
596- lambda t: progress(AROLL_SHARE + min(t / duration, 1.0) * (1 - AROLL_SHARE), "B-roll, motions et son"),
597- ctx.check_cancel)
708+ progress(0.99, "assemblage final")
709+ _run(mux_args(_listing(tmp / "final.txt", files), mix, partial), lambda t: None, ctx.check_cancel)
598710 partial.replace(dest)
599711 ctx.state["stage"] = "terminé"
600712 finally:
@@ -6,21 +6,25 @@ Deux passes :
6 enchaîné autour d'une coupe), à son volume, recadrée par les zooms comme dans l'aperçu, encodée à part en qualité haute ;6 enchaîné autour d'une coupe), à son volume, recadrée par les zooms comme dans l'aperçu, encodée à part en qualité haute ;
7 les morceaux sont recollés sans réencodage ;7 les morceaux sont recollés sans réencodage ;
8 2. par-dessus, le B-roll puis les motions (.mov avec transparence), puis les textes écrits à la main8 2. par-dessus, le B-roll puis les motions (.mov avec transparence), puis les textes écrits à la main
9- (dessinés par ffmpeg, dans la police de l'aperçu), et sous la voix les sons et la musique : un seul9+ (dessinés par ffmpeg, dans la police de l'aperçu) : encodés une fois, par morceaux d'une minute coupés
10- encodage final.10+ entre deux plans ; sous la voix, les sons et la musique, mixés à part sur toute la durée ; le tout recollé
11+ sans réencodage de l'image.
11 12
12 Tout tourne à la cadence de la vidéo source et les bornes des morceaux tombent sur des images :13 Tout tourne à la cadence de la vidéo source et les bornes des morceaux tombent sur des images :
13 rien ne glisse d'un morceau à l'autre.14 rien ne glisse d'un morceau à l'autre.
14 15
15 rendu/<qualité>.mp4 le dernier rendu de chaque qualité16 rendu/<qualité>.mp4 le dernier rendu de chaque qualité
16- rendu/.tmp-<job>/ les morceaux, effacés à la fin17+ rendu/.tmp-<job>/ les morceaux et le mix, effacés à la fin
17 """18 """
18 import hashlib19 import hashlib
19 import json20 import json
21+import math
20 import os22 import os
21 import re23 import re
22 import shutil24 import shutil
23 import subprocess25 import subprocess
26+import threading
27+import time
24 from fractions import Fraction28 from fractions import Fraction
25 from pathlib import Path29 from pathlib import Path
26 from typing import Callable30 from typing import Callable
@@ -34,8 +38,13 @@ RENDER_DIR = "rendu"
34 KIND = "project_long_render"38 KIND = "project_long_render"
35 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}39 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}
36 AUDIO_RATE = 4800040 AUDIO_RATE = 48000
37-# part de la première passe (A-roll) dans l'avancement41+# part de la première passe (A-roll) puis du mix des sons dans l'avancement
38 AROLL_SHARE = 0.542 AROLL_SHARE = 0.5
43+MIX_SHARE = 0.05
44+# la passe finale par morceaux de cette durée (s), à peu près : chacun n'ouvre que les médias qui le concernent
45+CHUNK_SECONDS = 60
46+# ffmpeg arrêté s'il n'avance plus depuis ce temps (s) : un job en erreur plutôt qu'un job qui tourne à vide
47+STALL_SECONDS = 300
39 # ce que `ffmpeg -progress` écrit : tout le reste est un message d'erreur48 # ce que `ffmpeg -progress` écrit : tout le reste est un message d'erreur
40 # les textes : l'écriture manuscrite de Windows, celle de l'aperçu (font-family « Ink Free »), avec un contour49 # les textes : l'écriture manuscrite de Windows, celle de l'aperçu (font-family « Ink Free »), avec un contour
41 # noir (fraction de la taille du texte) et un fondu à l'entrée et à la sortie50 # noir (fraction de la taille du texte) et un fondu à l'entrée et à la sortie
@@ -329,6 +338,7 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
329 "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]]338 "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]]
330 if seg["fade"] is not None:339 if seg["fade"] is not None:
331 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]340 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]
341+
332 prep = f"fps={rate},setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}"342 prep = f"fps={rate},setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}"
333 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"343 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
334 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")344 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
@@ -402,13 +412,54 @@ def overlay_groups(overlays: list[dict], fps: Fraction) -> list[list[dict]]:
402 return groups412 return groups
403 413
404 414
405-def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:415+def _group_span(group: list[dict]) -> tuple[float, float]:
406- """Le B-roll, les motions et les sons par-dessus l'A-roll, encodés une fois pour toutes."""416+ """Le temps qu'occupe un groupe de plans sur la timeline, prolongations comprises."""
417+ return group[0]["at"] - group[0]["pre"], max(o["at"] + o["len"] + o["post"] for o in group)
418+
419+
420+def chunks(p: dict, total: int) -> list[tuple[int, int]]:
421+ """
422+ La passe finale en morceaux d'environ CHUNK_SECONDS, bornes en images de la timeline, coupés là où rien
423+ n'est posé sur l'A-roll (ni plan, ni texte) : chaque morceau n'ouvre que les quelques médias qui le
424+ concernent. En un seul bloc, des centaines d'entrées font gonfler ffmpeg jusqu'à se bloquer.
425+ """
426+ fps = Fraction(p["fps_num"], p["fps_den"])
427+ spans = [_group_span(g) for g in overlay_groups(p["overlays"], fps)]
428+ spans += [(x["at"], x["at"] + x["dur"]) for x in p.get("texts") or []]
429+ # une image de marge de part et d'autre : une coupe tombe au plus près sur le bord d'un plan
430+ busy = sorted((math.floor(a * fps) - 1, math.ceil(b * fps) + 1) for a, b in spans)
431+ size = round(CHUNK_SECONDS * fps)
432+ out, start = [], 0
433+ while start < total:
434+ cut = start + size
435+ for a, b in busy:
436+ if a < cut < b:
437+ cut = b
438+ if total - cut < size / 2:
439+ cut = total
440+ out.append((start, cut))
441+ start = cut
442+ return out
443+
444+
445+def chunk_args(p: dict, aroll: Path, span: tuple[int, int], dest: Path, script: Path) -> list[str]:
446+ """
447+ Un morceau de la passe finale : le B-roll, les motions et les textes par-dessus l'A-roll, sans le son
448+ (mixé à part, sur toute la durée : mix_args).
449+ """
407 fps = Fraction(p["fps_num"], p["fps_den"])450 fps = Fraction(p["fps_num"], p["fps_den"])
408 rate = f"{p['fps_num']}/{p['fps_den']}"451 rate = f"{p['fps_num']}/{p['fps_den']}"
409 w, h = p["width"], p["height"]452 w, h = p["width"], p["height"]
410- args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-i", str(aroll)]453+ first, last = span
411- graph, video, sounds = [], "[0:v]", ["[0:a]"]454+ t0, frames = float(first / fps), last - first
455+ # lu un peu avant, puis coupé à la demi-image près : l'A-roll est horodaté à la milliseconde
456+ seek = max(0.0, t0 - 1)
457+ lo, hi = float((first - Fraction(1, 2)) / fps) - seek, float((last - Fraction(1, 2)) / fps) - seek
458+ args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
459+ "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-i", str(aroll)]
460+ # puis recalé sur la cadence exacte : des horodatages arrondis font perdre une image à l'encodage
461+ graph = [f"[0:v]trim=start={max(lo, 0):.6f}:end={hi:.6f},setpts=N*{p['fps_den']}/{p['fps_num']}/TB[base]"]
462+ video = "[base]"
412 index = 1463 index = 1
413 # xfade veut deux entrées de même format, même cadence, même base de temps464 # xfade veut deux entrées de même format, même cadence, même base de temps
414 norm = f"settb=expr={p['fps_den']}/{p['fps_num']},fps={rate}"465 norm = f"settb=expr={p['fps_den']}/{p['fps_num']},fps={rate}"
@@ -422,7 +473,9 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
422 return f"[c{clear}]"473 return f"[c{clear}]"
423 474
424 for g, group in enumerate(overlay_groups(p["overlays"], fps)):475 for g, group in enumerate(overlay_groups(p["overlays"], fps)):
425- group = [o for o in group if media.probe(Path(o["path"])).get("width")]476+ if not first <= round(_group_span(group)[0] * fps) < last:
477+ continue
478+ group = [{**o, "at": o["at"] - t0} for o in group if media.probe(Path(o["path"])).get("width")]
426 if not group:479 if not group:
427 continue480 continue
428 plain = len(group) == 1 and not group[0]["enter"] and not group[0]["exit"]481 plain = len(group) == 1 and not group[0]["enter"] and not group[0]["exit"]
@@ -454,26 +507,38 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
454 stream, length = f"[x{index}]", offset + total507 stream, length = f"[x{index}]", offset + total
455 index += 1508 index += 1
456 if not plain:509 if not plain:
457- first, last = group[0], group[-1]510+ head, tail = group[0], group[-1]
458- if first["enter"]:511+ if head["enter"]:
459- d = first["enter"]["dur"]512+ d = head["enter"]["dur"]
460- graph.append(f"{transparent(d)}{stream}xfade=transition={first['enter']['fx']}:"513+ graph.append(f"{transparent(d)}{stream}xfade=transition={head['enter']['fx']}:"
461 f"duration={d:.6f}:offset=0[e{g}]")514 f"duration={d:.6f}:offset=0[e{g}]")
462 stream = f"[e{g}]"515 stream = f"[e{g}]"
463- if last["exit"]:516+ if tail["exit"]:
464- d = last["exit"]["dur"]517+ d = tail["exit"]["dur"]
465- graph.append(f"{stream}{transparent(d)}xfade=transition={last['exit']['fx']}:"518+ graph.append(f"{stream}{transparent(d)}xfade=transition={tail['exit']['fx']}:"
466 f"duration={d:.6f}:offset={length - d:.6f}[q{g}]")519 f"duration={d:.6f}:offset={length - d:.6f}[q{g}]")
467 stream = f"[q{g}]"520 stream = f"[q{g}]"
468 graph.append(f"{stream}setpts=PTS-STARTPTS+{start:.6f}/TB[o{g}]")521 graph.append(f"{stream}setpts=PTS-STARTPTS+{start:.6f}/TB[o{g}]")
469 graph.append(f"{video}[o{g}]overlay=eof_action=pass:format=auto[v{g}]")522 graph.append(f"{video}[o{g}]overlay=eof_action=pass:format=auto[v{g}]")
470 video = f"[v{g}]"523 video = f"[v{g}]"
471 for n, x in enumerate(p.get("texts") or []):524 for n, x in enumerate(p.get("texts") or []):
472- graph.append(text_layer(x, n, w, h, rate, p["font"], script.parent))525+ if not first <= round(x["at"] * fps) < last:
526+ continue
527+ graph.append(text_layer({**x, "at": x["at"] - t0}, n, w, h, rate, p["font"], script.parent))
473 # le texte est dessiné au centre d'un calque transparent de la taille de l'image, qu'on décale à sa place528 # le texte est dessiné au centre d'un calque transparent de la taille de l'image, qu'on décale à sa place
474 graph.append(f"{video}[t{n}]overlay=x={round((x['x'] - 0.5) * w)}:y={round((x['y'] - 0.5) * h)}:"529 graph.append(f"{video}[t{n}]overlay=x={round((x['x'] - 0.5) * w)}:y={round((x['y'] - 0.5) * h)}:"
475 f"eof_action=pass:format=auto[w{n}]")530 f"eof_action=pass:format=auto[w{n}]")
476 video = f"[w{n}]"531 video = f"[w{n}]"
532+ graph.append(f"{video}format=yuv420p[vout]")
533+ script.write_text(";\n".join(graph), encoding="utf-8")
534+ return args + ["-filter_complex_script", str(script), "-map", "[vout]", "-frames:v", str(frames), "-r", rate,
535+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", str(dest)]
536+
537+
538+def mix_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
539+ """Les sons et la musique sous la voix de l'A-roll, sur toute la durée, en une passe (audio seul : rapide)."""
540+ args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-i", str(aroll)]
541+ graph, sounds, index = [], ["[0:a]"], 1
477 # un fichier son n'est ouvert qu'une fois, puis découpé dans le graphe : un même whoosh placé des dizaines de542 # un fichier son n'est ouvert qu'une fois, puis découpé dans le graphe : un même whoosh placé des dizaines de
478 # fois ferait sinon autant d'entrées, et la ligne de commande dépasserait la limite de Windows (32 767 caractères)543 # fois ferait sinon autant d'entrées, et la ligne de commande dépasserait la limite de Windows (32 767 caractères)
479 uses: dict[str, list[dict]] = {}544 uses: dict[str, list[dict]] = {}
@@ -494,15 +559,22 @@ def final_args(p: dict, aroll: Path, dest: Path, script: Path) -> list[str]:
494 f"volume={s['gain_db']}dB,adelay=delays={round(s['at'] * 1000)}:all=1{label}")559 f"volume={s['gain_db']}dB,adelay=delays={round(s['at'] * 1000)}:all=1{label}")
495 sounds.append(label)560 sounds.append(label)
496 index += 1561 index += 1
497- graph.append(f"{video}format=yuv420p[vout]")
498 if len(sounds) > 1:562 if len(sounds) > 1:
499 graph.append(f"{''.join(sounds)}amix=inputs={len(sounds)}:duration=first:normalize=0:dropout_transition=0[aout]")563 graph.append(f"{''.join(sounds)}amix=inputs={len(sounds)}:duration=first:normalize=0:dropout_transition=0[aout]")
500 else:564 else:
501 graph.append("[0:a]anull[aout]")565 graph.append("[0:a]anull[aout]")
502 script.write_text(";\n".join(graph), encoding="utf-8")566 script.write_text(";\n".join(graph), encoding="utf-8")
503- return args + ["-filter_complex_script", str(script), "-map", "[vout]", "-map", "[aout]", "-r", rate,567+ # en flottant : ce qui dépasse 0 dB n'est écrêté qu'à l'encodage final
504- "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",568+ return args + ["-filter_complex_script", str(script), "-map", "[aout]", "-c:a", "pcm_f32le",
505- "-c:a", "aac", "-b:a", "320k", "-ar", str(AUDIO_RATE), "-movflags", "+faststart", str(dest)]569+ "-ar", str(AUDIO_RATE), str(dest)]
570+
571+
572+def mux_args(listing: Path, mix: Path, dest: Path) -> list[str]:
573+ """Les morceaux recollés sans réencodage, avec le son mixé : le .mp4 final."""
574+ return [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
575+ "-f", "concat", "-safe", "0", "-i", str(listing), "-i", str(mix), "-map", "0:v", "-map", "1:a",
576+ "-c:v", "copy", "-c:a", "aac", "-b:a", "320k", "-ar", str(AUDIO_RATE), "-movflags", "+faststart",
577+ str(dest)]
506 578
507 579
508 def _filter_path(path: Path) -> str:580 def _filter_path(path: Path) -> str:
@@ -532,20 +604,40 @@ def text_layer(x: dict, n: int, w: int, h: int, rate: str, font: str, folder: Pa
532 604
533 605
534 def _run(args: list[str], on_time: Callable[[float], None], check_cancel: Callable[[], None]) -> None:606 def _run(args: list[str], on_time: Callable[[float], None], check_cancel: Callable[[], None]) -> None:
535- """ffmpeg avec son avancement (-progress) ; arrêté net si le job est annulé."""607+ """
608+ ffmpeg avec son avancement (-progress) ; arrêté net si le job est annulé, ou s'il n'avance plus depuis
609+ STALL_SECONDS (il continue d'écrire son avancement, figé : on surveille le temps produit).
610+ """
536 proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,611 proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
537 text=True, encoding="utf-8", errors="replace")612 text=True, encoding="utf-8", errors="replace")
538 tail: list[str] = []613 tail: list[str] = []
614+ moved = time.monotonic()
615+ stalled = threading.Event()
616+
617+ def watch() -> None:
618+ while proc.poll() is None:
619+ if time.monotonic() - moved > STALL_SECONDS:
620+ stalled.set()
621+ proc.kill()
622+ return
623+ time.sleep(5)
624+
625+ threading.Thread(target=watch, daemon=True).start()
539 try:626 try:
627+ done = -1
540 for line in proc.stdout:628 for line in proc.stdout:
541 check_cancel()629 check_cancel()
542 line = line.strip()630 line = line.strip()
543 key, _, value = line.partition("=")631 key, _, value = line.partition("=")
544 if key == "out_time_us" and value.isdigit():632 if key == "out_time_us" and value.isdigit():
633+ if int(value) > done:
634+ done, moved = int(value), time.monotonic()
545 on_time(int(value) / 1e6)635 on_time(int(value) / 1e6)
546 elif line and key not in PROGRESS_KEYS and not key.startswith("stream_"):636 elif line and key not in PROGRESS_KEYS and not key.startswith("stream_"):
547 # le reste : les messages d'erreur de ffmpeg637 # le reste : les messages d'erreur de ffmpeg
548 tail = (tail + [line])[-10:]638 tail = (tail + [line])[-10:]
639+ if stalled.is_set():
640+ raise RuntimeError(f"ffmpeg n'avance plus depuis {STALL_SECONDS // 60} min : arrêté")
549 if proc.wait() != 0:641 if proc.wait() != 0:
550 raise RuntimeError("ffmpeg : " + " | ".join(tail)[-400:])642 raise RuntimeError("ffmpeg : " + " | ".join(tail)[-400:])
551 finally:643 finally:
@@ -554,6 +646,12 @@ def _run(args: list[str], on_time: Callable[[float], None], check_cancel: Callab
554 proc.wait()646 proc.wait()
555 647
556 648
649+def _listing(path: Path, files: list[Path]) -> Path:
650+ """La liste des morceaux à recoller, pour le démuxeur concat de ffmpeg."""
651+ path.write_text("".join(f"file '{f.as_posix()}'\n" for f in files), encoding="utf-8")
652+ return path
653+
654+
557 def run(ctx: TaskContext) -> None:655 def run(ctx: TaskContext) -> None:
558 p = ctx.payload656 p = ctx.payload
559 fps = Fraction(p["fps_num"], p["fps_den"])657 fps = Fraction(p["fps_num"], p["fps_den"])
@@ -585,16 +683,30 @@ def run(ctx: TaskContext) -> None:
585 683
586 _run(segment_args(p, seg, file), on_time, ctx.check_cancel)684 _run(segment_args(p, seg, file), on_time, ctx.check_cancel)
587 files.append(file)685 files.append(file)
588- listing = tmp / "aroll.txt"686+ listing = _listing(tmp / "aroll.txt", files)
589- listing.write_text("".join(f"file '{f.as_posix()}'\n" for f in files), encoding="utf-8")
590 aroll = tmp / "aroll.mkv"687 aroll = tmp / "aroll.mkv"
591 progress(AROLL_SHARE, "assemblage de l'A-roll")688 progress(AROLL_SHARE, "assemblage de l'A-roll")
592 _run([tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-f", "concat", "-safe", "0",689 _run([tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", "-f", "concat", "-safe", "0",
593 "-i", str(listing), "-c", "copy", str(aroll)], lambda t: None, ctx.check_cancel)690 "-i", str(listing), "-c", "copy", str(aroll)], lambda t: None, ctx.check_cancel)
691+ mix = tmp / "mix.wav"
692+ _run(mix_args(p, aroll, mix, tmp / "mix.txt"),
693+ lambda t: progress(AROLL_SHARE + min(t / duration, 1.0) * MIX_SHARE, "mix des sons"), ctx.check_cancel)
694+ parts = chunks(p, total)
695+ done_share = AROLL_SHARE + MIX_SHARE
696+ files = []
697+ for i, span in enumerate(parts):
698+ file = tmp / f"final-{i:04d}.mkv"
699+ done = span[0] / fps
700+
701+ def on_time(t: float, done=done) -> None:
702+ progress(done_share + min(float(done) + t, duration) / duration * (1 - done_share),
703+ f"B-roll, motions et textes · morceau {i + 1}/{len(parts)}")
704+
705+ _run(chunk_args(p, aroll, span, file, tmp / f"final-{i:04d}.txt"), on_time, ctx.check_cancel)
706+ files.append(file)
594 partial = tmp / "final.mp4"707 partial = tmp / "final.mp4"
595- _run(final_args(p, aroll, partial, tmp / "final.txt"),708+ progress(0.99, "assemblage final")
596- lambda t: progress(AROLL_SHARE + min(t / duration, 1.0) * (1 - AROLL_SHARE), "B-roll, motions et son"),709+ _run(mux_args(_listing(tmp / "final.txt", files), mix, partial), lambda t: None, ctx.check_cancel)
597- ctx.check_cancel)
598 partial.replace(dest)710 partial.replace(dest)
599 ctx.state["stage"] = "terminé"711 ctx.state["stage"] = "terminé"
600 finally:712 finally: