ffmpeg-skill 1.4.14 → 1.5.0
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 -0
- package/docs/contract.md +14 -4
- package/package.json +1 -1
- package/references/scripts.md +12 -3
- package/scripts/_common.py +38 -1
- package/scripts/audio.py +8 -4
- package/scripts/broll.py +2 -1
- package/scripts/join.py +2 -1
- package/scripts/loudness.py +7 -2
- package/scripts/proxy.py +4 -5
- package/scripts/speedramp.py +1 -1
package/README.md
CHANGED
|
@@ -355,6 +355,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
|
|
|
355
355
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
356
356
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
|
357
357
|
| **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
|
|
358
|
+
| **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
|
|
358
359
|
| **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
|
|
359
360
|
| **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
|
|
360
361
|
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.5.0`) | 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`
|
|
@@ -46,7 +46,7 @@ For the whole of 1.x:
|
|
|
46
46
|
| Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
|
|
47
47
|
| CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
|
|
48
48
|
| `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
|
|
49
|
-
| Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor
|
|
49
|
+
| Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
|
|
50
50
|
| `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
|
|
51
51
|
| MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
|
|
52
52
|
| Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.5.0", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -299,6 +299,15 @@ Success (`exit 0`): one document matching `output_schema`, always with
|
|
|
299
299
|
`status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
|
|
300
300
|
file was written. `probe` prints its measurement document directly.
|
|
301
301
|
|
|
302
|
+
With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
|
|
303
|
+
also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
|
|
304
|
+
(issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
|
|
305
|
+
{"non_av_streams"}, "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
|
|
306
|
+
measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
|
|
307
|
+
`offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
|
|
308
|
+
1.x keys are not moved; the environment variable only adds the key, and its absence is the
|
|
309
|
+
default until 2.0.
|
|
310
|
+
|
|
302
311
|
Success is decided by `verify_output` in `_common.py`, not by the ffmpeg exit code alone:
|
|
303
312
|
the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
|
|
304
313
|
ffmpeg successfully but has no usable artifact fails with `kind: output` (a 0-byte file is
|
|
@@ -318,7 +327,8 @@ before, and, when `--json` was given, on stdout:
|
|
|
318
327
|
`message` carries the script's own reason (missing input, ffprobe failure, the last
|
|
319
328
|
stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
|
|
320
329
|
flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
|
|
321
|
-
or run so the caller can retry or report without re-deriving the command
|
|
330
|
+
or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
|
|
331
|
+
failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
|
|
322
332
|
purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
|
|
323
333
|
doesn't already make) for a caller that wants a stable enum instead of matching `kind`
|
|
324
334
|
strings. `retryable` is currently always `false`: none of the kinds are distinguishable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -281,6 +281,8 @@ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
|
|
|
281
281
|
0.5 = half speed). Picking exactly where a ramp should ease in or out is a
|
|
282
282
|
judgement call for the calling agent, made concrete here as the segment
|
|
283
283
|
boundaries it supplies.
|
|
284
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
285
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
284
286
|
|
|
285
287
|
### loop.py — repeat a clip
|
|
286
288
|
```
|
|
@@ -308,6 +310,8 @@ overlap or run past A's end, and B must have enough material from `--from`.
|
|
|
308
310
|
`--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
|
|
309
311
|
it inside each window with B's; `mix` plays both. The output's length is
|
|
310
312
|
verified against A's.
|
|
313
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
314
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
311
315
|
|
|
312
316
|
### metadata.py — chapter markers and container tags, streams copied
|
|
313
317
|
```
|
|
@@ -362,6 +366,8 @@ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
|
362
366
|
silent track generated for clips without audio), then chains `xfade` +
|
|
363
367
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
364
368
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
369
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
370
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
365
371
|
|
|
366
372
|
### render.py — the whole edit in one project.json
|
|
367
373
|
```
|
|
@@ -596,7 +602,8 @@ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
|
596
602
|
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
597
603
|
ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
|
|
598
604
|
leaves a 1-channel input untouched and downmixes >2 channels through
|
|
599
|
-
swresample. Video is always stream-copied
|
|
605
|
+
swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
606
|
+
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
600
607
|
Run `loudness.py` after this for final levels.
|
|
601
608
|
|
|
602
609
|
### loudness.py — EBU R128 normalisation
|
|
@@ -604,7 +611,8 @@ Run `loudness.py` after this for final levels.
|
|
|
604
611
|
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
605
612
|
```
|
|
606
613
|
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
607
|
-
the true-peak ceiling allows). Video
|
|
614
|
+
the true-peak ceiling allows). Video and any subtitle/data track are
|
|
615
|
+
stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
|
|
608
616
|
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
609
617
|
The written file is measured again: a lossy encoder can push peaks past the
|
|
610
618
|
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
@@ -629,6 +637,7 @@ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
|
|
|
629
637
|
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
630
638
|
factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
|
|
631
639
|
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
632
|
-
run `color.py --to-sdr` first if SDR is wanted)
|
|
640
|
+
run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
|
|
641
|
+
track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
|
|
633
642
|
given — does not decide which asset to proxy or what for.
|
|
634
643
|
|
package/scripts/_common.py
CHANGED
|
@@ -348,11 +348,44 @@ def emit(output: Optional[str], **extra: Any) -> None:
|
|
|
348
348
|
if meta:
|
|
349
349
|
doc["probe"] = meta
|
|
350
350
|
doc.update(extra)
|
|
351
|
+
if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
|
|
352
|
+
doc["result_v2"] = _result_v2(output, meta, extra)
|
|
351
353
|
print_json(doc)
|
|
352
354
|
elif output:
|
|
353
355
|
print(output)
|
|
354
356
|
|
|
355
357
|
|
|
358
|
+
_V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
|
|
362
|
+
"""The 2.0 success-document shape, previewed in 1.x as a parallel `result_v2` key when
|
|
363
|
+
FFMPEG_SKILL_RESULT_V2=1 (issue #189 B). Every tool gets the same six slots: `output`,
|
|
364
|
+
`probe`, `commands`, `metrics` (numbers a caller keys on: loudness's `result`/`measured`
|
|
365
|
+
dicts flattened, plus every top-level numeric extra such as `expected_duration` or
|
|
366
|
+
`offset_seconds`), `notes` (free text), `dropped` (what did not make it into the output),
|
|
367
|
+
and `details` (the tool's remaining extras, unchanged). The 1.x keys stay where they are;
|
|
368
|
+
this key is additive and its shape is what 2.0 promotes to the top level."""
|
|
369
|
+
metrics: Dict[str, Any] = {}
|
|
370
|
+
for key in ("measured", "result"):
|
|
371
|
+
if isinstance(extra.get(key), dict):
|
|
372
|
+
metrics.update(extra[key])
|
|
373
|
+
for key, value in extra.items():
|
|
374
|
+
if key not in _V2_HANDLED and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
375
|
+
metrics[key] = value
|
|
376
|
+
notes = extra.get("notes")
|
|
377
|
+
return {
|
|
378
|
+
"schema": 2,
|
|
379
|
+
"output": output,
|
|
380
|
+
"probe": meta or None,
|
|
381
|
+
"commands": list(STATE.commands),
|
|
382
|
+
"metrics": metrics,
|
|
383
|
+
"notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
|
|
384
|
+
"dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
|
|
385
|
+
"details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
356
389
|
def _cmdline(cmd: Sequence[str]) -> str:
|
|
357
390
|
return " ".join(shell_quote(c) for c in cmd)
|
|
358
391
|
|
|
@@ -398,8 +431,12 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
398
431
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
399
432
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
400
433
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
434
|
+
# The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
|
|
435
|
+
# a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
|
|
436
|
+
# are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
|
|
437
|
+
# document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
|
|
401
438
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
402
|
-
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=
|
|
439
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
|
|
403
440
|
|
|
404
441
|
|
|
405
442
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
package/scripts/audio.py
CHANGED
|
@@ -20,7 +20,7 @@ import argparse
|
|
|
20
20
|
import sys
|
|
21
21
|
from typing import List
|
|
22
22
|
|
|
23
|
-
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
|
|
23
|
+
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
24
24
|
|
|
25
25
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
26
26
|
|
|
@@ -226,8 +226,11 @@ def main() -> int:
|
|
|
226
226
|
if not keep_video:
|
|
227
227
|
# audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
|
|
228
228
|
cmd.append("-shortest")
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
if keep_video:
|
|
230
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
231
|
+
else:
|
|
232
|
+
dropped_streams = bool(has_video and (meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
233
|
+
run(cmd + [output])
|
|
231
234
|
r = probe(output, role="output")
|
|
232
235
|
a = r["audio"]
|
|
233
236
|
if r.get("video") and audio_out and not STATE.dry_run:
|
|
@@ -235,7 +238,8 @@ def main() -> int:
|
|
|
235
238
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
236
239
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
237
240
|
emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
238
|
-
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f]
|
|
241
|
+
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
242
|
+
dropped_non_av_streams=dropped_streams)
|
|
239
243
|
return 0
|
|
240
244
|
|
|
241
245
|
|
package/scripts/broll.py
CHANGED
|
@@ -145,7 +145,8 @@ def main() -> int:
|
|
|
145
145
|
if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
|
|
146
146
|
die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
|
|
147
147
|
info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
|
|
148
|
-
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio
|
|
148
|
+
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio,
|
|
149
|
+
dropped_non_av_streams=bool(meta_a.get("subtitle_streams") or meta_a.get("data_streams")))
|
|
149
150
|
return 0
|
|
150
151
|
|
|
151
152
|
|
package/scripts/join.py
CHANGED
|
@@ -206,7 +206,8 @@ def main() -> int:
|
|
|
206
206
|
expected = sum(durs) - d * (n - 1)
|
|
207
207
|
r = probe(output, role="output")
|
|
208
208
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
209
|
-
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3)
|
|
209
|
+
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
|
|
210
|
+
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
210
211
|
return 0
|
|
211
212
|
|
|
212
213
|
|
package/scripts/loudness.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -90,7 +90,10 @@ def main() -> int:
|
|
|
90
90
|
bitrate_pinned = args.audio_bitrate is not None
|
|
91
91
|
bitrate = args.audio_bitrate or "192k"
|
|
92
92
|
|
|
93
|
+
dropped_streams = False
|
|
94
|
+
|
|
93
95
|
def encode(tp: float, bitrate: str) -> None:
|
|
96
|
+
nonlocal dropped_streams
|
|
94
97
|
af = (
|
|
95
98
|
f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
|
|
96
99
|
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
@@ -101,6 +104,8 @@ def main() -> int:
|
|
|
101
104
|
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
102
105
|
else:
|
|
103
106
|
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
107
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
108
|
+
return
|
|
104
109
|
cmd.append(output)
|
|
105
110
|
run(cmd)
|
|
106
111
|
|
|
@@ -149,7 +154,7 @@ def main() -> int:
|
|
|
149
154
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
150
155
|
kind="verification", output=output, result=result,
|
|
151
156
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
152
|
-
emit(output, result=result)
|
|
157
|
+
emit(output, result=result, dropped_non_av_streams=dropped_streams)
|
|
153
158
|
return 0
|
|
154
159
|
|
|
155
160
|
|
package/scripts/proxy.py
CHANGED
|
@@ -25,7 +25,7 @@ Examples:
|
|
|
25
25
|
import argparse
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe,
|
|
28
|
+
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def even(n: float) -> int:
|
|
@@ -67,14 +67,13 @@ def main() -> int:
|
|
|
67
67
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
68
68
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
69
|
cmd += cfr_args(meta, args.fps)
|
|
70
|
-
cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
|
|
71
|
-
cmd
|
|
72
|
-
run(cmd)
|
|
70
|
+
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
|
71
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
73
72
|
|
|
74
73
|
result = probe(output)
|
|
75
74
|
v = result["video"]
|
|
76
75
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
|
|
77
|
-
emit(output)
|
|
76
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
78
77
|
return 0
|
|
79
78
|
|
|
80
79
|
|
package/scripts/speedramp.py
CHANGED
|
@@ -115,7 +115,7 @@ def main() -> int:
|
|
|
115
115
|
result = probe(output, role="output")
|
|
116
116
|
v = result["video"]
|
|
117
117
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
|
|
118
|
-
emit(output)
|
|
118
|
+
emit(output, dropped_non_av_streams=bool(meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
119
119
|
return 0
|
|
120
120
|
|
|
121
121
|
|