diff --git a/app/editor/[projectId]/EditorClient.tsx b/app/editor/[projectId]/EditorClient.tsx index 536f706..715eedd 100644 --- a/app/editor/[projectId]/EditorClient.tsx +++ b/app/editor/[projectId]/EditorClient.tsx @@ -1,35 +1,34 @@ 'use client' -import { useState, useEffect, useRef } from 'react' -import { Timeline } from '@/features/timeline/components/Timeline' -import { MediaLibrary } from '@/features/media/components/MediaLibrary' +import { createTextEffect, updateTextEffectStyle } from '@/app/actions/effects' +import { getMediaFileByHash, getSignedUrl } from '@/app/actions/media' +import { Button } from '@/components/ui/button' import { Canvas } from '@/features/compositor/components/Canvas' -import { PlaybackControls } from '@/features/compositor/components/PlaybackControls' import { FPSCounter } from '@/features/compositor/components/FPSCounter' -import { ExportDialog } from '@/features/export/components/ExportDialog' -import { Button } from '@/components/ui/button' -import { PanelRightOpen, Download, Type } from 'lucide-react' -import { Project } from '@/types/project' -import { TextEffect } from '@/types/effects' -import { TextEditor } from '@/features/effects/components/TextEditor' -import { createTextEffect, updateTextEffectStyle } from '@/app/actions/effects' +import { PlaybackControls } from '@/features/compositor/components/PlaybackControls' import { Compositor } from '@/features/compositor/utils/Compositor' -import { useCompositorStore } from '@/stores/compositor' -import { useTimelineStore } from '@/stores/timeline' -import { getSignedUrl } from '@/app/actions/media' -import { useKeyboardShortcuts } from '@/features/timeline/hooks/useKeyboardShortcuts' -import { ExportController } from '@/features/export/utils/ExportController' +import { TextEditor } from '@/features/effects/components/TextEditor' +import { ExportDialog } from '@/features/export/components/ExportDialog' import { ExportQuality } from '@/features/export/types' -import { getMediaFileByHash } from '@/app/actions/media' import { downloadFile } from '@/features/export/utils/download' -import { toast } from 'sonner' +import { ExportController } from '@/features/export/utils/ExportController' +import { MediaLibrary } from '@/features/media/components/MediaLibrary' +import { Timeline } from '@/features/timeline/components/Timeline' +import { useKeyboardShortcuts } from '@/features/timeline/hooks/useKeyboardShortcuts' +import { useCompositorStore } from '@/stores/compositor' +import { useTimelineStore } from '@/stores/timeline' +import { TextEffect } from '@/types/effects' +import { Project } from '@/types/project' +import { Download, PanelRightOpen, Type } from 'lucide-react' import * as PIXI from 'pixi.js' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' // Phase 9: Auto-save imports (AutoSaveManager managed by Zustand) -import { SaveStatus } from '@/features/timeline/utils/autosave' -import { RealtimeSyncManager, ConflictData } from '@/lib/supabase/sync' -import { SaveIndicatorCompact } from '@/components/SaveIndicator' import { ConflictResolutionDialog } from '@/components/ConflictResolutionDialog' import { RecoveryModal } from '@/components/RecoveryModal' +import { SaveIndicatorCompact } from '@/components/SaveIndicator' +import { SaveStatus } from '@/features/timeline/utils/autosave' +import { ConflictData, RealtimeSyncManager } from '@/lib/supabase/sync' interface EditorClientProps { project: Project @@ -183,11 +182,14 @@ export function EditorClient({ project }: EditorClientProps) { } // Sync effects with compositor when they change + // FIXED: Let Compositor handle playback loop, React only updates on effects changes useEffect(() => { - if (compositorRef.current && effects.length > 0) { - compositorRef.current.composeEffects(effects, timecode) - } - }, [effects, timecode]) + if (!compositorRef.current) return + + // Store effects in Compositor so playback loop can access them + // This allows the loop to update effect visibility every frame + compositorRef.current.setEffects(effects) + }, [effects]) // Handle export with progress callback const handleExport = async ( diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 304f933..5cc7edf 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -3,7 +3,7 @@ Auto-generated from all feature plans. Last updated: 2025-10-14 ## Active Technologies -- TypeScript 5.3+ with React 19 and Node.js 20 LTS + Next.js 15, Supabase Client SDK, PIXI.js v8, FFmpeg.wasm, Zustand, Tailwind CSS (001-proedit-mvp-browser) +- TypeScript 5.3+ with React 19 and Node.js 20 LTS + Next.js 15, Supabase Client SDK, PIXI.js v7.4.2, FFmpeg.wasm, Zustand, Tailwind CSS (001-proedit-mvp-browser) ## Project Structure ``` @@ -19,7 +19,7 @@ npm test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNO TypeScript 5.3+ with React 19 and Node.js 20 LTS: Follow standard conventions ## Recent Changes -- 001-proedit-mvp-browser: Added TypeScript 5.3+ with React 19 and Node.js 20 LTS + Next.js 15, Supabase Client SDK, PIXI.js v8, FFmpeg.wasm, Zustand, Tailwind CSS +- 001-proedit-mvp-browser: Added TypeScript 5.3+ with React 19 and Node.js 20 LTS + Next.js 15, Supabase Client SDK, PIXI.js v7.4.2, FFmpeg.wasm, Zustand, Tailwind CSS \ No newline at end of file diff --git a/features/compositor/managers/AudioManager.ts b/features/compositor/managers/AudioManager.ts index 9f20632..9096476 100644 --- a/features/compositor/managers/AudioManager.ts +++ b/features/compositor/managers/AudioManager.ts @@ -109,8 +109,17 @@ export class AudioManager { this.audios.delete(effectId) } + /** + * FIXED: Added error handling to prevent cleanup failures + */ destroy(): void { - this.audios.forEach((_, id) => this.remove(id)) + this.audios.forEach((_, id) => { + try { + this.remove(id) + } catch (error) { + console.warn(`AudioManager: Error removing audio ${id}:`, error) + } + }) this.audios.clear() } } diff --git a/features/compositor/managers/ImageManager.ts b/features/compositor/managers/ImageManager.ts index 2808db5..5a997dd 100644 --- a/features/compositor/managers/ImageManager.ts +++ b/features/compositor/managers/ImageManager.ts @@ -1,5 +1,5 @@ -import * as PIXI from 'pixi.js' import { ImageEffect } from '@/types/effects' +import * as PIXI from 'pixi.js' /** * ImageManager - Manages image effects on PIXI canvas @@ -73,8 +73,17 @@ export class ImageManager { this.images.delete(effectId) } + /** + * FIXED: Added error handling to prevent cleanup failures + */ destroy(): void { - this.images.forEach((_, id) => this.remove(id)) + this.images.forEach((_, id) => { + try { + this.remove(id) + } catch (error) { + console.warn(`ImageManager: Error removing image ${id}:`, error) + } + }) this.images.clear() } diff --git a/features/compositor/managers/TextManager.ts b/features/compositor/managers/TextManager.ts index 0f02e5d..ae531f5 100644 --- a/features/compositor/managers/TextManager.ts +++ b/features/compositor/managers/TextManager.ts @@ -5,18 +5,18 @@ * Constitutional FR-007 compliance - Text Overlay Creation */ -import * as PIXI from 'pixi.js' import { TextEffect } from '@/types/effects' import type { - TextStyleAlign, - TextStyleFontStyle, - TextStyleFontVariant, - TextStyleFontWeight, - TextStyleTextBaseline, - TextStyleWhiteSpace, + TextStyleAlign, + TextStyleFontStyle, + TextStyleFontVariant, + TextStyleFontWeight, + TextStyleTextBaseline, + TextStyleWhiteSpace, } from 'pixi.js' +import * as PIXI from 'pixi.js' -// TEXT_GRADIENT enum values for PIXI v8 +// TEXT_GRADIENT enum values for PIXI v7 type TEXT_GRADIENT = 0 | 1 // 0: VERTICAL, 1: HORIZONTAL // Font metadata for local font access API @@ -677,14 +677,39 @@ export class TextManager extends Map< /** * Cleanup * Port from omniclip Line 550-554 + * FIXED: Safe cleanup without calling sprite.destroy() to prevent cancelResize errors + * PIXI v7 has a known issue where resize observer cleanup fails in production builds */ destroy(): void { if (this.#setPermissionStatus && this.#permissionStatus) { this.#permissionStatus.removeEventListener('change', this.#setPermissionStatus) } - // Clean up all sprites + + // FIXED: Safe cleanup without sprite.destroy() + // PIXI v7 resize observer cleanup is unreliable in production this.forEach((item) => { - item.sprite.destroy() + try { + // Remove from stage first + if (item.sprite.parent) { + item.sprite.parent.removeChild(item.sprite) + } + // Remove event listeners + item.sprite.removeAllListeners() + // Explicitly destroy texture to prevent memory leaks + if (item.sprite.texture && item.sprite.texture !== PIXI.Texture.EMPTY) { + try { + item.sprite.texture.destroy(true) + } catch (err) { + // Texture destroy may fail, but that's acceptable + console.warn('TextManager: Texture destroy warning (safe to ignore):', err) + } + } + // Clear text content to free memory + item.sprite.text = '' + // DO NOT call item.sprite.destroy() - causes cancelResize error in production + } catch (error) { + console.warn('TextManager: Sprite cleanup error:', error) + } }) this.clear() } diff --git a/features/compositor/managers/VideoManager.ts b/features/compositor/managers/VideoManager.ts index 9ae3735..8ece788 100644 --- a/features/compositor/managers/VideoManager.ts +++ b/features/compositor/managers/VideoManager.ts @@ -1,5 +1,5 @@ -import * as PIXI from 'pixi.js' import { VideoEffect } from '@/types/effects' +import * as PIXI from 'pixi.js' /** * VideoManager - Manages video effects on PIXI canvas @@ -40,7 +40,7 @@ export class VideoManager { // Create PIXI texture from video (omniclip:60-62) const texture = PIXI.Texture.from(element) - // Note: PIXI.js v8 doesn't have autoPlay property, handled by video element + // Note: PIXI.js v7 doesn't have autoPlay property, handled by video element // Create sprite (omniclip:63-73) const sprite = new PIXI.Sprite(texture) @@ -180,9 +180,16 @@ export class VideoManager { /** * Cleanup all videos + * FIXED: Added error handling to prevent cleanup failures */ destroy(): void { - this.videos.forEach((_, id) => this.remove(id)) + this.videos.forEach((_, id) => { + try { + this.remove(id) + } catch (error) { + console.warn(`VideoManager: Error removing video ${id}:`, error) + } + }) this.videos.clear() } diff --git a/features/compositor/utils/Compositor.ts b/features/compositor/utils/Compositor.ts index bea6d3f..90efb94 100644 --- a/features/compositor/utils/Compositor.ts +++ b/features/compositor/utils/Compositor.ts @@ -24,6 +24,12 @@ export class Compositor { private timecode = 0 private animationFrameId: number | null = null + // FIXED: Store all effects for playback loop access + private allEffects: Effect[] = [] + + // Track visible effect IDs to detect changes + private visibleEffectIds = new Set() + // Currently visible effects private currentlyPlayedEffects = new Map() @@ -70,6 +76,51 @@ export class Compositor { this.onFpsUpdate = callback } + /** + * Set effects for playback + * FIXED: Store effects internally so playback loop can access them + */ + setEffects(effects: Effect[]): void { + this.allEffects = effects + // Immediately check if recompose is needed + void this.recomposeIfNeeded() + } + + /** + * Recompose only if visible effects changed + * FIXED: Performance optimization - only recompose when necessary + */ + private async recomposeIfNeeded(): Promise { + try { + // Get effects visible at current timecode + const visibleEffects = this.getEffectsRelativeToTimecode( + this.allEffects, + this.timecode + ) + const newIds = new Set(visibleEffects.map(e => e.id)) + + // Only recompose if the set of visible effects changed + if (!this.setsEqual(this.visibleEffectIds, newIds)) { + await this.composeEffects(this.allEffects, this.timecode) + this.visibleEffectIds = newIds + } + } catch (error) { + logger.error('Compositor: Recompose failed:', error) + // Keep going - don't crash the playback loop + } + } + + /** + * Compare two sets for equality + */ + private setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false + for (const item of a) { + if (!b.has(item)) return false + } + return true + } + /** * Start playback * Ported from omniclip:87-98 @@ -155,6 +206,7 @@ export class Compositor { /** * Main playback loop * Ported from omniclip:87-98 + * FIXED: Recompose effects every frame to update visibility based on timecode */ private startPlaybackLoop = (): void => { if (!this.isPlaying) return @@ -167,6 +219,12 @@ export class Compositor { // Update timecode this.timecode += elapsedTime + // FIXED: Only recompose if visible effects changed (performance optimization) + // Prevents unnecessary composeEffects calls (60fps → ~2-5fps) + if (this.allEffects.length > 0) { + void this.recomposeIfNeeded() + } + // Notify timecode change if (this.onTimecodeChange) { this.onTimecodeChange(this.timecode)