ffmpeg-skill 3.1.1 → 3.1.3

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.
@@ -44,7 +44,7 @@ from typing import Any, Dict, List, Optional, Tuple
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
- from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
47
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
48
48
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
49
49
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
50
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -78,10 +78,27 @@ TIME_RE = re.compile(
78
78
  )
79
79
 
80
80
 
81
+ # an SRT timing line: the times alone, the cue text on the lines after it
82
+ DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
83
+ SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
84
+
85
+
81
86
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
82
87
  cues: List[Tuple[float, float, str]] = []
83
88
  cursor = 0.0
84
- for raw in read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True):
89
+ lines = read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True)
90
+ for n, raw in enumerate(lines, 1):
91
+ if SRT_TIMING_RE.match(raw.rstrip("\r\n")):
92
+ # a cue file puts the text on the line with its times; this line has the times alone, so
93
+ # the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
94
+ # as captions, auto-timed 3 s apart (#396)
95
+ die(f"--text: {path} is an SRT file (line {n} is a timing line with no text: "
96
+ f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
97
+ if DROP_FRAME_RE.match(raw):
98
+ # TIME_RE has no ';', so the line would be burned as caption text, auto-timed
99
+ die(f"--text: line {n} has a drop-frame timecode (';'), which is not read: write it as "
100
+ "hh:mm:ss:ff@29.97 or in seconds", kind="input")
101
+ for raw in lines:
85
102
  line = raw.rstrip("\n")
86
103
  if not line.strip():
87
104
  continue
@@ -200,7 +217,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
200
217
  return cues, None
201
218
  assets = resolve_emoji_assets(getattr(args, "emoji_assets", None), None, brand)
202
219
  want = getattr(args, "emoji", "auto")
203
- support = emoji_support(assets, probe=True)
220
+ support = emoji_support(assets, probe=True, fonts_dir=getattr(args, "fonts_dir", None))
204
221
  mode = support["mode"] if want == "auto" else want
205
222
  if want == "color" and not support["libass_color"]:
206
223
  die("--emoji color: this ffmpeg renders emoji monochrome through libass "
@@ -208,6 +225,13 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
208
225
  kind="input")
209
226
  if want == "png" and not assets:
210
227
  die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
228
+ # --emoji mono asks for the font's glyph, and there is none: empty boxes (tofu) are a failed job
229
+ # (#395). An auto run takes mode "none" instead, as on a machine with no emoji face at all,
230
+ # and says why below; --emoji mono never uses assets, so they do not save it
231
+ if want == "mono" and support.get("libass_glyph") is False:
232
+ die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
233
+ f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
234
+ "or --emoji none to leave them out of the drawn text", kind="input")
211
235
  plan = {"mode": mode, "count": len(clusters_all),
212
236
  "clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
213
237
  "assets": assets, "missing": [], "overlays": []}
@@ -217,7 +241,14 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
217
241
  for cl in {cl for _i, cl in emoji_clusters(text)}:
218
242
  text = text.replace(cl, "")
219
243
  out.append((start, end, re.sub(r"[ \t]{2,}", " ", text).strip()))
220
- info("emoji: stripped from the drawn text (--emoji none)")
244
+ if want == "auto":
245
+ why = ("libass draws no emoji glyph on this build (an empty box at best)"
246
+ if support.get("libass_glyph") is False else "no face here draws them")
247
+ msg = (f"emoji: {len(clusters_all)} left out of the drawn text: {why} -- pass --emoji-assets DIR "
248
+ "to draw them as PNGs")
249
+ warn(msg, "emoji_not_drawn", count=len(clusters_all), reason=support.get("detail"))
250
+ else:
251
+ info("emoji: stripped from the drawn text (--emoji none)")
221
252
  return out, plan
222
253
  if mode in ("color", "mono"):
223
254
  if mode == "mono":
@@ -1097,6 +1128,10 @@ def main() -> int:
1097
1128
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
1098
1129
  if args.audio_stream and not audio_streams:
1099
1130
  die("--audio-stream needs an input with audio streams")
1131
+ if args.mode != "mux":
1132
+ # before any transcription or sidecar: a build without libass fails these at parse time
1133
+ # with a message that names no filter (#393), so ask the build first, dry run included
1134
+ require_filter("ass" if (args.ass or args.animate != "none" or args.karaoke) else "subtitles", "burning captions")
1100
1135
  fps_for_tc = args.fps
1101
1136
  if fps_for_tc is None and meta is not None:
1102
1137
  fps_for_tc = meta.get("video", {}).get("fps")
@@ -1586,8 +1621,11 @@ def main() -> int:
1586
1621
  side_notes.append(msg)
1587
1622
  caption_stats["cues_burned"] = burned
1588
1623
  caption_stats["cues_outside"] = outside
1624
+ require_filter("ass" if args.ass else "subtitles", "burning captions")
1625
+ # the path as a named option: with a positional first value a build without the filter fails at
1626
+ # parse time ("No option name near ...") instead of saying "No such filter" (#393)
1589
1627
  if args.ass:
1590
- vf = f"ass={escape_filter_path(args.ass)}"
1628
+ vf = f"ass=filename={escape_filter_path(args.ass)}"
1591
1629
  if args.fonts_dir:
1592
1630
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1593
1631
  else:
@@ -1608,7 +1646,7 @@ def main() -> int:
1608
1646
  f"MarginV={args.margin}",
1609
1647
  ]
1610
1648
  force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
1611
- vf = f"subtitles={escape_filter_path(srt_path)}:force_style='{force}'"
1649
+ vf = f"subtitles=filename={escape_filter_path(srt_path)}:force_style='{force}'"
1612
1650
  if args.fonts_dir:
1613
1651
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1614
1652
  if generated_ass or not args.ass:
package/scripts/cut.py CHANGED
@@ -61,7 +61,7 @@ from typing import List, Tuple
61
61
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
62
62
  from _common import require_tool
63
63
  from _common.probe import TS_EXTS, signatures_match, stream_signature
64
- from _common import sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
64
+ from _common import warn, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
65
65
 
66
66
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
67
67
  DROPPED_STREAMS: List[str] = []
@@ -1706,9 +1706,20 @@ def main() -> int:
1706
1706
  # selection writes one of each, so that is the count; the source's own picture/sound gap is
1707
1707
  # the reference for the lengths check
1708
1708
  expect_streams = {"video": int(bool(meta.get("video")) and not is_audio_output(output)), "audio": int(bool(meta.get("audio")))}
1709
+ # cut writes one audio track (ffmpeg's default selection, on a stream copy as on a re-encode):
1710
+ # a source with more must say which ones did not come along (#397), as export.py's non_av_streams does
1711
+ src_audio = meta.get("audio_streams") or []
1712
+ out_audio = (result.get("audio_streams") or []) if not STATE.dry_run else src_audio[:1]
1713
+ dropped_audio = len(src_audio) - len(out_audio) if src_audio else 0
1714
+ if dropped_audio > 0:
1715
+ kept = len(out_audio)
1716
+ msg = (f"the source has {len(src_audio)} audio tracks; this cut kept {kept} and left {dropped_audio} out "
1717
+ f"(cut writes one audio track, on a stream copy as on a re-encode)")
1718
+ warn(msg, "audio_tracks_dropped", source=len(src_audio), kept=kept, dropped=dropped_audio)
1719
+ notes.append(msg)
1709
1720
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
1710
1721
  expect_streams=expect_streams, stream_reference=[meta], stream_windows=[(s, e) for s, e in requested],
1711
- dropped_non_av_streams=bool(DROPPED_STREAMS),
1722
+ dropped_non_av_streams=bool(DROPPED_STREAMS), dropped_audio_streams=max(dropped_audio, 0),
1712
1723
  requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
1713
1724
  requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
1714
1725
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in requested] if len(requested) > 1 else None,
@@ -32,7 +32,7 @@ import sys
32
32
  from typing import List, Optional, Tuple
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
- from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
35
+ from _common import (require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
36
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
37
37
  ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
38
38
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
@@ -237,13 +237,18 @@ def main(force_route: Optional[str] = None) -> int:
237
237
  emoji_mode = None
238
238
  emoji_overlays: List[dict] = []
239
239
  if has_emoji(all_text):
240
- support = emoji_support(emoji_assets, probe=True)
240
+ _ff = args.font_file or brand.get("font_file")
241
+ support = emoji_support(emoji_assets, probe=True, fonts_dir=os.path.dirname(os.path.abspath(_ff)) if _ff else None)
241
242
  emoji_mode = support["mode"] if args.emoji == "auto" else args.emoji
242
243
  if args.emoji == "color" and not support["libass_color"]:
243
244
  die("--emoji color: this ffmpeg renders emoji monochrome through libass "
244
245
  f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono", kind="input")
245
246
  if args.emoji == "png" and not emoji_assets:
246
247
  die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
248
+ if args.emoji == "mono" and support.get("libass_glyph") is False:
249
+ die("--emoji mono: this ffmpeg draws no emoji glyph through libass "
250
+ f"({support['detail']}), so they would come out as empty boxes -- pass --emoji-assets DIR "
251
+ "for colour, or --emoji none", kind="input")
247
252
  if args.text_render == "drawtext" and shaping:
248
253
  die(f"{LANGUAGE_NAMES.get(_script, _script)} text cannot be shaped by drawtext on any ffmpeg "
249
254
  "build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
@@ -642,6 +647,7 @@ def main(force_route: Optional[str] = None) -> int:
642
647
  "assets": emoji_assets, "missing": missing,
643
648
  "overlays": len(emoji_overlays)}
644
649
  ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
650
+ require_filter("ass", "drawing this text through libass") # before the sidecar is written
645
651
  if STATE.dry_run:
646
652
  info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
647
653
  else:
@@ -682,7 +688,7 @@ def main(force_route: Optional[str] = None) -> int:
682
688
  chains = list(fc) if fc else [f"[0:v]{text or 'null'}[vout]"]
683
689
  last = "vout"
684
690
  if route == "ass":
685
- vf = f"ass={escape_filter_path(ass_path)}"
691
+ vf = f"ass=filename={escape_filter_path(ass_path)}"
686
692
  fdir = ass_fonts_dir()
687
693
  if fdir:
688
694
  vf += f":fontsdir={escape_filter_path(fdir)}"
package/scripts/grid.py CHANGED
@@ -29,6 +29,8 @@ from _common import add_common, apply_common, aac_args, cfr_args, default_output
29
29
  draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
31
  from _common import drawtext_fontfile # noqa: E402
32
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
33
+ from _common.emit import note, record_fallback # noqa: E402
32
34
 
33
35
  LABEL_MARGIN = 10
34
36
 
@@ -56,6 +58,13 @@ def main() -> int:
56
58
  add_common(ap)
57
59
  args = ap.parse_args()
58
60
  apply_common(args)
61
+ if args.label == "auto" and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
62
+ # the labels are the only drawtext in the grid: without the filter (Homebrew's plain
63
+ # ffmpeg) draw the grid unlabelled and say so, as look.py does for its stamps (#394)
64
+ args.label = "none"
65
+ record_fallback("this ffmpeg build has no drawtext filter, so the cells are not labelled "
66
+ "(the same as --label none)", "grid_no_drawtext")
67
+ note("no cell labels: this ffmpeg build has no drawtext filter (" + filter_missing_hint("drawtext") + ")")
59
68
 
60
69
  n = args.cols * args.rows
61
70
  if args.cols <= 0 or args.rows <= 0:
package/scripts/look.py CHANGED
@@ -19,6 +19,8 @@ from typing import List, Optional
19
19
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
20
20
  from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
21
21
  from _common import drawtext_fontfile # noqa: E402
22
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
23
+ from _common.emit import note, record_fallback # noqa: E402
22
24
 
23
25
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
24
26
 
@@ -162,6 +164,14 @@ def main() -> int:
162
164
  # a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
163
165
  # own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
164
166
  # nothing can be resolved, unchanged from before this existed.
167
+ if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
168
+ # a build without drawtext (Homebrew's plain ffmpeg) cannot stamp the timecodes, but the
169
+ # sheet itself only needs tile: draw it without them and say so, rather than failing the
170
+ # step every picture change ends with (#394)
171
+ args.no_timecode = True
172
+ record_fallback("this ffmpeg build has no drawtext filter, so the sheet has no timecode stamps "
173
+ "(the same as --no-timecode)", "look_no_drawtext")
174
+ note("no timecode stamps: this ffmpeg build has no drawtext filter (" + filter_missing_hint("drawtext") + ")")
165
175
  if args.no_timecode:
166
176
  font_prefix, tc = "", "" # nothing is drawn, so no font is resolved or copied
167
177
  else:
package/scripts/scenes.py CHANGED
@@ -27,6 +27,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
27
27
  ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
28
28
  rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
29
29
  from _common import drawtext_fontfile # noqa: E402
30
+ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
+ from _common.emit import note, record_fallback # noqa: E402
30
32
 
31
33
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
32
34
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -287,6 +289,13 @@ def main() -> int:
287
289
  fps = meta["video"].get("fps") or 30.0
288
290
  expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
289
291
  stamp = ""
292
+ if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
293
+ # as look.py (#394): the sheet needs drawtext only for its stamps
294
+ args.no_timecode = True
295
+ record_fallback("this ffmpeg build has no drawtext filter, so the --sheet has no timecode stamps "
296
+ "(the same as --no-timecode)", "scenes_sheet_no_drawtext")
297
+ note("--sheet without timecode stamps: this ffmpeg build has no drawtext filter ("
298
+ + filter_missing_hint("drawtext") + ")")
290
299
  if not args.no_timecode:
291
300
  default_font = default_font_file("DejaVu Sans")
292
301
  font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
@@ -297,7 +306,7 @@ def main() -> int:
297
306
  info(f"wrote {args.sheet}")
298
307
  result["sheet"] = args.sheet
299
308
 
300
- if args.json:
309
+ if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
301
310
  emit(None, **result)
302
311
  else:
303
312
  print_json(result)