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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""PyAV live-audio producer for the standalone Bladeworks server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from typing import Callable, Iterator
|
|
8
|
+
|
|
9
|
+
import av
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ..core.errors import RenderCapabilityError
|
|
13
|
+
from ..core.model import RenderDocument
|
|
14
|
+
from ..core.report import CompatibilityReport
|
|
15
|
+
from .contracts import ComposedAudioFrame
|
|
16
|
+
from ..tensor import audio_pyav
|
|
17
|
+
from ..tensor.audio_delivery import audio_delivery_layout, resolve_audio_delivery
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FFmpegPreviewAudioProducer:
|
|
21
|
+
"""Expose the tensor audio graph as seekable planar PCM preview chunks."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
document: RenderDocument,
|
|
26
|
+
*,
|
|
27
|
+
report: CompatibilityReport,
|
|
28
|
+
ffprobe_path: str,
|
|
29
|
+
chunk_milliseconds: int = 20,
|
|
30
|
+
**_: object,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.document = document
|
|
33
|
+
self.report = report
|
|
34
|
+
self.ffprobe_path = ffprobe_path
|
|
35
|
+
self.chunk_milliseconds = chunk_milliseconds
|
|
36
|
+
self._closed = False
|
|
37
|
+
|
|
38
|
+
def _samples(self) -> tuple[np.ndarray, int, str]:
|
|
39
|
+
resolution = resolve_audio_delivery(
|
|
40
|
+
self.document,
|
|
41
|
+
ffprobe=self.ffprobe_path,
|
|
42
|
+
report=self.report,
|
|
43
|
+
)
|
|
44
|
+
rate = resolution.output_sample_rate
|
|
45
|
+
layout = audio_delivery_layout(self.document)
|
|
46
|
+
if resolution.mode == "render" and resolution.execution is not None:
|
|
47
|
+
frames = audio_pyav.render_execution_frames(resolution.execution)
|
|
48
|
+
else:
|
|
49
|
+
frames = audio_pyav.render_silence_frames(
|
|
50
|
+
sample_rate=rate,
|
|
51
|
+
ffmpeg_layout=layout,
|
|
52
|
+
duration=resolution.output_duration,
|
|
53
|
+
)
|
|
54
|
+
resampler = av.AudioResampler(format="s16p", layout=layout, rate=rate)
|
|
55
|
+
chunks: list[np.ndarray] = []
|
|
56
|
+
for frame in frames:
|
|
57
|
+
for converted in resampler.resample(frame):
|
|
58
|
+
chunks.append(np.ascontiguousarray(converted.to_ndarray()))
|
|
59
|
+
for converted in resampler.resample(None):
|
|
60
|
+
chunks.append(np.ascontiguousarray(converted.to_ndarray()))
|
|
61
|
+
channels = 1 if layout == "mono" else 2
|
|
62
|
+
samples = np.concatenate(chunks, axis=1) if chunks else np.empty((channels, 0), dtype=np.int16)
|
|
63
|
+
return samples, rate, layout
|
|
64
|
+
|
|
65
|
+
def frames(self, start_time: Fraction, *, is_cancelled) -> Iterator[ComposedAudioFrame]:
|
|
66
|
+
samples, rate, layout = self._samples()
|
|
67
|
+
start = max(0, int(start_time * rate))
|
|
68
|
+
chunk = max(1, rate * self.chunk_milliseconds // 1000)
|
|
69
|
+
for offset in range(start, samples.shape[1], chunk):
|
|
70
|
+
if self._closed or is_cancelled():
|
|
71
|
+
return
|
|
72
|
+
yield ComposedAudioFrame(
|
|
73
|
+
time=Fraction(offset, rate),
|
|
74
|
+
sample_rate=rate,
|
|
75
|
+
layout=layout,
|
|
76
|
+
samples=np.ascontiguousarray(samples[:, offset : offset + chunk]),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
self._closed = True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FFmpegPreviewAudioFactory:
|
|
84
|
+
"""Compatibility-named factory backed entirely by tensor/PyAV audio."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
report_for: Callable[[RenderDocument], CompatibilityReport],
|
|
90
|
+
ffprobe_path: str | None = None,
|
|
91
|
+
**_: object,
|
|
92
|
+
) -> None:
|
|
93
|
+
resolved = ffprobe_path or shutil.which("ffprobe")
|
|
94
|
+
if resolved is None:
|
|
95
|
+
raise RenderCapabilityError("ffprobe is required for Bladeworks preview audio")
|
|
96
|
+
self.report_for = report_for
|
|
97
|
+
self.ffprobe_path = resolved
|
|
98
|
+
|
|
99
|
+
def create(self, document: RenderDocument) -> FFmpegPreviewAudioProducer:
|
|
100
|
+
return FFmpegPreviewAudioProducer(
|
|
101
|
+
document,
|
|
102
|
+
report=self.report_for(document),
|
|
103
|
+
ffprobe_path=self.ffprobe_path,
|
|
104
|
+
)
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Public, renderer-owned capability catalog for Bladeworks Studio.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
tensor effect and transition registries
|
|
7
|
+
+ FCPXML capability registry (UIDs and typed parameter contracts)
|
|
8
|
+
+ core renderer policy (mechanics, media, audio, export)
|
|
9
|
+
-> ``bladeworks_capabilities`` validates registry coverage
|
|
10
|
+
-> authenticated ``GET /api/editor/capabilities``
|
|
11
|
+
-> Studio builds only controls Bladeworks can execute
|
|
12
|
+
|
|
13
|
+
The renderer is the rate limiter. This module does not describe what the
|
|
14
|
+
browser happens to implement today. A missing browser control for an item in
|
|
15
|
+
this response is a Studio bug. Conversely, unsupported entries are explicit
|
|
16
|
+
so the browser cannot accidentally expose controls that Bladeworks rejects.
|
|
17
|
+
|
|
18
|
+
Resource identity is an invariant: every authorable effect and transition has
|
|
19
|
+
the exact FCPXML resource UID from ``FCPXML_RENDER_CAPABILITIES.yaml``. The
|
|
20
|
+
catalog fails loudly when the renderer registry grows without a matching
|
|
21
|
+
resource declaration, preventing a silent UI/backend drift.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import Any, Mapping
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter
|
|
30
|
+
|
|
31
|
+
from ..core.capabilities import Capability, CapabilityRegistry
|
|
32
|
+
from ..core.compositor import _MODE_SPECS # renderer-owned canonical map
|
|
33
|
+
from ..core.masks import MASK_AUTHORING_SOURCES, MASK_BLEND_MODES, MAX_MASKS
|
|
34
|
+
from ..tensor.effects import EFFECT_PORTS
|
|
35
|
+
from ..tensor.tr_equirect import ADMITTED_EQUIRECT_IDS
|
|
36
|
+
from ..tensor.tr_phase5 import PHASE5_IDS
|
|
37
|
+
from ..tensor.resolution import DEFAULT_PROFILE, SUPPORTED_PROFILES, RenderMode
|
|
38
|
+
from ..tensor.transitions import ADMITTED_XFADE_IDS, HANDLERS
|
|
39
|
+
from .render_jobs import STUDIO_EXPORT_PROFILES
|
|
40
|
+
|
|
41
|
+
SCHEMA_VERSION = 1
|
|
42
|
+
|
|
43
|
+
_IDENTITY_EFFECTS = frozenset(
|
|
44
|
+
{"cohort_color_curves", "cohort_hue_saturation_curves"}
|
|
45
|
+
)
|
|
46
|
+
_DEFAULT_ONLY_EFFECTS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"negative",
|
|
49
|
+
"threshold",
|
|
50
|
+
"mirror",
|
|
51
|
+
"colorize",
|
|
52
|
+
"tint",
|
|
53
|
+
"flipped",
|
|
54
|
+
"add_noise",
|
|
55
|
+
"pixellate_default",
|
|
56
|
+
"cohort_drop_shadow",
|
|
57
|
+
"cohort_callout",
|
|
58
|
+
"cohort_directional_blur",
|
|
59
|
+
"cohort_fisheye",
|
|
60
|
+
"cohort_vignette_mask",
|
|
61
|
+
"cohort_kaleidoscope",
|
|
62
|
+
"cohort_perspective_tile",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
_PARTIAL_EFFECTS = frozenset(
|
|
66
|
+
{"cohort_cartoon", "cohort_camcorder", "cohort_focus_blur", "cohort_earthquake"}
|
|
67
|
+
)
|
|
68
|
+
_EXACT_EFFECTS = frozenset({"color_adjustments"})
|
|
69
|
+
_NATIVE_TRANSITION_HANDLERS = frozenset(
|
|
70
|
+
{"cross_dissolve", "fade_color", "wipe", "slide_push"}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parameter(
|
|
75
|
+
key: str,
|
|
76
|
+
raw: Mapping[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
animatable: bool,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Translate one renderer parameter declaration to the stable HTTP shape.
|
|
81
|
+
|
|
82
|
+
Main callers:
|
|
83
|
+
- ``_effect`` and ``_transition`` while materializing the catalog.
|
|
84
|
+
|
|
85
|
+
Why this exists:
|
|
86
|
+
The internal registry contains calibration constants alongside its public
|
|
87
|
+
type contract. Studio needs the exact serialized key and editing bounds,
|
|
88
|
+
but must not depend on private FFmpeg scale factors.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
semantic_type = raw.get("semantic_type")
|
|
92
|
+
raw_type = raw.get("type")
|
|
93
|
+
components = int(raw.get("components", 1))
|
|
94
|
+
if raw_type in {"bool", "boolean"} or semantic_type == "boolean":
|
|
95
|
+
parameter_type = "boolean"
|
|
96
|
+
elif semantic_type == "enum" or "allowed" in raw:
|
|
97
|
+
parameter_type = "enum"
|
|
98
|
+
elif raw_type in {"integer", "color", "position", "vector"}:
|
|
99
|
+
parameter_type = {
|
|
100
|
+
"position": "point",
|
|
101
|
+
"vector": "point",
|
|
102
|
+
}.get(str(raw_type), str(raw_type))
|
|
103
|
+
elif components in {3, 4} and "color" in str(raw.get("name", "")).lower():
|
|
104
|
+
parameter_type = "color"
|
|
105
|
+
else:
|
|
106
|
+
parameter_type = "number"
|
|
107
|
+
|
|
108
|
+
result: dict[str, Any] = {
|
|
109
|
+
"key": key,
|
|
110
|
+
"name": str(raw.get("name", key)),
|
|
111
|
+
"type": parameter_type,
|
|
112
|
+
"animatable": animatable,
|
|
113
|
+
}
|
|
114
|
+
if raw.get("required") is True:
|
|
115
|
+
result["required"] = True
|
|
116
|
+
if "default" in raw:
|
|
117
|
+
if parameter_type == "boolean":
|
|
118
|
+
value = raw["default"]
|
|
119
|
+
result["default"] = value if isinstance(value, bool) else str(value).strip().casefold() in {"1", "true"}
|
|
120
|
+
else:
|
|
121
|
+
result["default"] = (
|
|
122
|
+
str(raw["default"]) if parameter_type == "enum" else raw["default"]
|
|
123
|
+
)
|
|
124
|
+
if "minimum" in raw:
|
|
125
|
+
result["min"] = raw["minimum"]
|
|
126
|
+
if "maximum" in raw:
|
|
127
|
+
result["max"] = raw["maximum"]
|
|
128
|
+
if "allowed" in raw and parameter_type != "boolean":
|
|
129
|
+
result["choices"] = [str(value) for value in raw["allowed"]]
|
|
130
|
+
if components != 1:
|
|
131
|
+
if parameter_type == "color":
|
|
132
|
+
component_names = ["red", "green", "blue", "alpha"][:components]
|
|
133
|
+
elif parameter_type == "point":
|
|
134
|
+
component_names = ["x", "y"][:components]
|
|
135
|
+
else:
|
|
136
|
+
component_names = [f"component{index + 1}" for index in range(components)]
|
|
137
|
+
result["components"] = component_names
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _display_name(entry: Capability) -> str:
|
|
142
|
+
return entry.aliases[0] if entry.aliases else entry.id
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _effect(entry: Capability) -> dict[str, Any]:
|
|
146
|
+
handler = entry.handler or ""
|
|
147
|
+
if handler in _DEFAULT_ONLY_EFFECTS:
|
|
148
|
+
support = "default_only"
|
|
149
|
+
notes = "Bladeworks renders the calibrated default and rejects authored parameters."
|
|
150
|
+
elif handler in _PARTIAL_EFFECTS:
|
|
151
|
+
support = "partial"
|
|
152
|
+
notes = "Bladeworks honors the listed controls; other template controls are unavailable."
|
|
153
|
+
elif handler in _EXACT_EFFECTS:
|
|
154
|
+
support = "exact"
|
|
155
|
+
notes = "Bladeworks honors the complete listed parameter contract."
|
|
156
|
+
else:
|
|
157
|
+
support = "approximate"
|
|
158
|
+
notes = entry.approximation or "Bladeworks renders a calibrated approximation."
|
|
159
|
+
assert entry.uid is not None, f"authorable effect {entry.id} has no FCPXML UID"
|
|
160
|
+
return {
|
|
161
|
+
"id": entry.id,
|
|
162
|
+
"name": _display_name(entry),
|
|
163
|
+
"handler": handler,
|
|
164
|
+
"resource": {"uid": entry.uid, "xfadeId": None},
|
|
165
|
+
"authorable": True,
|
|
166
|
+
"support": support,
|
|
167
|
+
# Ordinary effect parameter animation is rejected or not executed by
|
|
168
|
+
# Tensor. Mask mattes publish their separate, genuinely animated ABI.
|
|
169
|
+
"parameters": [
|
|
170
|
+
_parameter(key, raw, animatable=False)
|
|
171
|
+
for key, raw in entry.parameters.items()
|
|
172
|
+
],
|
|
173
|
+
"notes": [notes],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _transition(entry: Capability) -> dict[str, Any]:
|
|
178
|
+
xfade_id = str(entry.xfade["id"]) if entry.xfade else None
|
|
179
|
+
assert entry.uid is not None, f"authorable transition {entry.id} has no FCPXML UID"
|
|
180
|
+
support = "exact" if entry.handler == "cross_dissolve" else "approximate"
|
|
181
|
+
parameters = entry.parameters
|
|
182
|
+
if entry.handler == "cross_dissolve":
|
|
183
|
+
# Tensor implements Final Cut's fixed default dissolve. The template's
|
|
184
|
+
# Look/Ease metadata never changes the kernel and must not become fake
|
|
185
|
+
# Studio controls.
|
|
186
|
+
support = "default_only"
|
|
187
|
+
parameters = {}
|
|
188
|
+
if not entry.parameters:
|
|
189
|
+
support = "default_only" if entry.handler == "xfade" else support
|
|
190
|
+
return {
|
|
191
|
+
"id": entry.id,
|
|
192
|
+
"name": _display_name(entry),
|
|
193
|
+
"handler": entry.handler,
|
|
194
|
+
"resource": {"uid": entry.uid, "xfadeId": xfade_id},
|
|
195
|
+
"authorable": True,
|
|
196
|
+
"support": support,
|
|
197
|
+
"parameters": [
|
|
198
|
+
_parameter(key, raw, animatable=False)
|
|
199
|
+
for key, raw in parameters.items()
|
|
200
|
+
],
|
|
201
|
+
"notes": [
|
|
202
|
+
entry.approximation
|
|
203
|
+
or (
|
|
204
|
+
"Exact premultiplied linear-light dissolve."
|
|
205
|
+
if support == "exact"
|
|
206
|
+
else "Calibrated Bladeworks transition."
|
|
207
|
+
)
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _slide_push_transitions(entry: Capability) -> list[dict[str, Any]]:
|
|
213
|
+
"""Publish the two real modes behind Final Cut's shared resource UID.
|
|
214
|
+
|
|
215
|
+
Main callers:
|
|
216
|
+
- ``_registry_surfaces`` while expanding the native transition registry.
|
|
217
|
+
|
|
218
|
+
Final Cut uses one resource for Slide and Push and distinguishes them with
|
|
219
|
+
parameter key ``5``. Separate catalog tiles remain executable because each
|
|
220
|
+
carries a one-choice fixed mode parameter that Studio serializes normally.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
direction = entry.parameters["4"]
|
|
224
|
+
result: list[dict[str, Any]] = []
|
|
225
|
+
for identifier, name, mode in (
|
|
226
|
+
("transition-slide", "Slide", "0"),
|
|
227
|
+
("transition-push", "Push", "2"),
|
|
228
|
+
):
|
|
229
|
+
public = _transition(entry)
|
|
230
|
+
public["id"] = identifier
|
|
231
|
+
public["name"] = name
|
|
232
|
+
public["parameters"] = [
|
|
233
|
+
_parameter("4", direction, animatable=False),
|
|
234
|
+
_parameter(
|
|
235
|
+
"5",
|
|
236
|
+
{"name": "Mode", "type": "scalar", "default": mode, "allowed": [mode], "semantic_type": "enum"},
|
|
237
|
+
animatable=False,
|
|
238
|
+
),
|
|
239
|
+
]
|
|
240
|
+
public["notes"] = [
|
|
241
|
+
f"Calibrated Final Cut {name} using shared resource mode {mode}."
|
|
242
|
+
]
|
|
243
|
+
result.append(public)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _registry_surfaces() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
248
|
+
"""Resolve public effects/transitions and reject any registry drift.
|
|
249
|
+
|
|
250
|
+
Main callers:
|
|
251
|
+
- ``bladeworks_capabilities`` on each HTTP request and in drift tests.
|
|
252
|
+
|
|
253
|
+
Keeping this check on the serving path matters: adding a tensor port without
|
|
254
|
+
a browser-safe FCPXML identity is a broken release, not an optional catalog
|
|
255
|
+
omission.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
registry = CapabilityRegistry.load()
|
|
259
|
+
effect_entries = [
|
|
260
|
+
entry
|
|
261
|
+
for entry in registry.entries
|
|
262
|
+
if entry.kind == "video_filter" and entry.handler in EFFECT_PORTS
|
|
263
|
+
]
|
|
264
|
+
registered_effects = set(EFFECT_PORTS)
|
|
265
|
+
catalog_effects = {entry.handler for entry in effect_entries if entry.handler}
|
|
266
|
+
if catalog_effects != registered_effects:
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
"Tensor effect registry and FCPXML resource registry disagree: "
|
|
269
|
+
f"ports_only={sorted(registered_effects - catalog_effects)!r}, "
|
|
270
|
+
f"resources_only={sorted(catalog_effects - registered_effects)!r}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
admitted_xfade_ids = set(ADMITTED_XFADE_IDS) | set(PHASE5_IDS) | set(ADMITTED_EQUIRECT_IDS)
|
|
274
|
+
transition_entries = [
|
|
275
|
+
entry
|
|
276
|
+
for entry in registry.entries
|
|
277
|
+
if entry.kind == "transition"
|
|
278
|
+
and (
|
|
279
|
+
entry.handler in _NATIVE_TRANSITION_HANDLERS
|
|
280
|
+
or (entry.xfade is not None and entry.xfade.get("id") in admitted_xfade_ids)
|
|
281
|
+
)
|
|
282
|
+
]
|
|
283
|
+
catalog_xfade_ids = {
|
|
284
|
+
str(entry.xfade["id"]) for entry in transition_entries if entry.xfade is not None
|
|
285
|
+
}
|
|
286
|
+
if catalog_xfade_ids != admitted_xfade_ids:
|
|
287
|
+
raise RuntimeError(
|
|
288
|
+
"Tensor transition admission and FCPXML resource registry disagree: "
|
|
289
|
+
f"ports_only={sorted(admitted_xfade_ids - catalog_xfade_ids)!r}, "
|
|
290
|
+
f"resources_only={sorted(catalog_xfade_ids - admitted_xfade_ids)!r}"
|
|
291
|
+
)
|
|
292
|
+
native_handlers = {entry.handler for entry in transition_entries} & _NATIVE_TRANSITION_HANDLERS
|
|
293
|
+
if native_handlers != _NATIVE_TRANSITION_HANDLERS or not _NATIVE_TRANSITION_HANDLERS <= set(HANDLERS):
|
|
294
|
+
raise RuntimeError("Native tensor transition handlers and capability resources disagree")
|
|
295
|
+
|
|
296
|
+
effects = [
|
|
297
|
+
_effect(entry)
|
|
298
|
+
for entry in effect_entries
|
|
299
|
+
if entry.handler not in _IDENTITY_EFFECTS
|
|
300
|
+
]
|
|
301
|
+
transitions = [
|
|
302
|
+
public
|
|
303
|
+
for entry in transition_entries
|
|
304
|
+
for public in (
|
|
305
|
+
_slide_push_transitions(entry)
|
|
306
|
+
if entry.handler == "slide_push"
|
|
307
|
+
else [_transition(entry)]
|
|
308
|
+
)
|
|
309
|
+
]
|
|
310
|
+
return effects, transitions
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def bladeworks_capabilities() -> dict[str, Any]:
|
|
314
|
+
"""Return the complete public authoring and delivery contract."""
|
|
315
|
+
|
|
316
|
+
effects, transitions = _registry_surfaces()
|
|
317
|
+
blend_modes_by_name = {spec.canonical_name: spec for spec in _MODE_SPECS.values()}
|
|
318
|
+
blend_modes = [
|
|
319
|
+
{
|
|
320
|
+
"id": "".join(character.lower() for character in name if character.isalnum()),
|
|
321
|
+
"name": name,
|
|
322
|
+
"fcpxmlValue": re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-"),
|
|
323
|
+
"support": "exact"
|
|
324
|
+
if spec.semantic_status == "exact_alpha"
|
|
325
|
+
else "approximate",
|
|
326
|
+
"authorable": True,
|
|
327
|
+
}
|
|
328
|
+
for name, spec in sorted(blend_modes_by_name.items())
|
|
329
|
+
]
|
|
330
|
+
return {
|
|
331
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
332
|
+
"renderer": "tensor",
|
|
333
|
+
"mechanics": [
|
|
334
|
+
{"id": "timeline", "name": "Spine, connected clips, lanes and gaps", "support": "exact", "authorable": True},
|
|
335
|
+
{"id": "transform", "name": "Position, scale, rotation and anchor", "support": "exact", "authorable": True, "animatable": True},
|
|
336
|
+
{"id": "crop", "name": "Trim, Crop and Ken Burns", "support": "approximate", "authorable": True, "animatable": True, "modes": ["trim", "crop", "ken_burns"]},
|
|
337
|
+
{"id": "distort", "name": "Four-corner Distort", "support": "exact", "authorable": True, "animatable": True},
|
|
338
|
+
{"id": "spatialConform", "name": "Spatial conform", "support": "exact", "authorable": True, "modes": ["fit", "fill", "none"]},
|
|
339
|
+
{"id": "opacity", "name": "Opacity", "support": "exact", "authorable": True, "animatable": True},
|
|
340
|
+
{
|
|
341
|
+
"id": "masks",
|
|
342
|
+
"name": "Numeric shape, draw, color and luma masks",
|
|
343
|
+
"support": "approximate",
|
|
344
|
+
"authorable": True,
|
|
345
|
+
"animatable": True,
|
|
346
|
+
"maximumMasks": MAX_MASKS,
|
|
347
|
+
"blendModes": list(MASK_BLEND_MODES),
|
|
348
|
+
"invert": True,
|
|
349
|
+
"sourceKinds": [
|
|
350
|
+
{
|
|
351
|
+
**{
|
|
352
|
+
key: list(value) if isinstance(value, tuple) else value
|
|
353
|
+
for key, value in source.items()
|
|
354
|
+
if key != "parameters"
|
|
355
|
+
},
|
|
356
|
+
"parameters": [
|
|
357
|
+
{
|
|
358
|
+
"key": parameter["key"],
|
|
359
|
+
"name": parameter["name"],
|
|
360
|
+
"type": parameter["type"],
|
|
361
|
+
"min": parameter.get("minimum"),
|
|
362
|
+
"max": parameter.get("maximum"),
|
|
363
|
+
"default": parameter.get("default"),
|
|
364
|
+
"components": list(parameter.get("components", ())),
|
|
365
|
+
"units": parameter["units"],
|
|
366
|
+
"minimumItems": parameter.get("minimumItems"),
|
|
367
|
+
"maximumItems": parameter.get("maximumItems"),
|
|
368
|
+
"convex": parameter.get("convex"),
|
|
369
|
+
"animatable": parameter["animatable"],
|
|
370
|
+
}
|
|
371
|
+
for parameter in source["parameters"]
|
|
372
|
+
],
|
|
373
|
+
}
|
|
374
|
+
for source in MASK_AUTHORING_SOURCES
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
{"id": "titles", "name": "Titles and captions", "support": "partial", "authorable": True, "notes": "Supported title text/styles rasterize in the preview service; opaque Motion rigs reject."},
|
|
378
|
+
{"id": "customSolid", "name": "Custom Solid generator", "support": "exact", "authorable": True},
|
|
379
|
+
{"id": "nestedScopes", "name": "Compound, multicam, sync and audition scopes", "support": "exact", "authorable": True},
|
|
380
|
+
{"id": "preview", "name": "Seek and scan preview", "support": "exact", "authorable": False, "qualities": [720, 540, 480]},
|
|
381
|
+
],
|
|
382
|
+
"blendModes": blend_modes,
|
|
383
|
+
"retime": {
|
|
384
|
+
"support": "exact",
|
|
385
|
+
"authorable": True,
|
|
386
|
+
"modes": ["constant", "reverse", "freeze", "piecewise_linear"],
|
|
387
|
+
"frameSampling": ["floor"],
|
|
388
|
+
"preservePitch": True,
|
|
389
|
+
"notes": "Matches Final Cut's constant speed, Reverse, Hold and segmented retime editor model. Smooth speed transitions are unsupported.",
|
|
390
|
+
},
|
|
391
|
+
"effects": effects,
|
|
392
|
+
"transitions": transitions,
|
|
393
|
+
"audio": {
|
|
394
|
+
"support": "exact",
|
|
395
|
+
"authorable": True,
|
|
396
|
+
"controls": ["gain", "mute", "fade_in", "fade_out", "pan", "roles", "j_l_edits", "resampling"],
|
|
397
|
+
"outputLayouts": ["mono", "stereo"],
|
|
398
|
+
},
|
|
399
|
+
"media": {
|
|
400
|
+
"support": "exact",
|
|
401
|
+
"decodedPixelFormats": ["yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv444p12le"],
|
|
402
|
+
"colorMatrices": ["bt709", "bt601", "smpte170m", "bt2020nc"],
|
|
403
|
+
"hdrInputTransfers": ["arib-std-b67", "smpte2084"],
|
|
404
|
+
"missingMedia": "placeholder_video_and_silent_audio",
|
|
405
|
+
},
|
|
406
|
+
"export": {
|
|
407
|
+
"support": "exact",
|
|
408
|
+
"supportedResolutions": [
|
|
409
|
+
int(profile.value.removesuffix("p"))
|
|
410
|
+
for profile in SUPPORTED_PROFILES[RenderMode.RENDER]
|
|
411
|
+
],
|
|
412
|
+
"defaultResolution": int(
|
|
413
|
+
DEFAULT_PROFILE[RenderMode.RENDER].value.removesuffix("p")
|
|
414
|
+
),
|
|
415
|
+
"profiles": [
|
|
416
|
+
{
|
|
417
|
+
"id": profile.id,
|
|
418
|
+
"container": profile.container,
|
|
419
|
+
"video": profile.video_codec,
|
|
420
|
+
"audio": profile.audio_codec,
|
|
421
|
+
"alpha": profile.alpha,
|
|
422
|
+
"defaultResolution": 1080,
|
|
423
|
+
}
|
|
424
|
+
for profile in STUDIO_EXPORT_PROFILES.values()
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
"unsupported": [
|
|
428
|
+
{"id": "stabilization", "category": "mechanic", "reason": "Bladeworks rejects stabilization metadata."},
|
|
429
|
+
{"id": "rollingShutter", "category": "mechanic", "reason": "Bladeworks rejects rolling-shutter correction."},
|
|
430
|
+
{"id": "tracking", "category": "mask", "reason": "Opaque, tracked, Magnetic, Auto and ML masks reject."},
|
|
431
|
+
{"id": "smoothRetime", "category": "retime", "reason": "Smooth/eased time-map interpolation rejects."},
|
|
432
|
+
{"id": "frameBlending", "category": "retime", "reason": "Frame Blending is not implemented."},
|
|
433
|
+
{"id": "opticalFlow", "category": "retime", "reason": "Optical Flow and ML retiming are not implemented."},
|
|
434
|
+
{"id": "colorCurves", "category": "effect", "reason": "Authored curve data rejects; the identity port is not authorable."},
|
|
435
|
+
{"id": "hueSaturationCurves", "category": "effect", "reason": "Authored curve data rejects; the identity port is not authorable."},
|
|
436
|
+
{"id": "customLuts", "category": "effect", "reason": "Custom LUTs reject."},
|
|
437
|
+
{"id": "motionTitleRigs", "category": "title", "reason": "Opaque Motion title rigs reject."},
|
|
438
|
+
{"id": "nonCustomSolidGenerators", "category": "generator", "reason": "Only Custom Solid generators render."},
|
|
439
|
+
{"id": "surroundAudio", "category": "audio", "reason": "5.1 output rejects before delivery planning."},
|
|
440
|
+
{"id": "alphaSourceMedia", "category": "media", "reason": "Alpha-carrying decoded source formats reject."},
|
|
441
|
+
{"id": "hdrDelivery", "category": "export", "reason": "HDR input is tone-mapped to SDR; HDR output is not offered."},
|
|
442
|
+
{"id": "oracleMezzanine", "category": "export", "reason": "The tensor encoder has no ProRes 4:2:2 10-bit exit; the legacy CPU-only oracle profile is not a Studio capability."},
|
|
443
|
+
{"id": "crossChannelBlendModes", "category": "blend", "reason": "Hue, Saturation, Color and Luminosity blend modes reject."},
|
|
444
|
+
],
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def create_capability_router() -> APIRouter:
|
|
449
|
+
"""Build the narrow router mounted behind the server bearer dependency."""
|
|
450
|
+
|
|
451
|
+
router = APIRouter(prefix="/api/editor", tags=["capabilities"])
|
|
452
|
+
|
|
453
|
+
@router.get("/capabilities")
|
|
454
|
+
async def capabilities() -> dict[str, Any]:
|
|
455
|
+
return bladeworks_capabilities()
|
|
456
|
+
|
|
457
|
+
return router
|