hlassiege/darylpublic Fork
⑂ 079a7ba
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): normalize A-roll segments to bt709 and frame-aligned timestamps

An inserted SD clip (bt601) made ffmpeg reinitialize its filters mid-pass and drop frames, so the picture fell behind the sound. Clips are converted to bt709, timestamps reset before the frame rate conversion so segments join without gaps, and filter reinit is disabled on the A-roll in the final pass.
hlassiege committed 2026-10-11T21:35:11+02:00 Browse files
079a7ba parent: 15da6b5
modified backend/studio/modules/projects/media.py +3 -2
@@ -10,7 +10,7 @@ from ...core.system import tool
1010
1111
1212 def probe(path: Path) -> dict:
13- """{duration, width, height, fps, fps_num, fps_den, audio, timecode, shot_at} ; vide si ffprobe échoue."""
13+ """{duration, width, height, fps, fps_num, fps_den, colorspace, audio, timecode, shot_at} ; vide si ffprobe échoue."""
1414 try:
1515 out = subprocess.run(
1616 [tool("ffprobe"), "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
@@ -32,7 +32,8 @@ def probe(path: Path) -> dict:
3232 num, _, den = (video.get("r_frame_rate") or "25/1").partition("/")
3333 num, den = int(num or 25), int(den or 1)
3434 info.update(width=video.get("width"), height=video.get("height"),
35- fps=round(num / den, 3) if den else 25, fps_num=num, fps_den=den or 1)
35+ fps=round(num / den, 3) if den else 25, fps_num=num, fps_den=den or 1,
36+ colorspace=video.get("color_space"))
3637 # timecode de départ inscrit par la caméra (DJI, Sony…) : Resolve relie les médias par lui
3738 info["timecode"] = ((fmt.get("tags") or {}).get("timecode")
3839 or next((s["tags"]["timecode"] for s in streams if (s.get("tags") or {}).get("timecode")), None))
@@ -10,7 +10,7 @@ from ...core.system import tool
10 10
11 11
12 def probe(path: Path) -> dict:12 def probe(path: Path) -> dict:
13- """{duration, width, height, fps, fps_num, fps_den, audio, timecode, shot_at} ; vide si ffprobe échoue."""13+ """{duration, width, height, fps, fps_num, fps_den, colorspace, audio, timecode, shot_at} ; vide si ffprobe échoue."""
14 try:14 try:
15 out = subprocess.run(15 out = subprocess.run(
16 [tool("ffprobe"), "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],16 [tool("ffprobe"), "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
@@ -32,7 +32,8 @@ def probe(path: Path) -> dict:
32 num, _, den = (video.get("r_frame_rate") or "25/1").partition("/")32 num, _, den = (video.get("r_frame_rate") or "25/1").partition("/")
33 num, den = int(num or 25), int(den or 1)33 num, den = int(num or 25), int(den or 1)
34 info.update(width=video.get("width"), height=video.get("height"),34 info.update(width=video.get("width"), height=video.get("height"),
35- fps=round(num / den, 3) if den else 25, fps_num=num, fps_den=den or 1)35+ fps=round(num / den, 3) if den else 25, fps_num=num, fps_den=den or 1,
36+ colorspace=video.get("color_space"))
36 # timecode de départ inscrit par la caméra (DJI, Sony…) : Resolve relie les médias par lui37 # timecode de départ inscrit par la caméra (DJI, Sony…) : Resolve relie les médias par lui
37 info["timecode"] = ((fmt.get("tags") or {}).get("timecode")38 info["timecode"] = ((fmt.get("tags") or {}).get("timecode")
38 or next((s["tags"]["timecode"] for s in streams if (s.get("tags") or {}).get("timecode")), None))39 or next((s["tags"]["timecode"] for s in streams if (s.get("tags") or {}).get("timecode")), None))
modified backend/studio/modules/projects/render.py +24 -7
@@ -38,6 +38,8 @@ RENDER_DIR = "rendu"
3838 KIND = "project_long_render"
3939 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}
4040 AUDIO_RATE = 48000
41+# les matrices de couleur de la vidéo SD (ffprobe : color_space), converties en BT.709 comme le reste
42+BT601 = {"smpte170m", "bt470bg"}
4143 # part de la première passe (A-roll) puis du mix des sons dans l'avancement
4244 AROLL_SHARE = 0.5
4345 MIX_SHARE = 0.05
@@ -106,9 +108,11 @@ def plan(pid: str, quality: str) -> dict:
106108 def clip_media(c: dict) -> dict:
107109 """Le fichier d'un clip de l'A-roll : la vidéo source, ou le média inséré."""
108110 if not c.get("src"):
109- return {"path": source["path"], "length": source_length, "audio": info.get("audio", True)}
111+ return {"path": source["path"], "length": source_length, "audio": info.get("audio", True),
112+ "colorspace": info.get("colorspace")}
110113 found = media.probe(base / c["src"])
111- return {"path": str(base / c["src"]), "length": found.get("duration") or c["out"], "audio": found.get("audio", True)}
114+ return {"path": str(base / c["src"]), "length": found.get("duration") or c["out"], "audio": found.get("audio", True),
115+ "colorspace": found.get("colorspace")}
112116
113117 aroll = [{**c, **clip_media(c)} for c in timeline["aroll"]]
114118 ends = lambda x: {"enter": x.get("enter"), "exit": x.get("exit")}
@@ -123,7 +127,8 @@ def plan(pid: str, quality: str) -> dict:
123127 for s in timeline["audio"]]
124128 return {"project": pid, "quality": quality, "width": width, "height": height,
125129 "fps_num": info.get("fps_num") or 25, "fps_den": info.get("fps_den") or 1,
126- "source": {"path": source["path"], "duration": source_length, "audio": info.get("audio", True)},
130+ "source": {"path": source["path"], "duration": source_length, "audio": info.get("audio", True),
131+ "colorspace": info.get("colorspace")},
127132 "aroll": aroll, "zooms": timeline["zooms"], "overlays": overlays, "sounds": sounds,
128133 "texts": [x for x in timeline["texts"] if x["text"].strip()], "font": str(TEXT_FONT),
129134 "dest": str(output_file(pid, quality)), "signature": signature(pid, quality)}
@@ -339,7 +344,19 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
339344 if seg["fade"] is not None:
340345 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]
341346
342- prep = f"fps={rate},setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}"
347+ def prep(clip: dict) -> str:
348+ """
349+ Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux
350+ propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images.
351+ """
352+ convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv,"
353+ if clip.get("colorspace") in BT601 else "")
354+ # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0,
355+ # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll
356+ return (f"setpts=PTS-STARTPTS,fps={rate},{convert}"
357+ f"setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,"
358+ f"setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}")
359+
343360 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
344361 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
345362 zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""
@@ -354,9 +371,9 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
354371 return f"[{n}:a]{gain}{sound}[{label}]"
355372
356373 if seg["fade"] is None:
357- graph = [f"[0:v]{prep},{zoom}{fit}[v]", voice(0, clip_a, "a")]
374+ graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")]
358375 else:
359- graph = [f"[0:v]{prep}[va]", f"[1:v]{prep}[vb]",
376+ graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b)}[vb]",
360377 f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]",
361378 voice(0, clip_a, "aa"), voice(1, clip_b, "ab"),
362379 f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"]
@@ -456,7 +473,7 @@ def chunk_args(p: dict, aroll: Path, span: tuple[int, int], dest: Path, script:
456473 seek = max(0.0, t0 - 1)
457474 lo, hi = float((first - Fraction(1, 2)) / fps) - seek, float((last - Fraction(1, 2)) / fps) - seek
458475 args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
459- "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-i", str(aroll)]
476+ "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-reinit_filter", "0", "-i", str(aroll)]
460477 # puis recalé sur la cadence exacte : des horodatages arrondis font perdre une image à l'encodage
461478 graph = [f"[0:v]trim=start={max(lo, 0):.6f}:end={hi:.6f},setpts=N*{p['fps_den']}/{p['fps_num']}/TB[base]"]
462479 video = "[base]"
@@ -38,6 +38,8 @@ RENDER_DIR = "rendu"
38 KIND = "project_long_render"38 KIND = "project_long_render"
39 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}39 QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)}
40 AUDIO_RATE = 4800040 AUDIO_RATE = 48000
41+# les matrices de couleur de la vidéo SD (ffprobe : color_space), converties en BT.709 comme le reste
42+BT601 = {"smpte170m", "bt470bg"}
41 # part de la première passe (A-roll) puis du mix des sons dans l'avancement43 # part de la première passe (A-roll) puis du mix des sons dans l'avancement
42 AROLL_SHARE = 0.544 AROLL_SHARE = 0.5
43 MIX_SHARE = 0.0545 MIX_SHARE = 0.05
@@ -106,9 +108,11 @@ def plan(pid: str, quality: str) -> dict:
106 def clip_media(c: dict) -> dict:108 def clip_media(c: dict) -> dict:
107 """Le fichier d'un clip de l'A-roll : la vidéo source, ou le média inséré."""109 """Le fichier d'un clip de l'A-roll : la vidéo source, ou le média inséré."""
108 if not c.get("src"):110 if not c.get("src"):
109- return {"path": source["path"], "length": source_length, "audio": info.get("audio", True)}111+ return {"path": source["path"], "length": source_length, "audio": info.get("audio", True),
112+ "colorspace": info.get("colorspace")}
110 found = media.probe(base / c["src"])113 found = media.probe(base / c["src"])
111- return {"path": str(base / c["src"]), "length": found.get("duration") or c["out"], "audio": found.get("audio", True)}114+ return {"path": str(base / c["src"]), "length": found.get("duration") or c["out"], "audio": found.get("audio", True),
115+ "colorspace": found.get("colorspace")}
112 116
113 aroll = [{**c, **clip_media(c)} for c in timeline["aroll"]]117 aroll = [{**c, **clip_media(c)} for c in timeline["aroll"]]
114 ends = lambda x: {"enter": x.get("enter"), "exit": x.get("exit")}118 ends = lambda x: {"enter": x.get("enter"), "exit": x.get("exit")}
@@ -123,7 +127,8 @@ def plan(pid: str, quality: str) -> dict:
123 for s in timeline["audio"]]127 for s in timeline["audio"]]
124 return {"project": pid, "quality": quality, "width": width, "height": height,128 return {"project": pid, "quality": quality, "width": width, "height": height,
125 "fps_num": info.get("fps_num") or 25, "fps_den": info.get("fps_den") or 1,129 "fps_num": info.get("fps_num") or 25, "fps_den": info.get("fps_den") or 1,
126- "source": {"path": source["path"], "duration": source_length, "audio": info.get("audio", True)},130+ "source": {"path": source["path"], "duration": source_length, "audio": info.get("audio", True),
131+ "colorspace": info.get("colorspace")},
127 "aroll": aroll, "zooms": timeline["zooms"], "overlays": overlays, "sounds": sounds,132 "aroll": aroll, "zooms": timeline["zooms"], "overlays": overlays, "sounds": sounds,
128 "texts": [x for x in timeline["texts"] if x["text"].strip()], "font": str(TEXT_FONT),133 "texts": [x for x in timeline["texts"] if x["text"].strip()], "font": str(TEXT_FONT),
129 "dest": str(output_file(pid, quality)), "signature": signature(pid, quality)}134 "dest": str(output_file(pid, quality)), "signature": signature(pid, quality)}
@@ -339,7 +344,19 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
339 if seg["fade"] is not None:344 if seg["fade"] is not None:
340 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]345 args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]]
341 346
342- prep = f"fps={rate},setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}"347+ def prep(clip: dict) -> str:
348+ """
349+ Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux
350+ propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images.
351+ """
352+ convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv,"
353+ if clip.get("colorspace") in BT601 else "")
354+ # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0,
355+ # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll
356+ return (f"setpts=PTS-STARTPTS,fps={rate},{convert}"
357+ f"setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,"
358+ f"setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}")
359+
343 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"360 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
344 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")361 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
345 zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""362 zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""
@@ -354,9 +371,9 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
354 return f"[{n}:a]{gain}{sound}[{label}]"371 return f"[{n}:a]{gain}{sound}[{label}]"
355 372
356 if seg["fade"] is None:373 if seg["fade"] is None:
357- graph = [f"[0:v]{prep},{zoom}{fit}[v]", voice(0, clip_a, "a")]374+ graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")]
358 else:375 else:
359- graph = [f"[0:v]{prep}[va]", f"[1:v]{prep}[vb]",376+ graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b)}[vb]",
360 f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]",377 f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]",
361 voice(0, clip_a, "aa"), voice(1, clip_b, "ab"),378 voice(0, clip_a, "aa"), voice(1, clip_b, "ab"),
362 f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"]379 f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"]
@@ -456,7 +473,7 @@ def chunk_args(p: dict, aroll: Path, span: tuple[int, int], dest: Path, script:
456 seek = max(0.0, t0 - 1)473 seek = max(0.0, t0 - 1)
457 lo, hi = float((first - Fraction(1, 2)) / fps) - seek, float((last - Fraction(1, 2)) / fps) - seek474 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",475 args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
459- "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-i", str(aroll)]476+ "-ss", f"{seek:.6f}", "-t", f"{hi + 1:.6f}", "-reinit_filter", "0", "-i", str(aroll)]
460 # puis recalé sur la cadence exacte : des horodatages arrondis font perdre une image à l'encodage477 # 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]"]478 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]"479 video = "[base]"