Skip to content
Merged
52 changes: 27 additions & 25 deletions app/editor/[projectId]/EditorClient.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->
11 changes: 10 additions & 1 deletion features/compositor/managers/AudioManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
13 changes: 11 additions & 2 deletions features/compositor/managers/ImageManager.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
}

Expand Down
45 changes: 35 additions & 10 deletions features/compositor/managers/TextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
13 changes: 10 additions & 3 deletions features/compositor/managers/VideoManager.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}

Expand Down
58 changes: 58 additions & 0 deletions features/compositor/utils/Compositor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()

// Currently visible effects
private currentlyPlayedEffects = new Map<string, Effect>()

Expand Down Expand Up @@ -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<void> {
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<string>, b: Set<string>): 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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading