bladeworks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. bladeworks/__init__.py +56 -0
  2. bladeworks/__main__.py +4 -0
  3. bladeworks/cli.py +728 -0
  4. bladeworks/core/__init__.py +34 -0
  5. bladeworks/core/animation.py +893 -0
  6. bladeworks/core/animation_execution.py +817 -0
  7. bladeworks/core/audio_enhancements.py +673 -0
  8. bladeworks/core/audio_execution.py +1848 -0
  9. bladeworks/core/audio_ir.py +1971 -0
  10. bladeworks/core/basic_effects.py +168 -0
  11. bladeworks/core/bindings.py +196 -0
  12. bladeworks/core/capabilities.py +257 -0
  13. bladeworks/core/cohort_effects.py +702 -0
  14. bladeworks/core/color.py +324 -0
  15. bladeworks/core/compiler.py +3142 -0
  16. bladeworks/core/composition_diagnostics.py +629 -0
  17. bladeworks/core/composition_ir.py +3154 -0
  18. bladeworks/core/composition_planning.py +708 -0
  19. bladeworks/core/composition_shadow.py +687 -0
  20. bladeworks/core/compositor.py +1186 -0
  21. bladeworks/core/effect_parameter_semantics.py +165 -0
  22. bladeworks/core/effect_parameters.py +193 -0
  23. bladeworks/core/errors.py +21 -0
  24. bladeworks/core/filter_text.py +48 -0
  25. bladeworks/core/geometry.py +1711 -0
  26. bladeworks/core/group_surface.py +664 -0
  27. bladeworks/core/keyer.py +280 -0
  28. bladeworks/core/masks.py +453 -0
  29. bladeworks/core/missing_media.py +130 -0
  30. bladeworks/core/model.py +985 -0
  31. bladeworks/core/multicam_execution.py +496 -0
  32. bladeworks/core/output_color.py +52 -0
  33. bladeworks/core/parser.py +1257 -0
  34. bladeworks/core/pixel_domains.py +433 -0
  35. bladeworks/core/render_profile.py +311 -0
  36. bladeworks/core/render_sources.py +475 -0
  37. bladeworks/core/report.py +118 -0
  38. bladeworks/core/retime.py +967 -0
  39. bladeworks/core/retime_execution.py +1515 -0
  40. bladeworks/core/spatial_intrinsics.py +1749 -0
  41. bladeworks/core/story_containers.py +524 -0
  42. bladeworks/core/story_ir.py +1042 -0
  43. bladeworks/core/text.py +1032 -0
  44. bladeworks/core/text_templates.py +934 -0
  45. bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
  46. bladeworks/effects/__init__.py +1 -0
  47. bladeworks/effects/contract.py +162 -0
  48. bladeworks/examples/__init__.py +80 -0
  49. bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
  50. bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
  51. bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
  52. bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
  53. bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
  54. bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
  55. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
  56. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
  57. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
  58. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
  59. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
  60. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
  61. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
  62. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
  63. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
  64. bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
  65. bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
  66. bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
  67. bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
  68. bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
  69. bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
  70. bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
  71. bladeworks/executor.py +293 -0
  72. bladeworks/preview/__init__.py +11 -0
  73. bladeworks/preview/application.py +84 -0
  74. bladeworks/preview/audio.py +104 -0
  75. bladeworks/preview/capabilities.py +457 -0
  76. bladeworks/preview/contracts.py +157 -0
  77. bladeworks/preview/events.py +55 -0
  78. bladeworks/preview/export.py +57 -0
  79. bladeworks/preview/history.py +197 -0
  80. bladeworks/preview/main.py +19 -0
  81. bladeworks/preview/media.py +23 -0
  82. bladeworks/preview/media_library.py +381 -0
  83. bladeworks/preview/media_reporting.py +55 -0
  84. bladeworks/preview/media_routes.py +155 -0
  85. bladeworks/preview/media_visuals.py +225 -0
  86. bladeworks/preview/producer.py +211 -0
  87. bladeworks/preview/provider.py +74 -0
  88. bladeworks/preview/rawframe.py +218 -0
  89. bladeworks/preview/render_jobs.py +311 -0
  90. bladeworks/preview/routes.py +375 -0
  91. bladeworks/preview/runner.py +349 -0
  92. bladeworks/preview/security.py +61 -0
  93. bladeworks/preview/service.py +234 -0
  94. bladeworks/preview/session.py +457 -0
  95. bladeworks/preview/source.py +603 -0
  96. bladeworks/preview/source_routes.py +151 -0
  97. bladeworks/preview/studio.py +77 -0
  98. bladeworks/preview/webrtc.py +295 -0
  99. bladeworks/spatial_luts/manifest.json +17 -0
  100. bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
  101. bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
  102. bladeworks/studio_static/__init__.py +1 -0
  103. bladeworks/studio_static/app.js +6519 -0
  104. bladeworks/studio_static/build-id.js +1 -0
  105. bladeworks/studio_static/capability-ui.js +68 -0
  106. bladeworks/studio_static/clip-state.js +335 -0
  107. bladeworks/studio_static/fcpxml.js +3313 -0
  108. bladeworks/studio_static/fixtures.js +393 -0
  109. bladeworks/studio_static/history.js +50 -0
  110. bladeworks/studio_static/index.html +24 -0
  111. bladeworks/studio_static/magnetic-timeline.js +1194 -0
  112. bladeworks/studio_static/mask-ui.js +189 -0
  113. bladeworks/studio_static/media-visuals.js +224 -0
  114. bladeworks/studio_static/mock-capabilities.js +409 -0
  115. bladeworks/studio_static/retime-ui.js +151 -0
  116. bladeworks/studio_static/runtime.js +1652 -0
  117. bladeworks/studio_static/scope-ui.js +150 -0
  118. bladeworks/studio_static/styles.css +1558 -0
  119. bladeworks/studio_static/templates/catalog.js +77 -0
  120. bladeworks/studio_static/templates/icons.js +84 -0
  121. bladeworks/studio_static/templates/inspector.js +553 -0
  122. bladeworks/studio_static/templates/library.js +61 -0
  123. bladeworks/studio_static/templates/media.js +185 -0
  124. bladeworks/studio_static/templates/shared.js +43 -0
  125. bladeworks/studio_static/templates/shell.js +94 -0
  126. bladeworks/studio_static/templates/timeline.js +377 -0
  127. bladeworks/studio_static/templates/topbar.js +83 -0
  128. bladeworks/studio_static/templates/types.js +7 -0
  129. bladeworks/studio_static/templates/viewer.js +174 -0
  130. bladeworks/studio_static/templates.js +31 -0
  131. bladeworks/studio_static/types.js +18 -0
  132. bladeworks/studio_static/ui.js +97 -0
  133. bladeworks/studio_static/viewer-geometry.js +55 -0
  134. bladeworks/tensor/__init__.py +131 -0
  135. bladeworks/tensor/audio_delivery.py +280 -0
  136. bladeworks/tensor/audio_pyav.py +360 -0
  137. bladeworks/tensor/blend.py +217 -0
  138. bladeworks/tensor/color.py +53 -0
  139. bladeworks/tensor/composite.py +33 -0
  140. bladeworks/tensor/decode.py +1309 -0
  141. bladeworks/tensor/decode_policy.py +363 -0
  142. bladeworks/tensor/effects.py +322 -0
  143. bladeworks/tensor/encode.py +614 -0
  144. bladeworks/tensor/errors.py +32 -0
  145. bladeworks/tensor/expr.py +775 -0
  146. bladeworks/tensor/fx_basic.py +946 -0
  147. bladeworks/tensor/fx_branched.py +640 -0
  148. bladeworks/tensor/fx_cohort.py +404 -0
  149. bladeworks/tensor/fx_color.py +851 -0
  150. bladeworks/tensor/fx_keyer.py +140 -0
  151. bladeworks/tensor/fx_mask.py +352 -0
  152. bladeworks/tensor/fx_warp.py +337 -0
  153. bladeworks/tensor/hdr.py +122 -0
  154. bladeworks/tensor/pipeline.py +401 -0
  155. bladeworks/tensor/plan.py +2231 -0
  156. bladeworks/tensor/renderer.py +1199 -0
  157. bladeworks/tensor/requirements.txt +28 -0
  158. bladeworks/tensor/resolution.py +246 -0
  159. bladeworks/tensor/sampler.py +598 -0
  160. bladeworks/tensor/support.py +169 -0
  161. bladeworks/tensor/swscale_fixedpoint.py +181 -0
  162. bladeworks/tensor/tr_equirect.py +420 -0
  163. bladeworks/tensor/tr_handlers.py +443 -0
  164. bladeworks/tensor/tr_phase5.py +697 -0
  165. bladeworks/tensor/transitions.py +424 -0
  166. bladeworks/transitions/__init__.py +10 -0
  167. bladeworks/transitions/carryovers.py +299 -0
  168. bladeworks/transitions/cohort.py +981 -0
  169. bladeworks/transitions/contract.py +490 -0
  170. bladeworks/transitions/equirectangular.py +915 -0
  171. bladeworks/transitions/light_deco.py +141 -0
  172. bladeworks/transitions/panel_motion.py +495 -0
  173. bladeworks/transitions/stock.py +270 -0
  174. bladeworks-0.1.0.dist-info/METADATA +66 -0
  175. bladeworks-0.1.0.dist-info/RECORD +178 -0
  176. bladeworks-0.1.0.dist-info/WHEEL +5 -0
  177. bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
  178. bladeworks-0.1.0.dist-info/top_level.txt +1 -0
bladeworks/cli.py ADDED
@@ -0,0 +1,728 @@
1
+ """Command-line interface for inspecting and rendering portable FCPXML.
2
+
3
+ Architecture map
4
+ ----------------
5
+ This module is the ``bladeworks`` front door (the CLI is installed as the
6
+ ``bladeworks`` console script; the module entry point remains available for
7
+ development inside Bladeworks
8
+ via ``__main__.py``). It hosts a small family of subcommands:
9
+
10
+ - ``render`` -- compile an ``.fcpxml`` / ``.fcpxmld`` and execute a render.
11
+ Defaults to the ``tensor`` backend; ``-o/--output`` is
12
+ optional (``<stem>.mp4``, or ``.mov`` under ``--alpha``).
13
+ - ``inspect`` -- parse + classify a project without rendering.
14
+ - ``projects`` -- list the projects a library/bundle holds (browse the names
15
+ and UIDs you can pass to ``--project``), without compiling.
16
+ - ``examples`` -- ``ls`` the packaged sample projects or ``cp`` one out.
17
+ - ``doctor`` -- check runtime prerequisites (``ffprobe`` on PATH, the torch
18
+ device, and component versions).
19
+ - ``server`` -- run one opened bundle as a foreground local API, or check a
20
+ running server's health.
21
+ - ``studio`` -- run the same API plus the packaged browser editor.
22
+
23
+ Why the ``--oracle-mezzanine`` flag is hidden rather than removed
24
+ -----------------------------------------------------------------
25
+ ``--oracle-mezzanine`` (a 10-bit ProRes comparison master) is a dev / evidence
26
+ path, not a user-facing delivery, so it is suppressed from ``--help``. It is
27
+ NOT deleted: the CPU evidence harness and the tensor-backend rejection test
28
+ still drive it by name, so the option keeps working -- it just no longer
29
+ advertises itself.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ from pathlib import Path
42
+ from typing import Optional, Sequence
43
+
44
+
45
+
46
+ def build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(prog="bladeworks", description="Bladeworks renderer: portable FCPXML to video")
48
+ subparsers = parser.add_subparsers(dest="command", required=True)
49
+
50
+ inspect_parser = subparsers.add_parser("inspect", help="parse and classify an FCPXML document without rendering")
51
+ inspect_parser.add_argument("input", type=Path)
52
+ inspect_parser.add_argument("--project", help="exact project name or UID to select from a full library export")
53
+ inspect_parser.add_argument("--bindings", type=Path)
54
+ inspect_parser.add_argument("--report", type=Path)
55
+ inspect_parser.add_argument("--emit-plan", type=Path, help="write the resolved render document as JSON")
56
+ inspect_parser.add_argument("--strict", action="store_true", help="fail when any construct is approximated or omitted")
57
+
58
+ projects_parser = subparsers.add_parser(
59
+ "projects",
60
+ help="list the projects a .fcpxml file / .fcpxmld bundle contains (for --project selection)",
61
+ )
62
+ projects_parser.add_argument("input", type=Path, help="a plain .fcpxml file OR a .fcpxmld bundle directory")
63
+ projects_parser.add_argument(
64
+ "--json",
65
+ action="store_true",
66
+ help="emit a machine-readable [{event, name, uid}, ...] array instead of the grouped listing",
67
+ )
68
+
69
+ render_parser = subparsers.add_parser("render", help="compile and execute a render (default backend: tensor)")
70
+ render_parser.add_argument("input", type=Path, help="a plain .fcpxml file OR a .fcpxmld bundle directory")
71
+ render_parser.add_argument("--project", help="exact project name or UID to select from a full library export")
72
+ render_parser.add_argument(
73
+ "-o",
74
+ "--output",
75
+ type=Path,
76
+ default=None,
77
+ help="output path; default <input-stem>.mp4 (or <input-stem>.mov with --alpha)",
78
+ )
79
+ render_parser.add_argument("--bindings", type=Path)
80
+ render_parser.add_argument("--report", type=Path)
81
+ render_parser.add_argument("--manifest", type=Path)
82
+ render_parser.add_argument("--emit-plan", type=Path)
83
+ render_parser.add_argument(
84
+ "--backend",
85
+ choices=("tensor",),
86
+ default="tensor",
87
+ help=(
88
+ "pixel backend (default: tensor): tensor is the PyTorch renderer "
89
+ "(video rendered on tensors, audio still the calibrated FFmpeg "
90
+ "graph); cpu is the correctness reference; vulkan is strict; auto "
91
+ "falls back for the complete render with a reason and never picks "
92
+ "tensor"
93
+ ),
94
+ )
95
+ render_parser.add_argument(
96
+ "--device",
97
+ choices=("auto", "mps", "cuda", "cpu"),
98
+ default="auto",
99
+ help=(
100
+ "torch device for --backend tensor (default: auto = mps then cuda then cpu). "
101
+ "Fleet/gym must pass mps; an explicit mps/cuda pin fails loudly if missing"
102
+ ),
103
+ )
104
+ render_parser.add_argument(
105
+ "--video-only",
106
+ action="store_true",
107
+ help="render video with a silent output track and explicitly omit source audio",
108
+ )
109
+ # Hidden dev/evidence path: a 10-bit 4:2:2 ProRes comparison master. Kept
110
+ # functional (the CPU evidence harness and the tensor rejection test drive
111
+ # it by name) but removed from --help via argparse.SUPPRESS.
112
+ render_parser.add_argument(
113
+ "--oracle-mezzanine",
114
+ action="store_true",
115
+ help=argparse.SUPPRESS,
116
+ )
117
+ render_parser.add_argument(
118
+ "--alpha",
119
+ action="store_true",
120
+ help=(
121
+ "tensor backend only: write a ProRes 4444 straight-alpha .mov "
122
+ "(transparent root canvas) instead of delivery H.264"
123
+ ),
124
+ )
125
+ render_parser.add_argument("--strict", action="store_true", help="fail before FFmpeg when any construct is approximated or omitted")
126
+ render_parser.add_argument(
127
+ "--render-profile",
128
+ choices=("reference", "fast8"),
129
+ default="reference",
130
+ help=(
131
+ "CPU pixel pipeline: reference is the calibrated 16-bit linear "
132
+ "evidence graph; fast8 is the 8-bit scale-first product graph "
133
+ "(measured ~3x faster, SSIM >= 0.99 vs reference)"
134
+ ),
135
+ )
136
+ render_parser.add_argument(
137
+ "--encoder-preset",
138
+ default=None,
139
+ help="x264 preset override for delivery encodes (e.g. veryfast); default medium",
140
+ )
141
+ render_parser.add_argument(
142
+ "--cpu-segments",
143
+ action="store_true",
144
+ help=(
145
+ "render the CPU plan as independent time segments (bounded decoders "
146
+ "per process, segments run in parallel, then assembled); fails loudly "
147
+ "when the plan-authoritative CPU emitter does not accept the project"
148
+ ),
149
+ )
150
+ render_parser.add_argument(
151
+ "--segment-max-frames",
152
+ type=int,
153
+ default=None,
154
+ help="split segments so none exceeds this many frames (default: split only under source pressure)",
155
+ )
156
+ render_parser.add_argument(
157
+ "--segment-max-sources",
158
+ type=int,
159
+ default=8,
160
+ help="maximum distinct physical sources (decoders) opened by one segment process",
161
+ )
162
+ render_parser.add_argument(
163
+ "--segment-parallel",
164
+ type=int,
165
+ default=max(1, (os.cpu_count() or 2) - 1),
166
+ help="how many segment processes run at once",
167
+ )
168
+ render_parser.add_argument(
169
+ "--no-segment-narrowing",
170
+ action="store_true",
171
+ help="keep every segment source graph identical to the whole-plan emitter (decode from each clip's start)",
172
+ )
173
+ render_parser.add_argument(
174
+ "--no-progress",
175
+ action="store_true",
176
+ help="disable the tensor backend's completed-frame progress bar",
177
+ )
178
+
179
+ examples_parser = subparsers.add_parser("examples", help="list or copy out the packaged sample projects")
180
+ examples_sub = examples_parser.add_subparsers(dest="examples_command", required=True)
181
+ examples_sub.add_parser("ls", help="list the packaged sample projects (name + one-line description)")
182
+ examples_cp = examples_sub.add_parser("cp", help="copy a packaged sample .fcpxmld bundle to DEST")
183
+ examples_cp.add_argument("name", help="sample project name (see 'examples ls')")
184
+ examples_cp.add_argument(
185
+ "dest",
186
+ nargs="?",
187
+ type=Path,
188
+ default=Path("."),
189
+ help="destination directory (default: current directory)",
190
+ )
191
+
192
+ subparsers.add_parser("doctor", help="check runtime prerequisites (ffprobe, torch device, versions)")
193
+
194
+ server_parser = subparsers.add_parser("server", help="serve one .fcpxmld bundle on localhost")
195
+ server_sub = server_parser.add_subparsers(dest="server_command", required=True)
196
+ server_run = server_sub.add_parser("run", help="run one foreground Bladeworks server")
197
+ server_run.add_argument("input", type=Path, help="an existing .fcpxmld bundle directory")
198
+ _add_local_runtime_options(server_run, default_port=8765)
199
+ server_health = server_sub.add_parser("health", help="check liveness and readiness")
200
+ server_health.add_argument("--url", required=True, help="server base URL from its ready record")
201
+ server_health.add_argument("--timeout", type=float, default=2.0)
202
+
203
+ studio_parser = subparsers.add_parser(
204
+ "studio",
205
+ help="open one .fcpxmld bundle in the packaged local web editor",
206
+ )
207
+ studio_parser.add_argument("input", type=Path, help="an existing .fcpxmld bundle directory")
208
+ _add_local_runtime_options(studio_parser, default_port=0)
209
+ studio_parser.add_argument(
210
+ "--no-open",
211
+ action="store_true",
212
+ help="serve Studio without launching a browser",
213
+ )
214
+
215
+ return parser
216
+
217
+
218
+ def _add_local_runtime_options(command: argparse.ArgumentParser, *, default_port: int) -> None:
219
+ """Add the identical API runtime controls to server and Studio commands."""
220
+
221
+ command.add_argument("--host", default="127.0.0.1", choices=("127.0.0.1",))
222
+ command.add_argument(
223
+ "--port",
224
+ type=int,
225
+ default=default_port,
226
+ help="listen port; 0 selects an available port",
227
+ )
228
+ command.add_argument("--device", choices=("auto", "mps", "cuda", "cpu"), default="auto")
229
+ command.add_argument("--decoder-threads", type=int, default=2)
230
+ command.add_argument("--history-limit", type=int, default=50)
231
+ command.add_argument("--render-dir", type=Path, default=None)
232
+ command.add_argument(
233
+ "--log-level",
234
+ choices=("debug", "info", "warning", "error"),
235
+ default="info",
236
+ )
237
+ command.add_argument("--strict", action="store_true")
238
+ command.add_argument(
239
+ "--allow-origin",
240
+ action="append",
241
+ default=[],
242
+ help="explicit loopback browser origin; repeat for multiple origins",
243
+ )
244
+
245
+
246
+ def _output_profile(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str:
247
+ """Resolve the render command's output profile from its flags, loudly on conflicts.
248
+
249
+ ``--alpha`` is the tensor backend's ProRes 4444 straight-alpha exit
250
+ (``executor._TENSOR_OUTPUT_PROFILES``); no other backend has an
251
+ alpha-carrying delivery, so it is refused here by name rather than
252
+ surfacing as an unknown-profile error deep in the CPU graph builder.
253
+ """
254
+
255
+ if args.alpha and args.oracle_mezzanine:
256
+ parser.error("--alpha and --oracle-mezzanine are mutually exclusive output profiles")
257
+ if args.alpha and args.backend != "tensor":
258
+ parser.error("--alpha (ProRes 4444 straight-alpha delivery) is implemented by --backend tensor only")
259
+ if args.alpha:
260
+ return "delivery_alpha"
261
+ return "oracle_mezzanine" if args.oracle_mezzanine else "delivery"
262
+
263
+
264
+ def _resolve_render_output(args: argparse.Namespace) -> Path:
265
+ """Return the render command's output path, defaulting from the input stem.
266
+
267
+ Why this exists: ``-o/--output`` is optional. When omitted we deliver next
268
+ to the input using the container the chosen profile implies -- ``.mov`` for
269
+ the tensor ``--alpha`` (ProRes 4444) exit, ``.mp4`` otherwise. A
270
+ ``.fcpxmld`` bundle directory's stem is the bundle name (``Info.fcpxml``
271
+ lives INSIDE it), so ``single_clip.fcpxmld`` -> ``single_clip.mp4``.
272
+ """
273
+
274
+ if args.output is not None:
275
+ return args.output
276
+ suffix = ".mov" if args.alpha else ".mp4"
277
+ return args.input.with_suffix(suffix)
278
+
279
+
280
+ def main(argv: Optional[Sequence[str]] = None) -> int:
281
+ parser = build_parser()
282
+ args = parser.parse_args(argv)
283
+
284
+ # ``examples`` and ``doctor`` never compile a project, so they are handled
285
+ # before the compile path (they do not carry input/project/bindings).
286
+ if args.command == "examples":
287
+ return _run_examples(parser, args)
288
+ if args.command == "doctor":
289
+ return _run_doctor()
290
+ if args.command == "projects":
291
+ return _run_projects(args)
292
+ if args.command == "server":
293
+ return _run_server_command(parser, args)
294
+ if args.command == "studio":
295
+ return _run_studio_command(parser, args)
296
+
297
+ if args.command == "render":
298
+ args.output = _resolve_render_output(args)
299
+
300
+ from .core.compiler import compile_fcpxml
301
+ from .core.errors import FCPXMLRenderError
302
+ from .core.model import dataclass_json, fraction_json
303
+ from .executor import execute_render
304
+
305
+ compiled = None
306
+ try:
307
+ compiled = compile_fcpxml(args.input, project=args.project, bindings_path=args.bindings)
308
+ if args.command == "inspect":
309
+ report_path = args.report.expanduser().resolve() if args.report else args.input.expanduser().resolve().with_suffix(".compatibility.json")
310
+ compiled.report.write(report_path)
311
+ if args.emit_plan:
312
+ plan_path = args.emit_plan.expanduser().resolve()
313
+ plan_path.parent.mkdir(parents=True, exist_ok=True)
314
+ plan_path.write_text(
315
+ json.dumps(dataclass_json(compiled.render), indent=2, sort_keys=True) + "\n",
316
+ encoding="utf-8",
317
+ )
318
+ payload = {
319
+ "schema_version": 1,
320
+ "project_name": compiled.render.project_name,
321
+ "source_sha256": compiled.render.source_sha256,
322
+ "format": {
323
+ "width": compiled.render.width,
324
+ "height": compiled.render.height,
325
+ "frame_duration": fraction_json(compiled.render.frame_duration),
326
+ "frame_count": compiled.render.frame_count,
327
+ },
328
+ "clip_count": len(compiled.render.clips),
329
+ "transition_count": len(compiled.render.transitions),
330
+ "compatibility": compiled.report.to_json(),
331
+ }
332
+ print(json.dumps(payload, indent=2, sort_keys=True))
333
+ if compiled.report.findings:
334
+ print(compiled.report.human_summary(), file=sys.stderr)
335
+ if args.strict and compiled.report.has_strict_failures:
336
+ return 1
337
+ return 0
338
+
339
+ if args.backend == "tensor" and args.device != "auto":
340
+ from .tensor.renderer import require_torch_device
341
+
342
+ try:
343
+ require_torch_device(args.device)
344
+ except Exception as exc:
345
+ print(f"error: {exc}", file=sys.stderr)
346
+ return 1
347
+ result = execute_render(
348
+ compiled.render,
349
+ compiled.report,
350
+ output_path=args.output,
351
+ report_path=args.report,
352
+ manifest_path=args.manifest,
353
+ emit_plan_path=args.emit_plan,
354
+ strict=args.strict,
355
+ video_only=args.video_only,
356
+ output_profile=_output_profile(parser, args),
357
+ backend=args.backend,
358
+ device=None if args.device == "auto" else args.device,
359
+ cpu_segmentation=None,
360
+ cpu_segment_parallelism=args.segment_parallel,
361
+ render_profile=args.render_profile,
362
+ encoder_preset=args.encoder_preset,
363
+ show_progress=args.backend == "tensor" and not args.no_progress,
364
+ )
365
+ if compiled.report.findings:
366
+ print(compiled.report.human_summary(), file=sys.stderr)
367
+ print(
368
+ json.dumps(
369
+ {
370
+ "output": str(result.output_path),
371
+ "report": str(result.report_path),
372
+ "manifest": str(result.manifest_path),
373
+ "degraded": result.degraded,
374
+ "requested_backend": result.requested_backend,
375
+ "selected_backend": result.selected_backend,
376
+ },
377
+ indent=2,
378
+ sort_keys=True,
379
+ )
380
+ )
381
+ return 0
382
+ except FCPXMLRenderError as exc:
383
+ if args.command == "render" and compiled is None:
384
+ _write_compile_failure_artifacts(args, exc)
385
+ print(f"error: {exc}", file=sys.stderr)
386
+ return 1
387
+
388
+
389
+ def _write_compile_failure_artifacts(args: argparse.Namespace, error: Exception) -> None:
390
+ """Persist render diagnostics when parsing/compilation never produced an IR.
391
+
392
+ Main callers:
393
+ - ``main`` for failures before ``execute_render`` takes ownership.
394
+ """
395
+
396
+ input_path = args.input.expanduser().resolve()
397
+ output_path = args.output.expanduser().resolve()
398
+ report_path = args.report.expanduser().resolve() if args.report else output_path.with_suffix(".compatibility.json")
399
+ manifest_path = args.manifest.expanduser().resolve() if args.manifest else output_path.with_suffix(".manifest.json")
400
+ try:
401
+ digest = hashlib.sha256(input_path.read_bytes()).hexdigest()
402
+ except OSError:
403
+ digest = None
404
+ finding = {
405
+ "outcome": "failed",
406
+ "portable_status": "unsupported",
407
+ "fcpxml_path": "fcpxml",
408
+ "construct": "portable render compilation",
409
+ "uid": None,
410
+ "timeline_start": None,
411
+ "timeline_duration": None,
412
+ "disposition": str(error),
413
+ }
414
+ report = {
415
+ "schema_version": 1,
416
+ "source_path": str(input_path),
417
+ "source_sha256": digest,
418
+ "project_name": None,
419
+ "timeline_start": None,
420
+ "timeline_duration": None,
421
+ "degraded": False,
422
+ "counts": {"exact": 0, "approximated": 0, "omitted": 0, "failed": 1, "info": 0},
423
+ "findings": [finding],
424
+ }
425
+ manifest = {
426
+ "schema_version": 1,
427
+ "engine": "bladeworks-portable-fcpxml",
428
+ "engine_version": 1,
429
+ "status": "failed",
430
+ "error": str(error),
431
+ "source_path": str(input_path),
432
+ "source_sha256": digest,
433
+ "project_name": None,
434
+ "ffmpeg_version": None,
435
+ "render_backend": {
436
+ "requested": getattr(args, "backend", "cpu"),
437
+ "selected": None,
438
+ "fallback_reason": None,
439
+ },
440
+ "output": {"path": str(output_path)},
441
+ "asset_bindings": [],
442
+ "font_bindings": [],
443
+ "compatibility": {
444
+ "report_path": str(report_path),
445
+ "degraded": False,
446
+ "counts": report["counts"],
447
+ },
448
+ }
449
+ report_path.parent.mkdir(parents=True, exist_ok=True)
450
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
451
+ report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
452
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
453
+
454
+
455
+ def _project_label(project) -> str:
456
+ """Format one project as ``Name [uid]`` with loud fallbacks.
457
+
458
+ Mirrors ``parser._project_choices`` so the browse listing and the parser's
459
+ ambiguity/selection errors name the very same projects the same way.
460
+ """
461
+
462
+ name = project.get("name") or "Untitled Project"
463
+ uid = project.get("uid") or "no uid"
464
+ return f"{name} [{uid}]"
465
+
466
+
467
+ def _run_projects(args: argparse.Namespace) -> int:
468
+ """List the projects a ``.fcpxml`` file / ``.fcpxmld`` bundle contains.
469
+
470
+ Why this exists: ``render``/``inspect`` take ``--project NAME_OR_UID`` and
471
+ the parser's ambiguity error already names the choices, but there was no
472
+ NON-error way to browse them. This gives one.
473
+
474
+ Steps, procedurally:
475
+ 1. Resolve + securely read the input into its ``<fcpxml>`` root
476
+ (``read_fcpxml_root`` handles the ``.fcpxmld`` bundle -> ``Info.fcpxml``
477
+ resolution), catching parse errors as a loud non-zero exit.
478
+ 2. Enumerate ``(event, project)`` pairs via ``list_library_projects`` -- the
479
+ SAME source of truth ``_select_project`` uses, so the browse view and
480
+ the selectable set never drift apart.
481
+ 3. Fail loudly (non-zero, no silent fallback) when the document holds no
482
+ project inside a library event, using the parser's exact message.
483
+ 4. Print either a ``--json`` array or a human listing grouped by
484
+ library -> event, ending with a copy-pasteable ``render`` hint.
485
+
486
+ Main callers: ``main`` for the ``projects`` subcommand.
487
+ """
488
+
489
+ from .core.errors import FCPXMLParseError
490
+ from .core.parser import list_library_projects, read_fcpxml_root
491
+
492
+ try:
493
+ root = read_fcpxml_root(args.input)
494
+ except FCPXMLParseError as exc:
495
+ print(f"error: {exc}", file=sys.stderr)
496
+ return 1
497
+
498
+ candidates = list_library_projects(root)
499
+ if not candidates:
500
+ # Match the parser's message verbatim; no silent fallback.
501
+ print(
502
+ "error: document does not contain a project inside a library event",
503
+ file=sys.stderr,
504
+ )
505
+ return 1
506
+
507
+ if args.json:
508
+ payload = [
509
+ {"event": event.get("name"), "name": project.get("name"), "uid": project.get("uid")}
510
+ for event, project in candidates
511
+ ]
512
+ print(json.dumps(payload, indent=2))
513
+ return 0
514
+
515
+ # Human listing grouped by library -> event. ``candidates`` is in document
516
+ # order, so an event's projects are contiguous; we map each event back to
517
+ # its owning library element to print the two levels of headers. Identity
518
+ # (``id(...)``) is the group key so libraries/events that share a name still
519
+ # print as distinct groups.
520
+ library_of_event = {}
521
+ for library in root:
522
+ if _tag_name(library) != "library":
523
+ continue
524
+ for event in library:
525
+ if _tag_name(event) == "event":
526
+ library_of_event[id(event)] = library
527
+
528
+ last_library_id = None
529
+ last_event_id = None
530
+ for event, project in candidates:
531
+ library = library_of_event[id(event)]
532
+ if id(library) != last_library_id:
533
+ print(f"library: {library.get('name') or 'Untitled Library'}")
534
+ last_library_id = id(library)
535
+ last_event_id = None
536
+ if id(event) != last_event_id:
537
+ print(f" event: {event.get('name') or 'Untitled Event'}")
538
+ last_event_id = id(event)
539
+ print(f" {_project_label(project)}")
540
+
541
+ print(f"render one with: bladeworks render {args.input} --project NAME_OR_UID")
542
+ return 0
543
+
544
+
545
+ def _tag_name(element) -> str:
546
+ """Local-name of an element, stripping any XML namespace (like parser._tag)."""
547
+
548
+ return element.tag.rsplit("}", 1)[-1]
549
+
550
+
551
+ def _run_examples(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
552
+ """Handle ``examples ls`` and ``examples cp`` against the packaged manifest.
553
+
554
+ - ``ls`` prints each sample's name and one-line description (display order
555
+ is the manifest order).
556
+ - ``cp <name> [DEST]`` copies the sample ``.fcpxmld`` bundle into DEST
557
+ (default the current directory). It errors loudly -- never silently -- on
558
+ an unknown name (surfaces the known names) or when the target bundle
559
+ already exists at DEST (no silent overwrite of a user's file).
560
+
561
+ Main callers: ``main`` for the ``examples`` subcommand.
562
+ """
563
+
564
+ from .examples import EXAMPLES, example_bundle
565
+
566
+ if args.examples_command == "ls":
567
+ width = max(len(name) for name in EXAMPLES)
568
+ for name, example in EXAMPLES.items():
569
+ print(f"{name.ljust(width)} {example.description}")
570
+ return 0
571
+
572
+ # examples cp
573
+ try:
574
+ source = example_bundle(args.name)
575
+ except KeyError as exc:
576
+ # str(KeyError) wraps the message in quotes; use the raw argument.
577
+ print(f"error: {exc.args[0]}", file=sys.stderr)
578
+ return 1
579
+
580
+ dest_dir = args.dest.expanduser()
581
+ target = dest_dir / source.name
582
+ if target.exists():
583
+ print(f"error: destination already exists: {target} (refusing to overwrite)", file=sys.stderr)
584
+ return 1
585
+ dest_dir.mkdir(parents=True, exist_ok=True)
586
+ shutil.copytree(source, target)
587
+ print(str(target))
588
+ return 0
589
+
590
+
591
+ def _run_doctor() -> int:
592
+ """Report runtime prerequisites and exit non-zero if a hard one is missing.
593
+
594
+ Checks, in order:
595
+ - ``ffprobe`` on PATH (HARD prerequisite: probing shells out to it). If
596
+ absent, print a loud install hint and make the overall exit non-zero.
597
+ - The torch compute device the tensor backend would select (mps > cuda >
598
+ cpu), matching ``renderer._select_device``'s preference order.
599
+ - Versions of python, torch, av (PyAV), and ffprobe, so a bug report can
600
+ pin the toolchain.
601
+
602
+ Main callers: ``main`` for the ``doctor`` subcommand.
603
+ """
604
+
605
+ ok = True
606
+
607
+ ffprobe = shutil.which("ffprobe")
608
+ if ffprobe:
609
+ print(f"ffprobe: OK ({ffprobe})")
610
+ else:
611
+ ok = False
612
+ print("ffprobe: MISSING -- required for source probing.", file=sys.stderr)
613
+ print(" install hint: `brew install ffmpeg` (macOS) or your distro's ffmpeg package.", file=sys.stderr)
614
+
615
+ print(f"python: {sys.version.split()[0]} ({sys.executable})")
616
+
617
+ try:
618
+ import torch # local import: torch is heavy and only needed here / in the tensor backend
619
+
620
+ device = "cpu"
621
+ if torch.backends.mps.is_available():
622
+ device = "mps"
623
+ elif torch.cuda.is_available():
624
+ device = "cuda"
625
+ print(f"torch: {torch.__version__} (device: {device})")
626
+ except Exception as exc: # noqa: BLE001 - report, don't crash the doctor
627
+ ok = False
628
+ print(f"torch: UNAVAILABLE ({exc})", file=sys.stderr)
629
+
630
+ try:
631
+ import av # PyAV: decode/encode backing the tensor renderer
632
+
633
+ print(f"av (PyAV): {av.__version__}")
634
+ except Exception as exc: # noqa: BLE001
635
+ ok = False
636
+ print(f"av (PyAV): UNAVAILABLE ({exc})", file=sys.stderr)
637
+
638
+ if ffprobe:
639
+ print(f"ffprobe version: {_ffprobe_version(ffprobe)}")
640
+
641
+ return 0 if ok else 1
642
+
643
+
644
+ def _run_server_command(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
645
+ """Dispatch the lazy server commands without burdening render startup."""
646
+
647
+ from .core.errors import FCPXMLRenderError
648
+ from .preview.runner import ServerConfig, check_health, run_server
649
+
650
+ if args.server_command == "health":
651
+ if args.timeout <= 0:
652
+ parser.error("server health --timeout must be greater than zero")
653
+ return check_health(args.url, timeout=args.timeout)
654
+ if not 0 <= args.port <= 65535:
655
+ parser.error("server run --port must be between 0 and 65535")
656
+ if args.decoder_threads < 1:
657
+ parser.error("server run --decoder-threads must be at least 1")
658
+ if args.history_limit < 1:
659
+ parser.error("server run --history-limit must be at least 1")
660
+ try:
661
+ return run_server(
662
+ ServerConfig(
663
+ source=args.input,
664
+ host=args.host,
665
+ port=args.port,
666
+ device=args.device,
667
+ decoder_threads=args.decoder_threads,
668
+ history_limit=args.history_limit,
669
+ render_directory=args.render_dir,
670
+ log_level=args.log_level,
671
+ strict=args.strict,
672
+ allowed_origins=tuple(args.allow_origin),
673
+ )
674
+ )
675
+ except (OSError, RuntimeError, ValueError, FCPXMLRenderError) as error:
676
+ print(f"error: {error}", file=sys.stderr)
677
+ return 1
678
+
679
+
680
+ def _run_studio_command(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
681
+ """Run the same local API with packaged static assets mounted at root."""
682
+
683
+ from .core.errors import FCPXMLRenderError
684
+ from .preview.runner import ServerConfig, run_studio
685
+
686
+ if not 0 <= args.port <= 65535:
687
+ parser.error("studio --port must be between 0 and 65535")
688
+ if args.decoder_threads < 1:
689
+ parser.error("studio --decoder-threads must be at least 1")
690
+ if args.history_limit < 1:
691
+ parser.error("studio --history-limit must be at least 1")
692
+ try:
693
+ return run_studio(
694
+ ServerConfig(
695
+ source=args.input,
696
+ host=args.host,
697
+ port=args.port,
698
+ device=args.device,
699
+ decoder_threads=args.decoder_threads,
700
+ history_limit=args.history_limit,
701
+ render_directory=args.render_dir,
702
+ log_level=args.log_level,
703
+ strict=args.strict,
704
+ allowed_origins=tuple(args.allow_origin),
705
+ ),
706
+ open_browser=not args.no_open,
707
+ )
708
+ except (OSError, RuntimeError, ValueError, FCPXMLRenderError) as error:
709
+ print(f"error: {error}", file=sys.stderr)
710
+ return 1
711
+
712
+
713
+ def _ffprobe_version(ffprobe: str) -> str:
714
+ """Return the ffprobe version string, or a loud placeholder on failure."""
715
+
716
+ try:
717
+ result = subprocess.run(
718
+ (ffprobe, "-v", "error", "-show_entries", "program_version=version",
719
+ "-of", "default=noprint_wrappers=1:nokey=1"),
720
+ capture_output=True, text=True, check=True,
721
+ )
722
+ return result.stdout.strip() or "unknown"
723
+ except Exception as exc: # noqa: BLE001
724
+ return f"unknown ({exc})"
725
+
726
+
727
+ if __name__ == "__main__":
728
+ raise SystemExit(main())