ffmpeg-skill 3.8.10 → 3.8.11
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/docs/contract.md +13 -7
- package/package.json +1 -1
- package/references/scripts.md +13 -6
- package/scripts/_common/decision.py +19 -0
- package/scripts/_common/emit.py +8 -5
- package/scripts/_common/probe.py +5 -0
- package/scripts/_common/runner.py +42 -1
- package/scripts/_contract.py +3 -3
- package/scripts/background.py +30 -10
- package/scripts/broll.py +8 -2
- package/scripts/caption.py +2 -2
- package/scripts/color.py +8 -5
- package/scripts/crop.py +3 -1
- package/scripts/export.py +16 -7
- package/scripts/freeze.py +6 -3
- package/scripts/graphics.py +3 -1
- package/scripts/grid.py +4 -2
- package/scripts/loop.py +9 -5
- package/scripts/metadata.py +4 -0
- package/scripts/redact.py +14 -2
- package/scripts/render.py +64 -2
- package/scripts/reverse.py +20 -7
- package/scripts/straighten.py +14 -3
package/README.md
CHANGED
|
@@ -441,7 +441,7 @@ Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake t
|
|
|
441
441
|
- `join.py` puts transitions between clips that differ in size, frame rate or audio layout. Done by hand, those clips have to be normalised first or xfade fails.
|
|
442
442
|
- `sync.py` and `multicam.py` find the offset between two recordings by audio cross-correlation. No ffmpeg filter measures it.
|
|
443
443
|
|
|
444
|
-
Every command a tool
|
|
444
|
+
Every command a tool records as run is in its `--json` `commands` (`batch.py` lists none of its steps' commands, and a measurement pass is listed only where the tool records it), shell-quoted so it can be pasted and run again, except a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate), and `--dry-run` on a writing tool prints the commands without writing the output (some dry runs still run a measurement, and `verify.py` ignores `--dry-run`: see below). In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
|
|
445
445
|
|
|
446
446
|
### What does `verified` check, and what can it not tell me?
|
|
447
447
|
|
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.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.11`) | 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.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.11", "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"},
|
|
@@ -936,7 +936,8 @@ before, and, when `--json` was given, on stdout:
|
|
|
936
936
|
`message` carries the script's own reason (missing input, ffprobe failure, the last
|
|
937
937
|
stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
|
|
938
938
|
flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
|
|
939
|
-
or run so the caller can retry or report without re-deriving the command
|
|
939
|
+
or run so the caller can retry or report without re-deriving the command (a line that names a file
|
|
940
|
+
the tool wrote for the run and removed when it exited, such as a concat list, is a record of it); `kind: ffmpeg`
|
|
940
941
|
failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
|
|
941
942
|
purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
|
|
942
943
|
doesn't already make) for a caller that wants a stable enum instead of matching `kind`
|
|
@@ -976,8 +977,10 @@ says it is kept without the step that was stopped, and the document carries it a
|
|
|
976
977
|
**Source carry-over.** Every tool that writes a media file compares what its first input carried
|
|
977
978
|
besides the picture and the sound with the file it wrote, and says so in `source_carry:
|
|
978
979
|
{kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
|
|
979
|
-
`audio_channels` (a surround source only: more than two), `
|
|
980
|
-
|
|
980
|
+
`audio_channels` (a surround source only: more than two), `attachments` (Matroska attached files,
|
|
981
|
+
such as the fonts of a subtitle track; only a Matroska output holds them), `video_streams` (a
|
|
982
|
+
source with more than one playable picture: the tools write one), `creation_time`,
|
|
983
|
+
`audio_language` and `timecode`. A timecode is compared by its value: it is kept when the written start timecode
|
|
981
984
|
labels the frame the output starts on, which is the source's own label or, where the tool moved
|
|
982
985
|
the picture's start (`cut` from a later frame, `fit --method trim`, `sync --trim-second`, a
|
|
983
986
|
`render.py` clip with an `in`), the label of the source frame the output now starts on. A join of
|
|
@@ -985,9 +988,12 @@ several ranges (`cut --segments`, `--remove` leaving two or more) writes no star
|
|
|
985
988
|
no single start labels the frames after the first join. The key is present only when the source
|
|
986
989
|
had at least one of them, so a result for a plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
|
|
987
990
|
or more inputs have a picture or sound (which one's items the output should carry is the tool's
|
|
988
|
-
choice).
|
|
991
|
+
choice). `render.py`, whose stages run as their own tools, compares a project whose clips are all
|
|
992
|
+
one file, with no music, replacement or effects track and no video layer, with that file; its
|
|
993
|
+
timecode counts as kept when every stage that compared it kept it. An item bound to a stream the output does not have at all is not compared (a sound-only
|
|
989
994
|
extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
|
|
990
|
-
it. A dropped `subtitles`, `chapters`, `cover_art
|
|
995
|
+
it. A dropped `subtitles`, `chapters`, `cover_art`, `audio_channels`, `attachments` or
|
|
996
|
+
`video_streams` also records one
|
|
991
997
|
`source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
|
|
992
998
|
`dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
|
|
993
999
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.11",
|
|
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
|
@@ -412,7 +412,9 @@ burned-in line that does not move. That is a border blend, not inpainting,
|
|
|
412
412
|
and a logo that travels or subtitles that change position are out of
|
|
413
413
|
scope. The filter reads a 1 px ring outside the box, so the box cannot
|
|
414
414
|
touch the frame edge, and it only interpolates 8-bit YUV (an HDR or 10-bit
|
|
415
|
-
source is refused; `color.py --to-sdr` first).
|
|
415
|
+
source is refused; `color.py --to-sdr` first). Blur and pixelate cover the
|
|
416
|
+
rectangle exactly at an odd `--x`/`--y` too (on 4:2:0 the chroma sample on an
|
|
417
|
+
odd edge is shared with the pixel outside). The rest of the frame, and
|
|
416
418
|
the whole clip's timeline, are untouched -- to cover only part of the
|
|
417
419
|
timeline, `cut.py` the clip into segments first.
|
|
418
420
|
|
|
@@ -471,6 +473,8 @@ background.py -o OUT.mov --duration T --width W --height H --transparent
|
|
|
471
473
|
No input file: ffmpeg's own `color`/`gradients` source filters generate the
|
|
472
474
|
clip directly. For a title-card background, a placeholder layer, or a base
|
|
473
475
|
for `overlay.py` to composite onto. `--width`/`--height` must be even.
|
|
476
|
+
`--angle DEG` (any value, 0 = left to right, 90 = top to bottom) runs the gradient from the corner
|
|
477
|
+
the direction starts from (C1) to the opposite one (C2).
|
|
474
478
|
`--transparent` writes a canvas with alpha 0 in every pixel in an alpha line:
|
|
475
479
|
ProRes 4444 with 16-bit alpha (default; `.mov`/`.mkv`), `qtrle` (`.mov`), `png`
|
|
476
480
|
(`.mov`/`.mkv`) or `vp9` (`.webm`/`.mkv`). It is the base layer
|
|
@@ -485,7 +489,9 @@ reverse.py INPUT [--no-audio] [-o OUT]
|
|
|
485
489
|
Reverses video (and audio, unless `--no-audio`) with ffmpeg's `reverse`/
|
|
486
490
|
`areverse` filters, which buffer the whole clip in memory — keep this to
|
|
487
491
|
clips it makes sense to reverse (seconds to a couple of minutes), not
|
|
488
|
-
something this tool limits for you.
|
|
492
|
+
something this tool limits for you. A `memory_heavy` warning says when the
|
|
493
|
+
decoded frames come to more than about 2 GiB (size, chroma, depth and frame
|
|
494
|
+
count, not the length alone).
|
|
489
495
|
|
|
490
496
|
### stabilize.py — motion stabilisation
|
|
491
497
|
```
|
|
@@ -567,10 +573,11 @@ extend` only works with `--at` at (or past) the clip's end and just makes
|
|
|
567
573
|
the last frame last `--hold` seconds longer, with nothing pushed; `--mode insert` at
|
|
568
574
|
the clip's end (no `--at`) does the same. Audio is
|
|
569
575
|
silent during the held frame in `--mode insert` (there is no source audio
|
|
570
|
-
for a frozen moment that didn't exist before).
|
|
571
|
-
subtitle/data track rather than copy it with timestamps that
|
|
572
|
-
the pushed picture (`dropped_non_av_streams: true` in the
|
|
573
|
-
`fit.py --method speed` does;
|
|
576
|
+
for a frozen moment that didn't exist before). A hold inside the clip or at
|
|
577
|
+
its start drops a subtitle/data track rather than copy it with timestamps that
|
|
578
|
+
no longer match the pushed picture (`dropped_non_av_streams: true` in the
|
|
579
|
+
result), as `fit.py --method speed` does; a hold of the last frame (`--mode
|
|
580
|
+
extend`, or no `--at`) pushes nothing and keeps it.
|
|
574
581
|
|
|
575
582
|
### pad.py — add black/silent padding at the start/end
|
|
576
583
|
```
|
|
@@ -385,6 +385,25 @@ def stream_starts(meta: Optional[Dict[str, Any]]) -> Tuple[float, float]:
|
|
|
385
385
|
return out[0], out[1]
|
|
386
386
|
|
|
387
387
|
|
|
388
|
+
# The size of the decoded clip above which a tool that holds every frame (reverse, a boomerang)
|
|
389
|
+
# says `memory_heavy`.
|
|
390
|
+
HELD_FRAMES_WARN_BYTES = 2 * 1024 ** 3
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def held_frames_bytes(v: Dict[str, Any], frames: float) -> int:
|
|
394
|
+
"""About how many bytes `frames` decoded frames of the picture `v` (a probe()'s `video`) take:
|
|
395
|
+
the picture's size, its chroma planes (4:2:0 1.5x, 4:2:2 2x, 4:4:4 3x, an alpha plane one
|
|
396
|
+
more) and 2 bytes a sample above 8 bits. The one estimate reverse.py and loop.py --boomerang
|
|
397
|
+
warn from: reverse.py's was the clip's duration alone, so a 30 s 4K60 clip (tens of GB) got
|
|
398
|
+
no warning and a 61 s 320x180 one did (A174)."""
|
|
399
|
+
pix = str(v.get("pix_fmt") or "")
|
|
400
|
+
planes = 3.0 if "444" in pix or pix.startswith(("gbr", "rgb", "bgr")) else 2.0 if "422" in pix else 1.5
|
|
401
|
+
if pix.startswith(("yuva", "gbra", "rgba", "bgra", "argb", "abgr", "ya")):
|
|
402
|
+
planes += 1.0
|
|
403
|
+
depth = 2 if (v.get("bit_depth") or 8) > 8 else 1
|
|
404
|
+
return int((v.get("width") or 0) * (v.get("height") or 0) * planes * depth * max(0.0, frames))
|
|
405
|
+
|
|
406
|
+
|
|
388
407
|
def frame_rate(meta: Optional[Dict[str, Any]]) -> float:
|
|
389
408
|
"""The video's frame rate as cut.py's grid takes it: r_frame_rate, else fps; 0.0 when unknown."""
|
|
390
409
|
v = (meta or {}).get("video") or {}
|
package/scripts/_common/emit.py
CHANGED
|
@@ -167,10 +167,11 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
|
|
|
167
167
|
# source_carry: the items compared, in report order. The first group is content a viewer or
|
|
168
168
|
# listener notices missing and raises the warning; the second is tags, listed but never warned
|
|
169
169
|
# about (every camera file has a creation_time, so a warning for it would fire on every run).
|
|
170
|
-
CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
|
|
170
|
+
CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels", "attachments", "video_streams")
|
|
171
171
|
CARRY_TAGS = ("creation_time", "audio_language", "timecode")
|
|
172
172
|
_CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
|
|
173
|
-
"audio_channels": "audio channels"
|
|
173
|
+
"audio_channels": "audio channels", "attachments": "attached files (a subtitle track's fonts)",
|
|
174
|
+
"video_streams": "video streams"}
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
|
|
@@ -205,7 +206,9 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
|
|
|
205
206
|
continue
|
|
206
207
|
if item == "audio_channels" and src[item] <= 2:
|
|
207
208
|
continue # mono and stereo are what every tool writes; only a surround layout can be folded down
|
|
208
|
-
if item
|
|
209
|
+
if item == "video_streams" and src[item] <= 1:
|
|
210
|
+
continue # one picture is what every tool writes; only a second one can be left out
|
|
211
|
+
if item in ("subtitles", "timecode", "attachments", "video_streams") and src["has_video"] and not out["has_video"]:
|
|
209
212
|
continue
|
|
210
213
|
if item in ("audio_channels", "audio_language") and not out["has_audio"]:
|
|
211
214
|
continue
|
|
@@ -227,8 +230,8 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
|
|
|
227
230
|
if loud:
|
|
228
231
|
parts = []
|
|
229
232
|
for i in loud:
|
|
230
|
-
if i
|
|
231
|
-
parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]}
|
|
233
|
+
if i in ("audio_channels", "video_streams"):
|
|
234
|
+
parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} {_CARRY_NAMES[i]}")
|
|
232
235
|
else:
|
|
233
236
|
parts.append("its " + _CARRY_NAMES[i])
|
|
234
237
|
warn("the source carried " + ", ".join(parts) + " that this output does not have "
|
package/scripts/_common/probe.py
CHANGED
|
@@ -433,6 +433,11 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
433
433
|
"subtitles": len(subs),
|
|
434
434
|
"chapters": len(raw.get("chapters") or []),
|
|
435
435
|
"cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
|
|
436
|
+
# Matroska attachments (a subtitle track's fonts, A256) and the playable pictures beside
|
|
437
|
+
# the one probe() describes, which a tool that writes one picture leaves out (A258)
|
|
438
|
+
"attachments": sum(1 for s in streams if s.get("codec_type") == "attachment"),
|
|
439
|
+
"video_streams": sum(1 for s in streams if s.get("codec_type") == "video"
|
|
440
|
+
and not s.get("disposition", {}).get("attached_pic", 0)),
|
|
436
441
|
"creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
|
|
437
442
|
"audio_language": 1 if lang and lang.lower() != "und" else 0,
|
|
438
443
|
"timecode": 1 if out["timecode"].get("value") else 0,
|
|
@@ -2253,6 +2253,45 @@ def ffmpeg_failure_extra(stderr: str) -> Dict[str, Any]:
|
|
|
2253
2253
|
return {"hint": cls[1], "error_extra": {"reason": cls[0], "details": cls[2]}} if cls else {}
|
|
2254
2254
|
|
|
2255
2255
|
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
MATROSKA_EXT = frozenset({".mkv", ".mka", ".mks"})
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def carried_stream_maps(src: Optional[str], output: str, *, chapters: bool = True, attachments: bool = True) -> List[str]:
|
|
2262
|
+
"""What a command that keeps a source's subtitle/data streams (`-map 0:s? -map 0:d?`, or
|
|
2263
|
+
`-map 0`) adds so the written file carries what the source does, and nothing it does not:
|
|
2264
|
+
|
|
2265
|
+
- `-map -0:N` for an MP4/MOV chapter track. FFmpeg reads the QuickTime chapter text track as a
|
|
2266
|
+
data stream (codec tag `text`) and its muxer writes a new one from the chapters it is given;
|
|
2267
|
+
the copied one was written as a `gpmd` data track holding the old titles, so
|
|
2268
|
+
`metadata.py --clear-chapters` left them behind and every pass added a track (A173).
|
|
2269
|
+
- `-map 0:t? -c:t copy` into Matroska: a subtitle track's fonts are attachments, which no
|
|
2270
|
+
`-map 0:s?`/`0:d?` selects, so every tool that kept the subtitles dropped the fonts they are
|
|
2271
|
+
drawn with (A256). Other containers hold no attachments; source_carry reports them dropped.
|
|
2272
|
+
|
|
2273
|
+
Nothing when the source cannot be read here (the command then runs as before)."""
|
|
2274
|
+
if not src or not os.path.isfile(src):
|
|
2275
|
+
return []
|
|
2276
|
+
try:
|
|
2277
|
+
out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries",
|
|
2278
|
+
"stream=index,codec_type,codec_tag_string:chapter=id", "-of", "json", src],
|
|
2279
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
2280
|
+
errors="replace", timeout=_probe_limit()).stdout
|
|
2281
|
+
doc = json.loads(out or "{}")
|
|
2282
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
2283
|
+
return []
|
|
2284
|
+
streams = doc.get("streams") or []
|
|
2285
|
+
maps: List[str] = []
|
|
2286
|
+
if chapters and doc.get("chapters"):
|
|
2287
|
+
for s in streams:
|
|
2288
|
+
if s.get("codec_type") == "data" and str(s.get("codec_tag_string") or "").lower() == "text":
|
|
2289
|
+
maps += ["-map", f"-0:{s.get('index')}"]
|
|
2290
|
+
if attachments and os.path.splitext(output)[1].lower() in MATROSKA_EXT and any(s.get("codec_type") == "attachment" for s in streams):
|
|
2291
|
+
maps += ["-map", "0:t?", "-c:t", "copy"]
|
|
2292
|
+
return maps
|
|
2293
|
+
|
|
2294
|
+
|
|
2256
2295
|
def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
|
|
2257
2296
|
"""Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
|
|
2258
2297
|
the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
|
|
@@ -2334,7 +2373,9 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
|
|
|
2334
2373
|
so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
|
|
2335
2374
|
the new one; the MOV/MP4 muxer builds the track again from the label."""
|
|
2336
2375
|
skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
|
|
2337
|
-
|
|
2376
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
2377
|
+
first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + carried_stream_maps(src, output)
|
|
2378
|
+
+ ["-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
2338
2379
|
if first.returncode == 0:
|
|
2339
2380
|
return False
|
|
2340
2381
|
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
package/scripts/_contract.py
CHANGED
|
@@ -489,7 +489,7 @@ DIAGNOSTIC_CODES = {
|
|
|
489
489
|
"emoji_not_drawn": ("warnings", "caption.py / graphics.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
|
|
490
490
|
"emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
|
|
491
491
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
492
|
-
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture
|
|
492
|
+
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture, surround channels, attached files or a second video stream the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
493
493
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
494
494
|
"grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
|
|
495
495
|
"scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
@@ -689,7 +689,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
689
689
|
"levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
|
|
690
690
|
"additionalProperties": True}
|
|
691
691
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
692
|
-
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
|
|
692
|
+
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too), shell-quoted; a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate) cannot be run again as printed; a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
|
|
693
693
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
694
694
|
"output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
|
|
695
695
|
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
@@ -698,7 +698,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
698
698
|
"verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
|
|
699
699
|
"verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
|
|
700
700
|
"unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
|
|
701
|
-
"source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
|
|
701
|
+
"source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), attachments (Matroska attached files, such as a subtitle track's fonts), video_streams (a source with more than one playable picture), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound (render.py: a project whose clips are one file, with no music, replacement, effects track or video layer, is compared with that file). A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels/attachments/video_streams also records a source_items_dropped warning"},
|
|
702
702
|
"diagnostics": DIAGNOSTICS_SCHEMA}
|
|
703
703
|
extra: Dict[str, Any] = {}
|
|
704
704
|
if name == "check":
|
package/scripts/background.py
CHANGED
|
@@ -22,6 +22,35 @@ from _common.emit import note
|
|
|
22
22
|
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
|
|
26
|
+
"""The `gradients` source for a `w`x`h` gradient at `angle` degrees (0 = left to right, 90 =
|
|
27
|
+
top to bottom) that runs from the frame's first corner along that direction to its last: c0
|
|
28
|
+
on the corner the direction starts from, c1 on the one opposite.
|
|
29
|
+
|
|
30
|
+
`gradients` takes its two points inside its own frame: a coordinate below -1 is refused and
|
|
31
|
+
one outside 0..w-1 / 0..h-1 is replaced by a seeded random one. The points were (0, 0) and the
|
|
32
|
+
width along the angle, so every angle outside 0-90 failed as kind ffmpeg (135 put x1 at -45:
|
|
33
|
+
A169), and inside it the far point was past the frame (x1 = w at 0, y1 = w at 90) and drawn
|
|
34
|
+
from the seed instead. A line of any direction that crosses the whole frame fits inside a
|
|
35
|
+
square canvas as wide as the frame's diagonal, so the gradient is drawn there, through the
|
|
36
|
+
centre, over the frame's extent along the direction (w|cos| + h|sin|), and the frame is cropped
|
|
37
|
+
from the middle."""
|
|
38
|
+
rad = math.radians(angle)
|
|
39
|
+
c, s = math.cos(rad), math.sin(rad)
|
|
40
|
+
side = 2 * math.ceil((math.hypot(w, h) + 4) / 2) # even, so the centred crop is whole pixels
|
|
41
|
+
half = (w * abs(c) + h * abs(s)) / 2
|
|
42
|
+
mid = side / 2
|
|
43
|
+
x0, y0, x1, y1 = (round(mid - half * c), round(mid - half * s), round(mid + half * c), round(mid + half * s))
|
|
44
|
+
# gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
|
|
45
|
+
# slow rotation applied every frame), so without pinning both, this "static" background
|
|
46
|
+
# was neither reproducible between runs nor actually static across its own duration --
|
|
47
|
+
# violating the bit_exact/deterministic contract _contract.py declares for this tool.
|
|
48
|
+
# speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
|
|
49
|
+
# motionless the filter allows.
|
|
50
|
+
return (f"gradients=size={side}x{side}:rate={fps:g}:c0={c0}:c1={c1}:x0={x0}:y0={y0}:x1={x1}:y1={y1}"
|
|
51
|
+
f":seed=0:speed=1e-05,crop={w}:{h}:{(side - w) // 2}:{(side - h) // 2}")
|
|
52
|
+
|
|
53
|
+
|
|
25
54
|
def main() -> int:
|
|
26
55
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
27
56
|
ap.add_argument("-o", "--output", required=True, help="output file")
|
|
@@ -66,16 +95,7 @@ def main() -> int:
|
|
|
66
95
|
die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
|
|
67
96
|
validate_color(c0, "--gradient")
|
|
68
97
|
validate_color(c1, "--gradient")
|
|
69
|
-
|
|
70
|
-
x1 = round(args.width * math.cos(rad))
|
|
71
|
-
y1 = round(args.width * math.sin(rad))
|
|
72
|
-
# gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
|
|
73
|
-
# slow rotation applied every frame), so without pinning both, this "static" background
|
|
74
|
-
# was neither reproducible between runs nor actually static across its own duration --
|
|
75
|
-
# violating the bit_exact/deterministic contract _contract.py declares for this tool.
|
|
76
|
-
# speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
|
|
77
|
-
# motionless the filter allows.
|
|
78
|
-
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
|
|
98
|
+
src_filter = gradient_source(args.width, args.height, args.fps, c0, c1, args.angle)
|
|
79
99
|
colours = [c0, c1]
|
|
80
100
|
else:
|
|
81
101
|
validate_color(args.color, "--color")
|
package/scripts/broll.py
CHANGED
|
@@ -105,9 +105,15 @@ def main() -> int:
|
|
|
105
105
|
dur_b = input_length(meta_b)
|
|
106
106
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
107
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")
|
|
108
|
-
if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
|
|
109
|
-
die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
|
|
110
108
|
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
|
|
109
|
+
# overlap is a property of the windows in time, not of the order they were given in: compared
|
|
110
|
+
# against the one given before it, a later window listed first was called overlapping (A179)
|
|
111
|
+
order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
|
|
112
|
+
for prev, k in zip(order, order[1:]):
|
|
113
|
+
end_prev = cutaways[prev]["at"] + cutaways[prev]["length"]
|
|
114
|
+
if cutaways[k]["at"] < end_prev:
|
|
115
|
+
die(f"cutaway {k + 1} at {cutaways[k]['at']:g}s overlaps cutaway {prev + 1} "
|
|
116
|
+
f"({cutaways[prev]['at']:g}s to {end_prev:g}s)")
|
|
111
117
|
if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
|
|
112
118
|
die("--audio b/mix needs audio on every B-roll clip")
|
|
113
119
|
|
package/scripts/caption.py
CHANGED
|
@@ -56,7 +56,7 @@ from _common import asr as _asr
|
|
|
56
56
|
from _common.asr import ENGINE_CHOICES
|
|
57
57
|
from _common.emit import warn
|
|
58
58
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
59
|
-
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
59
|
+
from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
|
|
60
60
|
from _common.fonts import font_choice, record_font_choice
|
|
61
61
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
62
62
|
write_srt)
|
|
@@ -1787,7 +1787,7 @@ def main() -> int:
|
|
|
1787
1787
|
if meta.get("audio"):
|
|
1788
1788
|
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
1789
1789
|
if existing_subs:
|
|
1790
|
-
maps += ["-map", "0:s?"]
|
|
1790
|
+
maps += ["-map", "0:s?"] + carried_stream_maps(args.input, output, chapters=False) # their fonts (A256)
|
|
1791
1791
|
for n in range(len(added)):
|
|
1792
1792
|
maps += ["-map", f"{n + 1}:0"]
|
|
1793
1793
|
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
package/scripts/color.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
from typing import List, Optional
|
|
34
34
|
|
|
35
|
-
from _common.runner import fail_if_classified
|
|
35
|
+
from _common.runner import carried_stream_maps, fail_if_classified
|
|
36
36
|
from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.color import is_rgb, rgb_to_bt709_filter
|
|
@@ -329,7 +329,8 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
329
329
|
ext = os.path.splitext(output)[1].lower()
|
|
330
330
|
tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
|
|
331
331
|
bsf = retag_bsf(meta, args.retag)
|
|
332
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
332
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
333
|
+
+ ["-c", "copy"] + bsf + tags)
|
|
333
334
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
334
335
|
cmd += ["-movflags", "+faststart"]
|
|
335
336
|
cmd.append(output)
|
|
@@ -357,8 +358,9 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
357
358
|
info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
|
|
358
359
|
label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
|
|
359
360
|
enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
|
|
360
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
361
|
-
"-map", "0:s?", "-map", "0:d?"] +
|
|
361
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
362
|
+
"-map", "0:s?", "-map", "0:d?"] + carried_stream_maps(args.input, output) + label + enc
|
|
363
|
+
+ ["-c:s", "copy", "-c:d", "copy", output])
|
|
362
364
|
proc2 = run(cmd, check=False)
|
|
363
365
|
fail_if_classified(proc2)
|
|
364
366
|
if proc2.returncode != 0:
|
|
@@ -552,7 +554,8 @@ def main() -> int:
|
|
|
552
554
|
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
553
555
|
if not v.get("dolby_vision"):
|
|
554
556
|
warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
|
|
555
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
557
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
558
|
+
+ ["-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"])
|
|
556
559
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
557
560
|
cmd += ["-movflags", "+faststart"]
|
|
558
561
|
cmd.append(output)
|
package/scripts/crop.py
CHANGED
|
@@ -62,7 +62,9 @@ def main() -> int:
|
|
|
62
62
|
has_audio = bool(meta.get("audio"))
|
|
63
63
|
|
|
64
64
|
output = args.output or default_output(args.input, "crop")
|
|
65
|
-
|
|
65
|
+
# exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
|
|
66
|
+
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
|
|
67
|
+
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
|
|
66
68
|
if square_pixels(meta["video"]):
|
|
67
69
|
vf.insert(0, square_pixels(meta["video"]))
|
|
68
70
|
note(square_pixels_note(meta["video"]))
|
package/scripts/export.py
CHANGED
|
@@ -46,7 +46,7 @@ import tempfile
|
|
|
46
46
|
from pathlib import Path
|
|
47
47
|
from typing import Dict, List, Optional
|
|
48
48
|
|
|
49
|
-
from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
49
|
+
from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
50
50
|
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
@@ -321,15 +321,19 @@ _subtitle_codec = subtitle_codec_for
|
|
|
321
321
|
|
|
322
322
|
def _data_streams(path: str) -> List[Dict[str, object]]:
|
|
323
323
|
"""The data streams of a file ({index, codec}), without its attachments (probe()'s
|
|
324
|
-
data_streams counts both, and -map 0:d maps only the data streams)
|
|
324
|
+
data_streams counts both, and -map 0:d maps only the data streams) and without an MP4/MOV
|
|
325
|
+
chapter track, which the muxer writes again from the chapters (carried_stream_maps, A173)."""
|
|
325
326
|
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
|
|
326
|
-
"stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
|
|
327
|
+
"stream=codec_name,codec_tag_string:chapter=id", "-of", "json", path], quiet=True)
|
|
327
328
|
try:
|
|
328
|
-
|
|
329
|
+
doc = json.loads(proc.stdout or "{}")
|
|
329
330
|
except ValueError:
|
|
330
331
|
# an unread list would map nothing and report nothing dropped
|
|
331
332
|
die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
|
|
332
|
-
|
|
333
|
+
streams = doc.get("streams") or []
|
|
334
|
+
chapter_track = bool(doc.get("chapters"))
|
|
335
|
+
return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)
|
|
336
|
+
if not (chapter_track and str(s.get("codec_tag_string") or "").lower() == "text")]
|
|
333
337
|
|
|
334
338
|
|
|
335
339
|
def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
|
|
@@ -338,10 +342,13 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
|
|
|
338
342
|
subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
|
|
339
343
|
with the attempt's error. `cmd` already maps the picture and the sound and has no output.
|
|
340
344
|
Returns the stream types dropped ([] when everything mapped was written)."""
|
|
345
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
341
346
|
sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
|
|
342
347
|
for n, codec in enumerate(sub_codecs):
|
|
343
348
|
sub_args += [f"-c:s:{n}", codec]
|
|
344
|
-
|
|
349
|
+
if sub_codecs:
|
|
350
|
+
sub_args += carried_stream_maps(src, output, chapters=False) # the fonts the subtitles are drawn with (A256)
|
|
351
|
+
data_args = (["-map", "0:d"] + carried_stream_maps(src, output, attachments=False) + ["-c:d", "copy"]) if data else []
|
|
345
352
|
rungs = [(sub_args + data_args, [])]
|
|
346
353
|
if sub_codecs and data:
|
|
347
354
|
rungs.append((sub_args, ["data"]))
|
|
@@ -389,7 +396,9 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
|
|
|
389
396
|
written_data = _data_streams(output)
|
|
390
397
|
for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
|
|
391
398
|
for n, s in enumerate(streams):
|
|
392
|
-
row
|
|
399
|
+
# a data row keeps its n-th-of-type index in the source: the chapter text track is left
|
|
400
|
+
# out of the list, so a timecode track after it is data stream 1, not 0
|
|
401
|
+
row = {"type": kind, "index": s.get("index", n) if kind == "data" else n, "codec": s.get("codec")}
|
|
393
402
|
if kind == "subtitle":
|
|
394
403
|
row["language"] = s.get("language")
|
|
395
404
|
if STATE.dry_run and kind not in dropped:
|
package/scripts/freeze.py
CHANGED
|
@@ -67,7 +67,8 @@ def main() -> int:
|
|
|
67
67
|
# no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
|
|
68
68
|
# and the hold lost, so hold the last frame as --mode extend does (#416 review)
|
|
69
69
|
vend = v0 + (meta["video"].get("duration") or dur - v0)
|
|
70
|
-
|
|
70
|
+
holds_end = args.mode == "extend" or cut_at >= vend - 0.5 / rate
|
|
71
|
+
if holds_end:
|
|
71
72
|
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
72
73
|
# needed since it only ever appends past the real end.
|
|
73
74
|
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
@@ -127,10 +128,12 @@ def main() -> int:
|
|
|
127
128
|
cmd += aac_args()
|
|
128
129
|
else:
|
|
129
130
|
cmd += ["-an"]
|
|
130
|
-
if
|
|
131
|
+
if not holds_end:
|
|
131
132
|
# the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
|
|
132
133
|
# old timestamps and every cue after the freeze would fire early (same reasoning as
|
|
133
|
-
# fit.py --method speed). Drop it rather than ship captions that lie.
|
|
134
|
+
# fit.py --method speed). Drop it rather than ship captions that lie. A hold of the last
|
|
135
|
+
# frame (the default with no --at, or an --at at the end) moves nothing, so the track is
|
|
136
|
+
# kept as --mode extend keeps it (A180)
|
|
134
137
|
run(cmd + [output])
|
|
135
138
|
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
136
139
|
else:
|
package/scripts/graphics.py
CHANGED
|
@@ -564,7 +564,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
564
564
|
rise = int(fs * 0.5)
|
|
565
565
|
pop = f"min(1,(t-{s:.3f})/0.25)"
|
|
566
566
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
567
|
-
|
|
567
|
+
# every position rises into place, on both routes: a bottom chip started `rise` px above
|
|
568
|
+
# its rest on drawtext and dropped, where the ASS \move below rises (A177)
|
|
569
|
+
ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}+{rise}*(1-{pop})")
|
|
568
570
|
alpha = f"min({pop},{fade_a})"
|
|
569
571
|
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
570
572
|
y_rest = m_top if "top" in pos else H - m_bottom
|
package/scripts/grid.py
CHANGED
|
@@ -26,7 +26,7 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, drawtext_text_opts, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
@@ -173,7 +173,9 @@ def main() -> int:
|
|
|
173
173
|
if args.label == "auto":
|
|
174
174
|
font_file = label_fonts[i]["file"]
|
|
175
175
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
176
|
-
drawn
|
|
176
|
+
# the textfile= route every other drawn text takes: escape_drawtext() is for a font name,
|
|
177
|
+
# and dropped an apostrophe or a percent sign from a file name's label (A178)
|
|
178
|
+
drawn.append((f"drawtext={drawtext_text_opts(labels[i])}:{font_opt}:fontsize={args.font_size}:"
|
|
177
179
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
178
180
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
179
181
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
package/scripts/loop.py
CHANGED
|
@@ -34,7 +34,7 @@ import tempfile
|
|
|
34
34
|
|
|
35
35
|
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
|
-
from _common.decision import av_trim, length_output_t
|
|
37
|
+
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
39
|
from _common.emit import input_length
|
|
40
40
|
|
|
@@ -60,9 +60,8 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
|
|
|
60
60
|
f"-- cut the clip shorter first (cut.py)")
|
|
61
61
|
# reverse holds the clip's decoded frames; split, trim and loop pass references to the same
|
|
62
62
|
# frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
|
|
63
|
-
|
|
64
|
-
held
|
|
65
|
-
if held > 2 * 1024 ** 3:
|
|
63
|
+
held = held_frames_bytes(v, frames)
|
|
64
|
+
if held > HELD_FRAMES_WARN_BYTES:
|
|
66
65
|
warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
67
66
|
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
|
|
68
67
|
"memory_heavy", bytes=int(held))
|
|
@@ -160,7 +159,12 @@ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_f
|
|
|
160
159
|
if has_audio:
|
|
161
160
|
# acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
|
|
162
161
|
# 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
|
|
163
|
-
|
|
162
|
+
# the sound is shifted by the picture's start, the constant setpts=PTS-STARTPTS takes off the
|
|
163
|
+
# picture: rebased to its own start (aresample's first_pts=0 alone), a picture that starts
|
|
164
|
+
# 0.2 s after its sound came out 200 ms behind it in every repeat (A168)
|
|
165
|
+
v0 = stream_starts(meta)[0]
|
|
166
|
+
graph += (f";[0:a:0]atrim=start={v0:.6f},asetpts=PTS-{v0:.6f}/TB,aresample=async=1:first_pts=0,"
|
|
167
|
+
f"apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
|
|
164
168
|
f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
|
|
165
169
|
f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
|
|
166
170
|
f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
|
package/scripts/metadata.py
CHANGED
|
@@ -35,6 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
|
|
|
35
35
|
die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
|
|
36
36
|
STATE, read_text_or_die)
|
|
37
37
|
from _common.emit import input_length
|
|
38
|
+
from _common.runner import carried_stream_maps
|
|
38
39
|
|
|
39
40
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
40
41
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -267,6 +268,9 @@ def main() -> int:
|
|
|
267
268
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
|
|
268
269
|
else:
|
|
269
270
|
cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
|
|
271
|
+
# not the source's MP4/MOV chapter track: the muxer writes one from the chapters it is given,
|
|
272
|
+
# and the copy was a stray data track holding the old titles (A173)
|
|
273
|
+
cmd += carried_stream_maps(args.input, output, attachments=False)
|
|
270
274
|
for key, value in tags.items():
|
|
271
275
|
cmd += ["-metadata", f"{key}={value}"]
|
|
272
276
|
cmd += ["-c", "copy"]
|
package/scripts/redact.py
CHANGED
|
@@ -101,7 +101,14 @@ def main() -> int:
|
|
|
101
101
|
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
102
102
|
"-map", "0:v:0"]
|
|
103
103
|
else:
|
|
104
|
-
|
|
104
|
+
# exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
|
|
105
|
+
# overlay always does, so an odd rectangle was taken from and put back one pixel up and
|
|
106
|
+
# left (its last column and row left as they were, one outside it changed: A170). The
|
|
107
|
+
# patch is taken exactly, given a transparent first column/row where x/y is odd, and put
|
|
108
|
+
# at the even position before it: the luma inside the rectangle is replaced, the luma
|
|
109
|
+
# outside is not (the chroma pair on an odd edge is shared, so it is half mixed)
|
|
110
|
+
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1"
|
|
111
|
+
ox, oy = args.x % 2, args.y % 2
|
|
105
112
|
if args.mode == "blur":
|
|
106
113
|
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
107
114
|
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
@@ -110,7 +117,12 @@ def main() -> int:
|
|
|
110
117
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
111
118
|
else:
|
|
112
119
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
113
|
-
|
|
120
|
+
if ox or oy:
|
|
121
|
+
deep = overlay_format(meta)
|
|
122
|
+
region += (f",format=yuva444p{'10le' if deep else ''},"
|
|
123
|
+
f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
|
|
124
|
+
fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
|
|
125
|
+
f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
|
|
114
126
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
115
127
|
if has_audio:
|
|
116
128
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -74,7 +74,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
74
74
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
|
-
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
77
|
+
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
@@ -691,6 +691,59 @@ def check_snap_to(snap: Any, label: str) -> None:
|
|
|
691
691
|
|
|
692
692
|
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
693
693
|
|
|
694
|
+
# source_carry for a render (A255): the one picture/sound source of a project with one, and what
|
|
695
|
+
# each stage that ran said about the start timecode ("kept", "dropped", None: a stage that compared
|
|
696
|
+
# nothing, "unknown": a cache hit whose entry did not keep it). main() resets both.
|
|
697
|
+
PROJECT_SOURCE: Dict[str, Any] = {"src": None, "timecode": []}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _timecode_verdict(doc: Any) -> Optional[str]:
|
|
701
|
+
"""What a stage's document says about the start timecode: "kept", "dropped", or None when its
|
|
702
|
+
source_carry compared none."""
|
|
703
|
+
carry = doc.get("source_carry") if isinstance(doc, dict) else None
|
|
704
|
+
if not isinstance(carry, dict):
|
|
705
|
+
return None
|
|
706
|
+
return "dropped" if "timecode" in (carry.get("dropped") or []) else "kept" if "timecode" in (carry.get("kept") or []) else None
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def project_source(proj: Dict[str, Any], rel) -> Optional[str]:
|
|
710
|
+
"""The project's single picture/sound source -- its clips all one file, and no music,
|
|
711
|
+
replacement or effects track, no video layer and no audiogram picture beside it -- or None,
|
|
712
|
+
when the output mixes several (source_carry is then not compared, as for any tool with two
|
|
713
|
+
sources)."""
|
|
714
|
+
srcs = {os.path.realpath(rel(c["src"])) for c in proj.get("clips") or [] if isinstance(c, dict) and isinstance(c.get("src"), str)}
|
|
715
|
+
audio = proj.get("audio") if isinstance(proj.get("audio"), dict) else {}
|
|
716
|
+
others = [audio.get(k) for k in ("music", "replace", "effects") if audio.get(k)]
|
|
717
|
+
overlays = proj.get("overlays") if isinstance(proj.get("overlays"), list) else []
|
|
718
|
+
others += [ov.get("video") for ov in overlays if isinstance(ov, dict) and ov.get("video")]
|
|
719
|
+
if proj.get("audiogram"):
|
|
720
|
+
others.append("audiogram") # the picture is the waveform over an image, not the clip's
|
|
721
|
+
return next(iter(srcs)) if len(srcs) == 1 and not others else None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def emit(output: Optional[str], **extra: Any) -> None:
|
|
725
|
+
"""emit(), with render.py's source_carry: the stages run as children, so this process never
|
|
726
|
+
probed the project's source as an input and its result carried no source_carry at all, even
|
|
727
|
+
for a one-clip render that lost the source's subtitle track (A255). The source is probed here
|
|
728
|
+
(after the stages' intermediates, which are not sources), so emit() compares it with the
|
|
729
|
+
delivered file. A stage that moves the picture's start writes the label of the frame it starts
|
|
730
|
+
on, which is not the source's: the timecode counts as kept when every stage that compared it
|
|
731
|
+
kept it, as dropped when one dropped it, and is compared with the source's own label when no
|
|
732
|
+
stage said (nothing ran, or a cache entry written before stages' source_carry was kept)."""
|
|
733
|
+
src = PROJECT_SOURCE.get("src")
|
|
734
|
+
if src and output and not STATE.dry_run and os.path.isfile(src) and os.path.isfile(output) \
|
|
735
|
+
and os.path.realpath(src) != os.path.realpath(output) and "source_carry" not in extra:
|
|
736
|
+
STATE.first_input, STATE.video_inputs, STATE.audio_inputs = None, set(), set()
|
|
737
|
+
probe(src)
|
|
738
|
+
said = PROJECT_SOURCE["timecode"]
|
|
739
|
+
if "dropped" in said:
|
|
740
|
+
STATE.expected_timecode = ""
|
|
741
|
+
elif "kept" in said and "unknown" not in said:
|
|
742
|
+
STATE.expected_timecode = (probe(output, role="output").get("timecode") or {}).get("value") or ""
|
|
743
|
+
else:
|
|
744
|
+
STATE.expected_timecode = None
|
|
745
|
+
_emit(output, **extra)
|
|
746
|
+
|
|
694
747
|
|
|
695
748
|
def _refuse_uncached_earlier_stage(stage: str) -> None:
|
|
696
749
|
"""--from STAGE promises the earlier stages come from the cache. When one does not, say so
|
|
@@ -783,6 +836,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
783
836
|
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
|
|
784
837
|
_LAST_DOC.clear()
|
|
785
838
|
_LAST_DOC.update(doc if isinstance(doc, dict) else {})
|
|
839
|
+
PROJECT_SOURCE["timecode"].append(_timecode_verdict(doc))
|
|
786
840
|
absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
|
|
787
841
|
out_path = str(doc.get("output") or "")
|
|
788
842
|
if not STATE.dry_run:
|
|
@@ -981,6 +1035,10 @@ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) ->
|
|
|
981
1035
|
shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
|
|
982
1036
|
CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
|
|
983
1037
|
result = meta.get("result")
|
|
1038
|
+
# what the stage said about the start timecode, for render's source_carry; an entry written
|
|
1039
|
+
# before its source_carry was kept does not say
|
|
1040
|
+
PROJECT_SOURCE["timecode"].append(_timecode_verdict(result) if isinstance(result, dict) and "source_carry" in result
|
|
1041
|
+
else "unknown")
|
|
984
1042
|
if isinstance(result, dict):
|
|
985
1043
|
absorb_stage(tool or stage, dict(result, status="completed", dry_run=False)) # tagged as the stage's tool, as a live run is
|
|
986
1044
|
else:
|
|
@@ -994,7 +1052,9 @@ def _stage_result(doc: Any) -> Dict[str, Any]:
|
|
|
994
1052
|
"""What a cache entry keeps of the stage's own --json document, replayed on a hit."""
|
|
995
1053
|
if not isinstance(doc, dict):
|
|
996
1054
|
return {}
|
|
997
|
-
|
|
1055
|
+
kept = {k: doc[k] for k in ("verified", "verification", "notes", "diagnostics") if k in doc}
|
|
1056
|
+
kept["source_carry"] = doc.get("source_carry") # None too: the stage compared nothing (render's source_carry)
|
|
1057
|
+
return kept
|
|
998
1058
|
|
|
999
1059
|
|
|
1000
1060
|
def cache_store(stage: str, key: str, produced: str, seconds: float, doc: Any = None) -> None:
|
|
@@ -1472,6 +1532,7 @@ def main() -> int:
|
|
|
1472
1532
|
add_hw_orchestrator_args(ap)
|
|
1473
1533
|
args = ap.parse_args()
|
|
1474
1534
|
apply_common(args)
|
|
1535
|
+
PROJECT_SOURCE.update(src=None, timecode=[])
|
|
1475
1536
|
STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
|
|
1476
1537
|
|
|
1477
1538
|
if args.list_templates:
|
|
@@ -1650,6 +1711,7 @@ def main() -> int:
|
|
|
1650
1711
|
CACHE.clear()
|
|
1651
1712
|
CACHE.update(_fresh_cache())
|
|
1652
1713
|
CONTENT_KEYED.clear()
|
|
1714
|
+
PROJECT_SOURCE.update(src=project_source(proj, rel), timecode=[])
|
|
1653
1715
|
if args.cache:
|
|
1654
1716
|
cdir = Path(args.cache)
|
|
1655
1717
|
try:
|
package/scripts/reverse.py
CHANGED
|
@@ -17,7 +17,8 @@ import sys
|
|
|
17
17
|
|
|
18
18
|
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
|
|
19
19
|
from _common.emit import warn
|
|
20
|
-
from _common.decision import av_clip_length, av_trim, frame_durations, frame_rate,
|
|
20
|
+
from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
|
|
21
|
+
held_frames_bytes, stream_starts)
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def main() -> int:
|
|
@@ -37,10 +38,16 @@ def main() -> int:
|
|
|
37
38
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
38
39
|
|
|
39
40
|
output = args.output or default_output(args.input, "reverse")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
# keyed on what is held (every decoded frame), not on the length alone (A174): the estimate
|
|
42
|
+
# loop.py --boomerang warns from, over the frames of the picture's length at its rate
|
|
43
|
+
v = meta["video"]
|
|
44
|
+
vdur = v.get("duration") or meta.get("duration") or 0.0
|
|
45
|
+
frames = v.get("nb_frames") or vdur * (frame_rate(meta) or v.get("fps") or 30.0)
|
|
46
|
+
held = held_frames_bytes(v, frames)
|
|
47
|
+
if held > HELD_FRAMES_WARN_BYTES:
|
|
48
|
+
warn(f"reverse holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
49
|
+
f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
|
|
50
|
+
"memory_heavy", duration=meta.get("duration"), bytes=held)
|
|
44
51
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
45
52
|
if has_audio:
|
|
46
53
|
# Both streams are reversed about one end (#409); each reversed about its own end put a sound
|
|
@@ -55,14 +62,20 @@ def main() -> int:
|
|
|
55
62
|
# setpts: from FFmpeg 7.0 setpts leaves its output's frame rate unknown and its frames'
|
|
56
63
|
# durations 0, so a tpad after it clones nothing (the picture stayed 0.6 s short on 7.1);
|
|
57
64
|
# the closing fps gives the frames their durations back (frame_durations).
|
|
58
|
-
rate
|
|
65
|
+
# the rate the encoder line delivers (cfr_args: a VFR source is conformed to its average
|
|
66
|
+
# rate), so the graph's closing fps and the output's -r are one grid: the graph closed on
|
|
67
|
+
# r_frame_rate and -r then resampled it, 51 frames for 49 that ran 81 ms past the sound (A234)
|
|
68
|
+
# The trim that drops the hold's spare clones stays on the source's own grid (half a frame
|
|
69
|
+
# of r_frame_rate before the end): half a delivered frame cut a real last frame that sits
|
|
70
|
+
# nearer the end than that, and reverse then opened on the one before it.
|
|
71
|
+
rate = delivered_rate(meta) or frame_rate(meta) or meta["video"].get("fps") or 30.0
|
|
59
72
|
length = av_clip_length(meta, rate)
|
|
60
73
|
vlen = meta["video"].get("duration") or length
|
|
61
74
|
end = math.ceil(length * rate - 1e-6) / rate
|
|
62
75
|
vtrim, atrim = av_trim(stream_starts(meta)[0], None)
|
|
63
76
|
cmd += ["-filter_complex",
|
|
64
77
|
f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
|
|
65
|
-
f"trim=end={end - 0.5 / rate:.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
78
|
+
f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
66
79
|
f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
|
|
67
80
|
"-map", "[v]", "-map", "[a]"]
|
|
68
81
|
else:
|
package/scripts/straighten.py
CHANGED
|
@@ -80,9 +80,20 @@ def main() -> int:
|
|
|
80
80
|
dw, dh = display_size(meta["video"])
|
|
81
81
|
aspect = max(dw / dh, dh / dw) if dw and dh else 1.0
|
|
82
82
|
s = abs(math.cos(radians)) + aspect * abs(math.sin(radians))
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
83
|
+
# The window is the displayed frame's own size, to even (4:2:0 chroma), and the scaled frame
|
|
84
|
+
# is rounded UP to even so it still covers it. Scaling to iw*s and cropping back to
|
|
85
|
+
# trunc(iw/s/2)*2 lost to two roundings: 1920x1080 came out 1918x1078, and the scale's
|
|
86
|
+
# rounding left a non-square SAR such as 6112:6111 (A235).
|
|
87
|
+
cw, ch = (dw // 2 * 2, dh // 2 * 2) if dw and dh else (0, 0)
|
|
88
|
+
if cw and ch:
|
|
89
|
+
# the margin on each axis is a multiple of 4, so the centred crop's offset is even:
|
|
90
|
+
# crop floors an odd one to the chroma grid, which moved the window a pixel up and
|
|
91
|
+
# left and could bring the fill into the top-left corner
|
|
92
|
+
sw, sh = (c + math.ceil(max(0.0, c * s - c) / 4) * 4 for c in (cw, ch))
|
|
93
|
+
rotate = (f"scale={sw}:{sh},setsar=1,"
|
|
94
|
+
f"rotate={radians:.8f},crop={cw}:{ch},setsar=1")
|
|
95
|
+
else: # a dry run's pending input has no size yet: the old expression, planned only
|
|
96
|
+
rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
|
|
86
97
|
|
|
87
98
|
# a rotation of non-square pixels is a shear on screen: the picture is squared at its
|
|
88
99
|
# display aspect first (#411)
|