ffmpeg-skill 3.6.2 → 3.6.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
@@ -676,6 +676,8 @@ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `mai
676
676
 
677
677
  **Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
678
678
 
679
+ **A version that was tagged but never reached npm** (the npm step failed; a re-run of that release sees the tag and skips the publish) is finished by dispatching the same workflow on its tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`. That runs only the workflow's `recover` job. It refuses a version that is not `X.Y.Z`, has no tag, or that npm already has, and a run whose commit is not the tagged one: the npm provenance attestation names the run's commit, so it must be the commit the package is built from. It publishes with `--provenance` under the `latest` dist-tag when the version is above every stable (non-prerelease) version npm has, and under `backport` otherwise, so `latest` never moves back. Then it publishes the `plugin` branch when no commit there is for this version and the branch's tip is older, and the MCP Registry entry when the registry does not list the version (never for a `backport`). The GitHub Release is left as it is. A tag cut before the job was added (#448; every tag up to v3.6.3) has no `recover` job at its commit and cannot be recovered this way.
680
+
679
681
  Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
680
682
 
681
683
  ## Docs
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.6.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.6.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.6.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.6.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"},
@@ -760,6 +760,7 @@ silence a tool reported as fine while another measurement disagreed):
760
760
  | `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
761
761
  | `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
762
762
  | `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
763
+ | `frame_rate`, `clip_frames`, `transition_frames` | `join.py` (video join; added after 3.6.2) | the join's rate as an exact fraction (`"30000/1001"`), the whole frames of it each clip holds (its sound is cut to the samples those frames last), and the frames each transition overlaps (`--duration` rounded to whole frames, at least 1; `0` for `--transition none`). Lengths and xfade offsets are counted in these frames, never in rounded seconds (#455, #468); `inputs_duration` and `expected_duration` are derived from them. A pending `--dry-run` input counts 0 frames |
763
764
  | `margins` | `silence.py` | `{lead, tail}`: the seconds of each silence kept before speech starts (`--margin-lead`) and after it ends (`--margin-tail`); both equal `--margin` unless one is named. Absent under `--filler` with neither `--speech-aware` nor a sentence flag, which removes no silences |
764
765
  | `silence_positions`, `sentence` | `silence.py --sentence-min-silence` / `--sentence-margin-tail` | per entry of `silences`, `sentence_end` when the transcript word before it ends in sentence-final punctuation, else `inside`; and `{min_silence, margin_tail, source, word_timings, sentence_end_words, silences}`. `sentence_end_words: 0` means the transcript had no such punctuation, so every silence used `--min-silence` and `--margin-tail` (warning `sentence_ends_not_found`) |
765
766
  | `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.2",
3
+ "version": "3.6.4",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import platform
12
12
  import argparse
13
+ import contextlib
13
14
  import re
14
15
  import shutil
15
16
  import subprocess
@@ -329,7 +330,7 @@ class Context:
329
330
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
330
331
  """
331
332
 
332
- __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
333
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "checked", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
333
334
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
334
335
  "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
335
336
  "source_timecode", "smpte_hint", "delivered", "expected_timecode")
@@ -356,6 +357,7 @@ class Context:
356
357
  self.overwrite = False # --overwrite: an existing output may be replaced
357
358
  self.written: set = set() # output paths this process has written itself
358
359
  self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
360
+ self.checked: dict = {} # real output path -> owned_identity() at its output check (None: absent), or as this run left it; refuse_changed_output() compares it at the replace
359
361
  self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
360
362
  self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
361
363
  self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
@@ -1226,6 +1228,9 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
1226
1228
  real = os.path.realpath(output)
1227
1229
  except OSError:
1228
1230
  continue
1231
+ # what the decision below is about: compared again where the file is replaced
1232
+ # (refuse_changed_output), so one put there during the run is not replaced unasked
1233
+ _record_checked(output, _checked_state(output) if exists else None)
1229
1234
  if not exists or real in STATE.written:
1230
1235
  continue
1231
1236
  size = mtime_ns = None
@@ -1259,11 +1264,163 @@ def _remember_output(cmd: Sequence[str]) -> None:
1259
1264
  output = cmd[-1]
1260
1265
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
1261
1266
  return
1267
+ _remember_identity(output)
1268
+ report_committed(output)
1269
+
1270
+
1271
+ def _checked_key(path: str) -> str:
1272
+ """The place a check is about: `path`'s directory resolved, its last component as named, so a
1273
+ symlink created at an absent target since the check is still that target (#480 review)."""
1274
+ full = os.path.abspath(path)
1275
+ return os.path.join(os.path.realpath(os.path.dirname(full)), os.path.basename(full))
1276
+
1277
+
1278
+ def _parent(path: str) -> str:
1279
+ return os.path.realpath(os.path.dirname(os.path.abspath(path)))
1280
+
1281
+
1282
+ def _checked_state(path: str) -> "Optional[Tuple[Optional[Dict[str, int]], bool]]":
1283
+ """What is at `path` now: (owned_identity, is a symlink), or None when nothing is there."""
1284
+ if not os.path.lexists(path):
1285
+ return None
1286
+ return (owned_identity(path), os.path.islink(path))
1287
+
1288
+
1289
+ def _record_checked(path: str, state: Any) -> None:
1290
+ """Record what a check (or this run's own write) found at `path`, with the directory the path
1291
+ resolved to then, under the resolved place and under the path as named: a directory symlink
1292
+ switched since is then found by the name and refused, not looked up as a place never checked."""
1293
+ entry = (_parent(path), state)
1294
+ key = _checked_key(path)
1295
+ STATE.checked[key] = entry
1296
+ STATE.checked[os.path.abspath(path)] = entry
1297
+ # the same file reached by another spelling of its path (a write through the resolved name
1298
+ # after a check through a link) is the same record: a later replace through either matches
1299
+ # -- only a record made in the same directory: one whose link has been switched since keeps
1300
+ # what it saw, so the replace through it is still refused
1301
+ for named in [k for k in STATE.checked if k != key]:
1302
+ try:
1303
+ if _checked_key(named) == key and STATE.checked[named][0] == entry[0]:
1304
+ STATE.checked[named] = entry
1305
+ except OSError:
1306
+ pass
1307
+
1308
+
1309
+ def _remember_identity(path: str) -> None:
1310
+ """Count `path` as this run's own and record the identity this run left it with, which a
1311
+ later replace of it compares (refuse_changed_output)."""
1262
1312
  try:
1263
- STATE.written.add(os.path.realpath(output))
1313
+ real = os.path.realpath(path)
1314
+ STATE.written.add(real)
1264
1315
  except OSError:
1265
- pass
1266
- report_committed(output)
1316
+ return
1317
+ _record_checked(path, _checked_state(path))
1318
+
1319
+
1320
+ def refuse_changed_output(path: str, cleanup: "Optional[Callable[[], None]]" = None) -> None:
1321
+ """Refuse to replace `path` when it is no longer the file its output check decided about.
1322
+
1323
+ refuse_existing_outputs() decides before the encode whether an existing target may be
1324
+ replaced (--overwrite, this run's own file, batch.py's own delivery) and records the
1325
+ target's identity (owned_identity(), None when absent); _remember_identity() records it again
1326
+ when this run writes it. Called under the output's lock just before the replace: a file
1327
+ another process put there meanwhile -- a different file at an existing target, or one at a
1328
+ target that was absent -- is refused (`kind: input`, `reason: output_exists`) and `cleanup`
1329
+ removes the new output's temp, unless --overwrite gave consent to replace whatever is at the
1330
+ path (as batch.py's deliver() does). A path with no recorded check is left alone."""
1331
+ if STATE.overwrite or not path or path == "-" or path.startswith("pipe:") or path.startswith("-"):
1332
+ return
1333
+ try:
1334
+ key, named, real = _checked_key(path), os.path.abspath(path), os.path.realpath(path)
1335
+ # the name first: a resolved place another path also reached says nothing about this one
1336
+ recorded = STATE.checked.get(named) or STATE.checked.get(key)
1337
+ if recorded is None:
1338
+ return
1339
+ (parent_then, expected), parent_now, now = recorded, _parent(path), _checked_state(path)
1340
+ except OSError:
1341
+ return
1342
+ if now == expected and parent_now == parent_then:
1343
+ return
1344
+ if cleanup:
1345
+ cleanup()
1346
+ if parent_now != parent_then:
1347
+ why = (f"is now in another directory ({parent_now!r}, checked in {parent_then!r}): a directory "
1348
+ "link on its path was changed during the run")
1349
+ elif expected is None:
1350
+ why = "did not exist when the run checked it and another process created it during the run"
1351
+ elif now is None:
1352
+ why = "was removed or replaced by something that is not a regular file during the run"
1353
+ else:
1354
+ why = "changed during the run (another process wrote or replaced it after the output check)"
1355
+ refuse("input", "output_exists",
1356
+ f"refusing to replace {path!r}: it {why}; the new output was not put there. Pass --overwrite to "
1357
+ "replace it anyway, or choose another -o path",
1358
+ hint="pass --overwrite, or choose another -o path",
1359
+ outputs=[{"path": path, "resolved": real, "changed_during_run": True,
1360
+ "expected": expected, "found": now}])
1361
+
1362
+
1363
+ def _remove_quietly(path: Optional[str]) -> None:
1364
+ if path:
1365
+ try:
1366
+ os.remove(path)
1367
+ except OSError:
1368
+ pass
1369
+
1370
+
1371
+ def replace_checked(tmp: str, final: str) -> None:
1372
+ """os.replace(tmp, final) for a file a tool writes itself (a time map, a normalised export):
1373
+ under `final`'s output lock, after refuse_changed_output(), which removes `tmp` when it
1374
+ refuses."""
1375
+ replace_checked_together([(tmp, final)])
1376
+
1377
+
1378
+ def replace_checked_together(pairs: "Sequence[Tuple[str, str]]") -> None:
1379
+ """Replace each (tmp, final) only once every final has passed refuse_changed_output(), all
1380
+ under their output locks: files that belong together (a fill and its key). A file already at a
1381
+ final is moved aside first and put back if a later replace fails, so a failed pair leaves
1382
+ what was there. Every tmp is removed on a refusal, a refused lock or a failed replace (#480
1383
+ review)."""
1384
+ def cleanup() -> None:
1385
+ for tmp, _final in pairs:
1386
+ _remove_quietly(tmp)
1387
+ aside: List[Tuple[str, Optional[str]]] = [] # (final, where its earlier file went, or None)
1388
+ try:
1389
+ with contextlib.ExitStack() as stack:
1390
+ for final in sorted({final for _tmp, final in pairs}):
1391
+ stack.enter_context(_OutputLock(final))
1392
+ for _tmp, final in pairs:
1393
+ refuse_changed_output(final, cleanup)
1394
+ try:
1395
+ for tmp, final in pairs:
1396
+ kept = None
1397
+ if len(pairs) > 1 and os.path.lexists(final):
1398
+ d, base = os.path.split(final)
1399
+ stem, ext = os.path.splitext(base)
1400
+ kept = reserve_sibling(d, stem, ext, "keep")
1401
+ os.replace(final, kept)
1402
+ try:
1403
+ os.replace(tmp, final)
1404
+ except BaseException:
1405
+ if kept:
1406
+ os.replace(kept, final)
1407
+ raise
1408
+ aside.append((final, kept)) # replaced: undone below if a later one fails
1409
+ except BaseException:
1410
+ for final, kept in reversed(aside):
1411
+ if kept:
1412
+ try:
1413
+ os.replace(kept, final)
1414
+ except OSError:
1415
+ pass
1416
+ elif os.path.exists(final):
1417
+ _remove_quietly(final)
1418
+ raise
1419
+ for _final, kept in aside:
1420
+ _remove_quietly(kept)
1421
+ except BaseException:
1422
+ cleanup()
1423
+ raise
1267
1424
 
1268
1425
 
1269
1426
  # set only by batch.py for the steps it runs: the path of a JSON file {"replaceable": {real path:
@@ -1376,10 +1533,7 @@ def remember_written(*paths: Optional[str]) -> None:
1376
1533
  as replacing someone else's file (#415)."""
1377
1534
  for path in paths:
1378
1535
  if path and os.path.isfile(path):
1379
- try:
1380
- STATE.written.add(os.path.realpath(path))
1381
- except OSError:
1382
- pass
1536
+ _remember_identity(path)
1383
1537
  report_committed(path)
1384
1538
 
1385
1539
 
@@ -1638,6 +1792,9 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1638
1792
  if is_ffmpeg:
1639
1793
  flush_drawtext_textfiles(cmd)
1640
1794
  with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
1795
+ if is_ffmpeg:
1796
+ # a file put at the output since the check above is not ours to stage over
1797
+ refuse_changed_output(cmd[-1])
1641
1798
  exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
1642
1799
  def even_retry(proc: subprocess.CompletedProcess, ran: List[str]) -> "Tuple[subprocess.CompletedProcess, List[str]]":
1643
1800
  """An attempt that failed on odd dimensions, run again with an even scale: after the
@@ -1694,6 +1851,9 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1694
1851
  _fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
1695
1852
  if final and tmp:
1696
1853
  if proc.returncode == 0:
1854
+ # still under the lock: the target the check let this run replace, not a file
1855
+ # another process put there during the encode
1856
+ refuse_changed_output(final, lambda: _cleanup_partial_output(exec_cmd))
1697
1857
  try:
1698
1858
  os.replace(tmp, final)
1699
1859
  except OSError as e:
@@ -2104,6 +2264,9 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
2104
2264
  d, base = os.path.split(dst)
2105
2265
  stem, ext = os.path.splitext(base)
2106
2266
  with _OutputLock(dst):
2267
+ # under the lock: the target the check above decided about, not one put there since
2268
+ # (`src` is the caller's to remove, as on every other refusal)
2269
+ refuse_changed_output(dst)
2107
2270
  if move:
2108
2271
  try:
2109
2272
  os.replace(src, dst)
@@ -2114,6 +2277,7 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
2114
2277
  tmp = None
2115
2278
  try:
2116
2279
  tmp = copy_to_sibling(src, dst)
2280
+ refuse_changed_output(dst, lambda: _remove_quietly(tmp))
2117
2281
  os.replace(tmp, dst)
2118
2282
  except OSError as e:
2119
2283
  if tmp:
@@ -770,6 +770,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
770
770
  elif name == "join":
771
771
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
772
772
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
773
+ "frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
774
+ "clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
775
+ "transition_frames": {"type": "integer", "description": "video mode only: the frames each xfade/acrossfade overlaps (--duration rounded to whole frames, at least 1; 0 for --transition none)"},
773
776
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
774
777
  "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
775
778
  "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
package/scripts/export.py CHANGED
@@ -51,7 +51,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import square_pixels, square_pixels_note
54
- from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
54
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
55
  from _common.emit import file_identity
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
@@ -209,23 +209,32 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
209
209
  # the fill is always written to a hidden sibling and moved into place only after the encode
210
210
  # succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
211
211
  # left a partial new fill (the retry was refused as output_exists) or truncated an existing one
212
- fill_tmp = None
212
+ # the key is staged the same way, and both are committed together: a fill refused at its
213
+ # replace never leaves a new key beside it (which a retry would then refuse as existing)
214
+ fill_tmp = key_tmp = None
213
215
  if not STATE.dry_run:
214
216
  d, base = os.path.split(output)
215
217
  stem, ext = os.path.splitext(base)
216
218
  fill_tmp = reserve_sibling(d, stem, ext, "fill") # created exclusively: never through a planted link
219
+ kd, kbase = os.path.split(key)
220
+ kstem, kext = os.path.splitext(kbase)
221
+ key_tmp = reserve_sibling(kd, kstem, kext, "key")
222
+ # this run's own placeholder: run()'s output check must not refuse it as someone's file
223
+ STATE.written.add(os.path.realpath(key_tmp))
217
224
  cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
218
- cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
225
+ cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key_tmp or key]
219
226
  try:
220
227
  run(cmd)
221
- if fill_tmp:
222
- os.replace(fill_tmp, output)
228
+ if fill_tmp and key_tmp:
229
+ # under both locks, and only while each is the file guard_sidecar() decided about
230
+ replace_checked_together([(fill_tmp, output), (key_tmp, key)])
223
231
  finally:
224
- if fill_tmp:
225
- # the command is recorded with the path the caller asked for, not the temp name
226
- STATE.commands[:] = [c.replace(fill_tmp, output) for c in STATE.commands]
227
- if os.path.exists(fill_tmp):
228
- os.remove(fill_tmp)
232
+ for tmp, final in ((fill_tmp, output), (key_tmp, key)):
233
+ if tmp:
234
+ # the command is recorded with the path the caller asked for, not the temp name
235
+ STATE.commands[:] = [c.replace(tmp, final) for c in STATE.commands]
236
+ if os.path.exists(tmp):
237
+ os.remove(tmp)
229
238
  remember_written(output, key)
230
239
  extra: Dict[str, object] = {"alpha_mode": args.alpha_mode or "straight", "key": {"output": key}}
231
240
  # a fill and a key for a switcher carry the picture and the sound; the source's subtitle and
@@ -746,7 +755,8 @@ def main() -> int:
746
755
  die(f"--normalize: loudness.py failed: {why} ({kept_clause()})",
747
756
  code=child_exit_code(child, proc.returncode) if proc.returncode else 1,
748
757
  kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
749
- os.replace(tmp, output)
758
+ # the export this run wrote, unless another process replaced it during the pass
759
+ replace_checked(tmp, output)
750
760
  m = measure_loudness(output)
751
761
  if not m:
752
762
  # the file changed: the measurement before --normalize no longer describes it
package/scripts/join.py CHANGED
@@ -36,9 +36,11 @@ audio stream is never silent.
36
36
  """
37
37
  import argparse
38
38
  import json
39
+ import math
39
40
  import os
40
41
  import tempfile
41
42
  import sys
43
+ from fractions import Fraction
42
44
  from typing import Any, Dict, List, Optional, Tuple
43
45
 
44
46
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
@@ -46,6 +48,7 @@ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, pla
46
48
  from _common.emit import note, warn
47
49
  from _common.decision import av_clip_length, av_trim, stream_starts
48
50
  from _common.probe import display_size, square_pixels, square_pixels_note
51
+ from _common.timeline import exact_rate
49
52
 
50
53
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
51
54
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -112,6 +115,81 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
112
115
  return av_clip_length(meta, fps)
113
116
 
114
117
 
118
+ # the video join's sound rate (every clip is resampled to it before the trims)
119
+ JOIN_AUDIO_RATE = 48000
120
+
121
+
122
+ def output_rate(fps: float, first: Dict[str, Any], asked: bool) -> Fraction:
123
+ """The join's frame rate as an exact fraction: the first clip's own r_frame_rate/avg_frame_rate
124
+ when `fps` is that clip's (30000/1001, not 29.97), else the NTSC fraction or the plain one
125
+ `fps` names (timeline.exact_rate). Every clip length, offset and sound length is counted in
126
+ frames of this rate, so the graph's fps filter runs at the same fraction."""
127
+ if not asked:
128
+ for key in ("r_frame_rate", "avg_frame_rate"):
129
+ try:
130
+ q = Fraction(str(first.get(key) or ""))
131
+ except (ValueError, ZeroDivisionError):
132
+ continue
133
+ # the declared rate also wins when the average was taken over a length that leaves out
134
+ # the last frame's duration (#475: 5 frames in 0.1333 s read 37.5 fps, and the whole
135
+ # join ran at 75/2)
136
+ if 0 < q <= 240 and (abs(float(q) - fps) < 1e-3 or short_last_frame(first, q)):
137
+ return q
138
+ return exact_rate(fps)
139
+
140
+
141
+ def short_last_frame(video: Optional[Dict[str, Any]], q: Fraction) -> bool:
142
+ """Whether a picture's duration is its frame count less one frame at rate `q`: a container
143
+ that stores no duration for the last frame (MOV written with -fps_mode passthrough, seen on
144
+ FFmpeg 7.1) reads 5 frames as 0.1333 s at 30 fps (#475)."""
145
+ v = video or {}
146
+ nb, dur = v.get("nb_frames"), v.get("duration")
147
+ if not nb or nb < 2 or not dur or q <= 0:
148
+ return False
149
+ step = float(1 / q)
150
+ return abs(dur - (nb - 1) * step) < 0.25 * step and abs(dur - nb * step) > 0.25 * step
151
+
152
+
153
+ def with_whole_picture(meta: Dict[str, Any], q: Fraction) -> Dict[str, Any]:
154
+ """`meta` with its picture counted as every frame it holds when its duration leaves out the
155
+ last one (short_last_frame), so the clip-length rule does not read the sound as running a
156
+ frame past the picture and hold the clip longer (#475). Other clips are returned as they are."""
157
+ v = meta.get("video") or {}
158
+ # each clip at its OWN declared rate: at the join's rate a complete 30 fps clip of 5 frames
159
+ # (5/30 s) also equals 4 frames at --fps 24 and would be stretched to 5/24 s
160
+ try:
161
+ own = Fraction(str(v.get("r_frame_rate") or ""))
162
+ except (ValueError, ZeroDivisionError):
163
+ own = q
164
+ if not 0 < own <= 240:
165
+ own = q
166
+ if not short_last_frame(v, own):
167
+ return meta
168
+ return dict(meta, video=dict(v, duration=float(v["nb_frames"] / own)))
169
+
170
+
171
+ def clip_frames(meta: Dict[str, Any], length: float, q: Fraction) -> int:
172
+ """How many frames of rate `q` a clip of `length` seconds (clip_length) holds in the join: its
173
+ picture's frames, or, when the clip lasts as long as its sound, enough frames to cover that
174
+ sound (narration is never cut; a hundredth of a frame is rounding, not sound). A pending
175
+ dry-run stub's 0 s stays 0; a measured clip holds at least one frame (#455: a length in
176
+ milliseconds rounded up started every next clip late, and the errors added up)."""
177
+ exact = length * q.numerator / q.denominator
178
+ vlen = (meta.get("video") or {}).get("duration") or 0.0
179
+ frames = math.ceil(exact - 0.01) if vlen and length > vlen else round(exact)
180
+ return frames if meta.get("dry_run") else max(1, frames)
181
+
182
+
183
+ def frame_time(frames: int, q: Fraction) -> str:
184
+ """The time of frame `frames` at rate `q` in seconds, rounded down to the microsecond: never
185
+ after that frame's own timestamp, so a filter that starts on the first frame at or after it
186
+ (xfade's offset) starts on that frame and not the next (#468: 2.566667 written 2.567 started
187
+ the transition a frame late). Trailing zeros are dropped (0.5, not 0.500000)."""
188
+ us = (frames * q.denominator * 1_000_000) // q.numerator
189
+ text = f"{us // 1_000_000}.{us % 1_000_000:06d}".rstrip("0").rstrip(".")
190
+ return text or "0"
191
+
192
+
115
193
  def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
116
194
  """Every path (compared by its resolved absolute form) listed more than once, with the 0-based
117
195
  list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
@@ -543,6 +621,10 @@ def main() -> int:
543
621
  apply_common(args)
544
622
  if args.fps is not None and args.fps <= 0:
545
623
  die(f"--fps must be positive, got {args.fps:g}")
624
+ # nan and inf are refused by the parser (finite_float); a negative length was taken as the
625
+ # one-frame minimum and the join succeeded with a transition nobody asked for
626
+ if args.duration < 0:
627
+ die(f"--duration must be 0 or more seconds, got {args.duration:g}")
546
628
 
547
629
  if args.list:
548
630
  if args.inputs:
@@ -666,6 +748,9 @@ def main() -> int:
666
748
  w, h = fw, fh
667
749
  fps = args.fps or first.get("fps") or 30.0
668
750
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
751
+ # the exact rate every length below is counted in (30000/1001, not 29.97)
752
+ rate_q = output_rate(fps, first, bool(args.fps))
753
+ fps = float(rate_q)
669
754
  w, h = w - (w % 2), h - (h % 2)
670
755
  note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
671
756
  durs = [m.get("duration") or 0.0 for m in metas]
@@ -675,11 +760,37 @@ def main() -> int:
675
760
  # its picture and its sound together. The plain cut used to take the raw streams, and a sound
676
761
  # past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
677
762
  # the video there.
678
- lens = [clip_length(m, fps) for m in metas]
763
+ # Each length is a whole number of frames of the output rate, and each sound length the
764
+ # samples those frames last (#455): a length in milliseconds rounded 5 frames at 30 fps
765
+ # (0.166667 s) up to 0.167 s, the concat filter started the next clip after it, and 60 such
766
+ # clips left one-frame holes from about 8 s on. Sample counts come from the running frame
767
+ # total, so the sound never drifts from the picture by more than one sample.
768
+ lmetas = [with_whole_picture(m, rate_q) for m in metas]
769
+ frames = [clip_frames(m, clip_length(m, fps), rate_q) for m in lmetas]
770
+ # the transition is a whole number of frames too, so xfade's offsets (and acrossfade's
771
+ # overlap) land on frames of the output rate (#468). A transition is at least one frame:
772
+ # --duration 0 used to build acrossfade=ns=0, which FFmpeg refuses
773
+ tframes = max(1, round(d * fps)) if args.transition != "none" else 0
774
+ d = tframes / fps
775
+ lens = [f / fps for f in frames]
679
776
  place_silent(lens, d)
680
- for p, dur in zip(args.inputs, lens):
681
- if d and dur <= d * 2 and not STATE.dry_run:
682
- die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
777
+ for p, nf in zip(args.inputs, frames):
778
+ if d and nf <= tframes * 2 and not STATE.dry_run:
779
+ die(f"{p} is only {nf / fps:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
780
+ # every sample boundary is a frame position of the OUTPUT rounded once: clip i spans output
781
+ # frames [start_i, start_i + frames_i) and overlaps the clip before it over [start_i,
782
+ # start_i + tframes), so its samples and each overlap's are differences of rounded
783
+ # cumulative positions and their sum telescopes to the output's own length. Rounding each
784
+ # overlap on its own (14414.4 samples -> 14414 at 30000/1001 and 9 frames) left the sound
785
+ # 0.4 samples longer per transition.
786
+ def at(frame: int) -> int:
787
+ return round(frame * JOIN_AUDIO_RATE / rate_q)
788
+ starts, start = [], 0
789
+ for i, nf in enumerate(frames):
790
+ starts.append(start)
791
+ start += nf - tframes
792
+ samples = [at(s0 + nf) - at(s0) for s0, nf in zip(starts, frames)]
793
+ overlaps = [at(s0 + tframes) - at(s0) for s0 in starts]
683
794
 
684
795
  cmd = ffmpeg_base()
685
796
  extra_inputs: List[str] = []
@@ -745,7 +856,10 @@ def main() -> int:
745
856
  if len(set(chans)) > 1:
746
857
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
747
858
  for i in range(n):
748
- parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
859
+ # the fps filter's time base is one frame (1/rate), so every timestamp downstream is a
860
+ # frame number and an offset in seconds is rounded to a whole frame, never a microsecond
861
+ # off it on any FFmpeg (AVTB left xfade to compare microseconds)
862
+ parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={rate_q.numerator}/{rate_q.denominator},format={pixfmt}[v{i}]")
749
863
  # the sound is shifted by the picture's start, not to its own (#409): #338 started each
750
864
  # clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
751
865
  # after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
@@ -755,23 +869,29 @@ def main() -> int:
755
869
 
756
870
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
757
871
  # its last frame, then cut both there -- each clip boundary is then the same for both chains
872
+ # -- by frame and by sample, never by rounded seconds (#455)
758
873
  for i in range(n):
759
- vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
760
- hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
761
- parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
762
- parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
874
+ vlen = (lmetas[i].get("video") or {}).get("duration") or lens[i]
875
+ have = round(vlen * fps)
876
+ # one frame more than the gap is held, and the trim cuts at the clip's last frame
877
+ hold = f"tpad=stop_mode=clone:stop={frames[i] - have + 1}," if frames[i] > have else ""
878
+ parts.append(f"[v{i}]{hold}trim=end_frame={frames[i]}[vp{i}]")
879
+ parts.append(f"[a{i}]apad=whole_len={samples[i]},atrim=end_sample={samples[i]}[ap{i}]")
763
880
  if args.transition == "none":
764
881
  chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
765
882
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
766
883
  else:
767
884
  vprev, aprev = "vp0", "ap0"
768
- offset = 0.0
885
+ start = 0
769
886
  for i in range(1, n):
770
- offset += lens[i - 1] - d
887
+ # clip i starts on frame `start` of the output; xfade starts on the first frame at or
888
+ # after its offset, so the offset is that frame's time rounded down (#468)
889
+ start += frames[i - 1] - tframes
771
890
  vout = f"vx{i}" if i < n - 1 else "vout"
772
891
  aout = f"ax{i}" if i < n - 1 else "aout"
773
- parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
774
- parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
892
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={frame_time(tframes, rate_q)}"
893
+ f":offset={frame_time(start, rate_q)}[{vout}]")
894
+ parts.append(f"[{aprev}][ap{i}]acrossfade=ns={overlaps[i]}:c1=tri:c2=tri[{aout}]")
775
895
  vprev, aprev = vout, aout
776
896
 
777
897
  # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
@@ -784,7 +904,7 @@ def main() -> int:
784
904
  "by its measured length")
785
905
  elif not d and any(m.get("dry_run") for m in metas):
786
906
  # the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
787
- # planned trim=duration=0.000 is the stub's placeholder, not the cut
907
+ # planned trim=end_frame=0 is the stub's placeholder, not the cut
788
908
  STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
789
909
  "or holds it to its measured length")
790
910
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
@@ -818,6 +938,7 @@ def main() -> int:
818
938
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
819
939
  overlap = overlap_report(lens, expected)
820
940
  emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
941
+ frame_rate=f"{rate_q.numerator}/{rate_q.denominator}", clip_frames=frames, transition_frames=tframes,
821
942
  verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
822
943
  audio_substituted=substituted,
823
944
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
@@ -18,11 +18,13 @@ import argparse
18
18
  import re
19
19
  import sys
20
20
  import tempfile
21
+ from fractions import Fraction
21
22
  from pathlib import Path
22
23
 
23
24
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
24
25
  from _common.emit import note, warn
25
26
  from _common.probe import display_size, square_pixels_note
27
+ from _common.timeline import TimelineError, exact_rate
26
28
 
27
29
 
28
30
  def even(n: float) -> int:
@@ -34,6 +36,30 @@ def _concat_list_line(path: Path) -> str:
34
36
  return concat_list_line(str(path))
35
37
 
36
38
 
39
+ def frame_durations(count: int, rate: Fraction) -> list:
40
+ """The concat-list duration of each of `count` frames at `rate`, in whole microseconds: frame
41
+ i lasts round(start(i+1)) - round(start(i)) with start(i) = i/rate, each position rounded
42
+ once, so the demuxer's running sum is frame i's exact start to within half a microsecond at
43
+ every frame (#474: 1/30 written 0.033333 fell 3.3e-7 s behind per frame, half a frame after
44
+ about 50,000 frames)."""
45
+ def at(i: int) -> int:
46
+ return round(i * 1_000_000 / rate)
47
+ return [at(i + 1) - at(i) for i in range(count)]
48
+
49
+
50
+ def concat_list(frames: list, rate: Fraction) -> str:
51
+ """The concat-demuxer list for `frames` (paths) shown one frame each at `rate`."""
52
+ lines = []
53
+ framerate = f"option framerate {rate.numerator}/{rate.denominator}"
54
+ for f, us in zip(frames, frame_durations(len(frames), rate)):
55
+ lines.append(_concat_list_line(f))
56
+ lines.append(framerate)
57
+ lines.append(f"duration {us // 1_000_000}.{us % 1_000_000:06d}")
58
+ lines.append(_concat_list_line(frames[-1])) # concat demuxer: last entry's duration is ignored, so repeat it
59
+ lines.append(framerate)
60
+ return "\n".join(lines)
61
+
62
+
37
63
  def natural_key(name: str) -> list:
38
64
  """img2 < img10: digit runs compare as numbers, the rest case-insensitively."""
39
65
  return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
@@ -70,6 +96,10 @@ def main() -> int:
70
96
 
71
97
  if args.fps <= 0:
72
98
  die("--fps must be > 0")
99
+ try:
100
+ rate = exact_rate(args.fps) # 29.97 is 30000/1001, as every other tool reads it
101
+ except TimelineError as exc:
102
+ die(f"--fps: {exc}")
73
103
  directory = Path(args.dir)
74
104
  if not directory.is_dir():
75
105
  die(f"--dir not found or not a directory: {args.dir}")
@@ -123,27 +153,20 @@ def main() -> int:
123
153
  out_w, out_h = even(sw), even(sh)
124
154
 
125
155
  output = args.output or default_output(str(directory).rstrip("/\\") or "sequence", "sequence", "mp4")
126
- frame_duration = 1.0 / args.fps
127
-
128
156
  with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
129
157
  list_path = Path(tmp) / "frames.txt"
130
- lines = []
131
- for f in frames:
132
- lines.append(_concat_list_line(f.resolve()))
133
- lines.append(f"duration {frame_duration:.6f}")
134
- lines.append(_concat_list_line(frames[-1].resolve())) # concat demuxer: last entry's duration is ignored, so repeat it
135
- list_path.write_text("\n".join(lines), encoding="utf-8")
158
+ list_path.write_text(concat_list([f.resolve() for f in frames], rate), encoding="utf-8")
136
159
 
137
160
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
138
- vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={args.fps:g}"]
161
+ vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={rate.numerator}/{rate.denominator}"]
139
162
  cmd += ["-vf", ",".join(vf)]
140
163
  # RGB (PNG) frames are converted to the BT.709 the line is tagged with (the first frame's probe)
141
164
  cmd += video_args(None, args.crf, args.preset, source=frame_meta)
142
165
  # The concat demuxer's trailing repeated-last-file trick (needed so the last real file's
143
166
  # duration line takes effect) has been observed to produce an extra frame's worth of
144
167
  # duration on some ffmpeg builds -- force the exact intended length rather than trust it.
145
- total_duration = len(frames) * frame_duration
146
- cmd += ["-t", f"{total_duration:.6f}", "-an", output]
168
+ total_us = sum(frame_durations(len(frames), rate))
169
+ cmd += ["-t", f"{total_us // 1_000_000}.{total_us % 1_000_000:06d}", "-an", output]
147
170
  run(cmd)
148
171
 
149
172
  result = probe(output, role="output")
@@ -28,6 +28,7 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
28
28
  FILLER_PAD, read_text_or_die)
29
29
  from _common import asr as _asr
30
30
  from _common.emit import warn
31
+ from _common.runner import replace_checked
31
32
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
32
33
  from _common.emit import refuse
33
34
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
@@ -261,7 +262,8 @@ def write_time_map(path: str, tm: Dict[str, Any]) -> None:
261
262
  mask = os.umask(0) # mkstemp makes the file 0600; give the map the mode open() would have
262
263
  os.umask(mask)
263
264
  os.chmod(tmp, 0o666 & ~mask)
264
- os.replace(tmp, path)
265
+ # under the map's lock, and only while it is the file refuse_existing_outputs() decided about
266
+ replace_checked(tmp, path)
265
267
  except BaseException:
266
268
  try:
267
269
  os.remove(tmp)