Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c35a75b
feat(media-animations): per-category grids with lazy fetch on tab visit
AIEraDev Nov 6, 2026
41165ac
fix(media-animations): reactive catalog snapshot so animations actual…
AIEraDev Nov 6, 2026
edec056
fix(media-animations): auto-download manifest on select and recompile…
AIEraDev Nov 6, 2026
cccfa59
fix(preview): non-blocking image rasterization during playback
AIEraDev Nov 6, 2026
33f4d2e
feat: implement media animation performance telemetry system
AIEraDev Nov 6, 2026
a65fa30
fix: correct clipType discriminant and durationMs rounding in animati…
AIEraDev Nov 6, 2026
f6f9650
fix: lookahead cold-start stall — prime decode queue before first pla…
AIEraDev Nov 7, 2026
9214bb1
fix: remove as-any casts in getCatalogSnapshot and make priming harne…
AIEraDev Nov 7, 2026
80c91ce
fix: markNativeAudioUnavailable in updateSource for silent projects
AIEraDev Nov 7, 2026
44fe6d4
feat(media-animations): add localized toast feedback and fix infinite…
AIEraDev Nov 8, 2026
b01ddfd
fix(preview): resolve timeline-zero effect cache invalidation and pla…
AIEraDev Nov 9, 2026
d4613f8
feat(animation): harden text animation pipeline, uniform scaling, and…
AIEraDev Nov 9, 2026
6c625d3
feat(text-animations): refactor MotionPresetSection to MediaAnimation…
AIEraDev Nov 9, 2026
3d4e21b
fix(animation): select and apply preset immediately on card click eve…
AIEraDev Nov 9, 2026
fb360e3
feat(animation): per-glyph character stagger for text animation presets
AIEraDev Nov 9, 2026
9dde745
feat(animation): align text animation duration control with media ani…
AIEraDev Nov 9, 2026
b82448d
feat(ui): add FeaturePlaceholder and wire unfinished sidebar tabs
AIEraDev Nov 9, 2026
e9ee88c
feat(sidebar): enable FeaturePlaceholder for captions tab
AIEraDev Nov 9, 2026
c3fb993
fix(properties, export): resolve text-template properties tab and aud…
AIEraDev Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src-tauri/src/commands/export/audio_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ pub(crate) async fn has_audio_stream(path: &str) -> bool {



pub(crate) fn build_atempo_filter_chain(mut speed: f64) -> String {
let mut filters = Vec::new();
while speed > 2.0 {
filters.push("atempo=2.0".to_string());
speed /= 2.0;
}
while speed < 0.5 {
filters.push("atempo=0.5".to_string());
speed /= 0.5;
}
filters.push(format!("atempo={:.4}", speed));
filters.join(",")
}

pub(crate) fn build_export_audio_filter_complex(
valid_audio_clips: &[ExportAudioClip],
total_duration: f64,
Expand All @@ -71,7 +85,10 @@ pub(crate) fn build_export_audio_filter_complex(
for (idx, clip) in valid_audio_clips.iter().enumerate() {
let input_idx = idx + 1; // input 0 is pipe:0 (video)
let delay_ms = (clip.start_time * 1000.0) as i64;
let end_time = clip.trim_in + clip.duration;
let speed = clip.speed.unwrap_or(1.0).max(0.01);
let preserve_pitch = clip.preserve_pitch.unwrap_or(true);
let source_duration = clip.duration * speed;
let end_time = clip.trim_in + source_duration;

let fade_in = clip.fade_in.unwrap_or(0.0).max(0.0).min(clip.duration);
let fade_out = clip.fade_out.unwrap_or(0.0).max(0.0).min(clip.duration);
Expand All @@ -83,6 +100,14 @@ pub(crate) fn build_export_audio_filter_complex(
input_idx, clip.trim_in, end_time
);

if (speed - 1.0).abs() > 0.001 {
if preserve_pitch {
chain.push_str(&format!(",{}", build_atempo_filter_chain(speed)));
} else {
chain.push_str(&format!(",asetrate={:.0},aresample=48000", 48000.0 * speed));
}
}

let in_curve = match clip.fade_in_curve.as_deref() {
Some("exponential") => ":curve=exp",
Some("logarithmic") => ":curve=log",
Expand Down
55 changes: 55 additions & 0 deletions src-tauri/src/commands/export/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ mod tests {
duration: 5.0,
volume: 1.0,
trim_in: 0.0,
speed: None,
preserve_pitch: None,
fade_in: None,
fade_out: None,
fade_in_curve: None,
Expand All @@ -57,6 +59,8 @@ mod tests {
duration: 4.0,
volume: 0.8,
trim_in: 0.0,
speed: None,
preserve_pitch: None,
fade_in: None,
fade_out: None,
fade_in_curve: None,
Expand Down Expand Up @@ -87,6 +91,51 @@ mod tests {
);
}

#[test]
fn test_export_audio_speed_filter_chain() {
assert_eq!(
build_atempo_filter_chain(2.0),
"atempo=2.0000"
);
assert_eq!(
build_atempo_filter_chain(4.0),
"atempo=2.0,atempo=2.0000"
);
assert_eq!(
build_atempo_filter_chain(0.25),
"atempo=0.5,atempo=0.5000"
);

let clip_2x = ExportAudioClip {
path: "/path/to/voice.mp3".to_string(),
start_time: 1.0,
duration: 5.0,
trim_in: 0.0,
speed: Some(2.0),
preserve_pitch: Some(true),
volume: 1.0,
fade_in: None,
fade_out: None,
fade_in_curve: None,
fade_out_curve: None,
eq_low: None,
eq_mid: None,
eq_high: None,
pan: None,
noise_suppression: None,
volume_keyframes: None,
channel_map: None,
channel_mode: None,
downmix: None,
};

let filter = build_export_audio_filter_complex(&[clip_2x], 10.0);
// atrim must cover 5.0 * 2.0 = 10.0 seconds of source audio
assert!(filter.contains("atrim=start=0.000:end=10.000"));
// atempo filter must be included
assert!(filter.contains("atempo=2.0000"));
}

fn base_test_config() -> ExportConfig {
ExportConfig {
output_path: "/tmp/test_export.mp4".to_string(),
Expand Down Expand Up @@ -184,6 +233,8 @@ mod tests {
start_time: 0.0,
duration: -1.0,
trim_in: 0.0,
speed: None,
preserve_pitch: None,
volume: 1.0,
fade_in: None,
fade_out: None,
Expand All @@ -209,6 +260,8 @@ mod tests {
start_time: -0.5,
duration: 1.0,
trim_in: 0.0,
speed: None,
preserve_pitch: None,
volume: 1.0,
fade_in: None,
fade_out: None,
Expand All @@ -234,6 +287,8 @@ mod tests {
start_time: 0.0,
duration: 1.0,
trim_in: 0.0,
speed: None,
preserve_pitch: None,
volume: 1.0,
fade_in: None,
fade_out: None,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/commands/export/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ pub struct ExportAudioClip {
/// Trim in offset in seconds inside the source media file
pub trim_in: f64,

/// Speed multiplier (e.g. 1.0, 2.0, 0.5)
pub speed: Option<f64>,

/// Whether to preserve pitch during speed change (default true)
pub preserve_pitch: Option<bool>,

/// Volume multiplier (0.0-1.0)
/// This prevents precision loss during serialization round-trips
pub volume: f64,
Expand Down
18 changes: 16 additions & 2 deletions src-tauri/src/commands/native_playback/render_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,22 @@ impl NativeRenderSession {
.iter()
.find(|r| r.layer_id.as_deref() == Some(lid))
.cloned()
.or_else(|| {
request
.project
.raster_layers
.iter()
.find(|r| !r.asset_id.is_empty() && r.asset_id == update.asset_id)
.cloned()
})
} else {
request.project.raster_layers.get(i).cloned()
request
.project
.raster_layers
.iter()
.find(|r| !r.asset_id.is_empty() && r.asset_id == update.asset_id)
.cloned()
.or_else(|| request.project.raster_layers.get(i).cloned())
};
if let Some(mut layer) = existing {
if let Some(layer_id) = &update.layer_id {
Expand Down Expand Up @@ -724,7 +738,7 @@ impl NativeRenderSession {
.unwrap_or_else(|| "normal".to_string()),
color_grade: None,
is_mask: update.is_mask,
is_text: true,
is_text: update.asset_id.starts_with("native-text:"),
is_premultiplied: false,
});
}
Expand Down
10 changes: 7 additions & 3 deletions src-tauri/src/commands/native_preview/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2567,10 +2567,11 @@ pub(crate) async fn present_native_frame_internal(
let queued_key = request
.decode_cache_key()
.map_err(|error| error.to_string())?;
let has_video_layers = !request.project.video_layers.is_empty();
let is_playback_mode = request.mode.as_deref() == Some("playback");
let mut playback_lookahead_miss = false;
let lookahead_wait_us: u64 = 0;
let queued_frame =
let queued_frame = if has_video_layers {
if let Some(queue) = app.try_state::<Arc<tokio::sync::Mutex<NativePreviewFrameQueue>>>() {
let queue_arc = queue.inner().clone();
let mut q = queue_arc.lock().await;
Expand Down Expand Up @@ -2600,8 +2601,11 @@ pub(crate) async fn present_native_frame_internal(
}
} else {
None
};
if is_playback_mode {
}
} else {
None
};
if is_playback_mode && has_video_layers {
record_playback_lookahead_outcome(&app, queued_frame.is_some());
}
if playback_lookahead_miss {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,61 @@ describe("PropertiesPanel text clip animation and transform keyframe integration
expect(screen.getByText("Position")).toBeDefined();
expect(screen.getByText("Plane rotation")).toBeDefined();
});

it("renders sticky duration footer in PropertiesPanel when text clip has an active in-preset", () => {
useTimelineStore.setState({
clips: [
{
...mockTextClip,
motion: {
inPreset: "anim-in-flip-in",
inDuration: 0.6,
},
},
],
});

render(<PropertiesPanel />);

// Click "Animation" tab
const animationTabButton = screen.getByRole("button", {
name: /Animation/i,
});
fireEvent.click(animationTabButton);

// Verify the sticky duration control is rendered in the footer
expect(screen.getByText("Duration")).toBeDefined();
expect(screen.getByText("0.6s")).toBeDefined();

// Verify stepping duration updates the pill in UI
const plusButton = screen.getByRole("button", {
name: "Increase duration",
});
fireEvent.click(plusButton);
expect(screen.getByText("0.7s")).toBeDefined();
});

it("hides sticky duration footer when text clip has no active animation preset on selected tab", () => {
useTimelineStore.setState({
clips: [
{
...mockTextClip,
motion: {
inPreset: "none",
},
},
],
});

render(<PropertiesPanel />);

// Click "Animation" tab
const animationTabButton = screen.getByRole("button", {
name: /Animation/i,
});
fireEvent.click(animationTabButton);

// Verify duration is NOT rendered
expect(screen.queryByText("Duration")).toBeNull();
});
});
Loading
Loading