ffmpeg-skill 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/SKILL.md +5 -5
- package/docs/contract.md +28 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +264 -4
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/text.py +142 -2
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +150 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +289 -24
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
package/scripts/cut.py
CHANGED
|
@@ -25,11 +25,13 @@ Examples:
|
|
|
25
25
|
python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
|
|
26
26
|
"""
|
|
27
27
|
import argparse
|
|
28
|
+
import json
|
|
28
29
|
import os
|
|
29
30
|
import sys
|
|
30
31
|
import tempfile
|
|
31
32
|
from typing import List, Tuple
|
|
32
33
|
|
|
34
|
+
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
33
35
|
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
36
|
|
|
35
37
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -148,6 +150,123 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
148
150
|
return reencode
|
|
149
151
|
|
|
150
152
|
|
|
153
|
+
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
157
|
+
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
158
|
+
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
159
|
+
try:
|
|
160
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
161
|
+
doc = json.load(fh)
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
doc = None
|
|
164
|
+
if isinstance(doc, dict) and doc.get("beat_grid"):
|
|
165
|
+
grid = dict(doc["beat_grid"])
|
|
166
|
+
grid["beats"] = doc.get("beats") or []
|
|
167
|
+
# A scenes.py document carries the supported subset since 1.17; one written by an older
|
|
168
|
+
# build does not, and a grid whose supported points are unknown is not one this tool may
|
|
169
|
+
# move a cut onto -- an unknown subset is not an empty one, but it is not a measurement
|
|
170
|
+
# either, so it is refused rather than silently treated as "all of them".
|
|
171
|
+
grid["supported_beats"] = doc.get("beat_grid", {}).get("supported_beats")
|
|
172
|
+
if grid["supported_beats"] is None:
|
|
173
|
+
grid["supported_beats"] = doc.get("supported_beats")
|
|
174
|
+
try:
|
|
175
|
+
tempo = grid.get("tempo_bpm")
|
|
176
|
+
grid["tempo_bpm"] = float(tempo) if tempo is not None else None
|
|
177
|
+
grid["confidence"] = float(grid.get("confidence") or 0.0)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
die(f"--snap-source {path}: beat_grid.tempo_bpm and .confidence must be numbers "
|
|
180
|
+
"(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
|
|
181
|
+
if grid["beats"] and grid["tempo_bpm"] is None:
|
|
182
|
+
die(f"--snap-source {path}: this document lists beats but no tempo_bpm, so no grid "
|
|
183
|
+
"was actually measured in it. Regenerate it with "
|
|
184
|
+
"`scenes.py MUSIC --beats --json`.", kind="input")
|
|
185
|
+
grid["usable"] = grid["confidence"] >= min_confidence
|
|
186
|
+
return grid
|
|
187
|
+
if isinstance(doc, dict):
|
|
188
|
+
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
189
|
+
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
190
|
+
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
191
|
+
env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
|
|
192
|
+
return beat_grid(env, 0.01, min_confidence=min_confidence)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def snap_segments(args, segments, meta, total):
|
|
196
|
+
"""Move every in/out point to the nearest measured beat. Returns (result dict, segments).
|
|
197
|
+
|
|
198
|
+
A cut point may move to a measured, onset-supported grid point and may not appear from one:
|
|
199
|
+
the number of segments is unchanged, and nothing is ever proposed. The keyframe/tolerance
|
|
200
|
+
decision downstream then runs on the snapped values, which is the right order -- whether a cut
|
|
201
|
+
can be lossless depends on where it actually lands.
|
|
202
|
+
"""
|
|
203
|
+
source = args.snap_source or args.input
|
|
204
|
+
if not args.snap_source and not meta.get("audio"):
|
|
205
|
+
die("--snap beats needs audio to measure a beat in; this file has none. Cut without it "
|
|
206
|
+
"(--snap none), or pass --snap-source with the music bed.", kind="input")
|
|
207
|
+
# Compare the PARSED segments against the whole file, not the raw --start string: "0:00",
|
|
208
|
+
# "0.0" and "00:00:00" are all a zero start that a string comparison lets through, and the
|
|
209
|
+
# run would then snap the implicit end point and silently shorten a whole-file copy.
|
|
210
|
+
whole_file = (len(segments) == 1 and abs(segments[0][0]) < 1e-6
|
|
211
|
+
and (not total or abs(segments[0][1] - total) < 1e-6))
|
|
212
|
+
if whole_file:
|
|
213
|
+
die("--snap beats has no in or out point to move: this run copies the whole file. Give "
|
|
214
|
+
"--start/--end (or --segments), or drop --snap.", kind="input")
|
|
215
|
+
# A floor of zero would make the confidence check vacuous -- a grid measured from noise scores
|
|
216
|
+
# above 0.0 and would pass -- and the whole point of the flag is that a cut only moves onto a
|
|
217
|
+
# pulse somebody can hear. The number is a floor on belief, so it must be a positive one.
|
|
218
|
+
if args.min_confidence <= 0:
|
|
219
|
+
die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
|
|
220
|
+
"one measured from noise, which is exactly what --snap beats must not cut to. Use "
|
|
221
|
+
"--snap none if you do not want the points moved at all.", kind="input")
|
|
222
|
+
grid = _grid_from_source(source, args.min_confidence)
|
|
223
|
+
confidence = float(grid.get("confidence") or 0.0)
|
|
224
|
+
tempo = grid.get("tempo_bpm")
|
|
225
|
+
if confidence < args.min_confidence or not grid.get("beats"):
|
|
226
|
+
die(f"no reliable beat grid in this audio (confidence {confidence:.2f}, needs "
|
|
227
|
+
f"{args.min_confidence:.2f}): cutting to invented beats would move your in/out points "
|
|
228
|
+
"to times nothing in the audio supports. Re-run with --snap none, or pass "
|
|
229
|
+
"--snap-source from a music bed.", kind="input")
|
|
230
|
+
# THE grid a cut may move onto is the onset-supported subset, never the full regular grid.
|
|
231
|
+
# beat_grid() reports a regular grid over the whole duration by design -- a grid has to be
|
|
232
|
+
# regular -- so it runs on through a passage with no music in it. Snapping to one of those
|
|
233
|
+
# points moves a cut to a time nothing in the audio marks, which is the fabrication this
|
|
234
|
+
# release forbids and which this tool's own refusal text promises it does not do.
|
|
235
|
+
supported = grid.get("supported_beats")
|
|
236
|
+
if supported is None:
|
|
237
|
+
die(f"--snap-source {source}: this document does not say which grid points a measured "
|
|
238
|
+
"onset supports, so there is no way to tell a beat from a gap in it. Regenerate it "
|
|
239
|
+
"with `scenes.py MUSIC --beats --json`.", kind="input")
|
|
240
|
+
if not supported:
|
|
241
|
+
die(f"no measured onset supports any point of this beat grid (confidence "
|
|
242
|
+
f"{confidence:.2f}): the grid is regular but nothing in the audio marks it, so every "
|
|
243
|
+
"move would be to an invented time. Re-run with --snap none, or pass --snap-source "
|
|
244
|
+
"from a music bed.", kind="input")
|
|
245
|
+
points = [t for seg in segments for t in seg]
|
|
246
|
+
moved = snap_points(points, supported, args.snap_tolerance)
|
|
247
|
+
out_segments = []
|
|
248
|
+
for i in range(0, len(moved), 2):
|
|
249
|
+
s, e = moved[i]["to"], moved[i + 1]["to"]
|
|
250
|
+
if e <= s: # a snap that would collapse the segment is not applied to it
|
|
251
|
+
s, e = moved[i]["from"], moved[i + 1]["from"]
|
|
252
|
+
moved[i].update({"to": s, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
253
|
+
moved[i + 1].update({"to": e, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
254
|
+
out_segments.append((s, e))
|
|
255
|
+
snapped = sum(1 for m in moved if m["snapped"])
|
|
256
|
+
for m in moved:
|
|
257
|
+
if m["snapped"]:
|
|
258
|
+
info(f"--snap beats: {m['from']:.3f}s -> {m['to']:.3f}s ({m['delta'] * 1000:+.0f} ms)")
|
|
259
|
+
info(f"--snap beats: {tempo:.1f} BPM, confidence {confidence:.2f}; {snapped} of {len(moved)} "
|
|
260
|
+
f"point(s) moved, within {args.snap_tolerance:.3f}s, onto {len(supported)} of "
|
|
261
|
+
f"{len(grid['beats'])} grid point(s) a measured onset supports")
|
|
262
|
+
return ({"mode": "beats", "tolerance": args.snap_tolerance, "confidence": confidence,
|
|
263
|
+
"tempo_bpm": tempo, "grid": "supported", "grid_points": len(supported),
|
|
264
|
+
"moved": [dict(m) for m in moved], "snapped": snapped,
|
|
265
|
+
"unchanged": len(moved) - snapped,
|
|
266
|
+
"source": "measured" if not args.snap_source else args.snap_source},
|
|
267
|
+
out_segments)
|
|
268
|
+
|
|
269
|
+
|
|
151
270
|
def main() -> int:
|
|
152
271
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
153
272
|
ap.add_argument("input")
|
|
@@ -159,6 +278,17 @@ def main() -> int:
|
|
|
159
278
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
160
279
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
161
280
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
|
|
281
|
+
snap = ap.add_argument_group("beat snapping")
|
|
282
|
+
snap.add_argument("--snap", choices=["none", "beats"], default="none",
|
|
283
|
+
help="move each in/out point to the nearest measured beat (default none)")
|
|
284
|
+
snap.add_argument("--snap-tolerance", type=float, default=0.12,
|
|
285
|
+
help="most seconds a point may move with --snap beats (default 0.12, about a "
|
|
286
|
+
"quarter of a beat at 120 BPM)")
|
|
287
|
+
snap.add_argument("--snap-source", metavar="FILE",
|
|
288
|
+
help="take the beat grid from this scenes.py --beats --json document (or from "
|
|
289
|
+
"this media file) instead of measuring the input again")
|
|
290
|
+
snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
|
|
291
|
+
help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
|
|
162
292
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
163
293
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
|
|
164
294
|
add_common(ap)
|
|
@@ -196,6 +326,10 @@ def main() -> int:
|
|
|
196
326
|
die("end must be after start")
|
|
197
327
|
segments = [(start, end)]
|
|
198
328
|
|
|
329
|
+
snap_result = None
|
|
330
|
+
if args.snap == "beats":
|
|
331
|
+
snap_result, segments = snap_segments(args, segments, meta, total)
|
|
332
|
+
|
|
199
333
|
for s, e in segments:
|
|
200
334
|
if total and s >= total:
|
|
201
335
|
die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
|
|
@@ -249,7 +383,8 @@ def main() -> int:
|
|
|
249
383
|
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|
|
250
384
|
lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
|
|
251
385
|
f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
|
|
252
|
-
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None
|
|
386
|
+
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
|
|
387
|
+
snap=snap_result)
|
|
253
388
|
return 0
|
|
254
389
|
|
|
255
390
|
|
package/scripts/render.py
CHANGED
|
@@ -52,16 +52,35 @@ Examples:
|
|
|
52
52
|
"""
|
|
53
53
|
import argparse
|
|
54
54
|
import difflib
|
|
55
|
+
import hashlib
|
|
55
56
|
import re
|
|
56
57
|
import json
|
|
57
58
|
import os
|
|
59
|
+
import shutil
|
|
58
60
|
import sys
|
|
61
|
+
import time
|
|
59
62
|
from pathlib import Path
|
|
60
|
-
from typing import Any, Dict, List
|
|
63
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
61
64
|
|
|
62
65
|
from export import PRESETS, PLATFORM_OF
|
|
63
66
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
64
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
|
|
67
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
68
|
+
import subprocess
|
|
69
|
+
from _contract import CONTRACT_VERSION
|
|
70
|
+
from batch import file_key
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _skill_version() -> str:
|
|
74
|
+
"""The shipped version, from package.json -- in the cache key so a stage whose implementation
|
|
75
|
+
changed cannot serve back an artifact the old one wrote."""
|
|
76
|
+
try:
|
|
77
|
+
return str(json.loads((Path(__file__).resolve().parent.parent / "package.json")
|
|
78
|
+
.read_text(encoding="utf-8")).get("version") or "?")
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
return "?"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
SKILL_VERSION = _skill_version()
|
|
65
84
|
|
|
66
85
|
HERE = Path(__file__).resolve().parent
|
|
67
86
|
TEMPLATE_DIR = HERE.parent / "templates"
|
|
@@ -97,8 +116,10 @@ TEMPLATE = {
|
|
|
97
116
|
OBJECT_KEYS: Dict[str, frozenset] = {
|
|
98
117
|
"project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
|
|
99
118
|
"graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters",
|
|
100
|
-
"audiogram", "template"}),
|
|
101
|
-
"clips[]": frozenset({"src", "in", "out", "speed"}),
|
|
119
|
+
"audiogram", "template", "snap"}),
|
|
120
|
+
"clips[]": frozenset({"src", "in", "out", "speed", "snap"}),
|
|
121
|
+
# 1.17: beat snapping, forwarded to cut.py for any clip that has in/out
|
|
122
|
+
"snap": frozenset({"to", "tolerance", "min_confidence", "source"}),
|
|
102
123
|
"frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
|
|
103
124
|
"transition": frozenset({"type", "duration"}),
|
|
104
125
|
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
@@ -359,7 +380,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
|
359
380
|
|
|
360
381
|
def validate_project(proj: Dict[str, Any]) -> None:
|
|
361
382
|
check_keys(proj, "project", "project")
|
|
362
|
-
for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check"):
|
|
383
|
+
for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check", "snap"):
|
|
363
384
|
check_keys(proj.get(name), name, name)
|
|
364
385
|
check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
|
|
365
386
|
if isinstance(proj.get("chapters"), list):
|
|
@@ -374,10 +395,55 @@ def validate_project(proj: Dict[str, Any]) -> None:
|
|
|
374
395
|
if isinstance(items, list):
|
|
375
396
|
for i, item in enumerate(items):
|
|
376
397
|
check_keys(item, f"{name}[]", f"{name}[{i}]")
|
|
398
|
+
if name == "clips" and isinstance(item, dict) and item.get("snap") is not None:
|
|
399
|
+
check_keys(item["snap"], "snap", f"clips[{i}].snap")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _refuse_uncached_earlier_stage(stage: str) -> None:
|
|
406
|
+
"""--from STAGE promises the earlier stages come from the cache. When one does not, say so
|
|
407
|
+
rather than quietly re-encoding the thing the caller asked to skip."""
|
|
408
|
+
target = CACHE.get("from")
|
|
409
|
+
if not target or stage not in STAGE_ORDER or target not in STAGE_ORDER:
|
|
410
|
+
return
|
|
411
|
+
if STAGE_ORDER.index(stage) < STAGE_ORDER.index(target):
|
|
412
|
+
die(f"--from {target}: the {stage} stage is not in {CACHE['dir']} for this project and "
|
|
413
|
+
"these inputs, so there is nothing to start from. Render once without --from to fill "
|
|
414
|
+
"the cache (note that a different ffmpeg build or skill version never reuses one).",
|
|
415
|
+
kind="input")
|
|
416
|
+
|
|
377
417
|
|
|
418
|
+
def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> str:
|
|
419
|
+
"""Run a sibling script, forwarding --fast / --dry-run, returning its printed output path.
|
|
378
420
|
|
|
379
|
-
|
|
380
|
-
|
|
421
|
+
With --cache and a named `stage`, an identical stage that ran before is served from the
|
|
422
|
+
cache instead of re-encoded. `stages_done` is unchanged either way: a cached stage is still
|
|
423
|
+
a stage that happened.
|
|
424
|
+
"""
|
|
425
|
+
full = [str(a) for a in argv] + (extra or [])
|
|
426
|
+
dest = full[full.index("-o") + 1] if "-o" in full[:-1] else None
|
|
427
|
+
key = None
|
|
428
|
+
if stage and CACHE.get("dir") and dest:
|
|
429
|
+
inputs = [a for a in full if os.path.exists(a) and a != dest]
|
|
430
|
+
# The destination is where this stage's answer goes, not part of the question: hashing it
|
|
431
|
+
# would make a second run with the first run's output already on disk miss every time.
|
|
432
|
+
key_args = ["<out>" if a == dest else a for a in full]
|
|
433
|
+
key = cache_key(stage, script, key_args, inputs, dest)
|
|
434
|
+
if cache_lookup(stage, key, dest):
|
|
435
|
+
CACHE["hits"].append(stage)
|
|
436
|
+
info(f"→ {script} {stage}: served from --cache")
|
|
437
|
+
# No child ran, so there is no document: say so rather than leaving the PREVIOUS
|
|
438
|
+
# child's document standing, which a caller reading _LAST_DOC would misattribute.
|
|
439
|
+
_LAST_DOC.clear()
|
|
440
|
+
_LAST_DOC["cached"] = True
|
|
441
|
+
return dest
|
|
442
|
+
CACHE["misses"].append(stage)
|
|
443
|
+
_refuse_uncached_earlier_stage(stage)
|
|
444
|
+
elif stage and CACHE.get("dir"):
|
|
445
|
+
CACHE["misses"].append(stage)
|
|
446
|
+
started = time.time()
|
|
381
447
|
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
|
|
382
448
|
info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
|
|
383
449
|
proc = run_tool(cmd)
|
|
@@ -397,7 +463,136 @@ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
|
397
463
|
extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
|
|
398
464
|
die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
|
|
399
465
|
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
|
|
400
|
-
|
|
466
|
+
_LAST_DOC.clear()
|
|
467
|
+
_LAST_DOC.update(doc if isinstance(doc, dict) else {})
|
|
468
|
+
out_path = str(doc.get("output") or "")
|
|
469
|
+
if key:
|
|
470
|
+
cache_store(stage, key, out_path or (dest or ""), time.time() - started)
|
|
471
|
+
return out_path
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------ the stage cache (1.17)
|
|
475
|
+
#
|
|
476
|
+
# Opt-in only: --cache DIR. There is no default directory -- a cache that appears on someone's
|
|
477
|
+
# disk without being asked for is a surprise, and this tool's posture is that a plan leaves
|
|
478
|
+
# nothing behind.
|
|
479
|
+
|
|
480
|
+
STAGE_ORDER = ("clips", "audiogram", "join", "silence", "fit", "captions", "graphics",
|
|
481
|
+
"overlays", "audio", "loudness", "export", "chapters")
|
|
482
|
+
|
|
483
|
+
def _fresh_cache() -> "Dict[str, Any]":
|
|
484
|
+
return {"dir": None, "ffmpeg": None, "hits": [], "misses": [], "saved_seconds": 0.0,
|
|
485
|
+
"entries": 0, "would_hit": [], "from": None}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# Module-level so sh() can reach it without threading a parameter through every stage. main()
|
|
489
|
+
# resets it on entry, so two renders in one process (a test session, an embedding caller) do not
|
|
490
|
+
# inherit each other's hit/miss lists.
|
|
491
|
+
CACHE: Dict[str, Any] = _fresh_cache()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _content_hash(path: str) -> str:
|
|
495
|
+
"""The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
|
|
496
|
+
MB. Reused rather than reinvented so one file has one identity across the skill."""
|
|
497
|
+
try:
|
|
498
|
+
return file_key(Path(path))
|
|
499
|
+
except (OSError, ValueError):
|
|
500
|
+
return "missing"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def ffmpeg_banner() -> str:
|
|
504
|
+
"""The whole `ffprobe -version` first line, not just major.minor.
|
|
505
|
+
|
|
506
|
+
Two 7.1.x builds with different libx264 produce different bytes from the same command, and
|
|
507
|
+
the cache exists to hand back bytes. major.minor cannot tell them apart, so the banner --
|
|
508
|
+
which carries the build string and the configuration's version suffix -- is what goes in the
|
|
509
|
+
key. Unreadable falls back to the parsed pair, which still separates the major releases.
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE,
|
|
513
|
+
stderr=subprocess.DEVNULL, text=True, timeout=20).stdout
|
|
514
|
+
first = (out or "").strip().splitlines()
|
|
515
|
+
if first:
|
|
516
|
+
return first[0].strip()
|
|
517
|
+
except (OSError, subprocess.SubprocessError):
|
|
518
|
+
pass
|
|
519
|
+
return ".".join(str(n) for n in ffmpeg_version())
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[str]",
|
|
523
|
+
dest: "Optional[str]" = None) -> str:
|
|
524
|
+
"""sha1 of a canonical description of exactly what this stage is about to do.
|
|
525
|
+
|
|
526
|
+
The ffmpeg build banner and the skill version are IN the key, deliberately: a different build
|
|
527
|
+
simply misses rather than being asked to trust an artifact it did not write, and a stage whose
|
|
528
|
+
implementation changed must not serve an old one back.
|
|
529
|
+
|
|
530
|
+
`child_args()` is in the key too, and that is not a detail. render.py appends it to every
|
|
531
|
+
stage command AFTER the arguments the stage itself built, and it carries `--fast` -- which
|
|
532
|
+
rewrites the child's preset to veryfast. Without it in the key, `render --cache C --fast`
|
|
533
|
+
stored a draft and the next `render --cache C` served that draft back as the delivery, with
|
|
534
|
+
`cache.hits` presenting it as a legitimate reuse.
|
|
535
|
+
|
|
536
|
+
The output's extension is in the key as well (#15): the artifact is stored as `<key><ext>`
|
|
537
|
+
while the sidecar is `<key>.json`, so two runs differing only in container would otherwise
|
|
538
|
+
share one sidecar and invalidate each other on every run.
|
|
539
|
+
"""
|
|
540
|
+
payload = {
|
|
541
|
+
"stage": stage, "tool": script,
|
|
542
|
+
"args": [_content_hash(str(a)) if os.path.exists(str(a)) else str(a) for a in argv],
|
|
543
|
+
"inputs": [{"hash": _content_hash(p)} for p in inputs],
|
|
544
|
+
"child": [a for a in child_args() if a != "--dry-run"],
|
|
545
|
+
"codec": STATE.codec, "ext": Path(dest).suffix if dest else None,
|
|
546
|
+
"ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
|
|
547
|
+
}
|
|
548
|
+
return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def cache_lookup(stage: str, key: str, dest: str) -> bool:
|
|
552
|
+
"""Put the cached artifact for `key` at `dest` and return True, or return False on any
|
|
553
|
+
mismatch -- silently, because a miss is not an error, it is just work to do."""
|
|
554
|
+
cdir = CACHE.get("dir")
|
|
555
|
+
if not cdir:
|
|
556
|
+
return False
|
|
557
|
+
side = Path(cdir) / f"{key}.json"
|
|
558
|
+
art = Path(cdir) / f"{key}{Path(dest).suffix or '.bin'}"
|
|
559
|
+
if not (side.exists() and art.exists()):
|
|
560
|
+
return False
|
|
561
|
+
try:
|
|
562
|
+
meta = json.loads(side.read_text(encoding="utf-8"))
|
|
563
|
+
except (OSError, ValueError):
|
|
564
|
+
return False
|
|
565
|
+
if int(meta.get("size") or -1) != art.stat().st_size:
|
|
566
|
+
return False
|
|
567
|
+
if STATE.dry_run:
|
|
568
|
+
CACHE["would_hit"].append(stage)
|
|
569
|
+
return True
|
|
570
|
+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
571
|
+
try:
|
|
572
|
+
if Path(dest).exists():
|
|
573
|
+
Path(dest).unlink()
|
|
574
|
+
os.link(art, dest) # a hardlink where the filesystem allows it ...
|
|
575
|
+
except OSError:
|
|
576
|
+
shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
|
|
577
|
+
CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
|
|
582
|
+
"""Keep `produced` for the next run. Never under --dry-run: a plan writes nothing."""
|
|
583
|
+
cdir = CACHE.get("dir")
|
|
584
|
+
if not cdir or STATE.dry_run or not produced or not os.path.exists(produced):
|
|
585
|
+
return
|
|
586
|
+
art = Path(cdir) / f"{key}{Path(produced).suffix or '.bin'}"
|
|
587
|
+
try:
|
|
588
|
+
shutil.copy2(produced, art)
|
|
589
|
+
Path(cdir, f"{key}.json").write_text(json.dumps({
|
|
590
|
+
"stage": stage, "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION,
|
|
591
|
+
"created": time.time(), "size": art.stat().st_size, "seconds": round(seconds, 2),
|
|
592
|
+
"artifact": art.name}, indent=2), encoding="utf-8")
|
|
593
|
+
CACHE["entries"] += 1
|
|
594
|
+
except OSError as exc:
|
|
595
|
+
info(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected")
|
|
401
596
|
|
|
402
597
|
|
|
403
598
|
def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
@@ -501,6 +696,14 @@ def main() -> int:
|
|
|
501
696
|
ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
|
|
502
697
|
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
503
698
|
ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
|
|
699
|
+
ap.add_argument("--cache", metavar="DIR",
|
|
700
|
+
help="reuse the artifacts of identical stages from a previous run. Opt-in: "
|
|
701
|
+
"there is no default directory. The ffmpeg version and the skill version "
|
|
702
|
+
"are part of every key, so a cache never crosses either.")
|
|
703
|
+
ap.add_argument("--from", dest="from_stage", metavar="STAGE",
|
|
704
|
+
choices=list(STAGE_ORDER),
|
|
705
|
+
help="start at this stage, taking every earlier one from --cache; refuses if "
|
|
706
|
+
"one of them is not there")
|
|
504
707
|
ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
|
|
505
708
|
tpl = ap.add_argument_group("delivery templates (one command per destination)")
|
|
506
709
|
tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
|
|
@@ -601,7 +804,6 @@ def main() -> int:
|
|
|
601
804
|
# a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
|
|
602
805
|
# auto-named directory is ours alone, so remove it on every exit path
|
|
603
806
|
import atexit
|
|
604
|
-
import shutil
|
|
605
807
|
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
606
808
|
# Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
|
|
607
809
|
# is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
|
|
@@ -619,6 +821,35 @@ def main() -> int:
|
|
|
619
821
|
platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
|
|
620
822
|
stages_done: List[str] = []
|
|
621
823
|
|
|
824
|
+
CACHE.clear()
|
|
825
|
+
CACHE.update(_fresh_cache())
|
|
826
|
+
if args.cache:
|
|
827
|
+
cdir = Path(args.cache)
|
|
828
|
+
try:
|
|
829
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
830
|
+
probe_file = cdir / ".writable"
|
|
831
|
+
probe_file.write_text("", encoding="utf-8")
|
|
832
|
+
probe_file.unlink()
|
|
833
|
+
except OSError as exc:
|
|
834
|
+
die(f"--cache {args.cache}: not a writable directory ({exc})", kind="output")
|
|
835
|
+
CACHE["dir"] = str(cdir)
|
|
836
|
+
CACHE["ffmpeg"] = ffmpeg_banner()
|
|
837
|
+
CACHE["entries"] = len(list(cdir.glob("*.json")))
|
|
838
|
+
CACHE["from"] = args.from_stage
|
|
839
|
+
if args.from_stage and not args.cache:
|
|
840
|
+
die(f"--from {args.from_stage} needs --cache DIR with a previous run's stages: without a "
|
|
841
|
+
"cache there is no earlier artifact to start from, so every stage would run anyway.",
|
|
842
|
+
kind="input")
|
|
843
|
+
|
|
844
|
+
# A project may ask for its clip boundaries to land on the music's beat. The measurement and
|
|
845
|
+
# the refusal both live in cut.py -- render forwards the request and reports what came back,
|
|
846
|
+
# so a project that does not name "snap" builds the command line 1.16 built.
|
|
847
|
+
snap_spec = proj.get("snap") or {}
|
|
848
|
+
snap_reports: List[Dict[str, Any]] = []
|
|
849
|
+
if snap_spec and str(snap_spec.get("to") or "") not in ("", "none", "beats"):
|
|
850
|
+
die(f'snap.to: only "beats" (or "none") is a beat grid this skill can measure, got '
|
|
851
|
+
f'{snap_spec.get("to")!r}', kind="input")
|
|
852
|
+
|
|
622
853
|
# ---- clips
|
|
623
854
|
parts: List[str] = []
|
|
624
855
|
for i, c in enumerate(clips):
|
|
@@ -635,7 +866,26 @@ def main() -> int:
|
|
|
635
866
|
argv += ["--start", c["in"]]
|
|
636
867
|
if c.get("out") is not None:
|
|
637
868
|
argv += ["--end", c["out"]]
|
|
638
|
-
|
|
869
|
+
clip_snap = dict(snap_spec)
|
|
870
|
+
clip_snap.update(c.get("snap") or {})
|
|
871
|
+
if str(clip_snap.get("to") or "none") == "beats":
|
|
872
|
+
argv += ["--snap", "beats"]
|
|
873
|
+
if clip_snap.get("tolerance") is not None:
|
|
874
|
+
argv += ["--snap-tolerance", str(clip_snap["tolerance"])]
|
|
875
|
+
if clip_snap.get("min_confidence") is not None:
|
|
876
|
+
argv += ["--min-confidence", str(clip_snap["min_confidence"])]
|
|
877
|
+
if clip_snap.get("source"):
|
|
878
|
+
argv += ["--snap-source", rel(clip_snap["source"])]
|
|
879
|
+
sh("cut.py", *argv, stage="clips")
|
|
880
|
+
if _LAST_DOC.get("snap"):
|
|
881
|
+
snap_reports.append({"clip": i, **_LAST_DOC["snap"]})
|
|
882
|
+
elif _LAST_DOC.get("cached") and str(clip_snap.get("to") or "none") == "beats":
|
|
883
|
+
# The cut is the one the cache holds, so it WAS snapped -- the moves are simply
|
|
884
|
+
# not re-measured. Saying `snap: null` here would report the opposite.
|
|
885
|
+
snap_reports.append({"clip": i, "mode": "beats", "source": "cache",
|
|
886
|
+
"note": "this clip came from --cache; it was snapped when "
|
|
887
|
+
"it was first rendered and the moves are in that "
|
|
888
|
+
"run's result"})
|
|
639
889
|
else:
|
|
640
890
|
part = src
|
|
641
891
|
if c.get("speed"):
|
|
@@ -645,7 +895,7 @@ def main() -> int:
|
|
|
645
895
|
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
646
896
|
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
647
897
|
fitted = str(work / f"clip{i:02d}_speed{mid}")
|
|
648
|
-
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
898
|
+
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
|
|
649
899
|
part = fitted
|
|
650
900
|
parts.append(part)
|
|
651
901
|
stages_done.append("clips")
|
|
@@ -667,7 +917,7 @@ def main() -> int:
|
|
|
667
917
|
if ag.get(key) is not None:
|
|
668
918
|
argv += [flag, str(ag[key])]
|
|
669
919
|
argv += brand_args
|
|
670
|
-
sh("waveform.py", *argv)
|
|
920
|
+
sh("waveform.py", *argv, stage="audiogram")
|
|
671
921
|
current = nxt
|
|
672
922
|
parts = [current]
|
|
673
923
|
stages_done.append("audiogram")
|
|
@@ -686,7 +936,7 @@ def main() -> int:
|
|
|
686
936
|
argv += ["--height", str(frame["height"])]
|
|
687
937
|
if frame.get("fps"):
|
|
688
938
|
argv += ["--fps", str(frame["fps"])]
|
|
689
|
-
sh("join.py", *argv)
|
|
939
|
+
sh("join.py", *argv, stage="join")
|
|
690
940
|
stages_done.append("join")
|
|
691
941
|
if args.stop_after == "join":
|
|
692
942
|
emit(current, stages=stages_done)
|
|
@@ -700,7 +950,7 @@ def main() -> int:
|
|
|
700
950
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
|
|
701
951
|
if sil.get(k) is not None:
|
|
702
952
|
argv += [flag, str(sil[k])]
|
|
703
|
-
sh("silence.py", *argv)
|
|
953
|
+
sh("silence.py", *argv, stage="silence")
|
|
704
954
|
current = nxt
|
|
705
955
|
stages_done.append("silence")
|
|
706
956
|
if args.stop_after == "silence":
|
|
@@ -725,7 +975,7 @@ def main() -> int:
|
|
|
725
975
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
726
976
|
if fit.get(k) is not None:
|
|
727
977
|
argv += [flag, str(fit[k])]
|
|
728
|
-
sh("fit.py", *argv)
|
|
978
|
+
sh("fit.py", *argv, stage="fit")
|
|
729
979
|
current = nxt
|
|
730
980
|
stages_done.append("fit")
|
|
731
981
|
if args.stop_after == "fit":
|
|
@@ -751,7 +1001,7 @@ def main() -> int:
|
|
|
751
1001
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
752
1002
|
if cap.get(k):
|
|
753
1003
|
argv.append(flag)
|
|
754
|
-
sh("caption.py", *(argv + brand_args + platform_args))
|
|
1004
|
+
sh("caption.py", *(argv + brand_args + platform_args), stage="captions")
|
|
755
1005
|
current = nxt
|
|
756
1006
|
stages_done.append("captions")
|
|
757
1007
|
if args.stop_after == "captions":
|
|
@@ -769,7 +1019,7 @@ def main() -> int:
|
|
|
769
1019
|
if g.get(k) is not None:
|
|
770
1020
|
argv += [flag, str(g[k])]
|
|
771
1021
|
# the entry's own "platform" is the more specific statement than the template's destination
|
|
772
|
-
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)))
|
|
1022
|
+
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
|
|
773
1023
|
current = nxt
|
|
774
1024
|
if "graphics" not in stages_done:
|
|
775
1025
|
stages_done.append("graphics")
|
|
@@ -796,7 +1046,7 @@ def main() -> int:
|
|
|
796
1046
|
argv.append("--box")
|
|
797
1047
|
# review 12: the overlay stage was the one stage that never heard which destination this
|
|
798
1048
|
# is, so a template's top-left logo landed 24 px in -- under TikTok's own status bar.
|
|
799
|
-
sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)))
|
|
1049
|
+
sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)), stage="overlays")
|
|
800
1050
|
current = nxt
|
|
801
1051
|
if "overlays" not in stages_done:
|
|
802
1052
|
stages_done.append("overlays")
|
|
@@ -834,7 +1084,7 @@ def main() -> int:
|
|
|
834
1084
|
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
835
1085
|
if au.get(k):
|
|
836
1086
|
argv.append(flag)
|
|
837
|
-
sh("audio.py", *argv)
|
|
1087
|
+
sh("audio.py", *argv, stage="audio")
|
|
838
1088
|
current = nxt
|
|
839
1089
|
stages_done.append("audio")
|
|
840
1090
|
if args.stop_after == "audio":
|
|
@@ -850,7 +1100,7 @@ def main() -> int:
|
|
|
850
1100
|
argv += ["-I", str(ld["lufs"])]
|
|
851
1101
|
if ld.get("tp") is not None:
|
|
852
1102
|
argv += ["--tp", str(ld["tp"])]
|
|
853
|
-
sh("loudness.py", *argv)
|
|
1103
|
+
sh("loudness.py", *argv, stage="loudness")
|
|
854
1104
|
current = nxt
|
|
855
1105
|
stages_done.append("loudness")
|
|
856
1106
|
if args.stop_after == "loudness":
|
|
@@ -874,7 +1124,7 @@ def main() -> int:
|
|
|
874
1124
|
info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
|
|
875
1125
|
if normalize:
|
|
876
1126
|
argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
|
|
877
|
-
sh("export.py", *argv)
|
|
1127
|
+
sh("export.py", *argv, stage="export")
|
|
878
1128
|
stages_done.append("export")
|
|
879
1129
|
else:
|
|
880
1130
|
if not STATE.dry_run:
|
|
@@ -895,7 +1145,7 @@ def main() -> int:
|
|
|
895
1145
|
else:
|
|
896
1146
|
chapter_file = rel(ch)
|
|
897
1147
|
tagged = str(work / ("chapters" + Path(output).suffix))
|
|
898
|
-
sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged)
|
|
1148
|
+
sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged, stage="chapters")
|
|
899
1149
|
if not STATE.dry_run:
|
|
900
1150
|
place_output(tagged, output)
|
|
901
1151
|
stages_done.append("chapters")
|
|
@@ -926,7 +1176,6 @@ def main() -> int:
|
|
|
926
1176
|
# default name carries this process's PID, nothing else will ever reuse -- and so
|
|
927
1177
|
# implicitly clean up -- a leftover dry-run directory the way a same-named real run used
|
|
928
1178
|
# to before the PID suffix was added.
|
|
929
|
-
import shutil
|
|
930
1179
|
shutil.rmtree(work, ignore_errors=True)
|
|
931
1180
|
if exit_code:
|
|
932
1181
|
# The deliverable is written and verified, but it does not meet the requested platform
|
|
@@ -936,7 +1185,23 @@ def main() -> int:
|
|
|
936
1185
|
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
|
|
937
1186
|
probe=probe(output, role="output"))
|
|
938
1187
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
939
|
-
|
|
1188
|
+
cache_report = None
|
|
1189
|
+
if args.cache:
|
|
1190
|
+
cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
|
|
1191
|
+
"hits": CACHE["hits"], "misses": CACHE["misses"],
|
|
1192
|
+
"saved_seconds": round(CACHE["saved_seconds"], 1),
|
|
1193
|
+
"entries": CACHE["entries"]}
|
|
1194
|
+
if STATE.dry_run:
|
|
1195
|
+
cache_report["would_hit"] = CACHE["would_hit"]
|
|
1196
|
+
info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
|
|
1197
|
+
f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
|
|
1198
|
+
# One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
|
|
1199
|
+
# caller reads today by also carrying the first entry's keys at the top level.
|
|
1200
|
+
snap_report: Optional[Dict[str, Any]] = None
|
|
1201
|
+
if snap_reports:
|
|
1202
|
+
snap_report = dict(snap_reports[0])
|
|
1203
|
+
snap_report["clips"] = snap_reports
|
|
1204
|
+
emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
|
|
940
1205
|
verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
|
|
941
1206
|
return 0
|
|
942
1207
|
|