bladeworks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. bladeworks/__init__.py +56 -0
  2. bladeworks/__main__.py +4 -0
  3. bladeworks/cli.py +728 -0
  4. bladeworks/core/__init__.py +34 -0
  5. bladeworks/core/animation.py +893 -0
  6. bladeworks/core/animation_execution.py +817 -0
  7. bladeworks/core/audio_enhancements.py +673 -0
  8. bladeworks/core/audio_execution.py +1848 -0
  9. bladeworks/core/audio_ir.py +1971 -0
  10. bladeworks/core/basic_effects.py +168 -0
  11. bladeworks/core/bindings.py +196 -0
  12. bladeworks/core/capabilities.py +257 -0
  13. bladeworks/core/cohort_effects.py +702 -0
  14. bladeworks/core/color.py +324 -0
  15. bladeworks/core/compiler.py +3142 -0
  16. bladeworks/core/composition_diagnostics.py +629 -0
  17. bladeworks/core/composition_ir.py +3154 -0
  18. bladeworks/core/composition_planning.py +708 -0
  19. bladeworks/core/composition_shadow.py +687 -0
  20. bladeworks/core/compositor.py +1186 -0
  21. bladeworks/core/effect_parameter_semantics.py +165 -0
  22. bladeworks/core/effect_parameters.py +193 -0
  23. bladeworks/core/errors.py +21 -0
  24. bladeworks/core/filter_text.py +48 -0
  25. bladeworks/core/geometry.py +1711 -0
  26. bladeworks/core/group_surface.py +664 -0
  27. bladeworks/core/keyer.py +280 -0
  28. bladeworks/core/masks.py +453 -0
  29. bladeworks/core/missing_media.py +130 -0
  30. bladeworks/core/model.py +985 -0
  31. bladeworks/core/multicam_execution.py +496 -0
  32. bladeworks/core/output_color.py +52 -0
  33. bladeworks/core/parser.py +1257 -0
  34. bladeworks/core/pixel_domains.py +433 -0
  35. bladeworks/core/render_profile.py +311 -0
  36. bladeworks/core/render_sources.py +475 -0
  37. bladeworks/core/report.py +118 -0
  38. bladeworks/core/retime.py +967 -0
  39. bladeworks/core/retime_execution.py +1515 -0
  40. bladeworks/core/spatial_intrinsics.py +1749 -0
  41. bladeworks/core/story_containers.py +524 -0
  42. bladeworks/core/story_ir.py +1042 -0
  43. bladeworks/core/text.py +1032 -0
  44. bladeworks/core/text_templates.py +934 -0
  45. bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
  46. bladeworks/effects/__init__.py +1 -0
  47. bladeworks/effects/contract.py +162 -0
  48. bladeworks/examples/__init__.py +80 -0
  49. bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
  50. bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
  51. bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
  52. bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
  53. bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
  54. bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
  55. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
  56. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
  57. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
  58. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
  59. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
  60. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
  61. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
  62. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
  63. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
  64. bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
  65. bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
  66. bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
  67. bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
  68. bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
  69. bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
  70. bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
  71. bladeworks/executor.py +293 -0
  72. bladeworks/preview/__init__.py +11 -0
  73. bladeworks/preview/application.py +84 -0
  74. bladeworks/preview/audio.py +104 -0
  75. bladeworks/preview/capabilities.py +457 -0
  76. bladeworks/preview/contracts.py +157 -0
  77. bladeworks/preview/events.py +55 -0
  78. bladeworks/preview/export.py +57 -0
  79. bladeworks/preview/history.py +197 -0
  80. bladeworks/preview/main.py +19 -0
  81. bladeworks/preview/media.py +23 -0
  82. bladeworks/preview/media_library.py +381 -0
  83. bladeworks/preview/media_reporting.py +55 -0
  84. bladeworks/preview/media_routes.py +155 -0
  85. bladeworks/preview/media_visuals.py +225 -0
  86. bladeworks/preview/producer.py +211 -0
  87. bladeworks/preview/provider.py +74 -0
  88. bladeworks/preview/rawframe.py +218 -0
  89. bladeworks/preview/render_jobs.py +311 -0
  90. bladeworks/preview/routes.py +375 -0
  91. bladeworks/preview/runner.py +349 -0
  92. bladeworks/preview/security.py +61 -0
  93. bladeworks/preview/service.py +234 -0
  94. bladeworks/preview/session.py +457 -0
  95. bladeworks/preview/source.py +603 -0
  96. bladeworks/preview/source_routes.py +151 -0
  97. bladeworks/preview/studio.py +77 -0
  98. bladeworks/preview/webrtc.py +295 -0
  99. bladeworks/spatial_luts/manifest.json +17 -0
  100. bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
  101. bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
  102. bladeworks/studio_static/__init__.py +1 -0
  103. bladeworks/studio_static/app.js +6519 -0
  104. bladeworks/studio_static/build-id.js +1 -0
  105. bladeworks/studio_static/capability-ui.js +68 -0
  106. bladeworks/studio_static/clip-state.js +335 -0
  107. bladeworks/studio_static/fcpxml.js +3313 -0
  108. bladeworks/studio_static/fixtures.js +393 -0
  109. bladeworks/studio_static/history.js +50 -0
  110. bladeworks/studio_static/index.html +24 -0
  111. bladeworks/studio_static/magnetic-timeline.js +1194 -0
  112. bladeworks/studio_static/mask-ui.js +189 -0
  113. bladeworks/studio_static/media-visuals.js +224 -0
  114. bladeworks/studio_static/mock-capabilities.js +409 -0
  115. bladeworks/studio_static/retime-ui.js +151 -0
  116. bladeworks/studio_static/runtime.js +1652 -0
  117. bladeworks/studio_static/scope-ui.js +150 -0
  118. bladeworks/studio_static/styles.css +1558 -0
  119. bladeworks/studio_static/templates/catalog.js +77 -0
  120. bladeworks/studio_static/templates/icons.js +84 -0
  121. bladeworks/studio_static/templates/inspector.js +553 -0
  122. bladeworks/studio_static/templates/library.js +61 -0
  123. bladeworks/studio_static/templates/media.js +185 -0
  124. bladeworks/studio_static/templates/shared.js +43 -0
  125. bladeworks/studio_static/templates/shell.js +94 -0
  126. bladeworks/studio_static/templates/timeline.js +377 -0
  127. bladeworks/studio_static/templates/topbar.js +83 -0
  128. bladeworks/studio_static/templates/types.js +7 -0
  129. bladeworks/studio_static/templates/viewer.js +174 -0
  130. bladeworks/studio_static/templates.js +31 -0
  131. bladeworks/studio_static/types.js +18 -0
  132. bladeworks/studio_static/ui.js +97 -0
  133. bladeworks/studio_static/viewer-geometry.js +55 -0
  134. bladeworks/tensor/__init__.py +131 -0
  135. bladeworks/tensor/audio_delivery.py +280 -0
  136. bladeworks/tensor/audio_pyav.py +360 -0
  137. bladeworks/tensor/blend.py +217 -0
  138. bladeworks/tensor/color.py +53 -0
  139. bladeworks/tensor/composite.py +33 -0
  140. bladeworks/tensor/decode.py +1309 -0
  141. bladeworks/tensor/decode_policy.py +363 -0
  142. bladeworks/tensor/effects.py +322 -0
  143. bladeworks/tensor/encode.py +614 -0
  144. bladeworks/tensor/errors.py +32 -0
  145. bladeworks/tensor/expr.py +775 -0
  146. bladeworks/tensor/fx_basic.py +946 -0
  147. bladeworks/tensor/fx_branched.py +640 -0
  148. bladeworks/tensor/fx_cohort.py +404 -0
  149. bladeworks/tensor/fx_color.py +851 -0
  150. bladeworks/tensor/fx_keyer.py +140 -0
  151. bladeworks/tensor/fx_mask.py +352 -0
  152. bladeworks/tensor/fx_warp.py +337 -0
  153. bladeworks/tensor/hdr.py +122 -0
  154. bladeworks/tensor/pipeline.py +401 -0
  155. bladeworks/tensor/plan.py +2231 -0
  156. bladeworks/tensor/renderer.py +1199 -0
  157. bladeworks/tensor/requirements.txt +28 -0
  158. bladeworks/tensor/resolution.py +246 -0
  159. bladeworks/tensor/sampler.py +598 -0
  160. bladeworks/tensor/support.py +169 -0
  161. bladeworks/tensor/swscale_fixedpoint.py +181 -0
  162. bladeworks/tensor/tr_equirect.py +420 -0
  163. bladeworks/tensor/tr_handlers.py +443 -0
  164. bladeworks/tensor/tr_phase5.py +697 -0
  165. bladeworks/tensor/transitions.py +424 -0
  166. bladeworks/transitions/__init__.py +10 -0
  167. bladeworks/transitions/carryovers.py +299 -0
  168. bladeworks/transitions/cohort.py +981 -0
  169. bladeworks/transitions/contract.py +490 -0
  170. bladeworks/transitions/equirectangular.py +915 -0
  171. bladeworks/transitions/light_deco.py +141 -0
  172. bladeworks/transitions/panel_motion.py +495 -0
  173. bladeworks/transitions/stock.py +270 -0
  174. bladeworks-0.1.0.dist-info/METADATA +66 -0
  175. bladeworks-0.1.0.dist-info/RECORD +178 -0
  176. bladeworks-0.1.0.dist-info/WHEEL +5 -0
  177. bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
  178. bladeworks-0.1.0.dist-info/top_level.txt +1 -0
@@ -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