ffmpeg-skill 1.4.11 → 1.4.12
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 +3 -3
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +18 -8
- package/scripts/_common.py +76 -11
- package/scripts/audio.py +8 -3
- package/scripts/background.py +2 -2
- package/scripts/batch.py +2 -3
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +19 -7
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +4 -2
- package/scripts/deinterlace.py +2 -2
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +2 -2
- package/scripts/fit.py +2 -2
- package/scripts/freeze.py +10 -3
- package/scripts/graphics.py +2 -2
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +2 -2
- package/scripts/join.py +13 -4
- package/scripts/loop.py +2 -2
- package/scripts/multicam.py +3 -3
- package/scripts/overlay.py +2 -2
- package/scripts/pad.py +2 -2
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +2 -2
- package/scripts/render.py +2 -3
- package/scripts/reverse.py +2 -2
- package/scripts/sequence.py +11 -3
- package/scripts/silence.py +2 -2
- package/scripts/speedramp.py +6 -6
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +2 -2
- package/scripts/straighten.py +2 -2
- package/scripts/sync.py +7 -4
- package/scripts/waveform.py +2 -2
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
143
143
|
|
|
144
144
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
145
145
|
2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
146
|
-
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
146
|
+
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
147
147
|
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
148
148
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
149
149
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
|
@@ -168,7 +168,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
168
168
|
| Tool | What it does |
|
|
169
169
|
|---|---|
|
|
170
170
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio; reports `precision` |
|
|
171
|
-
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps and
|
|
171
|
+
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio |
|
|
172
172
|
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list |
|
|
173
173
|
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad or crop, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
|
|
174
174
|
| `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
|
|
@@ -198,7 +198,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
198
198
|
|---|---|
|
|
199
199
|
| `audio.py` | Voice clean-up chain, FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
|
|
200
200
|
| `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection) |
|
|
201
|
-
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target, video stream-copied; `--measure-only` |
|
|
201
|
+
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target, video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only` |
|
|
202
202
|
|
|
203
203
|
**Picture**
|
|
204
204
|
|
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.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.12`) | 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`
|
|
@@ -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.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.12", "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"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.12",
|
|
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
|
@@ -225,7 +225,8 @@ run time.
|
|
|
225
225
|
sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
|
|
226
226
|
[--start-number N] [--width W] [--height H] [-o OUT]
|
|
227
227
|
```
|
|
228
|
-
Turns a numbered or glob-matched set of still images into a video.
|
|
228
|
+
Turns a numbered or glob-matched set of still images into a video. Glob
|
|
229
|
+
matches are ordered naturally (`img2` before `img10`). The match
|
|
229
230
|
is checked on disk before ffmpeg runs (an empty match or a missing first
|
|
230
231
|
frame is refused here, not discovered from an opaque ffmpeg error).
|
|
231
232
|
|
|
@@ -251,7 +252,10 @@ how long the freeze lasts. `--mode insert` (default) inserts the hold at
|
|
|
251
252
|
extend` only works with `--at` at (or past) the clip's end and just makes
|
|
252
253
|
the last frame last `--hold` seconds longer, with nothing pushed. Audio is
|
|
253
254
|
silent during the held frame in `--mode insert` (there is no source audio
|
|
254
|
-
for a frozen moment that didn't exist before).
|
|
255
|
+
for a frozen moment that didn't exist before). `--mode insert` drops a
|
|
256
|
+
subtitle/data track rather than copy it with timestamps that no longer match
|
|
257
|
+
the pushed picture (`dropped_non_av_streams: true` in the result), as
|
|
258
|
+
`fit.py --method speed` does; `--mode extend` keeps it.
|
|
255
259
|
|
|
256
260
|
### pad.py — add black/silent padding at the start/end
|
|
257
261
|
```
|
|
@@ -260,7 +264,7 @@ pad.py INPUT [--start T] [--end T] [--color C] [-o OUT]
|
|
|
260
264
|
Distinct from `fit.py --fit pad`, which pads the *frame* (letterbox/
|
|
261
265
|
pillarbox bars around each existing frame) -- this pads the *timeline*:
|
|
262
266
|
extra seconds of solid colour and silence before and/or after the clip's
|
|
263
|
-
existing content. At least one of `--start`/`--end` must be > 0.
|
|
267
|
+
existing content (seconds or `mm:ss`). At least one of `--start`/`--end` must be > 0.
|
|
264
268
|
|
|
265
269
|
### speedramp.py — step through different speeds across a clip
|
|
266
270
|
```
|
|
@@ -272,7 +276,8 @@ pieces (repeatable) covering the clip start to end with no gaps or
|
|
|
272
276
|
overlaps, each played at its own constant speed (pitch-preserving audio,
|
|
273
277
|
matching `fit.py`), then concatenates them: "speed up, then slow way down
|
|
274
278
|
for the punch, then speed back up," built from a few constant segments
|
|
275
|
-
rather than a continuous curve. `
|
|
279
|
+
rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
|
|
280
|
+
`FACTOR` is 0.05..20 (2.0 = twice as fast,
|
|
276
281
|
0.5 = half speed). Picking exactly where a ramp should ease in or out is a
|
|
277
282
|
judgement call for the calling agent, made concrete here as the segment
|
|
278
283
|
boundaries it supplies.
|
|
@@ -352,8 +357,9 @@ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
|
352
357
|
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
|
|
353
358
|
[--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
|
|
354
359
|
```
|
|
355
|
-
Normalises every clip to one frame size, fps, `yuv420p
|
|
356
|
-
(
|
|
360
|
+
Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
|
|
361
|
+
channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
362
|
+
silent track generated for clips without audio), then chains `xfade` +
|
|
357
363
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
358
364
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
359
365
|
|
|
@@ -529,7 +535,9 @@ sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--f
|
|
|
529
535
|
Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
|
|
530
536
|
1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
|
|
531
537
|
= the second recording started later. `--replace-audio` writes the reference
|
|
532
|
-
video with the second file's audio aligned (video stream copied)
|
|
538
|
+
video with the second file's audio aligned (video stream copied); the output
|
|
539
|
+
keeps the reference's full length -- a shorter or head-trimmed second file is
|
|
540
|
+
padded with silence, never allowed to cut the picture.
|
|
533
541
|
`--trim-second` writes the second file shifted to the reference timeline.
|
|
534
542
|
`--fix-drift` measures the offset again near the end of the overlap, reports
|
|
535
543
|
the clock difference in ppm, and resamples the second file so a 60-minute
|
|
@@ -586,7 +594,9 @@ audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
|
|
|
586
594
|
`--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
|
|
587
595
|
standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
588
596
|
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
589
|
-
ITU centre/LFE weights for 5.1/7.1 → stereo.
|
|
597
|
+
ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
|
|
598
|
+
leaves a 1-channel input untouched and downmixes >2 channels through
|
|
599
|
+
swresample. Video is always stream-copied.
|
|
590
600
|
Run `loudness.py` after this for final levels.
|
|
591
601
|
|
|
592
602
|
### loudness.py — EBU R128 normalisation
|
package/scripts/_common.py
CHANGED
|
@@ -43,12 +43,11 @@ INSTALL_HINTS = {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
# `kind` (below)
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
|
|
46
|
+
# `kind` (below) is the machine-readable failure axis: input / missing_tool / ffmpeg / output
|
|
47
|
+
# since 0.1, plus timeout (1.3), verification (1.4.3) and interrupted (1.4.10). `ERROR_CODE` is an
|
|
48
|
+
# additive, purely informational refinement layered on top for agents that want a stable enum to
|
|
49
|
+
# switch on instead of pattern-matching `kind` strings -- a static 1:1 relabelling of the same
|
|
50
|
+
# buckets, not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
|
|
52
51
|
# distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
|
|
53
52
|
# split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
|
|
54
53
|
# bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
|
|
@@ -113,6 +112,17 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
113
112
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
114
113
|
if m:
|
|
115
114
|
_FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
|
|
115
|
+
else:
|
|
116
|
+
# git / vendor builds print "N-115000-g..." or a date, never major.minor; the
|
|
117
|
+
# libavutil major is still there and maps one-to-one onto the FFmpeg major
|
|
118
|
+
# (56=4, 57=5, 58=6, 59=7, 60=8). Without this every version branch took the
|
|
119
|
+
# oldest spelling on such builds: on 7.1 that skipped bt709_tag_args()'s
|
|
120
|
+
# workaround and an untagged source got a real matrix conversion.
|
|
121
|
+
m = re.search(r"^libavutil\s+(\d+)\.", out, re.M)
|
|
122
|
+
if m:
|
|
123
|
+
major = int(m.group(1)) - 52
|
|
124
|
+
if major >= 4:
|
|
125
|
+
_FFMPEG_VERSION = (major, 0)
|
|
116
126
|
except (OSError, subprocess.TimeoutExpired):
|
|
117
127
|
# (0, 0) = unknown: every version branch then takes the older, universally accepted
|
|
118
128
|
# spelling, the same "unknown is not missing" stance doctor takes.
|
|
@@ -139,7 +149,9 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str
|
|
|
139
149
|
then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
|
|
140
150
|
needed beyond the usual scale/pad set, and that is already required by redact.py."""
|
|
141
151
|
if fill == "blur":
|
|
142
|
-
radius
|
|
152
|
+
# boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
|
|
153
|
+
# <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
|
|
154
|
+
radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
|
|
143
155
|
return (f"split[__fitfg][__fitbg];"
|
|
144
156
|
f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
|
|
145
157
|
f"boxblur={radius}:2[__fitbgb];"
|
|
@@ -839,7 +851,8 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
|
|
839
851
|
return cmd
|
|
840
852
|
|
|
841
853
|
|
|
842
|
-
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".
|
|
854
|
+
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
|
|
855
|
+
".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
|
|
843
856
|
|
|
844
857
|
|
|
845
858
|
def _output_failed(path: str, why: str) -> "None":
|
|
@@ -902,7 +915,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
902
915
|
if role == "output":
|
|
903
916
|
_output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
|
|
904
917
|
die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
|
|
905
|
-
|
|
918
|
+
try:
|
|
919
|
+
raw = json.loads(proc.stdout or "{}")
|
|
920
|
+
except ValueError as e:
|
|
921
|
+
if role == "output":
|
|
922
|
+
_output_failed(path, f"ffprobe printed unreadable JSON: {e}")
|
|
923
|
+
die(f"ffprobe printed unreadable JSON for {path}: {e}", kind="ffmpeg")
|
|
906
924
|
fmt = raw.get("format", {})
|
|
907
925
|
streams = raw.get("streams", [])
|
|
908
926
|
video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
|
|
@@ -981,7 +999,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
981
999
|
"avg_frame_rate": video.get("avg_frame_rate"),
|
|
982
1000
|
"variable_frame_rate_suspected": vfr,
|
|
983
1001
|
"pix_fmt": video.get("pix_fmt"),
|
|
984
|
-
"bit_depth":
|
|
1002
|
+
"bit_depth": _bit_depth(pix),
|
|
985
1003
|
"hdr": hdr,
|
|
986
1004
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
987
1005
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
@@ -1037,6 +1055,53 @@ def concat_list_line(path: str) -> str:
|
|
|
1037
1055
|
return f"file '{escaped}'"
|
|
1038
1056
|
|
|
1039
1057
|
|
|
1058
|
+
def _bit_depth(pix_fmt: Optional[str]) -> int:
|
|
1059
|
+
"""Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
|
|
1060
|
+
chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
|
|
1061
|
+
yuv420p10le -> 10, gbrp12be -> 12, gray16le -> 16, yuv410p / yuv420p / rgb24 -> 8."""
|
|
1062
|
+
m = re.search(r"(\d{1,2})(?:le|be)?$", pix_fmt or "")
|
|
1063
|
+
if not m:
|
|
1064
|
+
return 8
|
|
1065
|
+
n = int(m.group(1))
|
|
1066
|
+
if n in (24, 32): # packed 8-bit rgb24/bgr32/rgb0 etc.
|
|
1067
|
+
return 8
|
|
1068
|
+
if n in (48, 64): # packed 16-bit rgb48/rgba64
|
|
1069
|
+
return 16
|
|
1070
|
+
return n if 8 <= n <= 16 else 8
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def fmt_secs(value: Optional[float]) -> str:
|
|
1074
|
+
"""`12.345s`, or `?s` when the probe had no duration (MPEG-TS without a duration tag, a
|
|
1075
|
+
stream whose container and streams all omit it). Every writing tool prints the duration
|
|
1076
|
+
of what it wrote; formatting None with :.3f used to raise TypeError after a successful
|
|
1077
|
+
encode, in 25+ scripts."""
|
|
1078
|
+
return "?s" if value is None else f"{value:.3f}s"
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def place_output(src: str, dst: str) -> None:
|
|
1082
|
+
"""Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
|
|
1083
|
+
the path is checked, an existing file is only replaced through a sibling temp so a
|
|
1084
|
+
failed copy never costs the caller what was there, and the result is remembered as ours.
|
|
1085
|
+
render.py's final `copyfile()` used to bypass all three."""
|
|
1086
|
+
import shutil
|
|
1087
|
+
cmd = ["ffmpeg", dst]
|
|
1088
|
+
_check_output_path(cmd)
|
|
1089
|
+
_check_existing_output(cmd)
|
|
1090
|
+
d, base = os.path.split(dst)
|
|
1091
|
+
stem, ext = os.path.splitext(base)
|
|
1092
|
+
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
1093
|
+
try:
|
|
1094
|
+
shutil.copyfile(src, tmp)
|
|
1095
|
+
os.replace(tmp, dst)
|
|
1096
|
+
except OSError as e:
|
|
1097
|
+
try:
|
|
1098
|
+
os.remove(tmp)
|
|
1099
|
+
except OSError:
|
|
1100
|
+
pass
|
|
1101
|
+
die(f"could not place {dst}: {e}", kind="output")
|
|
1102
|
+
_remember_output(cmd)
|
|
1103
|
+
|
|
1104
|
+
|
|
1040
1105
|
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
1041
1106
|
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
1042
1107
|
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
@@ -1468,7 +1533,7 @@ def color_hex(value: str) -> str:
|
|
|
1468
1533
|
return v.upper()
|
|
1469
1534
|
|
|
1470
1535
|
|
|
1471
|
-
_COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@
|
|
1536
|
+
_COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@(?:0(?:\.\d+)?|1(?:\.0+)?|\.\d+))?$") # alpha is 0..1; "red@2" used to reach ffmpeg
|
|
1472
1537
|
|
|
1473
1538
|
|
|
1474
1539
|
def validate_color(value: str, flag: str = "--color") -> str:
|
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
|
|
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
|
|
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
|
|
|
@@ -164,7 +164,12 @@ def main() -> int:
|
|
|
164
164
|
if args.limit:
|
|
165
165
|
fx.append(dynamics_filter("alimiter", args))
|
|
166
166
|
if args.mono:
|
|
167
|
-
|
|
167
|
+
in_ch = (meta.get("audio") or {}).get("channels") or 2
|
|
168
|
+
if in_ch == 2:
|
|
169
|
+
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
170
|
+
elif in_ch > 2:
|
|
171
|
+
fx.append("aformat=channel_layouts=mono") # swresample's standard downmix (centre/LFE weighted)
|
|
172
|
+
# 1 channel: already mono; the stereo pan used to halve it (-6 dB) because c1 was silence
|
|
168
173
|
elif args.stereo:
|
|
169
174
|
fx.append("aformat=channel_layouts=stereo")
|
|
170
175
|
|
|
@@ -227,7 +232,7 @@ def main() -> int:
|
|
|
227
232
|
a = r["audio"]
|
|
228
233
|
if r.get("video") and audio_out and not STATE.dry_run:
|
|
229
234
|
die(f"{output} unexpectedly contains a video stream")
|
|
230
|
-
info(f"wrote {output} ({r['duration']
|
|
235
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
231
236
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
232
237
|
emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
233
238
|
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f])
|
package/scripts/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -73,7 +73,7 @@ def main() -> int:
|
|
|
73
73
|
|
|
74
74
|
result = probe(output, role="output")
|
|
75
75
|
v = result["video"]
|
|
76
|
-
info(f"wrote {output} ({result['duration']
|
|
76
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
77
77
|
emit(output)
|
|
78
78
|
return 0
|
|
79
79
|
|
package/scripts/batch.py
CHANGED
|
@@ -31,11 +31,10 @@ import time
|
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
from typing import Any, Dict, List
|
|
33
33
|
|
|
34
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die
|
|
34
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
|
|
35
35
|
|
|
36
36
|
HERE = Path(__file__).resolve().parent
|
|
37
|
-
MEDIA_EXT = {
|
|
38
|
-
".wav", ".m4a", ".mp3", ".flac", ".aac", ".ogg", ".opus", ".aif", ".aiff"}
|
|
37
|
+
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
39
38
|
# recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
|
|
40
39
|
# with the `/` operator, which silently ignores the left side when the right side is itself an
|
|
41
40
|
# absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -143,7 +143,7 @@ def main() -> int:
|
|
|
143
143
|
|
|
144
144
|
result = probe(output, role="output")
|
|
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
|
-
die(f"output is {result.get('duration')
|
|
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
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
149
|
return 0
|
package/scripts/caption.py
CHANGED
|
@@ -36,7 +36,7 @@ import sys
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import List, Optional, Tuple
|
|
38
38
|
|
|
39
|
-
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die
|
|
39
|
+
from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
|
|
40
40
|
|
|
41
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
42
42
|
|
|
@@ -231,15 +231,14 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
231
231
|
Falls back to an even split when the window is silent or too short.
|
|
232
232
|
"""
|
|
233
233
|
import struct
|
|
234
|
-
import
|
|
235
|
-
from _common import require_tool
|
|
234
|
+
from _common import require_tool, run_analysis
|
|
236
235
|
total_cs = max(1, int(round((end - start) * 100)))
|
|
237
236
|
if n_words <= 1:
|
|
238
237
|
return [total_cs]
|
|
239
238
|
ffmpeg = require_tool("ffmpeg")
|
|
240
239
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
241
240
|
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
242
|
-
proc =
|
|
241
|
+
proc = run_analysis(cmd, check=False, text=False) # under --timeout like every other measurement
|
|
243
242
|
n = len(proc.stdout) // 2
|
|
244
243
|
if proc.returncode != 0 or n < 800:
|
|
245
244
|
per = total_cs // n_words
|
|
@@ -274,10 +273,23 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
274
273
|
out.append(cs)
|
|
275
274
|
prev = b
|
|
276
275
|
out.append(max(5, total_cs - sum(out)))
|
|
277
|
-
# normalise to the exact cue length
|
|
276
|
+
# normalise to the exact cue length. A cue too short for 5 cs per word (0.5 s, 20 words)
|
|
277
|
+
# used to push the remainder into the last word as a negative \kf; split evenly instead,
|
|
278
|
+
# and never let the last word go below 1 cs.
|
|
279
|
+
if total_cs < 5 * n_words:
|
|
280
|
+
per = max(1, total_cs // n_words)
|
|
281
|
+
out = [per] * (n_words - 1) + [max(1, total_cs - per * (n_words - 1))]
|
|
282
|
+
return out
|
|
278
283
|
scale = total_cs / max(1, sum(out))
|
|
279
284
|
out = [max(5, int(round(x * scale))) for x in out]
|
|
280
285
|
out[-1] += total_cs - sum(out)
|
|
286
|
+
while out[-1] < 1:
|
|
287
|
+
i = max(range(len(out) - 1), key=lambda j: out[j])
|
|
288
|
+
take = min(out[i] - 5, 1 - out[-1]) if out[i] > 5 else 0
|
|
289
|
+
if take <= 0:
|
|
290
|
+
break
|
|
291
|
+
out[i] -= take
|
|
292
|
+
out[-1] += take
|
|
281
293
|
return out
|
|
282
294
|
|
|
283
295
|
|
|
@@ -524,7 +536,7 @@ def main() -> int:
|
|
|
524
536
|
cmd += [output]
|
|
525
537
|
run(cmd)
|
|
526
538
|
result = probe(output, role="output")
|
|
527
|
-
info(f"wrote {output} ({result.get('duration')
|
|
539
|
+
info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, subtitle codec {codec})")
|
|
528
540
|
emit(output)
|
|
529
541
|
return 0
|
|
530
542
|
|
|
@@ -576,7 +588,7 @@ def main() -> int:
|
|
|
576
588
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
577
589
|
run(cmd)
|
|
578
590
|
result = probe(output, role="output")
|
|
579
|
-
info(f"wrote {output} ({result.get('duration')
|
|
591
|
+
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
580
592
|
emit(output)
|
|
581
593
|
return 0
|
|
582
594
|
|
package/scripts/color.py
CHANGED
|
@@ -22,7 +22,7 @@ import os
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import List
|
|
24
24
|
|
|
25
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
28
28
|
|
|
@@ -315,7 +315,7 @@ def main() -> int:
|
|
|
315
315
|
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
316
316
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
317
317
|
r = probe(output, role="output")
|
|
318
|
-
info(f"wrote {output} ({r['duration']
|
|
318
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
|
|
319
319
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
320
320
|
extra = {"dropped_non_av_streams": dropped_streams}
|
|
321
321
|
if measurements is not None:
|
package/scripts/crop.py
CHANGED
|
@@ -21,7 +21,7 @@ Examples:
|
|
|
21
21
|
import argparse
|
|
22
22
|
import sys
|
|
23
23
|
|
|
24
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
24
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -72,7 +72,7 @@ def main() -> int:
|
|
|
72
72
|
|
|
73
73
|
result = probe(output, role="output")
|
|
74
74
|
v = result["video"]
|
|
75
|
-
info(f"wrote {output} ({result['duration']
|
|
75
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']})")
|
|
76
76
|
emit(output)
|
|
77
77
|
return 0
|
|
78
78
|
|
package/scripts/cut.py
CHANGED
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
34
|
|
|
35
35
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
36
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
@@ -56,6 +56,8 @@ def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
|
|
|
56
56
|
die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
|
|
57
57
|
a, b = raw.rsplit("-", 1)
|
|
58
58
|
start, end = _t(a, fps), _t(b, fps)
|
|
59
|
+
if start < 0:
|
|
60
|
+
die(f"segment '{raw}': start must be >= 0")
|
|
59
61
|
if end <= start:
|
|
60
62
|
die(f"segment '{raw}': end must be after start")
|
|
61
63
|
segs.append((start, end))
|
|
@@ -225,7 +227,7 @@ def main() -> int:
|
|
|
225
227
|
# lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
|
|
226
228
|
mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
|
|
227
229
|
keyframe_snapped = precision == "packet"
|
|
228
|
-
info(f"wrote {output} ({got
|
|
230
|
+
info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
|
|
229
231
|
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
230
232
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
231
233
|
requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
|
package/scripts/deinterlace.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
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
|
|
25
|
+
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, fmt_secs
|
|
26
26
|
|
|
27
27
|
MODES = {"frame": 0, "field": 1}
|
|
28
28
|
PARITIES = {"auto": -1, "tff": 0, "bff": 1}
|
|
@@ -76,7 +76,7 @@ def main() -> int:
|
|
|
76
76
|
|
|
77
77
|
result = probe(output, role="output")
|
|
78
78
|
v = result["video"]
|
|
79
|
-
info(f"wrote {output} ({result['duration']
|
|
79
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps, mode={args.mode})")
|
|
80
80
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
81
81
|
return 0
|
|
82
82
|
|
package/scripts/denoise.py
CHANGED
|
@@ -18,7 +18,7 @@ Examples:
|
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
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
|
|
21
|
+
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, fmt_secs
|
|
22
22
|
|
|
23
23
|
# hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
|
|
24
24
|
# starting points its own documentation and common usage recommend (spatial then temporal,
|
|
@@ -85,7 +85,7 @@ def main() -> int:
|
|
|
85
85
|
|
|
86
86
|
result = probe(output, role="output")
|
|
87
87
|
v = result["video"]
|
|
88
|
-
info(f"wrote {output} ({result['duration']
|
|
88
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, strength={args.strength})")
|
|
89
89
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
90
90
|
return 0
|
|
91
91
|
|
package/scripts/export.py
CHANGED
|
@@ -26,7 +26,7 @@ import sys
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Dict, List
|
|
28
28
|
|
|
29
|
-
from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args
|
|
29
|
+
from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
30
30
|
PRESETS: Dict[str, Dict] = {
|
|
31
31
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
32
32
|
"youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
|
|
@@ -120,7 +120,7 @@ def main() -> int:
|
|
|
120
120
|
run(cmd)
|
|
121
121
|
result = probe(output, role="output")
|
|
122
122
|
v = result["video"]
|
|
123
|
-
info(f"wrote {output} ({result['duration']
|
|
123
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
124
124
|
emit(output)
|
|
125
125
|
return 0
|
|
126
126
|
|
package/scripts/fit.py
CHANGED
|
@@ -38,7 +38,7 @@ import sys
|
|
|
38
38
|
from fractions import Fraction
|
|
39
39
|
from typing import List
|
|
40
40
|
|
|
41
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, 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
|
|
41
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, 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
|
|
42
42
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
43
43
|
|
|
44
44
|
|
|
@@ -251,7 +251,7 @@ def main() -> int:
|
|
|
251
251
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
252
252
|
|
|
253
253
|
result = probe(output, role="output")
|
|
254
|
-
msg = f"wrote {output} ({result['duration']
|
|
254
|
+
msg = f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})"
|
|
255
255
|
if abs(factor - 1.0) > 1e-4:
|
|
256
256
|
msg += f", speed {factor:.3f}x"
|
|
257
257
|
info(msg)
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
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, parse_time
|
|
22
|
+
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, parse_time, fmt_secs, run
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def main() -> int:
|
|
@@ -101,11 +101,18 @@ def main() -> int:
|
|
|
101
101
|
cmd += aac_args()
|
|
102
102
|
else:
|
|
103
103
|
cmd += ["-an"]
|
|
104
|
-
|
|
104
|
+
if args.mode == "insert":
|
|
105
|
+
# the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
|
|
106
|
+
# old timestamps and every cue after the freeze would fire early (same reasoning as
|
|
107
|
+
# fit.py --method speed). Drop it rather than ship captions that lie.
|
|
108
|
+
run(cmd + [output])
|
|
109
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
110
|
+
else:
|
|
111
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
105
112
|
|
|
106
113
|
result = probe(output, role="output")
|
|
107
114
|
v = result["video"]
|
|
108
|
-
info(f"wrote {output} ({result['duration']
|
|
115
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, froze {args.hold:g}s at {at:g}s, mode={args.mode})")
|
|
109
116
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
110
117
|
return 0
|
|
111
118
|
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -169,7 +169,7 @@ def main() -> int:
|
|
|
169
169
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
170
170
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
171
171
|
r = probe(output, role="output")
|
|
172
|
-
info(f"wrote {output} ({r['duration']
|
|
172
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
|
|
173
173
|
emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
|
|
174
174
|
return 0
|
|
175
175
|
|
package/scripts/grid.py
CHANGED
|
@@ -25,7 +25,7 @@ import argparse
|
|
|
25
25
|
import os
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, \
|
|
28
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
29
|
escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
|
|
30
30
|
|
|
31
31
|
LABEL_MARGIN = 10
|
|
@@ -137,7 +137,7 @@ def main() -> int:
|
|
|
137
137
|
|
|
138
138
|
result = probe(output, role="output")
|
|
139
139
|
v = result["video"]
|
|
140
|
-
info(f"wrote {output} ({result['duration']
|
|
140
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
|
|
141
141
|
f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
|
|
142
142
|
emit(output, cols=args.cols, rows=args.rows, clips=n)
|
|
143
143
|
return 0
|
package/scripts/insert.py
CHANGED
|
@@ -27,7 +27,7 @@ import argparse
|
|
|
27
27
|
import math
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
|
|
30
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def even(n: float) -> int:
|
|
@@ -119,7 +119,7 @@ def main() -> int:
|
|
|
119
119
|
|
|
120
120
|
result = probe(output, role="output")
|
|
121
121
|
v = result["video"]
|
|
122
|
-
info(f"wrote {output} ({result['duration']
|
|
122
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
123
123
|
emit(output)
|
|
124
124
|
return 0
|
|
125
125
|
|
package/scripts/join.py
CHANGED
|
@@ -23,7 +23,7 @@ import argparse
|
|
|
23
23
|
import sys
|
|
24
24
|
from typing import List
|
|
25
25
|
|
|
26
|
-
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS
|
|
26
|
+
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS, fmt_secs
|
|
27
27
|
|
|
28
28
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
29
29
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -75,7 +75,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
75
75
|
die(f"{output} unexpectedly contains a video stream")
|
|
76
76
|
if a.get("sample_rate") != rate or a.get("channels") != channels:
|
|
77
77
|
die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
|
|
78
|
-
info(f"wrote {output} ({r['duration']
|
|
78
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
|
|
79
79
|
+ ("crossfade" if d else "butt join") + ")")
|
|
80
80
|
emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
|
|
81
81
|
sample_rate=rate, channels=channels, video=False)
|
|
@@ -172,9 +172,18 @@ def main() -> int:
|
|
|
172
172
|
# first clip used to drag an HDR second clip down to 8-bit without a tone map.
|
|
173
173
|
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("hdr")), None)
|
|
174
174
|
pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
|
|
175
|
+
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
176
|
+
# silently dropped the centre/LFE of 5.1 material
|
|
177
|
+
chans = [(m.get("audio") or {}).get("channels") or 2 for m in metas]
|
|
178
|
+
channels = args.channels or max(chans)
|
|
179
|
+
layout = LAYOUTS.get(channels)
|
|
180
|
+
if layout is None:
|
|
181
|
+
die(f"{channels}-channel output has no standard layout here (1, 2, 6 or 8); pass --channels")
|
|
182
|
+
if len(set(chans)) > 1:
|
|
183
|
+
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
175
184
|
for i in range(n):
|
|
176
185
|
parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
177
|
-
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=
|
|
186
|
+
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
178
187
|
|
|
179
188
|
if args.transition == "none":
|
|
180
189
|
chain = "".join(f"[v{i}][a{i}]" for i in range(n))
|
|
@@ -196,7 +205,7 @@ def main() -> int:
|
|
|
196
205
|
run(cmd)
|
|
197
206
|
expected = sum(durs) - d * (n - 1)
|
|
198
207
|
r = probe(output, role="output")
|
|
199
|
-
info(f"wrote {output} ({r['duration']
|
|
208
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
200
209
|
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
201
210
|
return 0
|
|
202
211
|
|
package/scripts/loop.py
CHANGED
|
@@ -18,7 +18,7 @@ import argparse
|
|
|
18
18
|
import math
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
|
|
21
|
+
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def main() -> int:
|
|
@@ -71,7 +71,7 @@ def main() -> int:
|
|
|
71
71
|
|
|
72
72
|
result = probe(output, role="output")
|
|
73
73
|
v = result["video"]
|
|
74
|
-
info(f"wrote {output} ({result['duration']
|
|
74
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
|
|
75
75
|
emit(output)
|
|
76
76
|
return 0
|
|
77
77
|
|
package/scripts/multicam.py
CHANGED
|
@@ -29,7 +29,7 @@ import argparse
|
|
|
29
29
|
import sys
|
|
30
30
|
from typing import List, Tuple
|
|
31
31
|
|
|
32
|
-
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS
|
|
32
|
+
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS, fmt_secs
|
|
33
33
|
from sync import measure_offset
|
|
34
34
|
|
|
35
35
|
|
|
@@ -203,10 +203,10 @@ def main() -> int:
|
|
|
203
203
|
|
|
204
204
|
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
205
205
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
206
|
-
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-
|
|
206
|
+
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
|
|
207
207
|
run(cmd)
|
|
208
208
|
r = probe(output, role="output")
|
|
209
|
-
info(f"wrote {output} ({r['duration']
|
|
209
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
|
|
210
210
|
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
|
|
211
211
|
return 0
|
|
212
212
|
|
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg
|
|
27
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -267,7 +267,7 @@ def main() -> int:
|
|
|
267
267
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
268
268
|
if not STATE.dry_run:
|
|
269
269
|
result = probe(output, role="output")
|
|
270
|
-
info(f"wrote {output} ({result['duration']
|
|
270
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])})")
|
|
271
271
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
272
272
|
return 0
|
|
273
273
|
|
package/scripts/pad.py
CHANGED
|
@@ -16,7 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
@@ -61,7 +61,7 @@ def main() -> int:
|
|
|
61
61
|
|
|
62
62
|
result = probe(output, role="output")
|
|
63
63
|
v = result["video"]
|
|
64
|
-
info(f"wrote {output} ({result['duration']
|
|
64
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, +{args.start:g}s start / +{args.end:g}s end)")
|
|
65
65
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
66
66
|
return 0
|
|
67
67
|
|
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, run, video_args
|
|
28
|
+
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, fmt_secs
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def even(n: float) -> int:
|
|
@@ -73,7 +73,7 @@ def main() -> int:
|
|
|
73
73
|
|
|
74
74
|
result = probe(output)
|
|
75
75
|
v = result["video"]
|
|
76
|
-
info(f"wrote {output} ({result['duration']
|
|
76
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
|
|
77
77
|
emit(output)
|
|
78
78
|
return 0
|
|
79
79
|
|
package/scripts/redact.py
CHANGED
|
@@ -20,7 +20,7 @@ Examples:
|
|
|
20
20
|
import argparse
|
|
21
21
|
import sys
|
|
22
22
|
|
|
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
|
|
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, fmt_secs
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def main() -> int:
|
|
@@ -91,7 +91,7 @@ def main() -> int:
|
|
|
91
91
|
|
|
92
92
|
result = probe(output, role="output")
|
|
93
93
|
v = result["video"]
|
|
94
|
-
info(f"wrote {output} ({result['duration']
|
|
94
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.mode} at x={args.x} y={args.y} {args.width}x{args.height})")
|
|
95
95
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
96
96
|
return 0
|
|
97
97
|
|
package/scripts/render.py
CHANGED
|
@@ -55,7 +55,7 @@ import sys
|
|
|
55
55
|
from pathlib import Path
|
|
56
56
|
from typing import Any, Dict, List
|
|
57
57
|
|
|
58
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool
|
|
58
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output
|
|
59
59
|
|
|
60
60
|
HERE = Path(__file__).resolve().parent
|
|
61
61
|
|
|
@@ -350,8 +350,7 @@ def main() -> int:
|
|
|
350
350
|
stages_done.append("export")
|
|
351
351
|
else:
|
|
352
352
|
if not STATE.dry_run:
|
|
353
|
-
|
|
354
|
-
shutil.copyfile(current, output)
|
|
353
|
+
place_output(current, output)
|
|
355
354
|
info(f"copied final stage to {output}")
|
|
356
355
|
current = output
|
|
357
356
|
|
package/scripts/reverse.py
CHANGED
|
@@ -14,7 +14,7 @@ Examples:
|
|
|
14
14
|
import argparse
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
17
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -47,7 +47,7 @@ def main() -> int:
|
|
|
47
47
|
run(cmd)
|
|
48
48
|
|
|
49
49
|
result = probe(output, role="output")
|
|
50
|
-
info(f"wrote {output} ({result['duration']
|
|
50
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
|
|
51
51
|
emit(output)
|
|
52
52
|
return 0
|
|
53
53
|
|
package/scripts/sequence.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import re
|
|
1
2
|
#!/usr/bin/env python3
|
|
2
3
|
"""Turn a numbered image sequence into a video.
|
|
3
4
|
|
|
@@ -19,7 +20,7 @@ import sys
|
|
|
19
20
|
import tempfile
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
22
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line
|
|
23
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def even(n: float) -> int:
|
|
@@ -31,6 +32,11 @@ def _concat_list_line(path: Path) -> str:
|
|
|
31
32
|
return concat_list_line(str(path))
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def natural_key(name: str) -> list:
|
|
36
|
+
"""img2 < img10: digit runs compare as numbers, the rest case-insensitively."""
|
|
37
|
+
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
|
|
38
|
+
|
|
39
|
+
|
|
34
40
|
def main() -> int:
|
|
35
41
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
36
42
|
ap.add_argument("--dir", required=True, help="directory containing the frames")
|
|
@@ -54,7 +60,9 @@ def main() -> int:
|
|
|
54
60
|
|
|
55
61
|
is_glob = "%" not in args.pattern
|
|
56
62
|
if is_glob:
|
|
57
|
-
|
|
63
|
+
# natural order: img2 before img10 (a lexical sort put every img1x before img2 and the
|
|
64
|
+
# frames came out shuffled); a printf pattern never had this problem
|
|
65
|
+
frames = sorted(directory.glob(args.pattern), key=lambda p: natural_key(p.name))
|
|
58
66
|
if not frames:
|
|
59
67
|
die(f"no files in {args.dir} match glob '{args.pattern}'")
|
|
60
68
|
info(f"found {len(frames)} frames matching '{args.pattern}'")
|
|
@@ -113,7 +121,7 @@ def main() -> int:
|
|
|
113
121
|
|
|
114
122
|
result = probe(output, role="output")
|
|
115
123
|
v = result["video"]
|
|
116
|
-
info(f"wrote {output} ({result['duration']
|
|
124
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
117
125
|
emit(output)
|
|
118
126
|
return 0
|
|
119
127
|
|
package/scripts/silence.py
CHANGED
|
@@ -17,7 +17,7 @@ import re
|
|
|
17
17
|
import sys
|
|
18
18
|
from typing import List, Tuple
|
|
19
19
|
|
|
20
|
-
from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending
|
|
20
|
+
from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending, fmt_secs
|
|
21
21
|
|
|
22
22
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
23
23
|
|
|
@@ -134,7 +134,7 @@ def main() -> int:
|
|
|
134
134
|
cmd += ["-af", af] + audio_codec_for(output) + [output]
|
|
135
135
|
run(cmd)
|
|
136
136
|
r = probe(output, role="output")
|
|
137
|
-
info(f"wrote {output} ({r['duration']
|
|
137
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
|
|
138
138
|
emit(output, **summary)
|
|
139
139
|
return 0
|
|
140
140
|
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, parse_time
|
|
23
23
|
|
|
24
24
|
MAX_SPEED = 20.0
|
|
25
25
|
MIN_SPEED = 0.05
|
|
@@ -41,11 +41,11 @@ def atempo_chain(factor: float) -> str:
|
|
|
41
41
|
|
|
42
42
|
def parse_segment(raw: str) -> Tuple[float, float, float]:
|
|
43
43
|
try:
|
|
44
|
-
span, factor_s = raw.
|
|
45
|
-
start_s, end_s = span.
|
|
46
|
-
start, end, factor =
|
|
44
|
+
span, factor_s = raw.rsplit(":", 1)
|
|
45
|
+
start_s, end_s = span.rsplit("-", 1)
|
|
46
|
+
start, end, factor = parse_time(start_s), parse_time(end_s), float(factor_s)
|
|
47
47
|
except ValueError:
|
|
48
|
-
die(f"--segment must look like START-END:FACTOR, got '{raw}'")
|
|
48
|
+
die(f"--segment must look like START-END:FACTOR (times in seconds or mm:ss), got '{raw}'")
|
|
49
49
|
if end <= start:
|
|
50
50
|
die(f"--segment {raw}: END must be after START")
|
|
51
51
|
if not MIN_SPEED <= factor <= MAX_SPEED:
|
|
@@ -114,7 +114,7 @@ def main() -> int:
|
|
|
114
114
|
|
|
115
115
|
result = probe(output, role="output")
|
|
116
116
|
v = result["video"]
|
|
117
|
-
info(f"wrote {output} ({result['duration']
|
|
117
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
|
|
118
118
|
emit(output)
|
|
119
119
|
return 0
|
|
120
120
|
|
package/scripts/sphere.py
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
import argparse
|
|
32
32
|
import sys
|
|
33
33
|
|
|
34
|
-
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
|
|
34
|
+
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, fmt_secs
|
|
35
35
|
|
|
36
36
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
37
|
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
@@ -117,7 +117,7 @@ def main() -> int:
|
|
|
117
117
|
|
|
118
118
|
result = probe(output, role="output")
|
|
119
119
|
v = result["video"]
|
|
120
|
-
info(f"wrote {output} ({result['duration']
|
|
120
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
|
|
121
121
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
122
122
|
return 0
|
|
123
123
|
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,7 +27,7 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending
|
|
30
|
+
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -94,7 +94,7 @@ def main() -> int:
|
|
|
94
94
|
run(cmd)
|
|
95
95
|
|
|
96
96
|
result = probe(output, role="output")
|
|
97
|
-
info(f"wrote {output} ({result['duration']
|
|
97
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
|
|
98
98
|
emit(output)
|
|
99
99
|
return 0
|
|
100
100
|
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def main() -> int:
|
|
@@ -88,7 +88,7 @@ def main() -> int:
|
|
|
88
88
|
|
|
89
89
|
result = probe(output, role="output")
|
|
90
90
|
v = result["video"]
|
|
91
|
-
info(f"wrote {output} ({result['duration']
|
|
91
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, degrees={args.degrees:g}, fit={args.fit})")
|
|
92
92
|
emit(output, dropped_non_av_streams=dropped_streams)
|
|
93
93
|
return 0
|
|
94
94
|
|
package/scripts/sync.py
CHANGED
|
@@ -291,10 +291,13 @@ def main() -> int:
|
|
|
291
291
|
if head_trim > 0:
|
|
292
292
|
cmd += ["-ss", f"{head_trim:.4f}"]
|
|
293
293
|
cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
# the reference picture is the deliverable: pad the (possibly trimmed) second audio to
|
|
295
|
+
# its length instead of -shortest, which cut the reference's tail whenever the second
|
|
296
|
+
# file started earlier (head_trim > 0) or was simply shorter
|
|
297
|
+
ref_dur = probe(args.reference).get("duration")
|
|
298
|
+
af_parts = drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
|
|
299
|
+
cmd += ["-af", ",".join(af_parts)]
|
|
300
|
+
cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
|
|
298
301
|
proc = run(cmd, check=False)
|
|
299
302
|
if proc.returncode != 0:
|
|
300
303
|
cmd = [c for c in cmd if c != "copy"]
|
package/scripts/waveform.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
|
|
25
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
28
|
|
|
@@ -88,7 +88,7 @@ def main() -> int:
|
|
|
88
88
|
|
|
89
89
|
result = probe(output, role="output")
|
|
90
90
|
v = result["video"]
|
|
91
|
-
info(f"wrote {output} ({result['duration']
|
|
91
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.style})")
|
|
92
92
|
emit(output)
|
|
93
93
|
return 0
|
|
94
94
|
|