ffmpeg-skill 1.26.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/SKILL.md +8 -7
- package/docs/contract.md +55 -46
- package/package.json +1 -1
- package/references/gotchas.md +9 -6
- package/references/scripts.md +21 -7
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/decision.py +2 -2
- package/scripts/_common/emit.py +2 -37
- package/scripts/_common/probe.py +10 -6
- package/scripts/_common/runner.py +13 -45
- package/scripts/_common/timeline.py +379 -0
- package/scripts/_contract.py +39 -33
- package/scripts/background.py +1 -1
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +1 -1
- package/scripts/check.py +3 -3
- package/scripts/color.py +4 -4
- package/scripts/crop.py +1 -1
- package/scripts/cut.py +1 -1
- package/scripts/deinterlace.py +1 -1
- package/scripts/denoise.py +1 -1
- package/scripts/export.py +6 -3
- package/scripts/fit.py +2 -2
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +1 -1
- package/scripts/join.py +2 -2
- package/scripts/look.py +1 -1
- package/scripts/loop.py +1 -1
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +1 -1
- package/scripts/pad.py +1 -1
- package/scripts/probe.py +1 -1
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +1 -1
- package/scripts/render.py +49 -1
- package/scripts/report.py +1 -1
- package/scripts/reverse.py +1 -1
- package/scripts/sequence.py +1 -1
- package/scripts/silence.py +1 -1
- package/scripts/speedramp.py +1 -1
- package/scripts/sphere.py +1 -1
- package/scripts/stabilize.py +1 -1
- package/scripts/straighten.py +1 -1
- package/scripts/sync.py +1 -1
- package/scripts/verify.py +2 -2
- package/scripts/waveform.py +1 -1
package/scripts/_common/probe.py
CHANGED
|
@@ -149,7 +149,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
149
149
|
# sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
|
|
150
150
|
# by it, so the stub can finally report the honest, unknown value.
|
|
151
151
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
152
|
-
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
152
|
+
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False, "bt2020_or_hdr": False,
|
|
153
153
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
154
154
|
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
|
|
155
155
|
die(f"input not found: {path}")
|
|
@@ -250,6 +250,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
250
250
|
dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
|
|
251
251
|
if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
|
|
252
252
|
hdr = True
|
|
253
|
+
hdr_signal = trc in ("smpte2084", "arib-std-b67") or bool(dovi)
|
|
253
254
|
out["video"] = {
|
|
254
255
|
"codec": video.get("codec_name"),
|
|
255
256
|
"profile": video.get("profile"),
|
|
@@ -262,11 +263,14 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
262
263
|
"variable_frame_rate_suspected": vfr,
|
|
263
264
|
"pix_fmt": video.get("pix_fmt"),
|
|
264
265
|
"bit_depth": _bit_depth(pix),
|
|
265
|
-
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
|
|
266
|
+
# 2.0: `hdr` is true only for a PQ / HLG transfer or Dolby Vision, i.e. a genuinely HDR
|
|
267
|
+
# signal (1.x also counted BT.2020 primaries on an SDR transfer). `hdr_signal` (1.9) is
|
|
268
|
+
# kept, equal to it, for callers that moved to it. `bt2020_or_hdr` is the 1.x meaning,
|
|
269
|
+
# which every editing tool still routes on: a BT.2020 SDR source goes through the
|
|
270
|
+
# 10-bit path with its own tags, since x264 with BT.709 tags would shift its colours.
|
|
271
|
+
"hdr": hdr_signal,
|
|
272
|
+
"hdr_signal": hdr_signal,
|
|
273
|
+
"bt2020_or_hdr": hdr,
|
|
270
274
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
271
275
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
272
276
|
"dolby_vision": dovi,
|
|
@@ -195,45 +195,20 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
195
195
|
g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
196
196
|
help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
|
|
197
197
|
g.add_argument("--overwrite", action="store_true",
|
|
198
|
-
help="allow replacing an existing output (
|
|
198
|
+
help="allow replacing an existing output (refused without it)")
|
|
199
199
|
g.add_argument("--plan", metavar="FILE",
|
|
200
200
|
help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
|
|
201
|
-
if codec and "
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
crf = ap._option_string_actions["--crf"]
|
|
206
|
-
# The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
|
|
207
|
-
# spelling argparse accepts, including the --cr / --c abbreviations) from the default;
|
|
208
|
-
# apply_common() puts _CRF_DEFAULT back when the flag was absent.
|
|
209
|
-
global _CRF_DEFAULT
|
|
210
|
-
_CRF_DEFAULT = crf.default
|
|
211
|
-
crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
|
|
212
|
-
crf.default = None
|
|
213
|
-
if "deprecated" not in (crf.help or ""):
|
|
214
|
-
# the nine tools that declare --crf with no help string used to fall through this and
|
|
215
|
-
# never show the mark at all (review 9)
|
|
216
|
-
crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
|
|
217
|
-
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
218
|
-
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
201
|
+
if codec and "crf" in ap._defaults:
|
|
202
|
+
# A tool that re-encodes declares its CRF default with set_defaults(crf=N) before calling
|
|
203
|
+
# this; --codec/--quality are its only encoder flags, resolved in video_args(). --crf was
|
|
204
|
+
# the 1.x spelling of --quality (an alias from 1.8, deprecated in 1.10, removed in 2.0).
|
|
219
205
|
g.add_argument("--codec", choices=CODECS, default=None,
|
|
220
206
|
help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
|
|
221
|
-
g.add_argument("--quality", type=int, default=
|
|
222
|
-
help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
# The declared default of a deprecated --crf, parked by add_common() (one parser per process).
|
|
226
|
-
_CRF_DEFAULT: Optional[int] = None
|
|
207
|
+
g.add_argument("--quality", type=int, default=ap._defaults["crf"], metavar="N",
|
|
208
|
+
help="encoder quality on the CRF scale (default %(default)s; lower = better; 18 visually lossless for x264/x265, up to 63 for av1); ignored by prores")
|
|
227
209
|
|
|
228
210
|
|
|
229
211
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
230
|
-
# Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
|
|
231
|
-
# whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
|
|
232
|
-
# an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
|
|
233
|
-
# prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
|
|
234
|
-
crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
|
|
235
|
-
if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
|
|
236
|
-
args.crf = _CRF_DEFAULT
|
|
237
212
|
STATE.plan = getattr(args, "plan", None) or None
|
|
238
213
|
STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
|
|
239
214
|
if STATE.plan:
|
|
@@ -269,12 +244,7 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
269
244
|
if os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
|
|
270
245
|
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
271
246
|
hint="give -o NAME.mov")
|
|
272
|
-
crf = getattr(args, "crf", None)
|
|
273
|
-
# The warning the deprecation policy asks for, only when the caller typed the flag (see
|
|
274
|
-
# crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
|
|
275
|
-
# --crf is not an alias and is not deprecated: warn only where --quality exists.
|
|
276
|
-
if crf is not None and crf_explicit:
|
|
277
|
-
info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
|
|
247
|
+
crf = getattr(args, "crf", None) # export.py's own --crf (its preset chooses the encoder)
|
|
278
248
|
top = 63 if STATE.codec == "av1" else 51
|
|
279
249
|
if crf is not None and not 0 <= int(crf) <= top:
|
|
280
250
|
die(f"--crf must be between 0 and {top} ({'SVT-AV1' if STATE.codec == 'av1' else 'x264/x265'} scale; 18 is visually lossless), got {crf}")
|
|
@@ -565,9 +535,9 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
|
565
535
|
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
566
536
|
"""An output path that already exists is someone's file: a previous result, a source the
|
|
567
537
|
agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
|
|
568
|
-
a run never blocks on a y/N prompt) would replace it without a word.
|
|
569
|
-
|
|
570
|
-
|
|
538
|
+
a run never blocks on a y/N prompt) would replace it without a word. Since 2.0 it is refused
|
|
539
|
+
(kind: input, before anything runs, dry runs included) unless --overwrite gives explicit
|
|
540
|
+
consent; 1.x only warned, and FFMPEG_SKILL_NO_OVERWRITE=1 was the opt-in to this. Paths this
|
|
571
541
|
process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
|
|
572
542
|
output = cmd[-1]
|
|
573
543
|
if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
|
|
@@ -586,10 +556,8 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
586
556
|
pass
|
|
587
557
|
if STATE.overwrite:
|
|
588
558
|
return
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
|
|
592
|
-
f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
|
|
559
|
+
die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path",
|
|
560
|
+
kind="input", hint="pass --overwrite, or choose another -o path")
|
|
593
561
|
|
|
594
562
|
|
|
595
563
|
def _remember_output(cmd: Sequence[str]) -> None:
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""render.py project -> an editor's timeline (EDL, FCPXML, OTIO).
|
|
2
|
+
|
|
3
|
+
`render.py project.json --export-timeline edit.fcpxml|.edl|.otio` hands a rough cut to a person
|
|
4
|
+
who finishes it in Premiere, DaVinci Resolve or Final Cut. It is a translation, not a render:
|
|
5
|
+
nothing is encoded, and every decision in the file is one the project already made.
|
|
6
|
+
|
|
7
|
+
The timeline has to match what render.py itself would deliver, or the editor opens a different
|
|
8
|
+
cut than the agent showed. join.py's crossfade overlaps neighbouring clips by the transition
|
|
9
|
+
duration `d` (xfade: the last `d` s of A blend with the first `d` s of B, so the whole is `d`
|
|
10
|
+
shorter). Editors model a dissolve centred on a cut, using media beyond each clip's visible
|
|
11
|
+
range ("handles"), so each side of a dissolve is trimmed by `d/2` and the dissolve spans `d/2`
|
|
12
|
+
either side of the new cut: the same frames blend over the same span, and the total length is
|
|
13
|
+
the rendered one.
|
|
14
|
+
|
|
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
|
+
Everything else in the project -- captions, graphics, overlays, the silence cut, fit/crop,
|
|
18
|
+
audio processing, loudness, the export preset -- is listed under `not_exported`, never silently
|
|
19
|
+
dropped.
|
|
20
|
+
"""
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from fractions import Fraction
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
from xml.sax.saxutils import escape as _xml_escape
|
|
27
|
+
|
|
28
|
+
FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
|
|
29
|
+
# Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
|
|
30
|
+
_NTSC = {23.976: Fraction(24000, 1001), 29.97: Fraction(30000, 1001), 59.94: Fraction(60000, 1001),
|
|
31
|
+
47.952: Fraction(48000, 1001), 119.88: Fraction(120000, 1001)}
|
|
32
|
+
# project keys an editor timeline cannot carry, with what the caller should know about each
|
|
33
|
+
_NOT_EXPORTED = {
|
|
34
|
+
"silence": "the silence cut (render.py measures it at render time; the timeline keeps the clips whole)",
|
|
35
|
+
"captions": "captions (burn them with render.py, or import the .srt into the editor)",
|
|
36
|
+
"graphics": "graphics (titles, lower thirds, social templates)",
|
|
37
|
+
"overlays": "overlays (logos and text)",
|
|
38
|
+
"fit": "fit / reframe to the delivery aspect (set the sequence frame in the editor)",
|
|
39
|
+
"audiogram": "the audiogram picture",
|
|
40
|
+
"loudness": "loudness normalisation",
|
|
41
|
+
"export": "the export preset (the editor's own export decides the delivery)",
|
|
42
|
+
"check": "the platform check",
|
|
43
|
+
"brand": "brand styles",
|
|
44
|
+
"snap": "beat snapping (the timeline uses the project's in/out as written, before any snap)",
|
|
45
|
+
}
|
|
46
|
+
_AUDIO_NOT_EXPORTED = ("replace", "voice", "denoise", "duck", "duck_amount", "duck_threshold", "duck_attack",
|
|
47
|
+
"duck_release", "gain", "stereo", "mono", "downmix", "stereo_widen", "effects",
|
|
48
|
+
"effects_volume", "stems", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
49
|
+
"music_loop")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TimelineError(ValueError):
|
|
53
|
+
"""A project that cannot be expressed as a timeline (a caller-facing `kind: input`)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def exact_rate(fps: float) -> Fraction:
|
|
57
|
+
for approx, exact in _NTSC.items():
|
|
58
|
+
if abs(fps - approx) < 0.01:
|
|
59
|
+
return exact
|
|
60
|
+
frac = Fraction(fps).limit_denominator(1001)
|
|
61
|
+
if frac <= 0:
|
|
62
|
+
raise TimelineError(f"frame rate {fps!r} is not usable for a timeline")
|
|
63
|
+
return frac
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
67
|
+
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
68
|
+
|
|
69
|
+
`probes` maps each resolved clip/music path to its probe(); `rel` resolves a project path
|
|
70
|
+
the way render.py does. Raises TimelineError for what an editor timeline cannot represent."""
|
|
71
|
+
clips_in = proj.get("clips") or []
|
|
72
|
+
if not clips_in:
|
|
73
|
+
raise TimelineError("project.clips is empty")
|
|
74
|
+
frame = proj.get("frame") or {}
|
|
75
|
+
first = probes[rel(clips_in[0]["src"])]
|
|
76
|
+
fps = frame.get("fps") or (first.get("video") or {}).get("fps")
|
|
77
|
+
if not fps:
|
|
78
|
+
raise TimelineError("no frame rate: the first clip has no video stream and project.frame.fps is not set")
|
|
79
|
+
rate = exact_rate(float(fps))
|
|
80
|
+
width = int(frame.get("width") or (first.get("video") or {}).get("width") or 1920)
|
|
81
|
+
height = int(frame.get("height") or (first.get("video") or {}).get("height") or 1080)
|
|
82
|
+
|
|
83
|
+
def fr(seconds: float) -> int:
|
|
84
|
+
return int(round(Fraction(seconds).limit_denominator(1000000) * rate))
|
|
85
|
+
|
|
86
|
+
trans = proj.get("transition") or {}
|
|
87
|
+
t_type = str(trans.get("type", "fade")) if len(clips_in) > 1 else "none"
|
|
88
|
+
d_frames = 0 if t_type == "none" else fr(float(trans.get("duration", 0.5)))
|
|
89
|
+
notes: List[str] = []
|
|
90
|
+
if d_frames and t_type != "fade":
|
|
91
|
+
notes.append(f'transition "{t_type}" is exported as a cross dissolve: editors do not share xfade\'s other patterns')
|
|
92
|
+
|
|
93
|
+
clips: List[Dict[str, Any]] = []
|
|
94
|
+
for i, c in enumerate(clips_in):
|
|
95
|
+
src = rel(c["src"])
|
|
96
|
+
meta = probes[src]
|
|
97
|
+
dur = float(meta.get("duration") or 0.0)
|
|
98
|
+
start = float(c.get("in") or 0.0)
|
|
99
|
+
end = float(c["out"]) if c.get("out") is not None else dur
|
|
100
|
+
if end <= start:
|
|
101
|
+
raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
|
|
102
|
+
speed = float(c.get("speed") or 1.0)
|
|
103
|
+
if not speed > 0:
|
|
104
|
+
raise TimelineError(f"clip {i}: speed must be positive, got {c.get('speed')!r}")
|
|
105
|
+
src_in, src_out = fr(start), fr(end)
|
|
106
|
+
clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
|
|
107
|
+
"src_in": src_in, "src_out": src_out, "speed": speed,
|
|
108
|
+
"length": int(round((src_out - src_in) / speed)),
|
|
109
|
+
"media_frames": fr(dur), "has_video": bool(meta.get("video")),
|
|
110
|
+
"has_audio": bool(meta.get("audio")),
|
|
111
|
+
"width": (meta.get("video") or {}).get("width"), "height": (meta.get("video") or {}).get("height")})
|
|
112
|
+
|
|
113
|
+
# Centre each dissolve on the cut: trim d/2 off the outgoing tail and d/2 off the incoming head.
|
|
114
|
+
half_a, half_b = d_frames // 2, d_frames - d_frames // 2
|
|
115
|
+
for i, c in enumerate(clips):
|
|
116
|
+
c["trim_head"] = half_b if (i > 0 and d_frames) else 0
|
|
117
|
+
c["trim_tail"] = half_a if (i < len(clips) - 1 and d_frames) else 0
|
|
118
|
+
visible = c["length"] - c["trim_head"] - c["trim_tail"]
|
|
119
|
+
if visible <= 0:
|
|
120
|
+
raise TimelineError(f"clip {c['index']} ({c['name']}) is shorter than its transitions")
|
|
121
|
+
c["visible"] = visible
|
|
122
|
+
pos = 0
|
|
123
|
+
for c in clips:
|
|
124
|
+
c["record_in"] = pos
|
|
125
|
+
pos += c["visible"]
|
|
126
|
+
total = pos
|
|
127
|
+
|
|
128
|
+
music = None
|
|
129
|
+
audio = proj.get("audio") or {}
|
|
130
|
+
if audio.get("music"):
|
|
131
|
+
msrc = rel(audio["music"])
|
|
132
|
+
mmeta = probes[msrc]
|
|
133
|
+
mlen = min(fr(float(mmeta.get("duration") or 0.0)), total)
|
|
134
|
+
if mlen > 0:
|
|
135
|
+
music = {"src": os.path.abspath(msrc), "name": os.path.basename(msrc), "length": mlen,
|
|
136
|
+
"media_frames": fr(float(mmeta.get("duration") or 0.0))}
|
|
137
|
+
if audio.get("music_loop"):
|
|
138
|
+
notes.append("music_loop is not exported: the bed plays once, up to the timeline's length")
|
|
139
|
+
|
|
140
|
+
markers: List[Dict[str, Any]] = []
|
|
141
|
+
chapters = proj.get("chapters")
|
|
142
|
+
if isinstance(chapters, list):
|
|
143
|
+
for ch in chapters:
|
|
144
|
+
at = fr(float(ch.get("at", 0)))
|
|
145
|
+
if 0 <= at < total:
|
|
146
|
+
markers.append({"at": at, "title": str(ch.get("title") or "")})
|
|
147
|
+
elif chapters:
|
|
148
|
+
notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
|
|
149
|
+
|
|
150
|
+
not_exported = [text for key, text in _NOT_EXPORTED.items() if proj.get(key)]
|
|
151
|
+
dropped_audio = sorted(k for k in _AUDIO_NOT_EXPORTED if audio.get(k) not in (None, False, "", 0) and k != "music")
|
|
152
|
+
if dropped_audio:
|
|
153
|
+
not_exported.append("audio processing: " + ", ".join(dropped_audio))
|
|
154
|
+
return {"name": Path(str(proj.get("output") or "timeline")).stem, "rate": rate, "width": width, "height": height,
|
|
155
|
+
"clips": clips, "transition": t_type if d_frames else "none", "transition_frames": d_frames,
|
|
156
|
+
"music": music, "markers": markers, "total": total, "notes": notes, "not_exported": not_exported}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------------------- EDL
|
|
160
|
+
def _tc(frames: int, fps_int: int) -> str:
|
|
161
|
+
h, rem = divmod(frames, fps_int * 3600)
|
|
162
|
+
m, rem = divmod(rem, fps_int * 60)
|
|
163
|
+
s, f = divmod(rem, fps_int)
|
|
164
|
+
return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def to_edl(tl: Dict[str, Any]) -> str:
|
|
168
|
+
"""CMX 3600, non-drop timecode at the nominal integer rate, record starting at 01:00:00:00.
|
|
169
|
+
Every event is reel AX (the convention for file-based media) with the file named in
|
|
170
|
+
FROM CLIP NAME / SOURCE FILE comments, which Premiere and Resolve both relink from. A speed
|
|
171
|
+
change is an M2 line. A dissolve is the standard pair: a zero-length cut on the outgoing
|
|
172
|
+
source where its event ended, then a D event that brings the incoming clip in over `d`
|
|
173
|
+
frames starting at the incoming clip's own in point -- so each event's record range runs
|
|
174
|
+
from where the previous one stopped to where the next dissolve begins."""
|
|
175
|
+
fps_int = int(round(float(tl["rate"])))
|
|
176
|
+
hour = fps_int * 3600
|
|
177
|
+
clips = tl["clips"]
|
|
178
|
+
d = tl["transition_frames"]
|
|
179
|
+
# record range of each event: starts `trim_head` before its visible start (the dissolve
|
|
180
|
+
# window), ends where the next event starts
|
|
181
|
+
starts = [c["record_in"] - c["trim_head"] for c in clips]
|
|
182
|
+
ends = starts[1:] + [tl["total"]]
|
|
183
|
+
lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
|
|
184
|
+
n = 0
|
|
185
|
+
prev_src_end = 0
|
|
186
|
+
for i, c in enumerate(clips):
|
|
187
|
+
n += 1
|
|
188
|
+
chan = "B" if c["has_audio"] and c["has_video"] else ("V" if c["has_video"] else "A")
|
|
189
|
+
rec_in, rec_out = hour + starts[i], hour + ends[i]
|
|
190
|
+
s_in = c["src_in"]
|
|
191
|
+
s_out = s_in + int(round((ends[i] - starts[i]) * c["speed"]))
|
|
192
|
+
if i > 0 and d:
|
|
193
|
+
lines.append(f"{n:03d} AX {chan:<5} C {_tc(prev_src_end, fps_int)} {_tc(prev_src_end, fps_int)} "
|
|
194
|
+
f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
|
|
195
|
+
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
|
|
196
|
+
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
197
|
+
lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
|
|
198
|
+
lines.append(f"* TO CLIP NAME: {c['name']}")
|
|
199
|
+
else:
|
|
200
|
+
lines.append(f"{n:03d} AX {chan:<5} C {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
|
|
201
|
+
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
202
|
+
lines.append(f"* FROM CLIP NAME: {c['name']}")
|
|
203
|
+
lines.append(f"* SOURCE FILE: {c['src']}")
|
|
204
|
+
if abs(c["speed"] - 1.0) > 1e-6:
|
|
205
|
+
lines.append(f"M2 AX {fps_int * c['speed']:05.1f} {_tc(s_in, fps_int)}")
|
|
206
|
+
lines.append("")
|
|
207
|
+
prev_src_end = s_out
|
|
208
|
+
if tl["music"]:
|
|
209
|
+
n += 1
|
|
210
|
+
m = tl["music"]
|
|
211
|
+
lines.append(f"{n:03d} AX A2 C {_tc(0, fps_int)} {_tc(m['length'], fps_int)} "
|
|
212
|
+
f"{_tc(hour, fps_int)} {_tc(hour + m['length'], fps_int)}")
|
|
213
|
+
lines.append(f"* FROM CLIP NAME: {m['name']}")
|
|
214
|
+
lines.append(f"* SOURCE FILE: {m['src']}")
|
|
215
|
+
lines.append("")
|
|
216
|
+
for mk in tl["markers"]:
|
|
217
|
+
lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {mk['title']}")
|
|
218
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------------------- FCPXML
|
|
222
|
+
def _t(frames: int, rate: Fraction) -> str:
|
|
223
|
+
"""A rational FCPXML time: frames at `rate`, as `N/Ds` (or `Ns` when whole)."""
|
|
224
|
+
value = Fraction(frames) / rate
|
|
225
|
+
return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _url(path: str) -> str:
|
|
229
|
+
# pathlib builds the percent-encoded file:// URL itself (and the drive-letter form on
|
|
230
|
+
# Windows): no network-library import in scripts/, not even for quoting (see the no-fetch test)
|
|
231
|
+
return Path(os.path.abspath(path)).as_uri()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
235
|
+
"""FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
|
|
236
|
+
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."""
|
|
238
|
+
rate = tl["rate"]
|
|
239
|
+
q = lambda s: _xml_escape(str(s), {'"': """}) # noqa: E731
|
|
240
|
+
fd = _t(1, rate)
|
|
241
|
+
res = [f' <format id="r1" name="FFVideoFormat{tl["height"]}p" frameDuration="{fd}" width="{tl["width"]}" height="{tl["height"]}"/>']
|
|
242
|
+
ids: Dict[str, str] = {}
|
|
243
|
+
|
|
244
|
+
def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool) -> str:
|
|
245
|
+
if path in ids:
|
|
246
|
+
return ids[path]
|
|
247
|
+
rid = f"r{len(ids) + 2}"
|
|
248
|
+
ids[path] = rid
|
|
249
|
+
res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
|
|
250
|
+
f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
|
|
251
|
+
+ (' format="r1"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
|
|
252
|
+
f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
|
|
253
|
+
return rid
|
|
254
|
+
|
|
255
|
+
spine: List[str] = []
|
|
256
|
+
d = tl["transition_frames"]
|
|
257
|
+
markers = list(tl["markers"])
|
|
258
|
+
for i, c in enumerate(tl["clips"]):
|
|
259
|
+
rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"])
|
|
260
|
+
if i > 0 and d:
|
|
261
|
+
spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
|
|
262
|
+
f'duration="{_t(d, rate)}"/>')
|
|
263
|
+
src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
|
|
264
|
+
inner: List[str] = []
|
|
265
|
+
if abs(c["speed"] - 1.0) > 1e-6:
|
|
266
|
+
# timeMap maps clip-local time to source time; two linear points give a constant speed
|
|
267
|
+
inner.append(" <timeMap>")
|
|
268
|
+
inner.append(f' <timept time="0s" value="{_t(src_start, rate)}" interp="linear"/>')
|
|
269
|
+
inner.append(f' <timept time="{_t(c["visible"], rate)}" '
|
|
270
|
+
f'value="{_t(src_start + int(round(c["visible"] * c["speed"])), rate)}" interp="linear"/>')
|
|
271
|
+
inner.append(" </timeMap>")
|
|
272
|
+
start_attr = "0s"
|
|
273
|
+
else:
|
|
274
|
+
start_attr = _t(src_start, rate)
|
|
275
|
+
local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
|
|
276
|
+
for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
|
|
277
|
+
inner.append(f' <chapter-marker start="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
|
|
278
|
+
f'duration="{fd}" value="{q(mk["title"])}"/>')
|
|
279
|
+
if i == 0 and tl["music"]:
|
|
280
|
+
m = tl["music"]
|
|
281
|
+
mid = asset(m["src"], m["name"], m["media_frames"], False, True)
|
|
282
|
+
inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_t(local0, rate)}" name="{q(m["name"])}" '
|
|
283
|
+
f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
|
|
284
|
+
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="r1" tcFormat="NDF"')
|
|
286
|
+
spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
|
|
287
|
+
out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
|
|
288
|
+
" <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
|
|
289
|
+
f' <project name="{q(tl["name"])}">',
|
|
290
|
+
f' <sequence format="r1" duration="{_t(tl["total"], rate)}" tcStart="0s" tcFormat="NDF">',
|
|
291
|
+
" <spine>", *spine, " </spine>", " </sequence>", " </project>", " </event>",
|
|
292
|
+
" </library>", "</fcpxml>"]
|
|
293
|
+
return "\n".join(out) + "\n"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------------------- OTIO
|
|
297
|
+
def _rt(frames: int, rate: Fraction) -> Dict[str, Any]:
|
|
298
|
+
return {"OTIO_SCHEMA": "RationalTime.1", "rate": float(rate), "value": float(frames)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _range(start: int, dur: int, rate: Fraction) -> Dict[str, Any]:
|
|
302
|
+
return {"OTIO_SCHEMA": "TimeRange.1", "start_time": _rt(start, rate), "duration": _rt(dur, rate)}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def to_otio(tl: Dict[str, Any]) -> str:
|
|
306
|
+
"""OpenTimelineIO JSON (Resolve imports it natively; Premiere/Avid through OTIO's adapters):
|
|
307
|
+
a video track of clips and SMPTE dissolves, a music track, markers on the stack. Times are in
|
|
308
|
+
timeline frames; a speed change is a LinearTimeWarp effect over the clip's source range."""
|
|
309
|
+
rate = tl["rate"]
|
|
310
|
+
d = tl["transition_frames"]
|
|
311
|
+
video: List[Dict[str, Any]] = []
|
|
312
|
+
for i, c in enumerate(tl["clips"]):
|
|
313
|
+
if i > 0 and d:
|
|
314
|
+
video.append({"OTIO_SCHEMA": "Transition.1", "name": "Cross Dissolve", "metadata": {},
|
|
315
|
+
"transition_type": "SMPTE_Dissolve",
|
|
316
|
+
# OTIO: in_offset is the part before the cut, out_offset the part after
|
|
317
|
+
"in_offset": _rt(c["trim_head"], rate), "out_offset": _rt(tl["clips"][i - 1]["trim_tail"], rate)})
|
|
318
|
+
src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
|
|
319
|
+
clip = {"OTIO_SCHEMA": "Clip.2", "name": c["name"], "metadata": {}, "enabled": True,
|
|
320
|
+
# OTIO's own convention: source_range starts at the media's in point and lasts the
|
|
321
|
+
# clip's length on the timeline; a LinearTimeWarp says how fast the media runs
|
|
322
|
+
# through it (core OTIO does not rescale durations by effects).
|
|
323
|
+
"source_range": _range(src_start, c["visible"], rate),
|
|
324
|
+
"effects": [], "markers": [],
|
|
325
|
+
"media_references": {"DEFAULT_MEDIA": {
|
|
326
|
+
"OTIO_SCHEMA": "ExternalReference.1", "name": c["name"], "metadata": {},
|
|
327
|
+
"target_url": _url(c["src"]), "available_range": _range(0, c["media_frames"], rate),
|
|
328
|
+
"available_image_bounds": None}},
|
|
329
|
+
"active_media_reference_key": "DEFAULT_MEDIA"}
|
|
330
|
+
if abs(c["speed"] - 1.0) > 1e-6:
|
|
331
|
+
clip["effects"].append({"OTIO_SCHEMA": "LinearTimeWarp.1", "name": "speed", "metadata": {},
|
|
332
|
+
"effect_name": "LinearTimeWarp", "time_scalar": c["speed"]})
|
|
333
|
+
video.append(clip)
|
|
334
|
+
tracks = [{"OTIO_SCHEMA": "Track.1", "name": "V1", "kind": "Video", "metadata": {}, "enabled": True,
|
|
335
|
+
"source_range": None, "effects": [], "markers": [], "children": video}]
|
|
336
|
+
if tl["music"]:
|
|
337
|
+
m = tl["music"]
|
|
338
|
+
tracks.append({"OTIO_SCHEMA": "Track.1", "name": "Music", "kind": "Audio", "metadata": {}, "enabled": True,
|
|
339
|
+
"source_range": None, "effects": [], "markers": [], "children": [{
|
|
340
|
+
"OTIO_SCHEMA": "Clip.2", "name": m["name"], "metadata": {}, "enabled": True,
|
|
341
|
+
"source_range": _range(0, m["length"], rate), "effects": [], "markers": [],
|
|
342
|
+
"media_references": {"DEFAULT_MEDIA": {
|
|
343
|
+
"OTIO_SCHEMA": "ExternalReference.1", "name": m["name"], "metadata": {},
|
|
344
|
+
"target_url": _url(m["src"]), "available_range": _range(0, m["media_frames"], rate),
|
|
345
|
+
"available_image_bounds": None}},
|
|
346
|
+
"active_media_reference_key": "DEFAULT_MEDIA"}]})
|
|
347
|
+
markers = [{"OTIO_SCHEMA": "Marker.2", "name": mk["title"], "metadata": {}, "color": "RED", "comment": "",
|
|
348
|
+
"marked_range": _range(mk["at"], 0, rate)} for mk in tl["markers"]]
|
|
349
|
+
doc = {"OTIO_SCHEMA": "Timeline.1", "name": tl["name"], "metadata": {}, "global_start_time": _rt(0, rate),
|
|
350
|
+
"tracks": {"OTIO_SCHEMA": "Stack.1", "name": "tracks", "metadata": {}, "enabled": True,
|
|
351
|
+
"source_range": None, "effects": [], "markers": markers, "children": tracks}}
|
|
352
|
+
return json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
WRITERS = {"edl": to_edl, "fcpxml": to_fcpxml, "otio": to_otio}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def parses(path: str, fmt: str) -> bool:
|
|
359
|
+
"""The verification step: the written file reads back as what it claims to be."""
|
|
360
|
+
try:
|
|
361
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
362
|
+
if fmt == "fcpxml":
|
|
363
|
+
import xml.etree.ElementTree as ET
|
|
364
|
+
return ET.fromstring(text.encode("utf-8")).tag == "fcpxml"
|
|
365
|
+
if fmt == "otio":
|
|
366
|
+
return json.loads(text).get("OTIO_SCHEMA") == "Timeline.1"
|
|
367
|
+
return text.startswith("TITLE:") and any(line[:3].isdigit() for line in text.splitlines())
|
|
368
|
+
except (OSError, ValueError, SyntaxError):
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]:
|
|
373
|
+
"""The part of the timeline a caller reads back in render.py's --json document."""
|
|
374
|
+
rate = tl["rate"]
|
|
375
|
+
return {"format": fmt, "rate": f"{rate.numerator}/{rate.denominator}" if rate.denominator != 1 else str(rate.numerator),
|
|
376
|
+
"duration": round(float(Fraction(tl["total"]) / rate), 3), "frames": tl["total"],
|
|
377
|
+
"clips": len(tl["clips"]), "transition": tl["transition"],
|
|
378
|
+
"transition_frames": tl["transition_frames"], "music": bool(tl["music"]),
|
|
379
|
+
"markers": len(tl["markers"]), "notes": tl["notes"], "not_exported": tl["not_exported"]}
|