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,614 @@
1
+ """Encoder exit: GPU canvas -> planes, PyAV encoder, optional encoder thread (A7 / A7-lite).
2
+
3
+ Architecture map
4
+ ----------------
5
+ canvas [4,H,W] premultiplied linear (renderer)
6
+ -> VideoEncoder.canvas_to_planes : the exit for the encoder's pixel policy (GPU):
7
+ opaque canvas_to_yuv420p : encode() to code space -> 8-bit RGB codes ->
8
+ BT.709 limited-range Y/Cb/Cr, 4:2:0 chroma
9
+ subsampling that mirrors swscale's default
10
+ (pair-average horizontally, 4-tap triangle
11
+ vertically) -> ONE flat uint8 tensor on device
12
+ alpha canvas_to_yuva444p10: unpremultiply -> straight RGB code + alpha ->
13
+ BT.709 limited-range 10-bit Y/Cb/Cr 4:4:4 +
14
+ full-range 10-bit straight alpha -> ONE flat
15
+ int16 tensor on device
16
+ -> .cpu() : the single device->host copy per frame (renderer)
17
+ -> VideoEncoder.write_planes : av.VideoFrame -> encode -> mux (serial)
18
+ EncoderThread.write_planes : bounded queue -> worker thread does the same (pipelined)
19
+
20
+ Pixel policies (PYTORCH_MVP_PLAN.md: "two pixel policies (opaque / alpha)")
21
+ --------------------------------------------------------------------------
22
+ ``opaque`` (default): Rec.709 limited-range yuv420p, libx264 (preset/crf
23
+ configurable) or an alternative encoder name (``h264_videotoolbox``), constant
24
+ frame rate, colour tags written -- unchanged from the legacy CPU delivery.
25
+
26
+ ``alpha`` (A7-lite): the root canvas is transparent (renderer), the exit
27
+ unpremultiplies at export only and writes **straight** alpha into ProRes 4444
28
+ (``prores_ks`` profile 4, ``yuva444p10le``) in a ``.mov``, Rec.709 limited
29
+ tags asserted, alpha plane full-range 10-bit. There is no swscale between the
30
+ renderer and the encoder: this module owns the only format conversion. The
31
+ CPU path has no alpha-carrying delivery (``oracle_rgba`` is a Vulkan rawvideo
32
+ oracle without audio), so the exit math here is exact float BT.709, rounded --
33
+ verified by ``test_tensor_color_io.py`` (planes vs a float64 oracle, and a
34
+ PyAV decode round-trip: alpha exact, RGB within 1 code).
35
+
36
+ Why the conversion moved to the GPU
37
+ -----------------------------------
38
+ The former exit handed 8-bit RGB to swscale (``VideoFrame.reformat``) on the
39
+ render thread: ~3 ms @720p / 5 ms @1080p of CPU work per frame in the one
40
+ thread that must never stall, plus a 3 B/px download instead of 1.5 B/px.
41
+ ``canvas_to_yuv420p`` does the same matrix and subsampling as elementwise
42
+ tensor ops and downloads half the bytes. Both render paths (serial and
43
+ pipelined) call this one function, so their outputs are identical by
44
+ construction; the residual drift vs swscale is fixed-point coefficient
45
+ rounding only (measured on random + gradient frames: |diff| <= 1 code,
46
+ Y differs on ~0.04 % of pixels, Cb/Cr on ~0.3 % of samples).
47
+
48
+ Main callers: ``renderer.render_document`` (both loops), the executor's
49
+ ``--backend tensor`` branch (``_render_tensor_video`` picks the policy), the
50
+ bench and tests.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import os
56
+ import queue
57
+ import threading
58
+ import time
59
+ from dataclasses import dataclass
60
+ from fractions import Fraction
61
+ from pathlib import Path
62
+ from typing import Literal, Optional
63
+
64
+ import av
65
+ import numpy as np
66
+ import torch
67
+ import torch.nn.functional as F
68
+
69
+ from .color import encode, unpremultiply
70
+ from .errors import TensorRenderError
71
+
72
+ PixelPolicy = Literal["opaque", "alpha"]
73
+
74
+ # BT.709 luma weights (Rec. ITU-R BT.709-6) and the limited-range scale/offsets.
75
+ _KR, _KB = 0.2126, 0.0722
76
+ _KG = 1.0 - _KR - _KB
77
+ # swscale's rgb24ToY_c adds ``1 << (RGB2YUV_SHIFT - 7)`` before its shift, a
78
+ # +1/128-code luma bias; matching it moves the round-boundary mismatches from
79
+ # ~0.9 % of pixels to ~0.04 % (measured against libswscale 9.1 / FFmpeg 8).
80
+ _SWSCALE_LUMA_BIAS = 1.0 / 128.0
81
+
82
+
83
+ # Row-major 3x3: [Y', Cb, Cr] = M @ [R, G, B] on 0..255 codes (Y' scaled to 219/255,
84
+ # Cb/Cr to 224/255), then + [16 + bias, 128, 128].
85
+ _RGB_TO_YCBCR = (
86
+ (_KR * 219.0 / 255.0, _KG * 219.0 / 255.0, _KB * 219.0 / 255.0),
87
+ (-_KR * 0.5 / (1.0 - _KB) * 224.0 / 255.0, -_KG * 0.5 / (1.0 - _KB) * 224.0 / 255.0, 0.5 * 224.0 / 255.0),
88
+ (0.5 * 224.0 / 255.0, -_KG * 0.5 / (1.0 - _KR) * 224.0 / 255.0, -_KB * 0.5 / (1.0 - _KR) * 224.0 / 255.0),
89
+ )
90
+ _YCBCR_OFFSET = (16.0 + _SWSCALE_LUMA_BIAS, 128.0, 128.0)
91
+ # swscale's 4:2:0 chroma taps at equal size: pair-average horizontally, [1/8, 3/8, 3/8, 1/8]
92
+ # vertically over rows 2j-1..2j+2 -> one depthwise conv2d (2 x 4 taps, stride 2).
93
+ _CHROMA_TAPS = tuple(0.5 * weight for weight in (0.125, 0.375, 0.375, 0.125))
94
+
95
+
96
+ class _ExitConstants:
97
+ """Per-device cache of the exit's small constant tensors (matrix, offset, chroma kernel)."""
98
+
99
+ def __init__(self) -> None:
100
+ self._cache: dict[tuple[str, int], tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
101
+
102
+ def get(self, like: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
103
+ key = (str(like.device), like.device.index or 0)
104
+ cached = self._cache.get(key)
105
+ if cached is None:
106
+ matrix = torch.tensor(_RGB_TO_YCBCR, dtype=torch.float32, device=like.device)
107
+ offset = torch.tensor(_YCBCR_OFFSET, dtype=torch.float32, device=like.device).view(3, 1, 1)
108
+ taps = torch.tensor(_CHROMA_TAPS, dtype=torch.float32, device=like.device).view(1, 1, 4, 1)
109
+ kernel = taps.expand(2, 1, 4, 2).contiguous() # [out=2, in/groups=1, kh=4, kw=2]
110
+ cached = self._cache[key] = (matrix, offset, kernel)
111
+ return cached
112
+
113
+
114
+ _EXIT_CONSTANTS = _ExitConstants()
115
+
116
+
117
+ def rgb_codes_to_yuv420p(rgb_codes: torch.Tensor) -> torch.Tensor:
118
+ """8-bit RGB codes ``[3, H, W]`` (float 0..255, already rounded) -> flat yuv420p uint8.
119
+
120
+ Pythonese: compute full-resolution Y'CbCr from the codes with the BT.709
121
+ matrix (one matmul + one bias add); Y' lands on 16..235 and Cb/Cr on
122
+ 16..240 around 128; subsample the chroma the way swscale's default
123
+ (bilinear, non-accurate-rounding) path does for rgb24 -> yuv420p at equal
124
+ size -- average each horizontal pixel pair (its ``rgb24ToUV_half``), then
125
+ filter vertically with the 4-tap triangle [1/8, 3/8, 3/8, 1/8] over rows
126
+ 2j-1..2j+2 (edge rows replicated) -- as one replicate pad + one stride-2
127
+ depthwise ``conv2d``; round half up, clamp, and lay out Y, U, V planes
128
+ back to back in one uint8 buffer (the memory layout
129
+ ``av.VideoFrame.from_ndarray(..., format="yuv420p")`` expects when
130
+ reshaped to ``[H * 3 / 2, W]``).
131
+
132
+ Why matmul + conv2d rather than per-channel arithmetic: eager MPS is
133
+ launch-bound (~0.1-0.2 ms per kernel at 720p), so the exit's cost is its
134
+ kernel count -- ~10 launches here vs ~35 written out per channel
135
+ (measured 3.2 ms/frame -> see stats).
136
+
137
+ Main callers: ``canvas_to_yuv420p``; tests measuring the drift vs swscale.
138
+ """
139
+
140
+ channels, height, width = rgb_codes.shape
141
+ if channels != 3 or height % 2 or width % 2:
142
+ raise TensorRenderError(f"yuv420p exit needs [3, even H, even W]; got {tuple(rgb_codes.shape)}")
143
+ matrix, offset, kernel = _EXIT_CONSTANTS.get(rgb_codes)
144
+ ycbcr = torch.matmul(matrix, rgb_codes.reshape(3, height * width)).view(3, height, width) + offset
145
+ padded = F.pad(ycbcr[1:].unsqueeze(0), (0, 0, 1, 2), mode="replicate") # rows -1 .. H+1
146
+ chroma = F.conv2d(padded, kernel, stride=2, groups=2).squeeze(0) # [2, H/2, W/2]
147
+ # Round half up via truncation of the (non-negative, clamped) value + 0.5.
148
+ y_u8 = (ycbcr[0] + 0.5).clamp_(0.0, 255.0).to(torch.uint8)
149
+ uv_u8 = (chroma + 0.5).clamp_(0.0, 255.0).to(torch.uint8)
150
+ return torch.cat((y_u8.reshape(-1), uv_u8.reshape(-1)))
151
+
152
+
153
+ def canvas_to_yuv420p(canvas: torch.Tensor) -> torch.Tensor:
154
+ """Premultiplied linear canvas ``[4, H, W]`` -> flat yuv420p uint8 tensor (on device).
155
+
156
+ The root canvas is opaque, so the RGB channels are the final light: encode
157
+ to code space, quantize to 8-bit codes exactly as the former rgb24 exit
158
+ did (round, clamp), then convert. Quantizing first keeps the yuv exit a
159
+ pure replacement of swscale on the same 8-bit input.
160
+ """
161
+
162
+ codes = (encode(canvas[:3]) * 255.0).round_().clamp_(0.0, 255.0)
163
+ return rgb_codes_to_yuv420p(codes)
164
+
165
+
166
+ def yuv420p_ndarray(flat_planes_cpu: torch.Tensor, *, height: int, width: int) -> np.ndarray:
167
+ """View a downloaded flat plane buffer as the ``[H*3/2, W]`` array PyAV wants."""
168
+
169
+ return flat_planes_cpu.numpy().reshape(height * 3 // 2, width)
170
+
171
+
172
+ # ---- alpha exit: straight RGBA -> yuva444p10le (BT.709 limited 10-bit + full-range alpha) ----
173
+
174
+ # Row-major 3x3 on straight RGB in 0..1: [Y', Cb, Cr] = M @ [R, G, B] * 1023-scale, then
175
+ # + [64, 512, 512] (10-bit limited: Y' 64..940, Cb/Cr 64..960 around 512).
176
+ _RGB_TO_YCBCR_10 = (
177
+ (_KR * 876.0, _KG * 876.0, _KB * 876.0),
178
+ (-_KR * 0.5 / (1.0 - _KB) * 896.0, -_KG * 0.5 / (1.0 - _KB) * 896.0, 0.5 * 896.0),
179
+ (0.5 * 896.0, -_KG * 0.5 / (1.0 - _KR) * 896.0, -_KB * 0.5 / (1.0 - _KR) * 896.0),
180
+ )
181
+ _YCBCR_OFFSET_10 = (64.0, 512.0, 512.0)
182
+
183
+
184
+ class _AlphaExitConstants:
185
+ """Per-device cache of the alpha exit's matrix / offset."""
186
+
187
+ def __init__(self) -> None:
188
+ self._cache: dict[tuple[str, int], tuple[torch.Tensor, torch.Tensor]] = {}
189
+
190
+ def get(self, like: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
191
+ key = (str(like.device), like.device.index or 0)
192
+ cached = self._cache.get(key)
193
+ if cached is None:
194
+ matrix = torch.tensor(_RGB_TO_YCBCR_10, dtype=torch.float32, device=like.device)
195
+ offset = torch.tensor(_YCBCR_OFFSET_10, dtype=torch.float32, device=like.device).view(3, 1, 1)
196
+ cached = self._cache[key] = (matrix, offset)
197
+ return cached
198
+
199
+
200
+ _ALPHA_EXIT_CONSTANTS = _AlphaExitConstants()
201
+
202
+
203
+ def canvas_to_yuva444p10(canvas: torch.Tensor) -> torch.Tensor:
204
+ """Premultiplied linear canvas ``[4, H, W]`` -> flat ``yuva444p10le`` int16 tensor (on device).
205
+
206
+ Pythonese: unpremultiply (colour 0 where alpha is 0), encode the straight
207
+ colour to code space, take the straight alpha as is; Y'CbCr = BT.709
208
+ limited-range 10-bit (one matmul + bias), alpha = full-range 10-bit
209
+ (``alpha * 1023``); round half up, clamp to 0..1023, and lay the four planes
210
+ Y, Cb, Cr, A back to back (``[4*H*W]``, int16 because torch has no
211
+ arithmetic uint16 -- the codes fit).
212
+
213
+ Why unpremultiply here and nowhere else: the whole tensor pipeline is
214
+ premultiplied (over, dissolves, opacity); ProRes 4444 carries straight
215
+ alpha, so the division happens exactly once, at the exit. Alpha is
216
+ quantised straight (no association), which is what QuickTime / Final Cut
217
+ expect from ``ap4h``.
218
+
219
+ Main callers: ``VideoEncoder.canvas_to_planes`` for ``pixel_policy="alpha"``.
220
+ """
221
+
222
+ channels, height, width = canvas.shape
223
+ if channels != 4:
224
+ raise TensorRenderError(f"alpha exit needs a [4, H, W] canvas; got {tuple(canvas.shape)}")
225
+ matrix, offset = _ALPHA_EXIT_CONSTANTS.get(canvas)
226
+ straight = unpremultiply(canvas)
227
+ rgb = encode(straight[:3]).reshape(3, height * width)
228
+ ycbcr = torch.matmul(matrix, rgb).view(3, height, width) + offset
229
+ alpha = straight[3:4].clamp(0.0, 1.0) * 1023.0
230
+ planes = torch.cat((ycbcr, alpha), dim=0)
231
+ return (planes + 0.5).clamp_(0.0, 1023.0).to(torch.int16).reshape(-1)
232
+
233
+
234
+ def yuva444p10_ndarray(flat_planes_cpu: torch.Tensor, *, height: int, width: int) -> np.ndarray:
235
+ """View a downloaded flat alpha-exit buffer as the ``[4, H, W]`` uint16 array PyAV wants."""
236
+
237
+ return flat_planes_cpu.numpy().view(np.uint16).reshape(4, height, width)
238
+
239
+
240
+ def _planar_frame_from_planes(planes: np.ndarray, pixel_format: str) -> av.VideoFrame:
241
+ """Build an ``av.VideoFrame`` from a ``[planes, H, W]`` array for formats PyAV cannot ``from_ndarray``.
242
+
243
+ Copies each plane into the frame's own buffer honouring the plane stride
244
+ (``line_size`` may exceed ``W * bytes_per_sample``), so any planar format
245
+ with equally sized planes works (used for ``yuva444p10le``).
246
+ """
247
+
248
+ count, height, width = planes.shape
249
+ frame = av.VideoFrame(width, height, pixel_format)
250
+ if len(frame.planes) != count:
251
+ raise TensorRenderError(f"{pixel_format} has {len(frame.planes)} planes; got {count}")
252
+ for index, plane in enumerate(frame.planes):
253
+ row_bytes = width * planes.dtype.itemsize
254
+ if plane.line_size == row_bytes:
255
+ plane.update(np.ascontiguousarray(planes[index]).tobytes())
256
+ continue
257
+ padded = np.zeros((height, plane.line_size), dtype=np.uint8)
258
+ padded[:, :row_bytes] = np.ascontiguousarray(planes[index]).view(np.uint8).reshape(height, row_bytes)
259
+ plane.update(padded.tobytes())
260
+ return frame
261
+
262
+
263
+ @dataclass
264
+ class EncoderAudio:
265
+ """The finished delivery audio to mux beside the video, in ONE container.
266
+
267
+ ``frames`` are float-planar (``fltp``) ``av.AudioFrame`` at ``sample_rate`` /
268
+ ``ffmpeg_layout`` -- exactly what ``tensor.audio_pyav`` yields and what the
269
+ AAC encoder takes directly. This is how the tensor renderer replaces the old
270
+ second ffmpeg mux process: the audio graph runs in-process and its frames are
271
+ interleaved into the encoder's own container.
272
+ """
273
+
274
+ frames: list # list[av.AudioFrame]
275
+ sample_rate: int
276
+ ffmpeg_layout: str
277
+ codec: str = "aac"
278
+
279
+
280
+ class VideoEncoder:
281
+ """Synchronous PyAV encoder: ``canvas_to_planes`` (GPU exit) + ``write_planes`` per frame, ``close`` flushes.
282
+
283
+ ``pixel_policy`` selects the exit and the container/codec contract:
284
+
285
+ * ``"opaque"``: ``codec`` (``libx264`` default, or ``h264_videotoolbox`` /
286
+ any encoder taking ``yuv420p``) into the given path (``.mp4`` by the
287
+ executor), Rec.709 limited yuv420p.
288
+ * ``"alpha"``: ``codec`` must be ``"prores_ks"`` (loud otherwise) into a
289
+ ``.mov``, ProRes 4444 (profile 4) ``yuva444p10le`` straight alpha,
290
+ Rec.709 limited tags; ``preset`` / ``crf`` do not apply.
291
+
292
+ ``write_yuv420p`` remains for callers that already hold the ``[H*3/2, W]``
293
+ uint8 array (the current renderer exit); ``write_planes`` takes the flat
294
+ host tensor of either exit.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ output_path: Path,
300
+ *,
301
+ width: int,
302
+ height: int,
303
+ frame_duration: Fraction,
304
+ codec: str = "libx264",
305
+ preset: str = "medium",
306
+ crf: int = 18,
307
+ threads: int = 0,
308
+ bit_rate: int = 0,
309
+ pixel_policy: PixelPolicy = "opaque",
310
+ audio: Optional["EncoderAudio"] = None,
311
+ ) -> None:
312
+ if pixel_policy not in ("opaque", "alpha"):
313
+ raise TensorRenderError(f"unknown pixel policy {pixel_policy!r} (opaque | alpha)")
314
+ if pixel_policy == "alpha" and codec != "prores_ks":
315
+ raise TensorRenderError(
316
+ f"the alpha exit writes ProRes 4444 through prores_ks; got codec={codec!r}"
317
+ )
318
+ if pixel_policy == "alpha" and output_path.suffix.lower() != ".mov":
319
+ raise TensorRenderError(f"the alpha exit needs a .mov container; got {output_path.name}")
320
+ self.output_path = output_path
321
+ self.width = width
322
+ self.height = height
323
+ self.pixel_policy: PixelPolicy = pixel_policy
324
+ self.pixel_format = "yuva444p10le" if pixel_policy == "alpha" else "yuv420p"
325
+ self.codec = codec
326
+ threads = threads or max(2, min(4, (os.cpu_count() or 2) // 2))
327
+ self._container = av.open(str(output_path), mode="w", options={"movflags": "+faststart+write_colr"})
328
+ stream = self._container.add_stream(codec, rate=Fraction(1) / frame_duration)
329
+ stream.width = width
330
+ stream.height = height
331
+ stream.pix_fmt = self.pixel_format
332
+ stream.time_base = frame_duration
333
+ options = {}
334
+ if pixel_policy == "alpha":
335
+ # ProRes 4444 (profile 4) with 16-bit alpha coding; the encoder's
336
+ # own rate control (bits_per_mb per profile) -- no crf/preset.
337
+ options = {"profile": "4", "alpha_bits": "16", "vendor": "apl0"}
338
+ elif codec == "libx264":
339
+ options = {
340
+ "preset": preset,
341
+ "crf": str(crf),
342
+ "x264-params": "colorprim=bt709:transfer=bt709:colormatrix=bt709",
343
+ }
344
+ else:
345
+ # Hardware / other encoders have no crf: target a bitrate in the
346
+ # same class as x264 crf 18 (~10 Mb/s at 720p, ~20 Mb/s at 1080p).
347
+ stream.bit_rate = bit_rate or int(10_000_000 * (width * height) / (720 * 1280))
348
+ if codec.endswith("_videotoolbox"):
349
+ options = {"allow_sw": "0", "realtime": "0"}
350
+ stream.codec_context.options = options
351
+ codec_context = stream.codec_context
352
+ # libavcodec defaults to ONE encoder thread unless the caller asks
353
+ # (the ffmpeg CLI sets auto). x264 frame-threading is what makes
354
+ # ``medium`` affordable; use every core, like the legacy path does.
355
+ codec_context.thread_type = "FRAME"
356
+ codec_context.thread_count = threads
357
+ for name, value in (
358
+ ("color_range", 1), # MPEG / limited
359
+ ("colorspace", 1), # BT.709
360
+ ("color_primaries", 1), # BT.709
361
+ ("color_trc", 1), # BT.709
362
+ ):
363
+ try:
364
+ setattr(codec_context, name, value)
365
+ except (AttributeError, TypeError, ValueError):
366
+ pass
367
+ self._stream = stream
368
+ self._frame_duration = frame_duration
369
+ self.frames_written = 0
370
+ self.busy_seconds = 0.0
371
+ self._closed = False
372
+
373
+ # ---- audio (single-container, interleaved by dts) ---------------------
374
+ # The audio is small, so it is pre-encoded to a packet list and the SMALL
375
+ # side is buffered: during the streaming video mux we flush audio packets
376
+ # up to each video frame's presentation time, keeping the muxer's
377
+ # interleave window bounded (never buffer the large video side). All of
378
+ # this runs on whichever thread owns the container (the encoder worker for
379
+ # the pipelined path), because PyAV containers are not thread-safe.
380
+ self._audio = audio
381
+ self._audio_stream = None
382
+ self._audio_packets: list = []
383
+ self._audio_index = 0
384
+ self._audio_primed = False
385
+ if audio is not None:
386
+ audio_stream = self._container.add_stream(audio.codec, rate=audio.sample_rate)
387
+ audio_stream.codec_context.layout = audio.ffmpeg_layout
388
+ audio_stream.codec_context.format = "fltp"
389
+ self._audio_stream = audio_stream
390
+
391
+ # ---- exit (GPU) -------------------------------------------------------------------
392
+
393
+ def canvas_to_planes(self, canvas: torch.Tensor) -> torch.Tensor:
394
+ """The exit for this encoder's pixel policy: canvas -> ONE flat plane tensor on device.
395
+
396
+ Main callers: ``renderer.render_document`` (once per frame, before the
397
+ single device->host download).
398
+ """
399
+
400
+ if self.pixel_policy == "alpha":
401
+ return canvas_to_yuva444p10(canvas)
402
+ return canvas_to_yuv420p(canvas)
403
+
404
+ # ---- encode (CPU) -----------------------------------------------------------------
405
+
406
+ def write_planes(self, host_planes: torch.Tensor) -> None:
407
+ """Encode one downloaded flat plane tensor of this encoder's exit (``canvas_to_planes``)."""
408
+
409
+ if self.pixel_policy == "alpha":
410
+ self.write_yuva444p10(yuva444p10_ndarray(host_planes, height=self.height, width=self.width))
411
+ else:
412
+ self.write_yuv420p(yuv420p_ndarray(host_planes, height=self.height, width=self.width))
413
+
414
+ def write_yuv420p(self, planes: np.ndarray) -> None:
415
+ """Encode one ``[H*3/2, W]`` uint8 yuv420p frame (Y rows, then U, then V rows)."""
416
+
417
+ if self.pixel_policy != "opaque":
418
+ raise TensorRenderError("write_yuv420p on an alpha-policy encoder")
419
+ expected = (self.height * 3 // 2, self.width)
420
+ if planes.dtype != np.uint8 or planes.shape != expected:
421
+ raise TensorRenderError(f"yuv420p frame must be uint8 {expected}; got {planes.dtype} {planes.shape}")
422
+ self._encode(av.VideoFrame.from_ndarray(planes, format="yuv420p"))
423
+
424
+ def write_yuva444p10(self, planes: np.ndarray) -> None:
425
+ """Encode one ``[4, H, W]`` uint16 yuva444p10le frame (Y, Cb, Cr, straight A)."""
426
+
427
+ if self.pixel_policy != "alpha":
428
+ raise TensorRenderError("write_yuva444p10 on an opaque-policy encoder")
429
+ expected = (4, self.height, self.width)
430
+ if planes.dtype != np.uint16 or planes.shape != expected:
431
+ raise TensorRenderError(f"yuva444p10 frame must be uint16 {expected}; got {planes.dtype} {planes.shape}")
432
+ self._encode(_planar_frame_from_planes(planes, "yuva444p10le"))
433
+
434
+ def _prime_audio(self) -> None:
435
+ """Encode the whole delivery audio to a dts-ordered packet list once.
436
+
437
+ Runs on the container-owning thread the first time a frame is encoded (or
438
+ at close for a zero-frame render). ``encode()`` does not touch the
439
+ muxer, so this only fills ``self._audio_packets``; muxing happens
440
+ interleaved in ``_flush_audio_until``.
441
+ """
442
+
443
+ self._audio_primed = True
444
+ if self._audio_stream is None or self._audio is None:
445
+ return
446
+ sample_index = 0
447
+ rate = self._audio.sample_rate
448
+ for audio_frame in self._audio.frames:
449
+ audio_frame.pts = sample_index
450
+ audio_frame.time_base = Fraction(1, rate)
451
+ sample_index += audio_frame.samples
452
+ self._audio_packets.extend(self._audio_stream.encode(audio_frame))
453
+ self._audio_packets.extend(self._audio_stream.encode(None))
454
+
455
+ def _flush_audio_until(self, video_seconds: Optional[float]) -> None:
456
+ """Mux buffered audio packets up to ``video_seconds`` (all if ``None``)."""
457
+
458
+ while self._audio_index < len(self._audio_packets):
459
+ packet = self._audio_packets[self._audio_index]
460
+ if (
461
+ video_seconds is not None
462
+ and packet.pts is not None
463
+ and float(packet.pts * packet.time_base) > video_seconds
464
+ ):
465
+ break
466
+ self._container.mux(packet)
467
+ self._audio_index += 1
468
+
469
+ def _encode(self, frame: av.VideoFrame) -> None:
470
+ started = time.perf_counter()
471
+ frame.pts = self.frames_written
472
+ frame.time_base = self._frame_duration
473
+ for packet in self._stream.encode(frame):
474
+ self._container.mux(packet)
475
+ self.frames_written += 1
476
+ if self._audio_stream is not None:
477
+ if not self._audio_primed:
478
+ self._prime_audio()
479
+ # Keep audio muxed just behind the video's presentation time so the
480
+ # interleaver never holds the large video side waiting for audio.
481
+ self._flush_audio_until(float(self.frames_written * self._frame_duration))
482
+ self.busy_seconds += time.perf_counter() - started
483
+
484
+ def close(self) -> None:
485
+ if self._closed:
486
+ return
487
+ self._closed = True
488
+ started = time.perf_counter()
489
+ for packet in self._stream.encode(None):
490
+ self._container.mux(packet)
491
+ if self._audio_stream is not None:
492
+ if not self._audio_primed:
493
+ self._prime_audio()
494
+ # Video is fully written; flush the remaining (small) audio tail.
495
+ self._flush_audio_until(None)
496
+ self._container.close()
497
+ self.busy_seconds += time.perf_counter() - started
498
+
499
+
500
+ @dataclass
501
+ class EncoderThreadStats:
502
+ frames: int = 0
503
+ wait_seconds: float = 0.0 # producer time blocked because the queue was full
504
+ busy_seconds: float = 0.0 # worker time inside encode + mux (incl. flush)
505
+ max_queue_depth: int = 0 # deepest backlog seen at submit time
506
+ queue_depth_sum: int = 0 # for the mean backlog
507
+
508
+
509
+ class EncoderThread:
510
+ """Bounded queue in front of ``VideoEncoder`` so x264 never blocks the GPU thread.
511
+
512
+ Pythonese: ``write_planes`` (flat host tensor of either exit) or
513
+ ``write_yuv420p`` (the ``[H*3/2, W]`` array) puts the frame on a queue of at
514
+ most ``queue_depth`` frames (blocking, and re-raising the worker's exception
515
+ if it died); one worker thread pops frames in order and calls the matching
516
+ ``VideoEncoder`` writer; ``close`` sends the end sentinel, joins the worker,
517
+ and re-raises whatever it failed with, so a broken encoder fails the render
518
+ loudly instead of hanging or truncating silently.
519
+
520
+ Why a thread and not a process: PyAV releases the GIL inside
521
+ ``avcodec_send_frame`` and x264 runs its own frame threads, so the encoder
522
+ stage overlaps with GPU work without pickling frames.
523
+ """
524
+
525
+ _DONE = object()
526
+
527
+ def __init__(self, encoder: VideoEncoder, *, queue_depth: int = 6) -> None:
528
+ if queue_depth < 1:
529
+ raise TensorRenderError("encoder queue depth must be >= 1")
530
+ self.encoder = encoder
531
+ self.stats = EncoderThreadStats()
532
+ self._queue: queue.Queue = queue.Queue(maxsize=queue_depth)
533
+ self._failure: Optional[BaseException] = None
534
+ self._closed = False
535
+ self._thread = threading.Thread(target=self._run, name="tensor-encoder", daemon=True)
536
+ self._thread.start()
537
+
538
+ def _run(self) -> None:
539
+ try:
540
+ while True:
541
+ item = self._queue.get()
542
+ if item is self._DONE:
543
+ return
544
+ if isinstance(item, torch.Tensor):
545
+ self.encoder.write_planes(item)
546
+ else:
547
+ self.encoder.write_yuv420p(item)
548
+ except BaseException as exc: # noqa: BLE001 - re-raised on the producer thread
549
+ self._failure = exc
550
+ # Drain so a blocked producer wakes up and sees the failure.
551
+ while True:
552
+ try:
553
+ self._queue.get_nowait()
554
+ except queue.Empty:
555
+ return
556
+
557
+ def _check_failure(self) -> None:
558
+ if self._failure is not None:
559
+ raise TensorRenderError(f"encoder thread failed: {self._failure!r}") from self._failure
560
+
561
+ def canvas_to_planes(self, canvas: torch.Tensor) -> torch.Tensor:
562
+ """The wrapped encoder's exit (GPU); see ``VideoEncoder.canvas_to_planes``."""
563
+
564
+ return self.encoder.canvas_to_planes(canvas)
565
+
566
+ def write_planes(self, host_planes: torch.Tensor) -> None:
567
+ self._submit(host_planes)
568
+
569
+ def write_yuv420p(self, planes: np.ndarray) -> None:
570
+ self._submit(planes)
571
+
572
+ def _submit(self, item: object) -> None:
573
+ depth = self._queue.qsize()
574
+ self.stats.max_queue_depth = max(self.stats.max_queue_depth, depth)
575
+ self.stats.queue_depth_sum += depth
576
+ started = time.perf_counter()
577
+ while True:
578
+ self._check_failure()
579
+ if not self._thread.is_alive():
580
+ raise TensorRenderError("encoder thread exited before the render finished")
581
+ try:
582
+ self._queue.put(item, timeout=0.5)
583
+ break
584
+ except queue.Full:
585
+ continue
586
+ self.stats.wait_seconds += time.perf_counter() - started
587
+ self.stats.frames += 1
588
+
589
+ def close(self) -> None:
590
+ """Flush: wait for the queue to drain, close the encoder, re-raise worker failures."""
591
+
592
+ if self._closed:
593
+ return
594
+ self._closed = True
595
+ started = time.perf_counter()
596
+ if self._thread.is_alive():
597
+ while True:
598
+ try:
599
+ self._queue.put(self._DONE, timeout=0.5)
600
+ break
601
+ except queue.Full:
602
+ if self._failure is not None or not self._thread.is_alive():
603
+ break
604
+ self._thread.join()
605
+ self.stats.wait_seconds += time.perf_counter() - started
606
+ try:
607
+ self.encoder.close()
608
+ except Exception:
609
+ if self._failure is None:
610
+ raise
611
+ # The worker's failure is the root cause; report that one.
612
+ finally:
613
+ self.stats.busy_seconds = self.encoder.busy_seconds
614
+ self._check_failure()
@@ -0,0 +1,32 @@
1
+ """Errors for the tensor renderer.
2
+
3
+ Why these derive from the renderer's shared error tree
4
+ -----------------------------------------------------
5
+ ``cli.main`` catches ``FCPXMLRenderError`` and ``executor.execute_render``
6
+ catches it to write the ``failed`` manifest and unlink the partial output.
7
+ Bare ``RuntimeError``s escape both, so a tensor rejection would exit with a
8
+ traceback and leave the pre-flight ``preparing`` manifest on disk. Deriving
9
+ from the existing types puts tensor failures on exactly the same reporting
10
+ path as every other backend:
11
+
12
+ - ``TensorRenderUnsupported`` is a *capability* verdict decided before any
13
+ frame is decoded, so it derives from ``RenderCapabilityError`` (the same
14
+ class the Vulkan gate and the segmentation gate raise).
15
+ - ``TensorRenderError`` is an *execution* failure (decode, encode, device),
16
+ so it derives from ``FFmpegExecutionError`` -- the class the executor
17
+ already treats as "the render ran and produced nothing usable".
18
+ """
19
+
20
+ from ..core.errors import FFmpegExecutionError, RenderCapabilityError
21
+
22
+
23
+ class TensorRenderUnsupported(RenderCapabilityError):
24
+ """A compiled construct is outside the tensor renderer's supported class.
25
+
26
+ Raised at plan time, before any frame is decoded, so a project either
27
+ renders fully or fails loudly with the construct named.
28
+ """
29
+
30
+
31
+ class TensorRenderError(FFmpegExecutionError):
32
+ """A runtime failure inside the tensor renderer (decode/encode/device)."""