ffmpeg-skill 3.1.3 → 3.1.4

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 CHANGED
@@ -1,14 +1,7 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="brand/app-icon-dark.png">
4
- <img src="brand/app-icon.png" width="128" alt="ffmpeg-skill app icon">
5
- </picture>
6
- </p>
7
-
8
- <p align="center">
9
- <picture>
10
- <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
11
- <img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
3
+ <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-horizontal-on-dark.svg">
4
+ <img src="brand/logo/ffmpeg-skill-horizontal.svg" height="72" alt="ffmpeg-skill">
12
5
  </picture>
13
6
  </p>
14
7
 
@@ -17,6 +10,7 @@ Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpe
17
10
  No cloud · No API keys · Python standard library</p>
18
11
 
19
12
  <p align="center">
13
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><b>Website</b></a> ·
20
14
  <a href="#quick-start"><b>Quick start</b></a> ·
21
15
  <a href="docs/demos.md">Demos</a> ·
22
16
  <a href="#tools">Tools</a> ·
@@ -32,6 +26,10 @@ No cloud · No API keys · Python standard library</p>
32
26
  <a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
33
27
  </p>
34
28
 
29
+ <p align="center">
30
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><img src="brand/og.png" width="720" alt="ffmpeg-skill v3: give your coding agent a video editor. An agent runs probe, cut, fit, export and check, and the final file is measured: 59.98 s, 1080x1920, 30 fps."></a>
31
+ </p>
32
+
35
33
  ```bash
36
34
  npx ffmpeg-skill
37
35
  ```
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.1.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.4`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.1.3", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.4", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -474,7 +474,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
474
474
  return _x264_raw(crf, preset, keep_bt709)
475
475
  if codec == "hevc":
476
476
  if hdr:
477
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
477
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
478
478
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
479
479
  "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
480
480
  params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
@@ -485,7 +485,8 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
485
485
  if "libsvtav1" in ffmpeg_encoders():
486
486
  args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
487
487
  if hdr:
488
- args += hdr_tags
488
+ svt = hdr10_svtav1_params(v)
489
+ args += (["-svtav1-params", svt] if svt else []) + hdr_tags
489
490
  elif keep_bt709:
490
491
  params, extra = _sdr_bt709("libsvtav1")
491
492
  args += (["-svtav1-params", params] if params else []) + extra
@@ -508,6 +509,42 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
508
509
  return []
509
510
 
510
511
 
512
+ def hdr10_x265_params(v: Dict[str, Any]) -> str:
513
+ """The source's HDR10 static metadata as x265 options (":master-display=...:max-cll=..."), or "".
514
+ FFmpeg before 7.0 does not pass the decoded frames' mastering-display / content-light side data
515
+ to libx265, so without these a 6.1 re-encode wrote PQ video with neither (#401). Given on 7+ too,
516
+ where they restate what FFmpeg would pass. x265 units: chromaticity 0.00002, luminance 0.0001 cd/m2."""
517
+ md, cl = _hdr10_parts(v)
518
+ out = ""
519
+ if md:
520
+ c = lambda k: round(md[k] * 50000)
521
+ out += (f":master-display=G({c('green_x')},{c('green_y')})B({c('blue_x')},{c('blue_y')})R({c('red_x')},{c('red_y')})"
522
+ f"WP({c('white_point_x')},{c('white_point_y')})L({round(md['max_luminance'] * 10000)},{round(md['min_luminance'] * 10000)})")
523
+ if cl:
524
+ out += f":max-cll={cl['max_content']},{cl['max_average']}"
525
+ return out
526
+
527
+
528
+ def hdr10_svtav1_params(v: Dict[str, Any]) -> str:
529
+ """The same for SVT-AV1 (-svtav1-params), which takes chromaticities and cd/m2 as decimals."""
530
+ md, cl = _hdr10_parts(v)
531
+ parts = []
532
+ if md:
533
+ f = lambda k: f"{md[k]:.5f}".rstrip("0").rstrip(".")
534
+ parts.append(f"mastering-display=G({f('green_x')},{f('green_y')})B({f('blue_x')},{f('blue_y')})R({f('red_x')},{f('red_y')})"
535
+ f"WP({f('white_point_x')},{f('white_point_y')})L({f('max_luminance')},{f('min_luminance')})")
536
+ if cl:
537
+ parts.append(f"content-light={cl['max_content']},{cl['max_average']}")
538
+ return ":".join(parts)
539
+
540
+
541
+ def _hdr10_parts(v: Dict[str, Any]) -> "Tuple[Optional[Dict[str, float]], Optional[Dict[str, int]]]":
542
+ st = v.get("hdr10_static") or {}
543
+ if v.get("color_transfer") != "smpte2084":
544
+ return None, None
545
+ return st.get("mastering_display"), st.get("content_light")
546
+
547
+
511
548
  def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
512
549
  """`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
513
550
  bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
@@ -667,7 +704,7 @@ def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
667
704
  cs = v.get("color_space") or "bt2020nc"
668
705
  prim = v.get("color_primaries") or "bt2020"
669
706
  trc = v.get("color_transfer") or "arib-std-b67"
670
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
707
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
671
708
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
672
709
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
673
710
 
@@ -137,6 +137,30 @@ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
137
137
  ctx.notes.append(note_line)
138
138
 
139
139
 
140
+ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None:
141
+ """A PQ source's mastering-display / content-light metadata that a PQ output came out without
142
+ (#401): measured on the written file, so whatever encoder or FFmpeg version dropped it is said.
143
+ An output that is no longer PQ (color.py --to-sdr, an HLG conversion) was converted on purpose."""
144
+ first = getattr(STATE, "first_input", None)
145
+ src = first[2] if first and len(first) > 2 else None
146
+ v = (meta or {}).get("video") or {}
147
+ if not src or v.get("color_transfer") != "smpte2084":
148
+ return
149
+ if len(getattr(STATE, "video_inputs", None) or ()) > 1:
150
+ return # several pictures: the first input's metadata need not be the output's source
151
+ have = v.get("hdr10_static") or {}
152
+ lost = [k for k in ("mastering_display", "content_light") if src.get(k) and not have.get(k)]
153
+ if not lost:
154
+ return
155
+ names = {"mastering_display": "mastering display", "content_light": "content light level"}
156
+ warn("the source's HDR10 " + " and ".join(names[k] for k in lost) + " metadata did not come through to this output "
157
+ "(the encoder or this FFmpeg build did not write it); players may tone-map it differently",
158
+ "hdr10_metadata_dropped", ctx=ctx, dropped=lost, codec=v.get("codec"))
159
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
160
+ if note_line and note_line not in ctx.notes:
161
+ ctx.notes.append(note_line)
162
+
163
+
140
164
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
141
165
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
142
166
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -178,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
178
202
 
179
203
 
180
204
  def note(msg: str, ctx: "Optional[Context]" = None) -> None:
181
- """A plain `notes` line that also goes to stderr."""
205
+ """A plain `notes` line that also goes to stderr, made inert as messages are (#400)."""
182
206
  ctx = ctx or STATE
207
+ msg = neutralize(msg)
183
208
  if msg not in ctx.notes:
184
209
  ctx.notes.append(msg)
185
210
  sys.stderr.write(f"{msg}\n")
@@ -682,6 +707,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
682
707
  if output and not ctx.dry_run:
683
708
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
684
709
  _report_dropped_audio_tracks(meta, ctx)
710
+ _report_dropped_hdr10_metadata(meta, ctx)
685
711
  if ctx.json:
686
712
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
687
713
  if meta:
@@ -317,6 +317,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
317
317
  "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
318
318
  "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
319
319
  "dolby_vision": dovi,
320
+ # HDR10 static metadata (mastering display, content light level) as the encoders take it:
321
+ # FFmpeg before 7 does not hand it from the decoded frames to libx265 / libsvtav1, so the
322
+ # tools pass it explicitly (decision.hdr10_x265_params). None when the source has none.
323
+ "hdr10_static": _hdr10_static(ffprobe, path, video) if trc == "smpte2084" else None,
320
324
  "color_space": video.get("color_space"),
321
325
  "color_primaries": video.get("color_primaries"),
322
326
  "color_transfer": video.get("color_transfer"),
@@ -355,13 +359,76 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
355
359
  "language": (a.get("tags") or {}).get("language"),
356
360
  "title": (a.get("tags") or {}).get("title"),
357
361
  } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
362
+ if role == "input" and out.get("video") and out["video"].get("codec") \
363
+ and os.path.splitext(path)[1].lower() not in _STILL_EXT:
364
+ # a still (an overlay's logo, an insert card) is not a second picture source (#412 review)
365
+ STATE.video_inputs.add(os.path.realpath(path))
358
366
  if role == "input" and out.get("audio_streams"):
359
367
  STATE.audio_inputs.add(os.path.realpath(path))
360
368
  if role == "input" and getattr(STATE, "first_input", None) is None:
361
- STATE.first_input = (path, len(out.get("audio_streams") or []))
369
+ STATE.first_input = (path, len(out.get("audio_streams") or []),
370
+ (out.get("video") or {}).get("hdr10_static"))
362
371
  return out
363
372
 
364
373
 
374
+ def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
375
+ """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
376
+ a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
377
+ frame is decoded when either part is missing or incomplete in the stream. Each part is taken
378
+ from wherever it is complete first, the stream before the frame. Chromaticities are CIE 1931
379
+ x/y, luminances cd/m2. None when it has neither."""
380
+ out = _hdr10_parts(stream.get("side_data_list") or [])
381
+ if len(out) < 2:
382
+ # V:0, not v:0: an attached cover picture before the motion video has no HDR side data
383
+ proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-read_intervals", "%+#1", "-show_frames",
384
+ "-show_entries", "frame=side_data_list", "-of", "json", path], quiet=True, check=False)
385
+ frames: List[Dict[str, Any]] = []
386
+ if proc.returncode == 0:
387
+ try:
388
+ frames = json.loads(proc.stdout or "{}").get("frames") or []
389
+ except ValueError:
390
+ pass
391
+ for k, v in _hdr10_parts((frames[0].get("side_data_list") or []) if frames else []).items():
392
+ out.setdefault(k, v)
393
+ return out or None
394
+
395
+
396
+ def _hdr10_parts(sides: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
397
+ """The complete parts in one side-data list: a mastering display with every field, a content
398
+ light level with both values; an incomplete record counts as absent."""
399
+ out: Dict[str, Any] = {}
400
+ for sd in sides:
401
+ kind = _side_kind(sd)
402
+ if kind == "mastering" and "mastering_display" not in out:
403
+ vals = {k: _ratio(sd.get(k)) for k in ("red_x", "red_y", "green_x", "green_y", "blue_x", "blue_y",
404
+ "white_point_x", "white_point_y", "min_luminance", "max_luminance")}
405
+ if all(v is not None for v in vals.values()):
406
+ out["mastering_display"] = {k: float(v) for k, v in vals.items()}
407
+ elif kind == "light" and "content_light" not in out:
408
+ mc, ma = _to_int(sd.get("max_content")), _to_int(sd.get("max_average"))
409
+ if mc is not None and ma is not None:
410
+ out["content_light"] = {"max_content": mc, "max_average": ma}
411
+ return out
412
+
413
+
414
+ _STILL_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif", ".heic")
415
+
416
+
417
+ def _ratio(v: Any) -> "Optional[Fraction]":
418
+ """A side-data value such as "34000/50000" or "0/10000". Unlike _fraction() a zero is a value
419
+ (a mastering display's minimum luminance may be 0 cd/m2); only a missing or malformed one is None."""
420
+ try:
421
+ f = Fraction(str(v))
422
+ except (ValueError, ZeroDivisionError, TypeError):
423
+ return None
424
+ return f if f >= 0 else None
425
+
426
+
427
+ def _side_kind(sd: Dict[str, Any]) -> "Optional[str]":
428
+ t = str(sd.get("side_data_type") or "")
429
+ return "mastering" if t == "Mastering display metadata" else "light" if t == "Content light level metadata" else None
430
+
431
+
365
432
  def _bit_depth(pix_fmt: Optional[str]) -> int:
366
433
  """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
367
434
  chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
@@ -324,7 +324,7 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
328
328
 
329
329
  def __init__(self) -> None:
330
330
  self.reset()
@@ -333,6 +333,7 @@ class Context:
333
333
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
334
334
  self.json = False # emit() prints a JSON document instead of the output path
335
335
  self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
+ self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
336
337
  self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
337
338
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
338
339
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
@@ -464,7 +464,8 @@ DIAGNOSTIC_CODES = {
464
464
  "crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
465
465
  # fonts and text
466
466
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
467
- "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams); {source, kept, dropped}"),
467
+ "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
468
+ "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
468
469
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
469
470
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
470
471
  "look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
package/scripts/batch.py CHANGED
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
36
  from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
37
- from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
37
+ from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
38
38
 
39
39
  HERE = Path(__file__).resolve().parent
40
40
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -722,7 +722,7 @@ def main() -> int:
722
722
  why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
723
723
  info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
724
724
  f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
725
- unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
725
+ unverified = [shown(os.path.basename(r["file"])) for r in results if r.get("ok") and not r.get("verified")]
726
726
  if unverified and not STATE.dry_run:
727
727
  note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
728
728
  + ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))