ffmpeg-skill 2.2.1 → 2.2.3
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/docs/contract.md +8 -2
- package/package.json +1 -1
- package/references/scripts.md +26 -9
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/decision.py +57 -1
- package/scripts/_common/probe.py +7 -1
- package/scripts/_common/timeline.py +155 -28
- package/scripts/_contract.py +10 -2
- package/scripts/color.py +39 -4
- package/scripts/fit.py +7 -39
- package/scripts/join.py +143 -28
- package/scripts/render.py +94 -14
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 (`2.2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.2.3`) | 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`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.2.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.2.3", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -492,6 +492,12 @@ Per-tool keys added in 1.18.0, all additive:
|
|
|
492
492
|
| `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
|
|
493
493
|
| `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
|
|
494
494
|
|
|
495
|
+
Per-tool keys added after 2.2.2, all additive:
|
|
496
|
+
|
|
497
|
+
| key | tool | what it holds |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
|
|
500
|
+
|
|
495
501
|
`check.py` also gains an informational `subtitles` row on **every** platform:
|
|
496
502
|
`PASS` when every soft subtitle stream carries a language tag, `WARN` when one
|
|
497
503
|
does not (or when there are none). Like `channels` and `chapters` it is never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
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
|
@@ -563,11 +563,21 @@ before anything runs: missing, empty (0 bytes) or unreadable as media. By defaul
|
|
|
563
563
|
are named in one refusal (`kind: input`, `problems: [{index, path, reason}]`, nothing encoded);
|
|
564
564
|
`--on-missing skip` joins the usable ones and reports the rest under `skipped` (always present,
|
|
565
565
|
`[]` when nothing was). A list with no entries is refused as such, and a skip that leaves fewer
|
|
566
|
-
than two inputs is refused rather than "joining" one file.
|
|
566
|
+
than two inputs is refused rather than "joining" one file. Under `--dry-run` an input that does
|
|
567
|
+
not exist yet is an earlier step's output, with either `--on-missing`: it stays in the plan and
|
|
568
|
+
is named under `pending: [{index, path}]` and in `notes` (never `skipped`). Its extension stands
|
|
569
|
+
in for its streams (any audio extension the skill reads, `.aiff` and `.caf` included: no
|
|
570
|
+
picture), so a pending `.mp4` next to measured audio is refused as the mix a real run would
|
|
571
|
+
refuse; `expected_duration` is null while any input is pending, and `notes` names the planned
|
|
572
|
+
frame, rate or xfade offsets that rest on a pending input's unmeasured placeholder.
|
|
567
573
|
Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
|
|
568
574
|
channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
569
575
|
silent track generated for clips without audio), then chains `xfade` +
|
|
570
|
-
`acrossfade`.
|
|
576
|
+
`acrossfade`. With a transition each clip has one length for picture and sound
|
|
577
|
+
-- the video stream's, or the audio's when it runs more than a frame longer --
|
|
578
|
+
and the shorter stream is padded (silence / last frame held), so every clip's
|
|
579
|
+
sound stays with its picture. Output length = sum of clip lengths −
|
|
580
|
+
transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
|
|
571
581
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
572
582
|
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
573
583
|
output; the result says so with `dropped_non_av_streams: true`.
|
|
@@ -586,13 +596,20 @@ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, othe
|
|
|
586
596
|
OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
|
|
587
597
|
`LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
|
|
588
598
|
timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
|
|
589
|
-
track; inline `chapters` are markers.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
599
|
+
track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
|
|
600
|
+
`hh:mm:ss:ff` at the source's fps), and the sequence is the project's `frame` sized by
|
|
601
|
+
fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
|
|
602
|
+
preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
|
|
603
|
+
refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
|
|
604
|
+
aspect gets its spatial conform in FCPXML (`adjust-conform`: `fill` for `frame.fit: crop`,
|
|
605
|
+
`fit` for pad); EDL and OTIO cannot state one. Everything else in the project -- captions,
|
|
606
|
+
graphics, overlays, the silence cut, fit, a reframe the format cannot state (and `blur`'s
|
|
607
|
+
background), audio processing, loudness, export -- is listed in the result's
|
|
608
|
+
`timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file URLs to the sources, nothing is
|
|
609
|
+
copied or encoded, and the existing-output rule applies (`--overwrite` to replace). The
|
|
610
|
+
timeline follows the project's numbers exactly; a render of the same project can come out a
|
|
611
|
+
few frames longer, because `join.py` offsets its crossfades by each part's container duration,
|
|
612
|
+
audio included.
|
|
596
613
|
A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
|
|
597
614
|
--plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
|
|
598
615
|
head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
|
|
@@ -69,10 +69,10 @@ from _common.emit import (
|
|
|
69
69
|
from _common.probe import (
|
|
70
70
|
analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
|
|
71
71
|
_fraction, keyframes_near, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
72
|
-
measured_level_dbfs, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
|
|
72
|
+
measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
|
|
73
73
|
)
|
|
74
74
|
from _common.decision import (
|
|
75
|
-
aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
75
|
+
aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
76
76
|
description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
|
|
77
77
|
frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
|
|
78
78
|
filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
|
|
@@ -82,7 +82,7 @@ from _common.decision import (
|
|
|
82
82
|
BEAT_Z_FLOOR, BEAT_Z_SPAN,
|
|
83
83
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
84
84
|
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
85
|
-
fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
85
|
+
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
86
86
|
signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
87
87
|
)
|
|
88
88
|
from _common.asr import (
|
|
@@ -201,7 +201,7 @@ __all__ = [
|
|
|
201
201
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
202
202
|
"is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
|
|
203
203
|
"keyframes_near", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
204
|
-
"load_brand", "measured_level_dbfs", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
|
|
204
|
+
"load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
|
|
205
205
|
"_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
|
|
206
206
|
"place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
|
|
207
207
|
"print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
|
|
@@ -9,9 +9,11 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import math
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
import argparse
|
|
14
|
+
from fractions import Fraction
|
|
13
15
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict, List, Optional, Sequence
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
17
|
from _common.color import bt709_tag_args, _sdr_bt709
|
|
16
18
|
from _common.emit import die
|
|
17
19
|
from _common.runner import CODECS, STATE, ffmpeg_encoders
|
|
@@ -47,6 +49,60 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
|
|
|
47
49
|
parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
def aspect_ratio(value: Any) -> Optional[Fraction]:
|
|
53
|
+
"""An aspect written W:H as an exact ratio, or None. fit.py --aspect and --export-timeline's
|
|
54
|
+
sequence frame read an aspect through this one function, so the export refuses `16/9` and
|
|
55
|
+
`2.39:1` where the render hands them to fit.py, which refuses them too (the timeline once
|
|
56
|
+
took them and wrote a sequence for a project whose render then failed in fit.py). Each side
|
|
57
|
+
is read with int(), as fit.py read it in 2.2.1, so a patch release refuses nothing fit.py
|
|
58
|
+
took: `+16:9` and `1_6:9` still parse, and `0:9` is a zero ratio, which frame_size() treats
|
|
59
|
+
as no aspect (2.2.1 kept the source frame). render.py's frame_from_preset() keeps 2.2.1's
|
|
60
|
+
looser match: it only picks the export preset's size."""
|
|
61
|
+
parts = str(value).split(":")
|
|
62
|
+
if len(parts) != 2:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return Fraction(int(parts[0]), int(parts[1]))
|
|
66
|
+
except (ValueError, ZeroDivisionError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _even(n: float) -> int:
|
|
71
|
+
v = int(round(n))
|
|
72
|
+
return v if v % 2 == 0 else v + 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def frame_size(ratio: Optional[Fraction], width: Optional[int], height: Optional[int],
|
|
76
|
+
src_w: int, src_h: int) -> Tuple[int, int]:
|
|
77
|
+
"""The frame fit.py sizes for --aspect `ratio` (None: the source's own) and --width/--height,
|
|
78
|
+
from a src_w x src_h picture -- and so the sequence frame render.py --export-timeline writes
|
|
79
|
+
for the same project frame. Both sides given are used directly; one side takes the other from
|
|
80
|
+
the aspect. With neither, the canvas takes the new aspect without exceeding the source's
|
|
81
|
+
resolution in either dimension: a narrower/taller target than the source (e.g. 9:16 from a
|
|
82
|
+
16:9 source) must be bounded by the source's HEIGHT, not its width -- bounding by width there
|
|
83
|
+
multiplies the height by src_ratio/ratio (a 1920x1080 source asked for 9:16 used to come out
|
|
84
|
+
1920x3414, a ~3.16x upscale in both fit=pad and fit=crop, entirely unrequested). Every side is
|
|
85
|
+
rounded to even."""
|
|
86
|
+
src_ratio = Fraction(src_w, src_h) if src_h else None
|
|
87
|
+
if ratio is None:
|
|
88
|
+
ratio = src_ratio
|
|
89
|
+
if width and height:
|
|
90
|
+
return _even(width), _even(height)
|
|
91
|
+
if width:
|
|
92
|
+
out_w = _even(width)
|
|
93
|
+
return out_w, (_even(out_w / ratio) if ratio else width)
|
|
94
|
+
if height:
|
|
95
|
+
out_h = _even(height)
|
|
96
|
+
return (_even(out_h * ratio) if ratio else height), out_h
|
|
97
|
+
if ratio and src_ratio:
|
|
98
|
+
if ratio <= src_ratio:
|
|
99
|
+
out_h = _even(src_h)
|
|
100
|
+
return _even(out_h * ratio), out_h
|
|
101
|
+
out_w = _even(src_w)
|
|
102
|
+
return out_w, _even(out_w / ratio)
|
|
103
|
+
return _even(src_w), _even(src_h)
|
|
104
|
+
|
|
105
|
+
|
|
50
106
|
# x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
|
|
51
107
|
SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
|
|
52
108
|
|
package/scripts/_common/probe.py
CHANGED
|
@@ -73,8 +73,11 @@ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool
|
|
|
73
73
|
return env
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
# the audio members of MEDIA_EXT: every audio file this skill reads, a wider set than the audio
|
|
77
|
+
# outputs it writes (AUDIO_CODECS / is_audio_output() -- no .aif, .aiff, .caf or .wma there)
|
|
78
|
+
AUDIO_MEDIA_EXT = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma"}
|
|
76
79
|
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
|
|
77
|
-
".
|
|
80
|
+
".png", ".jpg", ".jpeg", ".webp"} | AUDIO_MEDIA_EXT
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start: float = 0.0,
|
|
@@ -280,6 +283,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
280
283
|
"color_range": video.get("color_range"),
|
|
281
284
|
"rotation": rotation,
|
|
282
285
|
"nb_frames": _to_int(video.get("nb_frames")),
|
|
286
|
+
# the stream's own length (a container's also counts audio priming and a longer track)
|
|
287
|
+
"duration": _to_float(video.get("duration")),
|
|
283
288
|
"bitrate": _to_int(video.get("bit_rate")),
|
|
284
289
|
}
|
|
285
290
|
if audio:
|
|
@@ -289,6 +294,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
289
294
|
"channel_layout": audio.get("channel_layout"),
|
|
290
295
|
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
291
296
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
297
|
+
"duration": _to_float(audio.get("duration")),
|
|
292
298
|
}
|
|
293
299
|
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
294
300
|
out["audio_streams"] = [{
|
|
@@ -13,18 +13,23 @@ either side of the new cut: the same frames blend over the same span, and the to
|
|
|
13
13
|
the rendered one.
|
|
14
14
|
|
|
15
15
|
Only what an editor timeline can carry is exported: clips (with in/out and speed), the
|
|
16
|
-
transition between them, a music bed on its own audio track, and chapters as markers
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
transition between them, a music bed on its own audio track, and chapters as markers, on a
|
|
17
|
+
sequence of the project's frame (sized by the rule fit.py renders it with). FCPXML also states
|
|
18
|
+
how a clip of another aspect fills that frame (`adjust-conform`); EDL and OTIO cannot.
|
|
19
|
+
Everything else in the project -- captions, graphics, overlays, the silence cut, fit/crop, a
|
|
20
|
+
reframe the format cannot state, audio processing, loudness, the export preset -- is listed
|
|
21
|
+
under `not_exported`, never silently dropped.
|
|
20
22
|
"""
|
|
21
23
|
import json
|
|
24
|
+
import math
|
|
22
25
|
import os
|
|
23
26
|
from fractions import Fraction
|
|
24
27
|
from pathlib import Path
|
|
25
28
|
from typing import Any, Dict, List, Optional, Tuple
|
|
26
29
|
from xml.sax.saxutils import escape as _xml_escape
|
|
27
30
|
|
|
31
|
+
from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
|
|
32
|
+
|
|
28
33
|
FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
|
|
29
34
|
# Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
|
|
30
35
|
_NTSC = {23.976: Fraction(24000, 1001), 29.97: Fraction(30000, 1001), 59.94: Fraction(60000, 1001),
|
|
@@ -43,6 +48,9 @@ _NOT_EXPORTED = {
|
|
|
43
48
|
"brand": "brand styles",
|
|
44
49
|
"snap": "beat snapping (the timeline uses the project's in/out as written, before any snap)",
|
|
45
50
|
}
|
|
51
|
+
# frame.fit -> the FCPXML adjust-conform type that fills the sequence the same way. blur has no
|
|
52
|
+
# conform of its own: the picture is fitted whole, and its blurred background is not exported.
|
|
53
|
+
_CONFORM = {"pad": "fit", "crop": "fill", "blur": "fit"}
|
|
46
54
|
_AUDIO_NOT_EXPORTED = ("replace", "voice", "denoise", "duck", "duck_amount", "duck_threshold", "duck_attack",
|
|
47
55
|
"duck_release", "gain", "stereo", "mono", "downmix", "stereo_widen", "effects",
|
|
48
56
|
"effects_volume", "stems", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
@@ -63,6 +71,43 @@ def exact_rate(fps: float) -> Fraction:
|
|
|
63
71
|
return frac
|
|
64
72
|
|
|
65
73
|
|
|
74
|
+
def _number(value: Any, what: str) -> float:
|
|
75
|
+
"""A numeric project value (a rate, a size, a speed, a transition length) as a float, or a
|
|
76
|
+
TimelineError naming it -- never float()'s traceback."""
|
|
77
|
+
try:
|
|
78
|
+
number = float(value)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
number = math.nan
|
|
81
|
+
if not math.isfinite(number):
|
|
82
|
+
raise TimelineError(f"{what}: {value!r} is not a number")
|
|
83
|
+
return number
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _seconds(value: Any, what: str, fps: Optional[float]) -> float:
|
|
87
|
+
"""A project time read with the grammar render.py's own stages read it (time_arg()): seconds,
|
|
88
|
+
mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at `fps` or its own @fps. A value it refuses is a
|
|
89
|
+
TimelineError, so `--json` gets a `kind: input` document, not a traceback and empty stdout."""
|
|
90
|
+
try:
|
|
91
|
+
seconds = parse_time(str(value), fps)
|
|
92
|
+
except MissingFpsError as exc:
|
|
93
|
+
raise TimelineError(f"{what}: {exc}")
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise TimelineError(f"{what}: {exc} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the "
|
|
96
|
+
"source's fps or with an explicit @fps suffix)")
|
|
97
|
+
if not math.isfinite(seconds):
|
|
98
|
+
raise TimelineError(f"{what}: {value!r} is not a time")
|
|
99
|
+
return seconds
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
|
|
103
|
+
"""A source's displayed width x height (rotation metadata swaps them, as fit.py reads it)."""
|
|
104
|
+
video = meta.get("video") or {}
|
|
105
|
+
w, h = int(video.get("width") or 0), int(video.get("height") or 0)
|
|
106
|
+
if not (w and h):
|
|
107
|
+
return None
|
|
108
|
+
return (h, w) if video.get("rotation") in (90, -90, 270, -270) else (w, h)
|
|
109
|
+
|
|
110
|
+
|
|
66
111
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
67
112
|
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
68
113
|
|
|
@@ -71,44 +116,76 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
71
116
|
clips_in = proj.get("clips") or []
|
|
72
117
|
if not clips_in:
|
|
73
118
|
raise TimelineError("project.clips is empty")
|
|
119
|
+
for i, c in enumerate(clips_in):
|
|
120
|
+
if not isinstance(c, dict) or not c.get("src"):
|
|
121
|
+
raise TimelineError(f"clips[{i}]: no src")
|
|
74
122
|
frame = proj.get("frame") or {}
|
|
75
123
|
first = probes[rel(clips_in[0]["src"])]
|
|
76
|
-
|
|
124
|
+
frame_fps = _number(frame["fps"], "frame.fps") if frame.get("fps") else None
|
|
125
|
+
fps = frame_fps or (first.get("video") or {}).get("fps")
|
|
77
126
|
if not fps:
|
|
78
127
|
raise TimelineError("no frame rate: the first clip has no video stream and project.frame.fps is not set")
|
|
79
128
|
rate = exact_rate(float(fps))
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
# The sequence is the project's frame sized by fit.py's rule (render.py has already filled an
|
|
130
|
+
# aspect-only frame from the export preset), so {aspect 16:9, width 1920} is 1920x1080
|
|
131
|
+
# whatever the source's height -- the size the render's fit stage delivers. No frame at all
|
|
132
|
+
# is the first clip's own picture, which is what the render keeps.
|
|
133
|
+
aspect = frame.get("aspect")
|
|
134
|
+
ratio = aspect_ratio(aspect) if aspect else None
|
|
135
|
+
if aspect and ratio is None:
|
|
136
|
+
raise TimelineError(f"frame.aspect {aspect!r}: use W:H with whole numbers, like 16:9 (fit.py refuses it too)")
|
|
137
|
+
want_w, want_h = (int(_number(frame[k], f"frame.{k}")) if frame.get(k) else None for k in ("width", "height"))
|
|
138
|
+
if (want_w or 0) < 0 or (want_h or 0) < 0:
|
|
139
|
+
raise TimelineError(f"frame width/height must be positive, got {frame.get('width')!r} x {frame.get('height')!r}")
|
|
140
|
+
src_w, src_h = _picture(first) or (1920, 1080)
|
|
141
|
+
if ratio is None and not want_w and not want_h:
|
|
142
|
+
width, height = src_w, src_h
|
|
143
|
+
else:
|
|
144
|
+
width, height = frame_size(ratio, want_w, want_h, src_w, src_h)
|
|
145
|
+
fit_mode = str(frame.get("fit") or "pad")
|
|
146
|
+
if fit_mode not in _CONFORM:
|
|
147
|
+
raise TimelineError(f"frame.fit {frame.get('fit')!r}: use pad, crop or blur (fit.py's --fit)")
|
|
82
148
|
|
|
83
149
|
def fr(seconds: float) -> int:
|
|
84
150
|
return int(round(Fraction(seconds).limit_denominator(1000000) * rate))
|
|
85
151
|
|
|
86
152
|
trans = proj.get("transition") or {}
|
|
87
153
|
t_type = str(trans.get("type", "fade")) if len(clips_in) > 1 else "none"
|
|
88
|
-
d_frames = 0 if t_type == "none" else fr(
|
|
154
|
+
d_frames = 0 if t_type == "none" else fr(_number(trans.get("duration", 0.5), "transition.duration"))
|
|
89
155
|
notes: List[str] = []
|
|
90
156
|
if d_frames and t_type != "fade":
|
|
91
157
|
notes.append(f'transition "{t_type}" is exported as a cross dissolve: editors do not share xfade\'s other patterns')
|
|
92
158
|
|
|
93
159
|
clips: List[Dict[str, Any]] = []
|
|
160
|
+
reframed: List[str] = []
|
|
94
161
|
for i, c in enumerate(clips_in):
|
|
95
162
|
src = rel(c["src"])
|
|
96
163
|
meta = probes[src]
|
|
97
164
|
dur = float(meta.get("duration") or 0.0)
|
|
98
|
-
|
|
99
|
-
|
|
165
|
+
# in/out are on the source's own clock, as cut.py reads them: hh:mm:ss:ff at the
|
|
166
|
+
# source's fps, never frame.fps. A clip with no picture has no fps, and cut.py takes no
|
|
167
|
+
# other, so its hh:mm:ss:ff needs an @fps suffix here as in the render.
|
|
168
|
+
clip_fps = (meta.get("video") or {}).get("fps")
|
|
169
|
+
start = _seconds(c["in"], f"clips[{i}].in", clip_fps) if c.get("in") is not None else 0.0
|
|
170
|
+
if start < 0:
|
|
171
|
+
raise TimelineError(f"clips[{i}].in must not be negative, got {c['in']!r}")
|
|
172
|
+
end = _seconds(c["out"], f"clips[{i}].out", clip_fps) if c.get("out") is not None else dur
|
|
100
173
|
if end <= start:
|
|
101
174
|
raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
|
|
102
|
-
speed =
|
|
175
|
+
speed = _number(c.get("speed") or 1.0, f"clips[{i}].speed")
|
|
103
176
|
if not speed > 0:
|
|
104
177
|
raise TimelineError(f"clip {i}: speed must be positive, got {c.get('speed')!r}")
|
|
178
|
+
picture = _picture(meta)
|
|
179
|
+
reframe = bool(picture and abs(Fraction(*picture) - Fraction(width, height)) > Fraction(1, 100))
|
|
180
|
+
if reframe:
|
|
181
|
+
reframed.append(os.path.basename(src))
|
|
105
182
|
src_in, src_out = fr(start), fr(end)
|
|
106
183
|
clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
|
|
107
184
|
"src_in": src_in, "src_out": src_out, "speed": speed,
|
|
108
185
|
"length": int(round((src_out - src_in) / speed)),
|
|
109
186
|
"media_frames": fr(dur), "has_video": bool(meta.get("video")),
|
|
110
187
|
"has_audio": bool(meta.get("audio")),
|
|
111
|
-
"
|
|
188
|
+
"picture": picture, "fps": clip_fps, "reframed": reframe})
|
|
112
189
|
|
|
113
190
|
# Centre each dissolve on the cut: trim d/2 off the outgoing tail and d/2 off the incoming head.
|
|
114
191
|
half_a, half_b = d_frames // 2, d_frames - d_frames // 2
|
|
@@ -140,22 +217,44 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
|
|
|
140
217
|
markers: List[Dict[str, Any]] = []
|
|
141
218
|
chapters = proj.get("chapters")
|
|
142
219
|
if isinstance(chapters, list):
|
|
143
|
-
for ch in chapters:
|
|
144
|
-
|
|
220
|
+
for j, ch in enumerate(chapters):
|
|
221
|
+
# read as render.py's chapters stage reads it (metadata.py): a chapter list carries
|
|
222
|
+
# no fps, so hh:mm:ss:ff needs its @fps there and here alike
|
|
223
|
+
at = fr(_seconds(ch.get("at", 0), f"chapters[{j}].at", None))
|
|
145
224
|
if 0 <= at < total:
|
|
146
225
|
markers.append({"at": at, "title": str(ch.get("title") or "")})
|
|
147
226
|
elif chapters:
|
|
148
227
|
notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
|
|
149
228
|
|
|
229
|
+
# A clip of another aspect than the sequence is a reframe: whether the file can state it
|
|
230
|
+
# depends on the format, so summary() adds it to not_exported per format (_reframe_note).
|
|
150
231
|
not_exported = [text for key, text in _NOT_EXPORTED.items() if proj.get(key)]
|
|
151
232
|
dropped_audio = sorted(k for k in _AUDIO_NOT_EXPORTED if audio.get(k) not in (None, False, "", 0) and k != "music")
|
|
152
233
|
if dropped_audio:
|
|
153
234
|
not_exported.append("audio processing: " + ", ".join(dropped_audio))
|
|
154
235
|
return {"name": Path(str(proj.get("output") or "timeline")).stem, "rate": rate, "width": width, "height": height,
|
|
236
|
+
"fit": fit_mode, "reframed": list(dict.fromkeys(reframed)),
|
|
155
237
|
"clips": clips, "transition": t_type if d_frames else "none", "transition_frames": d_frames,
|
|
156
238
|
"music": music, "markers": markers, "total": total, "notes": notes, "not_exported": not_exported}
|
|
157
239
|
|
|
158
240
|
|
|
241
|
+
def _reframe_note(tl: Dict[str, Any], fmt: str) -> List[str]:
|
|
242
|
+
"""What `not_exported` says about the clips whose picture has another aspect than the
|
|
243
|
+
sequence. FCPXML states each one's spatial conform (adjust-conform: fit for frame.fit pad,
|
|
244
|
+
fill for crop), so there only blur's blurred background is missing; EDL and OTIO have no
|
|
245
|
+
field for a conform at all, so the editor's own default decides and the caller is told."""
|
|
246
|
+
if not tl["reframed"]:
|
|
247
|
+
return []
|
|
248
|
+
names = ", ".join(tl["reframed"])
|
|
249
|
+
if fmt == "fcpxml":
|
|
250
|
+
if tl["fit"] != "blur":
|
|
251
|
+
return []
|
|
252
|
+
return [f"frame.fit blur's blurred background behind {names}: the FCPXML fits the picture into "
|
|
253
|
+
f"{tl['width']}x{tl['height']} (adjust-conform fit), leaving bars where the render blurs"]
|
|
254
|
+
return [f"the reframe to {tl['width']}x{tl['height']} (frame.fit {tl['fit']}) of {names}: "
|
|
255
|
+
"set the clips' spatial conform in the editor (fit = pad, fill = crop)"]
|
|
256
|
+
|
|
257
|
+
|
|
159
258
|
# ---------------------------------------------------------------------------------------- EDL
|
|
160
259
|
def _tc(frames: int, fps_int: int) -> str:
|
|
161
260
|
h, rem = divmod(frames, fps_int * 3600)
|
|
@@ -234,21 +333,40 @@ def _url(path: str) -> str:
|
|
|
234
333
|
def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
235
334
|
"""FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
|
|
236
335
|
asset-clips with cross dissolves between them, the music bed as a connected clip on lane -1
|
|
237
|
-
of the first clip, chapters as chapter-markers. Speed is a linear timeMap on the clip.
|
|
336
|
+
of the first clip, chapters as chapter-markers. Speed is a linear timeMap on the clip. The
|
|
337
|
+
sequence has its own format (r1); each video asset has its source's, and a clip of another
|
|
338
|
+
aspect states its spatial conform (adjust-conform) for frame.fit."""
|
|
238
339
|
rate = tl["rate"]
|
|
239
340
|
q = lambda s: _xml_escape(str(s), {'"': """}) # noqa: E731
|
|
240
341
|
fd = _t(1, rate)
|
|
241
|
-
res
|
|
242
|
-
ids: Dict[
|
|
243
|
-
|
|
244
|
-
def
|
|
342
|
+
res: List[str] = []
|
|
343
|
+
ids: Dict[Any, str] = {} # a format's (width, height, frameDuration) or an asset's path -> its id
|
|
344
|
+
|
|
345
|
+
def fmt(width: int, height: int, frame_duration: str) -> str:
|
|
346
|
+
key = (width, height, frame_duration)
|
|
347
|
+
if key not in ids:
|
|
348
|
+
ids[key] = f"r{len(ids) + 1}"
|
|
349
|
+
res.append(f' <format id="{ids[key]}" name="FFVideoFormat{height}p" frameDuration="{frame_duration}" '
|
|
350
|
+
f'width="{width}" height="{height}"/>')
|
|
351
|
+
return ids[key]
|
|
352
|
+
|
|
353
|
+
seq_fmt = fmt(tl["width"], tl["height"], fd) # r1: the sequence's frame
|
|
354
|
+
|
|
355
|
+
def clip_fmt(c: Dict[str, Any]) -> str:
|
|
356
|
+
# An asset describes its own media, not the sequence: with every asset on r1, a 320x180
|
|
357
|
+
# file under a 1080x1920 frame claimed to be 1080x1920, and its conform had nothing to do.
|
|
358
|
+
if not (c["has_video"] and c["picture"]):
|
|
359
|
+
return seq_fmt
|
|
360
|
+
return fmt(c["picture"][0], c["picture"][1], _t(1, exact_rate(float(c["fps"]))) if c["fps"] else fd)
|
|
361
|
+
|
|
362
|
+
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "") -> str:
|
|
245
363
|
if path in ids:
|
|
246
364
|
return ids[path]
|
|
247
|
-
rid = f"r{len(ids) +
|
|
365
|
+
rid = f"r{len(ids) + 1}"
|
|
248
366
|
ids[path] = rid
|
|
249
367
|
res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
|
|
250
368
|
f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
|
|
251
|
-
+ (' format="
|
|
369
|
+
+ (f' format="{format_id}"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
|
|
252
370
|
f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
|
|
253
371
|
return rid
|
|
254
372
|
|
|
@@ -256,7 +374,8 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
256
374
|
d = tl["transition_frames"]
|
|
257
375
|
markers = list(tl["markers"])
|
|
258
376
|
for i, c in enumerate(tl["clips"]):
|
|
259
|
-
|
|
377
|
+
cid = clip_fmt(c)
|
|
378
|
+
rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid)
|
|
260
379
|
if i > 0 and d:
|
|
261
380
|
spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
|
|
262
381
|
f'duration="{_t(d, rate)}"/>')
|
|
@@ -273,21 +392,28 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
273
392
|
else:
|
|
274
393
|
start_attr = _t(src_start, rate)
|
|
275
394
|
local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
395
|
+
# the FCPXML 1.10 DTD's order inside an asset-clip: timeMap, then the adjust-* elements
|
|
396
|
+
# (adjust-conform), then anchored items (the music bed), then markers. Anything out of
|
|
397
|
+
# that order does not validate, and Final Cut validates what it imports against that DTD.
|
|
398
|
+
if c["reframed"]:
|
|
399
|
+
# the DTD reads a missing adjust-conform as fit, so crop has to say fill; fit is
|
|
400
|
+
# written too, so the file states the project's choice rather than a default
|
|
401
|
+
inner.append(f' <adjust-conform type="{_CONFORM[tl["fit"]]}"/>')
|
|
279
402
|
if i == 0 and tl["music"]:
|
|
280
403
|
m = tl["music"]
|
|
281
404
|
mid = asset(m["src"], m["name"], m["media_frames"], False, True)
|
|
282
405
|
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_t(local0, rate)}" name="{q(m["name"])}" '
|
|
283
406
|
f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
|
|
407
|
+
for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
|
|
408
|
+
inner.append(f' <chapter-marker start="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
|
|
409
|
+
f'duration="{fd}" value="{q(mk["title"])}"/>')
|
|
284
410
|
head = (f' <asset-clip ref="{rid}" offset="{_t(c["record_in"], rate)}" name="{q(c["name"])}" '
|
|
285
|
-
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="
|
|
411
|
+
f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" tcFormat="NDF"')
|
|
286
412
|
spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
|
|
287
413
|
out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
|
|
288
414
|
" <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
|
|
289
415
|
f' <project name="{q(tl["name"])}">',
|
|
290
|
-
f' <sequence format="
|
|
416
|
+
f' <sequence format="{seq_fmt}" duration="{_t(tl["total"], rate)}" tcStart="0s" tcFormat="NDF">',
|
|
291
417
|
" <spine>", *spine, " </spine>", " </sequence>", " </project>", " </event>",
|
|
292
418
|
" </library>", "</fcpxml>"]
|
|
293
419
|
return "\n".join(out) + "\n"
|
|
@@ -376,4 +502,5 @@ def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]:
|
|
|
376
502
|
"duration": round(float(Fraction(tl["total"]) / rate), 3), "frames": tl["total"],
|
|
377
503
|
"clips": len(tl["clips"]), "transition": tl["transition"],
|
|
378
504
|
"transition_frames": tl["transition_frames"], "music": bool(tl["music"]),
|
|
379
|
-
"markers": len(tl["markers"]), "notes": tl["notes"],
|
|
505
|
+
"markers": len(tl["markers"]), "notes": tl["notes"],
|
|
506
|
+
"not_exported": tl["not_exported"] + _reframe_note(tl, fmt)}
|
package/scripts/_contract.py
CHANGED
|
@@ -443,6 +443,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
443
443
|
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
|
|
444
444
|
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
445
445
|
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
|
|
446
|
+
elif name == "color":
|
|
447
|
+
extra = {"dropped_non_av_streams": {"type": "boolean"},
|
|
448
|
+
"sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
|
|
449
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
|
|
450
|
+
"measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
|
|
446
451
|
elif name == "cut":
|
|
447
452
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
448
453
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
@@ -451,10 +456,13 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
451
456
|
"reencoded": {"type": "boolean"},
|
|
452
457
|
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
|
|
453
458
|
elif name == "join":
|
|
454
|
-
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
459
|
+
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
460
|
+
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
455
461
|
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
456
462
|
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
|
|
457
|
-
"skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"}
|
|
463
|
+
"skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
|
|
464
|
+
"pending": {"type": "array", "description": "--dry-run: inputs that do not exist yet, planned on as an earlier step's output and never also under skipped: [{index, path}] ([] otherwise)"},
|
|
465
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--dry-run with pending inputs: which ones, what a real run does if one is still missing (including a skip that leaves fewer than two inputs), whether the mode came from the extensions, and which planned numbers are the unmeasured stub's placeholders (a pending first clip's frame and rate, xfade offsets after a pending clip)"}}
|
|
458
466
|
elif name == "audio":
|
|
459
467
|
extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
|
|
460
468
|
"audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
|
package/scripts/color.py
CHANGED
|
@@ -169,12 +169,36 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
|
|
|
169
169
|
return ",".join(chain)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
|
|
173
|
+
"""Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
|
|
174
|
+
true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
|
|
175
|
+
its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
|
|
176
|
+
white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
|
|
177
|
+
as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
|
|
178
|
+
v = meta["video"]
|
|
179
|
+
trc = v.get("color_transfer") or "bt709"
|
|
180
|
+
space = v.get("color_space") or "bt2020nc"
|
|
181
|
+
return ",".join([
|
|
182
|
+
f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
|
|
183
|
+
"format=gbrpf32le",
|
|
184
|
+
"zscale=p=bt709",
|
|
185
|
+
"zscale=t=bt709:m=bt709:r=tv",
|
|
186
|
+
"format=yuv420p",
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_bt2020_sdr(v: dict) -> bool:
|
|
191
|
+
"""BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
|
|
192
|
+
return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
|
|
193
|
+
and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
|
|
194
|
+
|
|
195
|
+
|
|
172
196
|
def main() -> int:
|
|
173
197
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
174
198
|
ap.add_argument("input")
|
|
175
199
|
ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
|
|
176
200
|
mode = ap.add_mutually_exclusive_group(required=True)
|
|
177
|
-
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020
|
|
201
|
+
mode.add_argument("--to-sdr", action="store_true", help="convert to SDR BT.709: tone-map HDR (PQ/HLG/Dolby Vision); BT.2020 primaries on an SDR transfer get a gamut conversion only, no tone map")
|
|
178
202
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
179
203
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
180
204
|
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
@@ -183,7 +207,7 @@ def main() -> int:
|
|
|
183
207
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
184
208
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
185
209
|
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
186
|
-
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is
|
|
210
|
+
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
|
|
187
211
|
ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
|
|
188
212
|
ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
|
|
189
213
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
@@ -278,10 +302,21 @@ def main() -> int:
|
|
|
278
302
|
return 0
|
|
279
303
|
|
|
280
304
|
measurements = None
|
|
305
|
+
sdr_extra: dict = {}
|
|
281
306
|
if args.to_sdr:
|
|
282
307
|
if not v.get("bt2020_or_hdr") and not args.force:
|
|
283
308
|
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
284
|
-
|
|
309
|
+
if is_bt2020_sdr(v):
|
|
310
|
+
vf = bt2020_sdr_to_bt709_chain(meta)
|
|
311
|
+
sdr_path = "gamut"
|
|
312
|
+
note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
|
|
313
|
+
f"without a tone map (--tonemap/--peak/--desat do not apply)")
|
|
314
|
+
else:
|
|
315
|
+
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
316
|
+
sdr_path = "tonemap"
|
|
317
|
+
note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
|
|
318
|
+
info(f"note: {note}")
|
|
319
|
+
sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
|
|
285
320
|
output = args.output or default_output(args.input, "sdr")
|
|
286
321
|
tag = "sdr"
|
|
287
322
|
elif args.correct:
|
|
@@ -323,7 +358,7 @@ def main() -> int:
|
|
|
323
358
|
r = probe(output, role="output")
|
|
324
359
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
|
|
325
360
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
326
|
-
extra = {"dropped_non_av_streams": dropped_streams}
|
|
361
|
+
extra = {"dropped_non_av_streams": dropped_streams, **sdr_extra}
|
|
327
362
|
if measurements is not None:
|
|
328
363
|
measurements["output"] = analyze_levels(output)
|
|
329
364
|
extra["measurements"] = measurements
|
package/scripts/fit.py
CHANGED
|
@@ -39,20 +39,16 @@ import sys
|
|
|
39
39
|
from fractions import Fraction
|
|
40
40
|
from typing import List
|
|
41
41
|
|
|
42
|
-
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
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size
|
|
43
43
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
44
|
-
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)}
|
|
45
44
|
|
|
46
45
|
|
|
47
46
|
def parse_aspect(value: str) -> Fraction:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
w, h = value.split(":")
|
|
52
|
-
return Fraction(int(w), int(h))
|
|
53
|
-
except (ValueError, ZeroDivisionError):
|
|
47
|
+
# the one aspect grammar, shared with render.py's frame and --export-timeline's sequence
|
|
48
|
+
ratio = aspect_ratio(value)
|
|
49
|
+
if ratio is None:
|
|
54
50
|
die(f"bad aspect '{value}', use W:H like 16:9")
|
|
55
|
-
return
|
|
51
|
+
return ratio
|
|
56
52
|
|
|
57
53
|
|
|
58
54
|
def atempo_chain(factor: float) -> str:
|
|
@@ -69,11 +65,6 @@ def atempo_chain(factor: float) -> str:
|
|
|
69
65
|
return ",".join(parts)
|
|
70
66
|
|
|
71
67
|
|
|
72
|
-
def even(n: float) -> int:
|
|
73
|
-
v = int(round(n))
|
|
74
|
-
return v if v % 2 == 0 else v + 1
|
|
75
|
-
|
|
76
|
-
|
|
77
68
|
def main() -> int:
|
|
78
69
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
79
70
|
ap.add_argument("input")
|
|
@@ -192,31 +183,8 @@ def main() -> int:
|
|
|
192
183
|
|
|
193
184
|
# ---- aspect / size
|
|
194
185
|
if args.aspect or args.width or args.height:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if args.width and args.height:
|
|
198
|
-
out_w, out_h = even(args.width), even(args.height)
|
|
199
|
-
elif args.width:
|
|
200
|
-
out_w = even(args.width)
|
|
201
|
-
out_h = even(out_w / ratio) if ratio else args.width
|
|
202
|
-
elif args.height:
|
|
203
|
-
out_h = even(args.height)
|
|
204
|
-
out_w = even(out_h * ratio) if ratio else args.height
|
|
205
|
-
elif ratio and src_ratio:
|
|
206
|
-
# No explicit --width/--height: size the canvas to the new aspect without exceeding
|
|
207
|
-
# the source's own resolution in either dimension. A narrower/taller target than the
|
|
208
|
-
# source (e.g. 9:16 from a 16:9 source) must be bounded by the source's HEIGHT, not
|
|
209
|
-
# its width -- bounding by width there multiplies the height by src_ratio/ratio (a
|
|
210
|
-
# 1920x1080 source asked for 9:16 used to come out 1920x3414, a ~3.16x upscale in
|
|
211
|
-
# both fit=pad and fit=crop, entirely unrequested).
|
|
212
|
-
if ratio <= src_ratio:
|
|
213
|
-
out_h = even(sh)
|
|
214
|
-
out_w = even(out_h * ratio)
|
|
215
|
-
else:
|
|
216
|
-
out_w = even(sw)
|
|
217
|
-
out_h = even(out_w / ratio)
|
|
218
|
-
else:
|
|
219
|
-
out_w, out_h = even(sw), even(sh)
|
|
186
|
+
# the one sizing rule, shared with render.py --export-timeline's sequence frame
|
|
187
|
+
out_w, out_h = frame_size(parse_aspect(args.aspect) if args.aspect else None, args.width, args.height, sw, sh)
|
|
220
188
|
if args.fit == "crop":
|
|
221
189
|
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
|
|
222
190
|
vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
|
package/scripts/join.py
CHANGED
|
@@ -24,19 +24,62 @@ Examples:
|
|
|
24
24
|
Every input is checked before anything is joined: a missing, empty or unreadable segment is
|
|
25
25
|
refused with every problem named at once (kind input), never discovered one run at a time.
|
|
26
26
|
--on-missing skip joins the usable segments instead and lists the others under `skipped`.
|
|
27
|
+
Under --dry-run a segment that does not exist yet is an earlier step's output: it is planned
|
|
28
|
+
on and named under `pending` (never `skipped`), whichever --on-missing was given, and its
|
|
29
|
+
extension stands in for what it will hold (an audio extension: no picture).
|
|
27
30
|
"""
|
|
28
31
|
import argparse
|
|
29
32
|
import os
|
|
30
33
|
import sys
|
|
31
|
-
from typing import Any, Dict, List
|
|
34
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
32
35
|
|
|
33
|
-
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, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
36
|
+
from _common import STATE, AUDIO_MEDIA_EXT, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
34
37
|
|
|
35
38
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
36
39
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
37
40
|
LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
|
|
38
41
|
# inputs --on-missing skip dropped, reported in the success document (empty when nothing was)
|
|
39
42
|
STATE_SKIPPED: List[Dict[str, Any]] = []
|
|
43
|
+
# --dry-run: inputs that do not exist yet, planned on as an earlier step's output
|
|
44
|
+
STATE_PENDING: List[Dict[str, Any]] = []
|
|
45
|
+
STATE_NOTES: List[str] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reported() -> Dict[str, Any]:
|
|
49
|
+
"""The input bookkeeping both success documents carry: `skipped` is what the join left out,
|
|
50
|
+
`pending` what a dry run planned on without it existing; `notes` says the same in words."""
|
|
51
|
+
extra: Dict[str, Any] = {"skipped": list(STATE_SKIPPED), "pending": list(STATE_PENDING)}
|
|
52
|
+
if STATE_NOTES:
|
|
53
|
+
extra["notes"] = list(STATE_NOTES)
|
|
54
|
+
return extra
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unpending_length(durs: List[float], d: float) -> Optional[float]:
|
|
58
|
+
"""The joined length the clip durations add up to, or None while an input is pending: its
|
|
59
|
+
dry-run stub says 0 s, which would count the clip as nothing and still subtract a transition
|
|
60
|
+
for it (a negative length when nothing exists yet)."""
|
|
61
|
+
if STATE_PENDING:
|
|
62
|
+
return None
|
|
63
|
+
return round(sum(durs) - d * (len(durs) - 1), 3)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
67
|
+
"""The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
|
|
68
|
+
video stream's, or the audio stream's when the sound runs more than a frame past the picture
|
|
69
|
+
(narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
|
|
70
|
+
a longer track) is only the fallback when a stream length is unknown (a pending input)."""
|
|
71
|
+
v = (meta.get("video") or {}).get("duration")
|
|
72
|
+
a = (meta.get("audio") or {}).get("duration")
|
|
73
|
+
if not v:
|
|
74
|
+
return meta.get("duration") or 0.0
|
|
75
|
+
if a and fps and a > v + 1.0 / fps:
|
|
76
|
+
return a
|
|
77
|
+
return v
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def expected_text(expected: Optional[float]) -> str:
|
|
81
|
+
"""unpending_length() as the stderr summary line puts it."""
|
|
82
|
+
return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
|
|
40
83
|
|
|
41
84
|
|
|
42
85
|
def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
@@ -47,8 +90,11 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
47
90
|
for p, dur in zip(args.inputs, durs):
|
|
48
91
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
49
92
|
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
# a pending input's probe is the dry-run stub (rate and layout unmeasured): plan from the
|
|
94
|
+
# clips that exist, and fall back to 48 kHz stereo only when none does
|
|
95
|
+
known = [m for m in metas if not m.get("dry_run")] or metas
|
|
96
|
+
rates = [m["audio"].get("sample_rate") or 48000 for m in known]
|
|
97
|
+
chans = [m["audio"].get("channels") or 2 for m in known]
|
|
52
98
|
rate = args.sample_rate or rates[0]
|
|
53
99
|
channels = args.channels or max(chans)
|
|
54
100
|
layout = LAYOUTS.get(channels)
|
|
@@ -76,7 +122,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
76
122
|
prev = out
|
|
77
123
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
|
|
78
124
|
run(cmd)
|
|
79
|
-
expected =
|
|
125
|
+
expected = unpending_length(durs, d)
|
|
80
126
|
r = probe(output, role="output")
|
|
81
127
|
a = r.get("audio") or {}
|
|
82
128
|
if not STATE.dry_run:
|
|
@@ -84,9 +130,9 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
84
130
|
die(f"{output} unexpectedly contains a video stream")
|
|
85
131
|
if a.get("sample_rate") != rate or a.get("channels") != channels:
|
|
86
132
|
die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
|
|
87
|
-
info(f"wrote {output} ({fmt_secs(r['duration'])},
|
|
133
|
+
info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
|
|
88
134
|
+ ("crossfade" if d else "butt join") + ")")
|
|
89
|
-
emit(output, mode="audio",
|
|
135
|
+
emit(output, mode="audio", **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
|
|
90
136
|
sample_rate=rate, channels=channels, video=False)
|
|
91
137
|
return 0
|
|
92
138
|
|
|
@@ -118,17 +164,20 @@ def read_list(path: str) -> List[str]:
|
|
|
118
164
|
return entries
|
|
119
165
|
|
|
120
166
|
|
|
121
|
-
def preflight(paths: List[str]) -> List[Dict[str, Any]]:
|
|
167
|
+
def preflight(paths: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
122
168
|
"""Every problem with every input, found before anything runs: missing, empty (0 bytes,
|
|
123
169
|
the usual trace of a TTS or download step that failed after creating its file), or not
|
|
124
170
|
readable as media. One ffprobe per file; nothing is decoded. Under --dry-run a file that
|
|
125
|
-
does not exist yet is not a problem
|
|
171
|
+
does not exist yet is not a problem but pending (the second list, with a note): in a
|
|
172
|
+
planned pipeline it is an earlier step's output, and the plan keeps it."""
|
|
126
173
|
ffprobe = require_tool("ffprobe")
|
|
127
174
|
problems: List[Dict[str, Any]] = []
|
|
175
|
+
pending: List[Dict[str, Any]] = []
|
|
128
176
|
for i, p in enumerate(paths):
|
|
177
|
+
if dry_run_input_pending(p):
|
|
178
|
+
pending.append({"index": i, "path": p})
|
|
179
|
+
continue
|
|
129
180
|
if not os.path.exists(p):
|
|
130
|
-
if STATE.dry_run:
|
|
131
|
-
continue # a dry-run pipeline plans on outputs earlier steps have not written yet
|
|
132
181
|
problems.append({"index": i, "path": p, "reason": "missing"})
|
|
133
182
|
continue
|
|
134
183
|
if os.path.isdir(p):
|
|
@@ -143,7 +192,7 @@ def preflight(paths: List[str]) -> List[Dict[str, Any]]:
|
|
|
143
192
|
if proc.returncode != 0 or not kinds & {"audio", "video"}:
|
|
144
193
|
first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
|
|
145
194
|
problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
|
|
146
|
-
return problems
|
|
195
|
+
return problems, pending
|
|
147
196
|
|
|
148
197
|
|
|
149
198
|
def main() -> int:
|
|
@@ -180,7 +229,7 @@ def main() -> int:
|
|
|
180
229
|
if not args.inputs:
|
|
181
230
|
die("give at least two clips (or --list FILE)")
|
|
182
231
|
skipped: List[Dict[str, Any]] = []
|
|
183
|
-
problems = preflight(args.inputs)
|
|
232
|
+
problems, pending = preflight(args.inputs)
|
|
184
233
|
if problems and args.on_missing == "fail":
|
|
185
234
|
listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
|
|
186
235
|
die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
|
|
@@ -192,21 +241,53 @@ def main() -> int:
|
|
|
192
241
|
for p in problems:
|
|
193
242
|
info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
|
|
194
243
|
STATE_SKIPPED[:] = skipped
|
|
244
|
+
STATE_PENDING[:] = pending
|
|
245
|
+
if pending:
|
|
246
|
+
# a dry run cannot know whether the file will be there: it plans on it, and says what a
|
|
247
|
+
# real run does if it still is not (the same --on-missing rule as any missing input --
|
|
248
|
+
# including the refusal when skipping would leave fewer than two inputs)
|
|
249
|
+
left = len(args.inputs) - len(pending)
|
|
250
|
+
if args.on_missing == "fail":
|
|
251
|
+
then = "refuses the join if one is still missing"
|
|
252
|
+
elif left >= 2:
|
|
253
|
+
then = "skips one that is still missing"
|
|
254
|
+
else:
|
|
255
|
+
then = (f"skips one that is still missing and refuses the join if that leaves fewer than two inputs "
|
|
256
|
+
f"(only {left} exist{'s' if left == 1 else ''} now)")
|
|
257
|
+
STATE_NOTES.append(f"{len(pending)} of {len(args.inputs) + len(skipped)} inputs do not exist yet and were planned on "
|
|
258
|
+
"as an earlier step's output: " + ", ".join(f"#{p['index'] + 1} {p['path']}" for p in pending)
|
|
259
|
+
+ f"; a real run {then}")
|
|
195
260
|
if len(args.inputs) < 2:
|
|
196
261
|
die("give at least two clips" if not skipped else
|
|
197
262
|
f"only {len(args.inputs)} usable input(s) left after skipping {len(skipped)}; nothing to join",
|
|
198
263
|
skipped=skipped)
|
|
199
264
|
validate_color(args.pad_color, "--pad-color")
|
|
200
265
|
metas = [probe(p) for p in args.inputs]
|
|
201
|
-
|
|
266
|
+
# A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
|
|
267
|
+
# file an earlier step will write. Its extension stands in: a step names its output for what
|
|
268
|
+
# it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
|
|
269
|
+
# reads, not only the ones it writes) is taken for a file without a picture and any other for
|
|
270
|
+
# one with a picture. Measured and pending inputs then meet the same rules a real run
|
|
271
|
+
# applies: all without a picture is an audio join, a mix is refused.
|
|
272
|
+
pictured = [(os.path.splitext(p)[1].lower() not in AUDIO_MEDIA_EXT) if m.get("dry_run") else bool(m.get("video"))
|
|
273
|
+
for p, m in zip(args.inputs, metas)]
|
|
274
|
+
if all(m.get("dry_run") for m in metas):
|
|
275
|
+
STATE_NOTES.append(f"no input exists yet: {'a video' if any(pictured) else 'an audio'} join was planned from the file extensions")
|
|
276
|
+
if not any(pictured):
|
|
202
277
|
for p, m in zip(args.inputs, metas):
|
|
203
278
|
if not m.get("audio"):
|
|
204
279
|
die(f"{p} has neither a video nor an audio stream")
|
|
205
280
|
return join_audio(args, metas)
|
|
206
|
-
for p, m in zip(args.inputs, metas):
|
|
207
|
-
if not
|
|
208
|
-
|
|
209
|
-
|
|
281
|
+
for p, m, pic in zip(args.inputs, metas, pictured):
|
|
282
|
+
if not pic:
|
|
283
|
+
# name an input measured to have a picture first; a pending one only as expected to
|
|
284
|
+
measured = [q for q, mm in zip(args.inputs, metas) if mm.get("video") and not mm.get("dry_run")]
|
|
285
|
+
planned = [q for q, mm, pc in zip(args.inputs, metas, pictured) if pc and mm.get("dry_run")]
|
|
286
|
+
other = (f"{measured[0]} has one" if measured else
|
|
287
|
+
f"{planned[0]}, which does not exist yet, is expected to have one (its extension is not an audio one)" if planned else "")
|
|
288
|
+
die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
|
|
289
|
+
else f"{p} has no video stream")
|
|
290
|
+
+ (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
|
|
210
291
|
first = metas[0]["video"]
|
|
211
292
|
fw, fh = first["width"], first["height"]
|
|
212
293
|
if first.get("rotation") in (90, -90, 270, -270):
|
|
@@ -226,7 +307,10 @@ def main() -> int:
|
|
|
226
307
|
w, h = w - (w % 2), h - (h % 2)
|
|
227
308
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
228
309
|
d = args.duration if args.transition != "none" else 0.0
|
|
229
|
-
for
|
|
310
|
+
# a crossfaded join gives each clip one length for both streams, so the offsets that place
|
|
311
|
+
# every picture also place its sound; the plain cut (concat) keeps the streams paired itself
|
|
312
|
+
lens = [clip_length(m, fps) for m in metas] if d else durs
|
|
313
|
+
for p, dur in zip(args.inputs, lens):
|
|
230
314
|
if d and dur <= d * 2 and not STATE.dry_run:
|
|
231
315
|
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
232
316
|
|
|
@@ -263,8 +347,9 @@ def main() -> int:
|
|
|
263
347
|
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
|
|
264
348
|
pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
|
|
265
349
|
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
266
|
-
# silently dropped the centre/LFE of 5.1 material
|
|
267
|
-
|
|
350
|
+
# silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
|
|
351
|
+
# layout: plan from the inputs that exist, as join_audio() does.
|
|
352
|
+
chans = [(m.get("audio") or {}).get("channels") or 2 for m in ([m for m in metas if not m.get("dry_run")] or metas)]
|
|
268
353
|
channels = args.channels or max(chans)
|
|
269
354
|
layout = LAYOUTS.get(channels)
|
|
270
355
|
if layout is None:
|
|
@@ -279,24 +364,54 @@ def main() -> int:
|
|
|
279
364
|
chain = "".join(f"[v{i}][a{i}]" for i in range(n))
|
|
280
365
|
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
281
366
|
else:
|
|
282
|
-
|
|
367
|
+
# pad the shorter stream to the clip's length: the audio with silence, the picture by
|
|
368
|
+
# holding its last frame, then cut both there -- each xfade and acrossfade then starts
|
|
369
|
+
# at the same clip boundary instead of the audio chain drifting by every clip's gap
|
|
370
|
+
for i in range(n):
|
|
371
|
+
vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
|
|
372
|
+
hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
|
|
373
|
+
parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
|
|
374
|
+
parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
|
|
375
|
+
vprev, aprev = "vp0", "ap0"
|
|
283
376
|
offset = 0.0
|
|
284
377
|
for i in range(1, n):
|
|
285
|
-
offset +=
|
|
378
|
+
offset += lens[i - 1] - d
|
|
286
379
|
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
287
380
|
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
288
|
-
parts.append(f"[{vprev}][
|
|
289
|
-
parts.append(f"[{aprev}][
|
|
381
|
+
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
|
|
382
|
+
parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
|
|
290
383
|
vprev, aprev = vout, aout
|
|
291
384
|
|
|
385
|
+
# A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
|
|
386
|
+
# plausible), and the planned command is built on it: say which numbers are placeholders.
|
|
387
|
+
if metas[0].get("dry_run") and not (args.width and args.height and args.fps):
|
|
388
|
+
STATE_NOTES.append(f"the first input is pending, so the planned frame and rate ({w}x{h} @ {fps:g}fps) are "
|
|
389
|
+
"placeholders: a real run takes them from that file")
|
|
390
|
+
if d and any(m.get("dry_run") for m in metas[:-1]):
|
|
391
|
+
STATE_NOTES.append("the planned xfade offsets count each pending input as 0 s long: a real run offsets "
|
|
392
|
+
"by its measured length")
|
|
292
393
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
293
394
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
395
|
+
if d:
|
|
396
|
+
# the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
|
|
397
|
+
# 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
|
|
398
|
+
# dissolve came out 52), which newer builds no longer do -- pass the frames through as built
|
|
399
|
+
cmd += ["-fps_mode", "passthrough"]
|
|
294
400
|
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
295
401
|
run(cmd)
|
|
296
|
-
expected =
|
|
402
|
+
expected = unpending_length(lens, d)
|
|
297
403
|
r = probe(output, role="output")
|
|
298
|
-
|
|
299
|
-
|
|
404
|
+
# the picture is what the offsets place: measure the video stream, not the container (whose
|
|
405
|
+
# length also counts the AAC tail), against the length the clips add up to
|
|
406
|
+
measured = (r.get("video") or {}).get("duration") or r.get("duration")
|
|
407
|
+
verification = []
|
|
408
|
+
if expected is not None and measured is not None and not STATE.dry_run:
|
|
409
|
+
tol = max(1.5 / fps, 0.05)
|
|
410
|
+
verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
|
|
411
|
+
"measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
|
|
412
|
+
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
413
|
+
emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
414
|
+
verification=verification,
|
|
300
415
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
301
416
|
return 0
|
|
302
417
|
|
package/scripts/render.py
CHANGED
|
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
64
64
|
|
|
65
65
|
from export import PRESETS, PLATFORM_OF
|
|
66
66
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
67
|
-
from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
67
|
+
from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
68
68
|
import subprocess
|
|
69
69
|
from _contract import CONTRACT_VERSION
|
|
70
70
|
from batch import file_key
|
|
@@ -394,7 +394,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
394
394
|
|
|
395
395
|
|
|
396
396
|
def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
397
|
-
"""Refuse an unrecognised key, naming the object, the key and the nearest valid one.
|
|
397
|
+
"""Refuse an unrecognised key, naming the object, the key and the nearest valid one. A value
|
|
398
|
+
that is not an object is require_object()'s to refuse, and only where the run reads it."""
|
|
398
399
|
if not isinstance(obj, dict):
|
|
399
400
|
return
|
|
400
401
|
valid = OBJECT_KEYS[schema]
|
|
@@ -406,25 +407,76 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
|
406
407
|
else f" (valid keys: {', '.join(sorted(valid))})"))
|
|
407
408
|
|
|
408
409
|
|
|
409
|
-
def
|
|
410
|
+
def require_object(obj: Any, label: str) -> None:
|
|
411
|
+
"""Refuse a value that is not an object where the run reads it with .get(): "export": "reels"
|
|
412
|
+
in a render, a string frame, a clip written as a bare path. 2.2.1 died there with a traceback
|
|
413
|
+
and nothing on stdout under --json. null, false and {} still ask for nothing."""
|
|
414
|
+
if obj and not isinstance(obj, dict):
|
|
415
|
+
die(f"{label}: must be an object {{...}}, got {type(obj).__name__} {obj!r:.60}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# the stage that first reads each section; --stop-after before it means the run never reads it
|
|
419
|
+
READ_AT = {"transition": "join", "silence": "silence", "fit": "fit", "captions": "captions",
|
|
420
|
+
"graphics": "graphics", "overlays": "overlays", "loudness": "loudness", "export": "export",
|
|
421
|
+
"check": "check"}
|
|
422
|
+
_READ_ORDER = ["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export", "check"]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _reaches(stage: str, stop_after: Optional[str]) -> bool:
|
|
426
|
+
return not stop_after or _READ_ORDER.index(stage) <= _READ_ORDER.index(stop_after)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: Optional[str] = None) -> None:
|
|
430
|
+
"""Every project error the run would hit, before the first ffmpeg call. A value that is not
|
|
431
|
+
an object is refused only where this run reads it (require_object()): a full render reads
|
|
432
|
+
every stage's section, a transition only between two or more clips and a clip's snap only
|
|
433
|
+
on a clip it cuts; --export-timeline (`timeline`) reads the clips, frame, audio and a
|
|
434
|
+
transition between clips, and only names the other sections in not_exported. 2.2.1 rendered
|
|
435
|
+
a single clip with "transition": "none" and exported "captions": "subs.srt", reading neither,
|
|
436
|
+
and a patch release does not refuse them."""
|
|
410
437
|
check_keys(proj, "project", "project")
|
|
438
|
+
clips = proj.get("clips")
|
|
439
|
+
# the join (or the timeline's dissolves) reads the transition; an audiogram render joins nothing
|
|
440
|
+
several = isinstance(clips, list) and len(clips) > 1 and (timeline or not proj.get("audiogram"))
|
|
441
|
+
read = {"frame", "audio"} | ({"transition"} if several else set())
|
|
442
|
+
if not timeline:
|
|
443
|
+
read |= {"silence", "audiogram", "captions", "loudness", "fit", "export", "check", "snap"}
|
|
444
|
+
# --stop-after ends the run before later stages read their sections: 2.2.1 completed a
|
|
445
|
+
# `--stop-after fit` preview of a project with "captions": "subs.srt", never reading it
|
|
446
|
+
read = {n for n in read if n not in READ_AT or _reaches(READ_AT[n], stop_after)}
|
|
411
447
|
for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check", "snap"):
|
|
448
|
+
if name in read:
|
|
449
|
+
require_object(proj.get(name), name)
|
|
412
450
|
check_keys(proj.get(name), name, name)
|
|
413
451
|
check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
|
|
414
452
|
if isinstance(proj.get("chapters"), list):
|
|
415
453
|
# Every other project error is raised here, before the first ffmpeg call; a chapter typo
|
|
416
454
|
# found inside the last stage costs a whole render and leaves an unchaptered file behind.
|
|
417
455
|
for i, item in enumerate(proj["chapters"]):
|
|
418
|
-
|
|
456
|
+
if isinstance(item, dict):
|
|
457
|
+
check_keys(item, "chapters[]", f"chapters[{i}]")
|
|
419
458
|
if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
|
|
420
459
|
die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
|
|
421
460
|
for name in ("clips", "graphics", "overlays"):
|
|
422
461
|
items = proj.get(name)
|
|
423
|
-
if
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
462
|
+
if name == "clips" or (not timeline and _reaches(READ_AT[name], stop_after)): # the export reads only the clips
|
|
463
|
+
if items and not isinstance(items, list):
|
|
464
|
+
die(f"{name}: must be a list of objects [{{...}}], got {type(items).__name__} {items!r:.60}")
|
|
465
|
+
for i, item in enumerate(items or []):
|
|
466
|
+
if not isinstance(item, dict):
|
|
467
|
+
die(f"{name}[{i}]: must be an object {{...}}, got {type(item).__name__} {item!r:.60}")
|
|
468
|
+
for i, item in enumerate(items if isinstance(items, list) else []):
|
|
469
|
+
check_keys(item, f"{name}[]", f"{name}[{i}]")
|
|
470
|
+
if name != "clips":
|
|
471
|
+
continue
|
|
472
|
+
# every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
|
|
473
|
+
if not item.get("src"):
|
|
474
|
+
die(f"clips[{i}]: no src")
|
|
475
|
+
if item.get("snap") is not None:
|
|
476
|
+
# the render reads a clip's snap only on a clip it cuts (in/out), the export never
|
|
477
|
+
if not timeline and (item.get("in") is not None or item.get("out") is not None):
|
|
478
|
+
require_object(item["snap"], f"clips[{i}].snap")
|
|
479
|
+
check_keys(item["snap"], "snap", f"clips[{i}].snap")
|
|
428
480
|
|
|
429
481
|
|
|
430
482
|
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
@@ -703,7 +755,11 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
|
|
|
703
755
|
"""Fill frame.width/height from the export preset when the project gave only an aspect.
|
|
704
756
|
Eval 7 (j08 twice, e01 by hand): "frame": {"aspect": "9:16"} with a reels export fitted a
|
|
705
757
|
1280x720 source to 406x720, captions were burned at that size, and export.py upscaled them
|
|
706
|
-
soft. A preset that names a delivery frame of the same aspect is that frame.
|
|
758
|
+
soft. A preset that names a delivery frame of the same aspect is that frame.
|
|
759
|
+
The match is 2.2.1's, looser than aspect_ratio(): `16/9` and `1.78:1` still take a 16:9
|
|
760
|
+
preset's size. A render that hands such a frame.aspect to fit.py fails there, as in 2.2.1;
|
|
761
|
+
one whose own fit.aspect replaces it, or that stops before fit, is sized by this match, and
|
|
762
|
+
a patch release does not change a render 2.2.1 completed."""
|
|
707
763
|
if not frame.get("aspect") or frame.get("width") or frame.get("height"):
|
|
708
764
|
return
|
|
709
765
|
preset = PRESETS.get(str(export.get("preset") or ""), {})
|
|
@@ -733,8 +789,20 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
733
789
|
if not os.path.exists(path):
|
|
734
790
|
die(f"timeline source not found: {path}")
|
|
735
791
|
probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
|
|
792
|
+
# the sequence frame is the one the render would deliver: an aspect-only frame takes its size
|
|
793
|
+
# from the export preset exactly as the render's own frame does. An export that is not an
|
|
794
|
+
# object names no preset here: 2.2.1's export never read it, and validate_project() refuses
|
|
795
|
+
# it only in the render, which reads it.
|
|
796
|
+
frame = dict(proj.get("frame") or {})
|
|
797
|
+
export = proj.get("export")
|
|
798
|
+
frame_from_preset(frame, export if isinstance(export, dict) else {})
|
|
799
|
+
fit = proj.get("fit")
|
|
800
|
+
if isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
|
|
801
|
+
# the render hands fit.py the fit object's own aspect, never this one (only the preset
|
|
802
|
+
# match above reads it), and completes: the sequence is the frame's size without it
|
|
803
|
+
del frame["aspect"]
|
|
736
804
|
try:
|
|
737
|
-
tl = tlmod.build(proj, probes, rel)
|
|
805
|
+
tl = tlmod.build(dict(proj, frame=frame), probes, rel)
|
|
738
806
|
except tlmod.TimelineError as exc:
|
|
739
807
|
die(f"--export-timeline: {exc}", kind="input")
|
|
740
808
|
text = tlmod.WRITERS[fmt](tl)
|
|
@@ -834,7 +902,7 @@ def main() -> int:
|
|
|
834
902
|
if "plan_version" in proj:
|
|
835
903
|
return execute_plan(proj, os.path.abspath(args.project))
|
|
836
904
|
base = Path(args.project).resolve().parent
|
|
837
|
-
validate_project(proj)
|
|
905
|
+
validate_project(proj, timeline=bool(args.export_timeline), stop_after=args.stop_after)
|
|
838
906
|
if args.write_project:
|
|
839
907
|
# Only the filled project: the point is to edit it before rendering, so nothing runs.
|
|
840
908
|
try:
|
|
@@ -1059,9 +1127,21 @@ def main() -> int:
|
|
|
1059
1127
|
fit.setdefault("aspect", frame["aspect"])
|
|
1060
1128
|
if frame.get("fit"):
|
|
1061
1129
|
fit.setdefault("fit", frame["fit"])
|
|
1062
|
-
|
|
1130
|
+
# Several clips were joined by join.py: into frame.width x frame.height when the frame gives
|
|
1131
|
+
# both, else at the FIRST clip's aspect. A frame of an aspect and ONE side then reached fit.py
|
|
1132
|
+
# as the aspect alone, which fit.py bounds by the joined picture: {aspect 9:16, width 1080}
|
|
1133
|
+
# over two 16:9 clips rendered 342x608, where one clip (and --export-timeline's sequence) is
|
|
1134
|
+
# 1080x1920. Only that case gets the frame's side. A frame with both sides is already the
|
|
1135
|
+
# join's size, and a project fit object with its own width, height or another aspect renders
|
|
1136
|
+
# as 2.2.1 rendered it: filling the frame's sides in there overrode the fit object's aspect
|
|
1137
|
+
# (fit {width 540} under a 1080x1920 frame came out 540x1920).
|
|
1138
|
+
own = proj.get("fit") or {}
|
|
1139
|
+
one_side = bool(frame.get("width")) != bool(frame.get("height"))
|
|
1140
|
+
sized = len(parts) == 1 or bool(frame.get("aspect") and one_side and not own.get("width") and not own.get("height")
|
|
1141
|
+
and own.get("aspect") in (None, frame.get("aspect")))
|
|
1142
|
+
if frame.get("width") and sized:
|
|
1063
1143
|
fit.setdefault("width", frame["width"])
|
|
1064
|
-
if frame.get("height") and
|
|
1144
|
+
if frame.get("height") and sized:
|
|
1065
1145
|
fit.setdefault("height", frame["height"])
|
|
1066
1146
|
if frame.get("fps") and len(parts) == 1:
|
|
1067
1147
|
fit.setdefault("fps", frame["fps"])
|