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,381 @@
|
|
|
1
|
+
"""File-oriented inventory and staged import for one opened FCPXML bundle.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
``<bundle>/Media``
|
|
7
|
+
-> explicit recursive scan
|
|
8
|
+
-> per-file PyAV stream probe (one failure does not abort the scan)
|
|
9
|
+
-> immutable, path-sorted inventory snapshot plus explicit probe failures
|
|
10
|
+
|
|
11
|
+
An import copies to a hidden ``.partial`` sibling, flushes it, and publishes it
|
|
12
|
+
with an atomic no-overwrite hard link. Media changes never edit ``Info.fcpxml``
|
|
13
|
+
and never participate in FCPXML undo or redo.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import threading
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from fractions import Fraction
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import av
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MediaLibraryError(RuntimeError):
|
|
31
|
+
"""Stable media-library failure translated by the HTTP adapter."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code: str, message: str, *, status: int) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
self.message = message
|
|
37
|
+
self.status = status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class MediaRecord:
|
|
42
|
+
relative_path: str
|
|
43
|
+
filename: str
|
|
44
|
+
size_bytes: int
|
|
45
|
+
duration: Fraction | None
|
|
46
|
+
width: int | None
|
|
47
|
+
height: int | None
|
|
48
|
+
frame_duration: Fraction | None
|
|
49
|
+
has_video: bool
|
|
50
|
+
has_audio: bool
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"relativePath": self.relative_path,
|
|
55
|
+
"filename": self.filename,
|
|
56
|
+
"sizeBytes": self.size_bytes,
|
|
57
|
+
"duration": float(self.duration) if self.duration is not None else None,
|
|
58
|
+
"width": self.width,
|
|
59
|
+
"height": self.height,
|
|
60
|
+
"frameDuration": (
|
|
61
|
+
f"{self.frame_duration.numerator}/{self.frame_duration.denominator}s"
|
|
62
|
+
if self.frame_duration is not None
|
|
63
|
+
else None
|
|
64
|
+
),
|
|
65
|
+
"hasVideo": self.has_video,
|
|
66
|
+
"hasAudio": self.has_audio,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class MediaProbeFailure:
|
|
72
|
+
"""One Media file the scan could not open as video or audio."""
|
|
73
|
+
|
|
74
|
+
relative_path: str
|
|
75
|
+
message: str
|
|
76
|
+
|
|
77
|
+
def to_json(self) -> dict[str, object]:
|
|
78
|
+
return {"relativePath": self.relative_path, "message": self.message}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class MediaInventory:
|
|
83
|
+
items: tuple[MediaRecord, ...]
|
|
84
|
+
failures: tuple[MediaProbeFailure, ...] = ()
|
|
85
|
+
|
|
86
|
+
def to_json(self) -> dict[str, object]:
|
|
87
|
+
return {
|
|
88
|
+
"items": [item.to_json() for item in self.items],
|
|
89
|
+
"failures": [failure.to_json() for failure in self.failures],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MediaLibrary:
|
|
94
|
+
"""Manage only physical files below one bundle's media directory."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, bundle_path: Path, *, media_dir_name: str = "Media") -> None:
|
|
97
|
+
bundle = bundle_path.expanduser().resolve()
|
|
98
|
+
if not bundle.is_dir():
|
|
99
|
+
raise MediaLibraryError(
|
|
100
|
+
"source_not_found",
|
|
101
|
+
f"FCPXML bundle does not exist: {bundle}",
|
|
102
|
+
status=404,
|
|
103
|
+
)
|
|
104
|
+
if Path(media_dir_name).name != media_dir_name or media_dir_name in {"", ".", ".."}:
|
|
105
|
+
raise ValueError("media_dir_name must be one directory name")
|
|
106
|
+
self.bundle_path = bundle
|
|
107
|
+
self.media_directory = bundle / media_dir_name
|
|
108
|
+
self._lock = threading.RLock()
|
|
109
|
+
self._inventory: MediaInventory | None = None
|
|
110
|
+
|
|
111
|
+
def inventory(self) -> MediaInventory:
|
|
112
|
+
"""Return the current explicit snapshot, scanning once on first use."""
|
|
113
|
+
|
|
114
|
+
with self._lock:
|
|
115
|
+
if self._inventory is None:
|
|
116
|
+
self._inventory = self._scan()
|
|
117
|
+
return self._inventory
|
|
118
|
+
|
|
119
|
+
def refresh(self) -> MediaInventory:
|
|
120
|
+
"""Replace the complete inventory snapshot from current disk state."""
|
|
121
|
+
|
|
122
|
+
with self._lock:
|
|
123
|
+
inventory = self._scan()
|
|
124
|
+
self._inventory = inventory
|
|
125
|
+
return inventory
|
|
126
|
+
|
|
127
|
+
def resolve_media_path(self, relative_path: str) -> Path:
|
|
128
|
+
"""Resolve one browser-visible inventory path without escaping Media.
|
|
129
|
+
|
|
130
|
+
Main callers:
|
|
131
|
+
- the transient filmstrip and waveform sampler.
|
|
132
|
+
|
|
133
|
+
The sampler accepts the same ``relativePath`` returned by inventory.
|
|
134
|
+
It never accepts an arbitrary host path because the local API bearer
|
|
135
|
+
token should not become a general-purpose file reader.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
if not relative_path or Path(relative_path).is_absolute():
|
|
139
|
+
raise MediaLibraryError(
|
|
140
|
+
"invalid_media_path",
|
|
141
|
+
"relativePath must identify one file below the opened bundle's Media directory.",
|
|
142
|
+
status=400,
|
|
143
|
+
)
|
|
144
|
+
candidate = (self.bundle_path / relative_path).resolve()
|
|
145
|
+
media_root = self.media_directory.resolve()
|
|
146
|
+
try:
|
|
147
|
+
candidate.relative_to(media_root)
|
|
148
|
+
except ValueError as error:
|
|
149
|
+
raise MediaLibraryError(
|
|
150
|
+
"invalid_media_path",
|
|
151
|
+
"relativePath must identify one file below the opened bundle's Media directory.",
|
|
152
|
+
status=400,
|
|
153
|
+
) from error
|
|
154
|
+
if not candidate.is_file() or self._ignored(candidate):
|
|
155
|
+
raise MediaLibraryError(
|
|
156
|
+
"media_not_found",
|
|
157
|
+
f"Media file is missing: {relative_path}",
|
|
158
|
+
status=404,
|
|
159
|
+
)
|
|
160
|
+
return candidate
|
|
161
|
+
|
|
162
|
+
def import_file(self, source_path: Path, destination_name: str) -> MediaRecord:
|
|
163
|
+
"""Stage, publish without overwrite, probe, and inventory one local file.
|
|
164
|
+
|
|
165
|
+
Main callers:
|
|
166
|
+
- ``POST /api/editor/media/import``.
|
|
167
|
+
|
|
168
|
+
The target is published before probing so direct filesystem readers see
|
|
169
|
+
either the complete bytes or no file. A probe failure removes the newly
|
|
170
|
+
imported target because the API never reports an unusable import as a
|
|
171
|
+
success.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
source = source_path.expanduser().resolve()
|
|
175
|
+
if not source.is_file():
|
|
176
|
+
raise MediaLibraryError(
|
|
177
|
+
"media_source_not_found",
|
|
178
|
+
f"Media import source is not a file: {source}",
|
|
179
|
+
status=404,
|
|
180
|
+
)
|
|
181
|
+
self._validate_destination_name(destination_name)
|
|
182
|
+
with self._lock:
|
|
183
|
+
self.media_directory.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
target = self.media_directory / destination_name
|
|
185
|
+
if target.exists():
|
|
186
|
+
raise self._collision(target)
|
|
187
|
+
stage = self.media_directory / f".{destination_name}.{uuid.uuid4().hex}.partial"
|
|
188
|
+
published = False
|
|
189
|
+
try:
|
|
190
|
+
with source.open("rb") as incoming, stage.open("xb") as outgoing:
|
|
191
|
+
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
|
|
192
|
+
outgoing.flush()
|
|
193
|
+
os.fsync(outgoing.fileno())
|
|
194
|
+
try:
|
|
195
|
+
os.link(stage, target)
|
|
196
|
+
except FileExistsError as error:
|
|
197
|
+
raise self._collision(target) from error
|
|
198
|
+
published = True
|
|
199
|
+
stage.unlink()
|
|
200
|
+
self._fsync_directory()
|
|
201
|
+
record = self._probe(target)
|
|
202
|
+
except Exception:
|
|
203
|
+
stage.unlink(missing_ok=True)
|
|
204
|
+
if published:
|
|
205
|
+
target.unlink(missing_ok=True)
|
|
206
|
+
self._fsync_directory()
|
|
207
|
+
raise
|
|
208
|
+
self._inventory = self._scan()
|
|
209
|
+
return record
|
|
210
|
+
|
|
211
|
+
def create_upload_stage(self, destination_name: str) -> Path:
|
|
212
|
+
"""Reserve a hidden stage path for an HTTP request body.
|
|
213
|
+
|
|
214
|
+
The route streams directly into this file. Publication remains a
|
|
215
|
+
separate operation so a disconnected or invalid request can only
|
|
216
|
+
leave a hidden partial file, which the route removes in ``finally``.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
self._validate_destination_name(destination_name)
|
|
220
|
+
with self._lock:
|
|
221
|
+
self.media_directory.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
target = self.media_directory / destination_name
|
|
223
|
+
if target.exists():
|
|
224
|
+
raise self._collision(target)
|
|
225
|
+
return self.media_directory / f".{destination_name}.{uuid.uuid4().hex}.partial"
|
|
226
|
+
|
|
227
|
+
def publish_upload(self, stage: Path, destination_name: str) -> MediaRecord:
|
|
228
|
+
"""Atomically publish and probe a completely written upload stage."""
|
|
229
|
+
|
|
230
|
+
self._validate_destination_name(destination_name)
|
|
231
|
+
stage = stage.resolve()
|
|
232
|
+
if stage.parent != self.media_directory.resolve() or not stage.name.endswith(".partial"):
|
|
233
|
+
raise MediaLibraryError(
|
|
234
|
+
"invalid_request",
|
|
235
|
+
"Upload stage is not owned by this media library.",
|
|
236
|
+
status=400,
|
|
237
|
+
)
|
|
238
|
+
with self._lock:
|
|
239
|
+
target = self.media_directory / destination_name
|
|
240
|
+
if target.exists():
|
|
241
|
+
raise self._collision(target)
|
|
242
|
+
published = False
|
|
243
|
+
try:
|
|
244
|
+
try:
|
|
245
|
+
os.link(stage, target)
|
|
246
|
+
except FileExistsError as error:
|
|
247
|
+
raise self._collision(target) from error
|
|
248
|
+
published = True
|
|
249
|
+
stage.unlink()
|
|
250
|
+
self._fsync_directory()
|
|
251
|
+
record = self._probe(target)
|
|
252
|
+
except Exception:
|
|
253
|
+
stage.unlink(missing_ok=True)
|
|
254
|
+
if published:
|
|
255
|
+
target.unlink(missing_ok=True)
|
|
256
|
+
self._fsync_directory()
|
|
257
|
+
raise
|
|
258
|
+
self._inventory = self._scan()
|
|
259
|
+
return record
|
|
260
|
+
|
|
261
|
+
def _scan(self) -> MediaInventory:
|
|
262
|
+
"""Probe every visible Media file. Keep readable items; record the rest.
|
|
263
|
+
|
|
264
|
+
Why this exists: a sidecar, damaged movie, or non-media file in Media
|
|
265
|
+
must not prevent Studio from opening the FCPXML Project. Import of a
|
|
266
|
+
single new file still raises through ``_probe`` so a failed upload is
|
|
267
|
+
never reported as success.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
if not self.media_directory.is_dir():
|
|
271
|
+
return MediaInventory(items=())
|
|
272
|
+
paths = sorted(
|
|
273
|
+
(
|
|
274
|
+
path
|
|
275
|
+
for path in self.media_directory.rglob("*")
|
|
276
|
+
if path.is_file() and not self._ignored(path)
|
|
277
|
+
),
|
|
278
|
+
key=lambda path: path.relative_to(self.bundle_path).as_posix(),
|
|
279
|
+
)
|
|
280
|
+
records: list[MediaRecord] = []
|
|
281
|
+
failures: list[MediaProbeFailure] = []
|
|
282
|
+
for path in paths:
|
|
283
|
+
relative = path.relative_to(self.bundle_path).as_posix()
|
|
284
|
+
try:
|
|
285
|
+
records.append(self._probe(path))
|
|
286
|
+
except MediaLibraryError as error:
|
|
287
|
+
failures.append(
|
|
288
|
+
MediaProbeFailure(
|
|
289
|
+
relative_path=relative,
|
|
290
|
+
message=error.message,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return MediaInventory(items=tuple(records), failures=tuple(failures))
|
|
294
|
+
|
|
295
|
+
def _ignored(self, path: Path) -> bool:
|
|
296
|
+
try:
|
|
297
|
+
relative = path.resolve().relative_to(self.media_directory.resolve())
|
|
298
|
+
except ValueError:
|
|
299
|
+
# A file-level symlink may point outside the allowed Media root.
|
|
300
|
+
# Keep it out of inventory just as resolve_media_path does.
|
|
301
|
+
return True
|
|
302
|
+
return any(part.startswith(".") for part in relative.parts) or path.name.endswith(".partial")
|
|
303
|
+
|
|
304
|
+
def _probe(self, path: Path) -> MediaRecord:
|
|
305
|
+
try:
|
|
306
|
+
with av.open(str(path), mode="r") as container:
|
|
307
|
+
video_streams = tuple(container.streams.video)
|
|
308
|
+
audio_streams = tuple(container.streams.audio)
|
|
309
|
+
width = video_streams[0].width if video_streams else None
|
|
310
|
+
height = video_streams[0].height if video_streams else None
|
|
311
|
+
frame_duration = _video_frame_duration(video_streams[0]) if video_streams else None
|
|
312
|
+
duration = _container_duration(container)
|
|
313
|
+
except Exception as error:
|
|
314
|
+
raise MediaLibraryError(
|
|
315
|
+
"media_probe_failed",
|
|
316
|
+
f"Could not probe media file {path}: {error}",
|
|
317
|
+
status=422,
|
|
318
|
+
) from error
|
|
319
|
+
if not video_streams and not audio_streams:
|
|
320
|
+
raise MediaLibraryError(
|
|
321
|
+
"media_probe_failed",
|
|
322
|
+
f"Media file has no audio or video streams: {path}",
|
|
323
|
+
status=422,
|
|
324
|
+
)
|
|
325
|
+
return MediaRecord(
|
|
326
|
+
relative_path=path.relative_to(self.bundle_path).as_posix(),
|
|
327
|
+
filename=path.name,
|
|
328
|
+
size_bytes=path.stat().st_size,
|
|
329
|
+
duration=duration,
|
|
330
|
+
width=width,
|
|
331
|
+
height=height,
|
|
332
|
+
frame_duration=frame_duration,
|
|
333
|
+
has_video=bool(video_streams),
|
|
334
|
+
has_audio=bool(audio_streams),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _validate_destination_name(self, name: str) -> None:
|
|
338
|
+
if (
|
|
339
|
+
not name
|
|
340
|
+
or Path(name).name != name
|
|
341
|
+
or name in {".", ".."}
|
|
342
|
+
or name.startswith(".")
|
|
343
|
+
or name.endswith(".partial")
|
|
344
|
+
):
|
|
345
|
+
raise MediaLibraryError(
|
|
346
|
+
"invalid_request",
|
|
347
|
+
"destinationName must be one visible filename and cannot end in .partial",
|
|
348
|
+
status=400,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _collision(self, target: Path) -> MediaLibraryError:
|
|
352
|
+
return MediaLibraryError(
|
|
353
|
+
"media_import_conflict",
|
|
354
|
+
f"Media destination already exists: {target.name}",
|
|
355
|
+
status=409,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _fsync_directory(self) -> None:
|
|
359
|
+
descriptor = os.open(self.media_directory, os.O_RDONLY)
|
|
360
|
+
try:
|
|
361
|
+
os.fsync(descriptor)
|
|
362
|
+
finally:
|
|
363
|
+
os.close(descriptor)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _video_frame_duration(stream: Any) -> Fraction | None:
|
|
367
|
+
rate = stream.average_rate or stream.base_rate
|
|
368
|
+
if rate is None or rate == 0:
|
|
369
|
+
return None
|
|
370
|
+
return 1 / Fraction(rate)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _container_duration(container: Any) -> Fraction | None:
|
|
374
|
+
if container.duration is not None:
|
|
375
|
+
return Fraction(container.duration, av.time_base)
|
|
376
|
+
durations = [
|
|
377
|
+
Fraction(stream.duration * stream.time_base)
|
|
378
|
+
for stream in container.streams
|
|
379
|
+
if stream.duration is not None and stream.time_base is not None
|
|
380
|
+
]
|
|
381
|
+
return max(durations, default=None)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Translate compiled missing-media facts into the public SSE payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..core.missing_media import missing_media_basename
|
|
8
|
+
from ..core.model import RenderDocument
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def missing_media_event_data(
|
|
12
|
+
document: RenderDocument,
|
|
13
|
+
*,
|
|
14
|
+
source_version: str,
|
|
15
|
+
) -> dict[str, object] | None:
|
|
16
|
+
"""Group exact offline locators while retaining every timeline reference.
|
|
17
|
+
|
|
18
|
+
Main callers:
|
|
19
|
+
- source compilation and preview session create/sync flows before emitting
|
|
20
|
+
the replayable ``missing_media`` SSE event.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
24
|
+
for reference in document.missing_media_references:
|
|
25
|
+
entry = grouped.setdefault(
|
|
26
|
+
reference.locator,
|
|
27
|
+
{
|
|
28
|
+
"locator": reference.locator,
|
|
29
|
+
"basename": missing_media_basename(reference.locator),
|
|
30
|
+
"hasVideo": False,
|
|
31
|
+
"hasAudio": False,
|
|
32
|
+
"references": {},
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
entry["hasVideo"] = bool(entry["hasVideo"] or reference.has_video)
|
|
36
|
+
entry["hasAudio"] = bool(entry["hasAudio"] or reference.has_audio)
|
|
37
|
+
key = (
|
|
38
|
+
reference.fcpxml_path,
|
|
39
|
+
reference.timeline_start,
|
|
40
|
+
reference.timeline_duration,
|
|
41
|
+
)
|
|
42
|
+
entry["references"][key] = {
|
|
43
|
+
"fcpxmlPath": reference.fcpxml_path,
|
|
44
|
+
"start": float(reference.timeline_start),
|
|
45
|
+
"duration": float(reference.timeline_duration),
|
|
46
|
+
}
|
|
47
|
+
if not grouped:
|
|
48
|
+
return None
|
|
49
|
+
paths: list[dict[str, object]] = []
|
|
50
|
+
for locator in sorted(grouped):
|
|
51
|
+
entry = grouped[locator]
|
|
52
|
+
references = entry.pop("references")
|
|
53
|
+
entry["references"] = [references[key] for key in sorted(references)]
|
|
54
|
+
paths.append(entry)
|
|
55
|
+
return {"sourceVersion": source_version, "paths": paths}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Standalone FastAPI router for explicit bundle-media operations.
|
|
2
|
+
|
|
3
|
+
The main server mounts this router after it opens one ``.fcpxmld``. Keeping the
|
|
4
|
+
router separate lets source/history and media lifecycles remain independent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import unquote
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
from fastapi import APIRouter, Header, Request
|
|
16
|
+
|
|
17
|
+
from .contracts import PreviewAPIError
|
|
18
|
+
from .media_library import MediaLibrary, MediaLibraryError
|
|
19
|
+
from .media_visuals import MediaVisualRequest, sample_media
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_media_router(library: MediaLibrary) -> APIRouter:
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
@router.get("/api/editor/media")
|
|
26
|
+
async def inventory() -> dict[str, object]:
|
|
27
|
+
return await _call(library.inventory)
|
|
28
|
+
|
|
29
|
+
@router.post("/api/editor/media/refresh")
|
|
30
|
+
async def refresh() -> dict[str, object]:
|
|
31
|
+
return await _call(library.refresh)
|
|
32
|
+
|
|
33
|
+
@router.post("/api/editor/media/import", status_code=201)
|
|
34
|
+
async def import_media(payload: dict[str, Any]) -> dict[str, object]:
|
|
35
|
+
source_path = payload.get("sourcePath")
|
|
36
|
+
destination_name = payload.get("destinationName")
|
|
37
|
+
if not isinstance(source_path, str) or not source_path:
|
|
38
|
+
raise PreviewAPIError(
|
|
39
|
+
"invalid_request",
|
|
40
|
+
"sourcePath is required and must be a non-empty string",
|
|
41
|
+
status=400,
|
|
42
|
+
)
|
|
43
|
+
if not isinstance(destination_name, str) or not destination_name:
|
|
44
|
+
raise PreviewAPIError(
|
|
45
|
+
"invalid_request",
|
|
46
|
+
"destinationName is required and must be a non-empty string",
|
|
47
|
+
status=400,
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
record = await asyncio.to_thread(
|
|
51
|
+
library.import_file,
|
|
52
|
+
Path(source_path),
|
|
53
|
+
destination_name,
|
|
54
|
+
)
|
|
55
|
+
except MediaLibraryError as error:
|
|
56
|
+
raise _public_error(error) from error
|
|
57
|
+
return {"media": record.to_json()}
|
|
58
|
+
|
|
59
|
+
@router.post("/api/editor/media/upload", status_code=201)
|
|
60
|
+
async def upload_media(
|
|
61
|
+
request: Request,
|
|
62
|
+
filename: str | None = Header(default=None, alias="X-Bladeworks-Filename"),
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
"""Stream a browser upload into a hidden stage before publication."""
|
|
65
|
+
|
|
66
|
+
if request.headers.get("content-type", "").split(";", 1)[0].lower() != "application/octet-stream":
|
|
67
|
+
raise PreviewAPIError(
|
|
68
|
+
"invalid_request",
|
|
69
|
+
"Media upload requires Content-Type application/octet-stream.",
|
|
70
|
+
status=400,
|
|
71
|
+
)
|
|
72
|
+
if not isinstance(filename, str) or not filename:
|
|
73
|
+
raise PreviewAPIError(
|
|
74
|
+
"invalid_request",
|
|
75
|
+
"X-Bladeworks-Filename is required.",
|
|
76
|
+
status=400,
|
|
77
|
+
)
|
|
78
|
+
filename = unquote(filename)
|
|
79
|
+
try:
|
|
80
|
+
stage = library.create_upload_stage(filename)
|
|
81
|
+
except MediaLibraryError as error:
|
|
82
|
+
raise _public_error(error) from error
|
|
83
|
+
try:
|
|
84
|
+
async with await anyio.open_file(stage, "xb") as output:
|
|
85
|
+
async for chunk in request.stream():
|
|
86
|
+
if chunk:
|
|
87
|
+
await output.write(chunk)
|
|
88
|
+
await output.flush()
|
|
89
|
+
await asyncio.to_thread(_fsync_file, stage)
|
|
90
|
+
record = await asyncio.to_thread(library.publish_upload, stage, filename)
|
|
91
|
+
except MediaLibraryError as error:
|
|
92
|
+
raise _public_error(error) from error
|
|
93
|
+
finally:
|
|
94
|
+
stage.unlink(missing_ok=True)
|
|
95
|
+
return {"media": record.to_json()}
|
|
96
|
+
|
|
97
|
+
@router.post("/api/editor/media/visuals")
|
|
98
|
+
async def media_visuals(payload: dict[str, Any]) -> dict[str, object]:
|
|
99
|
+
"""Return transient thumbnails and audio peaks for one visible clip."""
|
|
100
|
+
|
|
101
|
+
relative_path = payload.get("relativePath")
|
|
102
|
+
if not isinstance(relative_path, str):
|
|
103
|
+
raise PreviewAPIError("invalid_request", "relativePath is required.", status=400)
|
|
104
|
+
try:
|
|
105
|
+
request = MediaVisualRequest(
|
|
106
|
+
start=_number(payload, "start", default=0.0),
|
|
107
|
+
duration=_number(payload, "duration"),
|
|
108
|
+
thumbnail_count=_integer(payload, "thumbnailCount", default=0),
|
|
109
|
+
thumbnail_width=_integer(payload, "thumbnailWidth", default=96),
|
|
110
|
+
audio_bands=_integer(payload, "audioBands", default=0),
|
|
111
|
+
)
|
|
112
|
+
path = library.resolve_media_path(relative_path)
|
|
113
|
+
result = await asyncio.to_thread(sample_media, path, request)
|
|
114
|
+
# Preserve the inventory identity supplied by the caller. ``path``
|
|
115
|
+
# may be resolved through a symlink and its basename is not enough
|
|
116
|
+
# to distinguish nested files with the same name.
|
|
117
|
+
result["relativePath"] = relative_path
|
|
118
|
+
return result
|
|
119
|
+
except MediaLibraryError as error:
|
|
120
|
+
raise _public_error(error) from error
|
|
121
|
+
|
|
122
|
+
return router
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _call(operation) -> dict[str, object]:
|
|
126
|
+
try:
|
|
127
|
+
value = await asyncio.to_thread(operation)
|
|
128
|
+
except MediaLibraryError as error:
|
|
129
|
+
raise _public_error(error) from error
|
|
130
|
+
return value.to_json()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _public_error(error: MediaLibraryError) -> PreviewAPIError:
|
|
134
|
+
return PreviewAPIError(error.code, error.message, status=error.status)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _fsync_file(path: Path) -> None:
|
|
138
|
+
with path.open("rb") as handle:
|
|
139
|
+
import os
|
|
140
|
+
|
|
141
|
+
os.fsync(handle.fileno())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _number(payload: dict[str, Any], name: str, *, default: float | None = None) -> float:
|
|
145
|
+
value = payload.get(name, default)
|
|
146
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
147
|
+
raise PreviewAPIError("invalid_request", f"{name} must be a number.", status=400)
|
|
148
|
+
return float(value)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _integer(payload: dict[str, Any], name: str, *, default: int) -> int:
|
|
152
|
+
value = payload.get(name, default)
|
|
153
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
154
|
+
raise PreviewAPIError("invalid_request", f"{name} must be an integer.", status=400)
|
|
155
|
+
return value
|