ffmpeg-skill 3.7.0 → 3.8.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/README.md +1 -1
- package/SKILL.md +1 -0
- package/docs/contract.md +11 -4
- package/package.json +1 -1
- package/references/scripts.md +16 -3
- package/references/workflow.md +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +26 -2
- package/scripts/_common/emit.py +23 -0
- package/scripts/_contract.py +5 -3
- package/scripts/_platforms.py +17 -1
- package/scripts/audio.py +4 -14
- package/scripts/broll.py +13 -5
- package/scripts/check.py +24 -6
- package/scripts/export.py +5 -4
- package/scripts/fit.py +5 -0
- package/scripts/freeze.py +2 -1
- package/scripts/graphics.py +2 -1
- package/scripts/grid.py +19 -5
- package/scripts/join.py +3 -0
- package/scripts/look.py +2 -0
- package/scripts/loop.py +2 -1
- package/scripts/metadata.py +3 -1
- package/scripts/multicam.py +12 -6
- package/scripts/render.py +20 -3
- package/scripts/scenes.py +2 -1
- package/scripts/silence.py +2 -1
- package/scripts/speedramp.py +2 -1
- package/scripts/sync.py +3 -2
package/README.md
CHANGED
|
@@ -269,7 +269,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
269
269
|
|---|---|
|
|
270
270
|
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `dnxhr-lb`/`sq`/`hq`/`hqx`/`444`, the alpha masters `prores4444`/`prores4444xq`/`qtrle`/`vp9-alpha`/`hap-alpha` (alpha read back), `key-fill` (a switcher's fill + key pair), `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR or are an editing master (`prores`, `dnxhr-*`: the source's tags kept, codec, profile and pixel format read back) (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
|
|
271
271
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
272
|
-
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference, and with `--expect-changed SPAN\|none` fails when the picture changed outside the spans the caller declared; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
|
|
272
|
+
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; a length or file size over the table's upload limit is a WARN marked `basis: guidance` (platforms change these, the uploader checks the current one), while `--max-duration` / `--max-mb` state the caller's own limit and FAIL when a `--platform` is named (with none, an over-length is WARN like every judgement row); `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference, and with `--expect-changed SPAN\|none` fails when the picture changed outside the spans the caller declared; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
|
|
273
273
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
|
274
274
|
|
|
275
275
|
**Orchestration**
|
package/SKILL.md
CHANGED
|
@@ -34,6 +34,7 @@ Every step in full: `references/workflow.md`.
|
|
|
34
34
|
A delivery request is one `render.py --template`; three or more steps: a `render.py` project.
|
|
35
35
|
5. **Check** with `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`):
|
|
36
36
|
fix format rows; fix duration, aspect, fps or loudness only when the request implies it. Mention WARNs.
|
|
37
|
+
A `basis: guidance` WARN (length, size) is the table's upload limit: the uploader checks the current one.
|
|
37
38
|
6. **Verify** from the writer's `--json` probe or `probe.py`: exit 0 and the requested numbers, or it failed.
|
|
38
39
|
7. **Keep originals:** never overwrite a source; `--overwrite` only for this job's files or ones the user
|
|
39
40
|
asked to replace.
|
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.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`
|
|
@@ -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.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.1", "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"},
|
|
@@ -225,7 +225,14 @@ The workflow in `SKILL.md` is "probe first, verify last". The contract states it
|
|
|
225
225
|
`requires_visual_verification` is `true` exactly for the tools that change the picture
|
|
226
226
|
(fit, caption, overlay, graphics, color, join, multicam, render). Audio-only tools and
|
|
227
227
|
audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
|
|
228
|
-
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user)
|
|
228
|
+
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user), and `basis`:
|
|
229
|
+
`requirement` (the row can FAIL) or `guidance`. The `duration` and `file size` rows are `basis: guidance`
|
|
230
|
+
when their limit is the platform table's upload limit (`scripts/_platforms.py`, dated
|
|
231
|
+
`UPLOAD_LIMITS_AS_OF`): over it, or not measurable against it, is WARN with a `reason` naming the
|
|
232
|
+
table's date, never FAIL, and does not make `check` exit non-zero. Platforms change upload limits
|
|
233
|
+
and whoever uploads checks the current one. A limit the caller states (`--max-duration`, `--max-mb`)
|
|
234
|
+
is the caller's requirement: `basis: requirement`, FAIL when over or not measured (with no `--platform`
|
|
235
|
+
named, `duration` is WARN as every judgement row is).
|
|
229
236
|
The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
|
|
230
237
|
the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
|
|
231
238
|
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
|
|
@@ -772,7 +779,7 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
772
779
|
|
|
773
780
|
| key | tool | what it holds |
|
|
774
781
|
|---|---|---|
|
|
775
|
-
| `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` |
|
|
782
|
+
| `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`. The step verifies the trim `export.py` itself made; `check.py` reports the same table limit as guidance (WARN) |
|
|
776
783
|
| `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` |
|
|
777
784
|
| `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 |
|
|
778
785
|
| `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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.1",
|
|
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": [
|
package/references/scripts.md
CHANGED
|
@@ -58,8 +58,11 @@ Every row names the constant or flag it comes from, and a test holds the table t
|
|
|
58
58
|
<!-- thresholds:end -->
|
|
59
59
|
|
|
60
60
|
Delivery targets per platform (`scripts/_platforms.py`). A file passes loudness within the
|
|
61
|
-
platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB.
|
|
62
|
-
|
|
61
|
+
platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB. The
|
|
62
|
+
maximum length (and the file size `check.py` reads from the same table) is an upload limit: the
|
|
63
|
+
table's as of `_platforms.UPLOAD_LIMITS_AS_OF`, guidance rather than a requirement, because
|
|
64
|
+
platforms change it and whoever uploads checks the current one. `check.py` WARNs over it;
|
|
65
|
+
`export.py` trims to it unless `--allow-long` and holds the written length to it with no tolerance.
|
|
63
66
|
|
|
64
67
|
<!-- platforms:start -->
|
|
65
68
|
| platform | loudness | tolerance | true peak | max duration |
|
|
@@ -1240,6 +1243,15 @@ step before reporting a deliverable; fix FAILs, mention WARNs. Without
|
|
|
1240
1243
|
`--platform` the youtube spec is assumed and the judgement rows (duration,
|
|
1241
1244
|
aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
|
|
1242
1245
|
line, not FAIL: name the platform when the file is a delivery for it.
|
|
1246
|
+
Every row carries `basis`: `requirement` or `guidance`. The `duration` and `file size` rows are
|
|
1247
|
+
`guidance` when the limit is the platform table's: a file over it (or whose length or size could
|
|
1248
|
+
not be measured) is WARN, not FAIL, with a `reason` that names the table's date
|
|
1249
|
+
(`_platforms.UPLOAD_LIMITS_AS_OF`), and it does not make the exit code non-zero by itself. Upload
|
|
1250
|
+
limits change often and belong to whoever uploads, who checks the platform's current one; the
|
|
1251
|
+
encoding rows (codec, aspect, resolution, fps, pixel format, colour, loudness, true peak) are
|
|
1252
|
+
unchanged. `--max-duration S` and `--max-mb N` state the caller's own limit, on any platform
|
|
1253
|
+
including `custom`: those rows are `requirement` and FAIL when over or not measured (with no
|
|
1254
|
+
`--platform` at all, `duration` is WARN like the other judgement rows).
|
|
1243
1255
|
`--platform podcast` adds two informational rows: `channels` (PASS for mono or
|
|
1244
1256
|
stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
|
|
1245
1257
|
(PASS when the container carries at least one marker, WARN `none` otherwise —
|
|
@@ -2189,7 +2201,8 @@ overhead) in place of its CRF, and the encode runs in two passes. The written si
|
|
|
2189
2201
|
overshot. Refused for a preset that does not encode with x264 (and under `--hw`), with `--crf`,
|
|
2190
2202
|
for a source with no duration, and when the budget leaves under 100 kb/s for the picture.
|
|
2191
2203
|
Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix or RGB source is converted to it, as by every re-encode), sets `+faststart`,
|
|
2192
|
-
trims to platform maximums (Reels 90 s, X 140 s
|
|
2204
|
+
trims to the table's platform maximums (Reels 90 s, X 140 s; the table's as of its date, and
|
|
2205
|
+
platforms change them) unless `--allow-long`, and says
|
|
2193
2206
|
so: `trimmed: {from, to}` and a `notes` line. It
|
|
2194
2207
|
does not touch levels: for youtube / youtube4k / reels / x the written file is
|
|
2195
2208
|
measured and the result's `loudness` (and a `notes` line) says when it is
|
package/references/workflow.md
CHANGED
|
@@ -10,7 +10,7 @@ lines per step; every rule below still applies.
|
|
|
10
10
|
2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
|
|
11
11
|
3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
|
|
12
12
|
4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
|
|
13
|
-
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`; templates already check); never check after a failed write. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs.
|
|
13
|
+
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`; templates already check); never check after a failed write. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs. A `duration` or `file size` WARN with `basis: guidance` is the table's upload limit, not a requirement: platforms change these, the uploader checks the current one.
|
|
14
14
|
6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
|
|
15
15
|
7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); add `--overwrite` only for a file this job made or the user asked to replace.
|
|
16
16
|
8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
|
|
@@ -63,7 +63,7 @@ from _common.runner import (
|
|
|
63
63
|
STATE, _timed_out, _unwatch, _watch, X264_PRESETS
|
|
64
64
|
)
|
|
65
65
|
from _common.emit import (
|
|
66
|
-
absorb_stage, audit, _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
|
|
66
|
+
absorb_stage, audit, _brief, input_length, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
|
|
67
67
|
_plan_unbound_inputs, _PLAN_STRIP, PLAN_VERSION, print_json, record_fallback, record_skip, refuse, _set_current_ctx, warn,
|
|
68
68
|
write_plan
|
|
69
69
|
)
|
|
@@ -74,7 +74,7 @@ from _common.probe import (
|
|
|
74
74
|
display_size, pixel_aspect, square_pixels, square_pixels_note
|
|
75
75
|
)
|
|
76
76
|
from _common.decision import (
|
|
77
|
-
aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
77
|
+
aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
78
78
|
description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
|
|
79
79
|
frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
|
|
80
80
|
filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
|
|
@@ -15,7 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
16
16
|
from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
17
17
|
is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
18
|
-
from _common.emit import die, record_fallback, refuse, warn
|
|
18
|
+
from _common.emit import die, input_length, record_fallback, refuse, warn
|
|
19
19
|
from _common.runner import read_json
|
|
20
20
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
|
|
21
21
|
|
|
@@ -431,6 +431,30 @@ def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bo
|
|
|
431
431
|
f"atrim=start={start:.6f}{aend},asetpts=PTS-{start:.6f}/TB,{pad}")
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def conform_filter(samples: int, rate: int) -> str:
|
|
435
|
+
"""Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
|
|
436
|
+
seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
|
|
437
|
+
aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
|
|
438
|
+
head = f"aformat=sample_rates={rate}," if rate else ""
|
|
439
|
+
return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def conform_to_length(length: float, rate: int) -> str:
|
|
443
|
+
"""Pad with silence or trim a track to `length` seconds of a timeline that starts at 0, to the
|
|
444
|
+
sample (#478, #493). The count is of samples, and sound that starts after 0 keeps its start
|
|
445
|
+
(apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s past
|
|
446
|
+
`length`. The trim on the timestamp ends it there; half a sample of slack keeps the Nth sample
|
|
447
|
+
of a track that starts at 0. audio.py, grid.py, broll.py and multicam.py end their sound with
|
|
448
|
+
this; `atrim=duration={length:.3f}` cut 59244 samples (48 kHz) to 59232."""
|
|
449
|
+
return conform_filter(round(length * rate), rate) + f",atrim=end={length + 0.5 / rate:.6f}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def conform_output_t(length: float, rate: int) -> str:
|
|
453
|
+
"""The output `-t` beside conform_to_length(): `-t {length:.3f}` would cut the conformed sound
|
|
454
|
+
back to the millisecond, so the limit sits half a sample past the last one."""
|
|
455
|
+
return f"{length + 0.5 / rate:.6f}"
|
|
456
|
+
|
|
457
|
+
|
|
434
458
|
def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
435
459
|
"""The one length a clip holds once both streams start at its first frame (av_trim from the
|
|
436
460
|
video's start): the video's, or where the sound ends when it runs more than a frame past the
|
|
@@ -440,7 +464,7 @@ def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
|
440
464
|
v = (meta.get("video") or {}).get("duration")
|
|
441
465
|
a = (meta.get("audio") or {}).get("duration")
|
|
442
466
|
if not v:
|
|
443
|
-
return meta
|
|
467
|
+
return input_length(meta) # a file that states no length at all is refused, not 0 s long (#491)
|
|
444
468
|
if a:
|
|
445
469
|
v0, a0 = stream_starts(meta)
|
|
446
470
|
a = a + a0 - v0 # where the sound ends, counted from the first frame
|
package/scripts/_common/emit.py
CHANGED
|
@@ -647,6 +647,29 @@ def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
|
647
647
|
return None
|
|
648
648
|
|
|
649
649
|
|
|
650
|
+
LENGTH_UNREAD_HINT = ("remux the file so its container states a length (export.py INPUT --preset copy -o remuxed.mkv), "
|
|
651
|
+
"then run this on the remux")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def input_length(meta: Optional[Dict[str, Any]], path: Optional[str] = None) -> float:
|
|
655
|
+
"""An input's length in seconds, for a tool that computes with it (#491). A file that states
|
|
656
|
+
no length (Matroska written to a pipe: ffprobe prints N/A for the container and every stream)
|
|
657
|
+
probes as duration None, and `meta.get("duration") or 0.0` read that as a clip 0 s long: a
|
|
658
|
+
speed factor of 0.00x, "--at 1 is outside the clip (0..0.000)", a reverse of no frames. Unread
|
|
659
|
+
is not zero, so this refuses, as check.py's duration row says "could not measure". A dry run's
|
|
660
|
+
pending input (probe()'s stub, duration 0.0), a stated length of 0 and a still picture (one
|
|
661
|
+
image read by an image demuxer has no length to state) pass through as before."""
|
|
662
|
+
meta = meta or {}
|
|
663
|
+
length = meta.get("duration")
|
|
664
|
+
fmt = meta.get("format") or ""
|
|
665
|
+
still = fmt == "image2" or fmt.endswith("_pipe") # probe._is_still()'s rule, on the probe document
|
|
666
|
+
if length is None and not meta.get("dry_run") and not still:
|
|
667
|
+
die(f"the length of {path or meta.get('file') or 'the input'} could not be read: neither its container nor "
|
|
668
|
+
"its streams state one, and this operation is computed from the input's length",
|
|
669
|
+
kind="input", hint=LENGTH_UNREAD_HINT)
|
|
670
|
+
return float(length or 0.0)
|
|
671
|
+
|
|
672
|
+
|
|
650
673
|
def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
651
674
|
"""Where one stream ends on the file's timeline: its start plus its own length."""
|
|
652
675
|
length = stream_length(meta, kind)
|
package/scripts/_contract.py
CHANGED
|
@@ -638,7 +638,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
638
638
|
"levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
|
|
639
639
|
"change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
|
|
640
640
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
|
|
641
|
-
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN
|
|
641
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs under a --max-duration the caller stated (WARN under the platform table's limit or without one) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}, "basis": {"enum": ["requirement", "guidance"], "description": "where the expected value comes from. guidance: the duration and file size rows when their limit is the platform table's upload limit (scripts/_platforms.py, dated UPLOAD_LIMITS_AS_OF); such a row is PASS or WARN, never FAIL, is not counted in failed, and its reason says the uploader checks the platform's current limit. requirement: every other row, and the same two rows under --max-duration / --max-mb (the caller's own limit), which FAIL"}}}}}
|
|
642
642
|
elif name == "caption":
|
|
643
643
|
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
644
644
|
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
|
|
@@ -1820,7 +1820,9 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
|
|
|
1820
1820
|
"`check.py <output> --platform {platform}` before calling it done -- format rows (codec, pixel "
|
|
1821
1821
|
"format, size, true peak, colour tags, VFR) are safe to fix; judgement rows (duration, aspect, "
|
|
1822
1822
|
"fps, loudness) change content, so fix them only when the request implies the answer, otherwise "
|
|
1823
|
-
"state the choice and its cost. Mention WARNs; do not chase them."
|
|
1823
|
+
"state the choice and its cost. Mention WARNs; do not chase them. A duration or file size WARN with "
|
|
1824
|
+
"basis guidance is the table's upload limit, which platforms change: report it, the uploader "
|
|
1825
|
+
"checks the current one."),
|
|
1824
1826
|
},
|
|
1825
1827
|
{
|
|
1826
1828
|
"name": "podcast", "description": "Turn an audio recording into a loudness-normalised podcast episode with chapters.",
|
|
@@ -2053,7 +2055,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
2053
2055
|
"verify_last": "run the tools named in each ToolSpec.verification after it wrote an artifact",
|
|
2054
2056
|
"visual": "when requires_visual_verification is true, run ffmpeg-skill/look on the output and inspect the PNG",
|
|
2055
2057
|
"audio_only": "audio-only inputs and audio-only tools never need ffmpeg-skill/look",
|
|
2056
|
-
"check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user)",
|
|
2058
|
+
"check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user), and basis=requirement (can FAIL) or basis=guidance (the platform table's upload length/size limit: WARN at most, the uploader checks the current limit)",
|
|
2057
2059
|
},
|
|
2058
2060
|
"json_output": {
|
|
2059
2061
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
package/scripts/_platforms.py
CHANGED
|
@@ -12,7 +12,9 @@ the description bar. This module is the single table the delivery tools read:
|
|
|
12
12
|
"fps": the frame rate a delivery is conformed to (None = leave alone),
|
|
13
13
|
"spec": check.py's compliance row values (max_duration, aspects,
|
|
14
14
|
min_height, fps_max, codecs, max_bytes, lufs, lufs_tol, tp,
|
|
15
|
-
sdr_only) -- the keys check.py's SPECS has always had
|
|
15
|
+
sdr_only) -- the keys check.py's SPECS has always had;
|
|
16
|
+
max_duration and max_bytes are upload limits, guidance as of
|
|
17
|
+
UPLOAD_LIMITS_AS_OF rather than a requirement (#485),
|
|
16
18
|
"safe": the fraction of the frame each edge's UI covers
|
|
17
19
|
(top/bottom/left/right, 0..1) -- nothing readable goes there,
|
|
18
20
|
"caption": the caption defaults a template uses (size as a fraction of the
|
|
@@ -62,6 +64,20 @@ _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "
|
|
|
62
64
|
# by up to that much, so a tighter rule fails a file on measurement noise.
|
|
63
65
|
TP_TOLERANCE_DB = 0.1
|
|
64
66
|
|
|
67
|
+
# Upload limits are guidance (#485). A platform's maximum length and file size change often and
|
|
68
|
+
# differ by account; whoever uploads reads the current ones. The `max_duration` and `max_bytes`
|
|
69
|
+
# below are this table's as of the date here, the day the table as a whole was last decided --
|
|
70
|
+
# not a date each number was read from the platform. check.py reports a file over one of them as
|
|
71
|
+
# WARN with `basis: guidance`, never FAIL; the encoding values (codecs, aspects, fps, loudness,
|
|
72
|
+
# true peak) are the format a delivery is held to and stay requirements.
|
|
73
|
+
UPLOAD_LIMITS_AS_OF = "2026-10-30"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def upload_limit_note(platform: str) -> str:
|
|
77
|
+
"""Why a row measured against this table's length or size limit is guidance, in one clause."""
|
|
78
|
+
return (f"the limit is this tool's table for {platform} as of {UPLOAD_LIMITS_AS_OF}; platforms change "
|
|
79
|
+
"upload limits, so whoever uploads checks the current one")
|
|
80
|
+
|
|
65
81
|
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
|
66
82
|
"tiktok": {
|
|
67
83
|
"title": "TikTok",
|
package/scripts/audio.py
CHANGED
|
@@ -30,6 +30,7 @@ from typing import Any, Dict, List
|
|
|
30
30
|
from _common.probe import SILENT_PEAK_DBFS
|
|
31
31
|
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
|
|
32
32
|
from _common.emit import warn
|
|
33
|
+
from _common.decision import conform_filter, conform_to_length
|
|
33
34
|
|
|
34
35
|
# --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
|
|
35
36
|
DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
|
|
@@ -254,14 +255,6 @@ def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
|
|
|
254
255
|
return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
|
|
255
256
|
|
|
256
257
|
|
|
257
|
-
def conform_filter(samples: int, rate: int) -> str:
|
|
258
|
-
"""Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
|
|
259
|
-
seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
|
|
260
|
-
aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
|
|
261
|
-
head = f"aformat=sample_rates={rate}," if rate else ""
|
|
262
|
-
return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
|
|
263
|
-
|
|
264
|
-
|
|
265
258
|
def main() -> int:
|
|
266
259
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
267
260
|
ap.add_argument("input")
|
|
@@ -564,12 +557,9 @@ def main() -> int:
|
|
|
564
557
|
elif main_dur and (args.replace or args.music or keep_video):
|
|
565
558
|
if rate:
|
|
566
559
|
conform_samples = round(main_dur * rate)
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
570
|
-
# ends it with the picture; half a sample of slack keeps the Nth sample of a track
|
|
571
|
-
# that starts at 0.
|
|
572
|
-
post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
|
|
560
|
+
# counted in samples and ended on the timestamp, so sound that starts after the picture
|
|
561
|
+
# still ends with it (conform_to_length, shared with grid, broll and multicam since #493)
|
|
562
|
+
post.append(conform_to_length(main_dur, rate))
|
|
573
563
|
else:
|
|
574
564
|
# a dry run's pending input: no rate to count in yet; the real run counts samples
|
|
575
565
|
post.append(f"apad,atrim=0:{main_dur:.6f}")
|
package/scripts/broll.py
CHANGED
|
@@ -21,10 +21,14 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
|
|
24
|
+
from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, stream_starts
|
|
25
25
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
from _common.emit import note, record_fallback
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
28
|
+
from _common.emit import input_length
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MIX_RATE = 48000 # every layer of a b/mix sound is resampled to it before the mix
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
def main() -> int:
|
|
@@ -57,7 +61,7 @@ def main() -> int:
|
|
|
57
61
|
meta_a = probe(args.input)
|
|
58
62
|
if not meta_a.get("video"):
|
|
59
63
|
die("A-roll has no video stream")
|
|
60
|
-
dur_a = meta_a
|
|
64
|
+
dur_a = input_length(meta_a)
|
|
61
65
|
# the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
|
|
62
66
|
# onto a non-square-pixel A-roll squared at its display aspect (#411)
|
|
63
67
|
w, h = display_size(meta_a["video"])
|
|
@@ -98,7 +102,7 @@ def main() -> int:
|
|
|
98
102
|
die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
|
|
99
103
|
if dur_a and at + length > dur_a + 0.01:
|
|
100
104
|
die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
|
|
101
|
-
dur_b = meta_b
|
|
105
|
+
dur_b = input_length(meta_b)
|
|
102
106
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
103
107
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
104
108
|
if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
|
|
@@ -161,7 +165,11 @@ def main() -> int:
|
|
|
161
165
|
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
|
|
162
166
|
f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
|
|
163
167
|
layers.append(f"[ab{i}]")
|
|
164
|
-
|
|
168
|
+
# the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
|
|
169
|
+
# is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
|
|
170
|
+
# dry run's pending A-roll has no length to count, and keeps the old filter.
|
|
171
|
+
tail = conform_to_length(dur_a, MIX_RATE) if dur_a > 0 else f"atrim=duration={dur_a:.3f}"
|
|
172
|
+
parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,{tail}[aout]")
|
|
165
173
|
aout = "[aout]"
|
|
166
174
|
|
|
167
175
|
cmd += ["-filter_complex", ";".join(parts), "-map", vout]
|
|
@@ -174,7 +182,7 @@ def main() -> int:
|
|
|
174
182
|
cmd += aac_args()
|
|
175
183
|
else:
|
|
176
184
|
cmd += ["-an"]
|
|
177
|
-
cmd += ["-t", f"{dur_a:.3f}", output]
|
|
185
|
+
cmd += ["-t", conform_output_t(dur_a, MIX_RATE) if aout == "[aout]" and dur_a > 0 else f"{dur_a:.3f}", output]
|
|
178
186
|
run(cmd)
|
|
179
187
|
|
|
180
188
|
result = probe(output, role="output")
|
package/scripts/check.py
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
Checks duration, frame size / aspect, fps, codec, pixel format, colour tags,
|
|
5
5
|
file size, integrated loudness and true peak against the chosen platform
|
|
6
|
-
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs.
|
|
6
|
+
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs. A length
|
|
7
|
+
or file size over the platform table's limit is WARN (`basis: guidance`): upload
|
|
8
|
+
limits change and the uploader checks the current one; --max-duration / --max-mb
|
|
9
|
+
state the caller's own limit, which FAILs when a --platform is named (with none,
|
|
10
|
+
an over-length is WARN, as every judgement row is). Each
|
|
7
11
|
row's `fix` is the command that resolves it; a few of the less obvious FAILs
|
|
8
12
|
(video codec, pixel format, HDR colour, loudness) also carry a plain-language
|
|
9
13
|
`reason` -- "QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0",
|
|
@@ -43,7 +47,7 @@ import sys
|
|
|
43
47
|
from fractions import Fraction
|
|
44
48
|
from typing import Any, Dict, List, Optional, Tuple
|
|
45
49
|
|
|
46
|
-
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
|
|
50
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
|
|
47
51
|
from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
48
52
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
49
53
|
from _common.emit import file_identity
|
|
@@ -361,11 +365,11 @@ def main() -> int:
|
|
|
361
365
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
362
366
|
ap.add_argument("input")
|
|
363
367
|
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
364
|
-
ap.add_argument("--max-duration", type=float, help="
|
|
368
|
+
ap.add_argument("--max-duration", type=float, help="the caller's own maximum length in seconds: over it FAILs when a --platform is named, WARNs with none (the platform table's limit is guidance, WARN)")
|
|
365
369
|
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
366
370
|
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
367
371
|
ap.add_argument("--tp", type=float, help="override true-peak ceiling")
|
|
368
|
-
ap.add_argument("--max-mb", type=float, help="
|
|
372
|
+
ap.add_argument("--max-mb", type=float, help="the caller's own maximum file size in MB: over it FAILs (the platform table's limit is guidance, WARN)")
|
|
369
373
|
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
370
374
|
ap.add_argument("--content", action="store_true",
|
|
371
375
|
help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
|
|
@@ -448,6 +452,11 @@ def main() -> int:
|
|
|
448
452
|
|
|
449
453
|
# first frame / flashes: a fade from black or a strobe can be the edit, so neither is a mechanical fix
|
|
450
454
|
JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution", "first frame", "flashes"}
|
|
455
|
+
# #485: the rows whose limit is the platform table's upload limit rather than one the caller
|
|
456
|
+
# stated (--max-duration, --max-mb). Those are guidance: WARN at worst, never counted as failed.
|
|
457
|
+
guidance = {name for name, stated, limit in (("duration", args.max_duration, spec["max_duration"]),
|
|
458
|
+
("file size", args.max_mb, spec["max_bytes"]))
|
|
459
|
+
if stated is None and limit}
|
|
451
460
|
|
|
452
461
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
|
|
453
462
|
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
@@ -456,12 +465,19 @@ def main() -> int:
|
|
|
456
465
|
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
457
466
|
if status == "FAIL" and not named and name in JUDGEMENT:
|
|
458
467
|
status = "WARN"
|
|
468
|
+
# "basis" is where the expected value comes from: "requirement" (the delivery format, or a
|
|
469
|
+
# limit the caller stated) can FAIL; "guidance" (the table's upload limit) cannot.
|
|
470
|
+
basis = "guidance" if name in guidance else "requirement"
|
|
471
|
+
if basis == "guidance" and status != "PASS":
|
|
472
|
+
status = "WARN"
|
|
473
|
+
reason = (reason + "; " if reason else "") + upload_limit_note(args.platform)
|
|
459
474
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
460
475
|
"reason": reason if status != "PASS" else "",
|
|
461
|
-
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
476
|
+
"kind": "judgement" if name in JUDGEMENT else "format", "basis": basis})
|
|
462
477
|
|
|
463
478
|
# A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
|
|
464
|
-
# The row says it was not measured (FAIL under a length limit
|
|
479
|
+
# The row says it was not measured (FAIL under a length limit the caller stated, WARN under the
|
|
480
|
+
# platform table's or without one), and the
|
|
465
481
|
# --content rows that are shares of the running time say so too instead of disappearing.
|
|
466
482
|
dur_read = meta.get("duration")
|
|
467
483
|
dur = dur_read or 0.0
|
|
@@ -770,6 +786,8 @@ def main() -> int:
|
|
|
770
786
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
771
787
|
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
772
788
|
line += " [judgement]"
|
|
789
|
+
if r["status"] != "PASS" and r["basis"] == "guidance":
|
|
790
|
+
line += " [guidance]"
|
|
773
791
|
if r["status"] != "PASS" and r["reason"]:
|
|
774
792
|
line += f" ({r['reason']})"
|
|
775
793
|
if r["status"] != "PASS" and r["fix"]:
|
package/scripts/export.py
CHANGED
|
@@ -52,10 +52,10 @@ 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 PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
54
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
|
-
from _common.emit import file_identity
|
|
55
|
+
from _common.emit import file_identity, input_length
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
57
|
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
58
|
-
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
58
|
+
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
|
|
59
59
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
60
60
|
# from a literal restated here: before 1.14 they were typed twice and the facebook preset had
|
|
61
61
|
# already drifted (no duration cap against the table's 14400 s).
|
|
@@ -443,7 +443,7 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
|
|
|
443
443
|
+ (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
|
|
444
444
|
if not math.isfinite(args.max_mb) or args.max_mb <= 0:
|
|
445
445
|
die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
|
|
446
|
-
dur =
|
|
446
|
+
dur = input_length(meta) # the bitrate is the size over the length: no stated length is refused (#491)
|
|
447
447
|
if p["max"] and not args.allow_long:
|
|
448
448
|
dur = min(dur, float(p["max"]))
|
|
449
449
|
if dur <= 0:
|
|
@@ -679,7 +679,8 @@ def main() -> int:
|
|
|
679
679
|
# kept subtitle track is cut at the same point (a cue after it is not written)
|
|
680
680
|
trimmed = {"from": round(float(meta["duration"]), 3), "to": round(float(p["max"]), 3)}
|
|
681
681
|
note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
|
|
682
|
-
"(
|
|
682
|
+
f"(this tool's table as of {UPLOAD_LIMITS_AS_OF}, and platforms change it: "
|
|
683
|
+
"use --allow-long to keep full length)")
|
|
683
684
|
cmd += ["-t", f"{p['max']:.3f}"]
|
|
684
685
|
damage_before = media_damage(args.input) if args.repair and not STATE.dry_run else None
|
|
685
686
|
with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
|
package/scripts/fit.py
CHANGED
|
@@ -42,6 +42,7 @@ from typing import List
|
|
|
42
42
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
43
43
|
from _common.timeline import exact_rate, output_timecode_args
|
|
44
44
|
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
45
|
+
from _common.emit import input_length
|
|
45
46
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
46
47
|
|
|
47
48
|
|
|
@@ -157,6 +158,10 @@ def main() -> int:
|
|
|
157
158
|
target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
|
|
158
159
|
if target <= 0:
|
|
159
160
|
die("target duration must be > 0")
|
|
161
|
+
# speed and trim are both computed from the source's length: one that states none is refused
|
|
162
|
+
# (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
|
|
163
|
+
# place of a cut that does not exist yet and records the stand-in itself (A97)
|
|
164
|
+
src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
|
|
160
165
|
if args.method == "speed":
|
|
161
166
|
if src_dur <= 0 and STATE.dry_run:
|
|
162
167
|
src_dur = target # planning against an intermediate that does not exist yet
|
package/scripts/freeze.py
CHANGED
|
@@ -21,6 +21,7 @@ import sys
|
|
|
21
21
|
|
|
22
22
|
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
23
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
|
|
24
|
+
from _common.emit import input_length
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def main() -> int:
|
|
@@ -43,7 +44,7 @@ def main() -> int:
|
|
|
43
44
|
meta = probe(args.input)
|
|
44
45
|
if not meta.get("video"):
|
|
45
46
|
die("input has no video stream")
|
|
46
|
-
dur = meta
|
|
47
|
+
dur = input_length(meta)
|
|
47
48
|
fps = meta["video"].get("fps") or 30.0
|
|
48
49
|
if args.at is not None:
|
|
49
50
|
at = time_arg(args.at, "--at", (meta.get("video") or {}).get("fps"))
|
package/scripts/graphics.py
CHANGED
|
@@ -49,6 +49,7 @@ from _common.fonts import font_choice, record_font_choice
|
|
|
49
49
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
50
50
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
51
51
|
from _common import drawtext_fontfile # noqa: E402
|
|
52
|
+
from _common.emit import input_length # noqa: E402
|
|
52
53
|
|
|
53
54
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
54
55
|
TEXT = "@TEXT@"
|
|
@@ -201,7 +202,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
201
202
|
info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
|
|
202
203
|
fps = meta["video"].get("fps")
|
|
203
204
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
204
|
-
e = time_arg(args.end, "--end", fps) if args.end else
|
|
205
|
+
e = time_arg(args.end, "--end", fps) if args.end else input_length(meta) # no --end is the clip's end (#491)
|
|
205
206
|
# the picture's own length first: sound that runs on keeps the container open past the last frame
|
|
206
207
|
picture_len = meta["video"].get("duration") or dur
|
|
207
208
|
if picture_len and s >= picture_len:
|
package/scripts/grid.py
CHANGED
|
@@ -34,6 +34,8 @@ from _common.emit import note, record_fallback # noqa: E402
|
|
|
34
34
|
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
35
|
from _common.probe import square_pixels # noqa: E402
|
|
36
36
|
from _common.wrap import text_width_em # noqa: E402
|
|
37
|
+
from _common.emit import input_length # noqa: E402
|
|
38
|
+
from _common.decision import conform_output_t, conform_to_length # noqa: E402
|
|
37
39
|
|
|
38
40
|
LABEL_MARGIN = 10
|
|
39
41
|
|
|
@@ -114,7 +116,7 @@ def main() -> int:
|
|
|
114
116
|
if args.audio_from is not None and not metas[args.audio_from].get("audio"):
|
|
115
117
|
die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
|
|
116
118
|
|
|
117
|
-
durations = [m
|
|
119
|
+
durations = [input_length(m) for m in metas]
|
|
118
120
|
target_duration = max(durations) if args.pad else min(durations)
|
|
119
121
|
|
|
120
122
|
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
@@ -188,12 +190,24 @@ def main() -> int:
|
|
|
188
190
|
cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
|
|
189
191
|
layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
|
|
190
192
|
parts.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}:fill={args.background}[out]")
|
|
191
|
-
|
|
193
|
+
# The sound ends with the grid, to the sample (#493): padded when its clip is shorter than a
|
|
194
|
+
# --pad grid, trimmed when it is longer. `apad,atrim=duration={:.3f}` and the output's
|
|
195
|
+
# `-t {:.3f}` both cut it to the millisecond (59244 samples at 48 kHz came out as 59232). A dry
|
|
196
|
+
# run's pending input has no rate to count in yet, and keeps the old filter, as audio.py does.
|
|
197
|
+
audio_rate = (metas[args.audio_from]["audio"].get("sample_rate") or 0) if args.audio_from is not None else 0
|
|
198
|
+
conformed = bool(audio_rate and target_duration > 0)
|
|
199
|
+
audio_source = None
|
|
200
|
+
if conformed:
|
|
201
|
+
parts.append(f"[{args.audio_from}:a:0]{conform_to_length(target_duration, audio_rate)}[aout]")
|
|
202
|
+
audio_source = "[aout]"
|
|
203
|
+
elif args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
|
|
192
204
|
parts.append(f"[{args.audio_from}:a:0]apad,atrim=duration={target_duration:.3f}[aout]")
|
|
205
|
+
audio_source = "[aout]"
|
|
206
|
+
elif args.audio_from is not None:
|
|
207
|
+
audio_source = f"{args.audio_from}:a:0"
|
|
193
208
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[out]"]
|
|
194
209
|
|
|
195
|
-
if
|
|
196
|
-
audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
|
|
210
|
+
if audio_source:
|
|
197
211
|
cmd += ["-map", audio_source]
|
|
198
212
|
# A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
|
|
199
213
|
# deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
|
|
@@ -206,7 +220,7 @@ def main() -> int:
|
|
|
206
220
|
cmd += aac_args()
|
|
207
221
|
else:
|
|
208
222
|
cmd += ["-an"]
|
|
209
|
-
cmd += ["-t", f"{target_duration:.3f}", output]
|
|
223
|
+
cmd += ["-t", conform_output_t(target_duration, audio_rate) if conformed else f"{target_duration:.3f}", output]
|
|
210
224
|
run(cmd)
|
|
211
225
|
|
|
212
226
|
result = probe(output, role="output")
|
package/scripts/join.py
CHANGED
|
@@ -50,6 +50,7 @@ from _common.emit import note, warn
|
|
|
50
50
|
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _common.timeline import exact_rate
|
|
53
|
+
from _common.emit import input_length
|
|
53
54
|
|
|
54
55
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
55
56
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -704,6 +705,8 @@ def main() -> int:
|
|
|
704
705
|
skipped=skipped)
|
|
705
706
|
validate_color(args.pad_color, "--pad-color")
|
|
706
707
|
metas = [probe(p) for p in args.inputs]
|
|
708
|
+
for p, m in zip(args.inputs, metas):
|
|
709
|
+
input_length(m, p) # every clip is placed by its length: one that states none is refused, not 0 s (#491)
|
|
707
710
|
# A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
|
|
708
711
|
# file an earlier step will write. Its extension stands in: a step names its output for what
|
|
709
712
|
# it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
|
package/scripts/look.py
CHANGED
|
@@ -25,6 +25,7 @@ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
26
26
|
from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
|
|
27
27
|
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
|
|
28
|
+
from _common.emit import input_length # noqa: E402
|
|
28
29
|
|
|
29
30
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
30
31
|
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
@@ -363,6 +364,7 @@ def main() -> int:
|
|
|
363
364
|
except ValueError:
|
|
364
365
|
die("--tiles must look like 4x3")
|
|
365
366
|
n = cols * rows
|
|
367
|
+
dur = input_length(meta) # the sheet is spread over the length: none stated is refused with the way out (#491)
|
|
366
368
|
if not dur:
|
|
367
369
|
die("cannot build a contact sheet without a known duration")
|
|
368
370
|
frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
|
package/scripts/loop.py
CHANGED
|
@@ -36,6 +36,7 @@ from _common import STATE, concat_list_line, add_common, aac_args, apply_common,
|
|
|
36
36
|
from _common.emit import warn
|
|
37
37
|
from _common.decision import av_trim
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
|
+
from _common.emit import input_length
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
@@ -248,7 +249,7 @@ def main() -> int:
|
|
|
248
249
|
meta = probe(args.input)
|
|
249
250
|
if not meta.get("video"):
|
|
250
251
|
die("input has no video stream")
|
|
251
|
-
src_dur = meta
|
|
252
|
+
src_dur = input_length(meta) # no stated length: refused with the way out (#491)
|
|
252
253
|
if src_dur <= 0:
|
|
253
254
|
die("input has no measurable duration to loop")
|
|
254
255
|
has_audio = bool(meta.get("audio")) and not args.boomerang
|
package/scripts/metadata.py
CHANGED
|
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
|
|
|
33
33
|
from _common import (guard_sidecar, add_common, apply_common, default_output, description_block, detect_scenes, detect_silences,
|
|
34
34
|
die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
|
|
35
35
|
STATE, read_text_or_die)
|
|
36
|
+
from _common.emit import input_length
|
|
36
37
|
|
|
37
38
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
38
39
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -230,7 +231,8 @@ def main() -> int:
|
|
|
230
231
|
die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
|
|
231
232
|
f"{', '.join(sorted(CHAPTER_CONTAINERS))} (the streams are copied, so choose the matching family: .mp4/.mov/.m4a for MPEG-4, .mkv/.mka/.webm for Matroska)")
|
|
232
233
|
|
|
233
|
-
|
|
234
|
+
# the last chapter ends at the file's end and --auto-chapters spaces them over its length (#491)
|
|
235
|
+
duration = input_length(meta) if (args.chapters or args.auto_chapters) else (meta.get("duration") or 0.0)
|
|
234
236
|
chapters: Optional[List[Dict[str, Any]]] = parse_chapters(args.chapters, duration) if args.chapters else None
|
|
235
237
|
auto: Optional[Dict[str, Any]] = None
|
|
236
238
|
notes: List[str] = []
|
package/scripts/multicam.py
CHANGED
|
@@ -34,8 +34,9 @@ from typing import Dict, List, Tuple
|
|
|
34
34
|
|
|
35
35
|
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
36
36
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
37
|
-
from _common.decision import av_trim, rebase_project_paths
|
|
37
|
+
from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
|
|
38
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
39
|
+
from _common.emit import input_length
|
|
39
40
|
from sync import measure_offset
|
|
40
41
|
|
|
41
42
|
ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
|
|
@@ -187,8 +188,8 @@ def main() -> int:
|
|
|
187
188
|
ratio = 1.0
|
|
188
189
|
skipped_reason = None
|
|
189
190
|
if args.fix_drift:
|
|
190
|
-
ref_dur = metas[0]
|
|
191
|
-
sec_dur = probe(p)
|
|
191
|
+
ref_dur = input_length(metas[0])
|
|
192
|
+
sec_dur = input_length(probe(p))
|
|
192
193
|
overlap_end = min(ref_dur, sec_dur + off)
|
|
193
194
|
window = 60.0
|
|
194
195
|
head_len = min(args.analyze_seconds, overlap_end)
|
|
@@ -242,7 +243,7 @@ def main() -> int:
|
|
|
242
243
|
|
|
243
244
|
if args.min_shot <= 0:
|
|
244
245
|
die(f"--min-shot must be positive, got {args.min_shot:g}")
|
|
245
|
-
ref_dur = metas[0]
|
|
246
|
+
ref_dur = input_length(metas[0])
|
|
246
247
|
energy_mode = args.switch == "energy"
|
|
247
248
|
if energy_mode:
|
|
248
249
|
cuts = energy_switch(args.inputs, metas, offsets, ratios, ref_dur, args.min_shot)
|
|
@@ -339,12 +340,17 @@ def main() -> int:
|
|
|
339
340
|
afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
|
|
340
341
|
if offsets[a] > 0:
|
|
341
342
|
afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
|
|
342
|
-
|
|
343
|
+
# the sound ends with the reference, to the sample (#493), counted at the 48 kHz it is written
|
|
344
|
+
# in; `atrim=0:{:.3f}` and `-t {:.3f}` cut it to the millisecond. A dry run's pending reference
|
|
345
|
+
# has no length to count, and keeps the old filter.
|
|
346
|
+
conformed = ref_dur > 0
|
|
347
|
+
afx += [conform_to_length(ref_dur, 48000) if conformed else f"atrim=0:{ref_dur:.3f}",
|
|
348
|
+
"aformat=sample_rates=48000:channel_layouts=stereo"]
|
|
343
349
|
parts.append(f"[{a}:a]{','.join(afx)}[aout]")
|
|
344
350
|
|
|
345
351
|
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
346
352
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
347
|
-
cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
|
|
353
|
+
cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", conform_output_t(ref_dur, 48000) if conformed else f"{ref_dur:.3f}", output]
|
|
348
354
|
|
|
349
355
|
if args.write_project:
|
|
350
356
|
# Same cut decision as the combined render above (project_clips was built alongside the
|
package/scripts/render.py
CHANGED
|
@@ -393,6 +393,9 @@ def render_pack(names: List[str], args) -> int:
|
|
|
393
393
|
"size_bytes": size, "duration": probe_doc.get("duration"),
|
|
394
394
|
# a destination whose check did not run is "not checked", not "pass" (the class of A113)
|
|
395
395
|
"check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("not checked" if ok else "failed")),
|
|
396
|
+
# #485: a destination over the table's upload limit passes its check with a
|
|
397
|
+
# WARN; the pack keeps which, so the row is not a bare "pass"
|
|
398
|
+
"guidance": guidance_warns(chk),
|
|
396
399
|
"ok": bool(ok)})
|
|
397
400
|
outputs.append(out)
|
|
398
401
|
if not ok:
|
|
@@ -406,7 +409,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
406
409
|
for r in rows:
|
|
407
410
|
dur = f"{r['duration']:.2f} s" if r.get("duration") else "-"
|
|
408
411
|
mb = f"{r['size_bytes'] / 1024 / 1024:.1f} MB" if r.get("size_bytes") else "-"
|
|
409
|
-
|
|
412
|
+
verdict = r["check"] + (f" (upload-limit guidance: {'; '.join(r['guidance'])})" if r.get("guidance") else "")
|
|
413
|
+
lines.append(f"| {r['platform']} | {r['file']} | {mb} | {dur} | {verdict} |")
|
|
410
414
|
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
411
415
|
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
412
416
|
if not STATE.dry_run:
|
|
@@ -1090,7 +1094,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1090
1094
|
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
1091
1095
|
info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
|
|
1092
1096
|
else:
|
|
1093
|
-
|
|
1097
|
+
advised = guidance_warns(check_result)
|
|
1098
|
+
info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
|
|
1099
|
+
+ "; ".join(advised) if advised else ""))
|
|
1094
1100
|
# the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
|
|
1095
1101
|
# with `stage`, so `verified` is theirs and the check's together
|
|
1096
1102
|
emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
|
|
@@ -1188,6 +1194,14 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
|
1188
1194
|
return False
|
|
1189
1195
|
|
|
1190
1196
|
|
|
1197
|
+
def guidance_warns(check_doc: Optional[Dict[str, Any]]) -> List[str]:
|
|
1198
|
+
"""The rows of a check result that are over the platform table's upload limit (#485): WARN,
|
|
1199
|
+
`basis: guidance`. The check passes with them, so each place that says a check passed names
|
|
1200
|
+
them, with the limit, for whoever uploads to compare with the platform's current one."""
|
|
1201
|
+
return [f"{r['check']}={r['value']} (expected {r['expected']})" for r in (check_doc or {}).get("checks", [])
|
|
1202
|
+
if r.get("basis") == "guidance" and r.get("status") == "WARN"]
|
|
1203
|
+
|
|
1204
|
+
|
|
1191
1205
|
def planned_clip_length(clip: Dict[str, Any], src: str, index: int) -> float:
|
|
1192
1206
|
"""A dry run's length of clip `index` before its speed stage: the source's measured length,
|
|
1193
1207
|
or its in..out range of it (the cut a real run measures does not exist yet). Only when neither
|
|
@@ -1820,7 +1834,10 @@ def main() -> int:
|
|
|
1820
1834
|
info(f"check: could not run check.py — {err.get('message') if isinstance(err, dict) else err}")
|
|
1821
1835
|
exit_code = 1
|
|
1822
1836
|
else:
|
|
1823
|
-
|
|
1837
|
+
# #485: over the table's upload limit is a WARN the check passes with; say it here too
|
|
1838
|
+
advised = guidance_warns(check_result)
|
|
1839
|
+
info(f"check: OK for {check_label}" + (" — upload-limit guidance, the uploader checks the current limit: "
|
|
1840
|
+
+ "; ".join(advised) if advised else ""))
|
|
1824
1841
|
stages_done.append("check")
|
|
1825
1842
|
elif "check" in proj:
|
|
1826
1843
|
record_skip('the check stage names neither "platform" nor "content": true, so nothing was checked',
|
package/scripts/scenes.py
CHANGED
|
@@ -32,6 +32,7 @@ from _common.emit import note, record_fallback # noqa: E402
|
|
|
32
32
|
from _common.color import bt709_label_filter # noqa: E402
|
|
33
33
|
from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
|
|
34
34
|
from _common import dry_run_input_pending # noqa: E402
|
|
35
|
+
from _common.emit import input_length # noqa: E402
|
|
35
36
|
|
|
36
37
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
37
38
|
SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
|
|
@@ -176,7 +177,7 @@ def main() -> int:
|
|
|
176
177
|
die("--beats needs an audio stream; this file has none", kind="input")
|
|
177
178
|
if args.beat_step <= 0:
|
|
178
179
|
die("--beat-step must be greater than 0", kind="input")
|
|
179
|
-
dur = meta
|
|
180
|
+
dur = input_length(meta)
|
|
180
181
|
cuts = [0.0] if (audio_only or pending) else detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
|
|
181
182
|
has_audio = bool(meta.get("audio")) and not pending
|
|
182
183
|
bounds = cuts + [dur]
|
package/scripts/silence.py
CHANGED
|
@@ -35,6 +35,7 @@ from _common.emit import refuse
|
|
|
35
35
|
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
|
|
36
36
|
from _common.decision import frame_rate, stream_starts
|
|
37
37
|
from _common.probe import PROBED_AUDIO
|
|
38
|
+
from _common.emit import input_length
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
|
|
@@ -657,7 +658,7 @@ def main() -> int:
|
|
|
657
658
|
meta = probe(args.input)
|
|
658
659
|
if not meta.get("audio"):
|
|
659
660
|
die("input has no audio stream to analyse")
|
|
660
|
-
duration = meta
|
|
661
|
+
duration = input_length(meta)
|
|
661
662
|
breaths: List[Tuple[float, float]] = []
|
|
662
663
|
if args.speech_aware:
|
|
663
664
|
silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
|
package/scripts/speedramp.py
CHANGED
|
@@ -21,6 +21,7 @@ from typing import List, Tuple
|
|
|
21
21
|
|
|
22
22
|
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
23
|
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
24
|
+
from _common.emit import input_length
|
|
24
25
|
|
|
25
26
|
MAX_SPEED = 20.0
|
|
26
27
|
MIN_SPEED = 0.05
|
|
@@ -72,7 +73,7 @@ def main() -> int:
|
|
|
72
73
|
meta = probe(args.input)
|
|
73
74
|
if not meta.get("video"):
|
|
74
75
|
die("input has no video stream")
|
|
75
|
-
dur = meta
|
|
76
|
+
dur = input_length(meta)
|
|
76
77
|
if abs(segments[0][0] - 0.0) > 0.01:
|
|
77
78
|
die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
|
|
78
79
|
if abs(segments[-1][1] - dur) > 0.5:
|
package/scripts/sync.py
CHANGED
|
@@ -38,6 +38,7 @@ from _common.emit import record_skip, warn
|
|
|
38
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
|
|
39
39
|
from _common.timeline import output_timecode_args
|
|
40
40
|
from _common.runner import default_selection_maps
|
|
41
|
+
from _common.emit import input_length
|
|
41
42
|
|
|
42
43
|
SR = 8000 # decode sample rate
|
|
43
44
|
|
|
@@ -227,8 +228,8 @@ def measure_one(reference: str, path: str, args) -> Dict:
|
|
|
227
228
|
# the head to measure over) or low_confidence (the tail did not correlate); None when applied
|
|
228
229
|
skipped_reason = None
|
|
229
230
|
if args.fix_drift:
|
|
230
|
-
ref_dur = probe(reference)
|
|
231
|
-
sec_dur = probe(path)
|
|
231
|
+
ref_dur = input_length(probe(reference))
|
|
232
|
+
sec_dur = input_length(probe(path))
|
|
232
233
|
overlap_end = min(ref_dur, sec_dur + offset)
|
|
233
234
|
head_len = min(args.analyze_seconds, overlap_end)
|
|
234
235
|
tail_start = overlap_end - args.drift_window
|