ffmpeg-skill 3.6.1 → 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 +36 -8
- 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 +234 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +55 -0
- package/scripts/_common/decision.py +80 -0
- package/scripts/_common/emit.py +8 -0
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +148 -7
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +43 -15
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +149 -11
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +44 -19
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- 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 +30 -23
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/overlay.py
CHANGED
|
@@ -31,11 +31,11 @@ with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 dec
|
|
|
31
31
|
import argparse
|
|
32
32
|
import re
|
|
33
33
|
import sys
|
|
34
|
-
from typing import List, Optional
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import still_input_args # noqa: E402
|
|
38
|
-
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
38
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
41
41
|
has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
|
|
@@ -428,7 +428,9 @@ def main() -> int:
|
|
|
428
428
|
if end is not None and end > args.fade:
|
|
429
429
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
430
430
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
431
|
-
|
|
431
|
+
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
432
|
+
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
433
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
432
434
|
if enable:
|
|
433
435
|
ov += f":enable='{enable}'"
|
|
434
436
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
@@ -480,13 +482,18 @@ def main() -> int:
|
|
|
480
482
|
elif args.scale:
|
|
481
483
|
chain.append(f"scale={args.scale}:-2")
|
|
482
484
|
# an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
|
|
483
|
-
|
|
485
|
+
# a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
|
|
486
|
+
deep = bool(overlay_format(meta)) and (pip_meta["video"].get("bit_depth") or 8) > 8
|
|
487
|
+
chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
|
|
484
488
|
if args.chromakey:
|
|
485
489
|
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
486
490
|
if args.opacity < 1:
|
|
487
|
-
|
|
491
|
+
# the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
|
|
492
|
+
# layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
|
|
493
|
+
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
494
|
+
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
488
495
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
489
|
-
ov = f"overlay={x}:{y}:format=auto"
|
|
496
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
490
497
|
if enable:
|
|
491
498
|
ov += f":enable='{enable}'"
|
|
492
499
|
# a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
|
|
@@ -501,7 +508,13 @@ def main() -> int:
|
|
|
501
508
|
# converted to BT.709 YUV with its alpha plane: the 4:4:4 the conversion ends in had none,
|
|
502
509
|
# and the layer covered the picture as an opaque rectangle
|
|
503
510
|
pip = pip.replace("format=yuv444p", "format=yuva444p")
|
|
504
|
-
|
|
511
|
+
# an untagged picture is labelled beside a converted one, and beside a tagged one too: FFmpeg
|
|
512
|
+
# 8.0's overlay negotiates one colorspace for both inputs, and a BT.709-tagged main under an
|
|
513
|
+
# untagged PiP was converted to the PiP's "unknown" (BT.601) matrix, away from the PiP too
|
|
514
|
+
# (C03028 at 80/110/192 came back 88/103/192 on 8.0.1). Two untagged pictures agree as they are.
|
|
515
|
+
def tagged(m: Dict[str, Any]) -> bool:
|
|
516
|
+
return ((m.get("video") or {}).get("color_space") or "unknown") not in ("unknown", "unspecified")
|
|
517
|
+
if base or pip or tagged(meta) != tagged(pip_meta):
|
|
505
518
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
506
519
|
# overlay mixes samples: a full-range picture is taken to limited range (#410)
|
|
507
520
|
base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
|
package/scripts/redact.py
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
import argparse
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
30
|
+
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
33
|
|
|
@@ -110,7 +110,7 @@ def main() -> int:
|
|
|
110
110
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
111
111
|
else:
|
|
112
112
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
113
|
-
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
113
|
+
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}{overlay_format(meta)}[out]"
|
|
114
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
115
115
|
if has_audio:
|
|
116
116
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
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:
|
|
@@ -1260,6 +1261,12 @@ def main() -> int:
|
|
|
1260
1261
|
emit(args.write_project, template=args.template, stages=[], check=None)
|
|
1261
1262
|
return 0
|
|
1262
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
|
+
|
|
1263
1270
|
def rel(p: Any) -> str:
|
|
1264
1271
|
p = str(p)
|
|
1265
1272
|
return p if os.path.isabs(p) else str(base / p)
|
|
@@ -1407,17 +1414,17 @@ def main() -> int:
|
|
|
1407
1414
|
if needs_cut:
|
|
1408
1415
|
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
1409
1416
|
if c.get("in") is not None:
|
|
1410
|
-
argv +=
|
|
1417
|
+
argv += option_args("--start", c["in"])
|
|
1411
1418
|
if c.get("out") is not None:
|
|
1412
|
-
argv +=
|
|
1419
|
+
argv += option_args("--end", c["out"])
|
|
1413
1420
|
clip_snap = dict(snap_spec)
|
|
1414
1421
|
clip_snap.update(c.get("snap") or {})
|
|
1415
1422
|
if str(clip_snap.get("to") or "none") == "beats":
|
|
1416
1423
|
argv += ["--snap", "beats"]
|
|
1417
1424
|
if clip_snap.get("tolerance") is not None:
|
|
1418
|
-
argv +=
|
|
1425
|
+
argv += option_args("--snap-tolerance", clip_snap["tolerance"])
|
|
1419
1426
|
if clip_snap.get("min_confidence") is not None:
|
|
1420
|
-
argv +=
|
|
1427
|
+
argv += option_args("--min-confidence", clip_snap["min_confidence"])
|
|
1421
1428
|
if clip_snap.get("source"):
|
|
1422
1429
|
argv += ["--snap-source", rel(clip_snap["source"])]
|
|
1423
1430
|
sh("cut.py", *argv, stage="clips")
|
|
@@ -1464,7 +1471,7 @@ def main() -> int:
|
|
|
1464
1471
|
# against the project file, as every other path here and as the output guard
|
|
1465
1472
|
# checks it; it used to reach waveform.py as written, against the cwd
|
|
1466
1473
|
value = rel(value)
|
|
1467
|
-
argv +=
|
|
1474
|
+
argv += option_args(flag, value)
|
|
1468
1475
|
argv += brand_args
|
|
1469
1476
|
sh("waveform.py", *argv, stage="audiogram")
|
|
1470
1477
|
current = nxt
|
|
@@ -1480,11 +1487,11 @@ def main() -> int:
|
|
|
1480
1487
|
current = str(work / f"joined{mid}")
|
|
1481
1488
|
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
1482
1489
|
if frame.get("width"):
|
|
1483
|
-
argv +=
|
|
1490
|
+
argv += option_args("--width", frame["width"])
|
|
1484
1491
|
if frame.get("height"):
|
|
1485
|
-
argv +=
|
|
1492
|
+
argv += option_args("--height", frame["height"])
|
|
1486
1493
|
if frame.get("fps"):
|
|
1487
|
-
argv +=
|
|
1494
|
+
argv += option_args("--fps", frame["fps"])
|
|
1488
1495
|
sh("join.py", *argv, stage="join")
|
|
1489
1496
|
stages_done.append("join")
|
|
1490
1497
|
if args.stop_after == "join":
|
|
@@ -1499,7 +1506,7 @@ def main() -> int:
|
|
|
1499
1506
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
|
|
1500
1507
|
("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
|
|
1501
1508
|
if sil.get(k) is not None:
|
|
1502
|
-
argv +=
|
|
1509
|
+
argv += option_args(flag, sil[k])
|
|
1503
1510
|
sh("silence.py", *argv, stage="silence")
|
|
1504
1511
|
current = nxt
|
|
1505
1512
|
stages_done.append("silence")
|
|
@@ -1536,7 +1543,7 @@ def main() -> int:
|
|
|
1536
1543
|
argv = [current, "-o", nxt]
|
|
1537
1544
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
1538
1545
|
if fit.get(k) is not None:
|
|
1539
|
-
argv +=
|
|
1546
|
+
argv += option_args(flag, fit[k])
|
|
1540
1547
|
sh("fit.py", *argv, stage="fit")
|
|
1541
1548
|
current = nxt
|
|
1542
1549
|
stages_done.append("fit")
|
|
@@ -1560,7 +1567,7 @@ def main() -> int:
|
|
|
1560
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"),
|
|
1561
1568
|
("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
|
|
1562
1569
|
if cap.get(k) is not None:
|
|
1563
|
-
argv +=
|
|
1570
|
+
argv += option_args(flag, cap[k])
|
|
1564
1571
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
1565
1572
|
if cap.get(k):
|
|
1566
1573
|
argv.append(flag)
|
|
@@ -1578,11 +1585,11 @@ def main() -> int:
|
|
|
1578
1585
|
nxt = str(work / f"graphics{i:02d}{mid}")
|
|
1579
1586
|
if not g.get("template"):
|
|
1580
1587
|
die(f"graphics[{i}] needs a template")
|
|
1581
|
-
argv = [current, "-o", nxt
|
|
1588
|
+
argv = [current, "-o", nxt] + option_args("--template", g["template"])
|
|
1582
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"),
|
|
1583
1590
|
("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
|
|
1584
1591
|
if g.get(k) is not None:
|
|
1585
|
-
argv +=
|
|
1592
|
+
argv += option_args(flag, g[k])
|
|
1586
1593
|
# the entry's own "platform" is the more specific statement than the template's destination
|
|
1587
1594
|
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
|
|
1588
1595
|
current = nxt
|
|
@@ -1601,7 +1608,7 @@ def main() -> int:
|
|
|
1601
1608
|
elif ov.get("image"):
|
|
1602
1609
|
argv += ["--image", rel(ov["image"])]
|
|
1603
1610
|
elif ov.get("text"):
|
|
1604
|
-
argv +=
|
|
1611
|
+
argv += option_args("--text", ov["text"])
|
|
1605
1612
|
elif ov.get("video"):
|
|
1606
1613
|
# #326: sized and placed here, after fit, against the frame that ships
|
|
1607
1614
|
argv += ["--video", rel(ov["video"])]
|
|
@@ -1611,7 +1618,7 @@ def main() -> int:
|
|
|
1611
1618
|
("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
|
|
1612
1619
|
if ov.get(k) is not None:
|
|
1613
1620
|
# font_file is a path: the same one project_input_files() guards
|
|
1614
|
-
argv +=
|
|
1621
|
+
argv += option_args(flag, rel(ov[k]) if k == "font_file" and ov[k] else ov[k])
|
|
1615
1622
|
if ov.get("box"):
|
|
1616
1623
|
argv.append("--box")
|
|
1617
1624
|
# review 12: the overlay stage was the one stage that never heard which destination this
|
|
@@ -1647,10 +1654,10 @@ def main() -> int:
|
|
|
1647
1654
|
argv += [flag, rel(au[k])]
|
|
1648
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")):
|
|
1649
1656
|
if au.get(k) is not None:
|
|
1650
|
-
argv +=
|
|
1657
|
+
argv += option_args(flag, au[k])
|
|
1651
1658
|
# "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
|
|
1652
1659
|
if au.get("voice") is not None and au.get("voice") is not False:
|
|
1653
|
-
argv += ["--voice"]
|
|
1660
|
+
argv += ["--voice"] if au["voice"] is True else option_args("--voice", au["voice"])
|
|
1654
1661
|
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
1655
1662
|
if au.get(k):
|
|
1656
1663
|
argv.append(flag)
|
|
@@ -1672,9 +1679,9 @@ def main() -> int:
|
|
|
1672
1679
|
nxt = str(work / f"loudnorm{mid}")
|
|
1673
1680
|
argv = [current, "-o", nxt]
|
|
1674
1681
|
if ld.get("lufs") is not None:
|
|
1675
|
-
argv +=
|
|
1682
|
+
argv += option_args("--lufs", ld["lufs"])
|
|
1676
1683
|
if ld.get("tp") is not None:
|
|
1677
|
-
argv +=
|
|
1684
|
+
argv += option_args("--tp", ld["tp"])
|
|
1678
1685
|
sh("loudness.py", *argv, stage="loudness")
|
|
1679
1686
|
current = nxt
|
|
1680
1687
|
stages_done.append("loudness")
|
|
@@ -1687,9 +1694,9 @@ def main() -> int:
|
|
|
1687
1694
|
if ex and ex.get("preset"):
|
|
1688
1695
|
argv = [current, "--preset", ex["preset"], "-o", output]
|
|
1689
1696
|
if ex.get("fit"):
|
|
1690
|
-
argv +=
|
|
1697
|
+
argv += option_args("--fit", ex["fit"])
|
|
1691
1698
|
if ex.get("crf") is not None:
|
|
1692
|
-
argv +=
|
|
1699
|
+
argv += option_args("--crf", ex["crf"])
|
|
1693
1700
|
normalize = ex.get("normalize")
|
|
1694
1701
|
if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
|
|
1695
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]
|