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,598 @@
1
+ """One homography per layer: crop / conform / corner pin / transform -> ``grid_sample``.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ GeometryPlan.snapshot(t) (geometry.py -- the exact semantics)
7
+ composed_quad, camera_placement, crop_rect, source_rect
8
+ -> conform_matrix : source raster (edge coords) -> clip canvas
9
+ -> composed matrix : clip canvas -> clip canvas (corner pin, then affine)
10
+ -> canvas_to_project : clip canvas -> project (inert group placement)
11
+ -> layer_homography : the product, source edge coords -> project edge coords
12
+ source_alpha_window : integer pixel-index window (trim / crop / pan / conform none)
13
+ resize_exact_aspect_opaque(...) : encoded-domain legacy-swscale bilinear minification
14
+ for the calibrated whole-raster Fit / Fill fast path
15
+ warp(source_rgba, H, out_size) : inverse-map every output pixel centre through H,
16
+ bilinear ``grid_sample`` with transparent (zeros) padding
17
+
18
+ Conventions (the trap list in ``plan_research/xyzt_inventory.md`` §5)
19
+ --------------------------------------------------------------------
20
+ * Everything here is in *edge* coordinates: a raster of ``W`` pixels spans
21
+ ``[0, W]`` and pixel ``i`` is centred at ``i + 0.5``. ``geometry.py`` quads
22
+ are edge coordinates too (``compose_spatial_quad`` maps the project rectangle
23
+ ``(0,0)-(W,H)``). FFmpeg's ``perspective`` works on pixel *indices*; the
24
+ reference converts with ``correct_quad_for_pixel_centers`` -- applying the
25
+ edge homography to output pixel centres ``(x+0.5, y+0.5)`` and sampling with
26
+ ``align_corners=False`` is that same correction, so no half-pixel shift is
27
+ applied here. (Proof: for a pure scale ``s``, edge ``x = x0 + s*u`` at
28
+ centres gives index ``x_i = x0 + s*u_i + (s-1)/2`` -- exactly the reference's
29
+ ``(scale-1)/2`` conform term.)
30
+ * Rotation sign, +Y-up flip, project-height units, and the anchor rule are
31
+ *not* re-derived here: ``composed_quad`` already contains them.
32
+ * Crop edges are percent of *source* height and arrive resolved in
33
+ ``source_rect``/``crop_rect``; the alpha window uses the reference's
34
+ inclusive integer-index rule (``between(X, left, W-right)``).
35
+ * Sampling is bilinear in calibrated linear light on *straight* RGBA with a
36
+ transparent border, like the reference's ``lutrgb pow(1.94) -> perspective``;
37
+ callers premultiply *after* the warp.
38
+
39
+ Main callers:
40
+ - ``renderer.render_document`` (per layer, per frame).
41
+ - ``experimental_tests/core/test_tensor_sampler.py`` (corner-error test).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import math
47
+ from functools import lru_cache
48
+ from typing import Optional
49
+
50
+ import numpy as np
51
+ import torch
52
+ import torch.nn.functional as F
53
+
54
+ from ..core.geometry import FrameGeometry, GeometrySnapshot, Quad
55
+ from .swscale_fixedpoint import c_div as _c_div, finalize_swscale_filter
56
+
57
+ Matrix = np.ndarray # 3x3 float64 homography, row vector convention h @ [x, y, 1]^T
58
+
59
+
60
+ # --------------------------------------------------------------------------- matrices
61
+
62
+
63
+ def identity_matrix() -> Matrix:
64
+ return np.eye(3, dtype=np.float64)
65
+
66
+
67
+ def affine_matrix(scale_x: float, scale_y: float, translate_x: float, translate_y: float) -> Matrix:
68
+ """``x' = scale_x * x + translate_x`` (and likewise for y)."""
69
+
70
+ return np.array(
71
+ [[scale_x, 0.0, translate_x], [0.0, scale_y, translate_y], [0.0, 0.0, 1.0]],
72
+ dtype=np.float64,
73
+ )
74
+
75
+
76
+ def homography_from_points(source: Quad, destination: Quad) -> Matrix:
77
+ """Return the 3x3 homography mapping the four ``source`` points onto ``destination``.
78
+
79
+ Quad order is (top-left, top-right, bottom-left, bottom-right) as in
80
+ ``geometry.py``. Four exact correspondences pin a projective map; the
81
+ standard direct linear transform with ``h33 = 1`` is solved with numpy.
82
+ """
83
+
84
+ rows: list[list[float]] = []
85
+ rhs: list[float] = []
86
+ for (x, y), (u, v) in zip(source, destination):
87
+ rows.append([x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y])
88
+ rhs.append(u)
89
+ rows.append([0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y])
90
+ rhs.append(v)
91
+ solution = np.linalg.solve(np.array(rows, dtype=np.float64), np.array(rhs, dtype=np.float64))
92
+ return np.array(
93
+ [[solution[0], solution[1], solution[2]],
94
+ [solution[3], solution[4], solution[5]],
95
+ [solution[6], solution[7], 1.0]],
96
+ dtype=np.float64,
97
+ )
98
+
99
+
100
+ def apply_matrix(matrix: Matrix, points: Quad) -> Quad:
101
+ result = []
102
+ for x, y in points:
103
+ hx, hy, hw = matrix @ np.array([x, y, 1.0])
104
+ result.append((float(hx / hw), float(hy / hw)))
105
+ return tuple(result) # type: ignore[return-value]
106
+
107
+
108
+ def is_identity(matrix: Matrix, *, tolerance: float = 1e-9) -> bool:
109
+ return bool(np.allclose(matrix / matrix[2, 2], np.eye(3), atol=tolerance, rtol=0.0))
110
+
111
+
112
+ def project_rect_quad(width: int, height: int) -> Quad:
113
+ return ((0.0, 0.0), (float(width), 0.0), (0.0, float(height)), (float(width), float(height)))
114
+
115
+
116
+ # --------------------------------------------------------------------------- semantics
117
+
118
+
119
+ def conform_matrix(snapshot: GeometrySnapshot, frame: FrameGeometry, conform: str) -> Matrix:
120
+ """Source raster (edge coords) -> clip canvas (edge coords) for one frame.
121
+
122
+ Mirrors ``GeometryPlan._conform_filters`` / ``resolve_camera_placement``:
123
+ * Crop and Pan replace conform by the calibrated camera (``exact_scale``,
124
+ ``exact_origin``) computed from the full source and the reference window;
125
+ * Fit / Fill: ``scale = min/max(P/S)`` and ``x = (Pw - s*Ws)/2 + s*u``;
126
+ * None: 1:1 pixels, integer-centred like ``crop``/``pad`` (truncation).
127
+ """
128
+
129
+ camera = snapshot.camera_placement
130
+ if camera is not None:
131
+ scale = camera.exact_scale
132
+ return affine_matrix(scale, scale, camera.exact_origin_x, camera.exact_origin_y)
133
+ source_width, source_height = frame.source_width, frame.source_height
134
+ project_width, project_height = frame.project_width, frame.project_height
135
+ if conform == "none":
136
+ return affine_matrix(
137
+ 1.0, 1.0,
138
+ float(_truncated_half(project_width - source_width)),
139
+ float(_truncated_half(project_height - source_height)),
140
+ )
141
+ if conform == "fit":
142
+ scale = min(project_width / source_width, project_height / source_height)
143
+ elif conform == "fill":
144
+ scale = max(project_width / source_width, project_height / source_height)
145
+ else:
146
+ raise ValueError(f"unknown conform {conform!r}")
147
+ return affine_matrix(
148
+ scale, scale,
149
+ (project_width - scale * source_width) / 2.0,
150
+ (project_height - scale * source_height) / 2.0,
151
+ )
152
+
153
+
154
+ def _truncated_half(delta: int) -> int:
155
+ """FFmpeg ``crop``/``pad`` evaluate ``(a-b)/2`` as a double and store it in an int."""
156
+
157
+ return int(delta / 2.0)
158
+
159
+
160
+ def composed_matrix(snapshot: GeometrySnapshot, frame: FrameGeometry) -> Matrix:
161
+ """Clip canvas -> clip canvas: corner pin then anchor transform (``composed_quad``)."""
162
+
163
+ return homography_from_points(
164
+ project_rect_quad(frame.project_width, frame.project_height), snapshot.composed_quad
165
+ )
166
+
167
+
168
+ def layer_homography(
169
+ snapshot: GeometrySnapshot,
170
+ *,
171
+ frame: FrameGeometry,
172
+ conform: str,
173
+ canvas_to_project: Matrix,
174
+ ) -> Matrix:
175
+ """Source edge coords -> project edge coords: ``canvas_to_project @ composed @ conform``."""
176
+
177
+ return canvas_to_project @ composed_matrix(snapshot, frame) @ conform_matrix(snapshot, frame, conform)
178
+
179
+
180
+ def source_alpha_window(
181
+ snapshot: GeometrySnapshot,
182
+ *,
183
+ frame: FrameGeometry,
184
+ conform: str,
185
+ crop_mode: Optional[str],
186
+ ) -> Optional[tuple[int, int, int, int]]:
187
+ """Return ``(x0, x1, y0, y1)`` pixel-index bounds (half-open) that stay opaque, or None.
188
+
189
+ * Trim: the integer extraction rectangle (``crop`` then transparent ``pad``).
190
+ * Crop / Pan: the reference's ``geq`` window keeps integer sample indices
191
+ with ``left <= X <= W - right`` (inclusive, fractional edges).
192
+ * Conform None: ``crop`` to the project size, centred by truncation.
193
+ Windows intersect; a source without any window returns None.
194
+ """
195
+
196
+ source_width, source_height = frame.source_width, frame.source_height
197
+ x0, x1, y0, y1 = 0, source_width, 0, source_height
198
+ if crop_mode == "trim" and snapshot.crop_rect is not None:
199
+ rect = snapshot.crop_rect
200
+ x0, x1 = max(x0, rect.x), min(x1, rect.x + rect.width)
201
+ y0, y1 = max(y0, rect.y), min(y1, rect.y + rect.height)
202
+ elif crop_mode in {"crop", "pan"} and snapshot.source_rect is not None:
203
+ rect = snapshot.source_rect
204
+ x0 = max(x0, math.ceil(rect.x - 1e-9))
205
+ x1 = min(x1, math.floor(rect.x + rect.width + 1e-9) + 1)
206
+ y0 = max(y0, math.ceil(rect.y - 1e-9))
207
+ y1 = min(y1, math.floor(rect.y + rect.height + 1e-9) + 1)
208
+ if conform == "none" and snapshot.camera_placement is None:
209
+ if source_width > frame.project_width:
210
+ left = _truncated_half(source_width - frame.project_width)
211
+ x0, x1 = max(x0, left), min(x1, left + frame.project_width)
212
+ if source_height > frame.project_height:
213
+ top = _truncated_half(source_height - frame.project_height)
214
+ y0, y1 = max(y0, top), min(y1, top + frame.project_height)
215
+ if (x0, x1, y0, y1) == (0, source_width, 0, source_height):
216
+ return None
217
+ return (max(0, x0), max(0, x1), max(0, y0), max(0, y1))
218
+
219
+
220
+ def uses_exact_aspect_minification(
221
+ snapshot: GeometrySnapshot,
222
+ *,
223
+ frame: FrameGeometry,
224
+ conform: str,
225
+ crop_mode: Optional[str],
226
+ source_is_opaque: bool,
227
+ ) -> bool:
228
+ """Whether Final Cut uses its calibrated whole-raster resize path.
229
+
230
+ The retained Final Cut witnesses use an encoded-domain, support-scaled
231
+ bilinear filter when an opaque decoder raster is reduced to an exactly
232
+ matching Fit / Fill canvas. Crop and Pan create a camera, Trim creates an
233
+ alpha edge, and mismatched aspect ratios create transparent or clipped
234
+ edges, so all of those stay on the general homography sampler.
235
+
236
+ Main callers:
237
+ - ``renderer._FrameComposer.placed`` before linear-light conversion.
238
+
239
+ Why this exists:
240
+ A homography sampler reads only a 2x2 source neighbourhood. That aliases a
241
+ 4K or 1080p raster reduced to a small proxy, while Final Cut's resize filter
242
+ widens its footprint as the reduction ratio grows.
243
+ """
244
+
245
+ source_width, source_height = frame.source_width, frame.source_height
246
+ output_width, output_height = frame.project_width, frame.project_height
247
+ return (
248
+ source_is_opaque
249
+ and crop_mode is None
250
+ and snapshot.camera_placement is None
251
+ and conform in {"fit", "fill"}
252
+ and output_width >= 3
253
+ and output_height >= 3
254
+ and source_width > output_width
255
+ and source_height > output_height
256
+ and source_width * output_height == source_height * output_width
257
+ )
258
+
259
+
260
+ _SWS_H_ALIGN = 4
261
+ _SWS_V_ALIGN = 2
262
+ _SWS_ONE_H = 1 << 14
263
+ _SWS_ONE_V = 1 << 12
264
+
265
+ # Legacy swscale's default RGB matrix when an RGBA link has no colourspace tag.
266
+ # These are ``fill_rgb2yuv_table`` and ``ff_yuv2rgb_c_init_tables`` for
267
+ # SWS_CS_DEFAULT (BT.601), limited range, in FFmpeg n8.0.1.
268
+ _RGB2YUV_601 = (8414, 16519, 3208, -4865, -9528, 14392, 14392, -12061, -2332)
269
+ _YUV2RGB_601 = (8192, 9539, 13075, -6660, -3209, 16525)
270
+
271
+
272
+ @lru_cache(maxsize=256)
273
+ def _swscale_bilinear_filter(
274
+ source_length: int,
275
+ output_length: int,
276
+ *,
277
+ alignment: int,
278
+ one: int,
279
+ ) -> tuple[tuple[int, ...], tuple[tuple[int, ...], ...]]:
280
+ """Port legacy swscale ``initFilter`` for one bilinear minification axis.
281
+
282
+ This includes its 16.16 output-centre positions, support-scaled triangle,
283
+ near-zero tap reduction, SIMD-width padding, border folding, 14/12-bit
284
+ coefficient quantisation, and residual-error diffusion. The returned rows
285
+ each sum to ``one``.
286
+
287
+ An identity axis (``output_length == source_length``, i.e. no scaling on
288
+ that axis) is a special case: swscale performs no resampling there, so we
289
+ short-circuit to a copy filter instead of running the minification maths.
290
+
291
+ Main callers:
292
+ - ``resize_exact_aspect_opaque`` for the horizontal and vertical passes.
293
+
294
+ Why this exists:
295
+ PyTorch's antialiased bilinear kernel has different positions and weights.
296
+ On sharp content that difference is tens of 8-bit codes, so row 23 requires
297
+ swscale's actual legacy coefficients rather than a generic antialias flag.
298
+ """
299
+
300
+ # Identity axis: an unscaled axis reaches this builder on exact-2x opaque
301
+ # downscales, where the packed-RGBA path derives a half-width chroma plane
302
+ # (e.g. source 1920 -> chroma 960) whose length already equals the output
303
+ # width (960). swscale does no filtering on an unscaled axis, so the correct
304
+ # behaviour is a straight copy: output sample ``i`` selects source sample
305
+ # ``i`` with the full weight ``one``. Feeding this single-tap-per-row filter
306
+ # through the shared horizontal/vertical gather reproduces the copy exactly
307
+ # while staying in the same fixed-point domain a real minified row uses --
308
+ # every row still sums to ``one``, so the downstream >>13 / >>10 rescale is
309
+ # identical. We must NOT relax the guard below to ``<=`` and run the general
310
+ # maths for this case: at output == source it produces a degenerate,
311
+ # non-identity filter, and at output < 3 the tap geometry is undefined.
312
+ if output_length == source_length:
313
+ positions = tuple(range(output_length))
314
+ rows = tuple((one,) for _ in range(output_length))
315
+ return positions, rows
316
+
317
+ if not (3 <= output_length < source_length):
318
+ raise ValueError(f"legacy bilinear minification requires 3 <= output < source, got {source_length}->{output_length}")
319
+ increment = ((source_length << 16) + (output_length >> 1)) // output_length
320
+ size = 1 + (2 * source_length + output_length - 1) // output_length
321
+ size = max(1, min(size, source_length - 2))
322
+ fone = 1 << (54 - min((source_length // output_length).bit_length() - 1, 8))
323
+ position = ((128 * increment) >> 7) - ((128 * 0x10000) >> 7)
324
+ positions: list[int] = []
325
+ rows: list[list[int]] = []
326
+
327
+ # The bilinear kernel: a support-scaled triangle (``distance`` shrinks by the
328
+ # reduction ratio) quantised to ``fone``. This is the only part that differs
329
+ # from the bicubic port; ``finalize_swscale_filter`` runs the identical
330
+ # reduce / align / border-fix / normalise tail on the raw taps. Bilinear is
331
+ # strictly minifying, so it never needs the size-1 vertical align quirk
332
+ # (``apply_align_quirk=False``) and its historical reform only ever pads
333
+ # (``reform_truncates=False``).
334
+ for _ in range(output_length):
335
+ source_index = _c_div(position - (size - 2) * (1 << 16), 1 << 17)
336
+ positions.append(source_index)
337
+ row: list[int] = []
338
+ for _tap in range(size):
339
+ distance = abs(source_index * (1 << 17) - position) << 13
340
+ distance = distance * output_length // source_length
341
+ coefficient = max(0, (1 << 30) - distance) * (fone >> 30)
342
+ row.append(coefficient)
343
+ source_index += 1
344
+ rows.append(row)
345
+ position += 2 * increment
346
+
347
+ return finalize_swscale_filter(
348
+ positions,
349
+ rows,
350
+ size,
351
+ fone,
352
+ filter_align=alignment,
353
+ one=one,
354
+ apply_align_quirk=False,
355
+ reform_truncates=False,
356
+ src_len=source_length,
357
+ )
358
+
359
+
360
+ def _filter_tensors(
361
+ source_length: int,
362
+ output_length: int,
363
+ *,
364
+ alignment: int,
365
+ one: int,
366
+ device: torch.device,
367
+ ) -> tuple[torch.Tensor, torch.Tensor]:
368
+ positions, taps = _swscale_bilinear_filter(
369
+ source_length,
370
+ output_length,
371
+ alignment=alignment,
372
+ one=one,
373
+ )
374
+ return (
375
+ torch.tensor(positions, dtype=torch.long, device=device),
376
+ torch.tensor(taps, dtype=torch.int32, device=device),
377
+ )
378
+
379
+
380
+ def _floor_shift(values: torch.Tensor, bits: int) -> torch.Tensor:
381
+ return torch.div(values, 1 << bits, rounding_mode="floor")
382
+
383
+
384
+ def resize_exact_aspect_opaque(source_rgb: torch.Tensor, *, height: int, width: int) -> torch.Tensor:
385
+ """Minify opaque encoded RGB through FFmpeg n8.0.1 legacy swscale bilinear.
386
+
387
+ The port follows the packed-RGBA path, not an independent resize of R, G,
388
+ and B. Legacy swscale first converts RGB to 14-bit BT.601 limited-range
389
+ Y/U/V, derives half-width chroma when reducing by at least 2x, applies its
390
+ 14-bit horizontal and 12-bit vertical bilinear filters, then uses the
391
+ fixed-point full-chroma YUV-to-RGB writer. Input and output remain encoded
392
+ 8-bit code values, matching the calibrated plan-emitter stage order.
393
+
394
+ Main callers:
395
+ - ``renderer._FrameComposer.placed`` for the bounded exact-aspect case.
396
+ """
397
+
398
+ if source_rgb.ndim != 3 or source_rgb.shape[0] != 3:
399
+ raise ValueError(f"source_rgb must have shape [3,H,W], got {tuple(source_rgb.shape)}")
400
+ _, source_height, source_width = source_rgb.shape
401
+ if not (3 <= width < source_width and 3 <= height < source_height):
402
+ raise ValueError(
403
+ f"resize_exact_aspect_opaque only supports minification with output dimensions >= 3, "
404
+ f"got {source_width}x{source_height}->{width}x{height}"
405
+ )
406
+
407
+ code = torch.clamp(torch.round(source_rgb * 255.0), 0.0, 255.0).to(torch.int32)
408
+ red, green, blue = code[0], code[1], code[2]
409
+ ry, gy, by, ru, gu, bu, rv, gv, bv = _RGB2YUV_601
410
+ y14 = _floor_shift(ry * red + gy * green + by * blue + (32 << 14) + (1 << 8), 9)
411
+
412
+ chroma_half = source_width % 2 == 0 and width <= source_width // 2
413
+ if chroma_half:
414
+ red_pair = red[:, 0::2] + red[:, 1::2]
415
+ green_pair = green[:, 0::2] + green[:, 1::2]
416
+ blue_pair = blue[:, 0::2] + blue[:, 1::2]
417
+ u14 = _floor_shift(ru * red_pair + gu * green_pair + bu * blue_pair + (256 << 15) + (1 << 9), 10)
418
+ v14 = _floor_shift(rv * red_pair + gv * green_pair + bv * blue_pair + (256 << 15) + (1 << 9), 10)
419
+ chroma_width = source_width // 2
420
+ else:
421
+ u14 = _floor_shift(ru * red + gu * green + bu * blue + (256 << 14) + (1 << 8), 9)
422
+ v14 = _floor_shift(rv * red + gv * green + bv * blue + (256 << 14) + (1 << 8), 9)
423
+ chroma_width = source_width
424
+
425
+ h_positions, h_taps = _filter_tensors(
426
+ source_width, width, alignment=_SWS_H_ALIGN, one=_SWS_ONE_H, device=source_rgb.device
427
+ )
428
+ c_positions, c_taps = _filter_tensors(
429
+ chroma_width, width, alignment=_SWS_H_ALIGN, one=_SWS_ONE_H, device=source_rgb.device
430
+ )
431
+ v_positions, v_taps = _filter_tensors(
432
+ source_height, height, alignment=_SWS_V_ALIGN, one=_SWS_ONE_V, device=source_rgb.device
433
+ )
434
+
435
+ def horizontal(plane14: torch.Tensor, positions: torch.Tensor, taps: torch.Tensor) -> torch.Tensor:
436
+ value = torch.zeros((source_height, width), dtype=torch.int32, device=source_rgb.device)
437
+ for tap_index in range(taps.shape[1]):
438
+ indices = torch.clamp(positions + tap_index, max=plane14.shape[1] - 1)
439
+ value += torch.index_select(plane14, 1, indices) * taps[:, tap_index].view(1, -1)
440
+ return torch.clamp(_floor_shift(value, 13), max=32767)
441
+
442
+ horizontal_y = horizontal(y14, h_positions, h_taps)
443
+ horizontal_u = horizontal(u14, c_positions, c_taps)
444
+ horizontal_v = horizontal(v14, c_positions, c_taps)
445
+
446
+ def vertical(plane15: torch.Tensor, *, center_chroma: bool) -> torch.Tensor:
447
+ initial = (1 << 9) - (128 << 19) if center_chroma else (1 << 9)
448
+ value = torch.full((height, width), initial, dtype=torch.int32, device=source_rgb.device)
449
+ for tap_index in range(v_taps.shape[1]):
450
+ indices = torch.clamp(v_positions + tap_index, max=source_height - 1)
451
+ value += torch.index_select(plane15, 0, indices) * v_taps[:, tap_index].view(-1, 1)
452
+ return _floor_shift(value, 10)
453
+
454
+ y17 = vertical(horizontal_y, center_chroma=False)
455
+ u17 = vertical(horizontal_u, center_chroma=True)
456
+ v17 = vertical(horizontal_v, center_chroma=True)
457
+ y_offset, y_coeff, v2r, v2g, u2g, u2b = _YUV2RGB_601
458
+ luma = (y17 - y_offset) * y_coeff + (1 << 21)
459
+ limit = (1 << 30) - 1
460
+ result = torch.stack(
461
+ (
462
+ torch.clamp(luma + v17 * v2r, 0, limit),
463
+ torch.clamp(luma + v17 * v2g + u17 * u2g, 0, limit),
464
+ torch.clamp(luma + u17 * u2b, 0, limit),
465
+ )
466
+ )
467
+ return _floor_shift(result, 22).to(source_rgb.dtype) / 255.0
468
+
469
+
470
+ def apply_display_rotation(source: torch.Tensor, rotation_degrees: int) -> torch.Tensor:
471
+ """Apply container display rotation to ``[C,H,W]`` before timeline geometry.
472
+
473
+ FFmpeg's autorotate and Final Cut interpret a positive display-matrix angle
474
+ as a counter-clockwise image rotation. ``torch.rot90`` uses that same sign.
475
+
476
+ Main callers:
477
+ - ``renderer._FrameComposer.placed`` immediately after source decode.
478
+ """
479
+
480
+ normalized = rotation_degrees % 360
481
+ if normalized not in {0, 90, 180, 270}:
482
+ raise ValueError(f"display rotation must be a quarter turn, got {rotation_degrees}")
483
+ return source if normalized == 0 else torch.rot90(source, k=normalized // 90, dims=(-2, -1))
484
+
485
+
486
+ # --------------------------------------------------------------------------- torch
487
+
488
+
489
+ class GridCache:
490
+ """Homogeneous output pixel-centre grids per (height, width, device), plus per-key grids.
491
+
492
+ ``grid_for(key, matrix, ...)`` returns the normalized sampling grid for
493
+ ``matrix`` and reuses the previous one while the matrix is unchanged --
494
+ static layers pay the grid build once, animated layers once per frame.
495
+ """
496
+
497
+ def __init__(self) -> None:
498
+ self._centres: dict[tuple[int, int, str], torch.Tensor] = {}
499
+ self._grids: dict[str, tuple[bytes, torch.Tensor]] = {}
500
+
501
+ def centres(self, height: int, width: int, device: torch.device) -> torch.Tensor:
502
+ key = (height, width, str(device))
503
+ centres = self._centres.get(key)
504
+ if centres is None:
505
+ ys = torch.arange(height, dtype=torch.float32, device=device) + 0.5
506
+ xs = torch.arange(width, dtype=torch.float32, device=device) + 0.5
507
+ grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij")
508
+ centres = torch.stack((grid_x, grid_y, torch.ones_like(grid_x)), dim=-1).reshape(-1, 3)
509
+ self._centres[key] = centres
510
+ return centres
511
+
512
+ def grid_for(
513
+ self,
514
+ key: str,
515
+ matrix: Matrix,
516
+ *,
517
+ out_height: int,
518
+ out_width: int,
519
+ source_height: int,
520
+ source_width: int,
521
+ device: torch.device,
522
+ ) -> torch.Tensor:
523
+ signature = np.ascontiguousarray(matrix, dtype=np.float64).tobytes() + bytes(
524
+ f"{out_height}x{out_width}<{source_height}x{source_width}", "ascii"
525
+ )
526
+ cached = self._grids.get(key)
527
+ if cached is not None and cached[0] == signature:
528
+ return cached[1]
529
+ grid = sampling_grid(
530
+ matrix,
531
+ centres=self.centres(out_height, out_width, device),
532
+ out_height=out_height,
533
+ out_width=out_width,
534
+ source_height=source_height,
535
+ source_width=source_width,
536
+ )
537
+ self._grids[key] = (signature, grid)
538
+ return grid
539
+
540
+ def forget(self, key: str) -> None:
541
+ self._grids.pop(key, None)
542
+
543
+
544
+ def sampling_grid(
545
+ matrix: Matrix,
546
+ *,
547
+ centres: torch.Tensor,
548
+ out_height: int,
549
+ out_width: int,
550
+ source_height: int,
551
+ source_width: int,
552
+ ) -> torch.Tensor:
553
+ """Inverse-map output pixel centres through ``matrix`` into normalized source coords.
554
+
555
+ ``matrix`` maps source edge coords to output edge coords; the grid holds
556
+ the source position of every output pixel centre, normalized so that the
557
+ source raster spans ``[-1, 1]`` (``align_corners=False``). Solved in
558
+ float64 on the CPU for the 3x3 inverse, evaluated in float32 on device
559
+ (fp16 grids are ~1 px wrong; never use them).
560
+ """
561
+
562
+ inverse = np.linalg.inv(matrix)
563
+ inverse_t = torch.from_numpy(inverse.T.astype(np.float32)).to(centres.device)
564
+ mapped = centres @ inverse_t # [N, 3]
565
+ w = mapped[:, 2:3]
566
+ # A point behind the camera / at infinity (degenerate perspective) is sent
567
+ # far outside the source so zeros padding makes it transparent.
568
+ safe_w = torch.where(w.abs() < 1e-12, torch.full_like(w, 1e-12), w)
569
+ u = mapped[:, 0:1] / safe_w
570
+ v = mapped[:, 1:2] / safe_w
571
+ grid_x = u * (2.0 / source_width) - 1.0
572
+ grid_y = v * (2.0 / source_height) - 1.0
573
+ return torch.cat((grid_x, grid_y), dim=1).reshape(1, out_height, out_width, 2)
574
+
575
+
576
+ def warp(source_rgba: torch.Tensor, grid: torch.Tensor) -> torch.Tensor:
577
+ """Bilinear straight-RGBA warp with a transparent border; ``[4,h,w]`` -> ``[4,H,W]``."""
578
+
579
+ return F.grid_sample(
580
+ source_rgba.unsqueeze(0), grid, mode="bilinear", padding_mode="zeros", align_corners=False
581
+ ).squeeze(0)
582
+
583
+
584
+ def apply_alpha_window(alpha: torch.Tensor, window: Optional[tuple[int, int, int, int]]) -> torch.Tensor:
585
+ """Zero ``alpha`` (``[1,h,w]``) outside the half-open pixel-index window."""
586
+
587
+ if window is None:
588
+ return alpha
589
+ x0, x1, y0, y1 = window
590
+ masked = torch.zeros_like(alpha)
591
+ if x1 > x0 and y1 > y0:
592
+ masked[:, y0:y1, x0:x1] = alpha[:, y0:y1, x0:x1]
593
+ return masked
594
+
595
+
596
+ def premultiply(straight_rgba: torch.Tensor) -> torch.Tensor:
597
+ alpha = straight_rgba[3:4]
598
+ return torch.cat((straight_rgba[:3] * alpha, alpha), dim=0)