ffmpeg-skill 2.24.0 → 2.24.1

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/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 (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.24.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.24.1`) | 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`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"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"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.24.0", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.24.1", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -851,7 +851,7 @@ dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ff
851
851
  tool used to print to stderr only (emoji fallbacks, silent or short inputs, memory-heavy jobs,
852
852
  `--strip-dovi` without Dolby Vision, `render.py`'s frame-from-preset and `--normalize` defaults).
853
853
  `render.py`'s plan, check and cache records (`check_dry_run`, `check_output_unparsed`, `cache_entry_damaged`, ...),
854
- `export.py`'s `loudness_not_measured`, `loudness_outside_spec` and `colour_not_converted`, `multicam.py`'s
854
+ `export.py`'s `loudness_not_measured`, `loudness_outside_spec`, `colour_not_converted`, `size_over_limit` and `repair_incomplete`, `multicam.py`'s
855
855
  `camera_not_started`, `report.py`'s `report_section_missing` and `batch.py`'s cache records
856
856
  (`batch_cache_reset`, `batch_cache_output_changed`, ...) are listed there too. Every one of them
857
857
  still prints its stderr line; the record is additive.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.24.0",
3
+ "version": "2.24.1",
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",
@@ -830,7 +830,9 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
830
830
 
831
831
 
832
832
  # What a stream-copy join compares (cut.py --segments, join.py --copy).
833
- IN_BAND_CONFIG = ("mp3", "mp2")
833
+ # codecs that carry no out-of-band configuration in these containers, so no extradata on
834
+ # either side is a match, not a difference
835
+ IN_BAND_CONFIG = ("mp3", "mp2", "vp8", "vp9")
834
836
  TS_EXTS = (".ts", ".m2ts", ".mts")
835
837
  _SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
836
838
  "sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
@@ -879,8 +881,9 @@ def signatures_match(sigs: list, ext: str) -> bool:
879
881
 
880
882
  # check.py --compare (2.24): the output's picture against a reference, frame by frame.
881
883
  COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
882
- _SSIM_SUMMARY = re.compile(r"SSIM Y:[0-9.]+ .*All:([0-9.]+)")
883
- _PSNR_SUMMARY = re.compile(r"PSNR y:\S+ .*average:(\S+)")
884
+ # the channel letters follow the negotiated format: Y U V (or y u v) for YUV, R G B for RGB
885
+ _SSIM_SUMMARY = re.compile(r"SSIM [A-Za-z]:[0-9.]+ .*All:([0-9.]+)")
886
+ _PSNR_SUMMARY = re.compile(r"PSNR [a-z]:\S+ .*average:(\S+)")
884
887
  _SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
885
888
 
886
889
 
@@ -894,8 +897,13 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
894
897
  stats = os.path.join(tmp, "ssim.log")
895
898
  # frames paired by index (settb/setpts=N): ssim and psnr otherwise pair by timestamp, so a
896
899
  # shifted or irregular timestamp would meet the wrong reference frame
897
- graph = ("[0:V:0]settb=1/1000,setpts=N,split[a0][a1];[1:V:0]settb=1/1000,setpts=N,split[b0][b1];"
898
- f"[a0][b0]ssim=stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr")
900
+ # shortest=1: by default the shorter file's last frame is repeated against the rest of
901
+ # the longer one, which measures pairs that do not exist. format=yuv444p on both: the
902
+ # filters otherwise take the first input's format, so an RGB file against a YUV one
903
+ # measured differently by argument order
904
+ graph = ("[0:V:0]settb=1/1000,setpts=N,format=yuv444p,split[a0][a1];"
905
+ "[1:V:0]settb=1/1000,setpts=N,format=yuv444p,split[b0][b1];"
906
+ f"[a0][b0]ssim=shortest=1:stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr=shortest=1")
899
907
  proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
900
908
  "-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
901
909
  if proc.returncode != 0:
package/scripts/check.py CHANGED
@@ -35,7 +35,7 @@ import json
35
35
  import re
36
36
  import sys
37
37
  from fractions import Fraction
38
- from typing import Any, Dict, List, Optional
38
+ from typing import Any, Dict, List, Optional, Tuple
39
39
 
40
40
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
41
41
  from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
@@ -95,6 +95,12 @@ def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[L
95
95
  return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
96
96
 
97
97
 
98
+ def display_size(v: Dict[str, Any]) -> "Tuple[int, int]":
99
+ """The frame size as decoded and auto-rotated, which is what --compare's ffmpeg pass sees."""
100
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
101
+ return (h, w) if int(v.get("rotation") or 0) % 180 == 90 else (w, h)
102
+
103
+
98
104
  def _span_total(spans: Any) -> float:
99
105
  return sum(max(0.0, e - s) for s, e in spans)
100
106
 
@@ -147,6 +153,15 @@ def main() -> int:
147
153
  if args.max_mb is not None:
148
154
  spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
149
155
 
156
+ expected_identity = None
157
+ if args.expect_identity is not None:
158
+ # parsed before any measurement; "" (an unset shell variable) is malformed, not absent
159
+ try:
160
+ size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
161
+ except ValueError:
162
+ die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
163
+ expected_identity = {"size": size, "mtime_ns": mtime_ns}
164
+
150
165
  # read before anything is measured and again after: a file replaced mid-check is not the one graded
151
166
  identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
152
167
  meta = probe(args.input)
@@ -368,8 +383,8 @@ def main() -> int:
368
383
  if not v or not rv:
369
384
  row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
370
385
  reason="one of the files has no picture")
371
- elif (v.get("width"), v.get("height")) != (rv.get("width"), rv.get("height")):
372
- row("similarity", "WARN", f"not compared: {v.get('width')}x{v.get('height')} vs {rv.get('width')}x{rv.get('height')}",
386
+ elif display_size(v) != display_size(rv):
387
+ row("similarity", "WARN", "not compared: %dx%d vs %dx%d (as displayed)" % (display_size(v) + display_size(rv)),
373
388
  "the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
374
389
  reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
375
390
  else:
@@ -393,17 +408,15 @@ def main() -> int:
393
408
  nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
394
409
  if nref and nout and int(nref) != int(nout):
395
410
  STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
396
- f"order, so an added or dropped frame shifts every later pair")
411
+ f"order, so an added or dropped frame shifts every later pair, and only the "
412
+ f"first {min(int(nref), int(nout))} were compared")
397
413
  # read after every measurement, --compare's included, so a replacement at any point shows
398
414
  identity = None if dry_run_input_pending(args.input) else file_identity(args.input)
399
- if identity is not None and identity != identity_before and not args.expect_identity:
415
+ if identity is not None and identity != identity_before and expected_identity is None:
400
416
  STATE_NOTES.append("the file changed while it was being checked: input_identity is the file as it was "
401
417
  "at the end, and the rows may describe an earlier one; check it again")
402
- if args.expect_identity:
403
- try:
404
- size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
405
- except ValueError:
406
- die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
418
+ if expected_identity is not None:
419
+ size, mtime_ns = expected_identity["size"], expected_identity["mtime_ns"]
407
420
  if identity is not None:
408
421
  changed = identity != identity_before
409
422
  same = identity == {"size": size, "mtime_ns": mtime_ns} and not changed
package/scripts/cut.py CHANGED
@@ -80,7 +80,11 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
80
80
  piece anyone asked to keep. Refuses a removal that would leave nothing."""
81
81
  least = 1.0 / fps if fps else 0.01
82
82
  keep, cursor = [], 0.0
83
- for s, e in sorted((max(0.0, s), min(total, e)) for s, e in removed):
83
+ outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
84
+ if outside:
85
+ die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
86
+ "so it removes nothing; check the units (seconds, mm:ss)")
87
+ for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
84
88
  if e <= cursor:
85
89
  continue
86
90
  if s - cursor >= least:
package/scripts/export.py CHANGED
@@ -249,16 +249,20 @@ _TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
249
249
  def media_damage(path: str) -> Dict[str, int]:
250
250
  """One full decode of `path` at -v warning: lines about timestamps (non-monotonic DTS, invalid
251
251
  PTS) and every other error line, counted. What --repair is measured by, before and after."""
252
- proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "warning", "-i", path, "-f", "null", "-"],
252
+ # repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
253
+ # N timestamp warnings and was itself counted as a decode error
254
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
253
255
  check=False, record=True)
254
256
  lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
255
- stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln))
257
+ # "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
258
+ stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln) and "corrupt" not in ln.lower())
256
259
  return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
257
260
 
258
261
 
259
262
  MIB = 1024 * 1024
260
263
  SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
261
264
  SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
265
+ SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
262
266
 
263
267
 
264
268
  def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
@@ -281,6 +285,9 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
281
285
  if has_audio and "-b:a" in p["audio"]:
282
286
  audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
283
287
  video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
288
+ if video_kbps > SIZE_MAX_VIDEO_KBPS:
289
+ die(f"--max-mb {args.max_mb:g} allows {video_kbps / 1000:.0f} Mb/s over {dur:.1f}s, beyond any delivery bitrate; "
290
+ "a limit that large constrains nothing", hint="drop --max-mb (the preset's quality setting applies)")
284
291
  if video_kbps < SIZE_MIN_VIDEO_KBPS:
285
292
  die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
286
293
  f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
@@ -365,6 +372,9 @@ def main() -> int:
365
372
  vf.append(f"scale={p['w']}:-2")
366
373
  if args.repair and args.preset != "copy":
367
374
  die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
375
+ if args.max_mb is not None and args.preset == "gif":
376
+ # the GIF branch below returns before the size is ever applied or measured
377
+ die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
368
378
  cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
369
379
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
370
380
  # kept where the container can hold them: ffmpeg's default stream selection takes one video
package/scripts/join.py CHANGED
@@ -345,6 +345,45 @@ def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
345
345
  return (proc.stdout or "").split() if proc.returncode == 0 else []
346
346
 
347
347
 
348
+ SPAN_HEAD_PACKETS = 64 # enough to see the earliest presentation time past B-frame reordering
349
+ SPAN_TAIL_SECONDS = 10.0 # read from this far before the end, so a seek that lands early still reaches it
350
+
351
+
352
+ def _stream_span(path: str, kind: str, duration: float = 0.0, start: float = 0.0) -> "Optional[Tuple[float, float]]":
353
+ """First packet's time and last packet's time plus its duration for the first `kind` stream
354
+ (V or a): measured, because Matroska/WebM state no stream length and MPEG-TS only an estimate.
355
+ None when the packets cannot be read or one has no time: a partial span would invent a gap."""
356
+ # JSON, not csv: older ffprobe builds (5.1) print side-data rows between the packet rows in csv.
357
+ # A long clip is read at its head and its tail only: every packet of a two-hour file is
358
+ # hundreds of thousands of entries for two numbers
359
+ cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", f"{kind}:0", "-show_entries",
360
+ "packet=pts_time,dts_time,duration_time", "-of", "json"]
361
+ if duration > 3 * SPAN_TAIL_SECONDS:
362
+ # read_intervals takes timestamps, so the tail starts from the file's own start time
363
+ # (an MPEG-TS clock can start hours in; an edit list can start it below zero)
364
+ cmd += ["-read_intervals", f"%+#{SPAN_HEAD_PACKETS},{start + duration - SPAN_TAIL_SECONDS:.3f}%"]
365
+ proc = run(cmd + [path], quiet=True, check=False)
366
+ try:
367
+ packets = json.loads(proc.stdout or "{}").get("packets") or [] if proc.returncode == 0 else []
368
+ except ValueError:
369
+ return None
370
+ times = []
371
+ for pkt in packets:
372
+ try:
373
+ # a packet ffprobe gives no pts (some muxers leave it on B-frames) still has its dts
374
+ t = float(pkt.get("pts_time", pkt.get("dts_time")))
375
+ except (TypeError, ValueError):
376
+ return None
377
+ try:
378
+ d = float(pkt.get("duration_time") or 0.0)
379
+ except (TypeError, ValueError):
380
+ d = 0.0
381
+ times.append((t, t + d))
382
+ if not times:
383
+ return None
384
+ return min(t for t, _ in times), max(e for _, e in times)
385
+
386
+
348
387
  def join_copy(args, metas: List[Dict[str, Any]]) -> int:
349
388
  """--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
350
389
  way, and measured afterwards (video packet count and length against the clips')."""
@@ -354,6 +393,11 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
354
393
  die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
355
394
  if args.sample_rate or args.channels:
356
395
  die("--copy keeps every clip's own sound: --sample-rate and --channels need a re-encode, so drop --copy")
396
+ encoder = [f for f, on in (("--codec", STATE.codec), ("--hw", STATE.hw and STATE.hw_source == "flag"),
397
+ ("--quality", getattr(args, "quality_given", False)))
398
+ if on]
399
+ if encoder:
400
+ die(f"--copy keeps every clip's own encoding: {', '.join(encoder)} choose(s) an encode, so drop --copy")
357
401
  output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
358
402
  guard_sidecar(output, *args.inputs)
359
403
  ext = os.path.splitext(output)[1].lower()
@@ -391,15 +435,19 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
391
435
  hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
392
436
  gaps = []
393
437
  for p, m in zip(args.inputs, metas):
394
- # each stream's start, and its end (start plus length) where the container states one;
395
- # Matroska/WebM give no stream length, but the starts still have to agree
396
- vs, as_ = m.get("video") or {}, m.get("audio") or {}
397
- if not (vs and as_):
438
+ # each stream's span from its packets: Matroska/WebM state no stream length and an
439
+ # MPEG-TS stream length is an estimate, so a stated duration missed or invented gaps
440
+ if not (m.get("video") and m.get("audio")):
398
441
  continue
399
- vb, ab = float(vs.get("start_time") or 0.0), float(as_.get("start_time") or 0.0)
400
- vd, ad = vs.get("duration"), as_.get("duration")
401
- ve, ae = (vb + vd if vd else None), (ab + ad if ad else None)
402
- if abs(vb - ab) > COPY_AV_GAP or (ve is not None and ae is not None and abs(ve - ae) > COPY_AV_GAP):
442
+ length, origin = float(m.get("duration") or 0.0), float(m.get("start_time") or 0.0)
443
+ v_span, a_span = _stream_span(p, "V", length, origin), _stream_span(p, "a", length, origin)
444
+ if v_span is None or a_span is None:
445
+ refuse("input", "join_copy_mismatch",
446
+ f"--copy: could not measure where picture and sound start and end in {p} (a packet without a "
447
+ "time), so a gap at the join could not be ruled out", hint="drop --copy to re-encode the join",
448
+ clips=[{"path": p, "differs": {"av_span": "unmeasured"}}])
449
+ (vb, ve), (ab, ae) = v_span, a_span
450
+ if abs(vb - ab) > COPY_AV_GAP or abs(ve - ae) > COPY_AV_GAP:
403
451
  gaps.append({"path": p, "video": [round(vb, 3), None if ve is None else round(ve, 3)],
404
452
  "audio": [round(ab, 3), None if ae is None else round(ae, 3)]})
405
453
  if gaps:
@@ -452,7 +500,8 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
452
500
  info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
453
501
  emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
454
502
  verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
455
- stream_reference=metas)
503
+ stream_reference=metas,
504
+ dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
456
505
  return 0
457
506
 
458
507
 
@@ -492,6 +541,8 @@ def main() -> int:
492
541
  aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
493
542
  add_common(ap)
494
543
  args = ap.parse_args()
544
+ # given at all, not only different from the default: --quality 18 asks for an encode as much as 23 does
545
+ args.quality_given = any(a == "--quality" or a.startswith("--quality=") for a in sys.argv[1:])
495
546
  apply_common(args)
496
547
  if args.fps is not None and args.fps <= 0:
497
548
  die(f"--fps must be positive, got {args.fps:g}")
package/scripts/render.py CHANGED
@@ -1192,7 +1192,12 @@ def main() -> int:
1192
1192
  work.mkdir(parents=True, exist_ok=True)
1193
1193
  except OSError as e:
1194
1194
  die(f"cannot create the work directory {work}: {e}")
1195
- if not args.keep and not args.work:
1195
+ # --stop-after before the export reports a stage file inside the work directory as its output:
1196
+ # removing the directory at exit deleted the file the result named (and its output_identity)
1197
+ previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
1198
+ if previewing and not args.keep and not args.work:
1199
+ info(f"--stop-after {args.stop_after}: keeping the work directory {work}, since the result's output is in it")
1200
+ if not args.keep and not args.work and not previewing:
1196
1201
  # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
1197
1202
  # auto-named directory is ours alone, so remove it on every exit path
1198
1203
  import atexit