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.
079a7ba parent: 15da6b5 modified
backend/studio/modules/projects/media.py +3 -2 | @@ -10,7 +10,7 @@ from ...core.system import tool | ||
| 10 | 10 | |
| 11 | 11 | |
| 12 | 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 | 14 | try: |
| 15 | 15 | out = subprocess.run( |
| 16 | 16 | [tool("ffprobe"), "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)], |
| @@ -32,7 +32,8 @@ def probe(path: Path) -> dict: | ||
| 32 | 32 | num, _, den = (video.get("r_frame_rate") or "25/1").partition("/") |
| 33 | 33 | num, den = int(num or 25), int(den or 1) |
| 34 | 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 | 37 | # timecode de départ inscrit par la caméra (DJI, Sony…) : Resolve relie les médias par lui |
| 37 | 38 | info["timecode"] = ((fmt.get("tags") or {}).get("timecode") |
| 38 | 39 | 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 lui | 37 | # 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" | ||
| 38 | 38 | KIND = "project_long_render" |
| 39 | 39 | QUALITIES = {"1080p": (1920, 1080), "4k": (3840, 2160)} |
| 40 | 40 | 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 | 43 | # part de la première passe (A-roll) puis du mix des sons dans l'avancement |
| 42 | 44 | AROLL_SHARE = 0.5 |
| 43 | 45 | MIX_SHARE = 0.05 |
| @@ -106,9 +108,11 @@ def plan(pid: str, quality: str) -> dict: | ||
| 106 | 108 | def clip_media(c: dict) -> dict: |
| 107 | 109 | """Le fichier d'un clip de l'A-roll : la vidéo source, ou le média inséré.""" |
| 108 | 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 | 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 | 117 | aroll = [{**c, **clip_media(c)} for c in timeline["aroll"]] |
| 114 | 118 | ends = lambda x: {"enter": x.get("enter"), "exit": x.get("exit")} |
| @@ -123,7 +127,8 @@ def plan(pid: str, quality: str) -> dict: | ||
| 123 | 127 | for s in timeline["audio"]] |
| 124 | 128 | return {"project": pid, "quality": quality, "width": width, "height": height, |
| 125 | 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 | 132 | "aroll": aroll, "zooms": timeline["zooms"], "overlays": overlays, "sounds": sounds, |
| 128 | 133 | "texts": [x for x in timeline["texts"] if x["text"].strip()], "font": str(TEXT_FONT), |
| 129 | 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 | 344 | if seg["fade"] is not None: |
| 340 | 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 | 360 | fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos," |
| 344 | 361 | f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p") |
| 345 | 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 | 371 | return f"[{n}:a]{gain}{sound}[{label}]" |
| 355 | 372 | |
| 356 | 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 | 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 | 377 | f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]", |
| 361 | 378 | voice(0, clip_a, "aa"), voice(1, clip_b, "ab"), |
| 362 | 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 | 473 | seek = max(0.0, t0 - 1) |
| 457 | 474 | lo, hi = float((first - Fraction(1, 2)) / fps) - seek, float((last - Fraction(1, 2)) / fps) - seek |
| 458 | 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 | 477 | # puis recalé sur la cadence exacte : des horodatages arrondis font perdre une image à l'encodage |
| 461 | 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 | 479 | 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 = 48000 | 40 | 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'avancement | 43 | # part de la première passe (A-roll) puis du mix des sons dans l'avancement |
| 42 | AROLL_SHARE = 0.5 | 44 | AROLL_SHARE = 0.5 |
| 43 | MIX_SHARE = 0.05 | 45 | 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) - seek | 474 | 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'encodage | 477 | # 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]" |