bladeworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- bladeworks-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)."""
|