ffmpeg-skill 1.26.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/SKILL.md +8 -7
- package/docs/contract.md +55 -46
- package/package.json +1 -1
- package/references/gotchas.md +9 -6
- package/references/scripts.md +21 -7
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/decision.py +2 -2
- package/scripts/_common/emit.py +2 -37
- package/scripts/_common/probe.py +10 -6
- package/scripts/_common/runner.py +13 -45
- package/scripts/_common/timeline.py +379 -0
- package/scripts/_contract.py +39 -33
- package/scripts/background.py +1 -1
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +1 -1
- package/scripts/check.py +3 -3
- package/scripts/color.py +4 -4
- package/scripts/crop.py +1 -1
- package/scripts/cut.py +1 -1
- package/scripts/deinterlace.py +1 -1
- package/scripts/denoise.py +1 -1
- package/scripts/export.py +6 -3
- package/scripts/fit.py +2 -2
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +1 -1
- package/scripts/join.py +2 -2
- package/scripts/look.py +1 -1
- package/scripts/loop.py +1 -1
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +1 -1
- package/scripts/pad.py +1 -1
- package/scripts/probe.py +1 -1
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +1 -1
- package/scripts/render.py +49 -1
- package/scripts/report.py +1 -1
- package/scripts/reverse.py +1 -1
- package/scripts/sequence.py +1 -1
- package/scripts/silence.py +1 -1
- package/scripts/speedramp.py +1 -1
- package/scripts/sphere.py +1 -1
- package/scripts/stabilize.py +1 -1
- package/scripts/straighten.py +1 -1
- package/scripts/sync.py +1 -1
- package/scripts/verify.py +2 -2
- package/scripts/waveform.py +1 -1
package/scripts/_contract.py
CHANGED
|
@@ -206,7 +206,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
206
206
|
required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"},
|
|
207
207
|
{"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
|
|
208
208
|
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
209
|
-
"render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)"],
|
|
209
|
+
"render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
|
|
210
210
|
required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
|
|
211
211
|
video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
212
212
|
"batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
|
|
@@ -351,9 +351,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
351
351
|
if isinstance(action, argparse._HelpAction):
|
|
352
352
|
continue
|
|
353
353
|
prop: Dict[str, Any] = _json_type(action)
|
|
354
|
-
|
|
355
|
-
# distinguishable from the default); the schema still advertises the real one
|
|
356
|
-
default = getattr(action, "deprecated_default", action.default)
|
|
354
|
+
default = action.default
|
|
357
355
|
if action.help and action.help != argparse.SUPPRESS:
|
|
358
356
|
prop["description"] = action.help % {"default": default} if "%(default)" in action.help else action.help
|
|
359
357
|
if action.choices:
|
|
@@ -428,7 +426,10 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
428
426
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
|
|
429
427
|
"plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
|
|
430
428
|
"tool": {"type": "string", "description": "plan execution: the tool the plan ran"},
|
|
431
|
-
"tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"}
|
|
429
|
+
"tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
|
|
430
|
+
"timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
|
|
431
|
+
"transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
|
|
432
|
+
"editor timeline cannot carry)"}}
|
|
432
433
|
elif name == "verify":
|
|
433
434
|
extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
434
435
|
elif name == "batch":
|
|
@@ -1087,34 +1088,39 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1087
1088
|
# tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
|
|
1088
1089
|
# does not append --json for them (stated in invocation.structured.argument_mapping.json)
|
|
1089
1090
|
# ----------------------------------------------------------------------------- deprecations
|
|
1090
|
-
# What
|
|
1091
|
+
# What the next major removes, announced here per docs/contract.md's three-step deprecation policy:
|
|
1091
1092
|
# step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
|
|
1092
|
-
# removes it in the major. `where` says which surface a caller sees it on.
|
|
1093
|
-
#
|
|
1094
|
-
DEPRECATED: List[Dict[str, str]] = [
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1093
|
+
# removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0: all
|
|
1094
|
+
# five 1.10.0 deprecations were settled by it (REMOVED below).
|
|
1095
|
+
DEPRECATED: List[Dict[str, str]] = []
|
|
1096
|
+
|
|
1097
|
+
# What 2.0.0 removed or changed, with the version that deprecated each: the "Removed" half of the
|
|
1098
|
+
# policy's step 3, kept in the contract so a caller migrating from 1.x can diff it instead of reading
|
|
1099
|
+
# a CHANGELOG. docs/contract.md's "What 2.0 changed" table is written from this list.
|
|
1100
|
+
REMOVED: List[Dict[str, str]] = [
|
|
1098
1101
|
{"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
|
|
1099
|
-
"since": "1.10.0", "
|
|
1100
|
-
"
|
|
1102
|
+
"since": "1.10.0", "removed_in": "2.0.0", "where": "cli",
|
|
1103
|
+
"replacement": "--quality N (the same CRF scale, codec-neutral; each tool's old --crf default is its --quality default)"},
|
|
1101
1104
|
{"what": "json and progress in the MCP inputSchema (they are CLI transport flags, not tool arguments)",
|
|
1102
|
-
"since": "1.10.0", "
|
|
1103
|
-
"
|
|
1105
|
+
"since": "1.10.0", "removed_in": "2.0.0", "where": "mcp",
|
|
1106
|
+
"replacement": "nothing: the MCP transport sets them itself; a client that still sends them is not refused"},
|
|
1104
1107
|
{"what": "probe's hdr meaning BT.2020 primaries or a PQ/HLG transfer",
|
|
1105
|
-
"since": "1.10.0", "
|
|
1106
|
-
"
|
|
1107
|
-
{"what": "overwriting an existing output without --overwrite (warned
|
|
1108
|
-
"since": "1.10.0", "
|
|
1109
|
-
"
|
|
1108
|
+
"since": "1.10.0", "removed_in": "2.0.0", "where": "json",
|
|
1109
|
+
"replacement": "hdr is now true only for PQ / HLG / Dolby Vision (as hdr_signal, which stays); bt2020_or_hdr carries the 1.x meaning"},
|
|
1110
|
+
{"what": "overwriting an existing output without --overwrite (1.x warned)",
|
|
1111
|
+
"since": "1.10.0", "removed_in": "2.0.0", "where": "behaviour",
|
|
1112
|
+
"replacement": "--overwrite as explicit consent; without it an existing output is refused (kind: input) before anything runs"},
|
|
1113
|
+
{"what": "the result_v2 preview key and FFMPEG_SKILL_RESULT_V2 (the v1 top-level keys it was to replace were un-deprecated: they are the 2.0 shape)",
|
|
1114
|
+
"since": "1.10.0", "removed_in": "2.0.0", "where": "json",
|
|
1115
|
+
"replacement": "the top-level keys, unchanged from 1.x; --json-brief for a short document"},
|
|
1110
1116
|
]
|
|
1111
1117
|
|
|
1112
1118
|
|
|
1113
1119
|
MCP_JSON_EXEMPT = ("look", "probe")
|
|
1114
|
-
#
|
|
1115
|
-
#
|
|
1116
|
-
#
|
|
1117
|
-
|
|
1120
|
+
# json/progress are CLI transport flags the MCP server sets itself, not tool arguments: 2.0 leaves
|
|
1121
|
+
# them out of every inputSchema (1.10.0-1.x kept them unless FFMPEG_SKILL_MCP_LEAN=1). A client
|
|
1122
|
+
# that still sends them is not refused -- build_argv() maps them like any other key.
|
|
1123
|
+
MCP_TRANSPORT_FLAGS = ("json", "progress")
|
|
1118
1124
|
# default MCP tools/list surface (roadmap P1-7, shipped 1.18.3): the 42-tool schema dump costs
|
|
1119
1125
|
# every MCP session context whether it needs it or not. These 12 are the tools eval iterations
|
|
1120
1126
|
# 17-20's ground-truth `expect` lists actually name most often across the 118 agent prompts in
|
|
@@ -1185,7 +1191,7 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
|
|
|
1185
1191
|
"template": ("Bring {input} from HDR/Dolby Vision down to SDR: `color.py {input} --to-sdr` (or `--strip-dovi` "
|
|
1186
1192
|
"to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
|
|
1187
1193
|
"look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
|
|
1188
|
-
"for
|
|
1194
|
+
"only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
|
|
1189
1195
|
},
|
|
1190
1196
|
]
|
|
1191
1197
|
|
|
@@ -1259,13 +1265,12 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1259
1265
|
one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
|
|
1260
1266
|
if one_of:
|
|
1261
1267
|
structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
del structured["required"]
|
|
1268
|
+
for dest in MCP_TRANSPORT_FLAGS:
|
|
1269
|
+
props.pop(dest, None)
|
|
1270
|
+
if structured.get("required"):
|
|
1271
|
+
structured["required"] = [d for d in structured["required"] if d not in MCP_TRANSPORT_FLAGS]
|
|
1272
|
+
if not structured["required"]:
|
|
1273
|
+
del structured["required"]
|
|
1269
1274
|
schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
|
|
1270
1275
|
if structured:
|
|
1271
1276
|
schema["anyOf"] = [{"required": ["argv"]}, structured]
|
|
@@ -1297,6 +1302,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1297
1302
|
return {
|
|
1298
1303
|
"contract_version": CONTRACT_VERSION,
|
|
1299
1304
|
"deprecated": [dict(d) for d in DEPRECATED],
|
|
1305
|
+
"removed": [dict(d) for d in REMOVED],
|
|
1300
1306
|
"skill": {
|
|
1301
1307
|
"id": SKILL_ID,
|
|
1302
1308
|
"version": version,
|
package/scripts/background.py
CHANGED
|
@@ -28,7 +28,7 @@ def main() -> int:
|
|
|
28
28
|
src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
|
|
29
29
|
src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
|
|
30
30
|
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
|
-
ap.
|
|
31
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
32
32
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
33
|
add_common(ap)
|
|
34
34
|
args = ap.parse_args()
|
package/scripts/broll.py
CHANGED
|
@@ -35,7 +35,7 @@ def main() -> int:
|
|
|
35
35
|
ap.add_argument("--from", dest="from_", action="append", metavar="TIME", help="where in B to start from (default 0); one per --insert, or omit")
|
|
36
36
|
ap.add_argument("--audio", choices=["a", "b", "mix"], default="a", help="under a cutaway: A's audio (default), B's audio, or both mixed")
|
|
37
37
|
ap.add_argument("--pad-color", default="black", help="pad colour when B's aspect differs from A's (default black)")
|
|
38
|
-
ap.
|
|
38
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
39
39
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
40
40
|
add_common(ap)
|
|
41
41
|
args = ap.parse_args()
|
|
@@ -104,7 +104,7 @@ def main() -> int:
|
|
|
104
104
|
for i, c in enumerate(cutaways):
|
|
105
105
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
106
106
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
107
|
-
f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('
|
|
107
|
+
f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
108
108
|
parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
|
|
109
109
|
cur = f"[v{i}]"
|
|
110
110
|
vout = cur
|
package/scripts/caption.py
CHANGED
|
@@ -939,7 +939,7 @@ def main() -> int:
|
|
|
939
939
|
help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
|
|
940
940
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
941
941
|
enc = ap.add_argument_group("encoding")
|
|
942
|
-
enc.
|
|
942
|
+
enc.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
943
943
|
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
944
944
|
add_common(ap)
|
|
945
945
|
args = ap.parse_args(_glue_negative_offset(sys.argv[1:]))
|
package/scripts/check.py
CHANGED
|
@@ -137,14 +137,14 @@ def main() -> int:
|
|
|
137
137
|
if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
|
|
138
138
|
row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
|
|
139
139
|
reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
|
|
140
|
-
if spec["sdr_only"] and v.get("
|
|
140
|
+
if spec["sdr_only"] and v.get("bt2020_or_hdr"):
|
|
141
141
|
row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr",
|
|
142
142
|
reason="a platform or player without HDR support will show this washed-out, too dark, or with wrong colours -- not a rendering glitch, a colour space mismatch")
|
|
143
143
|
else:
|
|
144
144
|
tags = (v.get("color_primaries"), v.get("color_transfer"))
|
|
145
145
|
untagged = not tags[0] and not tags[1]
|
|
146
|
-
if v.get("
|
|
147
|
-
row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("
|
|
146
|
+
if v.get("bt2020_or_hdr") or tags == ("bt709", "bt709") or args.platform in ("podcast", "custom"):
|
|
147
|
+
row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("bt2020_or_hdr") else ""), "bt709/bt709 tagged (or HDR)")
|
|
148
148
|
elif untagged and (v.get("bit_depth") or 8) == 8:
|
|
149
149
|
# untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
|
|
150
150
|
row("colour", "PASS", "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
|
package/scripts/color.py
CHANGED
|
@@ -203,7 +203,7 @@ def main() -> int:
|
|
|
203
203
|
"the first track, same as leaving it unset always did. Only affects modes that re-encode "
|
|
204
204
|
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
205
205
|
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
206
|
-
ap.
|
|
206
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
207
207
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
208
208
|
add_common(ap)
|
|
209
209
|
args = ap.parse_args()
|
|
@@ -279,13 +279,13 @@ def main() -> int:
|
|
|
279
279
|
|
|
280
280
|
measurements = None
|
|
281
281
|
if args.to_sdr:
|
|
282
|
-
if not v.get("
|
|
282
|
+
if not v.get("bt2020_or_hdr") and not args.force:
|
|
283
283
|
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
284
284
|
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
285
285
|
output = args.output or default_output(args.input, "sdr")
|
|
286
286
|
tag = "sdr"
|
|
287
287
|
elif args.correct:
|
|
288
|
-
if v.get("
|
|
288
|
+
if v.get("bt2020_or_hdr") and not args.force:
|
|
289
289
|
die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
|
|
290
290
|
f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
|
|
291
291
|
vf = correction_chain(args)
|
|
@@ -295,7 +295,7 @@ def main() -> int:
|
|
|
295
295
|
# "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
|
|
296
296
|
measurements = None if STATE.dry_run else {"input": analyze_levels(args.input)}
|
|
297
297
|
else:
|
|
298
|
-
if v.get("
|
|
298
|
+
if v.get("bt2020_or_hdr") and not args.force:
|
|
299
299
|
die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
|
|
300
300
|
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
301
301
|
if not os.path.exists(args.lut):
|
package/scripts/crop.py
CHANGED
|
@@ -32,7 +32,7 @@ def main() -> int:
|
|
|
32
32
|
ap.add_argument("--y", type=int, required=True, help="top edge of the crop rectangle, in source pixels")
|
|
33
33
|
ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
|
|
34
34
|
ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
|
|
35
|
-
ap.
|
|
35
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
36
36
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
37
37
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
38
38
|
add_common(ap)
|
package/scripts/cut.py
CHANGED
|
@@ -292,7 +292,7 @@ def main() -> int:
|
|
|
292
292
|
"this media file) instead of measuring the input again")
|
|
293
293
|
snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
|
|
294
294
|
help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
|
|
295
|
-
ap.
|
|
295
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
296
296
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
|
|
297
297
|
add_common(ap)
|
|
298
298
|
args = ap.parse_args()
|
package/scripts/deinterlace.py
CHANGED
|
@@ -40,7 +40,7 @@ def main() -> int:
|
|
|
40
40
|
help="only deinterlace frames the source marks as interlaced; leave the rest untouched")
|
|
41
41
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
42
42
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
43
|
-
ap.
|
|
43
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
44
44
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
45
45
|
add_common(ap)
|
|
46
46
|
args = ap.parse_args()
|
package/scripts/denoise.py
CHANGED
|
@@ -42,7 +42,7 @@ def main() -> int:
|
|
|
42
42
|
ap.add_argument("--chroma-temporal", type=float, help="temporal chroma denoising strength (default from --strength)")
|
|
43
43
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
44
44
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
45
|
-
ap.
|
|
45
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
46
46
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
47
47
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
48
48
|
add_common(ap)
|
package/scripts/export.py
CHANGED
|
@@ -141,14 +141,17 @@ def main() -> int:
|
|
|
141
141
|
if not meta.get("video"):
|
|
142
142
|
die("input has no video stream")
|
|
143
143
|
notes: List[str] = []
|
|
144
|
-
if p.get("hdr_only") and not meta["video"].get("
|
|
144
|
+
if p.get("hdr_only") and not meta["video"].get("bt2020_or_hdr"):
|
|
145
145
|
# The point of this preset is that the delivery stays HDR. Running it on an SDR source
|
|
146
146
|
# would write a 10-bit HEVC file labelled with SDR tags and call it an HDR delivery.
|
|
147
147
|
die(f"--preset {args.preset} delivers HDR and this source is SDR ({meta['video'].get('codec')}, "
|
|
148
148
|
f"{meta['video'].get('color_transfer') or 'untagged'})",
|
|
149
149
|
hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
|
|
150
|
-
if meta["video"].get("
|
|
151
|
-
|
|
150
|
+
if meta["video"].get("bt2020_or_hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
|
|
151
|
+
# 2.0: `hdr` is a real PQ/HLG/DV signal; bt2020_or_hdr also covers BT.2020 SDR, which is
|
|
152
|
+
# not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
|
|
153
|
+
kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
|
|
154
|
+
notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
|
|
152
155
|
info("warning: " + notes[-1])
|
|
153
156
|
has_audio = bool(meta.get("audio"))
|
|
154
157
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
package/scripts/fit.py
CHANGED
|
@@ -106,7 +106,7 @@ def main() -> int:
|
|
|
106
106
|
r.add_argument("--rotate", type=int, choices=[90, 180, 270], help="rotate the picture clockwise by this many degrees")
|
|
107
107
|
r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
|
|
108
108
|
e = ap.add_argument_group("encoding")
|
|
109
|
-
e.
|
|
109
|
+
e.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
110
110
|
e.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
111
111
|
e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
112
112
|
add_common(ap)
|
|
@@ -227,7 +227,7 @@ def main() -> int:
|
|
|
227
227
|
# (gamma-encoded) signal and something else entirely on a PQ/HLG one -- so an HDR
|
|
228
228
|
# source keeps its blurred background undimmed rather than being silently altered
|
|
229
229
|
# (review 12). The blur itself is neutral either way, and no tone mapping happens.
|
|
230
|
-
darken = 0.0 if meta["video"].get("
|
|
230
|
+
darken = 0.0 if meta["video"].get("bt2020_or_hdr") else BLUR_DARKEN
|
|
231
231
|
if not darken:
|
|
232
232
|
info("--fit blur: HDR source, so the blurred background is not dimmed "
|
|
233
233
|
"(an eq on PQ/HLG code values is not the -15%% dim it is on SDR); "
|
package/scripts/freeze.py
CHANGED
|
@@ -30,7 +30,7 @@ def main() -> int:
|
|
|
30
30
|
ap.add_argument("--hold", type=float, required=True, help="how long the freeze lasts, in seconds")
|
|
31
31
|
ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
|
|
32
32
|
help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
|
|
33
|
-
ap.
|
|
33
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
34
34
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
35
35
|
add_common(ap)
|
|
36
36
|
args = ap.parse_args()
|
package/scripts/graphics.py
CHANGED
|
@@ -115,7 +115,7 @@ def main() -> int:
|
|
|
115
115
|
emo.add_argument("--emoji-scale", type=float, default=1.0,
|
|
116
116
|
help="emoji box as a multiple of the line's font size (default 1.0)")
|
|
117
117
|
emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build (default 60)")
|
|
118
|
-
ap.
|
|
118
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
119
119
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
120
120
|
add_common(ap)
|
|
121
121
|
args = ap.parse_args()
|
package/scripts/grid.py
CHANGED
|
@@ -49,7 +49,7 @@ def main() -> int:
|
|
|
49
49
|
ap.add_argument("--audio-from", type=int, help="0-based index into inputs to take audio from (default: no audio)")
|
|
50
50
|
ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
|
|
51
51
|
ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
|
|
52
|
-
ap.
|
|
52
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
53
53
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
54
54
|
add_common(ap)
|
|
55
55
|
args = ap.parse_args()
|
|
@@ -124,7 +124,7 @@ def main() -> int:
|
|
|
124
124
|
# A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
|
|
125
125
|
# deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
|
|
126
126
|
# once so the caller is not surprised by the 8-bit output.
|
|
127
|
-
if any((m.get("video") or {}).get("
|
|
127
|
+
if any((m.get("video") or {}).get("bt2020_or_hdr") for m in metas):
|
|
128
128
|
info("note: an HDR input is composited into an 8-bit SDR grid (grid.py is a comparison artefact); use color.py --to-sdr first for a graded conversion")
|
|
129
129
|
cmd += video_args(None, args.crf, args.preset)
|
|
130
130
|
cmd += cfr_args(None, args.fps)
|
package/scripts/insert.py
CHANGED
|
@@ -46,7 +46,7 @@ def main() -> int:
|
|
|
46
46
|
ap.add_argument("--zoom", choices=["in", "out"], help="Ken Burns: slow linear zoom in or out across the clip")
|
|
47
47
|
ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
|
|
48
48
|
ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
|
|
49
|
-
ap.
|
|
49
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
50
50
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
51
51
|
add_common(ap)
|
|
52
52
|
args = ap.parse_args()
|
package/scripts/join.py
CHANGED
|
@@ -93,7 +93,7 @@ def main() -> int:
|
|
|
93
93
|
ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
|
|
94
94
|
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
|
|
95
95
|
ap.add_argument("--pad-color", default="black")
|
|
96
|
-
ap.
|
|
96
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
97
97
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
98
98
|
aud = ap.add_argument_group("audio-only inputs")
|
|
99
99
|
aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
|
|
@@ -170,7 +170,7 @@ def main() -> int:
|
|
|
170
170
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
171
171
|
# Any HDR input makes the join HDR (10-bit, HEVC via video_args on that clip's tags): an SDR
|
|
172
172
|
# first clip used to drag an HDR second clip down to 8-bit without a tone map.
|
|
173
|
-
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("
|
|
173
|
+
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
|
|
174
174
|
pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
|
|
175
175
|
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
176
176
|
# silently dropped the centre/LFE of 5.1 material
|
package/scripts/look.py
CHANGED
|
@@ -191,7 +191,7 @@ def main() -> int:
|
|
|
191
191
|
f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
|
|
192
192
|
tc = safe_filter + tc
|
|
193
193
|
# HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
|
|
194
|
-
if meta["video"].get("
|
|
194
|
+
if meta["video"].get("bt2020_or_hdr"):
|
|
195
195
|
v = meta["video"]
|
|
196
196
|
tm = (f"zscale=tin={v.get('color_transfer') or 'arib-std-b67'}:pin={v.get('color_primaries') or 'bt2020'}:min={v.get('color_space') or 'bt2020nc'}:rin=tv:t=linear:npl=1000,"
|
|
197
197
|
"format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
|
package/scripts/loop.py
CHANGED
|
@@ -28,7 +28,7 @@ def main() -> int:
|
|
|
28
28
|
group = ap.add_mutually_exclusive_group(required=True)
|
|
29
29
|
group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
|
|
30
30
|
group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
|
|
31
|
-
ap.
|
|
31
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
32
32
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
33
|
add_common(ap)
|
|
34
34
|
args = ap.parse_args()
|
package/scripts/multicam.py
CHANGED
|
@@ -144,7 +144,7 @@ def main() -> int:
|
|
|
144
144
|
ap.add_argument("--width", type=int, help="output width (default: reference)")
|
|
145
145
|
ap.add_argument("--height", type=int, help="output height (default: reference)")
|
|
146
146
|
ap.add_argument("--fps", type=float, help="output fps (default: reference)")
|
|
147
|
-
ap.
|
|
147
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
148
148
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
149
149
|
add_common(ap)
|
|
150
150
|
args = ap.parse_args()
|
|
@@ -260,7 +260,7 @@ def main() -> int:
|
|
|
260
260
|
w, h = h, w
|
|
261
261
|
fps = args.fps or v0.get("fps") or 30.0
|
|
262
262
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
263
|
-
pixfmt = "yuv420p10le" if v0.get("
|
|
263
|
+
pixfmt = "yuv420p10le" if v0.get("bt2020_or_hdr") else "yuv420p"
|
|
264
264
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
|
|
265
265
|
|
|
266
266
|
cmd = ffmpeg_base()
|
package/scripts/overlay.py
CHANGED
|
@@ -137,7 +137,7 @@ def main() -> int:
|
|
|
137
137
|
emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build")
|
|
138
138
|
txt.add_argument("--box-color", default="black@0.5")
|
|
139
139
|
enc = ap.add_argument_group("encoding")
|
|
140
|
-
enc.
|
|
140
|
+
enc.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
141
141
|
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
142
142
|
add_common(ap)
|
|
143
143
|
args = ap.parse_args()
|
package/scripts/pad.py
CHANGED
|
@@ -26,7 +26,7 @@ def main() -> int:
|
|
|
26
26
|
ap.add_argument("--start", default="0", help="padding to add before the clip: seconds or mm:ss (default 0)")
|
|
27
27
|
ap.add_argument("--end", default="0", help="padding to add after the clip: seconds or mm:ss (default 0)")
|
|
28
28
|
ap.add_argument("--color", default="black", help="padding colour (default black)")
|
|
29
|
-
ap.
|
|
29
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
30
30
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
31
31
|
add_common(ap)
|
|
32
32
|
args = ap.parse_args()
|
package/scripts/probe.py
CHANGED
|
@@ -48,7 +48,7 @@ def main() -> int:
|
|
|
48
48
|
line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
|
|
49
49
|
if v.get("variable_frame_rate_suspected"):
|
|
50
50
|
line += " (VFR?)"
|
|
51
|
-
if v.get("
|
|
51
|
+
if v.get("bt2020_or_hdr"): # hdr_format also names the BT.2020 SDR case
|
|
52
52
|
line += f" [{v.get('hdr_format')}]"
|
|
53
53
|
if r.get("levels", {}).get("looks_like_log"):
|
|
54
54
|
line += " [Log?]"
|
package/scripts/proxy.py
CHANGED
|
@@ -5,7 +5,7 @@ editing-decision workflows that only need to look at (or feed a model) a
|
|
|
5
5
|
much smaller stand-in for the original.
|
|
6
6
|
|
|
7
7
|
Resizes to --width (default 640px, height follows the source aspect) or by
|
|
8
|
-
--scale factor, re-encodes at a proxy-grade --
|
|
8
|
+
--scale factor, re-encodes at a proxy-grade --quality (default 30 - well above any
|
|
9
9
|
delivery preset's 18-24 in export.py, since a proxy trades visual quality for
|
|
10
10
|
size and speed), and always uses the fastest x264/x265 preset. Keeps the
|
|
11
11
|
source's own dynamic range (an HDR source proxies to HEVC10, same as every
|
|
@@ -39,7 +39,7 @@ def main() -> int:
|
|
|
39
39
|
ap.add_argument("-o", "--output", help="output file (default: <name>_proxy.<ext>)")
|
|
40
40
|
ap.add_argument("--width", type=int, default=640, help="output width in px, height follows the source aspect (default 640)")
|
|
41
41
|
ap.add_argument("--scale", type=float, help="scale factor applied to the source dimensions instead of --width (0 < scale <= 1)")
|
|
42
|
-
ap.
|
|
42
|
+
ap.set_defaults(crf=30) # --quality's default (the --crf alias was removed in 2.0)
|
|
43
43
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate")
|
|
44
44
|
ap.add_argument("--no-audio", action="store_true", help="drop audio entirely (default: keep it)")
|
|
45
45
|
add_common(ap)
|
package/scripts/redact.py
CHANGED
|
@@ -36,7 +36,7 @@ def main() -> int:
|
|
|
36
36
|
ap.add_argument("--block-size", type=int, default=12, help="mosaic block size in px, --mode pixelate only (default 12)")
|
|
37
37
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
38
38
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
39
|
-
ap.
|
|
39
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
40
40
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
41
41
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
42
42
|
add_common(ap)
|
package/scripts/render.py
CHANGED
|
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
64
64
|
|
|
65
65
|
from export import PRESETS, PLATFORM_OF
|
|
66
66
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
67
|
-
from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
67
|
+
from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
|
|
68
68
|
import subprocess
|
|
69
69
|
from _contract import CONTRACT_VERSION
|
|
70
70
|
from batch import file_key
|
|
@@ -718,6 +718,44 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
|
|
|
718
718
|
info(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)")
|
|
719
719
|
|
|
720
720
|
|
|
721
|
+
def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
722
|
+
"""--export-timeline: the project's cut as an editor timeline, nothing rendered (_common.timeline)."""
|
|
723
|
+
from _common import timeline as tlmod
|
|
724
|
+
fmt = tlmod.FORMATS.get(Path(dest).suffix.lower())
|
|
725
|
+
if not fmt:
|
|
726
|
+
die(f"--export-timeline {dest}: unknown timeline format", kind="input",
|
|
727
|
+
hint="name the file .fcpxml, .edl or .otio")
|
|
728
|
+
paths = [rel(c["src"]) for c in proj.get("clips") or [] if c.get("src")]
|
|
729
|
+
if (proj.get("audio") or {}).get("music"):
|
|
730
|
+
paths.append(rel(proj["audio"]["music"]))
|
|
731
|
+
probes: Dict[str, Dict[str, Any]] = {}
|
|
732
|
+
for path in paths:
|
|
733
|
+
if not os.path.exists(path):
|
|
734
|
+
die(f"timeline source not found: {path}")
|
|
735
|
+
probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
|
|
736
|
+
try:
|
|
737
|
+
tl = tlmod.build(proj, probes, rel)
|
|
738
|
+
except tlmod.TimelineError as exc:
|
|
739
|
+
die(f"--export-timeline: {exc}", kind="input")
|
|
740
|
+
text = tlmod.WRITERS[fmt](tl)
|
|
741
|
+
report = tlmod.summary(tl, fmt)
|
|
742
|
+
_check_output_path(["ffmpeg", dest])
|
|
743
|
+
_check_existing_output(["ffmpeg", dest])
|
|
744
|
+
verification: List[Dict[str, Any]] = []
|
|
745
|
+
if not STATE.dry_run:
|
|
746
|
+
try:
|
|
747
|
+
Path(dest).write_text(text, encoding="utf-8")
|
|
748
|
+
except OSError as exc:
|
|
749
|
+
die(f"cannot write {dest}: {exc}", kind="output")
|
|
750
|
+
verification.append({"step": "parse", "ok": tlmod.parses(dest, fmt)})
|
|
751
|
+
for line in report["not_exported"]:
|
|
752
|
+
info(f"not exported: {line}")
|
|
753
|
+
info(("[dry-run] would write " if STATE.dry_run else "wrote ") +
|
|
754
|
+
f"{dest} ({fmt}, {report['clips']} clips, {report['duration']}s at {report['rate']} fps)")
|
|
755
|
+
emit(dest, timeline=report, verification=verification)
|
|
756
|
+
return 0
|
|
757
|
+
|
|
758
|
+
|
|
721
759
|
def main() -> int:
|
|
722
760
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
723
761
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -732,6 +770,10 @@ def main() -> int:
|
|
|
732
770
|
choices=list(STAGE_ORDER),
|
|
733
771
|
help="start at this stage, taking every earlier one from --cache; refuses if "
|
|
734
772
|
"one of them is not there")
|
|
773
|
+
ap.add_argument("--export-timeline", metavar="FILE",
|
|
774
|
+
help="write the project's cut as an editor timeline instead of rendering it: .fcpxml (Final Cut, "
|
|
775
|
+
"Resolve), .edl (CMX 3600: Premiere, Resolve, Avid) or .otio (OpenTimelineIO); clips, speed, "
|
|
776
|
+
"transitions, the music bed and chapter markers carry over, everything else is listed as not exported")
|
|
735
777
|
ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
|
|
736
778
|
tpl = ap.add_argument_group("delivery templates (one command per destination)")
|
|
737
779
|
tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
|
|
@@ -810,12 +852,18 @@ def main() -> int:
|
|
|
810
852
|
if isinstance(proj.get("chapters"), str) and not os.path.exists(rel(proj["chapters"])):
|
|
811
853
|
die(f"chapters file not found: {rel(proj['chapters'])}")
|
|
812
854
|
|
|
855
|
+
if args.export_timeline:
|
|
856
|
+
return export_timeline(proj, rel, args.export_timeline)
|
|
857
|
+
|
|
813
858
|
clips = proj.get("clips") or []
|
|
814
859
|
if not clips:
|
|
815
860
|
die("project.clips is empty")
|
|
816
861
|
output = rel(proj.get("output") or "final.mp4")
|
|
817
862
|
# the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
|
|
818
863
|
refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
|
|
864
|
+
# 2.0 refuses an existing output without --overwrite; the final place_output() would, but
|
|
865
|
+
# only after every stage had run -- say so before the first one instead
|
|
866
|
+
_check_existing_output(["ffmpeg", output])
|
|
819
867
|
# The default work dir name comes only from the output path, with no PID or timestamp --
|
|
820
868
|
# two concurrent render.py runs targeting the same output (a batch.py "project" recipe
|
|
821
869
|
# processing several files in parallel, or simply running render.py twice by mistake) shared
|
package/scripts/report.py
CHANGED
|
@@ -71,7 +71,7 @@ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
|
|
|
71
71
|
["Duration", fmt_dur(meta.get("duration"))],
|
|
72
72
|
["Size", f"{(meta.get('size_bytes') or 0) / 1024 / 1024:.1f} MB"],
|
|
73
73
|
["Video", f"{v.get('codec')} {v.get('width')}×{v.get('height')} @ {v.get('fps')} fps, {v.get('pix_fmt')}" if v else "none"],
|
|
74
|
-
["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("
|
|
74
|
+
["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("bt2020_or_hdr") else " (SDR)")) if v else "—"],
|
|
75
75
|
["Frame rate", ("variable (suspected)" if v.get("variable_frame_rate_suspected") else "constant") if v else "—"],
|
|
76
76
|
["Audio", f"{a.get('codec')} {a.get('channels')} ch {a.get('sample_rate')} Hz" if a else "none"],
|
|
77
77
|
]
|
package/scripts/reverse.py
CHANGED
|
@@ -22,7 +22,7 @@ def main() -> int:
|
|
|
22
22
|
ap.add_argument("input")
|
|
23
23
|
ap.add_argument("-o", "--output", help="output file (default: <name>_reverse.<ext>)")
|
|
24
24
|
ap.add_argument("--no-audio", action="store_true", help="drop audio instead of reversing it")
|
|
25
|
-
ap.
|
|
25
|
+
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
26
26
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
27
27
|
add_common(ap)
|
|
28
28
|
args = ap.parse_args()
|