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.
- package/docs/contract.md +11 -7
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +12 -6
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +115 -5
- package/scripts/_common/runner.py +38 -1
- package/scripts/_common/timeline.py +123 -27
- package/scripts/_contract.py +34 -3
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +105 -19
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +16 -15
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +91 -35
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/_common/fonts.py
CHANGED
|
@@ -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
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
w, h =
|
|
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 (
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 =
|
|
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
|
|
291
|
-
s_out =
|
|
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 {
|
|
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} {
|
|
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 {
|
|
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
|
-
|
|
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 = ""
|
|
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
|
-
|
|
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
|
-
|
|
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="{
|
|
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="{
|
|
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 =
|
|
395
|
-
|
|
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="{
|
|
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="{
|
|
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}"
|
|
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(
|
|
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:
|