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.
@@ -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
- ov = f"overlay={x}:{y}:format=auto"
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
- chain.append("format=yuva444p" if alpha_codec else "format=yuva420p")
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
- chain.append(f"colorchannelmixer=aa={args.opacity:g}")
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
- if base or pip:
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 += [flag, str(value)]
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 += ["--start", c["in"]]
1417
+ argv += option_args("--start", c["in"])
1411
1418
  if c.get("out") is not None:
1412
- argv += ["--end", c["out"]]
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 += ["--snap-tolerance", str(clip_snap["tolerance"])]
1425
+ argv += option_args("--snap-tolerance", clip_snap["tolerance"])
1419
1426
  if clip_snap.get("min_confidence") is not None:
1420
- argv += ["--min-confidence", str(clip_snap["min_confidence"])]
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 += [flag, str(value)]
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 += ["--width", str(frame["width"])]
1490
+ argv += option_args("--width", frame["width"])
1484
1491
  if frame.get("height"):
1485
- argv += ["--height", str(frame["height"])]
1492
+ argv += option_args("--height", frame["height"])
1486
1493
  if frame.get("fps"):
1487
- argv += ["--fps", str(frame["fps"])]
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 += [flag, str(sil[k])]
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 += [flag, str(fit[k])]
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 += [flag, str(cap[k])]
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, "--template", g["template"]]
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 += [flag, str(g[k])]
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 += ["--text", ov["text"]]
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 += [flag, rel(ov[k]) if k == "font_file" and ov[k] else str(ov[k])]
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 += [flag, str(au[k])]
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"] + ([] if au["voice"] is True else [str(au["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 += ["-I", str(ld["lufs"])]
1682
+ argv += option_args("--lufs", ld["lufs"])
1676
1683
  if ld.get("tp") is not None:
1677
- argv += ["--tp", str(ld["tp"])]
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 += ["--fit", ex["fit"]]
1697
+ argv += option_args("--fit", ex["fit"])
1691
1698
  if ex.get("crf") is not None:
1692
- argv += ["--crf", str(ex["crf"])]
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
@@ -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
- ref = (s + e) / 2
91
- before = [w for w in timed if (w["start"] + w["end"]) / 2 < ref]
92
- out.append("sentence_end" if before and ends_sentence(before[-1]["word"]) else "inside")
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
 
@@ -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
- vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f}[{vlabel}]")
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
- if head_trim > 0 and not drift_af:
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
- cmd += ["-ss", f"{head_trim:.4f}"]
402
- cmd += ["-i", args.second]
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]