diff --git a/src-tauri/src/commands/export/audio_filter.rs b/src-tauri/src/commands/export/audio_filter.rs index 640b24ee3..b957171a9 100644 --- a/src-tauri/src/commands/export/audio_filter.rs +++ b/src-tauri/src/commands/export/audio_filter.rs @@ -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, @@ -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); @@ -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", diff --git a/src-tauri/src/commands/export/tests.rs b/src-tauri/src/commands/export/tests.rs index b651b17a7..c27e5e553 100644 --- a/src-tauri/src/commands/export/tests.rs +++ b/src-tauri/src/commands/export/tests.rs @@ -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, @@ -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, @@ -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(), @@ -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, @@ -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, @@ -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, diff --git a/src-tauri/src/commands/export/types.rs b/src-tauri/src/commands/export/types.rs index cbb6b113d..fdcbcab56 100644 --- a/src-tauri/src/commands/export/types.rs +++ b/src-tauri/src/commands/export/types.rs @@ -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, + + /// Whether to preserve pitch during speed change (default true) + pub preserve_pitch: Option, + /// Volume multiplier (0.0-1.0) /// This prevents precision loss during serialization round-trips pub volume: f64, diff --git a/src-tauri/src/commands/native_playback/render_session.rs b/src-tauri/src/commands/native_playback/render_session.rs index a5666e89c..e6947e66a 100644 --- a/src-tauri/src/commands/native_playback/render_session.rs +++ b/src-tauri/src/commands/native_playback/render_session.rs @@ -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 { @@ -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, }); } diff --git a/src-tauri/src/commands/native_preview/mod.rs b/src-tauri/src/commands/native_preview/mod.rs index b262ed8cf..badf85196 100644 --- a/src-tauri/src/commands/native_preview/mod.rs +++ b/src-tauri/src/commands/native_preview/mod.rs @@ -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::>>() { let queue_arc = queue.inner().clone(); let mut q = queue_arc.lock().await; @@ -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 { diff --git a/src/components/editor/__tests__/PropertiesPanelTextAnimation.test.tsx b/src/components/editor/__tests__/PropertiesPanelTextAnimation.test.tsx index 632816305..f5071ae7b 100644 --- a/src/components/editor/__tests__/PropertiesPanelTextAnimation.test.tsx +++ b/src/components/editor/__tests__/PropertiesPanelTextAnimation.test.tsx @@ -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(); + + // 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(); + + // Click "Animation" tab + const animationTabButton = screen.getByRole("button", { + name: /Animation/i, + }); + fireEvent.click(animationTabButton); + + // Verify duration is NOT rendered + expect(screen.queryByText("Duration")).toBeNull(); + }); }); diff --git a/src/components/editor/preview/NativeProgramPreview.tsx b/src/components/editor/preview/NativeProgramPreview.tsx index 5ff645adf..1460bf23b 100644 --- a/src/components/editor/preview/NativeProgramPreview.tsx +++ b/src/components/editor/preview/NativeProgramPreview.tsx @@ -108,6 +108,7 @@ import { } from "@/lib/platform/tauri"; import { telemetryCollector } from "@/services/telemetryCollector"; import type { TelemetryPreviewContext } from "@/services/telemetryCollector"; +import { perfLogService } from "@/services/perfLogService"; import { warmBackgroundWorkersAndCachesAtIdle } from "@/services/idleWarmup"; import type { SmartOverlayClip } from "@/types/smartOverlay"; @@ -2532,6 +2533,19 @@ export const NativeProgramPreview: React.FC = () => { if (pending && isActive) { ensureNativePlaybackRenderSnapshot(pending.request); } + // Task 2: Prime lookahead after snapshot settles while paused so + // frames are decoded before the user presses play. + if (isActive && renderStateRef.current.clock.state !== "playing") { + const currentFrame = getFrameIndexAtTime( + renderStateRef.current.clock.time, + renderStateRef.current.clock.frameRate, + ); + primeNativeLookahead( + currentFrame, + LOOKAHEAD_PRIME_FRAMES, + "paused-settle", + ); + } }); }; @@ -2831,6 +2845,131 @@ export const NativeProgramPreview: React.FC = () => { nativeVideoPrefetchInFlight.set(key, task); }; + // Matches PrefetchPolicy::playback_window=200ms at 30fps. + const LOOKAHEAD_PRIME_FRAMES = 6; + + /** + * Cold-start lookahead primer (Bug 13 fix). + * + * Fires queueNativeFrame for `frameCount` frames starting at `currentFrame` + * while the clock is still paused so the Rust WorkPlanner has decoded frames + * ready before the first play demand arrives. + * + * MUST live inside the useEffect closure to share closure-local variables: + * nativePlaybackRenderSnapshotKey, nativeVideoPrefetchCompleted, + * nativeVideoPrefetchInFlight, nativeVideoPrefetchFailedAt, visibleRequestGeneration. + */ + const primeNativeLookahead = ( + currentFrame: number, + frameCount: number, + primeTriggeredBy: string, + ): void => { + if (!isTauriRuntime() || !isActive) return; + if (nativePlaybackRenderSnapshotKey === "") return; + const state = renderStateRef.current; + const project = state.project; + if (!project) return; + + const frameRate = Math.max(1, project.frameRate ?? 30); + const revision = `${project.id ?? "unknown-project"}:${state.epoch}`; + let queuedCount = 0; + + for (let i = 0; i < frameCount; i++) { + const frameIdx = currentFrame + i; + const cacheKey = `${revision}:video:prime:${frameIdx}`; + if ( + nativeVideoPrefetchCompleted.has(cacheKey) || + nativeVideoPrefetchInFlight.has(cacheKey) + ) { + continue; + } + + const time = getFrameStartTime(frameIdx / frameRate, frameRate); + const scene = evaluateTimelineSceneCached( + time, + state.clips, + state.tracks, + state.mediaAssets, + project, + state.epoch, + state.transitions, + state.sceneVersions, + ); + const hasVideo = scene.visualLayers.some( + (layer) => layer.layerType === "media", + ); + if (!hasVideo) continue; + + const baseRenderTarget = getNativeRenderTarget(state, false); + const primePrefetchRequest = buildNativeFrameRequest( + scene, + revision, + frameIdx, + frameRate, + baseRenderTarget.width, + baseRenderTarget.height, + [], + { mode: "prefetch", generation: visibleRequestGeneration }, + ); + if ( + !primePrefetchRequest || + primePrefetchRequest.project.videoLayers.length === 0 + ) { + continue; + } + + const task = queueNativeFrame(primePrefetchRequest) + .then(() => { + const current = renderStateRef.current; + if ( + !isActive || + current.project?.id !== project.id || + current.epoch !== state.epoch + ) { + return; + } + nativeVideoPrefetchCompleted.add(cacheKey); + while (nativeVideoPrefetchCompleted.size > 64) { + const oldest = nativeVideoPrefetchCompleted.values().next().value; + if (oldest === undefined) break; + nativeVideoPrefetchCompleted.delete(oldest); + } + nativeVideoPrefetchFailedAt.delete(cacheKey); + }) + .catch((error) => { + nativeVideoPrefetchFailedAt.set(cacheKey, performance.now()); + console.warn("[native-preview] lookahead-prime-failed", { + frameIndex: frameIdx, + primeTriggeredBy, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + nativeVideoPrefetchInFlight.delete(cacheKey); + }); + nativeVideoPrefetchInFlight.set(cacheKey, task); + queuedCount++; + } + + if (queuedCount > 0) { + try { + perfLogService.enqueue({ + kind: "playback-trace", + sessionId: perfLogService.getSessionId() ?? "unknown", + timestampEpochMs: Date.now(), + payload: { + event: "lookahead-prime", + frameIndex: currentFrame, + frameCount, + primeTriggeredBy, + }, + }); + } catch { + // Telemetry must never throw + } + } + }; + // Never start text/video prefetch preparation in the same turn as the first play intent. // Give the first native frame and audio clock a head start, then warm upcoming // boundaries from an idle timer. @@ -2925,6 +3064,10 @@ export const NativeProgramPreview: React.FC = () => { return { clips: previewClips, previewRevision }; }; + // Task 3: Track whether the previous RAF was in play state so we can detect the + // first RAF after a pause→play transition and prime the lookahead buffer eagerly. + let wasPlayingInLastFrame = false; + const renderLoop = async () => { if (!isActive || renderInFlight) return; const wasRenderInFlightAtStart = renderInFlight; // false at this point — guard passed @@ -2938,6 +3081,12 @@ export const NativeProgramPreview: React.FC = () => { const timeToRender = state.clock.time; const playbackState = state.clock.state; const isPlaying = playbackState === "playing"; + // Task 3: Detect first RAF after transition to playing. + const justStartedPlaying = isPlaying && !wasPlayingInLastFrame; + wasPlayingInLastFrame = isPlaying; + if (justStartedPlaying) { + lastNativePlaybackRequestKey = ""; + } const frameRate = state.project?.frameRate ?? 30; const frameIndex = getFrameIndexAtTime(timeToRender, frameRate); if (playbackState !== lastTracedPlaybackState) { @@ -3182,6 +3331,13 @@ export const NativeProgramPreview: React.FC = () => { isPlaying || Boolean(requestIntent?.isScrubbing) || requestIntent?.mode === "scrub", + // Still images with animations (visualKeyframes) go black during + // playback because registerNativeImageAsset is async. Use the cached + // snapshot with updated animated transforms while registration settles. + nonBlockingImages: + isPlaying || + Boolean(requestIntent?.isScrubbing) || + requestIntent?.mode === "scrub", }); traceSlowPlaybackStage("visible-raster-bridge", bridgeStartedAt, { frameIndex, @@ -3655,6 +3811,27 @@ export const NativeProgramPreview: React.FC = () => { if (!isPlaying) { nativePlaybackRenderFailed = false; } + // Task 4: Eager render-session establishment on the first paused frame + // with video. Without this, configureNativePlaybackRender is only called + // inside the `requestKey !== lastNativePlaybackRequestKey` gate below, + // which is skipped when the user hasn't yet scrubbed. Establishing the + // session eagerly here ensures queueNativeFrame calls from + // primeNativeLookahead and prefetchUpcomingNativeVideo have a valid + // decode worker ready before the first play demand arrives. + if ( + !isPlaying && + nativeSurfaceUsable && + !qualificationForcesWebView && + nativePlaybackRenderSnapshotKey === "" && + nativePlaybackRenderSnapshotInFlight === null && + nativeRequest !== null && + nativeRequest.project.videoLayers.length > 0 + ) { + // Check that the next line wouldn't duplicate this call — it won't + // because the outer gate (nativeDirectSurfacePath || nativeReadbackFallbackPath) + // may be false when persistentNativePausedEligible is not yet satisfied. + ensureNativePlaybackRenderSnapshot(nativeRequest); + } const nativeSurfaceNeedsHide = nativeSurfaceShown && (!nativeDirectSurfacePath || @@ -3833,6 +4010,18 @@ export const NativeProgramPreview: React.FC = () => { demandDelayUs, ); } + // Task 3: Prime the lookahead buffer on the first RAF after play + // starts so decoded frames are ready before frame deadlines arrive. + if ( + justStartedPlaying && + nativePlaybackRenderSnapshotKey !== "" + ) { + primeNativeLookahead( + frameIndex + 1, + LOOKAHEAD_PRIME_FRAMES, + "play-intent", + ); + } void submitNativePlaybackDemand( createNativePlaybackFrameDemand(playbackDemandRequest), ).catch((error) => { diff --git a/src/components/editor/preview/__tests__/ProgramPreview.renderLoop.test.ts b/src/components/editor/preview/__tests__/ProgramPreview.renderLoop.test.ts index 00c16aa64..7db3c2a0f 100644 --- a/src/components/editor/preview/__tests__/ProgramPreview.renderLoop.test.ts +++ b/src/components/editor/preview/__tests__/ProgramPreview.renderLoop.test.ts @@ -4993,3 +4993,469 @@ function buildMinimalBlurScene( }, }; } + +// ───────────────────────────────────────────────────────────────────────────── +// Bug 13 — Lookahead cold-start stall on play +// +// Problem: Every frame was dropped (droppedFramesRatio=1) on first play because +// the Rust WorkPlanner only populated the lookahead buffer when EngineControlLoop +// was already in Play mode. By the time decoded frames arrived, every deadline had +// expired. Three compounding delays: (1) configureNativePlaybackRender IPC not yet +// established, (2) serial audio IPC chain, (3) cold HEVC decode (50–500ms). +// +// Fix: primeNativeLookahead() fires queueNativeFrame for LOOKAHEAD_PRIME_FRAMES +// while still paused, seeding the Rust decode queue before play demands arrive. +// It is triggered from two sites: paused-settle (after ensureNativePlaybackRenderSnapshot +// settles) and play-intent (first RAF after transition to playing). +// Additionally, configureNativePlaybackRender is called eagerly on the first +// paused frame with video layers so the session exists before priming begins. +// ───────────────────────────────────────────────────────────────────────────── +describe("Bug 13 — Lookahead cold-start stall on play", () => { + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + /** + * Build a minimal media EvaluatedLayer. + */ + function makeMediaLayer( + overrides: Partial<{ + layerType: string; + mediaType: string; + }> = {}, + ) { + return { + layerType: overrides.layerType ?? "media", + mediaType: overrides.mediaType ?? "video", + layerId: "layer-1", + clipId: "clip-1", + role: "primary", + zIndex: 0, + mediaId: "asset-1", + sourcePath: "/test/video.mp4", + sourceTime: 0, + x: 0, + y: 0, + width: 1920, + height: 1080, + rotation: 0, + opacity: 1, + blendMode: "normal", + inTransition: false, + }; + } + + /** + * Lightweight mock of the closure variables used by primeNativeLookahead. + * The function is tested by modelling its logic directly (the function lives + * inside a React useEffect closure and cannot be imported directly). + */ + function makePrimingHarness( + options: { + snapshotKey?: string; + existingCompleted?: string[]; + existingInFlight?: string[]; + /** Map from frame index to whether it has video layers. Defaults to true. */ + videoLayersByFrame?: Record; + } = {}, + ) { + const { + snapshotKey = "project-id:1:1920x1080:full", + existingCompleted = [], + existingInFlight = [], + videoLayersByFrame = {}, + } = options; + + const queueNativeFrameCalls: Array<{ frameIndex: number; mode: string }> = + []; + const completedKeys = new Set(existingCompleted); + const inFlightKeys = new Map>(); + existingInFlight.forEach((k) => inFlightKeys.set(k, Promise.resolve())); + + const projectId = "project-id"; + const epoch = 1; + const frameRate = 30; + const revision = `${projectId}:${epoch}`; + const LOOKAHEAD_PRIME_FRAMES = 6; + + /** + * Simplified model of primeNativeLookahead that uses the same cache-key + * pattern and guards, but skips the actual Tauri IPC call. + */ + const primeNativeLookaheadModel = ( + isTauri: boolean, + isActive: boolean, + currentSnapshotKey: string, + currentFrame: number, + frameCount: number, + primeTriggeredBy: string, + ): { queuedFrameIndices: number[]; telemetryFired: boolean } => { + // Mirror the guards in the real implementation + if (!isTauri || !isActive) + return { queuedFrameIndices: [], telemetryFired: false }; + if (currentSnapshotKey === "") + return { queuedFrameIndices: [], telemetryFired: false }; + + const queuedFrameIndices: number[] = []; + + for (let i = 0; i < frameCount; i++) { + const frameIdx = currentFrame + i; + const cacheKey = `${revision}:video:prime:${frameIdx}`; + if (completedKeys.has(cacheKey) || inFlightKeys.has(cacheKey)) continue; + + // Check scene for media layers — honour the caller's per-frame override + // so the guard inside primeNativeLookahead is actually exercised. + const hasVideo = options.videoLayersByFrame?.[frameIdx] !== false; // default true + + if (!hasVideo) continue; + + // Record the call + queueNativeFrameCalls.push({ frameIndex: frameIdx, mode: "prefetch" }); + queuedFrameIndices.push(frameIdx); + inFlightKeys.set(cacheKey, Promise.resolve()); + } + + const telemetryFired = queuedFrameIndices.length > 0; + return { queuedFrameIndices, telemetryFired }; + }; + + return { + snapshotKey, + primeNativeLookaheadModel, + queueNativeFrameCalls, + completedKeys, + inFlightKeys, + LOOKAHEAD_PRIME_FRAMES, + revision, + }; + } + + // --------------------------------------------------------------------------- + // Test 1: fires queueNativeFrame for LOOKAHEAD_PRIME_FRAMES when snapshot established + // --------------------------------------------------------------------------- + it("primeNativeLookahead fires queueNativeFrame for LOOKAHEAD_PRIME_FRAMES frames when render snapshot is established", () => { + const { + primeNativeLookaheadModel, + queueNativeFrameCalls, + LOOKAHEAD_PRIME_FRAMES, + } = makePrimingHarness({ snapshotKey: "project-id:1:1920x1080:full" }); + + const { queuedFrameIndices, telemetryFired } = primeNativeLookaheadModel( + /* isTauri */ true, + /* isActive */ true, + /* snapshotKey */ "project-id:1:1920x1080:full", + /* currentFrame */ 0, + LOOKAHEAD_PRIME_FRAMES, + "test-trigger", + ); + + expect(queueNativeFrameCalls).toHaveLength(LOOKAHEAD_PRIME_FRAMES); + expect(queuedFrameIndices).toEqual([0, 1, 2, 3, 4, 5]); + expect(telemetryFired).toBe(true); + }); + + // --------------------------------------------------------------------------- + // Test 2: skips frames already in nativeVideoPrefetchCompleted + // --------------------------------------------------------------------------- + it("primeNativeLookahead skips frames already in nativeVideoPrefetchCompleted", () => { + const revision = "project-id:1"; + const alreadyCompleted = [ + `${revision}:video:prime:0`, + `${revision}:video:prime:1`, + `${revision}:video:prime:2`, + ]; + const { + primeNativeLookaheadModel, + queueNativeFrameCalls, + LOOKAHEAD_PRIME_FRAMES, + } = makePrimingHarness({ existingCompleted: alreadyCompleted }); + + const { queuedFrameIndices } = primeNativeLookaheadModel( + true, + true, + "project-id:1:1920x1080:full", + 0, + LOOKAHEAD_PRIME_FRAMES, + "test", + ); + + // Only frames 3, 4, 5 should be queued (0, 1, 2 already completed) + expect(queuedFrameIndices).toEqual([3, 4, 5]); + expect(queueNativeFrameCalls).toHaveLength(3); + }); + + // --------------------------------------------------------------------------- + // Test 3: does not fire when nativePlaybackRenderSnapshotKey is empty + // --------------------------------------------------------------------------- + it("primeNativeLookahead does not fire when nativePlaybackRenderSnapshotKey is empty", () => { + const { + primeNativeLookaheadModel, + queueNativeFrameCalls, + LOOKAHEAD_PRIME_FRAMES, + } = makePrimingHarness(); + + const { queuedFrameIndices } = primeNativeLookaheadModel( + true, + true, + /* snapshotKey = "" */ "", + 0, + LOOKAHEAD_PRIME_FRAMES, + "test", + ); + + expect(queuedFrameIndices).toHaveLength(0); + expect(queueNativeFrameCalls).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Test 4: fires after ensureNativePlaybackRenderSnapshot settles (paused-settle) + // --------------------------------------------------------------------------- + it("primeNativeLookahead fires after ensureNativePlaybackRenderSnapshot settles on paused seek (paused-settle trigger)", async () => { + // Model the .finally() block of ensureNativePlaybackRenderSnapshot: + // After the configure promise settles, if clock is paused, primeNativeLookahead is called. + const primeLog: string[] = []; + + let snapshotKey = ""; + let snapshotInFlight: Promise | null = null; + let snapshotPending: { key: string } | null = null; + + const fakeConfigureNativePlaybackRender = () => Promise.resolve(); + + const ensureSnapshot = (key: string) => { + if (snapshotKey === key || snapshotInFlight !== null) return; + snapshotInFlight = fakeConfigureNativePlaybackRender() + .then(() => { + snapshotKey = key; + }) + .catch(() => { + /* ignore */ + }) + .finally(() => { + snapshotInFlight = null; + const pending = snapshotPending; + snapshotPending = null; + if (pending) ensureSnapshot(pending.key); + + // This is the new Task 2 code — fires primeNativeLookahead when paused + const isPlaying = false; // clock is paused + if (isPlaying === false && snapshotKey !== "") { + primeLog.push("paused-settle"); + } + }); + }; + + ensureSnapshot("project-1:1920x1080:full"); + await snapshotInFlight; + + // After the promise resolves the finally block should have fired + expect(snapshotKey).toBe("project-1:1920x1080:full"); + expect(primeLog).toContain("paused-settle"); + }); + + // --------------------------------------------------------------------------- + // Test 5: fires before submitNativePlaybackDemand on first RAF after play + // --------------------------------------------------------------------------- + it("primeNativeLookahead fires before submitNativePlaybackDemand on first RAF after play (play-intent trigger)", () => { + // Model the renderLoop() logic for justStartedPlaying tracking and + // the primeNativeLookahead call before submitNativePlaybackDemand. + const callOrder: string[] = []; + + let wasPlayingInLastFrame = false; + let snapshotKey = "project-1:1920x1080:full"; // session already established + + const mockPrimeNativeLookahead = ( + _frameIndex: number, + _count: number, + triggeredBy: string, + ) => { + callOrder.push(`prime:${triggeredBy}`); + }; + + const mockSubmitNativePlaybackDemand = () => { + callOrder.push("submit-demand"); + }; + + // Simulate the renderLoop() play-intent logic: + // First call: paused → playing (justStartedPlaying = true) + const simulateRenderLoopTick = (isPlaying: boolean) => { + const justStartedPlaying = isPlaying && !wasPlayingInLastFrame; + wasPlayingInLastFrame = isPlaying; + + if (justStartedPlaying && snapshotKey !== "") { + // Task 3 code: prime before demand + mockPrimeNativeLookahead(1, 6, "play-intent"); + } + + if (isPlaying && snapshotKey !== "") { + mockSubmitNativePlaybackDemand(); + } + }; + + // First tick: paused, no events + simulateRenderLoopTick(false); + expect(callOrder).toHaveLength(0); + + // Second tick: first tick after play starts + simulateRenderLoopTick(true); + + // prime must appear before submit-demand + expect(callOrder).toEqual(["prime:play-intent", "submit-demand"]); + expect(callOrder.indexOf("prime:play-intent")).toBeLessThan( + callOrder.indexOf("submit-demand"), + ); + }); + + // --------------------------------------------------------------------------- + // Test 6: configureNativePlaybackRender called on first paused frame with video + // --------------------------------------------------------------------------- + it("configureNativePlaybackRender is called on first paused frame with video even when persistentNativePausedEligible is false", () => { + // Model the Task 4 eager-configure guard: + // When snapshotKey="" and there's a nativeRequest with video layers and the + // surface is usable (even if persistentNativePausedEligible is false), we call + // ensureNativePlaybackRenderSnapshot. + let snapshotKey = ""; + let snapshotInFlight: null | Promise = null; + let configureCalls = 0; + + const fakeEnsureSnapshot = (_request: { hasVideoLayers: boolean }) => { + configureCalls++; + snapshotInFlight = Promise.resolve().then(() => { + snapshotKey = "project-1:1920x1080:full"; + snapshotInFlight = null; + }); + }; + + // Simulate renderLoop() Task 4 guard: + const simulatePausedRenderLoopTick = (options: { + isPlaying: boolean; + nativeSurfaceUsable: boolean; + qualificationForcesWebView: boolean; + nativeRequestHasVideoLayers: boolean; + }) => { + const { + isPlaying, + nativeSurfaceUsable, + qualificationForcesWebView, + nativeRequestHasVideoLayers, + } = options; + + // Task 4 eager configure trigger: + if ( + !isPlaying && + nativeSurfaceUsable && + !qualificationForcesWebView && + snapshotKey === "" && + snapshotInFlight === null && + nativeRequestHasVideoLayers + ) { + fakeEnsureSnapshot({ hasVideoLayers: true }); + } + }; + + // Case 1: paused, surface usable, video present, no prior session — should configure + simulatePausedRenderLoopTick({ + isPlaying: false, + nativeSurfaceUsable: true, + qualificationForcesWebView: false, + nativeRequestHasVideoLayers: true, + }); + expect(configureCalls).toBe(1); + + // Case 2: called again — session already in-flight, should NOT reconfigure + simulatePausedRenderLoopTick({ + isPlaying: false, + nativeSurfaceUsable: true, + qualificationForcesWebView: false, + nativeRequestHasVideoLayers: true, + }); + // snapshotInFlight !== null at this point, so guard should skip + expect(configureCalls).toBe(1); + + // Case 3: playing — should NOT trigger eager configure + snapshotKey = ""; + snapshotInFlight = null; + simulatePausedRenderLoopTick({ + isPlaying: true, + nativeSurfaceUsable: true, + qualificationForcesWebView: false, + nativeRequestHasVideoLayers: true, + }); + expect(configureCalls).toBe(1); // unchanged + + // Case 4: qualification forces webview — should NOT configure + simulatePausedRenderLoopTick({ + isPlaying: false, + nativeSurfaceUsable: true, + qualificationForcesWebView: true, + nativeRequestHasVideoLayers: true, + }); + expect(configureCalls).toBe(1); // unchanged + + // Case 5: no video layers — should NOT configure + simulatePausedRenderLoopTick({ + isPlaying: false, + nativeSurfaceUsable: true, + qualificationForcesWebView: false, + nativeRequestHasVideoLayers: false, + }); + expect(configureCalls).toBe(1); // unchanged + }); +}); + +describe("Bug 14 — Playback Start at Timeline Zero & Effects Cache Invalidation (CLY-BUG-TIMELINE-ZERO-EFFECT)", () => { + it("clears lastNativePlaybackRequestKey on transition from paused to playing so t=0 frame demand is never swallowed", () => { + let wasPlayingInLastFrame = false; + let lastNativePlaybackRequestKey = "frame-0-paused-key"; + let dispatchedKey = ""; + + const simulateTick = (isPlaying: boolean, currentRequestKey: string) => { + const justStartedPlaying = isPlaying && !wasPlayingInLastFrame; + wasPlayingInLastFrame = isPlaying; + if (justStartedPlaying) { + lastNativePlaybackRequestKey = ""; + } + + if (currentRequestKey !== lastNativePlaybackRequestKey) { + dispatchedKey = currentRequestKey; + lastNativePlaybackRequestKey = currentRequestKey; + } + }; + + // User is paused at frame 0, request key is 'frame-0-paused-key' + simulateTick(false, "frame-0-paused-key"); + expect(lastNativePlaybackRequestKey).toBe("frame-0-paused-key"); + + // User starts playback at t=0: request key is still 'frame-0-paused-key' + dispatchedKey = ""; + simulateTick(true, "frame-0-paused-key"); + + // In fixed code, justStartedPlaying cleared lastNativePlaybackRequestKey, so dispatch succeeded! + expect(dispatchedKey).toBe("frame-0-paused-key"); + expect(lastNativePlaybackRequestKey).toBe("frame-0-paused-key"); + }); + + it("PlaybackClock does not freeze after 2 seconds when native audio is unavailable", () => { + const clock = new PlaybackClock(); + clock.setDuration(10); + clock.setNativeClockAuthority(true); + clock.markNativeAudioUnavailable(); + + expect(clock.nativeAudioWasUnavailable).toBe(true); + + const perfNowSpy = vi.spyOn(performance, "now"); + let currentMs = 10000; + perfNowSpy.mockImplementation(() => currentMs); + + clock.play(); + expect(clock.state).toBe("playing"); + + // Advance by 3 seconds (past the 2.0s CPAL init threshold) + currentMs += 3000; + + // In fixed code, clock continues advancing smoothly via wall-clock! + expect(clock.time).toBeCloseTo(3.0, 1); + + perfNowSpy.mockRestore(); + }); +}); + diff --git a/src/components/editor/preview/nativeTextPreview.ts b/src/components/editor/preview/nativeTextPreview.ts index d865ebda9..a83e8fa65 100644 --- a/src/components/editor/preview/nativeTextPreview.ts +++ b/src/components/editor/preview/nativeTextPreview.ts @@ -856,6 +856,17 @@ function buildNativeTextKeyObject( textRole: layer.textRole, maxWidth: layer.maxWidth, time: timeDependent ? layer.time : undefined, + // Per-glyph motion animation: each progress step produces a unique raster. + // Round to 2 decimal places (100 steps) to avoid runaway cache growth while + // still producing frame-accurate glyph transforms. + motionInProgress: + layer.motionInProgress !== undefined + ? Math.round(layer.motionInProgress * 100) / 100 + : undefined, + motionOutProgress: + layer.motionOutProgress !== undefined + ? Math.round(layer.motionOutProgress * 100) / 100 + : undefined, width: layer.baseWidth ?? layer.width, height: layer.baseHeight ?? layer.height, fontFamily: layer.fontFamily, diff --git a/src/components/editor/properties/__tests__/AudioSection.test.tsx b/src/components/editor/properties/__tests__/AudioSection.test.tsx index 25e501d85..2ca4e5276 100644 --- a/src/components/editor/properties/__tests__/AudioSection.test.tsx +++ b/src/components/editor/properties/__tests__/AudioSection.test.tsx @@ -141,9 +141,9 @@ describe("AudioSection", () => { ); const volumeInput = screen.getAllByRole("spinbutton")[0]; - fireEvent.change(volumeInput, { target: { value: "50" } }); + fireEvent.change(volumeInput, { target: { value: "0" } }); fireEvent.blur(volumeInput); - expect(handleUpdate).toHaveBeenCalledWith("volume", 0.5); + expect(handleUpdate).toHaveBeenCalledWith("volume", 1); const muteButton = screen.getByTitle("Mute"); fireEvent.click(muteButton); @@ -194,7 +194,8 @@ describe("AudioSection", () => { ); const volumeInput = screen.getAllByRole("spinbutton")[0]; - fireEvent.change(volumeInput, { target: { value: "75" } }); + // -2.5 dB corresponds to ~0.75 linear gain (decimals={1} on NumericControl) + fireEvent.change(volumeInput, { target: { value: "-2.5" } }); fireEvent.blur(volumeInput); // Must update volumeKeyframes, NOT plain volume @@ -205,7 +206,7 @@ describe("AudioSection", () => { const updatedKfs = kfCall![1]; const updatedKf = updatedKfs.find((k: any) => k.id === "vkf-a"); expect(updatedKf).toBeDefined(); - expect(updatedKf.gain).toBeCloseTo(0.75, 5); + expect(updatedKf.gain).toBeCloseTo(0.75, 2); // Other keyframe untouched expect(updatedKfs.find((k: any) => k.id === "vkf-b").gain).toBe(0.3); }); @@ -219,10 +220,14 @@ describe("AudioSection", () => { ); const volumeInput = screen.getAllByRole("spinbutton")[0]; - fireEvent.change(volumeInput, { target: { value: "40" } }); + // -8.0 dB corresponds to ~0.4 linear gain (10^(-8/20) ≈ 0.398) + fireEvent.change(volumeInput, { target: { value: "-8" } }); fireEvent.blur(volumeInput); - expect(handleUpdate).toHaveBeenCalledWith("volume", 0.4); + expect(handleUpdate).toHaveBeenCalledWith( + "volume", + expect.closeTo(0.398, 2), + ); const kfCall = handleUpdate.mock.calls.find( ([prop]) => prop === "volumeKeyframes", ); diff --git a/src/components/editor/properties/__tests__/MotionPresetSection.test.tsx b/src/components/editor/properties/__tests__/MotionPresetSection.test.tsx index e1a76be9e..ab9bb1f9f 100644 --- a/src/components/editor/properties/__tests__/MotionPresetSection.test.tsx +++ b/src/components/editor/properties/__tests__/MotionPresetSection.test.tsx @@ -143,14 +143,17 @@ const mockPresets: RemoteTextAnimationPreset[] = [ }, ]; +const useAnimationPhaseMock = vi.fn((phase: string, _enabled?: boolean) => ({ + presets: mockPresets.filter((p) => p.phase === phase), + status: "ready", +})); + vi.mock("@/features/text-animations/animationCatalog", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useAnimationPhase: (phase: string) => ({ - presets: mockPresets.filter((p) => p.phase === phase), - status: "ready", - }), + useAnimationPhase: (phase: string, enabled?: boolean) => + useAnimationPhaseMock(phase, enabled), useTextAnimationCatalog: () => ({ presets: mockPresets, phases: { @@ -205,7 +208,7 @@ describe("MotionPresetSection CapCut Parity & Kinematics", () => { expect(screen.getByRole("tab", { name: /Loop/i })).toBeDefined(); // In is active by default, showing starter presets - expect(screen.getByText("None")).toBeDefined(); + expect(screen.getAllByText("None").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Typewriter")).toBeDefined(); expect(screen.getByText("Slide Up")).toBeDefined(); expect(screen.getByText("Zoom In")).toBeDefined(); @@ -231,6 +234,31 @@ describe("MotionPresetSection CapCut Parity & Kinematics", () => { expect(payload.visualKeyframes.opacity).toBeDefined(); }); + it("clears legacy entranceAnimation and exitAnimation when compiling motion presets on a text clip", () => { + const handleUpdateMultiple = vi.fn(); + const clipWithLegacyAnimations: TextClip = { + ...mockClip, + entranceAnimation: { type: "slide-up", duration: 0.5, easing: "ease-out" }, + exitAnimation: { type: "slide-down", duration: 0.5, easing: "ease-in" }, + }; + + render( + , + ); + + const typewriterCard = screen.getByText("Typewriter"); + fireEvent.click(typewriterCard); + + expect(handleUpdateMultiple).toHaveBeenCalledTimes(1); + const payload = handleUpdateMultiple.mock.calls[0][0]; + expect(payload.motion.inPreset).toBe("typewriter"); + expect(payload.entranceAnimation).toBeUndefined(); + expect(payload.exitAnimation).toBeUndefined(); + }); + it("filters presets using search bar", () => { const handleUpdateMultiple = vi.fn(); render( @@ -253,7 +281,7 @@ describe("MotionPresetSection CapCut Parity & Kinematics", () => { expect(screen.queryByText("Typewriter")).toBeNull(); }); - it("filters presets using category pills", () => { + it("filters presets using favorites toggle", () => { const handleUpdateMultiple = vi.fn(); render( { />, ); - // Click "3D" pill - const threeDPill = screen.getByText("3D"); - fireEvent.click(threeDPill); + // Filter by favorites before any favorite exists + const favButton = screen.getByRole("button", { name: /Favorites/i }); + fireEvent.click(favButton); - expect(screen.getByText("Flip In")).toBeDefined(); + // None card is always present, but no preset cards match favorites + expect(screen.getAllByText("None").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("Slide Up")).toBeNull(); expect(screen.queryByText("Typewriter")).toBeNull(); + + // Toggle off favorites filter + fireEvent.click(favButton); + expect(screen.getByText("Typewriter")).toBeDefined(); + expect(screen.getByText("Slide Up")).toBeDefined(); }); - it("renders pinned duration bar with stepper and slider when preset is active", () => { + it("keeps all three tab grids mounted in DOM with CSS visibility toggling", () => { const handleUpdateMultiple = vi.fn(); + const { container } = render( + , + ); + + // Check containers: one has 'contents', two have 'hidden' + const contentsContainers = container.querySelectorAll(".contents"); + const hiddenContainers = container.querySelectorAll(".hidden"); + expect(contentsContainers.length).toBe(1); + expect(hiddenContainers.length).toBe(2); + + // Switch to Out tab + const outTab = screen.getByRole("tab", { name: /Out/i }); + fireEvent.click(outTab); + + // Still exactly 1 contents and 2 hidden + const updatedContents = container.querySelectorAll(".contents"); + const updatedHidden = container.querySelectorAll(".hidden"); + expect(updatedContents.length).toBe(1); + expect(updatedHidden.length).toBe(2); + }); + + it("delegates duration adjustment to panel footer and respects controlled tab props", () => { + const handleUpdateMultiple = vi.fn(); + const onTabChange = vi.fn(); const clipWithMotion: TextClip = { ...mockClip, motion: { @@ -284,24 +346,18 @@ describe("MotionPresetSection CapCut Parity & Kinematics", () => { , ); - // Duration bar is visible - expect(screen.getByText("Duration")).toBeDefined(); - expect(screen.getByText("0.5s")).toBeDefined(); - - // Click up arrow to step duration - const slider = screen.getByRole("slider"); - expect(slider).toBeDefined(); - fireEvent.change(slider, { target: { value: "0.8" } }); - - expect(handleUpdateMultiple).toHaveBeenCalled(); - const lastCall = - handleUpdateMultiple.mock.calls[ - handleUpdateMultiple.mock.calls.length - 1 - ][0]; - expect(lastCall.motion.inDuration).toBe(0.8); + // Duration is now rendered in the sticky footer of PropertiesPanel, not inlined in the section + expect(screen.queryByText("Duration")).toBeNull(); + + // Clicking out tab calls onTabChange + const outButton = screen.getByRole("tab", { name: "Out" }); + fireEvent.click(outButton); + expect(onTabChange).toHaveBeenCalledWith("out"); }); it("switches to Loop tab and provides speed and intensity controls", () => { @@ -335,4 +391,31 @@ describe("MotionPresetSection CapCut Parity & Kinematics", () => { expect(screen.getByText("Speed")).toBeDefined(); expect(screen.getByText("Intensity")).toBeDefined(); }); + + it("only enables useAnimationPhase for tabs that have been visited (lazy gating)", () => { + useAnimationPhaseMock.mockClear(); + const handleUpdateMultiple = vi.fn(); + render( + , + ); + + // Initial render: 'in' is enabled (true), while 'out' and 'loop' were not visited (false) + expect(useAnimationPhaseMock).toHaveBeenCalledWith("in", true); + expect(useAnimationPhaseMock).toHaveBeenCalledWith("out", false); + expect(useAnimationPhaseMock).toHaveBeenCalledWith("loop", false); + + useAnimationPhaseMock.mockClear(); + + // Click Out tab + const outTab = screen.getByRole("tab", { name: /Out/i }); + fireEvent.click(outTab); + + // Now 'in' remains enabled (true) and 'out' became enabled (true); 'loop' is still unvisited (false) + expect(useAnimationPhaseMock).toHaveBeenCalledWith("in", true); + expect(useAnimationPhaseMock).toHaveBeenCalledWith("out", true); + expect(useAnimationPhaseMock).toHaveBeenCalledWith("loop", false); + }); }); diff --git a/src/components/editor/properties/__tests__/PropertiesLockIntegrity.test.tsx b/src/components/editor/properties/__tests__/PropertiesLockIntegrity.test.tsx index 313213ecf..4cd427da5 100644 --- a/src/components/editor/properties/__tests__/PropertiesLockIntegrity.test.tsx +++ b/src/components/editor/properties/__tests__/PropertiesLockIntegrity.test.tsx @@ -32,6 +32,11 @@ vi.mock("@/hooks/usePlaybackClock", () => ({ time: 0, seek: vi.fn(), }), + usePlaybackClock: () => ({ + time: 0, + isPlaying: false, + isScrubbing: false, + }), })); vi.mock("@/core/playback/PlaybackClock", () => ({ diff --git a/src/components/editor/properties/__tests__/PropertyPanelHeader.test.tsx b/src/components/editor/properties/__tests__/PropertyPanelHeader.test.tsx new file mode 100644 index 000000000..f5d4867fd --- /dev/null +++ b/src/components/editor/properties/__tests__/PropertyPanelHeader.test.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import PropertyPanelHeader from "../core/PropertyPanelHeader"; +import type { TextPropertyTab, VideoPropertyTab, ImagePropertyTab, AudioPropertyTab } from "../types"; + +vi.mock("@/i18n", () => ({ + useI18n: () => ({ + t: (key: string) => { + const dict: Record = { + "properties.tabs.text": "Text", + "properties.tabs.animation": "Animation", + "properties.tabs.video": "Video", + "properties.tabs.audio": "Audio", + "properties.tabs.image": "Image", + "properties.tabs.basic": "Basic", + "properties.tabs.speed": "Speed", + "properties.tabs.adjust": "Adjust", + "properties.tabs.voiceChanger": "Voice changer", + }; + return dict[key] || key; + }, + }), +})); + +describe("PropertyPanelHeader: Text Template & Clip Kind Tab Suite", () => { + it("renders Text and Animation tabs and sets active tab to 'text' for text-template clips", () => { + let activeTab: TextPropertyTab | null = null; + const setActiveTab = vi.fn((tab) => { + activeTab = tab; + }); + + const { rerender } = render( + , + ); + + // Initial effect sets activePropertyTab + expect(setActiveTab).toHaveBeenCalledWith("text"); + + // Re-render with activePropertyTab now populated as in real parent + rerender( + , + ); + + // Verify tabs are rendered + const textTabButton = screen.getByRole("button", { name: /^Text$/i }); + const animationTabButton = screen.getByRole("button", { name: /^Animation$/i }); + expect(textTabButton).toBeInTheDocument(); + expect(animationTabButton).toBeInTheDocument(); + + // Clicking animation tab calls setActivePropertyTab with "animation" + fireEvent.click(animationTabButton); + expect(setActiveTab).toHaveBeenCalledWith("animation"); + }); + + it("renders standard text tabs for plain text clips", () => { + const setActiveTab = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^Text$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Animation$/i })).toBeInTheDocument(); + }); + + it("renders video tabs including audio when hasAudioTrack=true", () => { + const setActiveTab = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^Video$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Audio$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Speed$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Adjust$/i })).toBeInTheDocument(); + }); + + it("omits audio tab when video has no audio track (hasAudioTrack=false)", () => { + const setActiveTab = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^Video$/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Audio$/i })).not.toBeInTheDocument(); + }); + + it("renders audio tabs (Basic, Speed, Voice changer) for audio clips", () => { + const setActiveTab = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^Basic$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Speed$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Voice changer$/i })).toBeInTheDocument(); + }); + + it("renders image tabs (Image, Animation, Adjust) for image clips", () => { + const setActiveTab = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^Image$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Animation$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Adjust$/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/editor/properties/audio/sections/AudioSection.tsx b/src/components/editor/properties/audio/sections/AudioSection.tsx index 8208ae1ec..6817cb414 100644 --- a/src/components/editor/properties/audio/sections/AudioSection.tsx +++ b/src/components/editor/properties/audio/sections/AudioSection.tsx @@ -230,9 +230,20 @@ export const AudioSection: React.FC = ({ <> {/* Volume */}
- +
+ + +
{/* Slider wrapper — overlays a tick mark at the 0 dB position (75% along the -60→+20 range) */}
@@ -319,9 +330,20 @@ export const AudioSection: React.FC = ({ {/* Fade In */}
- +
+ + +
= ({ {/* Fade Out */}
- +
+ + +
= ({
{/* Volume */}
- +
+ + +
= ({ {/* Fade in */}
- +
+ + +
= ({ {/* Fade out */}
- +
+ + +
= ({ />
+ + {/* Audio FX Section */} +
); }; diff --git a/src/components/editor/properties/core/PropertiesPanel.tsx b/src/components/editor/properties/core/PropertiesPanel.tsx index 59bf35f93..90c3eea24 100644 --- a/src/components/editor/properties/core/PropertiesPanel.tsx +++ b/src/components/editor/properties/core/PropertiesPanel.tsx @@ -8,7 +8,10 @@ import { useI18n } from "@/i18n"; import { UnlinkAudioCommand } from "@/core/history/commands/UnlinkAudioCommand"; import { supportsVisualAnimation } from "@/lib/timeline/timelineClip"; import { getClipTypeInfo } from "@/lib/timeline/clipPropertyTransform"; -import type { TextClip, MediaAsset } from "@/types"; +import type { TextClip, MediaAsset, ClipMotionConfig } from "@/types"; +import { compileClipMotionKeyframes } from "@/core/animation/motionPresets"; +import { getCatalogSnapshot } from "@/features/media-animations/mediaAnimationCatalog"; +import { getTextAnimationCatalogSnapshot } from "@/features/text-animations/animationCatalog"; import { usePropertyEditCoordinator } from "@/hooks/usePropertyEditCoordinator"; import { useClipPropertyHandlers } from "@/hooks/useClipPropertyHandlers"; import { EmptyPropertiesState } from "./EmptyPropertiesState"; @@ -55,6 +58,7 @@ export const PropertiesPanel: React.FC = ({ | AudioPropertyTab | null >(null); + const [animationSubTab, setAnimationSubTab] = useState("in"); const [newPresetName, setNewPresetName] = useState(""); // ── Clip resolution ────────────────────────────────────────────────────── const selectedClipId = selectedClipIds[0] ?? null; @@ -260,28 +264,35 @@ export const PropertiesPanel: React.FC = ({ executePreviewCommand={coordinator.executePreviewCommand} savePreset={savePreset} deletePreset={deletePreset} + animationSubTab={animationSubTab} + onAnimationSubTabChange={setAnimationSubTab} /> )} {/* ── Properties panel footer ───────────────────────────────────── - When on the animation tab with an active In/Out media animation - preset, show the duration control instead of Save as Preset. + When on the animation tab with an active In/Out animation preset + (media or text), show the duration control instead of Save as Preset. Otherwise show the standard Reset / Save as Preset buttons. ── */} {(() => { const isAnimationTab = activePropertyTab === "animation"; - const isMediaAnimClip = !!isVideoClip || !!isImageClip; + const isAnimatableClip = !!isVideoClip || !!isImageClip || !!isTextClip; const motion = selectedClip?.motion; const activeInPreset = motion?.inPreset && motion.inPreset !== "none"; const activeOutPreset = motion?.outPreset && motion.outPreset !== "none"; + // Determine whether to target in or out preset based on active animation sub-tab. + // Fall back to whichever preset is active if user hasn't explicitly picked a sub-tab yet. + const tab = + animationSubTab === "out" + ? (activeOutPreset ? "out" : activeInPreset ? "in" : "out") + : (activeInPreset ? "in" : activeOutPreset ? "out" : "in"); + const hasActivePreset = tab === "out" ? activeOutPreset : activeInPreset; const showDurationControl = isAnimationTab && - isMediaAnimClip && - (activeInPreset || activeOutPreset); + isAnimatableClip && + (animationSubTab === "in" || animationSubTab === "out" || !animationSubTab) && + hasActivePreset; if (showDurationControl && selectedClip) { - // Determine which preset tab is active based on what's set. - // Prefer "in" if both are set (In tab is default). - const tab = activeInPreset ? "in" : "out"; const currentDuration = tab === "in" ? (motion?.inDuration ?? 0.5) @@ -291,16 +302,75 @@ export const PropertiesPanel: React.FC = ({ Math.min(5.0, selectedClip.duration), ); + const updateDuration = (nextDuration: number) => { + const nextMotion: ClipMotionConfig = + tab === "in" + ? { ...motion, inDuration: nextDuration } + : { ...motion, outDuration: nextDuration }; + const catalog = isTextClip + ? getTextAnimationCatalogSnapshot().presets + : getCatalogSnapshot().presets; + const compiledKeyframes = compileClipMotionKeyframes( + selectedClip, + nextMotion, + canvasWidth, + canvasHeight, + catalog, + ); + const updateFn = isTextClip + ? handlers.handleUpdateMultipleImmediate + : handlers.handleUpdateMultiple; + updateFn({ + motion: nextMotion, + visualKeyframes: compiledKeyframes, + ...(isTextClip + ? { entranceAnimation: undefined, exitAnimation: undefined } + : {}), + }); + }; + const step = (delta: number) => { const next = Math.max( 0.1, Math.min(maxDuration, +(currentDuration + delta).toFixed(2)), ); - handlers.handleUpdateMultiple( - tab === "in" - ? { motion: { ...motion, inDuration: next } } - : { motion: { ...motion, outDuration: next } }, - ); + updateDuration(next); + }; + + const handleTrackPointerDown = ( + e: React.PointerEvent, + ) => { + const track = e.currentTarget; + track.setPointerCapture(e.pointerId); + const updateFromPointer = (clientX: number) => { + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + const val = Math.max( + 0.1, + Math.min(maxDuration, +(ratio * maxDuration).toFixed(2)), + ); + updateDuration(val); + }; + updateFromPointer(e.clientX); + + const onPointerMove = (moveEvent: PointerEvent) => { + updateFromPointer(moveEvent.clientX); + }; + const onPointerUp = (upEvent: PointerEvent) => { + try { + track.releasePointerCapture(upEvent.pointerId); + } catch {} + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); }; return ( @@ -312,8 +382,9 @@ export const PropertiesPanel: React.FC = ({
- {/* Slider track */} + {/* Slider track with click & drag scrubbing */}
{ - const rect = e.currentTarget.getBoundingClientRect(); - const ratio = (e.clientX - rect.left) / rect.width; - const val = Math.max( - 0.1, - Math.min(maxDuration, +(ratio * maxDuration).toFixed(2)), - ); - handlers.handleUpdateMultiple( - tab === "in" - ? { motion: { ...motion, inDuration: val } } - : { motion: { ...motion, outDuration: val } }, - ); - }} + className="relative h-1.5 rounded-full bg-white/10 cursor-pointer select-none touch-none" + onPointerDown={handleTrackPointerDown} >
, ) => void; deletePreset: (id: string) => void; + animationSubTab?: string; + onAnimationSubTabChange?: (tab: string) => void; } const PropertyPanelBody: React.FC = ({ selectedClip, @@ -101,6 +103,8 @@ const PropertyPanelBody: React.FC = ({ executePreviewCommand, savePreset, deletePreset, + animationSubTab, + onAnimationSubTabChange, }) => { return (
@@ -173,6 +177,8 @@ const PropertyPanelBody: React.FC = ({ handleUpdateMultiple={handleUpdateMultiple} canvasWidth={canvasWidth} canvasHeight={canvasHeight} + activeTab={animationSubTab as any} + onTabChange={onAnimationSubTabChange as any} /> )} {/* Adjust Tab: Color Adjustments */} @@ -201,6 +207,8 @@ const PropertyPanelBody: React.FC = ({ handleUpdateMultiple={handleUpdateMultiple} canvasWidth={canvasWidth} canvasHeight={canvasHeight} + activeTab={animationSubTab as any} + onTabChange={onAnimationSubTabChange as any} /> )} {/* Image Adjust Tab */} @@ -251,6 +259,8 @@ const PropertyPanelBody: React.FC = ({ handleUpdateMultiple={handleUpdateMultiple} canvasWidth={canvasWidth} canvasHeight={canvasHeight} + activeTab={animationSubTab as any} + onTabChange={onAnimationSubTabChange as any} /> )} {/* Text Tracking */} diff --git a/src/components/editor/properties/core/PropertyPanelHeader.tsx b/src/components/editor/properties/core/PropertyPanelHeader.tsx index 32dc5d22b..2eb248337 100644 --- a/src/components/editor/properties/core/PropertyPanelHeader.tsx +++ b/src/components/editor/properties/core/PropertyPanelHeader.tsx @@ -37,8 +37,9 @@ const PropertyPanelHeader: React.FC = ({ setActivePropertyTab, hasAudioTrack = true, }) => { + const isText = clipKind === "text" || clipKind === "text-template"; const allTabs: PropertyTabsType[] = - clipKind === "text" + isText ? TEXT_TABS : clipKind === "video" ? VIDEO_TABS diff --git a/src/components/editor/properties/shared/MotionPresetSection.tsx b/src/components/editor/properties/shared/MotionPresetSection.tsx index f3edf875c..7c76a359a 100644 --- a/src/components/editor/properties/shared/MotionPresetSection.tsx +++ b/src/components/editor/properties/shared/MotionPresetSection.tsx @@ -1,61 +1,33 @@ /** * Motion Preset & Kinetic Behavior Inspector Section. * - * Provides CapCut-parity animation browsing and preset compilation: - * - 5-column square card grid with live hover previews and cyan active border + * Provides CapCut-parity animation browsing and preset compilation for text clips: * - In, Out, Loop segmented tabs - * - Real-time search across names, tags, and categories - * - Category filter pills (★ Favorites, All, Basic, Motion, 3D, Glitch) - * - Pinned bottom duration slider and numeric stepper + * - Real-time search across names, tags, and descriptions + * - Favorites toggle filter (All vs. Favorites) + * - All three PhaseGrids (in/out/loop) are always mounted; the active tab uses + * `contents` display while inactive use `hidden`, preserving fetch lifecycles and scroll + * - Gated lazy fetching on first tab visit via visitedTabs set + * - Pinned bottom duration slider and numeric stepper for In/Out + * - Loop speed and intensity controls for Loop * - Compiles high-level presets into responsive visualKeyframes */ -import React, { useState, useCallback, useMemo, useEffect } from "react"; -import { - Sparkles, - Zap, - Activity, - ArrowUp, - ArrowDown, - ArrowLeft, - ArrowRight, - ZoomIn, - ZoomOut, - Sun, - Moon, - FastForward, - Minimize2, - Download, - Waves, - Heart, - Shuffle, - Compass, - Ban, - Clock, - Gauge, - Search, - X, - Star, - RotateCw, - RotateCcw, - Type, - ChevronUp, - ChevronDown, -} from "lucide-react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { Gauge, Activity, Search, X, Star } from "lucide-react"; import type { Clip } from "@/types"; -import type { - ClipMotionConfig, - MotionBehaviorCategory, - MotionSubCategory, -} from "@/types/motion"; +import type { ClipMotionConfig, MotionBehaviorCategory } from "@/types/motion"; import { compileClipMotionKeyframes } from "@/core/animation/motionPresets"; import { - useAnimationPhase, useTextAnimationCatalog, downloadAnimationPreset, + getTextAnimationPreset, } from "@/features/text-animations/animationCatalog"; +import { textAnimationCacheManager } from "@/features/text-animations/cache/textAnimationCache"; import { useI18n } from "@/i18n"; -import { PropertySection } from "../primitives/PropertySection"; +import PhaseGrid, { + type CategoryFilter, +} from "@/features/text-animations/PhaseGrid"; import { SliderTrack } from "../primitives/SliderTrack"; import { NumericControl } from "../primitives/NumericControl"; @@ -64,6 +36,8 @@ interface MotionPresetSectionProps { handleUpdateMultiple: (fields: Record) => void; canvasWidth?: number; canvasHeight?: number; + activeTab?: MotionBehaviorCategory; + onTabChange?: (tab: MotionBehaviorCategory) => void; } const FAVORITES_STORAGE_KEY = "clypra_favorite_animation_presets"; @@ -88,47 +62,87 @@ const saveStoredFavorites = (favs: Set) => { } }; -const ICON_MAP: Record> = { - Zap, - Activity, - ArrowUp, - ArrowDown, - ArrowLeft, - ArrowRight, - ZoomIn, - ZoomOut, - Sun, - Moon, - FastForward, - Minimize2, - Download, - Waves, - Heart, - Shuffle, - Compass, - RotateCw, - RotateCcw, - Type, -}; - -type CategoryFilter = "all" | "favorites" | MotionSubCategory; - export const MotionPresetSection: React.FC = ({ selectedClip, handleUpdateMultiple, canvasWidth = 1920, canvasHeight = 1080, + activeTab: controlledActiveTab, + onTabChange, }) => { const { t } = useI18n(); - const [activeTab, setActiveTab] = useState("in"); + const [internalActiveTab, setInternalActiveTab] = + useState("in"); + const activeTab = controlledActiveTab ?? internalActiveTab; + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([controlledActiveTab ?? "in"]), + ); + + useEffect(() => { + if (controlledActiveTab) { + setVisitedTabs((prev) => { + if (prev.has(controlledActiveTab)) return prev; + const next = new Set(prev); + next.add(controlledActiveTab); + return next; + }); + } + }, [controlledActiveTab]); + + const handleTabChange = useCallback( + (tab: MotionBehaviorCategory) => { + if (onTabChange) { + onTabChange(tab); + } else { + setInternalActiveTab(tab); + } + setVisitedTabs((prev) => { + if (prev.has(tab)) return prev; + const next = new Set(prev); + next.add(tab); + return next; + }); + }, + [onTabChange], + ); + const [searchQuery, setSearchQuery] = useState(""); - const [activeCategory, setActiveCategory] = useState("all"); + const [activeFilter, setActiveFilter] = useState("all"); const [favorites, setFavorites] = useState>(() => getStoredFavorites(), ); + const [downloadingIds, setDownloadingIds] = useState>( + () => new Set(), + ); + const [downloadedIds, setDownloadedIds] = useState>(() => { + const keys = Array.from(textAnimationCacheManager.manifests.keys()).map( + (k) => k.split("@")[0], + ); + return new Set(keys); + }); + + useEffect(() => { + return textAnimationCacheManager.onHydrated(() => { + setDownloadedIds((prev) => { + const keys = Array.from(textAnimationCacheManager.manifests.keys()).map( + (k) => k.split("@")[0], + ); + return new Set([...prev, ...keys]); + }); + }); + }, []); + const animationCatalog = useTextAnimationCatalog(); - // Phase-specific snapshot — triggers a lazy fetch the first time each tab is opened. - const phaseData = useAnimationPhase(activeTab); + + const motion: ClipMotionConfig = selectedClip.motion ?? {}; + const hasIn = Boolean(motion.inPreset && motion.inPreset !== "none"); + const hasOut = Boolean(motion.outPreset && motion.outPreset !== "none"); + const hasLoop = Boolean(motion.loopPreset && motion.loopPreset !== "none"); + + const latestSelectedClipRef = useRef(selectedClip); + latestSelectedClipRef.current = selectedClip; + const latestMotionRef = useRef(motion); + latestMotionRef.current = motion; const toggleFavorite = useCallback((id: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -144,117 +158,128 @@ export const MotionPresetSection: React.FC = ({ }); }, []); - const motion: ClipMotionConfig = selectedClip.motion ?? {}; - const hasIn = Boolean(motion.inPreset && motion.inPreset !== "none"); - const hasOut = Boolean(motion.outPreset && motion.outPreset !== "none"); - const hasLoop = Boolean(motion.loopPreset && motion.loopPreset !== "none"); - const updateMotion = useCallback( (updates: Partial) => { const nextMotion: ClipMotionConfig = { - ...motion, + ...latestMotionRef.current, ...updates, }; + latestMotionRef.current = nextMotion; + + const mergedCatalog = animationCatalog.presets.map((p) => { + const merged = getTextAnimationPreset(p.id); + return merged ?? p; + }); + const currentClip = latestSelectedClipRef.current; const compiledKeyframes = compileClipMotionKeyframes( - selectedClip, + currentClip, nextMotion, canvasWidth, canvasHeight, - animationCatalog.presets, + mergedCatalog, ); handleUpdateMultiple({ motion: nextMotion, visualKeyframes: compiledKeyframes, + ...(currentClip.kind === "text" + ? { entranceAnimation: undefined, exitAnimation: undefined } + : {}), }); }, - [ - motion, - selectedClip, - canvasWidth, - canvasHeight, - handleUpdateMultiple, - animationCatalog.presets, - ], + [canvasWidth, canvasHeight, handleUpdateMultiple, animationCatalog.presets], ); - // Active catalog comes directly from the per-phase snapshot — no redundant filter. - const currentCatalog = phaseData.presets; - - // Filter presets based on category and search query - const filteredPresets = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - return currentCatalog.filter((preset) => { - // Category filter - if (activeCategory === "favorites" && !favorites.has(preset.id)) { - return false; - } - if ( - activeCategory !== "all" && - activeCategory !== "favorites" && - preset.subCategory !== activeCategory - ) { - return false; + const handleDownload = useCallback( + async ( + phase: MotionBehaviorCategory, + presetId: string, + e?: React.MouseEvent, + motionOverride?: ClipMotionConfig, + ) => { + e?.stopPropagation(); + if (downloadingIds.has(presetId)) return; + const preset = getTextAnimationPreset(presetId); + const version = preset?.revisionId; + if (!version) { + const updates: Partial = + phase === "in" + ? { inPreset: presetId } + : phase === "out" + ? { outPreset: presetId } + : { loopPreset: presetId }; + updateMotion(updates); + return; } - - // Query filter - if (q) { - const matchesName = preset.name.toLowerCase().includes(q); - const matchesDesc = preset.description.toLowerCase().includes(q); - const matchesTags = preset.tags?.some((tag) => - tag.toLowerCase().includes(q), + setDownloadingIds((prev) => new Set(prev).add(presetId)); + try { + await downloadAnimationPreset(preset); + setDownloadedIds((prev) => new Set(prev).add(presetId)); + + const targetMotion: ClipMotionConfig = + motionOverride ?? latestMotionRef.current; + const isActivePreset = + targetMotion.inPreset === presetId || + targetMotion.outPreset === presetId || + targetMotion.loopPreset === presetId; + if (!isActivePreset && !motionOverride) return; + + const currentMotion: ClipMotionConfig = { ...targetMotion }; + const freshCatalog = animationCatalog.presets.map((p) => { + const cached = getTextAnimationPreset(p.id); + return cached ?? p; + }); + const currentClip = latestSelectedClipRef.current; + const recompiledKeyframes = compileClipMotionKeyframes( + currentClip, + currentMotion, + canvasWidth, + canvasHeight, + freshCatalog, ); - if (!matchesName && !matchesDesc && !matchesTags) { - return false; - } + handleUpdateMultiple({ + motion: currentMotion, + visualKeyframes: recompiledKeyframes, + ...(currentClip.kind === "text" + ? { entranceAnimation: undefined, exitAnimation: undefined } + : {}), + }); + } catch (err) { + console.error("[MotionPresetSection] Download failed:", err); + } finally { + setDownloadingIds((prev) => { + const next = new Set(prev); + next.delete(presetId); + return next; + }); } - - return true; - }); - }, [currentCatalog, activeCategory, searchQuery, favorites]); - - const activePresetId = - activeTab === "in" - ? motion.inPreset - : activeTab === "out" - ? motion.outPreset - : motion.loopPreset; - - const currentDuration = - activeTab === "in" - ? (motion.inDuration ?? 0.5) - : activeTab === "out" - ? (motion.outDuration ?? 0.5) - : 0.5; - - const maxAllowedDuration = Math.max( - 0.5, - Math.min(5.0, selectedClip.duration), + }, + [ + downloadingIds, + animationCatalog.presets, + canvasWidth, + canvasHeight, + handleUpdateMultiple, + updateMotion, + ], ); - const handleDurationStep = useCallback( - (delta: number) => { - const next = Math.max( - 0.1, - Math.min(maxAllowedDuration, +(currentDuration + delta).toFixed(2)), - ); - if (activeTab === "in") { - updateMotion({ inDuration: next }); - } else if (activeTab === "out") { - updateMotion({ outDuration: next }); - } + const presetIdForPhase = useCallback( + (phase: MotionBehaviorCategory): string | undefined => { + if (phase === "in") return motion.inPreset; + if (phase === "out") return motion.outPreset; + return motion.loopPreset; }, - [currentDuration, maxAllowedDuration, activeTab, updateMotion], + [motion.inPreset, motion.outPreset, motion.loopPreset], ); const handleSelectPreset = useCallback( - (id: string) => { + (phase: MotionBehaviorCategory, id: string) => { if (id === "none") { - // Clear the active preset for this phase without downloading anything. - if (activeTab === "in") { + if (phase === "in") { updateMotion({ inPreset: "none" }); - } else if (activeTab === "out") { + } else if (phase === "out") { updateMotion({ outPreset: "none" }); } else { updateMotion({ loopPreset: "none" }); @@ -262,27 +287,27 @@ export const MotionPresetSection: React.FC = ({ return; } - // Find the catalog-index entry so we have the revisionId for the revision route. - const catalogEntry = phaseData.presets.find((p) => p.id === id); + const updates: Partial = + phase === "in" + ? { inPreset: id } + : phase === "out" + ? { outPreset: id } + : { loopPreset: id }; - // Apply the preset optimistically from the catalog index entry first so - // the UI responds immediately, then download the full revision and - // update the compiled keyframes once it arrives. - if (activeTab === "in") { - updateMotion({ inPreset: id }); - } else if (activeTab === "out") { - updateMotion({ outPreset: id }); - } else { - updateMotion({ loopPreset: id }); - } + const targetMotion: ClipMotionConfig = { + ...latestMotionRef.current, + ...updates, + }; + + updateMotion(updates); - // Download the immutable revision and cache it in-memory so the - // compositor can always resolve this preset's tracks for the session. - if (catalogEntry) { - void downloadAnimationPreset(catalogEntry); + const preset = getTextAnimationPreset(id); + const version = preset?.revisionId; + if (version && !textAnimationCacheManager.isCached(id, version)) { + void handleDownload(phase, id, undefined, targetMotion); } }, - [activeTab, updateMotion, phaseData.presets], + [updateMotion, handleDownload], ); return ( @@ -294,7 +319,7 @@ export const MotionPresetSection: React.FC = ({ role="tab" aria-selected={activeTab === "in"} aria-label="In" - onClick={() => setActiveTab("in")} + onClick={() => handleTabChange("in")} className={`flex-1 py-0.5 text-xs rounded-sm transition-all text-center capitalize flex items-center justify-center gap-1 ${ activeTab === "in" ? "bg-[#38383e] text-white shadow-sm font-semibold cursor-pointer" @@ -310,7 +335,7 @@ export const MotionPresetSection: React.FC = ({ role="tab" aria-selected={activeTab === "out"} aria-label="Out" - onClick={() => setActiveTab("out")} + onClick={() => handleTabChange("out")} className={`flex-1 py-0.5 text-xs rounded-sm transition-all text-center capitalize flex items-center justify-center gap-1 ${ activeTab === "out" ? "bg-[#38383e] text-white shadow-sm font-semibold cursor-pointer" @@ -326,7 +351,7 @@ export const MotionPresetSection: React.FC = ({ role="tab" aria-selected={activeTab === "loop"} aria-label="Loop" - onClick={() => setActiveTab("loop")} + onClick={() => handleTabChange("loop")} className={`flex-1 py-0.5 text-xs rounded-sm transition-all text-center capitalize flex items-center justify-center gap-1 ${ activeTab === "loop" ? "bg-[#38383e] text-white shadow-sm font-semibold cursor-pointer" @@ -340,289 +365,74 @@ export const MotionPresetSection: React.FC = ({
- {/* ── 2. CapCut Search Bar ────────────────────────────────────────────── */} -
- - setSearchQuery(e.target.value)} - placeholder={t("properties.animation.searchPlaceholder")} - className="bg-transparent text-[11px] text-text-primary placeholder:text-text-muted/60 outline-none w-full" - /> - {searchQuery && ( - - )} -
+ {/* ── 2. CapCut Search Bar & Favorites Toggle ─────────────────────────── */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t("properties.animation.searchPlaceholder")} + className="bg-transparent text-[11px] text-text-primary placeholder:text-text-muted/60 outline-none w-full" + /> + {searchQuery && ( + + )} +
- {/* ── 3. Category Filter Pills ────────────────────────────────────────── */} -
- {/* Favorite Star Pill */} - - {/* All Pill */} - - - {/* Basic Pill */} - - - {/* Motion Pill */} - - - {/* 3D Pill */} - - - {/* Glitch Pill */} -
- {/* ── 4. CapCut 5-Column Preset Card Grid ──────────────────────────────── */} - {phaseData.status === "error" && currentCatalog.length === 0 && ( -
- Animation catalog is unavailable. Reconnect to load published presets. -
- )} -
- {/* Card 0: None — always first */} - - - {/* Skeleton cards — shown while the phase catalog is loading */} - {phaseData.status === "loading" && - currentCatalog.length === 0 && - Array.from({ length: 9 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} - - {/* Preset Cards 1..N */} - {filteredPresets.map((preset) => { - const isSelected = activePresetId === preset.id; - const isFavorite = favorites.has(preset.id); - const IconComp = ICON_MAP[preset.icon] || Sparkles; - - return ( - - ); - })} -
- - {/* ── 5. CapCut Pinned Bottom Duration Bar (when In or Out preset is active) ── */} - {(activeTab === "in" ? hasIn : activeTab === "out" ? hasOut : false) && ( -
- - - {t("properties.animation.duration")} - - - {/* Stepper Input: [ 0.5s ↕ ] */} -
- - {currentDuration.toFixed(1)}s - -
- - -
-
- - {/* Continuous Slider Track */} - { - const val = parseFloat(e.target.value); - if (activeTab === "in") { - updateMotion({ inDuration: val }); - } else if (activeTab === "out") { - updateMotion({ outDuration: val }); - } - }} - className="flex-1 h-1 bg-[#333338] rounded-lg appearance-none cursor-pointer accent-[#00E5FF]" + handleSelectPreset(phase, id)} + onToggleFavorite={toggleFavorite} + onDownload={(id, e) => handleDownload(phase, id, e)} />
- )} + ))} - {/* ── 6. Loop Speed & Intensity Controls (when Loop tab is active and has loop) ── */} + {/* ── 4. Loop Speed & Intensity Controls (when Loop tab is active and has loop) ── */} {activeTab === "loop" && hasLoop && (
{/* Speed control */} @@ -688,3 +498,5 @@ export const MotionPresetSection: React.FC = ({
); }; + +export default MotionPresetSection; diff --git a/src/components/editor/sidebar/Sidebar.tsx b/src/components/editor/sidebar/Sidebar.tsx index 9807dae26..ed1fa6165 100644 --- a/src/components/editor/sidebar/Sidebar.tsx +++ b/src/components/editor/sidebar/Sidebar.tsx @@ -21,6 +21,8 @@ import { SmartOverlaysTab } from "./tabs/SmartOverlaysTab"; import { type TabType, type MediaTabProps } from "./types"; import { EffectsPanel } from "@/features/video-effects/components/EffectsPanel"; import { TextIcon, YouTubeIcon } from "@/components/ui/icons"; +import { FeaturePlaceholder } from "@/components/ui/FeaturePlaceholder"; +import { SIDEBAR_PLACEHOLDERS } from "./placeholders"; const SIDEBAR_TABS = [ { id: "media" as const, icon: YouTubeIcon, label: "Media" }, @@ -152,32 +154,38 @@ const SidebarComponent: React.FC = ({
) : (
- {activeTab === "media" && ( - - )} - {activeTab === "audio" && ( - - )} - {activeTab === "text" && ( - - )} - {activeTab === "smart-overlays" && ( - - )} - {activeTab === "stickers" && ( - - )} - {activeTab === "effects" && ( - - )} - {activeTab === "filters" && ( - - )} - {activeTab === "transitions" && ( - - )} - {activeTab === "captions" && ( - + {SIDEBAR_PLACEHOLDERS[activeTab] ? ( + + ) : ( + <> + {activeTab === "media" && ( + + )} + {activeTab === "audio" && ( + + )} + {activeTab === "text" && ( + + )} + {activeTab === "smart-overlays" && ( + + )} + {activeTab === "stickers" && ( + + )} + {activeTab === "effects" && ( + + )} + {activeTab === "filters" && ( + + )} + {activeTab === "transitions" && ( + + )} + {activeTab === "captions" && ( + + )} + )}
)} diff --git a/src/components/editor/sidebar/__tests__/SidebarPlaceholders.test.tsx b/src/components/editor/sidebar/__tests__/SidebarPlaceholders.test.tsx new file mode 100644 index 000000000..722b1c7c6 --- /dev/null +++ b/src/components/editor/sidebar/__tests__/SidebarPlaceholders.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import React from "react"; +import { Sidebar } from "../Sidebar"; + +// Mock heavier tabs to keep tests focused on placeholder routing +vi.mock("../tabs/MediaTab", () => ({ + MediaTab: () =>
Real Media Tab
, +})); + +vi.mock("../tabs/AudioTab", () => ({ + AudioTab: () =>
Real Audio Tab
, +})); + +vi.mock("../tabs/TextTab", () => ({ + TextTab: () =>
Real Text Tab
, +})); + +vi.mock("../tabs/CaptionsTab", () => ({ + CaptionsTab: () =>
Real Captions Tab
, +})); + +vi.mock("../tabs/SmartOverlaysTab", () => ({ + SmartOverlaysTab: () =>
Real Smart Overlays Tab
, +})); + +describe("Sidebar FeaturePlaceholders Integration", () => { + it("renders real MediaTab when initial tab is media", () => { + render(); + expect(screen.getByTestId("real-media-tab")).toBeDefined(); + expect(screen.queryByText("Stickers are on the way")).toBeNull(); + }); + + it("renders stickers placeholder when Stickers tab is clicked", () => { + render(); + const stickersTabButton = screen.getByRole("button", { name: /stickers/i }); + fireEvent.click(stickersTabButton); + + expect(screen.getByText("Stickers are on the way")).toBeDefined(); + expect( + screen.getByText("Add animated stickers and emojis to your clips."), + ).toBeDefined(); + expect(screen.queryByTestId("real-media-tab")).toBeNull(); + }); + + it("renders effects placeholder when Effects tab is clicked", () => { + render(); + const effectsTabButton = screen.getByRole("button", { name: /effects/i }); + fireEvent.click(effectsTabButton); + + expect(screen.getByText("Effects are in development")).toBeDefined(); + expect( + screen.getByText("Apply visual effects to clips and tracks."), + ).toBeDefined(); + }); + + it("renders filters placeholder when Filters tab is clicked", () => { + render(); + const filtersTabButton = screen.getByRole("button", { name: /filters/i }); + fireEvent.click(filtersTabButton); + + expect(screen.getByText("Filters are in development")).toBeDefined(); + expect( + screen.getByText("Colour looks you can apply in one click."), + ).toBeDefined(); + }); + + it("renders transitions placeholder when Transitions tab is clicked", () => { + render(); + const transitionsTabButton = screen.getByRole("button", { name: /transitions/i }); + fireEvent.click(transitionsTabButton); + + expect(screen.getByText("More transitions are coming")).toBeDefined(); + expect(screen.getByText("Smooth cuts between clips.")).toBeDefined(); + }); + + it("renders captions placeholder when Captions tab is clicked", () => { + render(); + const captionsTabButton = screen.getByRole("button", { name: /captions/i }); + fireEvent.click(captionsTabButton); + + expect(screen.getByText("Captions are planned")).toBeDefined(); + expect( + screen.getByText("Generate and style subtitles for your video."), + ).toBeDefined(); + expect(screen.queryByTestId("real-captions-tab")).toBeNull(); + }); + + it("keeps functional tabs active (Media, Audio, Text)", () => { + render(); + + // Audio + const audioTabButton = screen.getByRole("button", { name: /audio/i }); + fireEvent.click(audioTabButton); + expect(screen.getByTestId("real-audio-tab")).toBeDefined(); + + // Text + const textTabButton = screen.getByRole("button", { name: /text/i }); + fireEvent.click(textTabButton); + expect(screen.getByTestId("real-text-tab")).toBeDefined(); + }); +}); diff --git a/src/components/editor/sidebar/placeholders.ts b/src/components/editor/sidebar/placeholders.ts new file mode 100644 index 000000000..5d77ecbf4 --- /dev/null +++ b/src/components/editor/sidebar/placeholders.ts @@ -0,0 +1,85 @@ +import { Smile, Wand2, SlidersHorizontal, Shuffle, MessageSquare } from "lucide-react"; +import type { FeaturePlaceholderProps } from "@/components/ui/FeaturePlaceholder"; + +const openExternalUrl = async (url: string) => { + const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + if (isTauri) { + try { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(url); + return; + } catch (e) { + console.error("Tauri openUrl failed, falling back to window.open", e); + } + } + window.open(url, "_blank", "noopener,noreferrer"); +}; + +/** + * One config object per unfinished tab. When a tab's real panel ships, + * delete its entry and the fallback below stops applying to it. + */ +export const SIDEBAR_PLACEHOLDERS: Record = { + stickers: { + icon: Smile, + title: "Stickers are on the way", + description: "Add animated stickers and emojis to your clips.", + planned: [ + "Animated and static stickers", + "Search by name or category", + "Resize and rotate on the canvas", + ], + ghost: "grid", + action: { + label: "Follow progress", + onClick: () => openExternalUrl("https://github.com/AIEraDev/Clypra"), + }, + }, + effects: { + icon: Wand2, + title: "Effects are in development", + description: "Apply visual effects to clips and tracks.", + planned: [ + "Video effects with live preview", + "Adjustable intensity and timing", + ], + ghost: "grid", + action: { + label: "Follow progress", + onClick: () => openExternalUrl("https://github.com/AIEraDev/Clypra"), + }, + }, + filters: { + icon: SlidersHorizontal, + title: "Filters are in development", + description: "Colour looks you can apply in one click.", + planned: ["Preset looks", "Intensity slider"], + ghost: "grid", + action: { + label: "Follow progress", + onClick: () => openExternalUrl("https://github.com/AIEraDev/Clypra"), + }, + }, + transitions: { + icon: Shuffle, + title: "More transitions are coming", + description: "Smooth cuts between clips.", + status: "planned", + ghost: "list", + action: { + label: "Follow progress", + onClick: () => openExternalUrl("https://github.com/AIEraDev/Clypra"), + }, + }, + captions: { + icon: MessageSquare, + title: "Captions are planned", + description: "Generate and style subtitles for your video.", + status: "planned", + ghost: "list", + action: { + label: "Follow progress", + onClick: () => openExternalUrl("https://github.com/AIEraDev/Clypra"), + }, + }, +}; diff --git a/src/components/ui/FeaturePlaceholder.tsx b/src/components/ui/FeaturePlaceholder.tsx new file mode 100644 index 000000000..9b0298013 --- /dev/null +++ b/src/components/ui/FeaturePlaceholder.tsx @@ -0,0 +1,150 @@ +import type { ComponentType, ReactNode, SVGProps } from "react"; +import { useId } from "react"; + +/** + * FeaturePlaceholder + * ------------------ + * Drop-in body for any editor panel (Stickers, Effects, Filters, Transitions, + * Captions...) whose real functionality isn't ready yet. + * + * Theming: every colour reads a CSS variable first and falls back to the warm + * palette from the current panel, so you can wire it to your tokens later + * without touching this file. Suggested names: + * --panel-surface tile / card background + * --panel-border hairlines and dashed outlines + * --panel-text primary text + * --panel-muted secondary text + * --panel-accent orange accent + */ + +type IconType = ComponentType & { size?: number | string }>; + +export type FeatureStatus = "planned" | "in-progress" | "beta"; + +const STATUS_LABEL: Record = { + planned: "Planned", + "in-progress": "In development", + beta: "Almost ready", +}; + +export interface FeaturePlaceholderProps { + /** Lucide icon component (or any SVG component), e.g. `Smile`. */ + icon: IconType; + /** What the user will be able to do, e.g. "Stickers are on the way". */ + title: string; + /** One or two plain sentences. */ + description: string; + /** Short bullets of what's coming. Keep to 3-4. */ + planned?: string[]; + status?: FeatureStatus; + /** + * Optional action. Use onClick (not href) so you can route through Tauri's + * opener plugin, e.g. open the GitHub roadmap or issue tracker. + */ + action?: { label: string; onClick: () => void }; + /** Faint outline of the future layout behind the message. */ + ghost?: "grid" | "list" | "none"; + /** Extra content under the message, e.g. a version note. */ + footer?: ReactNode; + className?: string; +} + +export function FeaturePlaceholder({ + icon: Icon, + title, + description, + planned, + status = "in-progress", + action, + ghost = "grid", + footer, + className = "", +}: FeaturePlaceholderProps) { + const headingId = useId(); + + return ( +
+ {ghost !== "none" && } + +
+
+
+ + + + +

+ {title} +

+ +

+ {description} +

+ + {planned && planned.length > 0 && ( +
    + {planned.map((item) => ( +
  • +
  • + ))} +
+ )} + + {action && ( + + )} + + {footer && ( +
{footer}
+ )} +
+
+ ); +} + +/** Dashed outlines that hint at the layout the real panel will have. */ +function GhostLayout({ kind }: { kind: "grid" | "list" }) { + const tiles = kind === "grid" ? 9 : 5; + return ( + ); }; diff --git a/src/features/media-animations/__tests__/MediaAnimationSection.test.tsx b/src/features/media-animations/__tests__/MediaAnimationSection.test.tsx new file mode 100644 index 000000000..850c61022 --- /dev/null +++ b/src/features/media-animations/__tests__/MediaAnimationSection.test.tsx @@ -0,0 +1,187 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import React from "react"; +import { MediaAnimationSection } from "../MediaAnimationSection"; +import type { Clip } from "@/types"; + +const mockToast = vi.hoisted(() => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("@/lib/toast", () => ({ + toast: mockToast, +})); + +vi.mock("../cache/mediaAnimationCache", () => ({ + mediaAnimationCacheManager: { + initialize: vi.fn(async () => {}), + isCached: vi.fn(() => true), + manifests: new Map([["media-slide-in-left@1.6.1", {}]]), + onHydrated: vi.fn(() => () => {}), + }, +})); + +vi.mock("../mediaAnimationCatalog", async () => { + const actual = await vi.importActual( + "../mediaAnimationCatalog", + ); + return { + ...actual, + useMediaAnimationCategory: vi.fn((category: string) => { + if (category === "in") { + return { + presets: [ + { + id: "media-slide-in-left", + name: "Slide In Left", + phase: "in", + category: "in", + subCategory: "Motion", + tags: ["slide"], + description: "Slides in from the left", + defaultDuration: 0.5, + icon: "ArrowRight", + revisionId: "1.6.1", + tracks: [ + { + property: "translateX", + keyframes: [ + { progress: 0, value: -1815 }, + { progress: 1, value: 0 }, + ], + }, + ], + }, + ], + status: "ready", + error: null, + refetch: vi.fn(), + }; + } + return { presets: [], status: "ready", error: null, refetch: vi.fn() }; + }), + getMediaAnimationPreset: vi.fn((id: string) => { + if (id === "media-slide-in-left") { + return { + id: "media-slide-in-left", + name: "Slide In Left", + phase: "in", + category: "in", + subCategory: "Motion", + tags: ["slide"], + description: "Slides in from the left", + defaultDuration: 0.5, + icon: "ArrowRight", + revisionId: "1.6.1", + tracks: [ + { + property: "translateX", + keyframes: [ + { progress: 0, value: -1815 }, + { progress: 1, value: 0 }, + ], + }, + ], + }; + } + return undefined; + }), + useMediaAnimationCatalogSnapshot: vi.fn(() => ({ + presets: [], + phases: { in: [], out: [], combo: [] }, + status: "ready", + })), + }; +}); + +describe("MediaAnimationSection Toasts", () => { + const sampleClip: Clip = { + id: "clip-test-1", + name: "Test Video.mp4", + trackId: "track-1", + mediaId: "media-1", + startTime: 0, + duration: 10, + trimIn: 0, + trimOut: 10, + x: 0, + y: 0, + width: 1920, + height: 1080, + rotation: 0, + opacity: 1, + motion: { + inPreset: "none", + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows toast.info when 'None' is clicked to remove animation", () => { + const handleUpdateMultiple = vi.fn(); + render( + , + ); + + const noneButtons = screen.getAllByRole("button", { name: /none/i }); + fireEvent.click(noneButtons[0]); + + expect(mockToast.info).toHaveBeenCalledWith( + "properties.mediaAnimation.toastRemoved", + ); + }); + + it("shows toast.success with preset name when a preset is selected", () => { + const handleUpdateMultiple = vi.fn(); + render( + , + ); + + const presetCard = screen.getByText("Slide In Left"); + fireEvent.click(presetCard); + + expect(mockToast.success).toHaveBeenCalledWith( + "properties.mediaAnimation.toastApplied", + { + interpolation: { name: "Slide In Left" }, + }, + ); + }); + + it("filters presets using favorites toggle button", () => { + const handleUpdateMultiple = vi.fn(); + render( + , + ); + + // Filter by favorites before any favorite exists + const favButton = screen.getByRole("button", { name: /Favorites/i }); + expect(favButton).toBeDefined(); + + // Preset "Slide In Left" is initially visible + expect(screen.getByText("Slide In Left")).toBeDefined(); + + // Click favorites toggle + fireEvent.click(favButton); + + // "Slide In Left" is not in favorites, so it should be filtered out + expect(screen.queryByText("Slide In Left")).toBeNull(); + + // Toggle off favorites filter + fireEvent.click(favButton); + expect(screen.getByText("Slide In Left")).toBeDefined(); + }); +}); diff --git a/src/features/media-animations/__tests__/mediaAnimationCatalog.test.ts b/src/features/media-animations/__tests__/mediaAnimationCatalog.test.ts new file mode 100644 index 000000000..fd2dfbcab --- /dev/null +++ b/src/features/media-animations/__tests__/mediaAnimationCatalog.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + useMediaAnimationCatalogSnapshot, + getCatalogSnapshot, + notifyCatalogListeners, +} from "../mediaAnimationCatalog"; + +describe("Media Animation Catalog - useSyncExternalStore snapshot stability", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns a stable cached reference on consecutive calls when store has not changed", () => { + const snap1 = getCatalogSnapshot(); + const snap2 = getCatalogSnapshot(); + expect(snap1).toBe(snap2); + }); + + it("updates cached reference when notifyCatalogListeners is called and stabilizes on the new snapshot", () => { + const snap1 = getCatalogSnapshot(); + notifyCatalogListeners(); + const snap2 = getCatalogSnapshot(); + const snap3 = getCatalogSnapshot(); + + expect(snap1).not.toBe(snap2); + expect(snap2).toBe(snap3); + }); + + it("does not trigger getSnapshot caching errors or infinite loops when hooked into React", () => { + const errorSpy = vi.spyOn(console, "error"); + const { result, unmount } = renderHook(() => + useMediaAnimationCatalogSnapshot(), + ); + + expect(result.current).toBeDefined(); + expect(result.current.presets).toBeDefined(); + + // Verify React DEV did NOT log the getSnapshot caching warning + const infiniteLoopWarnings = errorSpy.mock.calls.filter((call) => + call.some( + (arg) => + typeof arg === "string" && + arg.includes("The result of getSnapshot should be cached"), + ), + ); + expect(infiniteLoopWarnings).toHaveLength(0); + + // Verify triggering store update notifies the hook properly without looping + act(() => { + notifyCatalogListeners(); + }); + + expect(result.current).toBe(getCatalogSnapshot()); + + unmount(); + }); +}); diff --git a/src/features/media-animations/cache/mediaAnimationCache.ts b/src/features/media-animations/cache/mediaAnimationCache.ts index 1b111fcd8..78e2d193b 100644 --- a/src/features/media-animations/cache/mediaAnimationCache.ts +++ b/src/features/media-animations/cache/mediaAnimationCache.ts @@ -15,6 +15,7 @@ import { remove, } from "@tauri-apps/plugin-fs"; import type { RemoteTextAnimationPreset } from "@/features/text-animations/animationCatalog"; +import { MediaAnimationTelemetry } from "../telemetry/mediaAnimationTelemetry"; // ─── Public types ───────────────────────────────────────────────────────────── @@ -93,6 +94,8 @@ class MediaAnimationCacheManager { async initialize(): Promise { if (this.initialized) return; + const t0 = performance.now(); + let errorCount = 0; try { const dirExists = await exists(CACHE_DIR, { baseDir: BaseDirectory.AppCache, @@ -105,7 +108,7 @@ class MediaAnimationCacheManager { } await this.loadIndex(); - await this.hydrateManifests(); + errorCount = await this.hydrateManifests(); this.initialized = true; } catch (error) { // Browser / web-preview environment — no Tauri FS available @@ -114,6 +117,14 @@ class MediaAnimationCacheManager { error, ); this.initialized = true; + } finally { + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: Math.round(performance.now() - t0), + manifestCount: this.manifests.size, + indexEntryCount: this.cacheIndex.size, + errorCount, + fromDiskCount: this.manifests.size, + }); } this.hydrateListeners.forEach((l) => l()); @@ -170,9 +181,11 @@ class MediaAnimationCacheManager { * Eagerly load all manifests referenced by the index into the in-memory * `manifests` map. This ensures that after an app restart, callers can * merge tracks immediately without an additional async read. + * Returns the number of index entries evicted due to missing or corrupt files. */ - private async hydrateManifests(): Promise { + private async hydrateManifests(): Promise { const entries = Array.from(this.cacheIndex.values()); + let errorCount = 0; await Promise.all( entries.map(async (entry) => { const cacheKey = `${entry.slug}@${entry.version}`; @@ -184,12 +197,15 @@ class MediaAnimationCacheManager { } else { // File missing or hash mismatch — remove stale index entry this.cacheIndex.delete(cacheKey); + errorCount++; } } catch { this.cacheIndex.delete(cacheKey); + errorCount++; } }), ); + return errorCount; } private async saveIndex(): Promise { @@ -272,17 +288,37 @@ class MediaAnimationCacheManager { if (this.cacheIndex.has(cacheKey)) { const entry = this.cacheIndex.get(cacheKey)!; + const t0 = performance.now(); const cached = await this.readAndVerifyManifest(entry); + const durationMs = Math.round(performance.now() - t0); if (cached) { this.manifests.set(cacheKey, cached); + MediaAnimationTelemetry.recordManifestDownload({ + slug, + cacheHit: true, + durationMs, + sizeBytes: entry.sizeBytes, + sha256VerifyMs: durationMs, + status: "success", + }); return cached; + } else { + MediaAnimationTelemetry.recordManifestDownload({ + slug, + cacheHit: true, + durationMs, + sizeBytes: entry.sizeBytes, + status: "hash-mismatch", + }); + // Index entry exists but file is missing or corrupt — fall through to re-download + this.cacheIndex.delete(cacheKey); } - // Index entry exists but file is missing or corrupt — fall through to re-download - this.cacheIndex.delete(cacheKey); } try { + const fetchStart = performance.now(); const manifest = await fetchFn(); + const fetchDurationMs = Math.round(performance.now() - fetchStart); const localPath = `media-animations/manifests/${slug}@${version}.json`; const json = JSON.stringify(manifest); @@ -290,6 +326,7 @@ class MediaAnimationCacheManager { let contentHash = manifest.contentHash ?? ""; + const writeStart = performance.now(); try { await writeFile(localPath, data, { baseDir: BaseDirectory.AppCache }); // Compute hash of what was actually written so verification on next @@ -303,6 +340,7 @@ class MediaAnimationCacheManager { fsError, ); } + const writeDurationMs = Math.round(performance.now() - writeStart); const entry: CachedManifestEntry = { slug, @@ -317,8 +355,26 @@ class MediaAnimationCacheManager { this.manifests.set(cacheKey, manifest); await this.saveIndex(); + MediaAnimationTelemetry.recordManifestDownload({ + slug, + cacheHit: false, + durationMs: fetchDurationMs + writeDurationMs, + fetchDurationMs, + writeDurationMs, + sizeBytes: data.byteLength, + status: "success", + }); + return manifest; } catch (error) { + MediaAnimationTelemetry.recordManifestDownload({ + slug, + cacheHit: false, + durationMs: 0, + sizeBytes: 0, + status: "error", + error: String(error), + }); console.error( `[MediaAnimationCache] Failed to download manifest for "${slug}@${version}":`, error, diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index 3751da41a..d5dfc74ea 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -1,21 +1,20 @@ /** - * mediaAnimationCatalog.ts — Media Animation Catalog Store + * mediaAnimationCatalog.ts * - * Fetches published media animation presets from the Clypra API and exposes - * them through the same external-store pattern used by animationCatalog.ts - * so that MediaAnimationSection can use the same compileClipMotionKeyframes - * compiler without any changes. + * Per-category fetch hook for the media animation catalog. + * Each CategoryGrid mounts with its own hook instance for isolated fetch state. * - * Categories: "in" | "out" | "combo" - * Track format returned by API: { property: "translateX"|"translateY", keyframes: [{ progress, value, easing }] } + * allPresets is a shared module-level map keyed by "id@category". A lightweight + * listener set (catalogListeners) lets useMediaAnimationCatalogSnapshot stay + * reactive — it re-renders MediaAnimationSection whenever new presets arrive so + * that updateMotion always sees a populated catalog when the user clicks a preset. */ -import { useEffect, useSyncExternalStore } from "react"; +import { useState, useEffect, useSyncExternalStore } from "react"; import { getApiBaseUrl, getApiHeaders } from "@/lib/api/apiUtils"; import type { MotionBehaviorCategory } from "@/types/motion"; import type { AnimationCatalogSnapshot, - PhaseSnapshot, RemoteTextAnimationPreset, } from "@/features/text-animations/animationCatalog"; import { @@ -23,6 +22,7 @@ import { type AnimationManifest, type MediaAnimationPreset, } from "./cache/mediaAnimationCache"; +import { MediaAnimationTelemetry } from "./telemetry/mediaAnimationTelemetry"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -34,112 +34,59 @@ export interface MediaAnimationCategorySnapshot { error?: string; } -// ─── Module-level state ──────────────────────────────────────────────────────── - -const IDLE: MediaAnimationCategorySnapshot = { presets: [], status: "idle" }; - -const categorySnapshots: Record< - MediaAnimationCategory, - MediaAnimationCategorySnapshot -> = { - in: { ...IDLE }, - out: { ...IDLE }, - combo: { ...IDLE }, -}; - -// Per-category immutable snapshot map — rebuilt on every emit() so each -// category's reference changes only when its data actually changes. -// Stored separately from mergedSnapshot so useMediaAnimationCategory can -// return a stable reference per category without going through phases. -const categorySnapshotRefs: Record< - MediaAnimationCategory, - MediaAnimationCategorySnapshot -> = { - in: { ...IDLE }, - out: { ...IDLE }, - combo: { ...IDLE }, -}; -const downloadedPresets = new Map(); -const categoryRequests: Partial>> = - {}; -const listeners = new Set<() => void>(); - -// Best-effort initialize the OS-level cache — no top-level await. -// After hydration completes, emit so consumers re-evaluate isCached() for all -// presets that were downloaded in a previous session. -mediaAnimationCacheManager - .initialize() - .then(() => emit()) - .catch(() => {}); - -// ─── Derived merged snapshot ───────────────────────────────────────────────── - -function buildMergedSnapshot(): AnimationCatalogSnapshot { +// ─── Shared preset map (for compiler lookups after manifest download) ───────── + +const allPresets = new Map(); + +// ─── Catalog subscription (keeps useMediaAnimationCatalogSnapshot reactive) ─── + +const catalogListeners = new Set<() => void>(); + +function buildCatalogSnapshot(): AnimationCatalogSnapshot { const seen = new Set(); const presets: RemoteTextAnimationPreset[] = []; - - for (const cat of ["in", "out", "combo"] as MediaAnimationCategory[]) { - for (const p of categorySnapshots[cat].presets) { - if (!seen.has(p.id)) { - seen.add(p.id); - presets.push(p); - } - } - } - for (const p of downloadedPresets.values()) { - if (!seen.has(p.id)) { - seen.add(p.id); - presets.push(p); + for (const preset of allPresets.values()) { + if (!seen.has(preset.id)) { + seen.add(preset.id); + presets.push(preset as unknown as RemoteTextAnimationPreset); } } - - const statuses = (["in", "out", "combo"] as MediaAnimationCategory[]).map( - (c) => categorySnapshots[c].status, - ); - const status: AnimationCatalogSnapshot["status"] = statuses.some( - (s) => s === "loading", - ) - ? "loading" - : statuses.some((s) => s === "ready") - ? "ready" - : statuses.some((s) => s === "error") - ? "error" - : "idle"; - - // phases maps the three media animation categories into the PhaseSnapshot - // shape. combo goes into the "loop" slot — "loop" is otherwise unused for - // media animations — so each category has its own independent slot and - // useMediaAnimationCategory can read the correct one per tab. - const phases: Record = { - in: { ...categorySnapshots.in }, - out: { ...categorySnapshots.out }, - loop: { ...categorySnapshots.combo }, // combo stored here, read via "loop" key + return { + presets, + phases: { + in: { + presets: presets.filter((p) => p.phase === "in"), + status: "ready", + }, + out: { + presets: presets.filter((p) => p.phase === "out"), + status: "ready", + }, + loop: { presets: [], status: "ready" }, + }, + status: "ready", }; +} + +let cachedCatalogSnapshot: AnimationCatalogSnapshot = buildCatalogSnapshot(); - return { presets, phases, status }; +export function notifyCatalogListeners(): void { + cachedCatalogSnapshot = buildCatalogSnapshot(); + catalogListeners.forEach((l) => l()); } -let mergedSnapshot: AnimationCatalogSnapshot = buildMergedSnapshot(); - -function emit(): void { - // Rebuild per-category refs — only create a new object if the underlying - // data actually changed, so useSyncExternalStore sees new references. - for (const cat of ["in", "out", "combo"] as MediaAnimationCategory[]) { - const current = categorySnapshots[cat]; - const ref = categorySnapshotRefs[cat]; - if ( - ref.status !== current.status || - ref.presets !== current.presets || - ref.error !== current.error - ) { - categorySnapshotRefs[cat] = { ...current }; - } - } - mergedSnapshot = buildMergedSnapshot(); - listeners.forEach((l) => l()); +function subscribeToCatalog(listener: () => void): () => void { + catalogListeners.add(listener); + return () => { + catalogListeners.delete(listener); + }; +} + +export function getCatalogSnapshot(): AnimationCatalogSnapshot { + return cachedCatalogSnapshot; } -// ─── Normalization ─────────────────────────────────────────────────────────── +// ─── Normalization ──────────────────────────────────────────────────────────── function iconForDirection(direction: string | undefined): string { switch (direction) { @@ -156,6 +103,31 @@ function iconForDirection(direction: string | undefined): string { } } +/** + * Ensures track keyframes have valid, non-degenerate progress values. + * Recovers from API manifests that collapsed all keyframe progress to 0. + */ +export function sanitizeTrackKeyframes( + tracks: MediaAnimationPreset["tracks"], +): MediaAnimationPreset["tracks"] { + if (!Array.isArray(tracks)) return []; + return tracks.map((track) => { + const rawKeyframes = track.keyframes ?? []; + if (rawKeyframes.length < 2) return track; + const isDegenerate = + rawKeyframes.every((kf) => (kf.progress ?? 0) <= 0) || + (rawKeyframes[rawKeyframes.length - 1].progress ?? 0) <= + (rawKeyframes[0].progress ?? 0); + if (!isDegenerate) return track; + + const sanitizedKeyframes = rawKeyframes.map((kf, index) => ({ + ...kf, + progress: index / (rawKeyframes.length - 1), + })); + return { ...track, keyframes: sanitizedKeyframes }; + }); +} + function normalizeMediaPreset( item: unknown, category: MediaAnimationCategory, @@ -163,6 +135,13 @@ function normalizeMediaPreset( if (!item || typeof item !== "object") return null; const raw = item as Record; + if (import.meta.env.DEV) { + console.log( + `[mediaAnimationCatalog] raw item (category="${category}"):`, + raw, + ); + } + const slug = typeof raw.slug === "string" ? raw.slug @@ -172,30 +151,40 @@ function normalizeMediaPreset( const name = typeof raw.name === "string" ? raw.name : null; if (!slug || !name) return null; - // tracks and durationFrames are not in summary; they come from the cached manifest - const defaultDuration = 0.5; + const durationFrames = + typeof raw.durationFrames === "number" ? raw.durationFrames : null; + const defaultDuration = durationFrames !== null ? durationFrames / 30 : 0.5; const direction = typeof raw.direction === "string" ? raw.direction : undefined; const thumbnailUrl = typeof raw.thumbnailUrl === "string" ? raw.thumbnailUrl : undefined; const previewUrl = typeof raw.previewUrl === "string" ? raw.previewUrl : undefined; - const version = typeof raw.version === "string" ? raw.version : undefined; - - // "combo" maps to "in" as the compiler phase — combo presets are standalone in-place animations + const version = + typeof raw.activeVersion === "string" + ? raw.activeVersion + : typeof raw.version === "string" + ? raw.version + : undefined; + + // combo presets use the "in" compiler phase (standalone in-place animations) const phase: MotionBehaviorCategory = category === "out" ? "out" : "in"; - return { + const preset: MediaAnimationPreset = { id: slug, name, phase, - category: phase, + category: phase, // MotionBehaviorCategory doesn't include "combo"; combo maps to "in" phase subCategory: "Basic", tags: direction ? [direction] : [], description: typeof raw.description === "string" ? raw.description : name, defaultDuration, icon: iconForDirection(direction), - tracks: [], // populated after manifest download + tracks: sanitizeTrackKeyframes( + Array.isArray(raw.tracks) + ? (raw.tracks as MediaAnimationPreset["tracks"]) + : [], + ), minDuration: 0.3, maxDuration: 3.0, previewLoopDuration: defaultDuration, @@ -203,99 +192,143 @@ function normalizeMediaPreset( thumbnailUrl, previewUrl, }; + + if (import.meta.env.DEV) { + console.log(`[mediaAnimationCatalog] normalized:`, { + id: preset.id, + revisionId: preset.revisionId, + phase: preset.phase, + defaultDuration: preset.defaultDuration, + tracksCount: preset.tracks.length, + }); + } + + return preset; } -// ─── Fetch ─────────────────────────────────────────────────────────────────── +// ─── Hook ───────────────────────────────────────────────────────────────────── -export async function fetchMediaAnimationCategory( +/** + * Fetches presets for a single category. Each CategoryGrid instance gets its + * own isolated state — no shared store, no bleed between tabs. + * The parent keys CategoryGrid by category so this remounts fresh on tab switch. + */ +export function useMediaAnimationCategory( category: MediaAnimationCategory, - force = false, -): Promise { - const inFlight = categoryRequests[category]; - if (inFlight) return inFlight; - - if (!force && categorySnapshots[category].status === "ready") return; - - const run = (async () => { - const previous = categorySnapshots[category]; - categorySnapshots[category] = { - ...previous, - status: "loading", - error: undefined, - }; - emit(); - - try { - const url = `${getApiBaseUrl()}/media-animations?category=${category}&fields=summary`; - const response = await fetch(url, { - headers: getApiHeaders(), - credentials: "include", - }); + enabled: boolean = true, +): MediaAnimationCategorySnapshot { + const [snapshot, setSnapshot] = useState({ + presets: [], + status: "idle", + }); - if (!response.ok) { - throw new Error( - `Media animation catalog fetch failed for "${category}" (${response.status})`, - ); + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + + setSnapshot({ presets: [], status: "loading" }); + + const url = `${getApiBaseUrl()}/media-animations?category=${category}&fields=summary`; + + const t0 = performance.now(); + + const run = async () => { + try { + const res = await fetch(url, { + headers: getApiHeaders(), + credentials: "include", + }); + if (!res.ok) throw new Error(`Fetch failed (${res.status})`); + const httpStatus = res.status; + const payload = (await res.json()) as unknown; + + if (cancelled) return; + + const items: unknown[] = Array.isArray(payload) + ? payload + : Array.isArray((payload as Record)?.items) + ? ((payload as Record).items as unknown[]) + : []; + + const presets = items + // Client-side guard: drop any item whose category doesn't match the + // requested one. Protects against an API that returns unfiltered + // data (e.g. missing auth header, edge-cache hit, future regression). + .filter((item) => { + const raw = item as Record; + return ( + typeof raw.category !== "string" || raw.category === category + ); + }) + .map((item) => normalizeMediaPreset(item, category)) + .filter((p): p is MediaAnimationPreset => p !== null); + + // Key by id@category so each category keeps its own copy in the map + // and later fetches for other categories don't overwrite this one. + presets.forEach((p) => allPresets.set(`${p.id}@${category}`, p)); + // Notify subscribers so useMediaAnimationCatalogSnapshot re-renders. + notifyCatalogListeners(); + + if (import.meta.env.DEV) { + console.log( + `[useMediaAnimationCategory] category="${category}" → ${presets.length} presets`, + ); + } + setSnapshot({ presets, status: "ready" }); + MediaAnimationTelemetry.recordCatalogFetch({ + category, + durationMs: Math.round(performance.now() - t0), + presetCount: presets.length, + status: "success", + httpStatus, + }); + } catch (err) { + if (cancelled) return; + console.error(`[useMediaAnimationCategory] fetch error:`, err); + setSnapshot({ + presets: [], + status: "error", + error: (err as Error).message, + }); + MediaAnimationTelemetry.recordCatalogFetch({ + category, + durationMs: Math.round(performance.now() - t0), + presetCount: 0, + status: "error", + error: (err as Error).message, + }); } + }; - const payload: unknown = await response.json(); - // Response is either an array of animations or { items: [...] } - const items: unknown[] = Array.isArray(payload) - ? payload - : Array.isArray((payload as Record)?.items) - ? (payload as { items: unknown[] }).items - : []; - - const presets = items - .map((item) => normalizeMediaPreset(item, category)) - .filter((p): p is MediaAnimationPreset => p !== null); - - categorySnapshots[category] = { presets, status: "ready" }; - } catch (error) { - categorySnapshots[category] = { - ...previous, - status: "error", - error: - error instanceof Error - ? error.message - : "Unable to load media animation catalog", - }; - } finally { - categoryRequests[category] = undefined; - emit(); - } - })(); - - categoryRequests[category] = run; - return run; -} + void run(); -// ─── External store API ─────────────────────────────────────────────────────── + return () => { + cancelled = true; + }; + }, [category, enabled]); -export function getMediaAnimationCatalogSnapshot(): AnimationCatalogSnapshot { - return mergedSnapshot; + return snapshot; } -export function subscribeToMediaAnimationCatalog( - listener: () => void, -): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} +// ─── Compiler support ───────────────────────────────────────────────────────── +/** + * Looks up a preset by id and merges cached manifest tracks if available. + * Used by compileClipMotionKeyframes. + */ export function getMediaAnimationPreset( id: string | undefined, ): MediaAnimationPreset | undefined { if (!id) return undefined; + // Search all categories — prefer "in" since most compiler lookups are for + // in-phase presets, then fall back through the others. const base = - downloadedPresets.get(id) ?? - (mergedSnapshot.presets.find((p) => p.id === id) as - | MediaAnimationPreset - | undefined); + allPresets.get(`${id}@in`) ?? + allPresets.get(`${id}@out`) ?? + allPresets.get(`${id}@combo`); if (!base) return undefined; - // If we have a cached manifest in memory (either from this session's - // download or eagerly loaded from disk on startup), merge its tracks. const version = base.revisionId; if (version) { const manifest = mediaAnimationCacheManager.manifests.get( @@ -304,7 +337,9 @@ export function getMediaAnimationPreset( if (manifest) { return { ...base, - tracks: manifest.tracks as MediaAnimationPreset["tracks"], + tracks: sanitizeTrackKeyframes( + manifest.tracks as MediaAnimationPreset["tracks"], + ), defaultDuration: manifest.durationFrames / (manifest.frameRateNum || 30), }; @@ -314,15 +349,25 @@ export function getMediaAnimationPreset( } /** - * Downloads and caches the full animation manifest for a preset. - * After downloading, the manifest is stored in the singleton's `manifests` - * map so getMediaAnimationPreset() can merge it synchronously. + * Reactive hook — returns the merged catalog snapshot and re-renders whenever + * a category fetch completes and writes new presets into allPresets. + * Uses useSyncExternalStore so MediaAnimationSection always has a fresh catalog + * when the user clicks a preset, even if the fetch just finished. + */ +export function useMediaAnimationCatalogSnapshot(): AnimationCatalogSnapshot { + return useSyncExternalStore(subscribeToCatalog, getCatalogSnapshot); +} + +/** + * Downloads and caches the full animation manifest for a preset, then + * notifies catalog listeners so any component holding a catalogSnapshot + * (e.g. MediaAnimationSection) re-renders with the manifest-merged tracks. */ export async function downloadManifest( slug: string, version: string, ): Promise { - return mediaAnimationCacheManager.ensureDownloaded( + const manifest = await mediaAnimationCacheManager.ensureDownloaded( slug, version, async () => { @@ -331,47 +376,22 @@ export async function downloadManifest( headers: getApiHeaders(), credentials: "include", }); - if (!response.ok) { + if (!response.ok) throw new Error( `Manifest fetch failed for "${slug}" (${response.status})`, ); - } return response.json() as Promise; }, ); -} -// ─── React hooks ───────────────────────────────────────────────────────────── + // Notify catalog subscribers so useMediaAnimationCatalogSnapshot re-renders + // with the manifest-merged tracks. This ensures that any component that + // builds a mergedCatalog (e.g. updateMotion in MediaAnimationSection) will + // pick up the full tracks on the next render after download completes. + notifyCatalogListeners(); -/** - * Returns the snapshot for a single category and triggers a lazy fetch the - * first time that category tab is opened. - */ -export function useMediaAnimationCategory( - category: MediaAnimationCategory, -): MediaAnimationCategorySnapshot { - // Subscribe to the external store so React re-renders when emit() fires. - // Read from categorySnapshotRefs — immutable copies rebuilt on every emit - // only when data changes — so React's reference equality check works correctly. - useSyncExternalStore( - subscribeToMediaAnimationCatalog, - getMediaAnimationCatalogSnapshot, - getMediaAnimationCatalogSnapshot, - ); - useEffect(() => { - void fetchMediaAnimationCategory(category); - }, [category]); - return categorySnapshotRefs[category]; + return manifest; } -/** - * Returns the merged catalog snapshot across all categories. - * Used by compileClipMotionKeyframes as the catalog argument. - */ -export function useMediaAnimationCatalogSnapshot(): AnimationCatalogSnapshot { - return useSyncExternalStore( - subscribeToMediaAnimationCatalog, - getMediaAnimationCatalogSnapshot, - getMediaAnimationCatalogSnapshot, - ); -} +// Initialize disk cache on load +mediaAnimationCacheManager.initialize().catch(() => {}); diff --git a/src/features/media-animations/telemetry/__tests__/mediaAnimationRollup.test.ts b/src/features/media-animations/telemetry/__tests__/mediaAnimationRollup.test.ts new file mode 100644 index 000000000..3cf56dbf4 --- /dev/null +++ b/src/features/media-animations/telemetry/__tests__/mediaAnimationRollup.test.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for MediaAnimationRollupCollector. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { MediaAnimationRollupCollector } from "../mediaAnimationRollup"; + +describe("MediaAnimationRollupCollector", () => { + let collector: MediaAnimationRollupCollector; + + beforeEach(() => { + collector = new MediaAnimationRollupCollector(); + }); + + describe("hasRecordedData", () => { + it("returns false on a fresh collector", () => { + expect(collector.hasRecordedData()).toBe(false); + }); + + it("returns true after recording a catalog fetch", () => { + collector.recordCatalogFetch("in", 100, false); + expect(collector.hasRecordedData()).toBe(true); + }); + + it("returns true after recording a download", () => { + collector.recordDownload(true, 5, 2048, false); + expect(collector.hasRecordedData()).toBe(true); + }); + + it("returns true after recording an animation eval", () => { + collector.recordAnimationEval(3, 2); + expect(collector.hasRecordedData()).toBe(true); + }); + + it("returns true after recording a hydration", () => { + collector.recordHydration(5, 50); + expect(collector.hasRecordedData()).toBe(true); + }); + }); + + describe("flush", () => { + it("returns zero counts when nothing was recorded", () => { + const summary = collector.flush(); + expect(summary.catalogFetches.count).toBe(0); + expect(summary.downloads.count).toBe(0); + expect(summary.animationEvals.count).toBe(0); + expect(summary.hydrations.count).toBe(0); + }); + + it("resets hasRecordedData to false after flush", () => { + collector.recordCatalogFetch("in", 100, false); + expect(collector.hasRecordedData()).toBe(true); + collector.flush(); + expect(collector.hasRecordedData()).toBe(false); + }); + + it("returns zero counts on a second flush after clear", () => { + collector.recordCatalogFetch("in", 100, false); + collector.flush(); + const second = collector.flush(); + expect(second.catalogFetches.count).toBe(0); + expect(second.downloads.count).toBe(0); + }); + + describe("catalogFetches", () => { + it("counts 3 fetches including 1 error", () => { + collector.recordCatalogFetch("in", 100, false); + collector.recordCatalogFetch("out", 200, false); + collector.recordCatalogFetch("combo", 300, true); + + const summary = collector.flush(); + expect(summary.catalogFetches.count).toBe(3); + expect(summary.catalogFetches.errorRate).toBeCloseTo(1 / 3, 3); + }); + + it("tracks byCategory counts correctly", () => { + collector.recordCatalogFetch("in", 100, false); + collector.recordCatalogFetch("in", 120, false); + collector.recordCatalogFetch("out", 150, false); + collector.recordCatalogFetch("combo", 80, true); + + const summary = collector.flush(); + expect(summary.catalogFetches.byCategory.in).toBe(2); + expect(summary.catalogFetches.byCategory.out).toBe(1); + expect(summary.catalogFetches.byCategory.combo).toBe(1); + }); + + it("computes average duration correctly", () => { + collector.recordCatalogFetch("in", 100, false); + collector.recordCatalogFetch("out", 200, false); + + const summary = collector.flush(); + expect(summary.catalogFetches.avgDurationMs).toBeCloseTo(150, 1); + }); + }); + + describe("downloads", () => { + it("computes cache hit rate excluding errors", () => { + collector.recordDownload(true, 5, 2048, false); // cache hit + collector.recordDownload(false, 200, 4096, false); // cache miss + collector.recordDownload(false, 0, 0, true); // error + + const summary = collector.flush(); + expect(summary.downloads.count).toBe(3); + // Cache hit rate = 1 hit / (1 hit + 1 miss) = 0.5 + expect(summary.downloads.cacheHitRate).toBeCloseTo(0.5, 3); + // Error rate = 1 error / 3 total = 0.333 + expect(summary.downloads.errorRate).toBeCloseTo(1 / 3, 3); + }); + + it("accumulates total size bytes", () => { + collector.recordDownload(false, 100, 1000, false); + collector.recordDownload(false, 200, 2000, false); + + const summary = collector.flush(); + expect(summary.downloads.totalSizeBytes).toBe(3000); + }); + }); + + describe("animationEvals", () => { + it("counts 3 evals and computes p95", () => { + collector.recordAnimationEval(10, 2); + collector.recordAnimationEval(20, 2); + collector.recordAnimationEval(30, 2); + + const summary = collector.flush(); + expect(summary.animationEvals.count).toBe(3); + expect(summary.animationEvals.avgDurationMs).toBeCloseTo(20, 1); + // p95 of [10, 20, 30]: ceil(3 * 0.95) - 1 = ceil(2.85) - 1 = 3 - 1 = 2 → sorted[2] = 30 + expect(summary.animationEvals.p95DurationMs).toBe(30); + expect(typeof summary.animationEvals.p95DurationMs).toBe("number"); + }); + + it("p95 is 0 when no evals recorded", () => { + const summary = collector.flush(); + expect(summary.animationEvals.p95DurationMs).toBe(0); + }); + }); + + describe("hydrations", () => { + it("averages manifest counts and durations", () => { + collector.recordHydration(5, 50); + collector.recordHydration(3, 30); + + const summary = collector.flush(); + expect(summary.hydrations.count).toBe(2); + expect(summary.hydrations.avgManifests).toBeCloseTo(4, 1); + expect(summary.hydrations.avgDurationMs).toBeCloseTo(40, 1); + }); + }); + + describe("full lifecycle", () => { + it("records mix of all operations and produces correct summary", () => { + // 2 success catalog fetches, 1 error + collector.recordCatalogFetch("in", 100, false); + collector.recordCatalogFetch("out", 150, false); + collector.recordCatalogFetch("combo", 200, true); + + // 1 cache hit, 1 miss, 1 error + collector.recordDownload(true, 5, 1024, false); + collector.recordDownload(false, 300, 4096, false); + collector.recordDownload(false, 0, 0, true); + + // 3 animation evals + collector.recordAnimationEval(5, 3); + collector.recordAnimationEval(10, 2); + collector.recordAnimationEval(15, 1); + + // 1 hydration + collector.recordHydration(5, 50); + + expect(collector.hasRecordedData()).toBe(true); + + const summary = collector.flush(); + + expect(summary.catalogFetches.count).toBe(3); + expect(summary.catalogFetches.errorRate).toBeCloseTo(1 / 3, 3); + expect(summary.catalogFetches.byCategory.in).toBe(1); + expect(summary.catalogFetches.byCategory.out).toBe(1); + expect(summary.catalogFetches.byCategory.combo).toBe(1); + + expect(summary.downloads.count).toBe(3); + expect(summary.downloads.cacheHitRate).toBeCloseTo(0.5, 3); + + expect(summary.animationEvals.count).toBe(3); + expect(typeof summary.animationEvals.p95DurationMs).toBe("number"); + + expect(summary.hydrations.count).toBe(1); + expect(summary.hydrations.avgManifests).toBe(5); + + // After flush, hasRecordedData is false + expect(collector.hasRecordedData()).toBe(false); + }); + }); + }); + + describe("clear", () => { + it("resets all counters without returning a summary", () => { + collector.recordCatalogFetch("in", 100, false); + collector.recordDownload(true, 5, 2048, false); + collector.clear(); + expect(collector.hasRecordedData()).toBe(false); + }); + }); +}); diff --git a/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.integration.test.ts b/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.integration.test.ts new file mode 100644 index 000000000..943130998 --- /dev/null +++ b/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.integration.test.ts @@ -0,0 +1,240 @@ +/** + * Integration test — full media animation telemetry lifecycle. + * + * Simulates a complete session: cache hydration → catalog fetch → manifest + * download → animation eval → rollup flush → PII audit. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock perfLogService before importing modules under test. +vi.mock("@/services/perfLogService", () => ({ + perfLogService: { + getSessionId: vi.fn(() => "test-session-id"), + enqueue: vi.fn(), + }, +})); + +import { perfLogService } from "@/services/perfLogService"; +import { MediaAnimationTelemetry } from "../mediaAnimationTelemetry"; +import { MediaAnimationRollupCollector } from "../mediaAnimationRollup"; + +const mockEnqueue = perfLogService.enqueue as ReturnType; + +describe("MediaAnimationTelemetry integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + (perfLogService.getSessionId as ReturnType).mockReturnValue( + "test-session-id", + ); + }); + + it("step 2 — recordCacheHydration enqueues correct entry", () => { + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: 50, + manifestCount: 5, + indexEntryCount: 5, + errorCount: 0, + fromDiskCount: 5, + }); + + const entry = mockEnqueue.mock.calls[0]?.[0]; + expect(entry.kind).toBe("media-animation-cache-hydration"); + expect(entry.sessionId).toBe("test-session-id"); + expect(entry.payload.durationMs).toBe(50); + expect(entry.payload.manifestCount).toBe(5); + expect(entry.payload.indexEntryCount).toBe(5); + expect(entry.payload.errorCount).toBe(0); + expect(entry.payload.fromDiskCount).toBe(5); + }); + + it("step 3 — recordCatalogFetch enqueues correct entry", () => { + MediaAnimationTelemetry.recordCatalogFetch({ + category: "in", + durationMs: 120, + presetCount: 8, + status: "success", + httpStatus: 200, + }); + + const entry = mockEnqueue.mock.calls[0]?.[0]; + expect(entry.kind).toBe("media-animation-catalog-fetch"); + expect(entry.sessionId).toBe("test-session-id"); + expect(entry.payload.category).toBe("in"); + expect(entry.payload.durationMs).toBe(120); + expect(entry.payload.presetCount).toBe(8); + expect(entry.payload.status).toBe("success"); + expect(entry.payload.httpStatus).toBe(200); + }); + + it("step 4 — recordManifestDownload enqueues correct entry", () => { + MediaAnimationTelemetry.recordManifestDownload({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 210, + sizeBytes: 4096, + fetchDurationMs: 200, + writeDurationMs: 10, + status: "success", + }); + + const entry = mockEnqueue.mock.calls[0]?.[0]; + expect(entry.kind).toBe("media-animation-download"); + expect(entry.sessionId).toBe("test-session-id"); + expect(entry.payload.slug).toBe("slide-in-left"); + expect(entry.payload.cacheHit).toBe(false); + expect(entry.payload.durationMs).toBe(210); + expect(entry.payload.sizeBytes).toBe(4096); + expect(entry.payload.status).toBe("success"); + }); + + it("step 5 — recordAnimationEval enqueues correct entry", () => { + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 3, + clipType: "video", + presetId: "slide-in-left", + trackCount: 2, + keyframeCount: 10, + }); + + const entry = mockEnqueue.mock.calls[0]?.[0]; + expect(entry.kind).toBe("animation-eval"); + expect(entry.sessionId).toBe("test-session-id"); + expect(entry.payload.durationMs).toBe(3); + expect(entry.payload.clipType).toBe("video"); + expect(entry.payload.presetId).toBe("slide-in-left"); + expect(entry.payload.trackCount).toBe(2); + expect(entry.payload.keyframeCount).toBe(10); + }); + + it("step 6 — rollup flush produces media-animation-rollup entry", () => { + const freshCollector = new MediaAnimationRollupCollector(); + freshCollector.recordCatalogFetch("in", 100, false); + freshCollector.recordDownload(true, 5, 2048, false); + freshCollector.recordAnimationEval(3, 2); + freshCollector.recordHydration(5, 50); + + const summary = freshCollector.flush(); + + // Manually simulate what perfLogService would do + perfLogService.enqueue({ + kind: "media-animation-rollup", + sessionId: "test-session-id", + timestampEpochMs: Date.now(), + payload: summary, + }); + + const entry = mockEnqueue.mock.calls[0]?.[0]; + expect(entry.kind).toBe("media-animation-rollup"); + expect(entry.payload.catalogFetches.count).toBe(1); + }); + + it("step 7 — all 4 telemetry methods + rollup produce 5 distinct kind values", () => { + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: 50, + manifestCount: 5, + indexEntryCount: 5, + errorCount: 0, + fromDiskCount: 5, + }); + MediaAnimationTelemetry.recordCatalogFetch({ + category: "in", + durationMs: 120, + presetCount: 8, + status: "success", + httpStatus: 200, + }); + MediaAnimationTelemetry.recordManifestDownload({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 210, + sizeBytes: 4096, + fetchDurationMs: 200, + writeDurationMs: 10, + status: "success", + }); + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 3, + clipType: "video", + presetId: "slide-in-left", + trackCount: 2, + keyframeCount: 10, + }); + + // Rollup entry + const freshCollector = new MediaAnimationRollupCollector(); + freshCollector.recordCatalogFetch("in", 100, false); + const summary = freshCollector.flush(); + perfLogService.enqueue({ + kind: "media-animation-rollup", + sessionId: "test-session-id", + timestampEpochMs: Date.now(), + payload: summary, + }); + + const kinds = new Set( + mockEnqueue.mock.calls.map( + (call: unknown[]) => (call[0] as { kind: string }).kind, + ), + ); + expect(kinds.size).toBe(5); + expect(kinds.has("media-animation-cache-hydration")).toBe(true); + expect(kinds.has("media-animation-catalog-fetch")).toBe(true); + expect(kinds.has("media-animation-download")).toBe(true); + expect(kinds.has("animation-eval")).toBe(true); + expect(kinds.has("media-animation-rollup")).toBe(true); + }); + + it("step 8 — no PII: no file paths, no clipId, no userId in payloads", () => { + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: 50, + manifestCount: 5, + indexEntryCount: 5, + errorCount: 0, + fromDiskCount: 5, + }); + MediaAnimationTelemetry.recordCatalogFetch({ + category: "in", + durationMs: 120, + presetCount: 8, + status: "success", + httpStatus: 200, + }); + MediaAnimationTelemetry.recordManifestDownload({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 210, + sizeBytes: 4096, + fetchDurationMs: 200, + writeDurationMs: 10, + status: "success", + }); + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 3, + clipType: "video", + presetId: "slide-in-left", + trackCount: 2, + keyframeCount: 10, + }); + + for (const call of mockEnqueue.mock.calls as Array< + [{ payload: Record }] + >) { + const payload = call[0].payload; + + // No string payload values at depth 1 should contain "/" + for (const [key, value] of Object.entries(payload)) { + if (typeof value === "string") { + expect( + value.includes("/"), + `Payload field "${key}" contains "/" (possible file path): "${value}"`, + ).toBe(false); + } + } + + // No clipId or userId fields + expect(Object.keys(payload)).not.toContain("clipId"); + expect(Object.keys(payload)).not.toContain("userId"); + } + }); +}); diff --git a/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.test.ts b/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.test.ts new file mode 100644 index 000000000..72f5a38cf --- /dev/null +++ b/src/features/media-animations/telemetry/__tests__/mediaAnimationTelemetry.test.ts @@ -0,0 +1,247 @@ +/** + * Unit tests for MediaAnimationTelemetry static methods. + * + * Verifies that each method forwards the correct kind, sessionId, and payload + * to perfLogService.enqueue, and that none of them throw when enqueue throws. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock perfLogService before importing the module under test. +vi.mock("@/services/perfLogService", () => ({ + perfLogService: { + getSessionId: vi.fn(() => "test-session-123"), + enqueue: vi.fn(), + }, +})); + +import { perfLogService } from "@/services/perfLogService"; +import { MediaAnimationTelemetry } from "../mediaAnimationTelemetry"; + +const mockEnqueue = perfLogService.enqueue as ReturnType; +const mockGetSessionId = perfLogService.getSessionId as ReturnType; + +describe("MediaAnimationTelemetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionId.mockReturnValue("test-session-123"); + }); + + describe("recordCatalogFetch", () => { + it("enqueues entry with kind media-animation-catalog-fetch", () => { + MediaAnimationTelemetry.recordCatalogFetch({ + category: "in", + durationMs: 120, + presetCount: 8, + status: "success", + httpStatus: 200, + }); + + expect(mockEnqueue).toHaveBeenCalledTimes(1); + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.kind).toBe("media-animation-catalog-fetch"); + expect(entry.sessionId).toBe("test-session-123"); + expect(entry.timestampEpochMs).toBeTypeOf("number"); + expect(entry.payload).toMatchObject({ + category: "in", + durationMs: 120, + presetCount: 8, + status: "success", + httpStatus: 200, + }); + }); + + it("forwards error status and error message", () => { + MediaAnimationTelemetry.recordCatalogFetch({ + category: "out", + durationMs: 300, + presetCount: 0, + status: "error", + error: "Network timeout", + }); + + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.payload.status).toBe("error"); + expect(entry.payload.error).toBe("Network timeout"); + expect(entry.payload.category).toBe("out"); + }); + + it("uses 'unknown' sessionId when getSessionId returns null", () => { + mockGetSessionId.mockReturnValue(null); + MediaAnimationTelemetry.recordCatalogFetch({ + category: "combo", + durationMs: 50, + presetCount: 3, + status: "success", + }); + + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.sessionId).toBe("unknown"); + }); + + it("does not throw when enqueue throws", () => { + mockEnqueue.mockImplementation(() => { + throw new Error("Service unavailable"); + }); + + expect(() => + MediaAnimationTelemetry.recordCatalogFetch({ + category: "in", + durationMs: 10, + presetCount: 0, + status: "error", + }), + ).not.toThrow(); + }); + }); + + describe("recordManifestDownload", () => { + it("enqueues entry with kind media-animation-download", () => { + MediaAnimationTelemetry.recordManifestDownload({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 210, + sizeBytes: 4096, + fetchDurationMs: 200, + writeDurationMs: 10, + status: "success", + }); + + expect(mockEnqueue).toHaveBeenCalledTimes(1); + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.kind).toBe("media-animation-download"); + expect(entry.sessionId).toBe("test-session-123"); + expect(entry.payload).toMatchObject({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 210, + sizeBytes: 4096, + status: "success", + }); + }); + + it("forwards cache-hit payload with sha256VerifyMs", () => { + MediaAnimationTelemetry.recordManifestDownload({ + slug: "fade-out", + cacheHit: true, + durationMs: 5, + sizeBytes: 2048, + sha256VerifyMs: 5, + status: "success", + }); + + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.payload.cacheHit).toBe(true); + expect(entry.payload.sha256VerifyMs).toBe(5); + }); + + it("does not throw when enqueue throws", () => { + mockEnqueue.mockImplementation(() => { + throw new Error("disk full"); + }); + + expect(() => + MediaAnimationTelemetry.recordManifestDownload({ + slug: "slide-in-left", + cacheHit: false, + durationMs: 0, + sizeBytes: 0, + status: "error", + error: "disk full", + }), + ).not.toThrow(); + }); + }); + + describe("recordCacheHydration", () => { + it("enqueues entry with kind media-animation-cache-hydration", () => { + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: 50, + manifestCount: 5, + indexEntryCount: 5, + errorCount: 0, + fromDiskCount: 5, + }); + + expect(mockEnqueue).toHaveBeenCalledTimes(1); + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.kind).toBe("media-animation-cache-hydration"); + expect(entry.sessionId).toBe("test-session-123"); + expect(entry.payload).toMatchObject({ + durationMs: 50, + manifestCount: 5, + indexEntryCount: 5, + errorCount: 0, + fromDiskCount: 5, + }); + }); + + it("does not throw when enqueue throws", () => { + mockEnqueue.mockImplementation(() => { + throw new Error("no session"); + }); + + expect(() => + MediaAnimationTelemetry.recordCacheHydration({ + durationMs: 0, + manifestCount: 0, + indexEntryCount: 0, + errorCount: 0, + fromDiskCount: 0, + }), + ).not.toThrow(); + }); + }); + + describe("recordAnimationEval", () => { + it("enqueues entry with kind animation-eval", () => { + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 3, + clipType: "video", + presetId: "slide-in-left", + trackCount: 2, + keyframeCount: 10, + }); + + expect(mockEnqueue).toHaveBeenCalledTimes(1); + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.kind).toBe("animation-eval"); + expect(entry.sessionId).toBe("test-session-123"); + expect(entry.payload).toMatchObject({ + durationMs: 3, + clipType: "video", + presetId: "slide-in-left", + trackCount: 2, + keyframeCount: 10, + }); + }); + + it("forwards entries without presetId", () => { + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 1, + clipType: "image", + trackCount: 0, + keyframeCount: 0, + }); + + const entry = mockEnqueue.mock.calls[0][0]; + expect(entry.payload.presetId).toBeUndefined(); + expect(entry.payload.clipType).toBe("image"); + }); + + it("does not throw when enqueue throws", () => { + mockEnqueue.mockImplementation(() => { + throw new Error("oops"); + }); + + expect(() => + MediaAnimationTelemetry.recordAnimationEval({ + durationMs: 0, + clipType: "video", + trackCount: 0, + keyframeCount: 0, + }), + ).not.toThrow(); + }); + }); +}); diff --git a/src/features/media-animations/telemetry/mediaAnimationRollup.ts b/src/features/media-animations/telemetry/mediaAnimationRollup.ts new file mode 100644 index 000000000..ba5ea7a6d --- /dev/null +++ b/src/features/media-animations/telemetry/mediaAnimationRollup.ts @@ -0,0 +1,259 @@ +/** + * Media Animation Session Rollup Collector + * + * Aggregates per-operation telemetry across the lifetime of a flush window + * and produces a compact `MediaAnimationRollupSummary` on `flush()`. + * + * Called by `perfLogService.flushAnimationRollupSummary()` on each poll interval + * and at session close. + * + * Follows the filmstripTelemetry rollup pattern: accumulate in-process, + * flush-and-clear on every interval, export singleton. + */ + +// ─── Summary interface ──────────────────────────────────────────────────────── + +export interface MediaAnimationRollupSummary { + catalogFetches: { + count: number; + avgDurationMs: number; + errorRate: number; + byCategory: Record<"in" | "out" | "combo", number>; + }; + downloads: { + count: number; + cacheHitRate: number; + avgDurationMs: number; + totalSizeBytes: number; + errorRate: number; + }; + animationEvals: { + count: number; + avgDurationMs: number; + p95DurationMs: number; + }; + hydrations: { + count: number; + avgManifests: number; + avgDurationMs: number; + }; +} + +// ─── Collector class ────────────────────────────────────────────────────────── + +export class MediaAnimationRollupCollector { + // Catalog fetch accumulators + private catalogFetches: { + count: number; + totalDurationMs: number; + errorCount: number; + byCategory: Record<"in" | "out" | "combo", number>; + } = { + count: 0, + totalDurationMs: 0, + errorCount: 0, + byCategory: { in: 0, out: 0, combo: 0 }, + }; + + // Manifest download accumulators + private downloads: { + count: number; + cacheHits: number; + cacheMisses: number; + totalDurationMs: number; + totalSizeBytes: number; + errorCount: number; + } = { + count: 0, + cacheHits: 0, + cacheMisses: 0, + totalDurationMs: 0, + totalSizeBytes: 0, + errorCount: 0, + }; + + // Animation eval accumulators + private animationEvals: { + count: number; + totalDurationMs: number; + durations: number[]; + maxPresetCount: number; + } = { + count: 0, + totalDurationMs: 0, + durations: [], + maxPresetCount: 0, + }; + + // Cache hydration accumulators + private hydrations: { + count: number; + totalManifests: number; + totalDurationMs: number; + } = { + count: 0, + totalManifests: 0, + totalDurationMs: 0, + }; + + // ── Public record methods ───────────────────────────────────────────────── + + recordCatalogFetch( + category: "in" | "out" | "combo", + durationMs: number, + error: boolean, + ): void { + this.catalogFetches.count++; + this.catalogFetches.totalDurationMs += durationMs; + if (error) this.catalogFetches.errorCount++; + this.catalogFetches.byCategory[category]++; + } + + recordDownload( + cacheHit: boolean, + durationMs: number, + sizeBytes: number, + error: boolean, + ): void { + this.downloads.count++; + this.downloads.totalDurationMs += durationMs; + this.downloads.totalSizeBytes += sizeBytes; + if (error) { + this.downloads.errorCount++; + } else if (cacheHit) { + this.downloads.cacheHits++; + } else { + this.downloads.cacheMisses++; + } + } + + recordAnimationEval(durationMs: number, presetCount: number): void { + this.animationEvals.count++; + this.animationEvals.totalDurationMs += durationMs; + this.animationEvals.durations.push(durationMs); + if (presetCount > this.animationEvals.maxPresetCount) { + this.animationEvals.maxPresetCount = presetCount; + } + } + + recordHydration(manifestCount: number, durationMs: number): void { + this.hydrations.count++; + this.hydrations.totalManifests += manifestCount; + this.hydrations.totalDurationMs += durationMs; + } + + /** Returns true if any counter has been incremented since the last flush. */ + hasRecordedData(): boolean { + return ( + this.catalogFetches.count > 0 || + this.downloads.count > 0 || + this.animationEvals.count > 0 || + this.hydrations.count > 0 + ); + } + + /** + * Computes and returns the summary for the current window, then clears + * all accumulators so the next window starts fresh. + */ + flush(): MediaAnimationRollupSummary { + const cf = this.catalogFetches; + const dl = this.downloads; + const ae = this.animationEvals; + const hy = this.hydrations; + + const catalogAvgDuration = + cf.count > 0 ? cf.totalDurationMs / cf.count : 0; + const catalogErrorRate = cf.count > 0 ? cf.errorCount / cf.count : 0; + + const dlAvgDuration = dl.count > 0 ? dl.totalDurationMs / dl.count : 0; + // Cache hit rate excludes error entries (they are neither hits nor misses) + const dlNonError = dl.cacheHits + dl.cacheMisses; + const dlCacheHitRate = dlNonError > 0 ? dl.cacheHits / dlNonError : 0; + const dlErrorRate = dl.count > 0 ? dl.errorCount / dl.count : 0; + + const aeAvgDuration = + ae.count > 0 ? ae.totalDurationMs / ae.count : 0; + + // p95: sort durations and pick the value at ceil(n * 0.95) - 1 + const p95Duration = + ae.durations.length > 0 + ? (() => { + const sorted = [...ae.durations].sort((a, b) => a - b); + return sorted[ + Math.min( + sorted.length - 1, + Math.ceil(sorted.length * 0.95) - 1, + ) + ]; + })() + : 0; + + const hyAvgManifests = + hy.count > 0 ? hy.totalManifests / hy.count : 0; + const hyAvgDuration = + hy.count > 0 ? hy.totalDurationMs / hy.count : 0; + + const summary: MediaAnimationRollupSummary = { + catalogFetches: { + count: cf.count, + avgDurationMs: Math.round(catalogAvgDuration * 100) / 100, + errorRate: Math.round(catalogErrorRate * 1000) / 1000, + byCategory: { ...cf.byCategory }, + }, + downloads: { + count: dl.count, + cacheHitRate: Math.round(dlCacheHitRate * 1000) / 1000, + avgDurationMs: Math.round(dlAvgDuration * 100) / 100, + totalSizeBytes: dl.totalSizeBytes, + errorRate: Math.round(dlErrorRate * 1000) / 1000, + }, + animationEvals: { + count: ae.count, + avgDurationMs: Math.round(aeAvgDuration * 100) / 100, + p95DurationMs: p95Duration, + }, + hydrations: { + count: hy.count, + avgManifests: Math.round(hyAvgManifests * 100) / 100, + avgDurationMs: Math.round(hyAvgDuration * 100) / 100, + }, + }; + + this.clear(); + return summary; + } + + /** Resets all accumulators to their initial zero state. */ + clear(): void { + this.catalogFetches = { + count: 0, + totalDurationMs: 0, + errorCount: 0, + byCategory: { in: 0, out: 0, combo: 0 }, + }; + this.downloads = { + count: 0, + cacheHits: 0, + cacheMisses: 0, + totalDurationMs: 0, + totalSizeBytes: 0, + errorCount: 0, + }; + this.animationEvals = { + count: 0, + totalDurationMs: 0, + durations: [], + maxPresetCount: 0, + }; + this.hydrations = { + count: 0, + totalManifests: 0, + totalDurationMs: 0, + }; + } +} + +// ─── Singleton export ───────────────────────────────────────────────────────── + +export const mediaAnimationRollup = new MediaAnimationRollupCollector(); diff --git a/src/features/media-animations/telemetry/mediaAnimationTelemetry.ts b/src/features/media-animations/telemetry/mediaAnimationTelemetry.ts new file mode 100644 index 000000000..6478fdb89 --- /dev/null +++ b/src/features/media-animations/telemetry/mediaAnimationTelemetry.ts @@ -0,0 +1,154 @@ +/** + * Media Animation Performance Telemetry + * + * Zero-PII, fire-and-forget telemetry for the media animation subsystem: + * - Catalog category fetches (per-category HTTP timings and preset counts) + * - Manifest downloads (cache-hit vs network spans, size, integrity timing) + * - Cache hydration (startup manifest restore timing) + * - Animation evaluation (keyframe compilation latency) + * + * Follows the EditorFeatureTelemetry static pattern exactly. + * Every method is wrapped in try/catch — telemetry never throws into the caller. + */ + +import { perfLogService } from "@/services/perfLogService"; + +// ─── Payload interfaces ─────────────────────────────────────────────────────── + +export interface CatalogFetchPayload { + /** Which of the three animation categories was fetched. */ + category: "in" | "out" | "combo"; + /** Wall-clock duration from fetch start to setSnapshot(). */ + durationMs: number; + /** Number of valid presets returned by the API. */ + presetCount: number; + /** Whether the request completed without error. */ + status: "success" | "error"; + /** HTTP status code on success. */ + httpStatus?: number; + /** Error message if status is "error". */ + error?: string; +} + +export interface ManifestDownloadPayload { + /** Non-identifying preset slug key (e.g. "slide-in-left"). Never a file path or user content. */ + slug: string; + /** Whether the manifest was served from the local disk cache. */ + cacheHit: boolean; + /** Total span duration (verify + fetch + write). */ + durationMs: number; + /** File size in bytes as recorded in the cache index or response. */ + sizeBytes: number; + /** Time spent computing and verifying SHA-256 on a cache-hit path (ms). */ + sha256VerifyMs?: number; + /** Network fetch duration (cache-miss path only). */ + fetchDurationMs?: number; + /** Disk write duration (cache-miss path only). */ + writeDurationMs?: number; + /** Outcome of the download attempt. */ + status: "success" | "hash-mismatch" | "missing" | "error"; + /** Error message if status is "error". */ + error?: string; +} + +export interface CacheHydrationPayload { + /** Wall-clock duration of the full loadIndex() + hydrateManifests() block. */ + durationMs: number; + /** Number of manifests successfully loaded into memory. */ + manifestCount: number; + /** Number of entries read from the on-disk index file. */ + indexEntryCount: number; + /** Number of index entries evicted due to missing or corrupt files. */ + errorCount: number; + /** Manifests successfully read from disk (equals manifestCount). */ + fromDiskCount: number; +} + +export interface AnimationEvalPayload { + /** Wall-clock duration of the keyframe compilation call (ms). */ + durationMs: number; + /** Clip type as stored in the clip object (e.g. "video", "image"). */ + clipType: string; + /** Preset slug applied to the clip (if any). Never a user file path. */ + presetId?: string; + /** Number of animation tracks compiled. */ + trackCount: number; + /** Total keyframe count across all compiled tracks. */ + keyframeCount: number; +} + +// ─── Telemetry class ────────────────────────────────────────────────────────── + +/** + * Static fire-and-forget telemetry methods for the media animation subsystem. + * Each method enqueues one PerfLogEntry and swallows all errors. + */ +export class MediaAnimationTelemetry { + /** + * Record a per-category catalog HTTP fetch span. + */ + static recordCatalogFetch(payload: CatalogFetchPayload): void { + try { + const sessionId = perfLogService.getSessionId() ?? "unknown"; + perfLogService.enqueue({ + kind: "media-animation-catalog-fetch", + sessionId, + timestampEpochMs: Date.now(), + payload, + }); + } catch { + // Non-blocking telemetry + } + } + + /** + * Record a manifest download span (cache-hit verification or network fetch). + */ + static recordManifestDownload(payload: ManifestDownloadPayload): void { + try { + const sessionId = perfLogService.getSessionId() ?? "unknown"; + perfLogService.enqueue({ + kind: "media-animation-download", + sessionId, + timestampEpochMs: Date.now(), + payload, + }); + } catch { + // Non-blocking telemetry + } + } + + /** + * Record the startup cache-hydration span (loadIndex + hydrateManifests). + */ + static recordCacheHydration(payload: CacheHydrationPayload): void { + try { + const sessionId = perfLogService.getSessionId() ?? "unknown"; + perfLogService.enqueue({ + kind: "media-animation-cache-hydration", + sessionId, + timestampEpochMs: Date.now(), + payload, + }); + } catch { + // Non-blocking telemetry + } + } + + /** + * Record a keyframe compilation (animation eval) span. + */ + static recordAnimationEval(payload: AnimationEvalPayload): void { + try { + const sessionId = perfLogService.getSessionId() ?? "unknown"; + perfLogService.enqueue({ + kind: "animation-eval", + sessionId, + timestampEpochMs: Date.now(), + payload, + }); + } catch { + // Non-blocking telemetry + } + } +} diff --git a/src/features/text-animations/PhaseGrid.tsx b/src/features/text-animations/PhaseGrid.tsx new file mode 100644 index 000000000..93802bed4 --- /dev/null +++ b/src/features/text-animations/PhaseGrid.tsx @@ -0,0 +1,146 @@ +import React, { useMemo, useEffect } from "react"; +import { Ban } from "lucide-react"; +import { useI18n } from "@/i18n"; +import PresetCard from "@/features/media-animations/PresetCard"; +import { + useAnimationPhase, + type RemoteTextAnimationPreset, +} from "./animationCatalog"; +import { textAnimationCacheManager } from "./cache/textAnimationCache"; +import type { MotionBehaviorCategory } from "@/types/motion"; + +export type CategoryFilter = "all" | "favorites"; + +interface PhaseGridProps { + phase: MotionBehaviorCategory; + enabled: boolean; + activePresetId: string | undefined; + searchQuery: string; + activeFilter: CategoryFilter; + favorites: Set; + downloadingIds: Set; + downloadedIds: Set; + onSelect: (id: string) => void; + onToggleFavorite: (id: string, e: React.MouseEvent) => void; + onDownload: (id: string, e: React.MouseEvent) => void; +} + +const PhaseGrid: React.FC = ({ + phase, + enabled, + activePresetId, + searchQuery, + activeFilter, + favorites, + downloadingIds, + downloadedIds, + onSelect, + onToggleFavorite, + onDownload, +}) => { + const { t } = useI18n(); + const phaseData = useAnimationPhase(phase, enabled); + + useEffect(() => { + if (import.meta.env.DEV) { + console.log(`[PhaseGrid "${phase}"] fetched`, phaseData.presets); + } + }, [phaseData.presets, phase]); + + const filteredPresets = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return phaseData.presets.filter((preset) => { + if (activeFilter === "favorites" && !favorites.has(preset.id)) { + return false; + } + if (q) { + const matchesName = preset.name.toLowerCase().includes(q); + const matchesDesc = preset.description.toLowerCase().includes(q); + const matchesTags = preset.tags?.some((tag) => + tag.toLowerCase().includes(q), + ); + if (!matchesName && !matchesDesc && !matchesTags) return false; + } + return true; + }); + }, [phaseData.presets, activeFilter, searchQuery, favorites]); + + return ( +
+ {/* None card */} + + + {/* Empty state */} + {filteredPresets.length === 0 && phaseData.status === "ready" && ( +
+ {t("properties.animation.none")} +
+ )} + + {/* Error state */} + {phaseData.status === "error" && filteredPresets.length === 0 && ( +
+ Animation catalog is unavailable. Reconnect to load published presets. +
+ )} + + {/* Loading skeletons */} + {phaseData.status === "loading" && + filteredPresets.length === 0 && + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+ ))} + + {/* Preset cards */} + {filteredPresets.map((preset) => { + const isDownloaded = + !preset.revisionId || + downloadedIds.has(preset.id) || + textAnimationCacheManager.isCached( + preset.id, + preset.revisionId ?? "", + ); + return ( + onSelect(preset.id)} + onToggleFavorite={(e) => onToggleFavorite(preset.id, e)} + isDownloaded={isDownloaded} + isDownloading={downloadingIds.has(preset.id)} + onDownload={(e) => onDownload(preset.id, e)} + /> + ); + })} +
+ ); +}; + +export default PhaseGrid; diff --git a/src/features/text-animations/animationCatalog.ts b/src/features/text-animations/animationCatalog.ts index 6da740078..30a76f01e 100644 --- a/src/features/text-animations/animationCatalog.ts +++ b/src/features/text-animations/animationCatalog.ts @@ -2,6 +2,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { getApiBaseUrl, getApiHeaders } from "@/lib/api/apiUtils"; import type { KeyframeEasing } from "@/types/keyframes"; import type { MotionBehaviorCategory, MotionPresetMeta, MotionSubCategory } from "@/types/motion"; +import { textAnimationCacheManager } from "./cache/textAnimationCache"; export type AnimationTrackProperty = | "opacity" @@ -32,6 +33,8 @@ export interface RemoteTextAnimationPreset extends MotionPresetMeta { procedural?: { type: string; intensity: number; frequency?: number }; revisionId?: string; updatedAt?: number; + thumbnailUrl?: string; + previewUrl?: string; } // ── Per-phase snapshot ──────────────────────────────────────────────────────── @@ -65,8 +68,7 @@ const phaseSnapshots: Record = { /** * In-memory cache for individually downloaded preset revisions. - * Keyed by preset id. Populated when a user clicks a preset card. - * Not persisted to localStorage — lives for the current session only. + * Keyed by preset id. Populated when a user clicks a preset card or on preload. */ const downloadedPresets = new Map(); @@ -78,18 +80,43 @@ const listeners = new Set<() => void>(); // ── Derived merged snapshot ─────────────────────────────────────────────────── function buildMergedSnapshot(): AnimationCatalogSnapshot { - // Merge all fetched phase presets; then layer in individually downloaded - // presets so the compositor can always resolve a selected preset's tracks. const seen = new Set(); const presets: RemoteTextAnimationPreset[] = []; for (const phase of ["in", "out", "loop"] as MotionBehaviorCategory[]) { for (const p of phaseSnapshots[phase].presets) { - if (!seen.has(p.id)) { seen.add(p.id); presets.push(p); } + if (!seen.has(p.id)) { + seen.add(p.id); + presets.push(p); + } } } + + // Layer in disk-cached manifests so full tracks are available on startup + for (const [cacheKey, p] of textAnimationCacheManager.manifests.entries()) { + const id = cacheKey.split("@")[0]; + if (!seen.has(id)) { + seen.add(id); + presets.push(p); + } else { + const idx = presets.findIndex((x) => x.id === id); + if (idx !== -1 && p.tracks.length > 0) { + presets[idx] = p; + } + } + } + + // Layer in session-downloaded presets for (const p of downloadedPresets.values()) { - if (!seen.has(p.id)) { seen.add(p.id); presets.push(p); } + if (!seen.has(p.id)) { + seen.add(p.id); + presets.push(p); + } else { + const idx = presets.findIndex((x) => x.id === p.id); + if (idx !== -1 && p.tracks.length > 0) { + presets[idx] = p; + } + } } const statuses = [phaseSnapshots.in.status, phaseSnapshots.out.status, phaseSnapshots.loop.status]; @@ -116,7 +143,13 @@ function phaseOf(value: unknown): MotionBehaviorCategory | null { } function categoryOf(value: unknown): MotionSubCategory { - return value === "3D" || value === "Glitch" || value === "Motion" ? value : "Basic"; + if (typeof value === "string") { + const lower = value.toLowerCase(); + if (lower === "3d") return "3D"; + if (lower === "glitch") return "Glitch"; + if (lower === "motion") return "Motion"; + } + return "Basic"; } function iconForPreset(name: string, tags: string[]): string { @@ -130,21 +163,43 @@ function iconForPreset(name: string, tags: string[]): string { return "Sparkles"; } -function normalizePreset(value: unknown): RemoteTextAnimationPreset | null { +export function normalizePreset(value: unknown): RemoteTextAnimationPreset | null { if (!value || typeof value !== "object") return null; const raw = value as Record; - const phase = phaseOf(raw.phase); - if (!phase || typeof raw.id !== "string" || typeof raw.name !== "string" || !Array.isArray(raw.tracks)) return null; + const phase = phaseOf(raw.phase ?? raw.category); + if (!phase || typeof raw.id !== "string" || typeof raw.name !== "string") return null; + // If tracks is not an array and no revisionId exists, the preset has neither tracks nor a way to fetch them + if ( + !Array.isArray(raw.tracks) && + typeof raw.revisionId !== "string" && + typeof raw.activeVersion !== "string" + ) { + return null; + } const tags = Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === "string") : []; - const tracks = raw.tracks.filter((track): track is RemoteTextAnimationPreset["tracks"][number] => - !!track && typeof track === "object" && typeof (track as { property?: unknown }).property === "string" && Array.isArray((track as { keyframes?: unknown }).keyframes), - ) as RemoteTextAnimationPreset["tracks"]; + const tracks = Array.isArray(raw.tracks) + ? (raw.tracks.filter((track): track is RemoteTextAnimationPreset["tracks"][number] => + !!track && + typeof track === "object" && + typeof (track as { property?: unknown }).property === "string" && + Array.isArray((track as { keyframes?: unknown }).keyframes), + ) as RemoteTextAnimationPreset["tracks"]) + : []; + + const thumbnailUrl = typeof raw.thumbnailUrl === "string" ? raw.thumbnailUrl : undefined; + const previewUrl = + typeof raw.previewUrl === "string" + ? raw.previewUrl + : typeof raw.previewVideoUrl === "string" + ? raw.previewVideoUrl + : undefined; + return { id: raw.id, name: raw.name, phase, category: phase, - subCategory: categoryOf(raw.category), + subCategory: categoryOf(raw.subCategory ?? raw.category), tags, description: typeof raw.description === "string" ? raw.description : `${raw.name} ${phase} animation`, defaultDuration: typeof raw.defaultDuration === "number" ? raw.defaultDuration : 0.6, @@ -155,8 +210,15 @@ function normalizePreset(value: unknown): RemoteTextAnimationPreset | null { previewLoopDuration: typeof raw.previewLoopDuration === "number" ? raw.previewLoopDuration : 1.2, stagger: raw.stagger as RemoteTextAnimationPreset["stagger"], procedural: raw.procedural as RemoteTextAnimationPreset["procedural"], - revisionId: typeof raw.revisionId === "string" ? raw.revisionId : undefined, + revisionId: + typeof raw.revisionId === "string" + ? raw.revisionId + : typeof raw.activeVersion === "string" + ? raw.activeVersion + : undefined, updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : undefined, + thumbnailUrl, + previewUrl, }; } @@ -169,7 +231,6 @@ function normalizePreset(value: unknown): RemoteTextAnimationPreset | null { * completes reuses the same Promise. * - Already-ready phases are served from the in-memory snapshot without * re-fetching unless `force` is true. - * - No localStorage: catalog lists are intentionally session-scoped. */ export async function fetchAnimationPhase( phase: MotionBehaviorCategory, @@ -187,18 +248,27 @@ export async function fetchAnimationPhase( phaseSnapshots[phase] = { ...previous, status: "loading", error: undefined }; emit(); try { - const response = await fetch( - `${getApiBaseUrl()}/text-animations/${phase}`, - { headers: getApiHeaders(), credentials: "include" }, - ); + const url = `${getApiBaseUrl()}/text-animations?category=${phase}&fields=summary`; + let response = await fetch(url, { + headers: getApiHeaders(), + credentials: "include", + }); + if (!response.ok) { + response = await fetch(`${getApiBaseUrl()}/text-animations/${phase}`, { + headers: getApiHeaders(), + credentials: "include", + }); + } if (!response.ok) { throw new Error(`Catalog fetch failed for "${phase}" (${response.status})`); } const payload: unknown = await response.json(); - if (!Array.isArray(payload)) { - throw new Error(`Invalid catalog payload for phase "${phase}"`); - } - const presets = payload.map(normalizePreset).filter(Boolean) as RemoteTextAnimationPreset[]; + const items: unknown[] = Array.isArray(payload) + ? payload + : Array.isArray((payload as Record)?.items) + ? ((payload as Record).items as unknown[]) + : []; + const presets = items.map(normalizePreset).filter(Boolean) as RemoteTextAnimationPreset[]; phaseSnapshots[phase] = { presets, status: "ready" }; } catch (error) { phaseSnapshots[phase] = { @@ -220,51 +290,59 @@ export async function fetchAnimationPhase( /** * Download the full immutable preset definition via the revision route and - * cache it in-memory for this session. + * cache it on disk (via textAnimationCacheManager) and in-memory for this session. * * Route: GET /text-animations/{category}/{id}/revisions/{revisionId} - * - * The `revisionId` is returned as part of the catalog index response. - * If the catalog hasn't loaded the preset yet (e.g. user jumped directly to - * a preset id), falls back to searching the merged snapshot for the revisionId. - * - * Returns the cached preset if already downloaded. */ export async function downloadAnimationPreset( preset: RemoteTextAnimationPreset, ): Promise { - // Return immediately if already cached this session. + // Return immediately if already cached in-memory with tracks. const cached = downloadedPresets.get(preset.id); - if (cached) return cached; + if (cached && cached.tracks.length > 0) return cached; const { id, phase, revisionId } = preset; if (!revisionId) { - // No revisionId in catalog entry — nothing to resolve from the revision route. - // Store the catalog entry itself so the compositor can use its tracks. downloadedPresets.set(id, preset); emit(); return preset; } + // Check disk cache first via textAnimationCacheManager + if (textAnimationCacheManager.isCached(id, revisionId)) { + const fromDisk = await textAnimationCacheManager.getManifest(id, revisionId); + if (fromDisk && fromDisk.tracks.length > 0) { + downloadedPresets.set(id, fromDisk); + emit(); + return fromDisk; + } + } + try { - const response = await fetch( - `${getApiBaseUrl()}/text-animations/${phase}/${id}/revisions/${revisionId}`, - { headers: getApiHeaders(), credentials: "include" }, + const full = await textAnimationCacheManager.ensureDownloaded( + id, + revisionId, + async () => { + const response = await fetch( + `${getApiBaseUrl()}/text-animations/${phase}/${id}/revisions/${revisionId}`, + { headers: getApiHeaders(), credentials: "include" }, + ); + if (!response.ok) { + throw new Error(`Revision fetch failed for "${id}@${revisionId}" (${response.status})`); + } + const payload: unknown = await response.json(); + const normalized = normalizePreset(payload); + if (!normalized) throw new Error(`Invalid revision payload for "${id}"`); + return normalized; + }, ); - if (!response.ok) { - throw new Error(`Revision fetch failed for "${id}@${revisionId}" (${response.status})`); - } - const payload: unknown = await response.json(); - const full = normalizePreset(payload); - if (!full) throw new Error(`Invalid revision payload for "${id}"`); downloadedPresets.set(id, full); - emit(); // expose downloaded preset through merged snapshot + emit(); return full; } catch (error) { console.error(`[AnimationCatalog] Failed to download preset "${id}":`, error); - // Fall back to the catalog-index entry so the compositor isn't left empty. downloadedPresets.set(id, preset); emit(); return preset; @@ -282,10 +360,17 @@ export function subscribeToTextAnimationCatalog(listener: () => void): () => voi return () => listeners.delete(listener); } -/** Look up a preset by id — checks downloaded cache first, then merged snapshot. */ +/** Look up a preset by id — checks downloaded cache first, then disk cache, then merged snapshot. */ export function getTextAnimationPreset(id: string | undefined): RemoteTextAnimationPreset | undefined { if (!id) return undefined; - return downloadedPresets.get(id) ?? mergedSnapshot.presets.find((p) => p.id === id); + const inMem = downloadedPresets.get(id); + if (inMem && inMem.tracks.length > 0) return inMem; + + for (const [key, p] of textAnimationCacheManager.manifests.entries()) { + if (key.startsWith(`${id}@`) && p.tracks.length > 0) return p; + } + + return inMem ?? mergedSnapshot.presets.find((p) => p.id === id); } /** Return all loaded presets for a given phase from the in-memory snapshot. */ @@ -297,18 +382,21 @@ export function getTextAnimationPresets(phase: MotionBehaviorCategory): RemoteTe /** * Returns the snapshot for a single phase and triggers a lazy fetch the first - * time the phase is accessed (i.e. when the user opens that tab). - * Re-renders only when state for any phase or the downloaded cache changes. + * time the phase is accessed when enabled is true. */ -export function useAnimationPhase(phase: MotionBehaviorCategory): PhaseSnapshot { +export function useAnimationPhase( + phase: MotionBehaviorCategory, + enabled: boolean = true, +): PhaseSnapshot { useSyncExternalStore( subscribeToTextAnimationCatalog, getTextAnimationCatalogSnapshot, getTextAnimationCatalogSnapshot, ); useEffect(() => { + if (!enabled) return; void fetchAnimationPhase(phase); - }, [phase]); + }, [phase, enabled]); return phaseSnapshots[phase]; } @@ -326,6 +414,21 @@ export function useTextAnimationCatalog(): AnimationCatalogSnapshot { export function toKeyframeEasing(value: AnimationTrackKeyframe["easing"]): KeyframeEasing { if (typeof value !== "string") return "linear"; - const supported: KeyframeEasing[] = ["linear", "easeIn", "easeOut", "easeInOut", "easeInQuad", "easeOutQuad", "easeInCubic", "easeOutCubic", "easeInBack", "easeOutBack", "easeOutBounce"]; - return supported.includes(value as KeyframeEasing) ? value as KeyframeEasing : "linear"; + const supported: KeyframeEasing[] = [ + "linear", + "easeIn", + "easeOut", + "easeInOut", + "easeInQuad", + "easeOutQuad", + "easeInCubic", + "easeOutCubic", + "easeInBack", + "easeOutBack", + "easeOutBounce", + ]; + return supported.includes(value as KeyframeEasing) ? (value as KeyframeEasing) : "linear"; } + +// Initialize disk cache on load +textAnimationCacheManager.initialize().catch(() => {}); diff --git a/src/features/text-animations/cache/textAnimationCache.ts b/src/features/text-animations/cache/textAnimationCache.ts new file mode 100644 index 000000000..ae0523d27 --- /dev/null +++ b/src/features/text-animations/cache/textAnimationCache.ts @@ -0,0 +1,335 @@ +/** + * Text Animation Cache Manager + * + * OS-level manifest and preset cache that stores animation definitions in + * BaseDirectory.AppCache under "text-animations/manifests/". + * Mirrors the mediaAnimationCache.ts pattern with SHA-256 integrity verification. + */ + +import { + BaseDirectory, + exists, + mkdir, + writeFile, + readFile, + remove, +} from "@tauri-apps/plugin-fs"; +import type { RemoteTextAnimationPreset } from "../animationCatalog"; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export interface CachedTextManifestEntry { + id: string; + version: string; + contentHash: string; + localPath: string; + downloadedAt: number; + sizeBytes: number; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CACHE_DIR = "text-animations/manifests"; +const INDEX_FILE = "text-animations/index.json"; + +// ─── SHA-256 helper ─────────────────────────────────────────────────────────── + +async function sha256Hex(data: Uint8Array): Promise { + if (typeof crypto !== "undefined" && crypto.subtle) { + const hash = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + return ""; +} + +// ─── Cache Manager ──────────────────────────────────────────────────────────── + +class TextAnimationCacheManager { + private cacheIndex: Map = new Map(); + private initialized = false; + + /** Listeners notified after initialize() completes so subscribers can + * re-read isCached() results (e.g., after restart hydration). */ + private hydrateListeners: Array<() => void> = []; + + /** In-memory preset map — eagerly populated during initialize() from the + * OS index so getManifest() is not required for every preset access. */ + readonly manifests: Map = new Map(); + + /** + * Creates the cache directory and loads the index from disk. + * Idempotent; gracefully handles environments where Tauri FS is unavailable. + */ + async initialize(): Promise { + if (this.initialized) return; + + try { + const dirExists = await exists(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + }); + if (!dirExists) { + await mkdir(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + recursive: true, + }); + } + + await this.loadIndex(); + await this.hydrateManifests(); + this.initialized = true; + } catch (error) { + // Browser / web-preview environment — no Tauri FS available + console.warn( + "[TextAnimationCache] Tauri FS unavailable; running in-memory only:", + error, + ); + this.initialized = true; + } + + this.hydrateListeners.forEach((l) => l()); + this.hydrateListeners = []; + } + + /** + * Subscribe to a one-shot notification that fires after the first + * initialize() completes. + */ + onHydrated(listener: () => void): () => void { + if (this.initialized) { + listener(); + return () => {}; + } + this.hydrateListeners.push(listener); + return () => { + this.hydrateListeners = this.hydrateListeners.filter( + (l) => l !== listener, + ); + }; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async loadIndex(): Promise { + try { + const indexExists = await exists(INDEX_FILE, { + baseDir: BaseDirectory.AppCache, + }); + if (!indexExists) return; + + const raw = await readFile(INDEX_FILE, { + baseDir: BaseDirectory.AppCache, + }); + const json = new TextDecoder().decode(raw); + const entries: CachedTextManifestEntry[] = JSON.parse(json); + + this.cacheIndex.clear(); + for (const entry of entries) { + this.cacheIndex.set(`${entry.id}@${entry.version}`, entry); + } + } catch (error) { + console.warn( + "[TextAnimationCache] Index corrupt or unavailable; starting fresh:", + error, + ); + this.cacheIndex.clear(); + } + } + + private async hydrateManifests(): Promise { + const entries = Array.from(this.cacheIndex.values()); + let errorCount = 0; + await Promise.all( + entries.map(async (entry) => { + const cacheKey = `${entry.id}@${entry.version}`; + if (this.manifests.has(cacheKey)) return; + try { + const manifest = await this.readAndVerifyManifest(entry); + if (manifest) { + this.manifests.set(cacheKey, manifest); + } else { + this.cacheIndex.delete(cacheKey); + errorCount++; + } + } catch { + this.cacheIndex.delete(cacheKey); + errorCount++; + } + }), + ); + return errorCount; + } + + private async saveIndex(): Promise { + try { + const entries = Array.from(this.cacheIndex.values()); + const json = JSON.stringify(entries, null, 2); + const data = new TextEncoder().encode(json); + await writeFile(INDEX_FILE, data, { baseDir: BaseDirectory.AppCache }); + } catch (error) { + console.warn("[TextAnimationCache] Failed to persist index:", error); + } + } + + private async readAndVerifyManifest( + entry: CachedTextManifestEntry, + ): Promise { + try { + const fileExists = await exists(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + if (!fileExists) return null; + + const raw = await readFile(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + const json = new TextDecoder().decode(raw); + const manifest = JSON.parse(json) as RemoteTextAnimationPreset; + + if (entry.contentHash && entry.contentHash !== "") { + const computedHash = await sha256Hex(raw); + if (computedHash && computedHash !== entry.contentHash) { + console.warn( + `[TextAnimationCache] Hash mismatch for "${entry.id}@${entry.version}"; ` + + `expected ${entry.contentHash}, got ${computedHash}. Evicting.`, + ); + try { + await remove(entry.localPath, { baseDir: BaseDirectory.AppCache }); + } catch {} + return null; + } + } + + return manifest; + } catch (error) { + console.warn( + `[TextAnimationCache] Could not read cached manifest for "${entry.id}@${entry.version}":`, + error, + ); + return null; + } + } + + // ── Public API ────────────────────────────────────────────────────────────── + + /** + * Returns true if an entry exists for id+version in the in-memory index. + */ + isCached(id: string, version: string): boolean { + return this.cacheIndex.has(`${id}@${version}`); + } + + /** + * If already cached, loads from disk and returns. + * Otherwise calls fetchFn(), writes JSON to disk, updates index, and returns. + */ + async ensureDownloaded( + id: string, + version: string, + fetchFn: () => Promise, + ): Promise { + await this.initialize(); + + const cacheKey = `${id}@${version}`; + + if (this.cacheIndex.has(cacheKey)) { + const entry = this.cacheIndex.get(cacheKey)!; + const cached = await this.readAndVerifyManifest(entry); + if (cached) { + this.manifests.set(cacheKey, cached); + return cached; + } + this.cacheIndex.delete(cacheKey); + } + + try { + const manifest = await fetchFn(); + const localPath = `${CACHE_DIR}/${id}@${version}.json`; + const json = JSON.stringify(manifest); + const data = new TextEncoder().encode(json); + + let contentHash = ""; + try { + await writeFile(localPath, data, { baseDir: BaseDirectory.AppCache }); + const computed = await sha256Hex(data); + if (computed) contentHash = computed; + } catch (fsError) { + console.warn( + "[TextAnimationCache] Could not write manifest to disk:", + fsError, + ); + } + + const entry: CachedTextManifestEntry = { + id, + version, + contentHash, + localPath, + downloadedAt: Date.now(), + sizeBytes: data.byteLength, + }; + + this.cacheIndex.set(cacheKey, entry); + this.manifests.set(cacheKey, manifest); + await this.saveIndex(); + + return manifest; + } catch (error) { + console.error( + `[TextAnimationCache] Failed to download manifest for "${id}@${version}":`, + error, + ); + throw error; + } + } + + async getManifest( + id: string, + version: string, + ): Promise { + const cacheKey = `${id}@${version}`; + const inMemory = this.manifests.get(cacheKey); + if (inMemory) return inMemory; + + const entry = this.cacheIndex.get(cacheKey); + if (!entry) return null; + + const manifest = await this.readAndVerifyManifest(entry); + if (manifest) { + this.manifests.set(cacheKey, manifest); + } else { + this.cacheIndex.delete(cacheKey); + await this.saveIndex(); + } + return manifest; + } + + async clearCache(): Promise { + await this.initialize(); + + for (const entry of this.cacheIndex.values()) { + try { + const fileExists = await exists(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + if (fileExists) { + await remove(entry.localPath, { baseDir: BaseDirectory.AppCache }); + } + } catch (error) { + console.warn( + `[TextAnimationCache] Failed to delete file "${entry.localPath}":`, + error, + ); + } + } + + this.cacheIndex.clear(); + this.manifests.clear(); + await this.saveIndex(); + } +} + +// ─── Singleton export ───────────────────────────────────────────────────────── + +export const textAnimationCacheManager = new TextAnimationCacheManager(); diff --git a/src/i18n/catalogs/de.json b/src/i18n/catalogs/de.json index a65ad5548..24694c023 100644 --- a/src/i18n/catalogs/de.json +++ b/src/i18n/catalogs/de.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "Animation anwenden", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "Animation „{{name}}“ angewendet", + "properties.mediaAnimation.toastRemoved": "Animation entfernt", "properties.text.presetStyle": "VOREINGESTELLTER STIL", "properties.text.expandPresets": "Voreingestellte Stile erweitern", "properties.text.collapsePresets": "Voreingestellte Stile einklappen", diff --git a/src/i18n/catalogs/en.json b/src/i18n/catalogs/en.json index 5ac09fe8e..656b6a558 100644 --- a/src/i18n/catalogs/en.json +++ b/src/i18n/catalogs/en.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "Apply animation", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "Applied “{{name}}” animation", + "properties.mediaAnimation.toastRemoved": "Removed animation", "properties.text.presetStyle": "PRESET STYLE", "properties.text.expandPresets": "Expand preset styles", "properties.text.collapsePresets": "Collapse preset styles", diff --git a/src/i18n/catalogs/es.json b/src/i18n/catalogs/es.json index 32ad58f04..24ebe49ac 100644 --- a/src/i18n/catalogs/es.json +++ b/src/i18n/catalogs/es.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "Aplicar animación", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "Animación “{{name}}” aplicada", + "properties.mediaAnimation.toastRemoved": "Animación eliminada", "properties.text.presetStyle": "ESTILO PREDETERMINADO", "properties.text.expandPresets": "Expandir estilos predeterminados", "properties.text.collapsePresets": "Contraer estilos predeterminados", diff --git a/src/i18n/catalogs/fr.json b/src/i18n/catalogs/fr.json index dc3b8dd97..339feb349 100644 --- a/src/i18n/catalogs/fr.json +++ b/src/i18n/catalogs/fr.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "Appliquer l'animation", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "Animation « {{name}} » appliquée", + "properties.mediaAnimation.toastRemoved": "Animation supprimée", "properties.text.presetStyle": "STYLE PRÉDÉFINI", "properties.text.expandPresets": "Développer les styles prédéfinis", "properties.text.collapsePresets": "Réduire les styles prédéfinis", diff --git a/src/i18n/catalogs/ja.json b/src/i18n/catalogs/ja.json index 074486d1a..e9833a080 100644 --- a/src/i18n/catalogs/ja.json +++ b/src/i18n/catalogs/ja.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "アニメーションを適用", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "「{{name}}」アニメーションを適用しました", + "properties.mediaAnimation.toastRemoved": "アニメーションを削除しました", "properties.text.presetStyle": "プリセットスタイル", "properties.text.expandPresets": "プリセットスタイルを展開", "properties.text.collapsePresets": "プリセットスタイルを折りたたむ", diff --git a/src/i18n/catalogs/ko.json b/src/i18n/catalogs/ko.json index c232d6366..88d6116bf 100644 --- a/src/i18n/catalogs/ko.json +++ b/src/i18n/catalogs/ko.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "애니메이션 적용", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "“{{name}}” 애니메이션 적용됨", + "properties.mediaAnimation.toastRemoved": "애니메이션 제거됨", "properties.text.presetStyle": "프리셋 스타일", "properties.text.expandPresets": "프리셋 스타일 펼치기", "properties.text.collapsePresets": "프리셋 스타일 접기", diff --git a/src/i18n/catalogs/ru.json b/src/i18n/catalogs/ru.json index ce9fc2192..c9645b17c 100644 --- a/src/i18n/catalogs/ru.json +++ b/src/i18n/catalogs/ru.json @@ -275,6 +275,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "Применить анимацию", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "Применена анимация «{{name}}»", + "properties.mediaAnimation.toastRemoved": "Анимация удалена", "properties.text.presetStyle": "ПРЕДУСТАНОВЛЕННЫЙ СТИЛЬ", "properties.text.expandPresets": "Развернуть стили", "properties.text.collapsePresets": "Свернуть стили", diff --git a/src/i18n/catalogs/zh-CN.json b/src/i18n/catalogs/zh-CN.json index cbba1a6d2..550317976 100644 --- a/src/i18n/catalogs/zh-CN.json +++ b/src/i18n/catalogs/zh-CN.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "应用动画", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "已应用“{{name}}”动画", + "properties.mediaAnimation.toastRemoved": "已移除动画", "properties.text.presetStyle": "预设样式", "properties.text.expandPresets": "展开预设样式", "properties.text.collapsePresets": "折叠预设样式", diff --git a/src/i18n/catalogs/zh-TW.json b/src/i18n/catalogs/zh-TW.json index 1b066f3fa..eee613a8e 100644 --- a/src/i18n/catalogs/zh-TW.json +++ b/src/i18n/catalogs/zh-TW.json @@ -271,6 +271,8 @@ "properties.mediaAnimation.downloading": "Downloading", "properties.mediaAnimation.selectAnimation": "套用動畫", "properties.mediaAnimation.downloadAnimation": "Download animation", + "properties.mediaAnimation.toastApplied": "已套用「{{name}}」動畫", + "properties.mediaAnimation.toastRemoved": "已移除動畫", "properties.text.presetStyle": "預設樣式", "properties.text.expandPresets": "展開預設樣式", "properties.text.collapsePresets": "摺疊預設樣式", diff --git a/src/lib/export/__tests__/nativeTimelineExport.test.ts b/src/lib/export/__tests__/nativeTimelineExport.test.ts index 58d4a5d5e..03af8b50c 100644 --- a/src/lib/export/__tests__/nativeTimelineExport.test.ts +++ b/src/lib/export/__tests__/nativeTimelineExport.test.ts @@ -351,3 +351,39 @@ describe("analyzeNativeTimelineExport", () => { ]); }); }); + +// ─── CLY-BUG-009 / CLY-BUG-011 Regression ─────────────────────────────────── +// Changing normalizeClipAudioProperties default from preservePitch=false to +// preservePitch=true must NOT cause plain video clips to be rejected from the +// native export path. The native path demuxes audio as-is (no atempo applied), +// so preservePitch is irrelevant when speed === 1.0. +describe("analyzeNativeTimelineExport — CLY-BUG-011 preservePitch default regression", () => { + it("does NOT reject a plain video clip because of the preservePitch=true default", () => { + // A clip with no explicit audio config: normalizeClipAudioProperties now + // returns preservePitch=true by default. This must NOT trigger the + // 'audio-mix export path' reason. + const plainClip = clip({ id: "plain", startTime: 0, duration: 9, trimIn: 0, trimOut: 9 }); + const result = analyzeNativeTimelineExport({ + clips: [plainClip], + tracks, + transitions: [], + assets, + project, + startTime: 0, + endTime: 9, + outputPath: "/output/plain.mp4", + width: 1920, + height: 1080, + frameRate: 30, + codec: "h264", + preset: "fast", + crf: 23, + pixelFormat: "yuv420p", + }); + expect(result.eligible).toBe(true); + if (!result.eligible) { + // Surface actionable failure message in CI + throw new Error(`Native export rejected: ${result.reasons.join(", ")}`); + } + }); +}); diff --git a/src/lib/export/nativeTimelineExport.ts b/src/lib/export/nativeTimelineExport.ts index 5050afea1..c592e8011 100644 --- a/src/lib/export/nativeTimelineExport.ts +++ b/src/lib/export/nativeTimelineExport.ts @@ -203,7 +203,10 @@ export function analyzeNativeTimelineExport( audio.fadeIn.duration > 0 || audio.fadeOut.duration > 0 || hasAudioEffects || - audio.speed.preservePitch || + // Note: audio.speed.preservePitch is intentionally NOT checked here. + // The native cut-only path demuxes audio as-is (no atempo applied), so + // preservePitch has no meaning in this context. Speed changes (speed ≠ 1.0) + // are already disqualifying via compositor-only visual settings checks above. audio.channelConfig.mode !== "auto" || audio.channelConfig.downmix !== "auto" || Boolean(audio.channelConfig.channelMap?.length); diff --git a/src/lib/export/otioImporter.ts b/src/lib/export/otioImporter.ts index fd35765bf..d1acfb13d 100644 --- a/src/lib/export/otioImporter.ts +++ b/src/lib/export/otioImporter.ts @@ -146,13 +146,16 @@ export function importFromOTIO(otioInput: string | OTIOTimeline): OTIOImportResu } } + const timelineDurationSec = + speed > 0 ? durationSec / speed : durationSec; + const clip: Clip = { id: clypraMeta.id || generateId("clip"), name: otioClip.name || "Imported Clip", trackId: track.id, mediaId, startTime: timelineCursorSec, - duration: durationSec, + duration: timelineDurationSec, trimIn: trimInSec, trimOut: trimOutSec, x: clypraMeta.x ?? 0, @@ -170,7 +173,7 @@ export function importFromOTIO(otioInput: string | OTIOTimeline): OTIOImportResu }; clips.push(clip); - timelineCursorSec += durationSec; + timelineCursorSec += timelineDurationSec; } } } diff --git a/src/services/perfLogService.ts b/src/services/perfLogService.ts index dc6a03efd..0478493cf 100644 --- a/src/services/perfLogService.ts +++ b/src/services/perfLogService.ts @@ -42,6 +42,7 @@ import { import { workerPerfCollector } from "@/core/monitoring/WorkerPerfCollector"; import { getTextMetricsSnapshot } from "@/lib/playback/textMetrics"; import { timelineZoomTelemetry } from "@/lib/timeline/timelineZoomTelemetry"; +import { mediaAnimationRollup } from "@/features/media-animations/telemetry/mediaAnimationRollup"; import { getOrCreateHashedInstallId } from "./telemetryCollector"; // ── Tauri runtime guard ─────────────────────────────────────────────────────── @@ -99,7 +100,11 @@ export type PerfLogKind = | "zero-copy-violation" | "preview-quality-benchmark" | "preview-benchmark-report" - | "diagnostic-run"; + | "diagnostic-run" + | "media-animation-catalog-fetch" + | "media-animation-download" + | "media-animation-cache-hydration" + | "media-animation-rollup"; export interface PerfLogEntry { kind: PerfLogKind; @@ -578,6 +583,17 @@ export class PerfLogService { } } + // Flush the final media animation rollup window. + if (mediaAnimationRollup.hasRecordedData()) { + const animSummary = mediaAnimationRollup.flush(); + this.queue.push({ + kind: "media-animation-rollup", + sessionId, + timestampEpochMs: Date.now(), + payload: animSummary, + }); + } + // Flush the final frontend A/V sync window so the partial interval is not lost. const finalUiDrift = uiPlayheadDrift.takeAndReset(); const finalPaintJitter = playheadPaintJitter.takeAndReset(); @@ -831,6 +847,7 @@ export class PerfLogService { this.flushWorkerSummary(); this.flushTextMetricsSummary(); this.flushTimelineZoomSummary(); + this.flushAnimationRollupSummary(); }, SYNC_POLL_INTERVAL_MS); } @@ -1069,6 +1086,24 @@ export class PerfLogService { }); } + /** + * Drains the media animation rollup collector into a single media-animation-rollup entry. + * + * Called every flush interval. Skips when no operations have occurred in this window. + */ + private flushAnimationRollupSummary(): void { + if (!this.sessionId) return; + if (!mediaAnimationRollup.hasRecordedData()) return; + const sessionId = this.sessionId; + const animSummary = mediaAnimationRollup.flush(); + this.enqueue({ + kind: "media-animation-rollup", + sessionId, + timestampEpochMs: Date.now(), + payload: animSummary, + }); + } + /** Subscribes to the Tauri native-diagnostic event bridge. */ private async subscribeToNativeDiagnostics(): Promise { try { diff --git a/src/types/__tests__/audio.test.ts b/src/types/__tests__/audio.test.ts index 995f35936..c721a48ba 100644 --- a/src/types/__tests__/audio.test.ts +++ b/src/types/__tests__/audio.test.ts @@ -8,6 +8,7 @@ import { } from "../audio"; import { fromRustClip, toRustClip } from "../serialization"; import type { Clip } from "../index"; +import type { ExportAudioClip } from "../export"; const baseClip: Clip = { id: "clip-audio", @@ -48,7 +49,9 @@ describe("ClipAudioProperties", () => { fadeIn: { duration: 1.25, curve: "exponential" }, fadeOut: { duration: 0.75, curve: "linear" }, channelConfig: { mode: "auto", downmix: "auto" }, - speed: { preservePitch: false }, + // CLY-BUG-009 fix: default must be `true` to match SpeedSection UI (?? true) + // and Rust audio_filter.rs fallback (unwrap_or(true)). + speed: { preservePitch: true }, }); }); @@ -155,3 +158,87 @@ describe("ClipAudioProperties", () => { }); }); }); + +// ─── CLY-BUG-009 Regression: preservePitch default alignment ───────────────── +describe("CLY-BUG-009 — preservePitch default is true (matches SpeedSection UI and Rust fallback)", () => { + it("defaults preservePitch to true when no speed config is supplied", () => { + // Regression guard: a clip with no speed info must produce preservePitch=true, + // matching SpeedSection.tsx (?? true) and audio_filter.rs unwrap_or(true). + const audio = normalizeClipAudioProperties({ kind: "audio" }); + expect(audio.speed.preservePitch).toBe(true); + }); + + it("preserves an explicit preservePitch=false when user has disabled pitch preservation", () => { + const audio = normalizeClipAudioProperties({ + kind: "audio", + audio: { + origin: "embedded", + linkState: "linked", + gainDb: 0, + pan: 0, + muted: false, + volumeKeyframes: [], + fadeIn: { duration: 0, curve: "linear" }, + fadeOut: { duration: 0, curve: "linear" }, + channelConfig: { mode: "auto", downmix: "auto" }, + speed: { preservePitch: false }, + }, + }); + expect(audio.speed.preservePitch).toBe(false); + }); + + it("preserves an explicit preservePitch=true round-trip through serialization", () => { + const clip: Clip = { + ...baseClip, + audio: normalizeClipAudioProperties({ + kind: "audio", + audio: { + origin: "embedded", + linkState: "linked", + gainDb: 0, + pan: 0, + muted: false, + volumeKeyframes: [], + fadeIn: { duration: 0, curve: "linear" }, + fadeOut: { duration: 0, curve: "linear" }, + channelConfig: { mode: "auto", downmix: "auto" }, + speed: { preservePitch: true }, + }, + }), + }; + const restored = fromRustClip(toRustClip(clip)); + expect(restored.audio?.speed.preservePitch).toBe(true); + }); +}); + +// ─── CLY-BUG-008 Regression: ExportAudioClip IPC contract ─────────────────── +describe("CLY-BUG-008 — ExportAudioClip TypeScript interface declares speed and preservePitch", () => { + it("ExportAudioClip interface accepts speed and preservePitch without TypeScript errors", () => { + // This test is a compile-time contract guard: if ExportAudioClip is missing + // speed/preservePitch, the import causes tsc to fail. + // At runtime we validate the shape holds the correct values. + const clip: ExportAudioClip = { + path: "/media/song.mp3", + startTime: 0, + duration: 10, + trimIn: 0, + volume: 0.8, + speed: 2.0, + preservePitch: true, + }; + expect(clip.speed).toBe(2.0); + expect(clip.preservePitch).toBe(true); + }); + + it("ExportAudioClip allows speed and preservePitch to be omitted (optional fields)", () => { + const clip: ExportAudioClip = { + path: "/media/song.mp3", + startTime: 0, + duration: 10, + trimIn: 0, + volume: 1.0, + }; + expect(clip.speed).toBeUndefined(); + expect(clip.preservePitch).toBeUndefined(); + }); +}); diff --git a/src/types/audio.ts b/src/types/audio.ts index 0e037ffab..ff0534c10 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -171,7 +171,12 @@ export function normalizeClipAudioProperties(fields: LegacyAudioClipFields): Cli channelMap: supplied?.channelConfig?.channelMap ? [...supplied.channelConfig.channelMap] : undefined, }, speed: { - preservePitch: supplied?.speed?.preservePitch ?? false, + // Default to `true` (preserve pitch) to match: + // 1. SpeedSection.tsx UI default (`?? true` at line 177) + // 2. Rust audio_filter.rs fallback (`unwrap_or(true)`) + // A `false` default would cause pitch-shifting on export even when the + // user never explicitly disabled pitch preservation. + preservePitch: supplied?.speed?.preservePitch ?? true, }, effects: supplied?.effects ?? fields.audioFX, }; diff --git a/src/types/export.ts b/src/types/export.ts index 9c4ffd609..19475446d 100644 --- a/src/types/export.ts +++ b/src/types/export.ts @@ -42,6 +42,19 @@ export interface ExportAudioClip { downmix?: "auto" | "mono" | "stereo"; /** Source channel index for each output channel (e.g. [1, 0] swaps stereo). */ channelMap?: number[]; + /** + * Playback speed multiplier for this clip (e.g. 0.5 = half speed, 2.0 = double speed). + * When omitted the clip plays at its original tempo (1.0×). + * CRITICAL: Must match `speed: Option` in Rust ExportAudioClip. + */ + speed?: number; + /** + * Whether to preserve the audio pitch during speed changes. + * Defaults to `true` on both the TS and Rust sides — pitch is preserved unless + * explicitly disabled by the user. + * CRITICAL: Must match `preserve_pitch: Option` in Rust ExportAudioClip. + */ + preservePitch?: boolean; } export interface ExportAudioKeyframe {