ffmpeg-skill 3.6.0 → 3.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +101 -25
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/render.py
CHANGED
|
@@ -74,9 +74,10 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
74
74
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
|
-
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
77
|
+
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
|
+
from _common.decision import rebase_project_paths
|
|
80
81
|
import subprocess
|
|
81
82
|
from _contract import CONTRACT_VERSION
|
|
82
83
|
from batch import file_key
|
|
@@ -362,7 +363,7 @@ def render_pack(names: List[str], args) -> int:
|
|
|
362
363
|
("--chapters", args.chapters), ("--image", args.image),
|
|
363
364
|
("--intermediate-preset", args.intermediate_preset)):
|
|
364
365
|
if value:
|
|
365
|
-
argv +=
|
|
366
|
+
argv += option_args(flag, value)
|
|
366
367
|
info(f"→ pack: {name}")
|
|
367
368
|
proc = run_tool(argv + child_args() + ["--json"])
|
|
368
369
|
try:
|
|
@@ -464,6 +465,64 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
464
465
|
die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
|
|
465
466
|
|
|
466
467
|
|
|
468
|
+
def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
|
|
469
|
+
"""Every file a project names as an input: clip sources, music/replace/effects, the brand,
|
|
470
|
+
caption files (a --text value that names an existing file), overlay images, videos and fonts, the
|
|
471
|
+
audiogram image, a beat grid's source and the chapters file. Values of the wrong type are left
|
|
472
|
+
to the stage that reads them."""
|
|
473
|
+
found: List[str] = []
|
|
474
|
+
|
|
475
|
+
def add(value: Any, must_exist: bool = False) -> None:
|
|
476
|
+
if isinstance(value, str) and value:
|
|
477
|
+
p = rel(value)
|
|
478
|
+
if not must_exist or os.path.isfile(p):
|
|
479
|
+
found.append(p)
|
|
480
|
+
|
|
481
|
+
for c in proj.get("clips") or []:
|
|
482
|
+
if isinstance(c, dict):
|
|
483
|
+
add(c.get("src"))
|
|
484
|
+
if isinstance(c.get("snap"), dict):
|
|
485
|
+
add(c["snap"].get("source"))
|
|
486
|
+
for key in ("audio", "captions", "audiogram", "snap"):
|
|
487
|
+
if not isinstance(proj.get(key), dict):
|
|
488
|
+
continue
|
|
489
|
+
obj = proj[key]
|
|
490
|
+
if key == "audio":
|
|
491
|
+
for k in ("music", "replace", "effects"):
|
|
492
|
+
add(obj.get(k))
|
|
493
|
+
elif key == "captions":
|
|
494
|
+
add(obj.get("srt"))
|
|
495
|
+
add(obj.get("ass"))
|
|
496
|
+
add(obj.get("text"), must_exist=True) # inline cue text is not a path
|
|
497
|
+
elif key == "audiogram":
|
|
498
|
+
add(obj.get("image"))
|
|
499
|
+
else:
|
|
500
|
+
add(obj.get("source"))
|
|
501
|
+
add(proj.get("brand"))
|
|
502
|
+
brand = proj.get("brand")
|
|
503
|
+
if isinstance(brand, str) and brand:
|
|
504
|
+
# the brand's own logo and font_file are read by the overlay and caption stages, resolved
|
|
505
|
+
# against the brand file as load_brand() resolves them; an unreadable brand is left to them
|
|
506
|
+
try:
|
|
507
|
+
data = json.loads(Path(rel(brand)).read_text(encoding="utf-8"))
|
|
508
|
+
except (OSError, ValueError):
|
|
509
|
+
data = None
|
|
510
|
+
if isinstance(data, dict):
|
|
511
|
+
base = Path(rel(brand)).resolve().parent
|
|
512
|
+
for k in ("logo", "font_file"):
|
|
513
|
+
v = data.get(k)
|
|
514
|
+
if isinstance(v, str) and v and "://" not in v:
|
|
515
|
+
found.append(v if os.path.isabs(v) else str(base / v))
|
|
516
|
+
add(proj.get("chapters"), must_exist=True)
|
|
517
|
+
overlays = proj.get("overlays")
|
|
518
|
+
for ov in (overlays if isinstance(overlays, list) else []): # any other type is the overlay stage's to refuse
|
|
519
|
+
if isinstance(ov, dict):
|
|
520
|
+
add(ov.get("image"))
|
|
521
|
+
add(ov.get("video"))
|
|
522
|
+
add(ov.get("font_file"))
|
|
523
|
+
return found
|
|
524
|
+
|
|
525
|
+
|
|
467
526
|
def require_object(obj: Any, label: str) -> None:
|
|
468
527
|
"""Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
|
|
469
528
|
in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
|
|
@@ -1202,6 +1261,12 @@ def main() -> int:
|
|
|
1202
1261
|
emit(args.write_project, template=args.template, stages=[], check=None)
|
|
1203
1262
|
return 0
|
|
1204
1263
|
|
|
1264
|
+
# every file the project names is read against the project file's directory (a template's,
|
|
1265
|
+
# the working directory): the same keys batch.py and multicam.py rebase on a copy they write
|
|
1266
|
+
# elsewhere, including the two rel() below never saw (audiogram.image, overlays[].font_file
|
|
1267
|
+
# were read against the working directory)
|
|
1268
|
+
proj = rebase_project_paths(proj, base)
|
|
1269
|
+
|
|
1205
1270
|
def rel(p: Any) -> str:
|
|
1206
1271
|
p = str(p)
|
|
1207
1272
|
return p if os.path.isabs(p) else str(base / p)
|
|
@@ -1216,8 +1281,13 @@ def main() -> int:
|
|
|
1216
1281
|
if not clips:
|
|
1217
1282
|
die("project.clips is empty")
|
|
1218
1283
|
output = rel(proj.get("output") or "final.mp4")
|
|
1219
|
-
# the final stage is a copy from the work dir, so run()'s own guard never sees the sources
|
|
1220
|
-
|
|
1284
|
+
# the final stage is a copy from the work dir, so run()'s own guard never sees the sources
|
|
1285
|
+
# (review 5): every file the project names is passed here, not only the clips, or --overwrite
|
|
1286
|
+
# replaced a music bed or a brand file named as the output
|
|
1287
|
+
if not args.stop_after or args.stop_after == "export":
|
|
1288
|
+
# only a run that ends in the final copy writes `output`; a --stop-after preview writes
|
|
1289
|
+
# its stage file inside the work directory and leaves `output` alone
|
|
1290
|
+
refuse_output_is_input(output, *([os.path.abspath(args.project)] if args.project else []), *project_input_files(proj, rel))
|
|
1221
1291
|
if _reaches("overlays", args.stop_after):
|
|
1222
1292
|
# #326: an overlay video is the caller's file, never an earlier stage's output, so a
|
|
1223
1293
|
# missing one is refused here, under --dry-run too, not after every stage before it ran
|
|
@@ -1344,17 +1414,17 @@ def main() -> int:
|
|
|
1344
1414
|
if needs_cut:
|
|
1345
1415
|
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
1346
1416
|
if c.get("in") is not None:
|
|
1347
|
-
argv +=
|
|
1417
|
+
argv += option_args("--start", c["in"])
|
|
1348
1418
|
if c.get("out") is not None:
|
|
1349
|
-
argv +=
|
|
1419
|
+
argv += option_args("--end", c["out"])
|
|
1350
1420
|
clip_snap = dict(snap_spec)
|
|
1351
1421
|
clip_snap.update(c.get("snap") or {})
|
|
1352
1422
|
if str(clip_snap.get("to") or "none") == "beats":
|
|
1353
1423
|
argv += ["--snap", "beats"]
|
|
1354
1424
|
if clip_snap.get("tolerance") is not None:
|
|
1355
|
-
argv +=
|
|
1425
|
+
argv += option_args("--snap-tolerance", clip_snap["tolerance"])
|
|
1356
1426
|
if clip_snap.get("min_confidence") is not None:
|
|
1357
|
-
argv +=
|
|
1427
|
+
argv += option_args("--min-confidence", clip_snap["min_confidence"])
|
|
1358
1428
|
if clip_snap.get("source"):
|
|
1359
1429
|
argv += ["--snap-source", rel(clip_snap["source"])]
|
|
1360
1430
|
sh("cut.py", *argv, stage="clips")
|
|
@@ -1396,7 +1466,12 @@ def main() -> int:
|
|
|
1396
1466
|
("color", "--color"), ("background", "--background"),
|
|
1397
1467
|
("width", "--width"), ("height", "--height"), ("fps", "--fps")):
|
|
1398
1468
|
if ag.get(key) is not None:
|
|
1399
|
-
|
|
1469
|
+
value = ag[key]
|
|
1470
|
+
if key == "image" and isinstance(value, str) and "://" not in value:
|
|
1471
|
+
# against the project file, as every other path here and as the output guard
|
|
1472
|
+
# checks it; it used to reach waveform.py as written, against the cwd
|
|
1473
|
+
value = rel(value)
|
|
1474
|
+
argv += option_args(flag, value)
|
|
1400
1475
|
argv += brand_args
|
|
1401
1476
|
sh("waveform.py", *argv, stage="audiogram")
|
|
1402
1477
|
current = nxt
|
|
@@ -1412,11 +1487,11 @@ def main() -> int:
|
|
|
1412
1487
|
current = str(work / f"joined{mid}")
|
|
1413
1488
|
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
1414
1489
|
if frame.get("width"):
|
|
1415
|
-
argv +=
|
|
1490
|
+
argv += option_args("--width", frame["width"])
|
|
1416
1491
|
if frame.get("height"):
|
|
1417
|
-
argv +=
|
|
1492
|
+
argv += option_args("--height", frame["height"])
|
|
1418
1493
|
if frame.get("fps"):
|
|
1419
|
-
argv +=
|
|
1494
|
+
argv += option_args("--fps", frame["fps"])
|
|
1420
1495
|
sh("join.py", *argv, stage="join")
|
|
1421
1496
|
stages_done.append("join")
|
|
1422
1497
|
if args.stop_after == "join":
|
|
@@ -1431,7 +1506,7 @@ def main() -> int:
|
|
|
1431
1506
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
|
|
1432
1507
|
("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
|
|
1433
1508
|
if sil.get(k) is not None:
|
|
1434
|
-
argv +=
|
|
1509
|
+
argv += option_args(flag, sil[k])
|
|
1435
1510
|
sh("silence.py", *argv, stage="silence")
|
|
1436
1511
|
current = nxt
|
|
1437
1512
|
stages_done.append("silence")
|
|
@@ -1468,7 +1543,7 @@ def main() -> int:
|
|
|
1468
1543
|
argv = [current, "-o", nxt]
|
|
1469
1544
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
1470
1545
|
if fit.get(k) is not None:
|
|
1471
|
-
argv +=
|
|
1546
|
+
argv += option_args(flag, fit[k])
|
|
1472
1547
|
sh("fit.py", *argv, stage="fit")
|
|
1473
1548
|
current = nxt
|
|
1474
1549
|
stages_done.append("fit")
|
|
@@ -1492,7 +1567,7 @@ def main() -> int:
|
|
|
1492
1567
|
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
|
|
1493
1568
|
("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
|
|
1494
1569
|
if cap.get(k) is not None:
|
|
1495
|
-
argv +=
|
|
1570
|
+
argv += option_args(flag, cap[k])
|
|
1496
1571
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
1497
1572
|
if cap.get(k):
|
|
1498
1573
|
argv.append(flag)
|
|
@@ -1510,11 +1585,11 @@ def main() -> int:
|
|
|
1510
1585
|
nxt = str(work / f"graphics{i:02d}{mid}")
|
|
1511
1586
|
if not g.get("template"):
|
|
1512
1587
|
die(f"graphics[{i}] needs a template")
|
|
1513
|
-
argv = [current, "-o", nxt
|
|
1588
|
+
argv = [current, "-o", nxt] + option_args("--template", g["template"])
|
|
1514
1589
|
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang"),
|
|
1515
1590
|
("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
|
|
1516
1591
|
if g.get(k) is not None:
|
|
1517
|
-
argv +=
|
|
1592
|
+
argv += option_args(flag, g[k])
|
|
1518
1593
|
# the entry's own "platform" is the more specific statement than the template's destination
|
|
1519
1594
|
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
|
|
1520
1595
|
current = nxt
|
|
@@ -1533,7 +1608,7 @@ def main() -> int:
|
|
|
1533
1608
|
elif ov.get("image"):
|
|
1534
1609
|
argv += ["--image", rel(ov["image"])]
|
|
1535
1610
|
elif ov.get("text"):
|
|
1536
|
-
argv +=
|
|
1611
|
+
argv += option_args("--text", ov["text"])
|
|
1537
1612
|
elif ov.get("video"):
|
|
1538
1613
|
# #326: sized and placed here, after fit, against the frame that ships
|
|
1539
1614
|
argv += ["--video", rel(ov["video"])]
|
|
@@ -1542,7 +1617,8 @@ def main() -> int:
|
|
|
1542
1617
|
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform"),
|
|
1543
1618
|
("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
|
|
1544
1619
|
if ov.get(k) is not None:
|
|
1545
|
-
|
|
1620
|
+
# font_file is a path: the same one project_input_files() guards
|
|
1621
|
+
argv += option_args(flag, rel(ov[k]) if k == "font_file" and ov[k] else ov[k])
|
|
1546
1622
|
if ov.get("box"):
|
|
1547
1623
|
argv.append("--box")
|
|
1548
1624
|
# review 12: the overlay stage was the one stage that never heard which destination this
|
|
@@ -1578,10 +1654,10 @@ def main() -> int:
|
|
|
1578
1654
|
argv += [flag, rel(au[k])]
|
|
1579
1655
|
for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
|
|
1580
1656
|
if au.get(k) is not None:
|
|
1581
|
-
argv +=
|
|
1657
|
+
argv += option_args(flag, au[k])
|
|
1582
1658
|
# "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
|
|
1583
1659
|
if au.get("voice") is not None and au.get("voice") is not False:
|
|
1584
|
-
argv += ["--voice"]
|
|
1660
|
+
argv += ["--voice"] if au["voice"] is True else option_args("--voice", au["voice"])
|
|
1585
1661
|
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
1586
1662
|
if au.get(k):
|
|
1587
1663
|
argv.append(flag)
|
|
@@ -1603,9 +1679,9 @@ def main() -> int:
|
|
|
1603
1679
|
nxt = str(work / f"loudnorm{mid}")
|
|
1604
1680
|
argv = [current, "-o", nxt]
|
|
1605
1681
|
if ld.get("lufs") is not None:
|
|
1606
|
-
argv +=
|
|
1682
|
+
argv += option_args("--lufs", ld["lufs"])
|
|
1607
1683
|
if ld.get("tp") is not None:
|
|
1608
|
-
argv +=
|
|
1684
|
+
argv += option_args("--tp", ld["tp"])
|
|
1609
1685
|
sh("loudness.py", *argv, stage="loudness")
|
|
1610
1686
|
current = nxt
|
|
1611
1687
|
stages_done.append("loudness")
|
|
@@ -1618,9 +1694,9 @@ def main() -> int:
|
|
|
1618
1694
|
if ex and ex.get("preset"):
|
|
1619
1695
|
argv = [current, "--preset", ex["preset"], "-o", output]
|
|
1620
1696
|
if ex.get("fit"):
|
|
1621
|
-
argv +=
|
|
1697
|
+
argv += option_args("--fit", ex["fit"])
|
|
1622
1698
|
if ex.get("crf") is not None:
|
|
1623
|
-
argv +=
|
|
1699
|
+
argv += option_args("--crf", ex["crf"])
|
|
1624
1700
|
normalize = ex.get("normalize")
|
|
1625
1701
|
if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
|
|
1626
1702
|
# eval 8: a reels project without the key rendered fully, failed the loudness check and
|
package/scripts/silence.py
CHANGED
|
@@ -82,14 +82,17 @@ def silence_positions(silences: List[Tuple[float, float]], words: "list",
|
|
|
82
82
|
of the word after (on demos/footage/ai_talk_en.mp4 three word starts sit 0.45 to 0.5 s before
|
|
83
83
|
the sound does), and a word's midpoint stays on its own side of the pause either way. A
|
|
84
84
|
silence with no word before it is `inside`."""
|
|
85
|
+
import bisect
|
|
85
86
|
timed = sorted(words, key=lambda w: w["start"] + w["end"])
|
|
87
|
+
mids = [(w["start"] + w["end"]) / 2 for w in timed]
|
|
86
88
|
out: List[str] = []
|
|
87
89
|
for s, e in silences:
|
|
88
90
|
if e == float("inf"):
|
|
89
91
|
e = max(s, duration) if duration is not None else s
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
# the last word whose midpoint is before the silence's: a binary search, not a scan of the
|
|
93
|
+
# whole transcript per silence (an hour of speech has thousands of each)
|
|
94
|
+
k = bisect.bisect_left(mids, (s + e) / 2)
|
|
95
|
+
out.append("sentence_end" if k and ends_sentence(timed[k - 1]["word"]) else "inside")
|
|
93
96
|
return out
|
|
94
97
|
|
|
95
98
|
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
23
|
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
24
24
|
|
|
25
25
|
MAX_SPEED = 20.0
|
|
@@ -106,7 +106,11 @@ def main() -> int:
|
|
|
106
106
|
die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
|
|
107
107
|
f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
|
|
108
108
|
vtrim, atrim = av_trim(bounds[i], seg_end, rate)
|
|
109
|
-
|
|
109
|
+
# the closing fps puts the sped-up frames back on the source's grid with its frame
|
|
110
|
+
# duration: concat starts the next segment after the last frame's duration, which setpts
|
|
111
|
+
# leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
|
|
112
|
+
# frames late against the sound (and added those frames)
|
|
113
|
+
vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, rate)}[{vlabel}]")
|
|
110
114
|
labels.append(f"[{vlabel}]")
|
|
111
115
|
if has_audio:
|
|
112
116
|
aparts.append(f"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
|
package/scripts/sync.py
CHANGED
|
@@ -35,7 +35,8 @@ from typing import Dict, List
|
|
|
35
35
|
from _common.runner import fail_if_classified
|
|
36
36
|
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
|
-
from _common.decision import stream_starts
|
|
38
|
+
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
|
|
39
|
+
from _common.timeline import output_timecode_args
|
|
39
40
|
|
|
40
41
|
SR = 8000 # decode sample rate
|
|
41
42
|
|
|
@@ -385,7 +386,12 @@ def main() -> int:
|
|
|
385
386
|
cmd = cmd[:-1] + video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) + [output]
|
|
386
387
|
run(cmd)
|
|
387
388
|
else:
|
|
388
|
-
|
|
389
|
+
# A picture is never stream-copied here: a copy starts at the keyframe at or before -ss
|
|
390
|
+
# and -avoid_negative_ts make_zero shows the frames from it, so the output started
|
|
391
|
+
# early by up to a GOP (a 5 s GOP: nothing trimmed at all) and was reported as synced.
|
|
392
|
+
# Even a keyframe on the trim point moved the picture by its B-frame delay (+69 ms).
|
|
393
|
+
# The re-encode below starts on the frame itself; a sound-only file is still copied.
|
|
394
|
+
if head_trim > 0 and not drift_af and not has_video:
|
|
389
395
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
|
|
390
396
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
391
397
|
cmd += ["-movflags", "+faststart"]
|
|
@@ -396,10 +402,20 @@ def main() -> int:
|
|
|
396
402
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
397
403
|
run(cmd)
|
|
398
404
|
else:
|
|
405
|
+
# Seek SEEK_MARGIN early and drop the margin on the output side, as cut.py's
|
|
406
|
+
# re-encode does: an input seek straight to head_trim can land (the MP4 demuxer
|
|
407
|
+
# seeks over raw decode times) on a keyframe whose picture comes after it, and a
|
|
408
|
+
# B-frame HEVC file lost the frames before it. The output-side -ss decodes and
|
|
409
|
+
# drops the margin from both streams alike; it counts on the output's clock, which
|
|
410
|
+
# a drift fix runs faster by drift_ratio (setpts and asetrate alike).
|
|
399
411
|
cmd = ffmpeg_base()
|
|
412
|
+
out_seek: List[str] = []
|
|
400
413
|
if head_trim > 0:
|
|
401
|
-
|
|
402
|
-
|
|
414
|
+
m = min(SEEK_MARGIN, head_trim)
|
|
415
|
+
cmd += ["-ss", f"{head_trim - m:.6f}"]
|
|
416
|
+
if m > 0:
|
|
417
|
+
out_seek = ["-ss", f"{m / (drift_ratio if drift_af else 1.0):.6f}"]
|
|
418
|
+
cmd += ["-i", args.second] + out_seek
|
|
403
419
|
af_parts = on_clock + drift_af
|
|
404
420
|
if delay_ms > 0:
|
|
405
421
|
af_parts.append(f"adelay={delay_ms}:all=1")
|
|
@@ -412,6 +428,14 @@ def main() -> int:
|
|
|
412
428
|
if vf:
|
|
413
429
|
cmd += ["-vf", ",".join(vf)]
|
|
414
430
|
cmd += video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf)
|
|
431
|
+
# the output's first frame is the second file's frame at head_trim, or (padded)
|
|
432
|
+
# one `offset` before its first: its start timecode moves with it
|
|
433
|
+
v0, rate = stream_starts(second_meta)[0], frame_rate(second_meta)
|
|
434
|
+
# (a trim before a picture that starts after the file's origin starts on its
|
|
435
|
+
# first frame; only the padded head counts back before it)
|
|
436
|
+
first = (grid_at_or_after(max(round(head_trim, 4), v0), v0, rate) - v0 if head_trim > 0
|
|
437
|
+
else -offset if delay_ms > 0 else 0.0)
|
|
438
|
+
cmd += output_timecode_args(second_meta, first)
|
|
415
439
|
if af_parts:
|
|
416
440
|
cmd += ["-af", ",".join(af_parts)]
|
|
417
441
|
cmd += audio_codec_for(output) + [output]
|