Compare commits

..

No commits in common. "83609cc9dc8ffcc1f372ffc4a5e0eeb8e2769d2f" and "54d5764bd0b270b7dd0a0ab2f21c3130a9e0daa7" have entirely different histories.

27 changed files with 403 additions and 748 deletions

View File

@ -14,7 +14,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: ubuntu-24.04 - platform: ubuntu-22.04
target: '' target: ''
artifact-name: linux-x86_64 artifact-name: linux-x86_64
- platform: macos-latest - platform: macos-latest
@ -62,7 +62,7 @@ jobs:
# ── Linux dependencies ── # ── Linux dependencies ──
- name: Install dependencies (Linux) - name: Install dependencies (Linux)
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y \ sudo apt-get install -y \
@ -74,7 +74,7 @@ jobs:
libpulse-dev squashfs-tools dpkg rpm libpulse-dev squashfs-tools dpkg rpm
- name: Install cargo packaging tools (Linux) - name: Install cargo packaging tools (Linux)
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
uses: taiki-e/install-action@v2 uses: taiki-e/install-action@v2
with: with:
tool: cargo-deb,cargo-generate-rpm tool: cargo-deb,cargo-generate-rpm
@ -135,7 +135,7 @@ jobs:
rm -f lightningbeam-ui/lightningbeam-editor/assets/presets/README.md rm -f lightningbeam-ui/lightningbeam-editor/assets/presets/README.md
- name: Inject preset entries into RPM metadata (Linux) - name: Inject preset entries into RPM metadata (Linux)
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
cd lightningbeam-ui cd lightningbeam-ui
@ -190,7 +190,7 @@ jobs:
# Linux Packaging # Linux Packaging
# ══════════════════════════════════════════════ # ══════════════════════════════════════════════
- name: Build .deb package - name: Build .deb package
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
cd lightningbeam-ui cd lightningbeam-ui
@ -206,15 +206,14 @@ jobs:
rm -rf "$WORK" rm -rf "$WORK"
- name: Build .rpm package - name: Build .rpm package
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
cd lightningbeam-ui cd lightningbeam-ui
RPM_VERSION=$(grep '^version' lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/' | tr '-' '~') cargo generate-rpm -p lightningbeam-editor
cargo generate-rpm -p lightningbeam-editor --set-metadata="version = \"$RPM_VERSION\""
- name: Build AppImage - name: Build AppImage
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
cd lightningbeam-ui cd lightningbeam-ui
@ -260,7 +259,7 @@ jobs:
chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage" chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
- name: Upload .deb - name: Upload .deb
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: deb-package name: deb-package
@ -268,7 +267,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Upload .rpm - name: Upload .rpm
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: rpm-package name: rpm-package
@ -276,7 +275,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Upload AppImage - name: Upload AppImage
if: matrix.platform == 'ubuntu-24.04' if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: appimage name: appimage
@ -381,7 +380,7 @@ jobs:
release: release:
needs: build needs: build
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
permissions: permissions:
contents: write contents: write
steps: steps:

View File

@ -1,24 +1,3 @@
# 1.0.4-alpha:
Changes:
- Beats are now the canonical time representation (replacing seconds)
- Tempo can now be non-constant (variable BPM)
- All events now have time references in seconds, measures/beats, and frames
- Add piano roll note snapping
- Snap to beats in measures mode
- Add velocity and modulation editing
- Add pitch bend support
- Add automation inputs for audio graphs
- Add automatable volume and pan controls to default instruments
- Add count-in and metronome
- Add drawing tablet input support
- Set default timeline mode based on activity
- Tweaked automation lane appearance
- Double CPU rendering performance by switching to tiny-skia
Bugfixes:
- Fix MIDI track recording previews
- Fix timeline elements not updating on BPM changes
# 1.0.3-alpha: # 1.0.3-alpha:
Changes: Changes:
- Add gradient support to vector graphics - Add gradient support to vector graphics

View File

@ -1,31 +1,33 @@
#!/bin/bash #!/bin/bash
# Ensure the script stops on error
set -e set -e
# Check if a version argument was passed
if [ -z "$1" ]; then if [ -z "$1" ]; then
echo "Usage: ./create_release.sh <version>" echo "Usage: ./create-release.sh <version>"
exit 1 exit 1
fi fi
VERSION=$1 VERSION=$1
RELEASE_BRANCH="release" RELEASE_BRANCH="release"
MAIN_BRANCH=$(git rev-parse --abbrev-ref HEAD) MAIN_BRANCH="main"
CARGO_TOML="lightningbeam-ui/lightningbeam-editor/Cargo.toml" CONFIG_FILE="src-tauri/tauri.conf.json"
echo "Updating version to $VERSION in $CARGO_TOML..." echo "Updating version to $VERSION in $CONFIG_FILE..."
sed -i "0,/^version = .*/s/^version = .*/version = \"$VERSION\"/" "$CARGO_TOML" jq --arg version "$VERSION" '.version = $version' $CONFIG_FILE > tmp.json && mv tmp.json $CONFIG_FILE
echo "Committing to $MAIN_BRANCH..." echo "Committing to main..."
git add "$CARGO_TOML" Changelog.md git add $CONFIG_FILE
git diff --cached --quiet || git commit -m "Bump version to $VERSION" git commit -m "Bump version to $VERSION"
echo "Checking out the release branch..." echo "Checking out the release branch..."
git checkout $RELEASE_BRANCH git checkout $RELEASE_BRANCH
echo "Merging $MAIN_BRANCH into $RELEASE_BRANCH..." echo "Merging the main branch into $RELEASE_BRANCH..."
git merge $MAIN_BRANCH --no-ff -m "Release $VERSION" git merge $MAIN_BRANCH --no-ff -m "Release $VERSION"
echo "Pushing $RELEASE_BRANCH..." echo "Pushing changes to the release branch..."
git push origin $RELEASE_BRANCH git push origin $RELEASE_BRANCH
git checkout $MAIN_BRANCH git checkout $MAIN_BRANCH

View File

@ -75,7 +75,7 @@ impl EffectRegistry {
INVERT_ID, INVERT_ID,
"Invert", "Invert",
EffectCategory::Color, EffectCategory::Color,
format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_invert.wgsl")), include_str!("shaders/effect_invert.wgsl"),
vec![ vec![
EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0), EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0),
], ],
@ -88,7 +88,7 @@ impl EffectRegistry {
BRIGHTNESS_CONTRAST_ID, BRIGHTNESS_CONTRAST_ID,
"Brightness/Contrast", "Brightness/Contrast",
EffectCategory::Color, EffectCategory::Color,
format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_brightness_contrast.wgsl")), include_str!("shaders/effect_brightness_contrast.wgsl"),
vec![ vec![
EffectParameterDef::float_range("brightness", "Brightness", 0.0, -1.0, 1.0), EffectParameterDef::float_range("brightness", "Brightness", 0.0, -1.0, 1.0),
EffectParameterDef::float_range("contrast", "Contrast", 1.0, 0.0, 3.0), EffectParameterDef::float_range("contrast", "Contrast", 1.0, 0.0, 3.0),
@ -102,7 +102,7 @@ impl EffectRegistry {
HUE_SATURATION_ID, HUE_SATURATION_ID,
"Hue/Saturation", "Hue/Saturation",
EffectCategory::Color, EffectCategory::Color,
format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_hue_saturation.wgsl")), include_str!("shaders/effect_hue_saturation.wgsl"),
vec![ vec![
EffectParameterDef::angle("hue", "Hue Shift", 0.0), EffectParameterDef::angle("hue", "Hue Shift", 0.0),
EffectParameterDef::float_range("saturation", "Saturation", 1.0, 0.0, 3.0), EffectParameterDef::float_range("saturation", "Saturation", 1.0, 0.0, 3.0),

View File

@ -6,41 +6,6 @@
use super::HDR_FORMAT; use super::HDR_FORMAT;
/// Shared WGSL sRGB transfer functions — the single source of the sRGB OETF/EOTF
/// used by every gamma-aware shader. Prepend it to a shader's source (it defines
/// the functions before the body, so call order doesn't matter):
/// `srgb_to_linear_channel` / `linear_to_srgb_channel` (scalar) and
/// `srgb_to_linear` / `linear_to_srgb` (vec3). `linear_to_srgb_channel` clamps to
/// [0,1] (its outputs target 8-bit / SDR display surfaces).
pub const COLOR_WGSL: &str = r#"
fn srgb_to_linear_channel(c: f32) -> f32 {
return select(pow((c + 0.055) / 1.055, 2.4), c / 12.92, c <= 0.04045);
}
fn linear_to_srgb_channel(c: f32) -> f32 {
let x = clamp(c, 0.0, 1.0);
return select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308);
}
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return vec3<f32>(srgb_to_linear_channel(c.r), srgb_to_linear_channel(c.g), srgb_to_linear_channel(c.b));
}
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
return vec3<f32>(linear_to_srgb_channel(c.r), linear_to_srgb_channel(c.g), linear_to_srgb_channel(c.b));
}
"#;
/// sRGB → linear for one channel in `[0, 1]` (CPU twin of the WGSL
/// `srgb_to_linear_channel`). The single source of the EOTF for CPU code.
pub fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// linear → sRGB for one channel, clamped to `[0, 1]` (CPU twin of the WGSL
/// `linear_to_srgb_channel`). The single source of the OETF for CPU code.
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}
/// GPU pipeline for sRGB to linear color space conversion /// GPU pipeline for sRGB to linear color space conversion
/// ///
/// Converts Rgba8Srgb textures to Rgba16Float linear textures. /// Converts Rgba8Srgb textures to Rgba16Float linear textures.

View File

@ -14,7 +14,7 @@ pub mod yuv_converter;
// Re-export commonly used types // Re-export commonly used types
pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat};
pub use color_convert::{SrgbToLinearConverter, COLOR_WGSL, srgb_to_linear, linear_to_srgb}; pub use color_convert::SrgbToLinearConverter;
pub use compositor::{Compositor, CompositorLayer, BlendMode}; pub use compositor::{Compositor, CompositorLayer, BlendMode};
pub use effect_processor::{EffectProcessor, EffectUniforms}; pub use effect_processor::{EffectProcessor, EffectUniforms};
pub use yuv_converter::YuvConverter; pub use yuv_converter::YuvConverter;

View File

@ -31,23 +31,14 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
return out; return out;
} }
// The HDR pipeline feeds these shaders LINEAR light, but brightness/contrast
// (additive brightness, contrast pivoting around 0.5 perceptual mid-gray) are
// defined in gamma/display space. Convert to sRGB, adjust there, then convert
// back to linear so the controls behave like standard editors.
// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended
// COLOR_WGSL prelude.
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv); let src = textureSample(source_tex, source_sampler, in.uv);
let brightness = uniforms.params0.x; // -1 to 1 let brightness = uniforms.params0.x; // -1 to 1
let contrast = uniforms.params0.y; // 0 to 3 let contrast = uniforms.params0.y; // 0 to 3
let src_srgb = linear_to_srgb(src.rgb);
// Apply brightness (additive) // Apply brightness (additive)
var color = src_srgb + vec3<f32>(brightness); var color = src.rgb + vec3<f32>(brightness);
// Apply contrast (multiply around midpoint 0.5) // Apply contrast (multiply around midpoint 0.5)
color = (color - vec3<f32>(0.5)) * contrast + vec3<f32>(0.5); color = (color - vec3<f32>(0.5)) * contrast + vec3<f32>(0.5);
@ -55,6 +46,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Clamp to valid range // Clamp to valid range
color = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0)); color = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0));
let result_srgb = mix(src_srgb, color, uniforms.mix); let result = mix(src.rgb, color, uniforms.mix);
return vec4<f32>(srgb_to_linear(result_srgb), src.a); return vec4<f32>(result, src.a);
} }

View File

@ -84,12 +84,6 @@ fn hsl_to_rgb(hsl: vec3<f32>) -> vec3<f32> {
); );
} }
// The HDR pipeline feeds this shader LINEAR light, but the HSL model (and the
// lightness/saturation axes users expect) is defined on gamma-encoded sRGB.
// Convert to sRGB, run the HSL adjustment there, then convert back to linear.
// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended
// COLOR_WGSL prelude.
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv); let src = textureSample(source_tex, source_sampler, in.uv);
@ -97,10 +91,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let saturation = uniforms.params0.y; // Multiplier (1.0 = no change) let saturation = uniforms.params0.y; // Multiplier (1.0 = no change)
let lightness = uniforms.params0.z; // Additive (-1 to 1) let lightness = uniforms.params0.z; // Additive (-1 to 1)
let src_srgb = linear_to_srgb(src.rgb);
// Convert to HSL // Convert to HSL
var hsl = rgb_to_hsl(src_srgb); var hsl = rgb_to_hsl(src.rgb);
// Apply adjustments // Apply adjustments
hsl.x = fract(hsl.x + hue_shift); // Shift hue (wrapping) hsl.x = fract(hsl.x + hue_shift); // Shift hue (wrapping)
@ -110,6 +102,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Convert back to RGB // Convert back to RGB
let adjusted = hsl_to_rgb(hsl); let adjusted = hsl_to_rgb(hsl);
let result_srgb = mix(src_srgb, adjusted, uniforms.mix); let result = mix(src.rgb, adjusted, uniforms.mix);
return vec4<f32>(srgb_to_linear(result_srgb), src.a); return vec4<f32>(result, src.a);
} }

View File

@ -33,19 +33,13 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
return out; return out;
} }
// The HDR pipeline feeds these shaders LINEAR light, but "invert" is a
// perceptual operation defined in gamma/display space (Photoshop, GIMP, etc.).
// Convert to sRGB, invert there, then convert back to linear. The sRGB helpers
// (linear_to_srgb / srgb_to_linear) come from the prepended COLOR_WGSL prelude.
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv); let src = textureSample(source_tex, source_sampler, in.uv);
let amount = uniforms.params0.x; // params[0] let amount = uniforms.params0.x; // params[0]
let src_srgb = linear_to_srgb(src.rgb); let inverted = vec3<f32>(1.0) - src.rgb;
let inverted = vec3<f32>(1.0) - src_srgb; let result = mix(src.rgb, inverted, amount * uniforms.mix);
let result_srgb = mix(src_srgb, inverted, amount * uniforms.mix);
return vec4<f32>(srgb_to_linear(result_srgb), src.a); return vec4<f32>(result, src.a);
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.4-alpha" version = "1.0.3-alpha"
edition = "2021" edition = "2021"
description = "Multimedia editor for audio, video and 2D animation" description = "Multimedia editor for audio, video and 2D animation"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -11,36 +11,6 @@ use uuid::Uuid;
/// Size of effect thumbnails in pixels /// Size of effect thumbnails in pixels
pub const EFFECT_THUMBNAIL_SIZE: u32 = 64; pub const EFFECT_THUMBNAIL_SIZE: u32 = 64;
use lightningbeam_core::gpu::{srgb_to_linear, linear_to_srgb};
/// sRGB-u8 RGBA → linear-`f16` RGBA bytes (little-endian). Feeds the effect
/// shaders linear light at float precision, matching the live HDR pipeline (an
/// 8-bit linear intermediate would band in shadows). RGB go through the sRGB
/// EOTF; alpha is linear.
fn srgb_image_to_linear_f16(rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rgba.len() * 2);
for px in rgba.chunks_exact(4) {
for &c in &px[..3] {
out.extend_from_slice(&half::f16::from_f32(srgb_to_linear(c as f32 / 255.0)).to_le_bytes());
}
out.extend_from_slice(&half::f16::from_f32(px[3] as f32 / 255.0).to_le_bytes());
}
out
}
/// linear-`f16` RGBA bytes → sRGB-u8 RGBA. Inverse of [`srgb_image_to_linear_f16`].
fn linear_f16_to_srgb_image(f16_rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(f16_rgba.len() / 2);
for texel in f16_rgba.chunks_exact(8) {
let ch = |i: usize| half::f16::from_le_bytes([texel[i], texel[i + 1]]).to_f32();
out.push((linear_to_srgb(ch(0)) * 255.0 + 0.5) as u8);
out.push((linear_to_srgb(ch(2)) * 255.0 + 0.5) as u8);
out.push((linear_to_srgb(ch(4)) * 255.0 + 0.5) as u8);
out.push((ch(6).clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
}
out
}
/// Embedded still-life image for effect preview thumbnails /// Embedded still-life image for effect preview thumbnails
const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg"); const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg");
@ -69,18 +39,10 @@ impl EffectThumbnailGenerator {
/// Create a new effect thumbnail generator /// Create a new effect thumbnail generator
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
// Load and decode the source image // Load and decode the source image
// The effect shaders operate in LINEAR light (matching the live HDR let source_rgba = Self::load_source_image();
// pipeline, which feeds them a linear Rgba16Float texture). The preview
// image is sRGB-encoded, so linearize it before upload and re-encode the
// result after readback. This keeps thumbnails consistent with the live
// render for every effect, including the gamma-space perceptual ones.
// Linearize to f16 (float precision — an 8-bit linear intermediate would
// band in shadows, the reason the live canvas is Rgba16Float).
let source_f16 = srgb_image_to_linear_f16(&Self::load_source_image());
// Effect processor + textures use Rgba16Float linear, matching the live // Create effect processor (using Rgba8Unorm for thumbnail output)
// pipeline so thumbnails render identically to the on-canvas effect. let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba8Unorm);
let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba16Float);
// Create source texture // Create source texture
let source_texture = device.create_texture(&wgpu::TextureDescriptor { let source_texture = device.create_texture(&wgpu::TextureDescriptor {
@ -93,12 +55,12 @@ impl EffectThumbnailGenerator {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}); });
// Upload source image data (Rgba16Float = 8 bytes/texel). // Upload source image data
queue.write_texture( queue.write_texture(
wgpu::TexelCopyTextureInfo { wgpu::TexelCopyTextureInfo {
texture: &source_texture, texture: &source_texture,
@ -106,10 +68,10 @@ impl EffectThumbnailGenerator {
origin: wgpu::Origin3d::ZERO, origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All, aspect: wgpu::TextureAspect::All,
}, },
&source_f16, &source_rgba,
wgpu::TexelCopyBufferLayout { wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 8), bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 4),
rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), rows_per_image: Some(EFFECT_THUMBNAIL_SIZE),
}, },
wgpu::Extent3d { wgpu::Extent3d {
@ -132,15 +94,17 @@ impl EffectThumbnailGenerator {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[], view_formats: &[],
}); });
let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default()); let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default());
// Create readback buffer (Rgba16Float = 8 bytes/texel, rows 256-aligned). // Create readback buffer
let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; let _buffer_size = (EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as u64;
// Align to 256 bytes for wgpu requirements
let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256;
let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("effect_thumbnail_readback"), label: Some("effect_thumbnail_readback"),
size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64, size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64,
@ -284,8 +248,8 @@ impl EffectThumbnailGenerator {
return None; return None;
} }
// Copy result to readback buffer (Rgba16Float = 8 bytes/texel). // Copy result to readback buffer
let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256;
encoder.copy_texture_to_buffer( encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { wgpu::TexelCopyTextureInfo {
texture: &self.dest_texture, texture: &self.dest_texture,
@ -327,21 +291,20 @@ impl EffectThumbnailGenerator {
return None; return None;
} }
// De-stride the linear-f16 result (drop the 256-byte row padding). // Copy data from mapped buffer (handling row alignment)
let data = buffer_slice.get_mapped_range(); let data = buffer_slice.get_mapped_range();
let row_tight = (EFFECT_THUMBNAIL_SIZE * 8) as usize; let mut rgba = Vec::with_capacity((EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as usize);
let mut f16_rgba = Vec::with_capacity(row_tight * EFFECT_THUMBNAIL_SIZE as usize);
for row in 0..EFFECT_THUMBNAIL_SIZE { for row in 0..EFFECT_THUMBNAIL_SIZE {
let row_start = (row * aligned_bytes_per_row) as usize; let row_start = (row * aligned_bytes_per_row) as usize;
f16_rgba.extend_from_slice(&data[row_start..row_start + row_tight]); let row_end = row_start + (EFFECT_THUMBNAIL_SIZE * 4) as usize;
rgba.extend_from_slice(&data[row_start..row_end]);
} }
drop(data); drop(data);
self.readback_buffer.unmap(); self.readback_buffer.unmap();
// Result is linear f16 (the effect ran in linear light); re-encode to Some(rgba)
// sRGB-u8 for display, mirroring the live pipeline's linear→sRGB output.
Some(linear_f16_to_srgb_image(&f16_rgba))
} }
/// Get all effect IDs that have pending thumbnail requests /// Get all effect IDs that have pending thumbnail requests

View File

@ -113,10 +113,10 @@ struct ParallelExportState {
video_progress_rx: Receiver<ExportProgress>, video_progress_rx: Receiver<ExportProgress>,
/// Audio progress channel /// Audio progress channel
audio_progress_rx: Receiver<ExportProgress>, audio_progress_rx: Receiver<ExportProgress>,
/// Video encoder thread handle (taken when the mux thread is spawned). /// Video encoder thread handle
video_thread: Option<std::thread::JoinHandle<()>>, video_thread: std::thread::JoinHandle<()>,
/// Audio export thread handle (taken when the mux thread is spawned). /// Audio export thread handle
audio_thread: Option<std::thread::JoinHandle<()>>, audio_thread: std::thread::JoinHandle<()>,
/// Temporary video file path /// Temporary video file path
temp_video_path: PathBuf, temp_video_path: PathBuf,
/// Temporary audio file path /// Temporary audio file path
@ -127,9 +127,6 @@ struct ParallelExportState {
video_progress: Option<ExportProgress>, video_progress: Option<ExportProgress>,
/// Latest audio progress /// Latest audio progress
audio_progress: Option<ExportProgress>, audio_progress: Option<ExportProgress>,
/// Result channel for the background mux. `Some` once muxing has started; the
/// mux runs off the UI thread so the app stays responsive during finalization.
mux_rx: Option<Receiver<Result<(), String>>>,
} }
impl ExportOrchestrator { impl ExportOrchestrator {
@ -223,33 +220,47 @@ impl ExportOrchestrator {
parallel.audio_progress = Some(progress); parallel.audio_progress = Some(progress);
} }
// If a background mux is already running, poll it without blocking the UI. // Check if both are complete
if parallel.mux_rx.is_some() { let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. }));
match parallel.mux_rx.as_ref().unwrap().try_recv() { let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. }));
Ok(Ok(())) => {
if video_complete && audio_complete {
println!("🎬🎵 [PARALLEL] Both video and audio complete, starting mux");
// Take parallel state to extract file paths
let parallel_state = self.parallel_export.take().unwrap();
// Wait for threads to finish
parallel_state.video_thread.join().ok();
parallel_state.audio_thread.join().ok();
// Start muxing
match Self::mux_video_and_audio(
&parallel_state.temp_video_path,
&parallel_state.temp_audio_path,
&parallel_state.final_output_path,
) {
Ok(()) => {
println!("✅ [MUX] Muxing complete, cleaning up temp files"); println!("✅ [MUX] Muxing complete, cleaning up temp files");
let state = self.parallel_export.take().unwrap();
std::fs::remove_file(&state.temp_video_path).ok(); // Clean up temp files
std::fs::remove_file(&state.temp_audio_path).ok(); std::fs::remove_file(&parallel_state.temp_video_path).ok();
return Some(ExportProgress::Complete { output_path: state.final_output_path }); std::fs::remove_file(&parallel_state.temp_audio_path).ok();
return Some(ExportProgress::Complete {
output_path: parallel_state.final_output_path,
});
} }
Ok(Err(err)) => { Err(err) => {
println!("❌ [MUX] Muxing failed: {}", err); println!("❌ [MUX] Muxing failed: {}", err);
self.parallel_export = None; return Some(ExportProgress::Error {
return Some(ExportProgress::Error { message: format!("Muxing failed: {}", err) }); message: format!("Muxing failed: {}", err),
} });
Err(std::sync::mpsc::TryRecvError::Empty) => {
// Still muxing — keep the UI responsive and show finalizing state.
return Some(ExportProgress::Finalizing);
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.parallel_export = None;
return Some(ExportProgress::Error { message: "Mux thread terminated unexpectedly".to_string() });
} }
} }
} }
// Check for errors before completion. // Check for errors
if let Some(ExportProgress::Error { ref message }) = parallel.video_progress { if let Some(ExportProgress::Error { ref message }) = parallel.video_progress {
return Some(ExportProgress::Error { message: format!("Video: {}", message) }); return Some(ExportProgress::Error { message: format!("Video: {}", message) });
} }
@ -257,30 +268,6 @@ impl ExportOrchestrator {
return Some(ExportProgress::Error { message: format!("Audio: {}", message) }); return Some(ExportProgress::Error { message: format!("Audio: {}", message) });
} }
// Both streams done → spawn the mux on a background thread (the previous
// implementation muxed synchronously here on the UI thread, which froze the
// app for the whole re-mux pass after progress already hit 100%).
let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. }));
let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. }));
if video_complete && audio_complete {
println!("🎬🎵 [PARALLEL] Both video and audio complete, starting background mux");
let video_thread = parallel.video_thread.take();
let audio_thread = parallel.audio_thread.take();
let video_path = parallel.temp_video_path.clone();
let audio_path = parallel.temp_audio_path.clone();
let output_path = parallel.final_output_path.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
// The export threads have signalled Complete; join is near-instant.
if let Some(t) = video_thread { t.join().ok(); }
if let Some(t) = audio_thread { t.join().ok(); }
let result = Self::mux_video_and_audio(&video_path, &audio_path, &output_path);
tx.send(result).ok();
});
parallel.mux_rx = Some(rx);
return Some(ExportProgress::Finalizing);
}
// Return combined progress // Return combined progress
match (&parallel.video_progress, &parallel.audio_progress) { match (&parallel.video_progress, &parallel.audio_progress) {
(Some(ExportProgress::FrameRendered { frame, total }), _) => { (Some(ExportProgress::FrameRendered { frame, total }), _) => {
@ -992,14 +979,13 @@ impl ExportOrchestrator {
self.parallel_export = Some(ParallelExportState { self.parallel_export = Some(ParallelExportState {
video_progress_rx, video_progress_rx,
audio_progress_rx, audio_progress_rx,
video_thread: Some(video_thread), video_thread,
audio_thread: Some(audio_thread), audio_thread,
temp_video_path, temp_video_path,
temp_audio_path, temp_audio_path,
final_output_path: output_path, final_output_path: output_path,
video_progress: None, video_progress: None,
audio_progress: None, audio_progress: None,
mux_rx: None,
}); });
println!("🎬🎵 [PARALLEL EXPORT] Both threads spawned, ready for frames"); println!("🎬🎵 [PARALLEL EXPORT] Both threads spawned, ready for frames");

View File

@ -191,9 +191,7 @@ impl ExportGpuResources {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("linear_to_srgb_shader"), label: Some("linear_to_srgb_shader"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(LINEAR_TO_SRGB_SHADER.into()),
format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, LINEAR_TO_SRGB_SHADER).into(),
),
}); });
let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@ -312,24 +310,32 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
return out; return out;
} }
// linear_to_srgb / linear_to_srgb_channel are provided by the prepended // Linear to sRGB color space conversion (per channel)
// COLOR_WGSL prelude (see the create_shader_module call site). fn linear_to_srgb_channel(c: f32) -> f32 {
return select(
1.055 * pow(c, 1.0 / 2.4) - 0.055,
c * 12.92,
c <= 0.0031308
);
}
fn linear_to_srgb(color: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
linear_to_srgb_channel(color.r),
linear_to_srgb_channel(color.g),
linear_to_srgb_channel(color.b)
);
}
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv); let src = textureSample(source_tex, source_sampler, in.uv);
// The compositor accumulates PREMULTIPLIED linear color. Unpremultiply
// before the sRGB OETF (srgb(rgb*a) != srgb(rgb)*a) and emit STRAIGHT
// alpha, which is what PNG export / the readback path expect. For opaque
// pixels (a == 1, the normal video case) this is an exact identity.
let a = src.a;
let straight = select(src.rgb / a, vec3<f32>(0.0), a <= 0.0);
// Convert linear HDR to sRGB // Convert linear HDR to sRGB
let srgb = linear_to_srgb(straight); let srgb = linear_to_srgb(src.rgb);
return vec4<f32>(srgb, a); // Alpha stays unchanged
return vec4<f32>(srgb, src.a);
} }
"#; "#;
@ -516,27 +522,12 @@ pub fn setup_video_encoder(
encoder.set_bit_rate((bitrate_kbps * 1000) as usize); encoder.set_bit_rate((bitrate_kbps * 1000) as usize);
encoder.set_gop(framerate as u32); // 1 second GOP encoder.set_gop(framerate as u32); // 1 second GOP
// Tag the color metadata so players interpret the YUV correctly. Our
// RGB→YUV conversion uses the BT.709 matrix with FULL-range (0255) luma
// and no transfer applied to the already-sRGB-encoded RGB. Tagging this
// as full-range BT.709 (matrix/primaries/transfer) prevents the level/
// hue shift that occurs when a player assumes limited-range or BT.601.
// colorspace (matrix) and range have safe setters; primaries and trc are
// generic AVCodecContext options set via the open dictionary below.
encoder.set_colorspace(ffmpeg::color::Space::BT709);
encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range
println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)", println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)",
width, height, aligned_width, aligned_height); width, height, aligned_width, aligned_height);
// Open encoder with codec (like working MP3 export). color_primaries and // Open encoder with codec (like working MP3 export)
// color_trc have no typed setter on the encoder, so pass them as generic
// AVCodecContext options (BT.709) through the open dictionary.
let mut color_opts = ffmpeg::Dictionary::new();
color_opts.set("color_primaries", "bt709");
color_opts.set("color_trc", "bt709");
let encoder = encoder let encoder = encoder
.open_as_with(codec, color_opts) .open_as(codec)
.map_err(|e| format!("Failed to open video encoder: {}", e))?; .map_err(|e| format!("Failed to open video encoder: {}", e))?;
Ok((encoder, codec)) Ok((encoder, codec))
@ -982,18 +973,8 @@ pub fn render_frame_to_rgba_hdr(
// Set document time to the frame timestamp // Set document time to the frame timestamp
document.current_time = timestamp; document.current_time = timestamp;
// Scale the document to the export resolution. The core renderer bakes this // Use identity transform for export (document coordinates = pixel coordinates)
// base transform into every layer (vector scenes, raster and video layer let base_transform = Affine::IDENTITY;
// transforms), so the whole stage scales up/down to fill the output. When the
// export size matches the document this is the identity.
let base_transform = if document.width > 0.0 && document.height > 0.0 {
Affine::scale_non_uniform(
width as f64 / document.width,
height as f64 / document.height,
)
} else {
Affine::IDENTITY
};
// Render document for compositing (returns per-layer scenes) // Render document for compositing (returns per-layer scenes)
let composite_result = render_document_for_compositing( let composite_result = render_document_for_compositing(
@ -1199,18 +1180,8 @@ pub fn render_frame_to_gpu_rgba(
// Set document time to the frame timestamp // Set document time to the frame timestamp
document.current_time = timestamp; document.current_time = timestamp;
// Scale the document to the export resolution. The core renderer bakes this // Use identity transform for export (document coordinates = pixel coordinates)
// base transform into every layer (vector scenes, raster and video layer let base_transform = Affine::IDENTITY;
// transforms), so the whole stage scales up/down to fill the output. When the
// export size matches the document this is the identity.
let base_transform = if document.width > 0.0 && document.height > 0.0 {
Affine::scale_non_uniform(
width as f64 / document.width,
height as f64 / document.height,
)
} else {
Affine::IDENTITY
};
// Render document for compositing (returns per-layer scenes) // Render document for compositing (returns per-layer scenes)
let composite_result = render_document_for_compositing( let composite_result = render_document_for_compositing(

View File

@ -24,94 +24,24 @@ use lightningbeam_core::brush_engine::GpuDab;
// Colour-space helpers // Colour-space helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use lightningbeam_core::gpu::srgb_to_linear; /// Decode one sRGB-encoded byte to linear float [0, 1].
fn srgb_to_linear(c: f32) -> f32 {
// Lookup tables that keep the per-pixel `powf`/f16 conversions out of the canvas if c <= 0.04045 {
// upload/readback loops. Doing the sRGB transfer per pixel was ~110ms for an c / 12.92
// 800x600 readback in a debug build; precomputing it once turns each channel
// into a table index.
/// Upload encode: sRGB byte → linear f16 little-endian bytes (for RGB channels).
fn srgb_to_linear_f16_lut() -> &'static [[u8; 2]; 256] {
static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new();
LUT.get_or_init(|| {
let mut lut = [[0u8; 2]; 256];
for (i, out) in lut.iter_mut().enumerate() {
*out = half::f16::from_f32(srgb_to_linear(i as f32 / 255.0)).to_le_bytes();
}
lut
})
}
/// Upload encode: byte → linear f16 little-endian bytes (for the alpha channel,
/// which is not gamma-encoded).
fn linear_f16_lut() -> &'static [[u8; 2]; 256] {
static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new();
LUT.get_or_init(|| {
let mut lut = [[0u8; 2]; 256];
for (i, out) in lut.iter_mut().enumerate() {
*out = half::f16::from_f32(i as f32 / 255.0).to_le_bytes();
}
lut
})
}
// ---------------------------------------------------------------------------
// Incremental ping-pong sync
// ---------------------------------------------------------------------------
/// Tile size (px) for incremental canvas sync copies between the ping-pong
/// textures. The brush keeps both textures identical; each frame only the tiles
/// touched by that frame's dabs are copied, so this bounds both the wasted bytes
/// per touched tile and the number of copy regions.
const SYNC_TILE: u32 = 128;
/// Coalesced copy rectangles (x, y, w, h) covering the tiles touched by `dabs`,
/// clamped to the canvas. Adjacent tiles in a row are merged into one rectangle
/// to keep the number of `copy_texture_to_texture` calls small.
fn dirty_tile_rects(dabs: &[GpuDab], canvas_w: u32, canvas_h: u32) -> Vec<(u32, u32, u32, u32)> {
if canvas_w == 0 || canvas_h == 0 || dabs.is_empty() {
return Vec::new();
}
let tiles_x = canvas_w.div_ceil(SYNC_TILE);
let tiles_y = canvas_h.div_ceil(SYNC_TILE);
let mut mask = vec![false; (tiles_x * tiles_y) as usize];
for d in dabs {
let r = d.radius + 1.0;
// Dab pixel bbox clamped to the canvas, then mapped to tile indices.
let px0 = (d.x - r).floor().clamp(0.0, (canvas_w - 1) as f32) as u32;
let py0 = (d.y - r).floor().clamp(0.0, (canvas_h - 1) as f32) as u32;
let px1 = (d.x + r).ceil().clamp(0.0, (canvas_w - 1) as f32) as u32;
let py1 = (d.y + r).ceil().clamp(0.0, (canvas_h - 1) as f32) as u32;
for ty in (py0 / SYNC_TILE)..=(py1 / SYNC_TILE) {
for tx in (px0 / SYNC_TILE)..=(px1 / SYNC_TILE) {
mask[(ty * tiles_x + tx) as usize] = true;
}
}
}
// Merge horizontal runs of set tiles in each tile-row into one rectangle.
let mut rects = Vec::new();
for ty in 0..tiles_y {
let mut tx = 0;
while tx < tiles_x {
if mask[(ty * tiles_x + tx) as usize] {
let run_start = tx;
while tx < tiles_x && mask[(ty * tiles_x + tx) as usize] {
tx += 1;
}
let x = run_start * SYNC_TILE;
let y = ty * SYNC_TILE;
let w = (tx * SYNC_TILE).min(canvas_w) - x;
let h = ((ty + 1) * SYNC_TILE).min(canvas_h) - y;
rects.push((x, y, w, h));
} else { } else {
tx += 1; ((c + 0.055) / 1.055).powf(2.4)
} }
} }
}
rects /// Encode one linear float [0, 1] to an sRGB-encoded byte.
fn linear_to_srgb_byte(c: u8) -> u8 {
let f = c as f32 / 255.0;
let encoded = if f <= 0.0031308 {
f * 12.92
} else {
1.055 * f.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0 + 0.5) as u8
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -138,7 +68,7 @@ impl CanvasPair {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_SRC
@ -163,24 +93,18 @@ impl CanvasPair {
/// `pixels` is expected to be **sRGB-encoded premultiplied** (the format stored /// `pixels` is expected to be **sRGB-encoded premultiplied** (the format stored
/// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied /// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied
/// before being written to the canvas, which operates entirely in linear space. /// before being written to the canvas, which operates entirely in linear space.
/// The canvas is `Rgba16Float`, so linear values are stored at 16-bit float
/// precision — storing linear light in 8 bits would band badly in shadows.
pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) { pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) {
// Decode sRGB-premultiplied → linear premultiplied f16 for the GPU canvas. // Decode sRGB-premultiplied → linear premultiplied for the GPU canvas.
// LUT-driven so there is no per-pixel powf / float conversion.
let rgb_lut = srgb_to_linear_f16_lut();
let a_lut = linear_f16_lut();
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| { let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
let r = rgb_lut[p[0] as usize]; let r = (srgb_to_linear(p[0] as f32 / 255.0) * 255.0 + 0.5) as u8;
let g = rgb_lut[p[1] as usize]; let g = (srgb_to_linear(p[1] as f32 / 255.0) * 255.0 + 0.5) as u8;
let b = rgb_lut[p[2] as usize]; let b = (srgb_to_linear(p[2] as f32 / 255.0) * 255.0 + 0.5) as u8;
let a = a_lut[p[3] as usize]; [r, g, b, p[3]]
[r[0], r[1], g[0], g[1], b[0], b[1], a[0], a[1]]
}).collect(); }).collect();
let layout = wgpu::TexelCopyBufferLayout { let layout = wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(self.width * 8), // Rgba16Float = 8 bytes/texel bytes_per_row: Some(self.width * 4),
rows_per_image: Some(self.height), rows_per_image: Some(self.height),
}; };
let extent = wgpu::Extent3d { let extent = wgpu::Extent3d {
@ -280,7 +204,7 @@ impl RasterTransformPipeline {
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly, access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2,
}, },
count: None, count: None,
@ -460,7 +384,7 @@ impl WarpApplyPipeline {
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly, access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2,
}, },
count: None, count: None,
@ -672,7 +596,7 @@ impl LiquifyBrushPipeline {
// Gradient-fill pipeline // Gradient-fill pipeline
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// One gradient stop on the GPU side. Colors are sRGB straight-alpha [0..1]. /// One gradient stop on the GPU side. Colors are linear straight-alpha [0..1].
/// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`. /// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@ -687,18 +611,13 @@ pub struct GpuGradientStop {
impl GpuGradientStop { impl GpuGradientStop {
/// Construct from sRGB u8 bytes (as stored in `ShapeColor`). /// Construct from sRGB u8 bytes (as stored in `ShapeColor`).
/// /// RGB is converted to linear; alpha is kept linear (not gamma-encoded).
/// Stops are kept in sRGB (gamma) space so the shader interpolates between
/// them in gamma space — matching the CPU raster path (`Gradient::eval`) and
/// the vector/peniko path, and the gamma-space gradients users expect from
/// tools like Photoshop/Flash. The shader converts the interpolated color to
/// linear before compositing into the linear canvas.
pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self { pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self {
Self { Self {
position, position,
r: r as f32 / 255.0, r: srgb_to_linear(r as f32 / 255.0),
g: g as f32 / 255.0, g: srgb_to_linear(g as f32 / 255.0),
b: b as f32 / 255.0, b: srgb_to_linear(b as f32 / 255.0),
a: a as f32 / 255.0, a: a as f32 / 255.0,
_pad: [0.0; 3], _pad: [0.0; 3],
} }
@ -735,7 +654,7 @@ impl GradientFillPipeline {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient_fill_shader"), label: Some("gradient_fill_shader"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(
color_wgsl(include_str!("panes/shaders/gradient_fill.wgsl")).into(), include_str!("panes/shaders/gradient_fill.wgsl").into(),
), ),
}); });
@ -782,7 +701,7 @@ impl GradientFillPipeline {
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly, access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2,
}, },
count: None, count: None,
@ -861,18 +780,23 @@ impl GradientFillPipeline {
/// Compute pipeline: composites the scratch buffer C over the source A → output B. /// Compute pipeline: composites the scratch buffer C over the source A → output B.
/// ///
/// Binding layout (see `alpha_composite.wgsl`): /// Binding layout (see `alpha_composite.wgsl`):
/// 0 = tex_a (texture_2d<f32>, Rgba16Float, sampled, not filterable) /// 0 = tex_a (texture_2d<f32>, Rgba8Unorm, sampled, not filterable)
/// 1 = tex_c (texture_2d<f32>, Rgba16Float, sampled, not filterable) /// 1 = tex_c (texture_2d<f32>, Rgba8Unorm, sampled, not filterable)
/// 2 = tex_b (texture_storage_2d<rgba8unorm, write>) /// 2 = tex_b (texture_storage_2d<rgba8unorm, write>)
/// Prepend the shared WGSL sRGB color functions ([`COLOR_WGSL`]) to a shader struct AlphaCompositePipeline {
/// source so the OETF/EOTF live in exactly one place. pipeline: wgpu::ComputePipeline,
fn color_wgsl(shader_src: &str) -> String { bg_layout: wgpu::BindGroupLayout,
format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, shader_src)
} }
/// A compute `texture_2d<f32>` sampled binding (non-filterable). impl AlphaCompositePipeline {
fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { fn new(device: &wgpu::Device) -> Self {
wgpu::BindGroupLayoutEntry { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("alpha_composite_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/alpha_composite.wgsl").into(),
),
});
let sampled_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding, binding,
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture { ty: wgpu::BindingType::Texture {
@ -881,135 +805,41 @@ fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
multisampled: false, multisampled: false,
}, },
count: None, count: None,
} };
} let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("alpha_composite_bgl"),
/// A compute write-only storage-texture binding of the given format. entries: &[
fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry { sampled_entry(0), // tex_a
sampled_entry(1), // tex_c
wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry {
binding, binding: 2,
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly, access: wgpu::StorageTextureAccess::WriteOnly,
format, format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2,
}, },
count: None, count: None,
} },
} ],
/// Build a compute pipeline + matching bind-group layout from WGSL source and
/// layout entries (entry point `main`). Collapses the otherwise-identical
/// pipeline/layout construction boilerplate shared by the compute pipelines.
fn build_compute_pipeline(
device: &wgpu::Device,
label: &str,
shader_src: &str,
entries: &[wgpu::BindGroupLayoutEntry],
) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
});
let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
}); });
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label), label: Some("alpha_composite_layout"),
bind_group_layouts: &[&bg_layout], bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[], push_constant_ranges: &[],
}); });
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(label), label: Some("alpha_composite_pipeline"),
layout: Some(&layout), layout: Some(&layout),
module: &shader, module: &shader,
entry_point: Some("main"), entry_point: Some("main"),
compilation_options: Default::default(), compilation_options: Default::default(),
cache: None, cache: None,
}); });
(pipeline, bg_layout)
}
struct AlphaCompositePipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl AlphaCompositePipeline {
fn new(device: &wgpu::Device) -> Self {
let (pipeline, bg_layout) = build_compute_pipeline(
device,
"alpha_composite",
include_str!("panes/shaders/alpha_composite.wgsl"),
&[
sampled_tex_entry(0), // tex_a
sampled_tex_entry(1), // tex_c
storage_tex_entry(2, wgpu::TextureFormat::Rgba16Float), // tex_b (out)
],
);
Self { pipeline, bg_layout } Self { pipeline, bg_layout }
} }
} }
/// Compute pipeline that converts the linear-premultiplied `Rgba16Float` canvas
/// into an `Rgba8Unorm` sRGB-premultiplied texture for fast readback.
struct ReadbackSrgbPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl ReadbackSrgbPipeline {
fn new(device: &wgpu::Device) -> Self {
let (pipeline, bg_layout) = build_compute_pipeline(
device,
"canvas_readback_srgb",
&color_wgsl(include_str!("panes/shaders/canvas_readback_srgb.wgsl")),
&[
sampled_tex_entry(0), // src (linear)
storage_tex_entry(1, wgpu::TextureFormat::Rgba8Unorm), // dst (sRGB)
],
);
Self { pipeline, bg_layout }
}
}
/// Reusable scratch for `readback_canvas`: the Rgba8Unorm conversion target plus
/// its MAP_READ staging buffer, kept across calls and rebuilt only on size change.
struct ReadbackScratch {
width: u32,
height: u32,
view: wgpu::TextureView,
tex: wgpu::Texture,
staging: wgpu::Buffer,
/// 256-aligned bytes-per-row of the staging buffer (Rgba8 = 4 B/texel).
bytes_per_row_aligned: u32,
}
impl ReadbackScratch {
fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("canvas_readback_srgb"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
let bytes_per_row_aligned = ((width * 4 + 255) / 256) * 256;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("canvas_readback_buf"),
size: (bytes_per_row_aligned * height) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { width, height, view, tex, staging, bytes_per_row_aligned }
}
}
// GpuBrushEngine // GpuBrushEngine
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1030,20 +860,13 @@ pub struct GpuBrushEngine {
/// Lazily created on first unified-tool composite dispatch. /// Lazily created on first unified-tool composite dispatch.
composite_pipeline: Option<AlphaCompositePipeline>, composite_pipeline: Option<AlphaCompositePipeline>,
/// Lazily-created pipeline converting the canvas to sRGB for fast readback.
readback_srgb_pipeline: Option<ReadbackSrgbPipeline>,
/// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated
/// only when the canvas size changes, to avoid per-stroke GPU allocations.
readback_scratch: Option<ReadbackScratch>,
/// Canvas texture pairs keyed by keyframe UUID. /// Canvas texture pairs keyed by keyframe UUID.
pub canvases: HashMap<Uuid, CanvasPair>, pub canvases: HashMap<Uuid, CanvasPair>,
/// Displacement map buffers keyed by a caller-supplied UUID. /// Displacement map buffers keyed by a caller-supplied UUID.
pub displacement_bufs: HashMap<Uuid, DisplacementBuffer>, pub displacement_bufs: HashMap<Uuid, DisplacementBuffer>,
/// Persistent `Rgba16Float` textures for idle raster layers. /// Persistent `Rgba8Unorm` textures for idle raster layers.
/// ///
/// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded /// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded
/// once when `RasterKeyframe::texture_dirty` is set, then reused every frame. /// once when `RasterKeyframe::texture_dirty` is set, then reused every frame.
@ -1067,7 +890,7 @@ struct DabParams {
impl GpuBrushEngine { impl GpuBrushEngine {
/// Create the pipeline. Returns `Err` if the device lacks the required /// Create the pipeline. Returns `Err` if the device lacks the required
/// storage-texture capability for `Rgba16Float`. /// storage-texture capability for `Rgba8Unorm`.
pub fn new(device: &wgpu::Device) -> Self { pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("brush_dab_shader"), label: Some("brush_dab_shader"),
@ -1119,7 +942,7 @@ impl GpuBrushEngine {
visibility: wgpu::ShaderStages::COMPUTE, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly, access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float, format: wgpu::TextureFormat::Rgba8Unorm,
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2,
}, },
count: None, count: None,
@ -1155,8 +978,6 @@ impl GpuBrushEngine {
liquify_brush_pipeline: None, liquify_brush_pipeline: None,
gradient_fill_pipeline: None, gradient_fill_pipeline: None,
composite_pipeline: None, composite_pipeline: None,
readback_srgb_pipeline: None,
readback_scratch: None,
canvases: HashMap::new(), canvases: HashMap::new(),
displacement_bufs: HashMap::new(), displacement_bufs: HashMap::new(),
raster_layer_cache: HashMap::new(), raster_layer_cache: HashMap::new(),
@ -1201,14 +1022,12 @@ impl GpuBrushEngine {
) { ) {
if dabs.is_empty() { return; } if dabs.is_empty() { return; }
// render_dabs_batch keeps both ping-pong textures identical after every // Smudge dabs must be applied one at a time so each dab reads the canvas
// call, so smudge — whose each dab must read the previous dab's output — // state written by the previous dab. Use bbox-only copies (union of current
// simply dispatches one dab at a time: each per-dab call leaves src fully // and previous dab) to avoid an expensive full-canvas copy per dab.
// authoritative for the next. Paint/erase dabs are independent within a
// frame (the shader accumulates them over the shared source), so they
// dispatch together as one batch.
let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false); let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false);
if is_smudge { if is_smudge {
let mut prev_bbox: Option<(i32, i32, i32, i32)> = None;
for dab in dabs { for dab in dabs {
let r = dab.radius + 1.0; let r = dab.radius + 1.0;
let cur_bbox = ( let cur_bbox = (
@ -1217,24 +1036,31 @@ impl GpuBrushEngine {
(dab.x + r).ceil() as i32, (dab.x + r).ceil() as i32,
(dab.y + r).ceil() as i32, (dab.y + r).ceil() as i32,
); );
// Expand copy region to include the previous dab's bbox so the
// pixels it wrote are visible as the source for this dab's smudge.
let copy_bbox = match prev_bbox {
Some(pb) => (cur_bbox.0.min(pb.0), cur_bbox.1.min(pb.1),
cur_bbox.2.max(pb.2), cur_bbox.3.max(pb.3)),
None => cur_bbox,
};
self.render_dabs_batch(device, queue, keyframe_id, self.render_dabs_batch(device, queue, keyframe_id,
std::slice::from_ref(dab), cur_bbox, canvas_w, canvas_h); std::slice::from_ref(dab), cur_bbox, Some(copy_bbox), canvas_w, canvas_h);
prev_bbox = Some(cur_bbox);
} }
} else { } else {
self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, canvas_w, canvas_h); self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, None, canvas_w, canvas_h);
} }
} }
/// Dispatch one batch of dabs and keep the ping-pong textures identical. /// Inner batch dispatch.
/// ///
/// Reads `src` and writes the result over `dispatch_bbox` into `dst`, then /// `dispatch_bbox` — region dispatched to the compute shader (usually the union of all dab bboxes).
/// copies only the tiles the dabs touched back from `dst` to `src` so both /// `copy_bbox` — region to copy src→dst before dispatch:
/// textures stay authoritative — no full-canvas copy. The textures start equal /// - `None` → copy the full canvas (required for paint/erase batches so
/// (seeded by `upload` at stroke start) and this preserves that invariant, so a /// dabs outside the current frame's region are preserved).
/// single dab per call is enough for smudge's read-after-write dependency. /// - `Some(r)` → copy only region `r` (sufficient for sequential smudge dabs
/// /// because both textures hold identical data outside previously
/// `dispatch_bbox` is the region dispatched to the compute shader (the union of /// touched regions, so no full copy is needed).
/// the batch's dab bboxes).
fn render_dabs_batch( fn render_dabs_batch(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
@ -1242,6 +1068,7 @@ impl GpuBrushEngine {
keyframe_id: Uuid, keyframe_id: Uuid,
dabs: &[GpuDab], dabs: &[GpuDab],
dispatch_bbox: (i32, i32, i32, i32), dispatch_bbox: (i32, i32, i32, i32),
copy_bbox: Option<(i32, i32, i32, i32)>,
canvas_w: u32, canvas_w: u32,
canvas_h: u32, canvas_h: u32,
) { ) {
@ -1261,7 +1088,61 @@ impl GpuBrushEngine {
let bbox_w = x1 - x0; let bbox_w = x1 - x0;
let bbox_h = y1 - y0; let bbox_h = y1 - y0;
// Step 1: Upload all dabs as a single storage buffer. // Step 1: Copy src→dst.
// For paint/erase batches (copy_bbox = None): copy the ENTIRE canvas so dst
// starts with all previous dabs — a bbox-only copy would lose dabs outside
// this frame's region after swap.
// For smudge (copy_bbox = Some(r)): copy only the union of the current and
// previous dab bboxes. Outside that region both textures hold identical
// data so no full copy is needed.
let mut copy_enc = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") },
);
match copy_bbox {
None => {
copy_enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: canvas.src(),
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: canvas.dst(),
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 },
);
}
Some(cb) => {
let cx0 = cb.0.max(0) as u32;
let cy0 = cb.1.max(0) as u32;
let cx1 = (cb.2 as u32).min(canvas_w);
let cy1 = (cb.3 as u32).min(canvas_h);
if cx1 > cx0 && cy1 > cy0 {
copy_enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: canvas.src(),
mip_level: 0,
origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: canvas.dst(),
mip_level: 0,
origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d { width: cx1 - cx0, height: cy1 - cy0, depth_or_array_layers: 1 },
);
}
}
}
queue.submit(Some(copy_enc.finish()));
// Step 2: Upload all dabs as a single storage buffer.
let dab_bytes = bytemuck::cast_slice(dabs); let dab_bytes = bytemuck::cast_slice(dabs);
let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { let dab_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dab_storage_buf"), label: Some("dab_storage_buf"),
@ -1300,7 +1181,7 @@ impl GpuBrushEngine {
], ],
}); });
// Step 2: Single dispatch over the union bounding box. // Step 3: Single dispatch over the union bounding box.
let mut compute_enc = device.create_command_encoder( let mut compute_enc = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") },
); );
@ -1312,98 +1193,52 @@ impl GpuBrushEngine {
pass.set_bind_group(0, &bg, &[]); pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1);
} }
// Step 3: Sync only the tiles these dabs touched from dst back to src, so
// both ping-pong textures stay identical and authoritative — only the bytes
// that actually changed are moved (no full-canvas copy). The copies share
// the compute encoder, so wgpu orders them after the dispatch writes.
for (rx, ry, rw, rh) in dirty_tile_rects(dabs, canvas_w, canvas_h) {
compute_enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: canvas.dst(),
mip_level: 0,
origin: wgpu::Origin3d { x: rx, y: ry, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: canvas.src(),
mip_level: 0,
origin: wgpu::Origin3d { x: rx, y: ry, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d { width: rw, height: rh, depth_or_array_layers: 1 },
);
}
queue.submit(Some(compute_enc.finish())); queue.submit(Some(compute_enc.finish()));
// Step 4: Swap once — dst (with all dabs applied) becomes the new src. // Step 4: Swap once — dst (with all dabs applied) becomes the new src.
canvas.swap(); canvas.swap();
} }
/// Read the current canvas back to a CPU `Vec<u8>` (sRGB-premultiplied RGBA, /// Read the current canvas back to a CPU `Vec<u8>` (raw RGBA, row-major).
/// row-major).
/// ///
/// The linear→sRGB conversion runs on the GPU into an `Rgba8Unorm` scratch /// **Blocks** until the GPU work is complete (`Maintain::Wait`).
/// texture, so the CPU side is just a per-row `memcpy` (no per-pixel decode). /// Should only be called at stroke end, not every frame.
/// **Blocks** until the GPU work is complete. Call at stroke end, not per frame.
/// ///
/// Returns `None` if no canvas exists for `keyframe_id`. /// Returns `None` if no canvas exists for `keyframe_id`.
pub fn readback_canvas( pub fn readback_canvas(
&mut self, &self,
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
keyframe_id: Uuid, keyframe_id: Uuid,
) -> Option<Vec<u8>> { ) -> Option<Vec<u8>> {
// Lazily build the conversion pipeline and (re)create the cached scratch if
// the canvas size changed — these mutable borrows end before the shared
// borrows below.
if self.readback_srgb_pipeline.is_none() {
self.readback_srgb_pipeline = Some(ReadbackSrgbPipeline::new(device));
}
let (width, height) = {
let canvas = self.canvases.get(&keyframe_id)?; let canvas = self.canvases.get(&keyframe_id)?;
(canvas.width, canvas.height) let width = canvas.width;
}; let height = canvas.height;
if self.readback_scratch.as_ref().map_or(true, |s| s.width != width || s.height != height) {
self.readback_scratch = Some(ReadbackScratch::new(device, width, height));
}
let pipeline = self.readback_srgb_pipeline.as_ref().unwrap(); // wgpu requires bytes_per_row to be a multiple of 256
let scratch = self.readback_scratch.as_ref().unwrap(); let bytes_per_row_aligned =
let canvas = self.canvases.get(&keyframe_id)?; ((width * 4 + 255) / 256) * 256;
let total_bytes = (bytes_per_row_aligned * height) as u64;
// GPU pass: linear-premultiplied Rgba16Float → sRGB-premultiplied Rgba8Unorm. let staging = device.create_buffer(&wgpu::BufferDescriptor {
// Reading back 8-bit sRGB lets the CPU just memcpy each row. label: Some("canvas_readback_buf"),
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { size: total_bytes,
label: Some("canvas_readback_srgb_bg"), usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
layout: &pipeline.bg_layout, mapped_at_creation: false,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(canvas.src_view()) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&scratch.view) },
],
}); });
let bytes_per_row_aligned = scratch.bytes_per_row_aligned;
let mut encoder = device.create_command_encoder( let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("canvas_readback_encoder") }, &wgpu::CommandEncoderDescriptor { label: Some("canvas_readback_encoder") },
); );
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("canvas_readback_srgb_pass"), timestamp_writes: None },
);
pass.set_pipeline(&pipeline.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
encoder.copy_texture_to_buffer( encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { wgpu::TexelCopyTextureInfo {
texture: &scratch.tex, texture: canvas.src(),
mip_level: 0, mip_level: 0,
origin: wgpu::Origin3d::ZERO, origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All, aspect: wgpu::TextureAspect::All,
}, },
wgpu::TexelCopyBufferInfo { wgpu::TexelCopyBufferInfo {
buffer: &scratch.staging, buffer: &staging,
layout: wgpu::TexelCopyBufferLayout { layout: wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(bytes_per_row_aligned), bytes_per_row: Some(bytes_per_row_aligned),
@ -1414,8 +1249,8 @@ impl GpuBrushEngine {
); );
queue.submit(Some(encoder.finish())); queue.submit(Some(encoder.finish()));
// Block until complete. // Block until complete
let slice = scratch.staging.slice(..); let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
let _ = device.poll(wgpu::PollType::wait_indefinitely()); let _ = device.poll(wgpu::PollType::wait_indefinitely());
@ -1423,19 +1258,27 @@ impl GpuBrushEngine {
let mapped = slice.get_mapped_range(); let mapped = slice.get_mapped_range();
// De-stride with a per-row memcpy (dropping the 256-byte row padding). The // De-stride: copy only `width * 4` bytes per row (drop alignment padding)
// bytes are already sRGB-premultiplied RGBA8 from the GPU pass, which is let bytes_per_row_tight = (width * 4) as usize;
// what Vello expects (ImageAlphaType::Premultiplied with sRGB channels). let bytes_per_row_src = bytes_per_row_aligned as usize;
let row_tight = (width * 4) as usize;
let row_src = bytes_per_row_aligned as usize;
let mut pixels = vec![0u8; (width * height * 4) as usize]; let mut pixels = vec![0u8; (width * height * 4) as usize];
for row in 0..height as usize { for row in 0..height as usize {
let src = &mapped[row * row_src .. row * row_src + row_tight]; let src = &mapped[row * bytes_per_row_src .. row * bytes_per_row_src + bytes_per_row_tight];
pixels[row * row_tight .. (row + 1) * row_tight].copy_from_slice(src); let dst = &mut pixels[row * bytes_per_row_tight .. (row + 1) * bytes_per_row_tight];
dst.copy_from_slice(src);
} }
drop(mapped); drop(mapped);
scratch.staging.unmap(); staging.unmap();
// Encode linear premultiplied → sRGB-encoded premultiplied so the returned
// bytes match what Vello expects (ImageAlphaType::Premultiplied with sRGB
// channels). Alpha is left unchanged.
for pixel in pixels.chunks_exact_mut(4) {
pixel[0] = linear_to_srgb_byte(pixel[0]);
pixel[1] = linear_to_srgb_byte(pixel[1]);
pixel[2] = linear_to_srgb_byte(pixel[2]);
}
Some(pixels) Some(pixels)
} }

View File

@ -5616,8 +5616,11 @@ impl eframe::App for EditorApp {
// Close the progress dialog after a brief delay // Close the progress dialog after a brief delay
self.export_progress_dialog.close(); self.export_progress_dialog.close();
// Send desktop notification (fire-and-forget; never blocks the UI) // Send desktop notification
notifications::notify_export_complete(output_path); if let Err(e) = notifications::notify_export_complete(output_path) {
// Log but don't fail - notifications are non-critical
eprintln!("⚠️ Could not send desktop notification: {}", e);
}
} }
lightningbeam_core::export::ExportProgress::Error { ref message } => { lightningbeam_core::export::ExportProgress::Error { ref message } => {
eprintln!("❌ Export error: {}", message); eprintln!("❌ Export error: {}", message);
@ -5627,8 +5630,11 @@ impl eframe::App for EditorApp {
); );
// Keep the dialog open to show the error // Keep the dialog open to show the error
// Send desktop notification for error (fire-and-forget) // Send desktop notification for error
notifications::notify_export_error(message); if let Err(e) = notifications::notify_export_error(message) {
// Log but don't fail - notifications are non-critical
eprintln!("⚠️ Could not send desktop notification: {}", e);
}
} }
} }
} }

View File

@ -3,52 +3,52 @@
use notify_rust::Notification; use notify_rust::Notification;
use std::path::Path; use std::path::Path;
// `notify_rust`'s `Notification::show()` is a SYNCHRONOUS D-Bus call. When no /// Send a desktop notification for successful export
// notification daemon is running it can block for the service-activation timeout ///
// (~25s) before failing, so these are fire-and-forget on a detached thread — the /// # Arguments
// UI must never wait on them (e.g. they're triggered from the export-complete /// * `output_path` - Path to the exported file
// handler on the UI thread). ///
/// # Returns
/// Show a desktop notification for a successful export (fire-and-forget). /// Ok(()) if notification sent successfully, Err with log message if failed
pub fn notify_export_complete(output_path: &Path) { pub fn notify_export_complete(output_path: &Path) -> Result<(), String> {
let filename = output_path let filename = output_path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("unknown") .unwrap_or("unknown");
.to_string();
std::thread::spawn(move || { Notification::new()
if let Err(e) = Notification::new()
.summary("Export Complete") .summary("Export Complete")
.body(&format!("Successfully exported: {}", filename)) .body(&format!("Successfully exported: {}", filename))
.icon("dialog-information") // Standard icon name (freedesktop.org) .icon("dialog-information") // Standard icon name (freedesktop.org)
.timeout(5000) // 5 seconds .timeout(5000) // 5 seconds
.show() .show()
{ .map_err(|e| format!("Failed to show notification: {}", e))?;
eprintln!("⚠️ Could not send desktop notification: {}", e);
} Ok(())
});
} }
/// Show a desktop notification for an export error (fire-and-forget). /// Send a desktop notification for export error
pub fn notify_export_error(error_message: &str) { ///
// Truncate very long error messages (on a char boundary). /// # Arguments
let truncated = if error_message.chars().count() > 100 { /// * `error_message` - The error message to display
let prefix: String = error_message.chars().take(97).collect(); ///
format!("{}...", prefix) /// # Returns
/// Ok(()) if notification sent successfully, Err with log message if failed
pub fn notify_export_error(error_message: &str) -> Result<(), String> {
// Truncate very long error messages
let truncated = if error_message.len() > 100 {
format!("{}...", &error_message[..97])
} else { } else {
error_message.to_string() error_message.to_string()
}; };
std::thread::spawn(move || { Notification::new()
if let Err(e) = Notification::new()
.summary("Export Failed") .summary("Export Failed")
.body(&truncated) .body(&truncated)
.icon("dialog-error") // Standard error icon .icon("dialog-error") // Standard error icon
.timeout(10000) // 10 seconds for errors (longer to read) .timeout(10000) // 10 seconds for errors (longer to read)
.show() .show()
{ .map_err(|e| format!("Failed to show notification: {}", e))?;
eprintln!("⚠️ Could not send desktop notification: {}", e);
} Ok(())
});
} }

View File

@ -5,12 +5,12 @@
// //
// B[px] = C[px] + A[px] * (1 C[px].a) (Porter-Duff src-over, C over A) // B[px] = C[px] + A[px] * (1 C[px].a) (Porter-Duff src-over, C over A)
// //
// All textures are Rgba16Float, linear premultiplied RGBA. // All textures are Rgba8Unorm, linear premultiplied RGBA.
// Dispatch: ceil(w/8) × ceil(h/8) × 1. // Dispatch: ceil(w/8) × ceil(h/8) × 1.
@group(0) @binding(0) var tex_a: texture_2d<f32>; // source (A) @group(0) @binding(0) var tex_a: texture_2d<f32>; // source (A)
@group(0) @binding(1) var tex_c: texture_2d<f32>; // accumulated dabs (C) @group(0) @binding(1) var tex_c: texture_2d<f32>; // accumulated dabs (C)
@group(0) @binding(2) var tex_b: texture_storage_2d<rgba16float, write>; // output (B) @group(0) @binding(2) var tex_b: texture_storage_2d<rgba8unorm, write>; // output (B)
@compute @workgroup_size(8, 8) @compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { fn main(@builtin(global_invocation_id) gid: vec3<u32>) {

View File

@ -37,7 +37,7 @@ struct Params {
@group(0) @binding(0) var<storage, read> dabs: array<GpuDab>; @group(0) @binding(0) var<storage, read> dabs: array<GpuDab>;
@group(0) @binding(1) var<uniform> params: Params; @group(0) @binding(1) var<uniform> params: Params;
@group(0) @binding(2) var canvas_src: texture_2d<f32>; @group(0) @binding(2) var canvas_src: texture_2d<f32>;
@group(0) @binding(3) var canvas_dst: texture_storage_2d<rgba16float, write>; @group(0) @binding(3) var canvas_dst: texture_storage_2d<rgba8unorm, write>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Manual bilinear sample from canvas_src at sub-pixel coordinates (px, py). // Manual bilinear sample from canvas_src at sub-pixel coordinates (px, py).

View File

@ -1,27 +0,0 @@
// Canvas readback color conversion.
//
// Converts the Rgba16Float linear-PREMULTIPLIED canvas into an Rgba8Unorm
// sRGB-encoded premultiplied texture so the CPU readback is a pure row memcpy
// instead of a per-pixel sRGB decode. Matches the bytes the previous CPU decode
// produced: the sRGB OETF is applied per channel to the premultiplied RGB, and
// alpha (which is not gamma-encoded) is passed through.
// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude.
@group(0) @binding(0) var src: texture_2d<f32>; // linear premultiplied
@group(0) @binding(1) var dst: texture_storage_2d<rgba8unorm, write>; // sRGB premultiplied
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let dim = textureDimensions(src);
if gid.x >= dim.x || gid.y >= dim.y {
return;
}
let p = vec2<i32>(i32(gid.x), i32(gid.y));
let c = textureLoad(src, p, 0);
let srgb = vec3<f32>(
linear_to_srgb_channel(c.r),
linear_to_srgb_channel(c.g),
linear_to_srgb_channel(c.b),
);
textureStore(dst, p, vec4<f32>(srgb, c.a));
}

View File

@ -2,10 +2,8 @@
// //
// Reads the anchor canvas (before_pixels), composites a gradient over it, and // Reads the anchor canvas (before_pixels), composites a gradient over it, and
// writes the result to the display canvas. All color values in the canvas are // writes the result to the display canvas. All color values in the canvas are
// linear premultiplied RGBA. The stop colors passed via `stops` are sRGB // linear premultiplied RGBA. The stop colors passed via `stops` are linear
// straight-alpha [0..1]; the gradient is interpolated in sRGB (gamma) space to // straight-alpha [0..1] (sRGBlinear conversion is done on the CPU).
// match the CPU raster and vector gradient paths, then the interpolated color is
// converted to linear before compositing.
// //
// Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1 // Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1
@ -27,7 +25,7 @@ struct Params {
// 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side. // 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side.
struct GradientStop { struct GradientStop {
position: f32, position: f32,
r: f32, // sRGB [0..1], straight-alpha r: f32, // linear [0..1], straight-alpha
g: f32, g: f32,
b: f32, b: f32,
a: f32, a: f32,
@ -36,11 +34,10 @@ struct GradientStop {
_pad2: f32, _pad2: f32,
} }
// srgb_to_linear_channel is provided by the prepended COLOR_WGSL prelude.
@group(0) @binding(0) var<uniform> params: Params; @group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>; @group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> stops: array<GradientStop>; @group(0) @binding(2) var<storage, read> stops: array<GradientStop>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba16float, write>; @group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
fn apply_extend(t: f32) -> f32 { fn apply_extend(t: f32) -> f32 {
if params.extend_mode == 0u { if params.extend_mode == 0u {
@ -125,15 +122,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
} }
let t = apply_extend(t_raw); let t = apply_extend(t_raw);
let grad = eval_gradient(t); // straight-alpha sRGB RGBA (interpolated in gamma space) let grad = eval_gradient(t); // straight-alpha linear RGBA
// Convert the interpolated sRGB color to linear for compositing. Alpha is
// not gamma-encoded, so it passes through unchanged.
let grad_rgb_lin = vec3<f32>(
srgb_to_linear_channel(grad.r),
srgb_to_linear_channel(grad.g),
srgb_to_linear_channel(grad.b),
);
// Effective alpha: gradient alpha × tool opacity. // Effective alpha: gradient alpha × tool opacity.
let a = grad.a * params.opacity; let a = grad.a * params.opacity;
@ -142,7 +131,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// src_px.rgb is premultiplied (= straight_rgb * src_a). // src_px.rgb is premultiplied (= straight_rgb * src_a).
// Output is also premultiplied. // Output is also premultiplied.
let out_a = a + src_px.a * (1.0 - a); let out_a = a + src_px.a * (1.0 - a);
let out_rgb = grad_rgb_lin * a + src_px.rgb * (1.0 - a); let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a);
textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(out_rgb, out_a)); textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(out_rgb, out_a));
} }

View File

@ -30,23 +30,27 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
return out; return out;
} }
// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude. // Linear to sRGB conversion for a single channel
// Formula: c <= 0.0031308 ? c*12.92 : 1.055*pow(c, 1/2.4) - 0.055
fn linear_to_srgb_channel(c: f32) -> f32 {
let clamped = clamp(c, 0.0, 1.0);
return select(
1.055 * pow(clamped, 1.0 / 2.4) - 0.055,
clamped * 12.92,
clamped <= 0.0031308
);
}
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Sample linear HDR texture. The compositor accumulates PREMULTIPLIED // Sample linear HDR texture
// linear color, so unpremultiply before applying the sRGB OETF:
// srgb(rgb*a) != srgb(rgb)*a, so encoding premultiplied color directly
// corrupts antialiased edges and transparent pixels. We output straight
// alpha to match the straight-alpha display blit and PNG export.
let linear = textureSample(input_tex, input_sampler, in.uv); let linear = textureSample(input_tex, input_sampler, in.uv);
let a = linear.a;
let straight = select(linear.rgb / a, vec3<f32>(0.0), a <= 0.0);
// Convert from linear to sRGB for display (alpha stays linear)
return vec4<f32>( return vec4<f32>(
linear_to_srgb_channel(straight.r), linear_to_srgb_channel(linear.r),
linear_to_srgb_channel(straight.g), linear_to_srgb_channel(linear.g),
linear_to_srgb_channel(straight.b), linear_to_srgb_channel(linear.b),
a linear.a
); );
} }

View File

@ -26,7 +26,7 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params; @group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>; @group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var dst: texture_storage_2d<rgba16float, write>; @group(0) @binding(2) var dst: texture_storage_2d<rgba8unorm, write>;
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> { fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {

View File

@ -26,7 +26,7 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params; @group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>; @group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> disp: array<vec2f>; @group(0) @binding(2) var<storage, read> disp: array<vec2f>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba16float, write>; @group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> { fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {

View File

@ -217,9 +217,7 @@ impl SharedVelloResources {
// Uses linear_to_srgb.wgsl which reads from Rgba16Float HDR texture // Uses linear_to_srgb.wgsl which reads from Rgba16Float HDR texture
let hdr_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { let hdr_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hdr_blit_shader"), label: Some("hdr_blit_shader"),
source: wgpu::ShaderSource::Wgsl( source: wgpu::ShaderSource::Wgsl(include_str!("shaders/linear_to_srgb.wgsl").into()),
format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, include_str!("shaders/linear_to_srgb.wgsl")).into(),
),
}); });
let hdr_blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { let hdr_blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {

View File

@ -8,7 +8,7 @@
//! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. | //! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. |
//! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. | //! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. |
//! //!
//! All three are `Rgba16Float` with the same pixel dimensions. The framework //! All three are `Rgba8Unorm` with the same pixel dimensions. The framework
//! allocates and validates them in [`begin_raster_workspace`]; tools only //! allocates and validates them in [`begin_raster_workspace`]; tools only
//! dispatch shaders. //! dispatch shaders.
@ -45,11 +45,11 @@ pub enum WorkspaceSource {
/// commit or cancel. /// commit or cancel.
#[derive(Debug)] #[derive(Debug)]
pub struct RasterWorkspace { pub struct RasterWorkspace {
/// A canvas (Rgba16Float) — source pixels, uploaded at mousedown, read-only for tools. /// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools.
pub a_canvas_id: Uuid, pub a_canvas_id: Uuid,
/// B canvas (Rgba16Float) — output / display; compositor shows this while active. /// B canvas (Rgba8Unorm) — output / display; compositor shows this while active.
pub b_canvas_id: Uuid, pub b_canvas_id: Uuid,
/// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke. /// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke.
pub c_canvas_id: Uuid, pub c_canvas_id: Uuid,
/// Optional R8Unorm selection mask (same pixel dimensions as A/B/C). /// Optional R8Unorm selection mask (same pixel dimensions as A/B/C).
/// `None` means the entire workspace is selected. /// `None` means the entire workspace is selected.

View File

@ -213,7 +213,7 @@ impl TabletInput {
let pressure = ctx.input(|i| { let pressure = ctx.input(|i| {
i.events.iter().rev().find_map(|e| { i.events.iter().rev().find_map(|e| {
if let egui::Event::Touch { if let egui::Event::Touch {
force: Some(f), force: Some(egui::TouchForce::Normalized(f)),
.. ..
} = e } = e
{ {

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Lightningbeam", "productName": "Lightningbeam",
"version": "1.0.4-alpha", "version": "1.0.3-alpha",
"identifier": "org.lightningbeam.core", "identifier": "org.lightningbeam.core",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"