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.
Files changed (49) hide show
  1. package/README.md +6 -6
  2. package/SKILL.md +8 -7
  3. package/docs/contract.md +55 -46
  4. package/package.json +1 -1
  5. package/references/gotchas.md +9 -6
  6. package/references/scripts.md +21 -7
  7. package/scripts/_common/__init__.py +6 -6
  8. package/scripts/_common/decision.py +2 -2
  9. package/scripts/_common/emit.py +2 -37
  10. package/scripts/_common/probe.py +10 -6
  11. package/scripts/_common/runner.py +13 -45
  12. package/scripts/_common/timeline.py +379 -0
  13. package/scripts/_contract.py +39 -33
  14. package/scripts/background.py +1 -1
  15. package/scripts/broll.py +2 -2
  16. package/scripts/caption.py +1 -1
  17. package/scripts/check.py +3 -3
  18. package/scripts/color.py +4 -4
  19. package/scripts/crop.py +1 -1
  20. package/scripts/cut.py +1 -1
  21. package/scripts/deinterlace.py +1 -1
  22. package/scripts/denoise.py +1 -1
  23. package/scripts/export.py +6 -3
  24. package/scripts/fit.py +2 -2
  25. package/scripts/freeze.py +1 -1
  26. package/scripts/graphics.py +1 -1
  27. package/scripts/grid.py +2 -2
  28. package/scripts/insert.py +1 -1
  29. package/scripts/join.py +2 -2
  30. package/scripts/look.py +1 -1
  31. package/scripts/loop.py +1 -1
  32. package/scripts/multicam.py +2 -2
  33. package/scripts/overlay.py +1 -1
  34. package/scripts/pad.py +1 -1
  35. package/scripts/probe.py +1 -1
  36. package/scripts/proxy.py +2 -2
  37. package/scripts/redact.py +1 -1
  38. package/scripts/render.py +49 -1
  39. package/scripts/report.py +1 -1
  40. package/scripts/reverse.py +1 -1
  41. package/scripts/sequence.py +1 -1
  42. package/scripts/silence.py +1 -1
  43. package/scripts/speedramp.py +1 -1
  44. package/scripts/sphere.py +1 -1
  45. package/scripts/stabilize.py +1 -1
  46. package/scripts/straighten.py +1 -1
  47. package/scripts/sync.py +1 -1
  48. package/scripts/verify.py +2 -2
  49. package/scripts/waveform.py +1 -1
@@ -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
- "hdr": hdr,
266
- # 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
267
- # Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
268
- # transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
269
- "hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
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 (warned today, refused from 2.0)")
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 "--crf" in ap._option_string_actions:
202
- # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
203
- # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
204
- # here, once, rather than in each re-encoding tool's own parser.
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=None, metavar="N",
222
- help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
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. Until 2.0 this only
569
- warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
570
- 2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
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
- if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
590
- die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
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), {'"': "&quot;"}) # 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"]}