| @@ -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 main | 8 | 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 | 9 | + (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 fin | 17 | + rendu/.tmp-<job>/ les morceaux et le mix, effacés à la fin |
| 17 | """ | 18 | """ |
| 18 | import hashlib | 19 | import hashlib |
| 19 | import json | 20 | import json |
| | 21 | +import math |
| 20 | import os | 22 | import os |
| 21 | import re | 23 | import re |
| 22 | import shutil | 24 | import shutil |
| 23 | import subprocess | 25 | import subprocess |
| | 26 | +import threading |
| | 27 | +import time |
| 24 | from fractions import Fraction | 28 | from fractions import Fraction |
| 25 | from pathlib import Path | 29 | from pathlib import Path |
| 26 | from typing import Callable | 30 | 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 = 48000 | 40 | 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 |
| 38 | AROLL_SHARE = 0.5 | 42 | 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'erreur | 48 | # 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 contour | 49 | # 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 sortie | 50 | # 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 groups | 412 | 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 = 1 | 463 | index = 1 |
| 413 | # xfade veut deux entrées de même format, même cadence, même base de temps | 464 | # 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 | continue | 480 | 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 + total | 507 | stream, length = f"[x{index}]", offset + total |
| 455 | index += 1 | 508 | 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 place | 528 | # 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 de | 542 | # 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 += 1 | 561 | 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 ffmpeg | 637 | # 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.payload | 656 | 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: |