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.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|