M5.8: wgpu compositor — frame plans (render crate), YUV/RGB layer sampling with footprint supersampling in WGSL, linear-light premultiplied compositing, upload cache; Program monitor composites on the GPU (FILMCRAFT_CPU_COMPOSITE=1 to disable)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 07:16:32 -04:00
parent 8210007d19
commit b7b7042b87
14 changed files with 1060 additions and 10 deletions

16
Cargo.lock generated
View File

@ -1351,6 +1351,21 @@ dependencies = [
"serde",
]
[[package]]
name = "filmcraft-gpu"
version = "0.1.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-media",
"filmcraft-render",
"filmcraft-time",
"log",
"pollster 0.4.0",
"wgpu",
]
[[package]]
name = "filmcraft-h264"
version = "0.1.0"
@ -1439,6 +1454,7 @@ dependencies = [
"filmcraft-engine",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-gpu",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",

View File

@ -25,6 +25,7 @@ filmcraft-codecs = { path = "crates/codecs" }
filmcraft-project = { path = "crates/project" }
filmcraft-edit = { path = "crates/edit" }
filmcraft-render = { path = "crates/render" }
filmcraft-gpu = { path = "crates/gpu" }
filmcraft-export = { path = "crates/export" }
filmcraft-engine = { path = "crates/engine" }
filmcraft-ui-egui = { path = "crates/ui-egui" }

View File

@ -59,6 +59,11 @@ fn main() -> eframe::Result {
}
let mut app = FilmcraftApp::new(session);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
if let Some(out) = audio::CpalOut::new() {
app.audio = Some(Box::new(out));
}

24
crates/gpu/Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "filmcraft-gpu"
description = "wgpu compositor: YUV/RGB layer sampling, linear-light premultiplied compositing in WGSL"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
filmcraft-color = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-geom = { workspace = true }
filmcraft-render = { workspace = true }
wgpu = { version = "30", default-features = false, features = ["wgsl"] }
log = { workspace = true }
[dev-dependencies]
pollster = "0.4"
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
filmcraft-media = { workspace = true }
filmcraft-time = { workspace = true }

View File

@ -0,0 +1,134 @@
// FilmCraft GPU compositor.
// Layers are drawn as transformed quads; the fragment shader samples the source (YUV planes or
// RGBA) with manual bilinear filtering (textureLoad, so any float format works) and N×N
// supersampling over the pixel footprint when minifying, converts to linear light, premultiplies
// and scales by opacity. Blending into the Rgba16Float accumulator is premultiplied "over".
struct U {
m0: vec4<f32>, // a b c d
m1: vec4<f32>, // e f out_w out_h
src: vec4<f32>, // src_w src_h chroma_w chroma_h
p0: vec4<f32>, // opacity, kind (0 rgba8 srgb straight, 1 rgba16f premul linear, 2 yuv), taps, transfer (0 srgb, 1 linear, 2 pq, 3 hlg)
p1: vec4<f32>, // y_off y_scale c_off c_scale (code units)
p2: vec4<f32>, // kr kb code_scale footprint
};
@group(0) @binding(0) var<uniform> u: U;
@group(0) @binding(1) var tex0: texture_2d<f32>;
@group(0) @binding(2) var tex1: texture_2d<f32>;
@group(0) @binding(3) var tex2: texture_2d<f32>;
struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) sp: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VOut {
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
let c = corners[vi];
let s = c * u.src.xy;
let o = vec2(u.m0.x * s.x + u.m0.z * s.y + u.m1.x, u.m0.y * s.x + u.m0.w * s.y + u.m1.y);
var out: VOut;
out.pos = vec4(o.x / u.m1.z * 2.0 - 1.0, 1.0 - o.y / u.m1.w * 2.0, 0.0, 1.0);
out.sp = s;
return out;
}
fn load4(t: texture_2d<f32>, p: vec2<f32>) -> vec4<f32> {
let d = vec2<i32>(textureDimensions(t));
let q = p - 0.5;
let i = vec2<i32>(floor(q));
let f = q - floor(q);
let a = textureLoad(t, clamp(i, vec2(0), d - 1), 0);
let b = textureLoad(t, clamp(i + vec2(1, 0), vec2(0), d - 1), 0);
let cc = textureLoad(t, clamp(i + vec2(0, 1), vec2(0), d - 1), 0);
let dd = textureLoad(t, clamp(i + vec2(1, 1), vec2(0), d - 1), 0);
return mix(mix(a, b, f.x), mix(cc, dd, f.x), f.y);
}
fn srgb_to_linear(v: vec3<f32>) -> vec3<f32> {
let lo = v / 12.92;
let hi = pow((max(v, vec3(0.0)) + 0.055) / 1.055, vec3(2.4));
return select(hi, lo, v <= vec3(0.04045));
}
fn pq_eotf(e: vec3<f32>) -> vec3<f32> {
let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875;
let p = pow(max(e, vec3(0.0)), vec3(1.0 / m2));
return pow(max(p - c1, vec3(0.0)) / (c2 - c3 * p), vec3(1.0 / m1)) * 100.0;
}
fn hlg_inv(e: vec3<f32>) -> vec3<f32> {
let a = 0.17883277; let b = 0.28466892; let c = 0.55991073;
let lo = e * e / 3.0;
let hi = (exp((e - c) / a) + b) / 12.0;
return select(hi, lo, e <= vec3(0.5));
}
fn to_linear(v: vec3<f32>) -> vec3<f32> {
let t = u32(u.p0.w);
if t == 1u { return v; }
if t == 2u { return pq_eotf(v); }
if t == 3u { return hlg_inv(v); }
return srgb_to_linear(clamp(v, vec3(0.0), vec3(1.0)));
}
// One linear premultiplied sample at source position p.
fn sample(p: vec2<f32>) -> vec4<f32> {
let kind = u32(u.p0.y);
if kind == 0u {
// rgba8 sRGB texture: loads are already linear, straight alpha
let c = load4(tex0, p);
return vec4(c.rgb * c.a, c.a);
}
if kind == 1u {
return load4(tex0, p);
}
let cs = u.p2.z;
let yc = load4(tex0, p).r * cs;
let cp = p * u.src.zw / u.src.xy;
let cb = load4(tex1, cp).r * cs;
let cr = load4(tex2, cp).r * cs;
let y = (yc - u.p1.x) / u.p1.y;
let b = (cb - u.p1.z) / u.p1.w;
let r = (cr - u.p1.z) / u.p1.w;
let kr = u.p2.x; let kb = u.p2.y; let kg = 1.0 - kr - kb;
let R = y + 2.0 * (1.0 - kr) * r;
let B = y + 2.0 * (1.0 - kb) * b;
let G = (y - kr * R - kb * B) / kg;
return vec4(to_linear(vec3(R, G, B)), 1.0);
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
let n = max(u32(u.p0.z), 1u);
let fp = u.p2.w;
var acc = vec4(0.0);
for (var j = 0u; j < n; j++) {
for (var i = 0u; i < n; i++) {
let off = (vec2(f32(i), f32(j)) + 0.5) / f32(n) - 0.5;
acc += sample(in.sp + off * fp);
}
}
return acc / f32(n * n) * u.p0.x;
}
// ---- final pass: accumulator (linear premul) over black → sRGB target
@group(0) @binding(0) var accum: texture_2d<f32>;
@vertex
fn vs_full(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
let x = f32((vi << 1u) & 2u) * 2.0 - 1.0;
let y = f32(vi & 2u) * 2.0 - 1.0;
return vec4(x, y, 0.0, 1.0);
}
@fragment
fn fs_full(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = clamp(textureLoad(accum, vec2<i32>(pos.xy), 0).rgb, vec3(0.0), vec3(1.0));
let lo = c * 12.92;
let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
return vec4(select(hi, lo, c <= vec3(0.0031308)), 1.0);
}

467
crates/gpu/src/lib.rs Normal file
View File

@ -0,0 +1,467 @@
//! The wgpu compositor.
//!
//! Executes a [`FramePlan`](filmcraft_render::plan::FramePlan) on the GPU: every layer's source is
//! uploaded as textures (Y/Cb/Cr planes stay YUV — conversion to linear RGB happens per pixel in
//! the shader), drawn as a transformed quad with premultiplied "over" blending into a Rgba16Float
//! accumulator, then resolved over black into an sRGB `Rgba8UnormSrgb` texture that the UI
//! registers as a native texture. Uploads are cached by pixel-buffer identity, so a paused frame or
//! a still costs nothing.
//!
//! The CPU plan executor (`filmcraft_render::plan::execute_cpu`) is the oracle; tests compare.
use std::collections::HashMap;
use std::sync::Arc;
use filmcraft_color::{Matrix, Range, Transfer};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_render::plan::{FramePlan, PlanLayer};
/// Output texture format: gamma-encoded RGBA8 (what egui expects of native textures); the resolve
/// shader applies the sRGB encoding.
pub const OUTPUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const ACCUM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
struct Uploaded {
views: [wgpu::TextureView; 3],
kind: u32,
code_scale: f32,
chroma: (u32, u32),
last_used: u64,
}
pub struct GpuCompositor {
device: wgpu::Device,
queue: wgpu::Queue,
layer_pipeline: wgpu::RenderPipeline,
final_pipeline: wgpu::RenderPipeline,
layer_bgl: wgpu::BindGroupLayout,
final_bgl: wgpu::BindGroupLayout,
accum: Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>,
output: Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>,
uploads: HashMap<(usize, u32, u32), Uploaded>,
clock: u64,
dummy: wgpu::TextureView,
/// Total bytes uploaded (stats).
pub uploaded_bytes: u64,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
/// f32 → IEEE half (round to nearest even), no dependency needed.
pub fn f32_to_f16(v: f32) -> u16 {
let x = v.to_bits();
let sign = ((x >> 16) & 0x8000) as u16;
let exp = ((x >> 23) & 0xff) as i32;
let mant = x & 0x7f_ffff;
if exp == 0xff {
return sign | 0x7c00 | if mant != 0 { 0x200 } else { 0 };
}
let e = exp - 127 + 15;
if e >= 0x1f {
return sign | 0x7c00;
}
if e <= 0 {
if e < -10 {
return sign;
}
let m = (mant | 0x80_0000) >> (1 - e);
let round = (m >> 12) & 1;
return sign | (((m >> 13) + round) as u16);
}
let m = mant >> 13;
let round_bits = mant & 0x1fff;
let mut h = (sign as u32) | ((e as u32) << 10) | m;
if round_bits > 0x1000 || (round_bits == 0x1000 && (m & 1) == 1) {
h += 1;
}
h as u16
}
impl GpuCompositor {
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("filmcraft-composite"),
source: wgpu::ShaderSource::Wgsl(include_str!("composite.wgsl").into()),
});
let layer_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("layer"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
tex_entry(1),
tex_entry(2),
tex_entry(3),
],
});
let final_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("final"),
entries: &[wgpu::BindGroupLayoutEntry { binding: 0, ..tex_entry(0) }],
});
let pl =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("layer"), bind_group_layouts: &[Some(&layer_bgl)], immediate_size: 0 });
let layer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("layer"),
layout: Some(&pl),
vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] },
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: ACCUM_FORMAT,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let fpl =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("final"), bind_group_layouts: &[Some(&final_bgl)], immediate_size: 0 });
let final_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("final"),
layout: Some(&fpl),
vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_full"), compilation_options: Default::default(), buffers: &[] },
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_full"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState { format: OUTPUT_FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
}),
multiview_mask: None,
cache: None,
});
let dummy_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dummy"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let dummy = dummy_tex.create_view(&Default::default());
Self {
device: device.clone(),
queue: queue.clone(),
layer_pipeline,
final_pipeline,
layer_bgl,
final_bgl,
accum: None,
output: None,
uploads: HashMap::new(),
clock: 0,
dummy,
uploaded_bytes: 0,
}
}
fn target(
device: &wgpu::Device,
slot: &mut Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>,
w: u32,
h: u32,
format: wgpu::TextureFormat,
extra: wgpu::TextureUsages,
) {
if slot.as_ref().is_some_and(|s| s.2 == (w, h)) {
return;
}
let t = device.create_texture(&wgpu::TextureDescriptor {
label: Some("filmcraft-target"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | extra,
view_formats: &[],
});
let v = t.create_view(&Default::default());
*slot = Some((t, v, (w, h)));
}
fn plane_texture(&mut self, w: u32, h: u32, format: wgpu::TextureFormat, bytes: &[u8], bpp: u32) -> wgpu::TextureView {
let t = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("filmcraft-plane"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &t, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytes,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * bpp), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.uploaded_bytes += bytes.len() as u64;
t.create_view(&Default::default())
}
/// Upload (or reuse) the textures of a frame.
fn upload(&mut self, f: &VideoFrame) -> (usize, u32, u32) {
let id = match &f.data {
PixelData::Rgba8(d) => Arc::as_ptr(d) as *const u8 as usize,
PixelData::RgbaF32(d) => Arc::as_ptr(d) as *const u8 as usize,
PixelData::Yuv8 { planes, .. } => Arc::as_ptr(&planes[0]) as *const u8 as usize,
PixelData::Yuv16 { planes, .. } => Arc::as_ptr(&planes[0]) as *const u8 as usize,
};
let key = (id, f.width, f.height);
self.clock += 1;
if let Some(u) = self.uploads.get_mut(&key) {
u.last_used = self.clock;
return key;
}
let (w, h) = (f.width, f.height);
let d0 = self.dummy.clone();
let dummy = || d0.clone();
let up = match &f.data {
PixelData::Rgba8(d) => {
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba8UnormSrgb, d, 4);
Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock }
}
PixelData::RgbaF32(d) => {
let half: Vec<u8> = d.iter().flat_map(|v| f32_to_f16(*v).to_le_bytes()).collect();
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba16Float, &half, 8);
Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock }
}
PixelData::Yuv8 { planes, chroma, .. } => {
let (sx, sy) = chroma.shifts();
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
let y = self.plane_texture(w, h, wgpu::TextureFormat::R8Unorm, &planes[0], 1);
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[1], 1);
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[2], 1);
Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock }
}
PixelData::Yuv16 { planes, chroma, bits, .. } => {
let (sx, sy) = chroma.shifts();
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
let scale = (1u32 << bits) as f32;
let conv = |p: &[u16]| -> Vec<u8> { p.iter().flat_map(|v| f32_to_f16(*v as f32 / scale).to_le_bytes()).collect() };
let (py, pu, pv) = (conv(&planes[0]), conv(&planes[1]), conv(&planes[2]));
let y = self.plane_texture(w, h, wgpu::TextureFormat::R16Float, &py, 2);
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &pu, 2);
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &pv, 2);
let _ = Chroma::C420;
Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock }
}
};
self.uploads.insert(key, up);
// keep the most recent uploads (enough for several stacked layers + playback lookahead)
if self.uploads.len() > 24 {
let mut v: Vec<(u64, (usize, u32, u32))> = self.uploads.iter().map(|(k, u)| (u.last_used, *k)).collect();
v.sort_unstable();
for (_, k) in v.into_iter().take(self.uploads.len() - 24) {
self.uploads.remove(&k);
}
}
key
}
fn uniforms(&self, l: &PlanLayer, key: (usize, u32, u32), out: (u32, u32)) -> [f32; 24] {
let up = &self.uploads[&key];
let f = &l.frame;
let m = &l.matrix;
let (kr, kb) = f.color.matrix.kr_kb();
let bits_scale = up.code_scale / 255.0; // code units relative to 8-bit
let (yo, ys, co, cs) = match (f.color.range, up.kind) {
(Range::Limited, 2) => (16.0 * bits_scale, 219.0 * bits_scale, 128.0 * bits_scale, 224.0 * bits_scale),
(Range::Full, 2) => (0.0, up.code_scale - 1.0, up.code_scale / 2.0, up.code_scale - 1.0),
_ => (0.0, 1.0, 0.0, 1.0),
};
let transfer = match f.color.transfer {
Transfer::Linear => 1.0,
Transfer::Pq => 2.0,
Transfer::Hlg => 3.0,
_ => 0.0,
};
// source pixels per output pixel → supersampling taps
let sx = (m.a * m.a + m.b * m.b).sqrt();
let sy = (m.c * m.c + m.d * m.d).sqrt();
let footprint = (1.0 / sx.min(sy).max(1e-6)) as f32;
let taps = if footprint > 1.25 { footprint.ceil().min(4.0) } else { 1.0 };
let _ = Matrix::Bt709;
[
m.a as f32,
m.b as f32,
m.c as f32,
m.d as f32,
m.e as f32,
m.f as f32,
out.0 as f32,
out.1 as f32,
f.width as f32,
f.height as f32,
up.chroma.0 as f32,
up.chroma.1 as f32,
l.opacity,
up.kind as f32,
taps,
transfer,
yo,
ys,
co,
cs,
kr,
kb,
up.code_scale,
footprint.max(1.0),
]
}
/// Composite a plan; returns the output view (sRGB, over black) and its size.
pub fn composite(&mut self, plan: &FramePlan) -> (&wgpu::TextureView, (u32, u32)) {
let owned;
let (w, h, layers): (u32, u32, &[PlanLayer]) = match plan {
FramePlan::Layers { width, height, layers } => (*width as u32, *height as u32, layers.as_slice()),
FramePlan::Image(img) => {
owned = [PlanLayer {
frame: Arc::new(VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px.clone())),
matrix: filmcraft_geom::Affine::IDENTITY,
opacity: 1.0,
}];
(img.w as u32, img.h as u32, &owned[..])
}
};
let (w, h) = (w.max(1), h.max(1));
Self::target(&self.device, &mut self.accum, w, h, ACCUM_FORMAT, wgpu::TextureUsages::empty());
Self::target(&self.device, &mut self.output, w, h, OUTPUT_FORMAT, wgpu::TextureUsages::COPY_SRC);
let keys: Vec<(usize, u32, u32)> = layers.iter().map(|l| self.upload(&l.frame)).collect();
let mut bind_groups = Vec::with_capacity(layers.len());
for (l, k) in layers.iter().zip(&keys) {
let u = self.uniforms(l, *k, (w, h));
let bytes: Vec<u8> = u.iter().flat_map(|v| v.to_le_bytes()).collect();
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("layer-u"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue.write_buffer(&buf, 0, &bytes);
let up = &self.uploads[k];
let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("layer"),
layout: &self.layer_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&up.views[0]) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&up.views[1]) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&up.views[2]) },
],
});
bind_groups.push(bg);
}
let accum_view = &self.accum.as_ref().expect("accum").1;
let out_view = &self.output.as_ref().expect("output").1;
let final_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("final"),
layout: &self.final_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(accum_view) }],
});
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filmcraft-composite") });
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("layers"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: accum_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.layer_pipeline);
for bg in &bind_groups {
pass.set_bind_group(0, bg, &[]);
pass.draw(0..6, 0..1);
}
}
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("resolve"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: out_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.final_pipeline);
pass.set_bind_group(0, &final_bg, &[]);
pass.draw(0..3, 0..1);
}
self.queue.submit([enc.finish()]);
(&self.output.as_ref().expect("output").1, (w, h))
}
/// Read the output back as RGBA8 (tests / screenshots / thumbnails).
pub fn read_output(&self) -> Option<(u32, u32, Vec<u8>)> {
let (tex, _, (w, h)) = self.output.as_ref()?;
let row = (w * 4).div_ceil(256) * 256;
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (row * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = self.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(*h) } },
wgpu::Extent3d { width: *w, height: *h, depth_or_array_layers: 1 },
);
self.queue.submit([enc.finish()]);
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().ok()?.ok()?;
let data = slice.get_mapped_range().ok()?;
let mut out = Vec::with_capacity((w * h * 4) as usize);
for y in 0..*h {
out.extend_from_slice(&data[(y * row) as usize..(y * row + w * 4) as usize]);
}
drop(data);
buf.unmap();
Some((*w, *h, out))
}
}
#[cfg(test)]
mod tests;

79
crates/gpu/src/tests.rs Normal file
View File

@ -0,0 +1,79 @@
use super::*;
use filmcraft_geom::{Affine, Vec2};
use filmcraft_render::plan::execute_cpu;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
}
fn yuv_frame(w: u32, h: u32) -> Arc<VideoFrame> {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let y: Vec<u8> = (0..w * h).map(|i| (16 + ((i % w) * 219 / w)) as u8).collect();
let u: Vec<u8> = (0..cw * ch).map(|i| (64 + (i / cw) * 128 / ch) as u8).collect();
let v: Vec<u8> = (0..cw * ch).map(|i| (200 - (i % cw) * 100 / cw) as u8).collect();
Arc::new(VideoFrame {
width: w,
height: h,
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma: Chroma::C420, alpha: None },
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
pts: Default::default(),
})
}
#[test]
fn gpu_matches_cpu_plan() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let pixels = filmcraft_media::generators::render(
&filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::OceanSunset),
320,
180,
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let (w, h) = (320usize, 180usize);
let plan = FramePlan::Layers {
width: w,
height: h,
layers: vec![
PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0 },
PlanLayer { frame: rgba, matrix: Affine::motion(Vec2::new(200.0, 100.0), Vec2::new(0.4, 0.4), 12.0, Vec2::new(160.0, 90.0)), opacity: 0.7 },
],
};
let cpu = execute_cpu(&plan).over_black_rgba8();
c.composite(&plan);
let (gw, gh, gpu) = c.read_output().expect("readback");
assert_eq!((gw as usize, gh as usize), (w, h));
// compare away from antialiased edges: mean abs error and 99th percentile
let mut diffs: Vec<u32> =
cpu.chunks(4).zip(gpu.chunks(4)).map(|(a, b)| (0..3).map(|k| (a[k] as i32 - b[k] as i32).unsigned_abs()).max().unwrap_or(0)).collect();
diffs.sort_unstable();
let p99 = diffs[diffs.len() * 99 / 100];
let mean = diffs.iter().sum::<u32>() as f64 / diffs.len() as f64;
assert!(p99 <= 6 && mean < 1.5, "p99 {p99}, mean {mean}");
// cache: compositing the same plan again uploads nothing
let before = c.uploaded_bytes;
c.composite(&plan);
assert_eq!(c.uploaded_bytes, before);
}
#[test]
fn half_float_conversion() {
for v in [0.0f32, 1.0, 0.5, 0.123, 65504.0, -2.0, 1e-5] {
let h = f32_to_f16(v);
// decode
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let e = ((h >> 10) & 0x1f) as i32;
let m = (h & 0x3ff) as f32;
let d = if e == 0 { s * m * 2f32.powi(-24) } else { s * (1.0 + m / 1024.0) * 2f32.powi(e - 15) };
assert!((d - v).abs() <= v.abs() * 1e-3 + 1e-6, "{v} → {d}");
}
}

View File

@ -11,6 +11,7 @@ pub mod audio;
pub mod blend;
pub mod effects;
pub mod image;
pub mod plan;
pub mod transitions;
use std::sync::Arc;
@ -130,7 +131,7 @@ fn with_opacity(mut img: Image, op: f32) -> Image {
img
}
fn opacity_blend(item: &TrackItem, mt: Tick) -> (f32, Blend) {
pub(crate) fn opacity_blend(item: &TrackItem, mt: Tick) -> (f32, Blend) {
match item.effect("opacity") {
Some(e) if e.enabled => {
let op = (e.f64_at("opacity", mt) / 100.0).clamp(0.0, 1.0) as f32;
@ -145,7 +146,7 @@ fn opacity_blend(item: &TrackItem, mt: Tick) -> (f32, Blend) {
}
/// Size of an item's source at full resolution.
fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> {
pub(crate) fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)),
@ -186,7 +187,7 @@ pub fn motion_matrix(seq: &Sequence, item: &TrackItem, src: (u32, u32), mt: Tick
/// Render one track item's layer at timeline time `t` into a canvas-sized image.
/// Returns (layer, opacity, blend).
fn item_layer(
pub(crate) fn item_layer(
project: &Project,
seq: &Sequence,
item: &TrackItem,

172
crates/render/src/plan.rs Normal file
View File

@ -0,0 +1,172 @@
//! Frame plans for the GPU compositor.
//!
//! [`plan_frame`] resolves what is visible at a time into a list of layers the GPU can draw
//! directly: a decoded source frame (YUV planes or RGBA), the matrix from source pixels to output
//! pixels, and an opacity. Anything the shaders don't cover yet — non-Normal blend modes,
//! standard effects, adjustment layers, nested sequences, non-dissolve transitions — is rendered
//! on the CPU for that layer (or the whole frame) and handed over as a pre-composited image, so
//! the GPU path is always exact with respect to the CPU reference.
use std::sync::Arc;
use filmcraft_frame::VideoFrame;
use filmcraft_geom::Affine;
use filmcraft_media::FrameRequest;
use filmcraft_project::{ItemId, ItemKind, Project, Sequence, TrackItem};
use filmcraft_time::Tick;
use crate::{Blend, RenderOptions, SourceProvider, motion_matrix, output_size};
/// One layer for the GPU, bottom to top.
#[derive(Clone)]
pub struct PlanLayer {
pub frame: Arc<VideoFrame>,
/// Maps frame pixels (0..w, 0..h) to output pixels.
pub matrix: Affine,
pub opacity: f32,
}
#[derive(Clone)]
pub enum FramePlan {
/// Draw these layers over black.
Layers { width: usize, height: usize, layers: Vec<PlanLayer> },
/// The CPU produced the final image (fallback).
Image(crate::Image),
}
fn cpu_frame(img: crate::Image) -> Arc<VideoFrame> {
Arc::new(VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px))
}
fn simple_transition(id: &str) -> bool {
matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve" | "morph_cut")
}
/// Whether a track item can be drawn by the GPU as-is (no standard effects, Normal blend).
fn gpu_simple(project: &Project, item: &TrackItem, mt: Tick) -> bool {
let is_media = project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Media(_) | ItemKind::Subclip { .. }));
let no_fx = !item.has_standard_effects();
let normal =
item.effect("opacity").is_none_or(|e| !e.enabled || e.param("blend").is_none_or(|p| matches!(p.value, filmcraft_project::ParamValue::Choice(0))));
let _ = mt;
is_media && no_fx && normal
}
/// Plan the frame at timeline `t`.
pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> FramePlan {
let Some(seq) = project.sequence(seq_id) else { return FramePlan::Image(crate::Image::new(1, 1)) };
let (w, h) = output_size(seq, opts.scale);
// Whole-frame fallback: adjustment layers or complex transitions anywhere at t.
for tr in &seq.video_tracks {
if !tr.enabled {
continue;
}
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
&& !simple_transition(&trn.effect.effect)
{
return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources));
}
if let Some(it) = tr.item_at(t)
&& (project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
|| crate::opacity_blend(it, it.source_time_at(t)).1 != Blend::Normal)
{
return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources));
}
}
let mut layers = Vec::new();
for tr in &seq.video_tracks {
if !tr.enabled {
continue;
}
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) {
let mut p = trn.progress(t) as f32;
if trn.reverse {
p = 1.0 - p;
}
let a = trn.from.and_then(|id| tr.item(id)).filter(|i| i.enabled);
let b = trn.to.and_then(|id| tr.item(id)).filter(|i| i.enabled);
match trn.effect.effect.as_str() {
"dip_to_black" | "dip_to_white" => {
let col = if trn.effect.effect == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] };
layers.push(PlanLayer {
frame: Arc::new(VideoFrame::rgba_f32(1, 1, col.to_vec())),
matrix: Affine::scale(w as f64, h as f64),
opacity: 1.0,
});
let (it, k) = if p < 0.5 { (a, 1.0 - p * 2.0) } else { (b, (p - 0.5) * 2.0) };
if let Some(it) = it {
push_item(project, seq, it, t, opts, sources, k, &mut layers);
}
}
_ => {
// cross dissolve: A at full, B over it at p (premultiplied over == linear mix when A is opaque)
if let Some(it) = a {
push_item(project, seq, it, t, opts, sources, 1.0 - if b.is_none() { p } else { 0.0 }, &mut layers);
}
if let Some(it) = b {
push_item(project, seq, it, t, opts, sources, p, &mut layers);
}
}
}
continue;
}
let Some(item) = tr.item_at(t) else { continue };
if !item.enabled {
continue;
}
push_item(project, seq, item, t, opts, sources, 1.0, &mut layers);
}
FramePlan::Layers { width: w, height: h, layers }
}
#[allow(clippy::too_many_arguments)]
fn push_item(
project: &Project,
seq: &Sequence,
item: &TrackItem,
t: Tick,
opts: RenderOptions,
sources: &dyn SourceProvider,
extra_opacity: f32,
out: &mut Vec<PlanLayer>,
) {
let mt = item.source_time_at(t);
let (op, bl) = crate::opacity_blend(item, mt);
if gpu_simple(project, item, mt) && bl == Blend::Normal {
let Some(src) = sources.source(item.item) else { return };
let Some(size) = crate::source_size(project, item.item) else { return };
let motion = motion_matrix(seq, item, size, mt);
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
let Ok(frame) = src.video_frame(FrameRequest { time: mt, scale: want }) else { return };
let px_scale = frame.width as f64 / size.0.max(1) as f64;
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale));
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity });
return;
}
// CPU-rendered layer (standard effects): drawn by the GPU as a pre-rendered canvas image.
let tc = filmcraft_time::format_time(t, seq.settings.frame_rate, seq.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48_000);
if let Some((img, op2, _)) = crate::item_layer(project, seq, item, t, opts, sources, &tc) {
out.push(PlanLayer { frame: cpu_frame(img), matrix: Affine::IDENTITY, opacity: op2 * extra_opacity });
}
}
/// Execute a plan on the CPU (reference for the GPU compositor).
pub fn execute_cpu(plan: &FramePlan) -> crate::Image {
match plan {
FramePlan::Image(img) => img.clone(),
FramePlan::Layers { width, height, layers } => {
let mut canvas = crate::Image::new(*width, *height);
for l in layers {
let src = crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() };
let placed = if l.matrix == Affine::scale(*width as f64, *height as f64) && src.w == 1 && src.h == 1 {
crate::Image::filled(*width, *height, src.get(0, 0))
} else {
src.transformed(*width, *height, &l.matrix)
};
crate::blend::composite(&mut canvas, &placed, l.opacity, Blend::Normal);
}
canvas
}
}
}

View File

@ -167,3 +167,29 @@ fn audio_mix_bars_tone() {
let expect = 10f32.powf(-26.0 / 20.0);
assert!((peak - expect).abs() < 0.01, "{peak} vs {expect}");
}
#[test]
fn plan_matches_reference_renderer() {
let (mut p, red, ocean, seq, map) = setup();
place(&mut p, seq, 0, ocean, 0, 48);
let top = place(&mut p, seq, 1, red, 10, 20);
{
let s = p.sequence_mut(seq).unwrap();
let (_, it) = s.find_item_mut(top).unwrap();
let m = it.effect_mut("motion").unwrap();
m.params.get_mut("scale").unwrap().value = ParamValue::Float(40.0);
m.params.get_mut("rotation").unwrap().value = ParamValue::Float(15.0);
it.effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(70.0);
}
let r = FrameRate::FPS_24;
for f in [0, 12, 20, 40] {
let opts = RenderOptions { scale: 0.5, ..Default::default() };
let reference = render_sequence(&p, seq, r.tick_of(f), opts, &map);
let plan = plan::plan_frame(&p, seq, r.tick_of(f), opts, &map);
assert!(matches!(plan, plan::FramePlan::Layers { .. }), "simple frame should be GPU-drawable");
let got = plan::execute_cpu(&plan);
assert_eq!((got.w, got.h), (reference.w, reference.h));
let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
assert!(max < 0.02, "frame {f}: max diff {max}");
}
}

View File

@ -19,6 +19,7 @@ filmcraft-project = { workspace = true }
filmcraft-edit = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-engine = { workspace = true }
filmcraft-gpu = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
serde = { workspace = true }

View File

@ -18,6 +18,8 @@ pub enum Target {
Sequence(ItemId),
/// A single project item at a media time (Source monitor, thumbnails).
Item(ItemId),
/// A GPU frame plan of a sequence (decoded layers + transforms), composited on the GPU.
SequencePlan(ItemId),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -48,6 +50,7 @@ struct Shared {
queue: Mutex<VecDeque<Job>>,
cv: Condvar,
done: Mutex<Cache>,
plans: Mutex<HashMap<FrameKey, (Arc<filmcraft_render::plan::FramePlan>, u64)>>,
in_flight: Mutex<Vec<FrameKey>>,
}
@ -92,6 +95,7 @@ impl FrameServer {
queue: Mutex::new(VecDeque::new()),
cv: Condvar::new(),
done: Mutex::new(Cache { map: HashMap::new(), bytes: 0, budget: 768 << 20, clock: 0 }),
plans: Mutex::new(HashMap::new()),
in_flight: Mutex::new(Vec::new()),
});
let repaint: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
@ -125,9 +129,24 @@ impl FrameServer {
})
}
pub fn get_plan(&self, k: &FrameKey) -> Option<Arc<filmcraft_render::plan::FramePlan>> {
self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).get(k).map(|(p, _)| p.clone())
}
/// Nearest cached plan at or before `frame`.
pub fn nearest_plan(&self, key: FrameKey, max_back: i64) -> Option<(FrameKey, Arc<filmcraft_render::plan::FramePlan>)> {
let g = self.shared.plans.lock().unwrap_or_else(|e| e.into_inner());
(0..=max_back).find_map(|d| {
let k = FrameKey { frame: key.frame - d, ..key };
g.get(&k).map(|(p, _)| (k, p.clone()))
})
}
/// Queue a job unless it is cached, queued or in flight.
pub fn request(&self, key: FrameKey, time: Tick, scale: f32, project: &Arc<Project>, prio: u32) {
if self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(&key) {
if self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).map.contains_key(&key)
|| self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&key)
{
return;
}
if self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).contains(&key) {
@ -168,8 +187,20 @@ impl FrameServer {
fn run_one_sync(&self) {
let job = { self.shared.queue.lock().unwrap_or_else(|e| e.into_inner()).pop_front() };
if let Some(job) = job {
let img = render_job(&job, &self.pool, &self.services);
self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, Arc::new(img));
if let Target::SequencePlan(seq) = job.key.target {
let provider = self.pool.provider(job.project.clone(), self.services.clone());
let plan = filmcraft_render::plan::plan_frame(
&job.project,
seq,
job.time,
filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() },
&provider,
);
self.shared.plans.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, (Arc::new(plan), 0));
} else {
let img = render_job(&job, &self.pool, &self.services);
self.shared.done.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, Arc::new(img));
}
}
}
}
@ -189,8 +220,29 @@ fn worker(sh: Arc<Shared>, pool: Arc<MediaPool>, services: Arc<dyn Services>, re
}
};
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).push(job.key);
let img = render_job(&job, &pool, &services);
sh.done.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, Arc::new(img));
if let Target::SequencePlan(seq) = job.key.target {
let provider = pool.provider(job.project.clone(), services.clone());
let plan = filmcraft_render::plan::plan_frame(
&job.project,
seq,
job.time,
filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() },
&provider,
);
let mut g = sh.plans.lock().unwrap_or_else(|e| e.into_inner());
let clock = g.values().map(|v| v.1).max().unwrap_or(0) + 1;
g.insert(job.key, (Arc::new(plan), clock));
if g.len() > 96 {
let mut v: Vec<(u64, FrameKey)> = g.iter().map(|(k, v)| (v.1, *k)).collect();
v.sort_unstable_by_key(|x| x.0);
for (_, k) in v.into_iter().take(g.len() - 96) {
g.remove(&k);
}
}
} else {
let img = render_job(&job, &pool, &services);
sh.done.lock().unwrap_or_else(|e| e.into_inner()).insert(job.key, Arc::new(img));
}
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).retain(|k| *k != job.key);
if let Some(ctx) = repaint.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
ctx.request_repaint();
@ -209,6 +261,13 @@ fn render_job(job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>) ->
&provider,
)),
Target::Item(item) => filmcraft_render::render_item(&job.project, item, job.time, job.scale, &provider),
Target::SequencePlan(seq) => Some(filmcraft_render::render_sequence(
&job.project,
seq,
job.time,
filmcraft_render::RenderOptions { scale: job.scale, ..Default::default() },
&provider,
)),
};
match img {
Some(img) => Rgba { w: img.w, h: img.h, px: img.over_black_rgba8() },

View File

@ -102,6 +102,52 @@ pub struct FilmcraftApp {
bindings: Vec<(egui::Modifiers, egui::Key, String)>,
pub toast: Option<(String, f64)>,
pub tl: panels::timeline::TlState,
/// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds.
pub gpu: Option<GpuState>,
}
pub struct GpuState {
pub render_state: eframe::egui_wgpu::RenderState,
pub compositor: filmcraft_gpu::GpuCompositor,
pub texture: Option<egui::TextureId>,
pub last_key: Option<FrameKey>,
pub size: (u32, u32),
/// Composite time of the last frame (ms).
pub last_ms: f32,
}
impl FilmcraftApp {
/// Enable the GPU compositor on the eframe wgpu device.
pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) {
let compositor = filmcraft_gpu::GpuCompositor::new(&rs.device, &rs.queue);
self.gpu = Some(GpuState { render_state: rs, compositor, texture: None, last_key: None, size: (0, 0), last_ms: 0.0 });
}
/// Composite a plan on the GPU and return the egui texture showing it.
pub fn gpu_present(&mut self, key: FrameKey, plan: &filmcraft_render::plan::FramePlan) -> Option<(egui::TextureId, (u32, u32))> {
let g = self.gpu.as_mut()?;
if g.last_key == Some(key)
&& let Some(t) = g.texture
{
return Some((t, g.size));
}
let t0 = std::time::Instant::now();
let (view, size) = g.compositor.composite(plan);
let view = view.clone();
g.last_ms = t0.elapsed().as_secs_f32() * 1000.0;
let mut renderer = g.render_state.renderer.write();
let id = match g.texture {
Some(id) => {
renderer.update_egui_texture_from_wgpu_texture(&g.render_state.device, &view, eframe::wgpu::FilterMode::Linear, id);
id
}
None => renderer.register_native_texture(&g.render_state.device, &view, eframe::wgpu::FilterMode::Linear),
};
g.texture = Some(id);
g.last_key = Some(key);
g.size = size;
Some((id, size))
}
}
impl FilmcraftApp {
@ -139,6 +185,7 @@ impl FilmcraftApp {
bindings: menus::bindings(),
toast: None,
tl: Default::default(),
gpu: None,
}
}

View File

@ -83,7 +83,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
.project
.item(match target {
Target::Item(i) => i,
Target::Sequence(s) => s,
Target::Sequence(s) | Target::SequencePlan(s) => s,
})
.is_some_and(|i| !matches!(i.kind, ItemKind::Sequence(_)))
{
@ -92,6 +92,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
app.session.revision
};
let size_key = (scale * 1000.0) as u32;
let use_gpu = which == Which::Program && app.gpu.is_some();
let target = match (use_gpu, target) {
(true, Target::Sequence(s)) => Target::SequencePlan(s),
(_, t) => t,
};
let key = FrameKey { target, frame, size: size_key, revision: rev };
let project = app.session.project.clone();
let playing = which == Which::Program && app.playback.playing;
@ -110,7 +115,20 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
app.frames.retain_queue(|k| k.target != target || (k.revision == rev && (k.frame - cur) * dir >= 0 && (k.frame - cur).abs() < 40));
}
let tex_name = format!("monitor-{prefix}");
let shown = if let Some(img) = app.frames.get(&key) {
let shown = if use_gpu {
let exact = app.frames.get_plan(&key).map(|p| (key, p));
if exact.is_some() {
if playing {
app.playback.shown += 1;
}
} else if playing && app.playback.last_frame_shown() != frame {
app.playback.dropped += 1;
}
match exact.or_else(|| app.frames.nearest_plan(key, 6)) {
Some((k, plan)) => app.gpu_present(k, &plan).map(|(id, _)| id),
None => app.gpu.as_ref().and_then(|g| g.texture),
}
} else if let Some(img) = app.frames.get(&key) {
if playing {
app.playback.shown += 1;
}