Skip to content

Commit d2344c0

Browse files
committed
feat: implement some features
1 parent 0687c00 commit d2344c0

File tree

19 files changed

+1062
-45
lines changed

19 files changed

+1062
-45
lines changed

Cargo.lock

Lines changed: 41 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ ratatui = "0.29"
3434
crossterm = "0.28"
3535

3636
# Image encoding (for single-frame PNG export)
37-
image = { version = "0.25", default-features = false, features = ["png"] }
37+
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
3838

3939
# Parallelism
4040
rayon = "1"
@@ -68,3 +68,6 @@ notify = "7"
6868

6969
# HTTP client (blocking, for Iconify API)
7070
ureq = "3"
71+
72+
# QR code generation
73+
qrcode = "0.14"

src/components/counter.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ impl Widget for Counter {
6868
let weight = match font_weight {
6969
FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
7070
FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
71+
FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
7172
};
7273
let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
7374

@@ -144,6 +145,7 @@ impl Widget for Counter {
144145
let skia_font_style = match font_weight {
145146
FontWeight::Bold => FontStyle::bold(),
146147
FontWeight::Normal => FontStyle::normal(),
148+
FontWeight::Weight(w) => FontStyle::new(skia_safe::font_style::Weight::from(w as i32), skia_safe::font_style::Width::NORMAL, skia_safe::font_style::Slant::Upright),
147149
};
148150
let typeface = fm
149151
.match_family_style(font_family, skia_font_style)

src/components/flex.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,11 @@ impl Widget for Flex {
184184
pub(crate) fn resolve_size_constraints(size: &Option<FlexSize>, constraints: &Constraints) -> Constraints {
185185
let w = size.as_ref().and_then(|s| match &s.width {
186186
SizeDimension::Fixed(v) => Some(*v),
187-
SizeDimension::Auto(_) => None,
187+
SizeDimension::Percent(_) | SizeDimension::Auto => None,
188188
});
189189
let h = size.as_ref().and_then(|s| match &s.height {
190190
SizeDimension::Fixed(v) => Some(*v),
191-
SizeDimension::Auto(_) => None,
191+
SizeDimension::Percent(_) | SizeDimension::Auto => None,
192192
});
193193
Constraints {
194194
min_width: w.unwrap_or(constraints.min_width),

src/components/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub mod gif;
77
pub mod grid;
88
pub mod icon;
99
pub mod image;
10+
pub mod progress;
11+
pub mod qrcode;
1012
pub mod shape;
1113
pub mod stack;
1214
pub mod svg;

src/components/progress.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use anyhow::Result;
2+
use skia_safe::{Canvas, PaintStyle, Rect, RRect};
3+
4+
use crate::engine::renderer::{color4f_from_hex, paint_from_hex};
5+
use crate::schema::ProgressBarLayer;
6+
7+
pub fn render_progress_bar(canvas: &Canvas, layer: &ProgressBarLayer) -> Result<()> {
8+
let x = layer.position.x;
9+
let y = layer.position.y;
10+
let w = layer.width;
11+
let h = layer.height;
12+
let radius = layer.border_radius;
13+
let progress = layer.progress.clamp(0.0, 1.0) as f32;
14+
15+
// Background
16+
let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&layer.background_color), None);
17+
bg_paint.set_style(PaintStyle::Fill);
18+
bg_paint.set_anti_alias(true);
19+
20+
let bg_rect = Rect::from_xywh(x, y, w, h);
21+
let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius);
22+
canvas.draw_rrect(bg_rrect, &bg_paint);
23+
24+
// Fill (progress)
25+
if progress > 0.001 {
26+
let mut fill_paint = paint_from_hex(&layer.fill_color);
27+
fill_paint.set_style(PaintStyle::Fill);
28+
fill_paint.set_anti_alias(true);
29+
30+
let fill_w = w * progress;
31+
let fill_rect = Rect::from_xywh(x, y, fill_w, h);
32+
33+
// Clip to the rounded background shape to prevent fill from exceeding bounds
34+
canvas.save();
35+
canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true);
36+
canvas.draw_rect(fill_rect, &fill_paint);
37+
canvas.restore();
38+
}
39+
40+
Ok(())
41+
}

src/components/qrcode.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use anyhow::Result;
2+
use skia_safe::{Canvas, PaintStyle, Rect};
3+
4+
use crate::engine::renderer::color4f_from_hex;
5+
use crate::schema::QrCodeLayer;
6+
7+
pub fn render_qr_code(canvas: &Canvas, layer: &QrCodeLayer) -> Result<()> {
8+
use qrcode::QrCode;
9+
10+
let code = QrCode::new(layer.content.as_bytes())
11+
.map_err(|e| anyhow::anyhow!("QR code generation failed: {}", e))?;
12+
13+
let modules = code.to_colors();
14+
let module_count = code.width() as f32;
15+
let module_size = layer.size / module_count;
16+
17+
let x = layer.position.x;
18+
let y = layer.position.y;
19+
20+
// Background
21+
let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&layer.background_color), None);
22+
bg_paint.set_style(PaintStyle::Fill);
23+
canvas.draw_rect(Rect::from_xywh(x, y, layer.size, layer.size), &bg_paint);
24+
25+
// Foreground modules
26+
let mut fg_paint = skia_safe::Paint::new(color4f_from_hex(&layer.foreground_color), None);
27+
fg_paint.set_style(PaintStyle::Fill);
28+
fg_paint.set_anti_alias(false); // Sharp edges for QR
29+
30+
for (idx, &color) in modules.iter().enumerate() {
31+
if color == qrcode::Color::Dark {
32+
let col = (idx % code.width()) as f32;
33+
let row = (idx / code.width()) as f32;
34+
let rect = Rect::from_xywh(
35+
x + col * module_size,
36+
y + row * module_size,
37+
module_size,
38+
module_size,
39+
);
40+
canvas.draw_rect(rect, &fg_paint);
41+
}
42+
}
43+
44+
Ok(())
45+
}

src/components/shape.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ fn render_shape_text(
135135
let font_style = match text.font_weight {
136136
FontWeight::Bold => skia_safe::FontStyle::bold(),
137137
FontWeight::Normal => skia_safe::FontStyle::normal(),
138+
FontWeight::Weight(w) => skia_safe::FontStyle::new(skia_safe::font_style::Weight::from(w as i32), skia_safe::font_style::Width::NORMAL, skia_safe::font_style::Slant::Upright),
138139
};
139140

140141
let typeface = fm

src/components/text.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ impl Widget for Text {
4848
let weight = match font_weight {
4949
FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
5050
FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
51+
FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
5152
};
5253
let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
5354

@@ -194,6 +195,7 @@ impl Widget for Text {
194195
let weight = match font_weight {
195196
FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
196197
FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
198+
FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
197199
};
198200
let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
199201
let typeface = fm

src/encode/audio.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use symphonia::core::probe::Hint;
99

1010
use crate::schema::AudioTrack;
1111

12-
const TARGET_SAMPLE_RATE: u32 = 44100;
12+
const TARGET_SAMPLE_RATE: u32 = 48000;
1313
const TARGET_CHANNELS: u32 = 2;
1414

1515
/// Decode an audio file into PCM i16 samples (stereo, 44100Hz, interleaved)
@@ -122,10 +122,16 @@ pub fn mix_audio_tracks(tracks: &[AudioTrack], total_duration: f64) -> Result<Op
122122
break;
123123
}
124124

125-
let mut sample = resampled[i] * track.volume;
125+
let frame = i / TARGET_CHANNELS as usize;
126+
let current_time = track.start + (frame as f64 / TARGET_SAMPLE_RATE as f64);
127+
let vol = if !track.volume_keyframes.is_empty() {
128+
interpolate_volume_keyframes(&track.volume_keyframes, current_time)
129+
} else {
130+
track.volume
131+
};
132+
let mut sample = resampled[i] * vol;
126133

127134
// Apply fade in
128-
let frame = i / TARGET_CHANNELS as usize;
129135
if fade_in_samples > 0.0 && (frame as f64) < fade_in_samples {
130136
sample *= frame as f32 / fade_in_samples as f32;
131137
}
@@ -152,6 +158,33 @@ pub fn mix_audio_tracks(tracks: &[AudioTrack], total_duration: f64) -> Result<Op
152158
Ok(Some(pcm_bytes))
153159
}
154160

161+
/// Interpolate volume at a given time using volume keyframes with easing
162+
fn interpolate_volume_keyframes(keyframes: &[crate::schema::VolumeKeyframe], time: f64) -> f32 {
163+
if keyframes.is_empty() {
164+
return 1.0;
165+
}
166+
if time <= keyframes[0].time {
167+
return keyframes[0].volume;
168+
}
169+
if time >= keyframes.last().unwrap().time {
170+
return keyframes.last().unwrap().volume;
171+
}
172+
for i in 0..keyframes.len() - 1 {
173+
let kf0 = &keyframes[i];
174+
let kf1 = &keyframes[i + 1];
175+
if time >= kf0.time && time <= kf1.time {
176+
let duration = kf1.time - kf0.time;
177+
if duration < 1e-9 {
178+
return kf1.volume;
179+
}
180+
let t = (time - kf0.time) / duration;
181+
let progress = crate::engine::animator::ease(t, &kf0.easing);
182+
return kf0.volume + (kf1.volume - kf0.volume) * progress as f32;
183+
}
184+
}
185+
keyframes.last().unwrap().volume
186+
}
187+
155188
fn to_stereo(samples: &[f32], channels: u32) -> Vec<f32> {
156189
match channels {
157190
1 => {

0 commit comments

Comments
 (0)