ffmpeg-skill 3.1.5 → 3.1.7

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 (42) hide show
  1. package/docs/contract.md +11 -7
  2. package/package.json +1 -1
  3. package/references/ci-platform-pitfalls.md +3 -2
  4. package/references/gotchas.md +5 -1
  5. package/references/scripts.md +12 -6
  6. package/scripts/_common/__init__.py +7 -5
  7. package/scripts/_common/asr.py +49 -10
  8. package/scripts/_common/color.py +40 -1
  9. package/scripts/_common/decision.py +177 -10
  10. package/scripts/_common/emit.py +2 -0
  11. package/scripts/_common/fonts.py +26 -0
  12. package/scripts/_common/probe.py +115 -5
  13. package/scripts/_common/runner.py +38 -1
  14. package/scripts/_common/timeline.py +123 -27
  15. package/scripts/_contract.py +34 -3
  16. package/scripts/broll.py +35 -14
  17. package/scripts/caption.py +105 -19
  18. package/scripts/color.py +37 -8
  19. package/scripts/crop.py +8 -3
  20. package/scripts/cropdetect.py +13 -9
  21. package/scripts/cut.py +4 -4
  22. package/scripts/export.py +16 -15
  23. package/scripts/fit.py +8 -4
  24. package/scripts/freeze.py +38 -10
  25. package/scripts/graphics.py +104 -67
  26. package/scripts/grid.py +49 -7
  27. package/scripts/insert.py +9 -1
  28. package/scripts/join.py +29 -19
  29. package/scripts/look.py +41 -12
  30. package/scripts/loop.py +45 -6
  31. package/scripts/multicam.py +16 -7
  32. package/scripts/overlay.py +68 -14
  33. package/scripts/proxy.py +8 -4
  34. package/scripts/redact.py +10 -5
  35. package/scripts/reverse.py +27 -2
  36. package/scripts/scenes.py +16 -5
  37. package/scripts/sequence.py +6 -2
  38. package/scripts/silence.py +91 -35
  39. package/scripts/speedramp.py +17 -5
  40. package/scripts/straighten.py +17 -5
  41. package/scripts/sync.py +8 -2
  42. package/scripts/waveform.py +8 -3
@@ -61,6 +61,13 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
61
61
  # common CJK system fonts when the request looks CJK (so Japanese text does not render as
62
62
  # boxes in Arial), and Arial only as the last resort.
63
63
  wanted = _family_key(font_name)
64
+ # The family's own file first, from the table of what Windows installs under which file
65
+ # name (#414): "Malgun Gothic" is malgun.ttf and "Times New Roman" times.ttf, which no
66
+ # file-name prefix finds -- the prefix rule resolved them to YuGothM.ttc and arial.ttf and
67
+ # reported a font that is installed as substituted.
68
+ for name in windows_family_files(wanted):
69
+ if (fonts / name).is_file():
70
+ return {"file": str(fonts / name), "family": font_name, "substituted": False, "via": "windows_fonts"}
64
71
  try:
65
72
  files = sorted(fonts.iterdir()) if fonts.is_dir() else []
66
73
  except OSError:
@@ -199,6 +206,25 @@ WINDOWS_FONTS = {
199
206
  }
200
207
 
201
208
 
209
+ # Latin families Windows installs that WINDOWS_FONTS (a per-script table) does not name, by the
210
+ # file each one is installed as: a file name that does not start with the family's name
211
+ WINDOWS_LATIN_FONTS = [("arial.ttf", "Arial"), ("times.ttf", "Times New Roman"), ("cour.ttf", "Courier New"),
212
+ ("segoeui.ttf", "Segoe UI"), ("tahoma.ttf", "Tahoma"), ("verdana.ttf", "Verdana"),
213
+ ("georgia.ttf", "Georgia"), ("calibri.ttf", "Calibri"), ("cambria.ttc", "Cambria"),
214
+ ("consola.ttf", "Consolas"), ("trebuc.ttf", "Trebuchet MS"), ("comic.ttf", "Comic Sans MS"),
215
+ ("impact.ttf", "Impact"), ("seguiemj.ttf", "Segoe UI Emoji")]
216
+
217
+
218
+ def windows_family_files(wanted_key: str) -> "List[str]":
219
+ """The Windows Fonts file names of the family whose _family_key() is `wanted_key`, best first,
220
+ from WINDOWS_FONTS and WINDOWS_LATIN_FONTS ([] for a family neither names)."""
221
+ out: "List[str]" = []
222
+ for name, family in [e for entries in WINDOWS_FONTS.values() for e in entries] + WINDOWS_LATIN_FONTS:
223
+ if wanted_key and _family_key(family) == wanted_key and name not in out:
224
+ out.append(name)
225
+ return out
226
+
227
+
202
228
  _SCRIPT_RANGES = (
203
229
  ("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
204
230
  ("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
@@ -38,7 +38,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
38
38
  *, check: bool = True) -> List[float]:
39
39
  """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
40
40
  single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
41
- sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
41
+ sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
42
+ The samples are on the file's clock: a sound that starts late is padded at its head."""
42
43
  ffmpeg = require_tool("ffmpeg")
43
44
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
44
45
  if start:
@@ -46,7 +47,10 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
46
47
  cmd += ["-i", path]
47
48
  if seconds is not None:
48
49
  cmd += ["-t", f"{seconds:.3f}"]
49
- cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
50
+ # first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
51
+ # so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
52
+ # starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
53
+ cmd += ["-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
50
54
  proc = run_analysis(cmd, check=False, text=False)
51
55
  if proc.returncode != 0 or not proc.stdout:
52
56
  if check:
@@ -142,23 +146,40 @@ def _tag(entry: Dict[str, Any], key: str) -> Optional[str]:
142
146
  return next((v for k, v in (entry.get("tags") or {}).items() if k.lower() == key), None)
143
147
 
144
148
 
149
+ def _nominal_fps(video: Optional[Dict[str, Any]]) -> Optional[int]:
150
+ """The integer timecode rate of the picture (30 for 29.97), from avg_frame_rate, else
151
+ r_frame_rate: the order probe()'s `fps` uses, which the timeline export validates against."""
152
+ for key in ("avg_frame_rate", "r_frame_rate"):
153
+ try:
154
+ num, _, den = str((video or {}).get(key) or "").partition("/")
155
+ rate = float(num) / float(den or 1)
156
+ except (ValueError, ZeroDivisionError):
157
+ continue
158
+ if 0 < rate <= 240:
159
+ return int(round(rate))
160
+ return None
161
+
162
+
145
163
  def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
146
164
  video: Optional[Dict[str, Any]]) -> Dict[str, Any]:
147
165
  """The first well-formed `timecode` tag, from the video stream, then a `tmcd` data stream,
148
166
  then the container: {value, drop_frame, source}. `drop_frame` is the `;` (or `.`) separator
149
167
  before the frames field; `source` is `stream:<ffprobe index>` or `format`. None when no
150
- source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF is skipped, not
151
- repaired."""
168
+ source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF, or whose frames field
169
+ the picture's rate cannot reach, is skipped, not repaired."""
152
170
  candidates = []
153
171
  if video is not None:
154
172
  candidates.append((video, f"stream:{video.get('index')}"))
155
173
  candidates += [(s, f"stream:{s.get('index')}") for s in streams
156
174
  if s.get("codec_type") == "data" and str(s.get("codec_tag_string", "")).lower() == "tmcd"]
157
175
  candidates.append((fmt, "format"))
176
+ nominal = _nominal_fps(video)
158
177
  for entry, source in candidates:
159
178
  value = _tag(entry, "timecode")
160
179
  m = _TIMECODE.match(value.strip()) if isinstance(value, str) else None
161
- if m:
180
+ # a frames field the picture's rate cannot reach (frame 29 at 25 fps) is no timecode
181
+ # either: the next candidate (a tmcd track's own tag) may still hold a real one (#427)
182
+ if m and not (nominal and int(m.group(5)) >= nominal):
162
183
  return {"value": value.strip(), "drop_frame": m.group(4) in ";.", "source": source}
163
184
  return {"value": None, "drop_frame": None, "source": None}
164
185
 
@@ -284,6 +305,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
284
305
  rotation = int(video["tags"]["rotate"])
285
306
  except ValueError:
286
307
  pass
308
+ if not rotation and _is_still(fmt, video):
309
+ # an EXIF Orientation reaches ffmpeg as the decoded frame's display matrix, not as
310
+ # stream side data, so -show_streams has no rotation for a phone's portrait JPEG;
311
+ # ffmpeg still turns the picture upright on decode, and every tool that sizes the
312
+ # frame has to know it did (#411)
313
+ rotation = _still_rotation(path)
287
314
  pix = video.get("pix_fmt") or ""
288
315
  trc = video.get("color_transfer") or ""
289
316
  prim = video.get("color_primaries") or ""
@@ -301,6 +328,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
301
328
  "width": w,
302
329
  "height": h,
303
330
  "display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
331
+ # the stored pixel's shape (DV, HDV 1440x1080, DVD and XDCAM store non-square pixels);
332
+ # every tool that sizes or draws on the picture works on square pixels at the display
333
+ # aspect (square_pixels() / display_size() below)
334
+ "sample_aspect_ratio": _sar_string(video.get("sample_aspect_ratio")),
304
335
  "fps": round(fps, 3) if fps else None,
305
336
  "r_frame_rate": video.get("r_frame_rate"),
306
337
  "avg_frame_rate": video.get("avg_frame_rate"),
@@ -369,6 +400,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
369
400
  if role == "input" and getattr(STATE, "first_input", None) is None:
370
401
  STATE.first_input = (path, len(out.get("audio_streams") or []),
371
402
  (out.get("video") or {}).get("hdr10_static"))
403
+ if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
404
+ STATE.source_timecode = (out["timecode"]["value"], duration)
372
405
  return out
373
406
 
374
407
 
@@ -658,6 +691,83 @@ def _fraction(v: Optional[str]) -> Optional[Fraction]:
658
691
  return None
659
692
 
660
693
 
694
+ def _sar_string(v: Optional[str]) -> str:
695
+ """ffprobe's sample_aspect_ratio as "N:D", "1:1" when unset (0:1, N/A, missing)."""
696
+ f = _fraction((v or "").replace(":", "/"))
697
+ return f"{f.numerator}:{f.denominator}" if f else "1:1"
698
+
699
+
700
+ def pixel_aspect(video: Optional[Dict[str, Any]]) -> Fraction:
701
+ """The stored pixel's width over its height (the SAR); 1 when the stream does not say."""
702
+ return _fraction(str((video or {}).get("sample_aspect_ratio") or "").replace(":", "/")) or Fraction(1)
703
+
704
+
705
+ def _square_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int, Fraction]":
706
+ """(width, height, SAR) of the frame the filter graph receives: rotation applied (ffmpeg
707
+ autorotates on decode, and its transpose turns the SAR over too), pixels not yet squared."""
708
+ video = video or {}
709
+ w, h = int(video.get("width") or 0), int(video.get("height") or 0)
710
+ sar = pixel_aspect(video)
711
+ if video.get("rotation") in (90, -90, 270, -270):
712
+ w, h, sar = h, w, 1 / sar
713
+ return w, h, sar
714
+
715
+
716
+ def display_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int]":
717
+ """The displayed frame in square pixels: rotation applied, and a non-square-pixel picture
718
+ widened or narrowed to its display aspect at its own height (1440x1080 at 4:3 is 1920x1080,
719
+ 720x480 at 8:9 is 640x480). It is the frame square_pixels() hands the rest of a graph, so a
720
+ tool sizes, places and draws in it. 0x0 when unknown (a dry run's pending input)."""
721
+ w, h, sar = _square_size(video)
722
+ if w and h and sar != 1:
723
+ w = max(2, int(round(w * sar / 2)) * 2)
724
+ return w, h
725
+
726
+
727
+ def square_pixels(video: Optional[Dict[str, Any]]) -> str:
728
+ """The filter that makes a non-square-pixel picture square at its display aspect
729
+ ("scale=640:360,setsar=1"), or "" when the pixels are square already. A tool that scales,
730
+ crops, places or draws starts its video chain with it, so every such tool agrees on one
731
+ frame (display_size()) and nothing is squashed or drawn stretched (#411). Tools that only
732
+ re-encode keep the stored size and its SAR, which the encoder writes through."""
733
+ w, h, sar = _square_size(video)
734
+ if not (w and h) or sar == 1:
735
+ return ""
736
+ dw, dh = display_size(video)
737
+ return f"scale={dw}:{dh},setsar=1"
738
+
739
+
740
+ def square_pixels_note(video: Optional[Dict[str, Any]], what: str = "the input") -> Optional[str]:
741
+ """The notes line for a square_pixels() scale, or None when there is none."""
742
+ if not square_pixels(video):
743
+ return None
744
+ w, h, _ = _square_size(video)
745
+ dw, dh = display_size(video)
746
+ return (f"{what} stores non-square pixels (SAR {(video or {}).get('sample_aspect_ratio')}): "
747
+ f"its {w}x{h} picture was scaled to {dw}x{dh} square pixels, its display aspect")
748
+
749
+
750
+ def _is_still(fmt: Dict[str, Any], video: Dict[str, Any]) -> bool:
751
+ """A single picture read by an image demuxer (image2, jpeg_pipe, png_pipe, ...)."""
752
+ name = fmt.get("format_name") or ""
753
+ return name == "image2" or name.endswith("_pipe")
754
+
755
+
756
+ def _still_rotation(path: str) -> int:
757
+ """The display rotation of a still's first decoded frame (an EXIF Orientation), 0 if none."""
758
+ proc = run([require_tool("ffprobe"), "-v", "error", *still_input_args(path), "-select_streams", "v:0",
759
+ "-read_intervals", "%+#1", "-show_entries", "frame_side_data=rotation", "-of", "json", path],
760
+ quiet=True, check=False)
761
+ try:
762
+ frames = json.loads(proc.stdout or "{}").get("frames") or []
763
+ for sd in (frames[0].get("side_data_list") or []) if frames else []:
764
+ if "rotation" in sd:
765
+ return int(round(float(sd["rotation"])))
766
+ except (ValueError, TypeError, AttributeError):
767
+ pass
768
+ return 0
769
+
770
+
661
771
  def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
662
772
  if not w or not h:
663
773
  return None
@@ -324,7 +324,8 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
328
+ "source_timecode", "smpte_hint")
328
329
 
329
330
  def __init__(self) -> None:
330
331
  self.reset()
@@ -334,6 +335,8 @@ class Context:
334
335
  self.json = False # emit() prints a JSON document instead of the output path
335
336
  self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
337
  self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
338
+ self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
339
+ self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
337
340
  self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
338
341
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
339
342
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
@@ -1483,6 +1486,40 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
1483
1486
  return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
1484
1487
 
1485
1488
 
1489
+ # eia_608 (a QuickTime c608 caption track) decodes to text, so it converts like one (ffmpeg 6.1)
1490
+ TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text", "eia_608"})
1491
+ MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
1492
+
1493
+
1494
+ def subtitle_codec_for(codec: str, out_ext: str) -> str:
1495
+ """The -c:s for one source subtitle stream going into a container with extension `out_ext`
1496
+ (no dot): copy when the container holds it as it is, else the container's own text format --
1497
+ mov_text in MPEG-4, srt in Matroska for a mov_text track, webvtt in WebM. A bitmap track is
1498
+ returned as copy; subtitle_container_holds() says whether that copy can work."""
1499
+ out_ext = out_ext.lstrip(".").lower()
1500
+ if out_ext == "mov" and codec == "eia_608":
1501
+ return "copy" # QuickTime holds a c608 track as it is; .mp4 has no tag for it
1502
+ if out_ext in MP4_FAMILY and codec in TEXT_SUBTITLES and codec != "mov_text":
1503
+ return "mov_text"
1504
+ if out_ext == "mkv" and codec == "mov_text":
1505
+ return "srt"
1506
+ if out_ext == "webm" and codec in TEXT_SUBTITLES and codec != "webvtt":
1507
+ return "webvtt"
1508
+ return "copy"
1509
+
1510
+
1511
+ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
1512
+ """Whether a subtitle stream of `codec` can be written to an `out_ext` container at all,
1513
+ converted by subtitle_codec_for() where it must be: Matroska takes any, MPEG-4 a text track or
1514
+ DVD bitmaps, WebM only text (as WebVTT)."""
1515
+ out_ext = out_ext.lstrip(".").lower()
1516
+ if out_ext in MP4_FAMILY:
1517
+ return codec in TEXT_SUBTITLES or codec == "dvd_subtitle"
1518
+ if out_ext == "webm":
1519
+ return codec in TEXT_SUBTITLES
1520
+ return True
1521
+
1522
+
1486
1523
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1487
1524
  """Run an ffmpeg command that already maps its video/audio, trying first to also
1488
1525
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
@@ -23,6 +23,7 @@ under `not_exported`, never silently dropped.
23
23
  import json
24
24
  import math
25
25
  import os
26
+ import re
26
27
  from fractions import Fraction
27
28
  from pathlib import Path
28
29
  from typing import Any, Dict, List, Optional, Tuple
@@ -30,6 +31,7 @@ from xml.sax.saxutils import escape as _xml_escape
30
31
 
31
32
  from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
32
33
  from _common.emit import warn
34
+ from _common.probe import display_size
33
35
 
34
36
  FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
35
37
  # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
@@ -101,12 +103,54 @@ def _seconds(value: Any, what: str, fps: Optional[float]) -> float:
101
103
 
102
104
 
103
105
  def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
104
- """A source's displayed width x height (rotation metadata swaps them, as fit.py reads it)."""
105
- video = meta.get("video") or {}
106
- w, h = int(video.get("width") or 0), int(video.get("height") or 0)
106
+ """A source's displayed width x height, as fit.py reads it: rotation metadata swaps them, and
107
+ non-square pixels are squared at the display aspect (probe.display_size(), #411)."""
108
+ w, h = display_size(meta.get("video") or {})
107
109
  if not (w and h):
108
110
  return None
109
- return (h, w) if video.get("rotation") in (90, -90, 270, -270) else (w, h)
111
+ return (w, h)
112
+
113
+
114
+ _TC = re.compile(r"^(\d{2}):(\d{2}):(\d{2})[:;.](\d{2,3})$")
115
+
116
+
117
+ def _drop_frame(drop: Optional[bool], rate: Fraction) -> bool:
118
+ """Drop-frame counting applies to the NTSC rates only (29.97 and 59.94)."""
119
+ return bool(drop) and rate.denominator == 1001 and round(float(rate)) in (30, 60)
120
+
121
+
122
+ def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
123
+ """A source's start timecode (probe()'s `timecode`: {value, drop_frame}) as a frame count at
124
+ its own `rate`, drop-frame counted where it is one; 0 when the source has none (#414). A
125
+ label that does not exist at that rate (frame 29 at 25 fps, or a frame drop-frame counting
126
+ skips) is read as none, as probe() skips a malformed tag, rather than as a shifted start."""
127
+ m = _TC.match(str((tc or {}).get("value") or "").strip())
128
+ if not m:
129
+ return 0
130
+ h, mi, se, fr = (int(g) for g in m.groups())
131
+ nominal = int(round(float(rate)))
132
+ if mi > 59 or se > 59 or fr >= nominal:
133
+ return 0
134
+ frames = ((h * 60 + mi) * 60 + se) * nominal + fr
135
+ if _drop_frame(tc.get("drop_frame"), rate):
136
+ if se == 0 and mi % 10 and fr < nominal // 15:
137
+ return 0
138
+ minutes = h * 60 + mi
139
+ frames -= (nominal // 15) * (minutes - minutes // 10)
140
+ return frames
141
+
142
+
143
+ def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
144
+ """`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
145
+ whose own timecode is drop-frame, so the label is the one the media itself shows."""
146
+ nominal = int(round(float(rate)))
147
+ if not _drop_frame(drop, rate):
148
+ return _tc(frames, nominal)
149
+ d = nominal // 15
150
+ per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
151
+ tens, rem = divmod(frames, per_10)
152
+ frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
153
+ return _tc(frames, nominal)[:-3] + ";" + _tc(frames, nominal)[-2:]
110
154
 
111
155
 
112
156
  def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
@@ -171,6 +215,14 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
171
215
  if start < 0:
172
216
  raise TimelineError(f"clips[{i}].in must not be negative, got {c['in']!r}")
173
217
  end = _seconds(c["out"], f"clips[{i}].out", clip_fps) if c.get("out") is not None else dur
218
+ # as cut.py reads them (#414): an in at or past the media's end is refused, an out past it
219
+ # is the media's end -- the render clamps it there, so the timeline does too, and says so
220
+ if dur > 0 and start >= dur:
221
+ raise TimelineError(f"clips[{i}].in ({start:g}s) is at or past the end of {os.path.basename(src)} ({dur:.3f}s)")
222
+ if dur > 0 and end > dur + 1e-6:
223
+ notes.append(f"clips[{i}].out ({end:g}s) is past the end of {os.path.basename(src)} ({dur:.3f}s): "
224
+ "the clip ends at the media's end, as the render cuts it")
225
+ end = dur
174
226
  if end <= start:
175
227
  raise TimelineError(f"clip {i}: out ({end:g}) is not after in ({start:g})")
176
228
  speed = _number(c.get("speed") or 1.0, f"clips[{i}].speed")
@@ -181,8 +233,17 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
181
233
  if reframe:
182
234
  reframed.append(os.path.basename(src))
183
235
  src_in, src_out = fr(start), fr(end)
236
+ # the source's own clock (#414): its frame rate, and its start timecode as a frame count at
237
+ # that rate. EDL source timecodes are counted at it (a 25 fps clip in a 29.97 sequence has
238
+ # no frame :29); FCPXML and OTIO start the media at it, so a conform finds the frames.
239
+ clip_rate = exact_rate(float(clip_fps)) if clip_fps else rate
240
+ tc = meta.get("timecode") or {}
241
+ tc_start = start_tc_frames(tc, clip_rate)
184
242
  clips.append({"index": i, "src": os.path.abspath(src), "name": os.path.basename(src),
185
243
  "src_in": src_in, "src_out": src_out, "speed": speed,
244
+ "in_seconds": Fraction(start).limit_denominator(1000000), "rate": clip_rate,
245
+ "tc_start": tc_start, "tc_seconds": Fraction(tc_start) / clip_rate,
246
+ "drop_frame": _drop_frame(tc.get("drop_frame"), clip_rate),
186
247
  "length": int(round((src_out - src_in) / speed)),
187
248
  "media_frames": fr(dur), "has_video": bool(meta.get("video")),
188
249
  "has_audio": bool(meta.get("audio")),
@@ -218,12 +279,24 @@ def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[
218
279
  markers: List[Dict[str, Any]] = []
219
280
  chapters = proj.get("chapters")
220
281
  if isinstance(chapters, list):
282
+ total_seconds = Fraction(total) / rate
283
+ previous: Optional[float] = None
221
284
  for j, ch in enumerate(chapters):
222
285
  # read as render.py's chapters stage reads it (metadata.py): a chapter list carries
223
- # no fps, so hh:mm:ss:ff needs its @fps there and here alike
224
- at = fr(_seconds(ch.get("at", 0), f"chapters[{j}].at", None))
225
- if 0 <= at < total:
226
- markers.append({"at": at, "title": str(ch.get("title") or "")})
286
+ # no fps, so hh:mm:ss:ff needs its @fps there and here alike. What metadata.py refuses
287
+ # is refused here too, never dropped (#414): a chapter at or past the end, or one that
288
+ # does not come after the one before it.
289
+ at_s = _seconds(ch.get("at", 0), f"chapters[{j}].at", None)
290
+ if at_s < 0:
291
+ raise TimelineError(f"chapters[{j}].at must not be negative, got {ch.get('at')!r}")
292
+ if previous is not None and at_s <= previous:
293
+ raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s does not come after the previous one "
294
+ f"at {previous:g}s (metadata.py refuses it too)")
295
+ if at_s >= total_seconds:
296
+ raise TimelineError(f"chapters[{j}]: the chapter at {at_s:g}s starts at or after the end of the "
297
+ f"timeline ({float(total_seconds):.3f}s); metadata.py refuses it too")
298
+ previous = at_s
299
+ markers.append({"at": min(fr(at_s), total - 1), "title": str(ch.get("title") or "")})
227
300
  elif chapters:
228
301
  notes.append("chapters given as a file are not exported as markers; write them inline as [{at, title}]")
229
302
 
@@ -282,27 +355,38 @@ def to_edl(tl: Dict[str, Any]) -> str:
282
355
  ends = starts[1:] + [tl["total"]]
283
356
  lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
284
357
  n = 0
285
- prev_src_end = 0
358
+ prev_src_end = ""
359
+ rate = tl["rate"]
360
+
361
+ def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
362
+ """The source timecode `seq_frames` (sequence frames of source media) after the clip's in
363
+ point: counted at the clip's own rate from its own start timecode (#414)."""
364
+ own = c.get("rate") or rate
365
+ seconds = c.get("in_seconds", Fraction(c["src_in"]) / rate) + Fraction(seq_frames) / rate
366
+ return _tc_label(int(round(seconds * own)) + c.get("tc_start", 0), own, c.get("drop_frame", False))
367
+
286
368
  for i, c in enumerate(clips):
287
369
  n += 1
288
370
  chan = "B" if c["has_audio"] and c["has_video"] else ("V" if c["has_video"] else "A")
289
371
  rec_in, rec_out = hour + starts[i], hour + ends[i]
290
- s_in = c["src_in"]
291
- s_out = s_in + int(round((ends[i] - starts[i]) * c["speed"]))
372
+ s_in = src_tc(c, 0)
373
+ s_out = src_tc(c, int(round((ends[i] - starts[i]) * c["speed"])))
292
374
  if i > 0 and d:
293
- lines.append(f"{n:03d} AX {chan:<5} C {_tc(prev_src_end, fps_int)} {_tc(prev_src_end, fps_int)} "
375
+ lines.append(f"{n:03d} AX {chan:<5} C {prev_src_end} {prev_src_end} "
294
376
  f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
295
- lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
377
+ lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
296
378
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
297
379
  lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
298
380
  lines.append(f"* TO CLIP NAME: {c['name']}")
299
381
  else:
300
- lines.append(f"{n:03d} AX {chan:<5} C {_tc(s_in, fps_int)} {_tc(s_out, fps_int)} "
382
+ lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
301
383
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
302
384
  lines.append(f"* FROM CLIP NAME: {c['name']}")
303
385
  lines.append(f"* SOURCE FILE: {c['src']}")
304
386
  if abs(c["speed"] - 1.0) > 1e-6:
305
- lines.append(f"M2 AX {fps_int * c['speed']:05.1f} {_tc(s_in, fps_int)}")
387
+ # the speed in source frames per second, at the rate the source labels are counted in
388
+ own_fps = int(round(float(c.get("rate") or rate)))
389
+ lines.append(f"M2 AX {own_fps * c['speed']:05.1f} {s_in}")
306
390
  lines.append("")
307
391
  prev_src_end = s_out
308
392
  if tl["music"]:
@@ -325,6 +409,11 @@ def _t(frames: int, rate: Fraction) -> str:
325
409
  return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
326
410
 
327
411
 
412
+ def _ts(value: Fraction) -> str:
413
+ """A rational FCPXML time from seconds."""
414
+ return f"{value.numerator}s" if value.denominator == 1 else f"{value.numerator}/{value.denominator}s"
415
+
416
+
328
417
  def _url(path: str) -> str:
329
418
  # pathlib builds the percent-encoded file:// URL itself (and the drive-letter form on
330
419
  # Windows): no network-library import in scripts/, not even for quoting (see the no-fetch test)
@@ -360,12 +449,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
360
449
  return seq_fmt
361
450
  return fmt(c["picture"][0], c["picture"][1], _t(1, exact_rate(float(c["fps"]))) if c["fps"] else fd)
362
451
 
363
- def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "") -> str:
452
+ def asset(path: str, name: str, frames: int, has_video: bool, has_audio: bool, format_id: str = "",
453
+ start: Fraction = Fraction(0)) -> str:
364
454
  if path in ids:
365
455
  return ids[path]
366
456
  rid = f"r{len(ids) + 1}"
367
457
  ids[path] = rid
368
- res.append(f' <asset id="{rid}" name="{q(name)}" start="0s" duration="{_t(frames, rate)}" '
458
+ # the media starts at its own start timecode (#414): a clip's start is counted from there
459
+ res.append(f' <asset id="{rid}" name="{q(name)}" start="{_ts(start)}" duration="{_t(frames, rate)}" '
369
460
  f'hasVideo="{int(has_video)}" hasAudio="{int(has_audio)}"'
370
461
  + (f' format="{format_id}"' if has_video else "") + ' audioSources="1" audioChannels="2">\n'
371
462
  f' <media-rep kind="original-media" src="{q(_url(path))}"/>\n </asset>')
@@ -376,7 +467,8 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
376
467
  markers = list(tl["markers"])
377
468
  for i, c in enumerate(tl["clips"]):
378
469
  cid = clip_fmt(c)
379
- rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid)
470
+ base = c.get("tc_seconds", Fraction(0))
471
+ rid = asset(c["src"], c["name"], c["media_frames"], c["has_video"], c["has_audio"], cid, base)
380
472
  if i > 0 and d:
381
473
  spine.append(f' <transition name="Cross Dissolve" offset="{_t(c["record_in"] - c["trim_head"], rate)}" '
382
474
  f'duration="{_t(d, rate)}"/>')
@@ -385,14 +477,15 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
385
477
  if abs(c["speed"] - 1.0) > 1e-6:
386
478
  # timeMap maps clip-local time to source time; two linear points give a constant speed
387
479
  inner.append(" <timeMap>")
388
- inner.append(f' <timept time="0s" value="{_t(src_start, rate)}" interp="linear"/>')
480
+ inner.append(f' <timept time="0s" value="{_ts(base + Fraction(src_start) / rate)}" interp="linear"/>')
389
481
  inner.append(f' <timept time="{_t(c["visible"], rate)}" '
390
- f'value="{_t(src_start + int(round(c["visible"] * c["speed"])), rate)}" interp="linear"/>')
482
+ f'value="{_ts(base + Fraction(src_start + int(round(c["visible"] * c["speed"]))) / rate)}" interp="linear"/>')
391
483
  inner.append(" </timeMap>")
392
484
  start_attr = "0s"
393
485
  else:
394
- start_attr = _t(src_start, rate)
395
- local0 = src_start if abs(c["speed"] - 1.0) <= 1e-6 else 0
486
+ start_attr = _ts(base + Fraction(src_start) / rate)
487
+ # the clip's local time: the source's (from its start timecode), or 0 under a timeMap
488
+ local0 = base + Fraction(src_start) / rate if abs(c["speed"] - 1.0) <= 1e-6 else Fraction(0)
396
489
  # the FCPXML 1.10 DTD's order inside an asset-clip: timeMap, then the adjust-* elements
397
490
  # (adjust-conform), then anchored items (the music bed), then markers. Anything out of
398
491
  # that order does not validate, and Final Cut validates what it imports against that DTD.
@@ -403,13 +496,14 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
403
496
  if i == 0 and tl["music"]:
404
497
  m = tl["music"]
405
498
  mid = asset(m["src"], m["name"], m["media_frames"], False, True)
406
- inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_t(local0, rate)}" name="{q(m["name"])}" '
499
+ inner.append(f' <asset-clip ref="{mid}" lane="-1" offset="{_ts(local0)}" name="{q(m["name"])}" '
407
500
  f'start="0s" duration="{_t(m["length"], rate)}" audioRole="music"/>')
408
501
  for mk in [m for m in markers if c["record_in"] <= m["at"] < c["record_in"] + c["visible"]]:
409
- inner.append(f' <chapter-marker start="{_t(local0 + mk["at"] - c["record_in"], rate)}" '
502
+ inner.append(f' <chapter-marker start="{_ts(local0 + Fraction(mk["at"] - c["record_in"]) / rate)}" '
410
503
  f'duration="{fd}" value="{q(mk["title"])}"/>')
411
504
  head = (f' <asset-clip ref="{rid}" offset="{_t(c["record_in"], rate)}" name="{q(c["name"])}" '
412
- f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" tcFormat="NDF"')
505
+ f'start="{start_attr}" duration="{_t(c["visible"], rate)}" format="{cid}" '
506
+ f'tcFormat="{"DF" if c.get("drop_frame") else "NDF"}"')
413
507
  spine.append(head + (">\n" + "\n".join(inner) + "\n </asset-clip>" if inner else "/>"))
414
508
  out = ['<?xml version="1.0" encoding="UTF-8"?>', "<!DOCTYPE fcpxml>", '<fcpxml version="1.10">',
415
509
  " <resources>", *res, " </resources>", " <library>", f' <event name="{q(tl["name"])}">',
@@ -443,15 +537,17 @@ def to_otio(tl: Dict[str, Any]) -> str:
443
537
  # OTIO: in_offset is the part before the cut, out_offset the part after
444
538
  "in_offset": _rt(c["trim_head"], rate), "out_offset": _rt(tl["clips"][i - 1]["trim_tail"], rate)})
445
539
  src_start = c["src_in"] + int(round(c["trim_head"] * c["speed"]))
540
+ # the media's range starts at its start timecode (#414), in timeline frames
541
+ base = c.get("tc_seconds", Fraction(0)) * rate
446
542
  clip = {"OTIO_SCHEMA": "Clip.2", "name": c["name"], "metadata": {}, "enabled": True,
447
543
  # OTIO's own convention: source_range starts at the media's in point and lasts the
448
544
  # clip's length on the timeline; a LinearTimeWarp says how fast the media runs
449
545
  # through it (core OTIO does not rescale durations by effects).
450
- "source_range": _range(src_start, c["visible"], rate),
546
+ "source_range": _range(base + src_start, c["visible"], rate),
451
547
  "effects": [], "markers": [],
452
548
  "media_references": {"DEFAULT_MEDIA": {
453
549
  "OTIO_SCHEMA": "ExternalReference.1", "name": c["name"], "metadata": {},
454
- "target_url": _url(c["src"]), "available_range": _range(0, c["media_frames"], rate),
550
+ "target_url": _url(c["src"]), "available_range": _range(base, c["media_frames"], rate),
455
551
  "available_image_bounds": None}},
456
552
  "active_media_reference_key": "DEFAULT_MEDIA"}
457
553
  if abs(c["speed"] - 1.0) > 1e-6: