Skip to content

Commit eae0f62

Browse files
committed
adding filters page and vhs filter
1 parent cb99e1e commit eae0f62

9 files changed

Lines changed: 433 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Bun
17+
uses: oven-sh/setup-bun@v2
18+
with:
19+
bun-version: latest
20+
21+
- name: Install frontend deps
22+
run: bun install --frozen-lockfile
23+
24+
- name: Frontend build (TypeScript + Vite)
25+
run: bun run build
26+
27+
- name: Setup Rust toolchain (stable)
28+
uses: dtolnay/rust-toolchain@stable
29+
30+
- name: Cache cargo build artifacts
31+
uses: Swatinem/rust-cache@v2
32+
with:
33+
workspaces: |
34+
src-tauri -> target
35+
36+
- name: Cargo build (debug)
37+
working-directory: src-tauri
38+
run: cargo build --locked
39+
40+
- name: Cargo check
41+
working-directory: src-tauri
42+
run: cargo check --locked
43+
44+
- name: Cargo clippy (deny warnings)
45+
working-directory: src-tauri
46+
run: cargo clippy --all-targets -- -D warnings
47+
48+

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
2525
base64 = "0.22"
2626
thiserror = "1.0"
2727
toml = "0.8"
28+
rand = "0.8"
2829

src-tauri/src/engine/filters.rs

Lines changed: 0 additions & 76 deletions
This file was deleted.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use crate::types::{FilterChainRequest, FilterStep};
2+
use image::{imageops::FilterType, DynamicImage, ImageFormat, Rgba, RgbaImage};
3+
use std::io::Cursor;
4+
use thiserror::Error;
5+
6+
mod vhs;
7+
8+
#[derive(Debug, Error)]
9+
pub enum FilterError {
10+
#[error("unsupported image data url")]
11+
UnsupportedDataUrl,
12+
#[error(transparent)]
13+
Image(#[from] image::ImageError),
14+
}
15+
16+
fn decode_data_url_to_image(data_url: &str) -> Result<DynamicImage, FilterError> {
17+
let (header, b64) = data_url
18+
.split_once(",")
19+
.ok_or(FilterError::UnsupportedDataUrl)?;
20+
if !header.contains("base64") {
21+
return Err(FilterError::UnsupportedDataUrl);
22+
}
23+
use base64::engine::general_purpose::STANDARD as B64;
24+
use base64::Engine;
25+
let bytes = B64
26+
.decode(b64)
27+
.map_err(|_| FilterError::UnsupportedDataUrl)?;
28+
let img = image::load_from_memory(&bytes)?;
29+
Ok(img)
30+
}
31+
32+
fn encode_png_base64(img: &RgbaImage) -> Result<String, FilterError> {
33+
let mut buf = Cursor::new(Vec::new());
34+
DynamicImage::ImageRgba8(img.clone()).write_to(&mut buf, ImageFormat::Png)?;
35+
use base64::engine::general_purpose::STANDARD as B64;
36+
use base64::Engine;
37+
let b64 = B64.encode(buf.into_inner());
38+
Ok(format!("data:image/png;base64,{}", b64))
39+
}
40+
41+
#[allow(dead_code)]
42+
fn resize_exact_rgba(img: &DynamicImage, w: u32, h: u32) -> RgbaImage {
43+
img.resize_exact(w, h, FilterType::Nearest).to_rgba8()
44+
}
45+
46+
fn upscale_center_to(img: &RgbaImage, display_size: u32) -> RgbaImage {
47+
let max_dim = display_size.max(1);
48+
let factor_w = (max_dim / img.width()).max(1);
49+
let factor_h = (max_dim / img.height()).max(1);
50+
let factor = factor_w.min(factor_h);
51+
let scaled = image::imageops::resize(
52+
img,
53+
img.width() * factor,
54+
img.height() * factor,
55+
FilterType::Nearest,
56+
);
57+
let mut canvas: RgbaImage = image::ImageBuffer::from_pixel(max_dim, max_dim, Rgba([0, 0, 0, 0]));
58+
let off_x = (max_dim - scaled.width()) / 2;
59+
let off_y = (max_dim - scaled.height()) / 2;
60+
image::imageops::overlay(&mut canvas, &scaled, off_x.into(), off_y.into());
61+
canvas
62+
}
63+
64+
pub trait Filter {
65+
fn name(&self) -> &'static str;
66+
fn apply(&self, img: &mut RgbaImage, amount: f32);
67+
}
68+
69+
struct IdentityFilter;
70+
impl Filter for IdentityFilter {
71+
fn name(&self) -> &'static str { "Identity" }
72+
fn apply(&self, _img: &mut RgbaImage, _amount: f32) {}
73+
}
74+
75+
struct BrightnessFilter;
76+
impl Filter for BrightnessFilter {
77+
fn name(&self) -> &'static str { "Brightness" }
78+
fn apply(&self, img: &mut RgbaImage, amount: f32) {
79+
let centered = (amount.clamp(0.0, 1.0) - 0.5) * 2.0; // -1..1
80+
let delta = (centered * 255.0) as i32;
81+
for p in img.pixels_mut() {
82+
let [r, g, b, a] = p.0;
83+
let nr = (r as i32 + delta).clamp(0, 255) as u8;
84+
let ng = (g as i32 + delta).clamp(0, 255) as u8;
85+
let nb = (b as i32 + delta).clamp(0, 255) as u8;
86+
*p = Rgba([nr, ng, nb, a]);
87+
}
88+
}
89+
}
90+
91+
struct ContrastFilter;
92+
impl Filter for ContrastFilter {
93+
fn name(&self) -> &'static str { "Contrast" }
94+
fn apply(&self, img: &mut RgbaImage, amount: f32) {
95+
let scale = 2.0_f32.powf((amount.clamp(0.0, 1.0) - 0.5) * 2.0);
96+
for p in img.pixels_mut() {
97+
let [r, g, b, a] = p.0;
98+
let fr = ((r as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0;
99+
let fg = ((g as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0;
100+
let fb = ((b as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0;
101+
*p = Rgba([
102+
fr.clamp(0.0, 255.0) as u8,
103+
fg.clamp(0.0, 255.0) as u8,
104+
fb.clamp(0.0, 255.0) as u8,
105+
a,
106+
]);
107+
}
108+
}
109+
}
110+
111+
fn get_filter_by_name(name: &str) -> Option<Box<dyn Filter + Send + Sync>> {
112+
match name {
113+
"Identity" => Some(Box::new(IdentityFilter)),
114+
"Brightness" => Some(Box::new(BrightnessFilter)),
115+
"Contrast" => Some(Box::new(ContrastFilter)),
116+
"VHS" => Some(Box::new(vhs::VhsFilter)),
117+
_ => None,
118+
}
119+
}
120+
121+
fn apply_filter_chain(mut img: RgbaImage, steps: &[FilterStep]) -> RgbaImage {
122+
for step in steps.iter() {
123+
if !step.enabled { continue; }
124+
if let Some(f) = get_filter_by_name(step.name.as_str()) {
125+
let amt = step.amount.clamp(0.0, 1.0);
126+
let mut_ref: &mut RgbaImage = &mut img;
127+
f.apply(mut_ref, amt);
128+
}
129+
}
130+
img
131+
}
132+
133+
pub fn render_filters_preview_png(req: FilterChainRequest) -> Result<String, FilterError> {
134+
let img = decode_data_url_to_image(&req.image_data_url)?;
135+
let mut frame = img.to_rgba8();
136+
frame = apply_filter_chain(frame, &req.steps);
137+
let target = req.display_size.unwrap_or(560);
138+
let up = upscale_center_to(&frame, target);
139+
Ok(encode_png_base64(&up)?)
140+
}
141+
142+

0 commit comments

Comments
 (0)