ffmpeg-skill 3.6.3 → 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 +2 -0
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/runner.py +172 -8
- package/scripts/export.py +21 -11
- package/scripts/sequence.py +34 -11
- package/scripts/silence.py +3 -1
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.
|
|
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.
|
|
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"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.6.
|
|
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
|
-
|
|
1313
|
+
real = os.path.realpath(path)
|
|
1314
|
+
STATE.written.add(real)
|
|
1264
1315
|
except OSError:
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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:
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
os.
|
|
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
|
-
|
|
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/sequence.py
CHANGED
|
@@ -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
|
-
|
|
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={
|
|
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
|
-
|
|
146
|
-
cmd += ["-t", f"{
|
|
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")
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
-
|
|
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)
|