UNPKG

ems-editor

Version:

EMS Video Editor SDK - Universal JavaScript SDK for external video editor integration

1,269 lines (1,263 loc) 71.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); const byteToHex = []; for (let i = 0; i < 256; ++i) { byteToHex.push((i + 0x100).toString(16).slice(1)); } function unsafeStringify(arr, offset = 0) { return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + '-' + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + '-' + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + '-' + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + '-' + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); } let getRandomValues; const rnds8 = new Uint8Array(16); function rng() { if (!getRandomValues) { if (typeof crypto === 'undefined' || !crypto.getRandomValues) { throw new Error('crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported'); } getRandomValues = crypto.getRandomValues.bind(crypto); } return getRandomValues(rnds8); } const randomUUID = typeof crypto !== 'undefined' && crypto.randomUUID && crypto.randomUUID.bind(crypto); var native = { randomUUID }; function v4(options, buf, offset) { if (native.randomUUID && true && !options) { return native.randomUUID(); } options = options || {}; const rnds = options.random ?? options.rng?.() ?? rng(); if (rnds.length < 16) { throw new Error('Random bytes length must be >= 16'); } rnds[6] = (rnds[6] & 0x0f) | 0x40; rnds[8] = (rnds[8] & 0x3f) | 0x80; return unsafeStringify(rnds); } /** * EMS Editor SDK - Simple and Reliable * Handles communication between external apps and iframe editor */ class EMSEditor { constructor(config) { this.ws = null; this.connected = false; this.ready = false; this.eventListeners = new Map(); this.iframe = null; this.templateIsDirty = false; this.config = config; this.sessionId = config.sessionId || v4(); console.log('🎬 EMS Editor SDK initialized', { sessionId: this.sessionId }); this.connect(); } // Connection Management async connect() { const wsUrl = this.config.serverUrl.replace(/^http/, 'ws') + '/ws'; try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('✅ Connected to WebSocket'); this.connected = true; this.registerSession(); }; this.ws.onmessage = (event) => { const message = JSON.parse(event.data); this.handleMessage(message); }; this.ws.onclose = () => { console.log('🔌 WebSocket disconnected'); this.connected = false; this.ready = false; this.emit('disconnected'); }; this.ws.onerror = (error) => { console.error('❌ WebSocket error:', error); this.emit('error', error); }; } catch (error) { console.error('❌ Connection failed:', error); this.emit('error', error); } } registerSession() { this.sendMessage({ type: 'REGISTER_SESSION', payload: { sessionId: this.sessionId, type: 'external-app' } }); } handleMessage(message) { console.log('📨 Received:', message.type); switch (message.type) { case 'SESSION_REGISTERED': this.emit('connected'); break; case 'PARTNER_READY': this.ready = true; console.log('🎉 Editor is ready!'); this.emit('ready'); break; case 'EDITOR_READY': this.emit('editorReady'); break; case 'MAM_STATUS': this.emit('mamStatus', message.payload); break; case 'THEME_CHANGED': this.emit('themeChanged', message.payload); break; case 'THEME_UPDATE': this.emit('themeUpdate', message.payload); break; case 'CUSTOMIZE_THEME': this.emit('customTheme', message.payload); break; case 'TEMPLATE_IMPORTED': this.templateIsDirty = false; // Template imported from external source, reset dirty flag this.emit('templateImported', message.payload); break; case 'RENDER_PROGRESS': this.emit('renderProgress', message.payload); break; case 'PIPELINE_EVENT': this.emit('pipelineEvent', message.payload); // Emit specific pipeline stage events if (message.payload.stage === 'uploading') { this.emit('mamUploadStarted', message.payload); } break; case 'MAM_UPLOAD_EVENT': console.log(`📤 MAM Upload ${message.payload.stage}:`, message.payload); this.emit('mamUploadEvent', message.payload); // Emit specific MAM upload stage events for easier handling this.emit(`mamUpload${message.payload.stage.charAt(0).toUpperCase() + message.payload.stage.slice(1)}`, message.payload); break; case 'RENDER_COMPLETE': this.emit('renderComplete', message.payload); // Emit MAM upload completion if asset ID is present if (message.payload.mamAssetId) { this.emit('mamUploadComplete', { assetId: message.payload.mamAssetId, assetName: message.payload.mamAssetName, assetUrl: message.payload.mamAssetUrl, jobId: message.payload.jobId, videoUrl: message.payload.videoUrl }); } break; case 'WORKSPACES_CLEARED': this.emit('workspacesCleared', message.payload); break; case 'WORKSPACE_CREATED': this.emit('workspaceCreated', message.payload); break; case 'WORKSPACE_RENAMED': this.emit('workspaceRenamed', message.payload); break; case 'TEMPLATE_ADDED_TO_WORKSPACE': this.emit('templateAddedToWorkspace', message.payload); break; case 'WORKSPACES_LIST': this.emit('workspacesListReceived', message.payload); break; case 'WORKSPACE_LOADING_PROGRESS': console.log(`📥 Workspace loading ${message.payload.stage}:`, message.payload); this.emit('workspaceLoadingProgress', message.payload); break; case 'WORKSPACE_LOADED': console.log('✅ Workspace loaded successfully:', message.payload); this.templateIsDirty = false; // Workspace loaded from server, reset dirty flag this.emit('workspaceLoaded', message.payload); break; case 'WORKSPACE_LOADING_ERROR': console.error('❌ Workspace loading failed:', message.payload); this.emit('workspaceLoadingError', message.payload); break; case 'QUICK_RENDER_RESULT': console.log('🚀 Quick render result received:', message.payload); this.emit('quickRenderResult', message.payload); break; case 'TEMPLATE_STATUS_CHANGED': console.log('📝 Template status changed:', message.payload); this.templateIsDirty = message.payload?.isDirty || false; this.emit('templateStatusChanged', message.payload); break; case 'PARTNER_DISCONNECTED': this.ready = false; this.emit('editorDisconnected'); break; case 'ERROR': console.error('❌ Server error:', message.payload.error); this.emit('error', message.payload.error); break; } } // Public API Methods /** * Set iframe reference for the editor */ setIframe(iframe) { this.iframe = iframe; iframe.src = `${this.config.serverUrl}?sessionId=${this.sessionId}`; console.log('🖼️ Iframe set with sessionId:', this.sessionId); } /** * Login to MAM system */ async loginToMAM(mamConfig) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'MAM_LOGIN', sessionId: this.sessionId, payload: mamConfig }); } /** * Logout from MAM system */ async logoutFromMAM() { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'MAM_LOGOUT', sessionId: this.sessionId, payload: {} }); } /** * Set theme (light or dark) */ async setTheme(theme) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'SET_THEME', sessionId: this.sessionId, payload: { theme } }); } /** * Update theme with preset theme ID or custom theme object */ async updateTheme(themeId, customTheme) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'THEME_UPDATE', sessionId: this.sessionId, payload: { themeId, customTheme } }); } /** * Customize theme with primary, secondary, background colors */ async customizeTheme(themeConfig) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } // Generate a complete theme object from the basic colors const customTheme = { id: 'custom', name: 'Custom Theme', primary: this.generateColorPalette(themeConfig.primary || '#3b82f6'), secondary: this.generateColorPalette(themeConfig.secondary || '#64748b'), background: { primary: themeConfig.background || '#0f172a', secondary: this.adjustColor(themeConfig.background || '#0f172a', 10), tertiary: this.adjustColor(themeConfig.background || '#0f172a', 20), accent: this.adjustColor(themeConfig.background || '#0f172a', 30) }, text: { primary: '#f8fafc', secondary: '#e2e8f0', muted: '#94a3b8', accent: themeConfig.accent || '#60a5fa' }, border: { primary: this.adjustColor(themeConfig.background || '#0f172a', 40), secondary: this.adjustColor(themeConfig.background || '#0f172a', 50), accent: themeConfig.primary || '#3b82f6' }, shadows: { sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', xl: '0 25px 50px -12px rgb(0 0 0 / 0.25)' } }; this.sendMessage({ type: 'CUSTOMIZE_THEME', sessionId: this.sessionId, payload: { customTheme } }); } /** * Generate color palette from base color */ generateColorPalette(baseColor) { return { 50: this.adjustColor(baseColor, 95), 100: this.adjustColor(baseColor, 90), 200: this.adjustColor(baseColor, 80), 300: this.adjustColor(baseColor, 60), 400: this.adjustColor(baseColor, 40), 500: baseColor, 600: this.adjustColor(baseColor, -20), 700: this.adjustColor(baseColor, -40), 800: this.adjustColor(baseColor, -60), 900: this.adjustColor(baseColor, -80), 950: this.adjustColor(baseColor, -90) }; } /** * Adjust color lightness */ adjustColor(color, percent) { const num = parseInt(color.replace('#', ''), 16); const amt = Math.round(2.55 * percent); const R = (num >> 16) + amt; const G = (num >> 8 & 0x00FF) + amt; const B = (num & 0x0000FF) + amt; return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1); } /** * Import template */ async importTemplate(templateData) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('templateImported', handleResponse); reject(new Error('Import template timeout')); }, 30000); // 30 second timeout const handleResponse = (data) => { clearTimeout(timeout); this.off('templateImported', handleResponse); if (data.success) { const result = { success: true, templateId: templateData.id || 'unknown', workspaceId: data.workspaceId || 'unknown', templateName: templateData.name || 'Imported Template', tracksCount: templateData.tracks?.length || 0, elementsCount: templateData.tracks?.reduce((total, track) => total + (track.elements?.length || 0), 0) || 0, timestamp: new Date().toISOString() }; resolve(result); } else { const result = { success: false, templateId: templateData.id || 'unknown', workspaceId: '', templateName: templateData.name || 'Imported Template', tracksCount: templateData.tracks?.length || 0, elementsCount: templateData.tracks?.reduce((total, track) => total + (track.elements?.length || 0), 0) || 0, timestamp: new Date().toISOString(), error: data.error || 'Import failed' }; resolve(result); } }; // Listen for template import response this.on('templateImported', handleResponse); // Send import template message this.sendMessage({ type: 'IMPORT_TEMPLATE', sessionId: this.sessionId, payload: templateData }); }); } /** * Import template from assets array - Auto-generates template with video/audio track separation */ async importFromAssets(assets, options) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } if (!assets || assets.length === 0) { throw new Error('Assets array is required and cannot be empty'); } console.log('🎬 Generating template from assets:', assets.length, 'assets'); // Set default options const opts = { name: 'Generated Project', description: 'Auto-generated from assets', width: 1920, height: 1080, fps: 30, backgroundColor: '#000000', ...options }; // Calculate max duration in frames from all assets let maxDurationMs = 0; assets.forEach(asset => { if (asset.duration && asset.duration > maxDurationMs) { maxDurationMs = asset.duration; } }); // Default to 5 seconds if no duration found, convert to frames const maxDurationFrames = maxDurationMs > 0 ? Math.ceil(maxDurationMs / 1000 * opts.fps) : opts.fps * 5; console.log(`🎯 Max duration: ${maxDurationMs}ms = ${maxDurationFrames} frames at ${opts.fps}fps`); // Generate template structure const template = { id: this.generateUUID(), name: opts.name, description: opts.description, version: '1.0.0', settings: { width: opts.width, height: opts.height, fps: opts.fps, duration: maxDurationFrames, backgroundColor: opts.backgroundColor, quality: 'high', audioSampleRate: 48000, audioChannels: 2 }, tracks: this.generateTracksFromAssets(assets, opts), assets: { videos: [], images: [], audio: [], fonts: [], shapes: [], templates: [] }, animations: [], transitions: [], groups: [], metadata: { author: 'EMS Editor SDK', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: '1.0.0', license: 'MIT', tags: [], category: 'Generated', complexity: 'beginner', description: opts.description, requirements: [], compatibility: ['web', 'mobile'] } }; console.log('✅ Generated template:', template); // Import the generated template and wait for confirmation return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('templateImported', handleResponse); reject(new Error('Import from assets timeout')); }, 30000); // 30 second timeout const handleResponse = (data) => { clearTimeout(timeout); this.off('templateImported', handleResponse); if (data.success) { const result = { success: true, templateId: template.id, workspaceId: data.workspaceId || 'unknown', templateName: template.name, tracksCount: template.tracks.length, elementsCount: template.tracks.reduce((total, track) => total + track.elements.length, 0), timestamp: new Date().toISOString() }; resolve(result); } else { const result = { success: false, templateId: template.id, workspaceId: '', templateName: template.name, tracksCount: template.tracks.length, elementsCount: template.tracks.reduce((total, track) => total + track.elements.length, 0), timestamp: new Date().toISOString(), error: data.error || 'Import failed' }; resolve(result); } }; // Listen for template import response this.on('templateImported', handleResponse); // Send import template message this.sendMessage({ type: 'IMPORT_TEMPLATE', sessionId: this.sessionId, payload: template }); }); } /** * Generate a template from assets without importing it * Returns the template object for further customization or use */ generateTemplate(assets, options) { if (!assets || assets.length === 0) { throw new Error('Assets array is required and cannot be empty'); } console.log('🎨 Generating template from assets:', assets.length, 'assets'); // Set default options const opts = { name: 'Generated Template', description: 'Auto-generated template from assets', width: 1920, height: 1080, fps: 30, backgroundColor: '#000000', layout: 'sequential', assetPlacement: 'auto', customPosition: {}, enableTransitions: false, transitionDuration: 30, // frames audioMixing: 'separate', trackNaming: 'auto', ...options }; // Calculate duration based on layout let templateDuration = 0; if (opts.layout === 'sequential' || opts.layout === 'custom') { // Sequential: sum of all asset durations assets.forEach(asset => { const assetDuration = asset.duration || 5000; // 5 seconds default templateDuration += assetDuration; }); if (opts.enableTransitions && assets.length > 1) { templateDuration += (assets.length - 1) * (opts.transitionDuration / opts.fps * 1000); } } else { // Parallel: longest asset duration templateDuration = Math.max(...assets.map(asset => asset.duration || 5000)); } const templateDurationFrames = Math.ceil(templateDuration / 1000 * opts.fps); console.log(`🎯 Template duration: ${templateDuration}ms = ${templateDurationFrames} frames`); // Generate template structure const template = { id: this.generateUUID(), name: opts.name, description: opts.description, version: '1.0.0', settings: { width: opts.width, height: opts.height, fps: opts.fps, duration: templateDurationFrames, backgroundColor: opts.backgroundColor, quality: 'high', audioSampleRate: 48000, audioChannels: 2 }, tracks: this.generateAdvancedTracksFromAssets(assets, opts), assets: this.generateAssetLibrary(assets), animations: [], transitions: opts.enableTransitions ? this.generateTransitions(assets, opts) : [], groups: [], metadata: { author: 'EMS Editor SDK', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: '1.0.0', license: 'MIT', tags: this.generateTags(assets), category: 'Generated', complexity: assets.length > 5 ? 'advanced' : assets.length > 2 ? 'intermediate' : 'beginner', description: opts.description, requirements: [], compatibility: ['web', 'mobile'], generationOptions: opts } }; console.log('✅ Generated template:', template); return template; } /** * Generate quick template with minimal configuration * Simplified API for basic use cases */ generateQuickTemplate(assets, name) { return this.generateTemplate(assets, { name: name || 'Quick Template', description: 'Quickly generated template', layout: 'sequential', enableTransitions: true, transitionDuration: 15 }); } /** * Generate template from single asset with text overlay */ generateSingleAssetTemplate(asset, options) { const opts = { name: 'Single Asset Template', overlayText: '', textStyle: { fontSize: 48, color: '#ffffff', fontFamily: 'Arial', fontWeight: 'bold', textAlign: 'center', x: 50, // percent y: 50 // percent }, duration: asset.duration || 5000, ...options }; const template = this.generateTemplate([asset], { name: opts.name, description: `Template with ${asset.name}`, layout: 'parallel' }); // Add text overlay if specified if (opts.overlayText) { const textTrack = { id: 'text-overlay-track', name: 'Text Overlay', type: 'text', locked: false, visible: true, zIndex: 100, elements: [{ id: 'text-overlay-element', type: 'text', name: 'Overlay Text', startFrame: 0, endFrame: Math.ceil(opts.duration / 1000 * template.settings.fps), transform: { x: (opts.textStyle.x || 50) * template.settings.width / 100, // Convert percentage to pixels y: (opts.textStyle.y || 50) * template.settings.height / 100, width: template.settings.width * 0.8, // Default to 80% width height: 100, // Default height for text scaleX: 1, scaleY: 1, rotation: 0 }, properties: { text: opts.overlayText, fontSize: opts.textStyle.fontSize || 48, color: opts.textStyle.color || '#ffffff', fontFamily: opts.textStyle.fontFamily || 'Arial', fontWeight: opts.textStyle.fontWeight || 'normal', textAlign: opts.textStyle.textAlign || 'center', opacity: 1 } }] }; template.tracks.push(textTrack); } return template; } /** * Generate tracks from assets with proper video/audio separation */ generateTracksFromAssets(assets, options) { const tracks = []; let videoTrackIndex = 1; let audioTrackIndex = 1; let imageTrackIndex = 1; let currentPosition = 0; assets.forEach((asset, index) => { const elementId = this.generateElementId(); const durationFrames = asset.duration ? Math.ceil(asset.duration / 1000 * options.fps) : options.fps * 5; // 5 seconds default if (asset.type === 'video') { // Create linked video and audio tracks for video assets const videoElementId = `${elementId}-video`; const audioElementId = `${elementId}-audio`; // Video track const videoTrack = this.findOrCreateTrack(tracks, 'video', `Video Track ${videoTrackIndex}`); const videoElement = this.createVideoElement(asset, videoElementId, audioElementId, currentPosition, durationFrames, options); videoTrack.elements.push(videoElement); // Audio track const audioTrack = this.findOrCreateTrack(tracks, 'audio', `Audio Track ${audioTrackIndex}`); const audioElement = this.createAudioElement(asset, audioElementId, videoElementId, currentPosition, durationFrames, options); audioTrack.elements.push(audioElement); videoTrackIndex++; audioTrackIndex++; } else if (asset.type === 'audio') { // Audio-only track const audioTrack = this.findOrCreateTrack(tracks, 'audio', `Audio Track ${audioTrackIndex}`); const audioElement = this.createAudioOnlyElement(asset, elementId, currentPosition, durationFrames, options); audioTrack.elements.push(audioElement); audioTrackIndex++; } else if (asset.type === 'image') { // Image track const imageTrack = this.findOrCreateTrack(tracks, 'image', `Image Track ${imageTrackIndex}`); const imageElement = this.createImageElement(asset, elementId, currentPosition, durationFrames, options); imageTrack.elements.push(imageElement); imageTrackIndex++; } // Move position for next asset (sequential placement) currentPosition += durationFrames; }); // Set proper z-index for tracks tracks.forEach((track, index) => { track.zIndex = tracks.length - index; // Video on top, audio on bottom }); return tracks; } /** * Find existing track or create new one */ findOrCreateTrack(tracks, type, name) { let track = tracks.find(t => t.type === type && t.elements.length === 0); if (!track) { track = { id: `track-${Date.now()}-${type}`, name, type, locked: false, visible: true, muted: type === 'audio' ? false : undefined, volume: type === 'audio' ? 1 : undefined, zIndex: tracks.length + 1, elements: [] }; tracks.push(track); } return track; } /** * Create video element with linking */ createVideoElement(asset, videoId, audioId, startFrame, duration, options) { return { id: videoId, type: 'video', name: `${asset.name} (Video)`, startFrame, endFrame: startFrame + duration, layerIndex: 0, transform: this.calculateTransform(asset, options), properties: { src: asset.proxyUrl || asset.fileUrl, originalSrc: asset.fileUrl, muted: true, volume: 0, originalWidth: asset.width, originalHeight: asset.height, aspectRatio: asset.width && asset.height ? asset.width / asset.height : undefined, mamAssetId: asset.mamAssetId || asset.id, mamOriginalType: 'Video', isMAMAsset: !!asset.mamAssetId, fileUrl: asset.fileUrl, proxyUrl: asset.proxyUrl, isLinked: true, linkedElementId: audioId, linkType: 'video-audio', originalMediaUrl: asset.fileUrl, playbackMode: 'video-only' }, animations: [], filters: [], blendMode: 'normal', opacity: 1 }; } /** * Create audio element with linking */ createAudioElement(asset, audioId, videoId, startFrame, duration, options) { return { id: audioId, type: 'audio', name: `${asset.name} (Audio)`, startFrame, endFrame: startFrame + duration, layerIndex: 0, transform: this.calculateTransform(asset, options), properties: { src: asset.proxyUrl || asset.fileUrl, originalSrc: asset.fileUrl, originalWidth: asset.width, originalHeight: asset.height, aspectRatio: asset.width && asset.height ? asset.width / asset.height : undefined, mamAssetId: asset.mamAssetId || asset.id, mamOriginalType: 'Video', isMAMAsset: !!asset.mamAssetId, fileUrl: asset.fileUrl, proxyUrl: asset.proxyUrl, isLinked: true, linkedElementId: videoId, linkType: 'video-audio', originalMediaUrl: asset.fileUrl, playbackMode: 'audio-only' }, animations: [], filters: [], blendMode: 'normal', opacity: 1 }; } /** * Create audio-only element */ createAudioOnlyElement(asset, elementId, startFrame, duration, options) { return { id: elementId, type: 'audio', name: asset.name, startFrame, endFrame: startFrame + duration, layerIndex: 0, transform: this.calculateTransform(asset, options), properties: { src: asset.proxyUrl || asset.fileUrl, originalSrc: asset.fileUrl, mamAssetId: asset.mamAssetId || asset.id, mamOriginalType: 'Audio', isMAMAsset: !!asset.mamAssetId, fileUrl: asset.fileUrl, proxyUrl: asset.proxyUrl, isLinked: false, playbackMode: 'both' }, animations: [], filters: [], blendMode: 'normal', opacity: 1 }; } /** * Create image element */ createImageElement(asset, elementId, startFrame, duration, options) { return { id: elementId, type: 'image', name: asset.name, startFrame, endFrame: startFrame + duration, layerIndex: 0, transform: this.calculateTransform(asset, options), properties: { src: asset.proxyUrl || asset.fileUrl, originalSrc: asset.fileUrl, originalWidth: asset.width, originalHeight: asset.height, aspectRatio: asset.width && asset.height ? asset.width / asset.height : undefined, mamAssetId: asset.mamAssetId || asset.id, mamOriginalType: 'Image', isMAMAsset: !!asset.mamAssetId, fileUrl: asset.fileUrl, proxyUrl: asset.proxyUrl, isLinked: false }, animations: [], filters: [], blendMode: 'normal', opacity: 1 }; } /** * Calculate transform for asset positioning */ calculateTransform(asset, options) { const canvasWidth = options.width; const canvasHeight = options.height; if (asset.type === 'image' || asset.type === 'video') { // Use asset dimensions if available, otherwise default const assetWidth = asset.width || canvasWidth; const assetHeight = asset.height || canvasHeight; // Calculate contain fit (like CSS object-fit: contain) const canvasAspectRatio = canvasWidth / canvasHeight; const assetAspectRatio = assetWidth / assetHeight; let scaledWidth; let scaledHeight; if (assetAspectRatio > canvasAspectRatio) { // Asset is wider - scale based on width scaledWidth = canvasWidth; scaledHeight = scaledWidth / assetAspectRatio; } else { // Asset is taller - scale based on height scaledHeight = canvasHeight; scaledWidth = scaledHeight * assetAspectRatio; } // Center on canvas const x = (canvasWidth - scaledWidth) / 2; const y = (canvasHeight - scaledHeight) / 2; return { x: Math.round(x), y: Math.round(y), width: Math.round(scaledWidth), height: Math.round(scaledHeight), rotation: 0, scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, originX: 0.5, originY: 0.5 }; } // Default transform for audio return { x: 0, y: 0, width: canvasWidth, height: canvasHeight, rotation: 0, scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, originX: 0.5, originY: 0.5 }; } /** * Generate UUID */ generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; const v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Generate element ID */ generateElementId() { return `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Start render process */ async startRender(renderOptions) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'START_RENDER', sessionId: this.sessionId, payload: renderOptions }); } /** * Clear all workspaces/tabs in the editor */ async clearWorkspaces() { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } this.sendMessage({ type: 'CLEAR_WORKSPACES', sessionId: this.sessionId, payload: {} }); } /** * Create a new workspace */ async createWorkspace(options) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('workspaceCreated', handleResponse); reject(new Error('Create workspace timeout')); }, 10000); // 10 second timeout const handleResponse = (data) => { clearTimeout(timeout); this.off('workspaceCreated', handleResponse); if (data.success) { resolve({ workspaceId: data.workspaceId, success: true }); } else { reject(new Error(data.error || 'Failed to create workspace')); } }; this.on('workspaceCreated', handleResponse); this.sendMessage({ type: 'CREATE_WORKSPACE', sessionId: this.sessionId, payload: options || {} }); }); } /** * Get list of all open workspaces in the editor */ async getWorkspacesList() { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('workspacesListReceived', handleResponse); reject(new Error('Get workspaces list timeout')); }, 10000); // 10 second timeout const handleResponse = (data) => { clearTimeout(timeout); this.off('workspacesListReceived', handleResponse); if (data.success) { resolve(data.workspaces || []); } else { reject(new Error(data.error || 'Failed to get workspaces list')); } }; // Listen for response this.on('workspacesListReceived', handleResponse); // Send request this.sendMessage({ type: 'GET_WORKSPACES_LIST', sessionId: this.sessionId, payload: {} }); }); } /** * Rename a workspace by its ID */ async renameWorkspace(workspaceId, newName) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } if (!workspaceId || !newName) { throw new Error('Workspace ID and new name are required'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('workspaceRenamed', handleResponse); reject(new Error('Rename workspace timeout')); }, 10000); // 10 second timeout const handleResponse = (data) => { clearTimeout(timeout); this.off('workspaceRenamed', handleResponse); if (data.success) { resolve({ success: true, workspaceId: data.workspaceId, newName: data.newName }); } else { reject(new Error(data.error || 'Failed to rename workspace')); } }; this.on('workspaceRenamed', handleResponse); this.sendMessage({ type: 'RENAME_WORKSPACE', sessionId: this.sessionId, payload: { workspaceId, newName } }); }); } /** * Add a template to an existing workspace */ async addTemplateToWorkspace(workspaceId, template, mergeMode = 'append') { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } if (!workspaceId || !template) { throw new Error('Workspace ID and template are required'); } // Validate template structure if (!template || typeof template !== 'object') { throw new Error('Invalid template format'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('templateAddedToWorkspace', handleResponse); reject(new Error('Add template to workspace timeout')); }, 15000); // 15 second timeout for potentially large templates const handleResponse = (data) => { clearTimeout(timeout); this.off('templateAddedToWorkspace', handleResponse); if (data.success) { resolve({ success: true, workspaceId: data.workspaceId, templateId: data.templateId, mergeMode: data.mergeMode }); } else { reject(new Error(data.error || 'Failed to add template to workspace')); } }; this.on('templateAddedToWorkspace', handleResponse); this.sendMessage({ type: 'ADD_TEMPLATE_TO_WORKSPACE', sessionId: this.sessionId, payload: { workspaceId, template, mergeMode } }); }); } /** * Load workspace from server by workspace ID */ async loadWorkspaceFromServer(options, callbacks = {}) { if (!this.ready) { throw new Error('Editor not ready. Wait for ready event.'); } if (!options.workspaceId) { throw new Error('Workspace ID is required'); } const { onProgress = (progress) => console.log(`📥 Workspace Loading: ${progress.stage} - ${progress.progress}%`), onComplete = (result) => console.log('✅ Workspace Loaded:', result), onError = (error) => console.error('❌ Workspace Loading Error:', error) } = callbacks; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.off('workspaceLoaded', handleSuccess); this.off('workspaceLoadingError', handleError); this.off('workspaceLoadingProgress', handleProgress); reject(new Error('Load workspace timeout - operation took longer than 30 seconds')); }, 30000); // 30 second timeout const handleSuccess = (data) => { if (data.workspaceId === options.workspaceId) { clearTimeout(timeout); this.off('workspaceLoaded', handleSuccess); this.off('workspaceLoadingError', handleError); this.off('workspaceLoadingProgress', handleProgress); onComplete(data); resolve(data); } }; const handleError = (data) => { if (data.workspaceId === options.workspaceId) { clearTimeout(timeout); this.off('workspaceLoaded', handleSuccess); this.off('workspaceLoadingError', handleError); this.off('workspaceLoadingProgress', handleProgress); const error = data.error || 'Failed to load workspace from server'; onError(error); reject(new Error(error)); } }; const handleProgress = (data) => { if (data.workspaceId === options.workspaceId) { onProgress(data); } }; // Set up event listeners this.on('workspaceLoaded', handleSuccess); this.on('workspaceLoadingError', handleError); this.on('workspaceLoadingProgress', handleProgress); // Send the load workspace request this.sendMessage({ type: 'LOAD_WORKSPACE_FROM_SERVER', sessionId: this.sessionId, payload: { workspaceId: options.workspaceId, sessionId: options.sessionId || this.sessionId, mergeMode: options.mergeMode || 'replace', switchToWorkspace: options.switchToWorkspace !== false // Default to true } }); console.log('📥 Loading workspace from server:', options.workspaceId); }); } /** * MAM Upload Event Handlers */ /** * Track MAM upload progress for a specific job */ trackMAMUpload(jobId, callbacks = {}) { const trackingId = `mam-upload-${jobId}-${Date.now()}`; // Default callbacks const { onProgress = (progress) => console.log(`📤 MAM Upload Progress: ${progress.progress}%`), onStageChange = (stage, data) => console.log(`📤 MAM Upload Stage: ${stage}`, data), onComplete = (result) => console.log('✅ MAM Upload Complete:', result), onError = (error) => console.error('❌ MAM Upload Error:', error) } = callbacks; // Track upload events const handleUploadEvent = (data) => { if (data.jobId === jobId) { onProgress(data); onStageChange(data.stage, data); } }; const handleUploadComplete = (data) => { if (data.jobId === jobId) { onComplete(data); this.off('mamUploadEvent', handleUploadEvent); this.off('mamUploadComplete', handleUploadComplete); this.off('renderComplete', handleRenderComplete); } }; const handleRenderComplete = (data) => { if (data.jobId === jobId && data.mamUploadError) { onError(data.mamUploadError); this.off('mamUploadEvent', handleUploadEvent); this.off('mamUploadComplete', handleUploadComplete); this.off('renderComplete', handleRenderComplete); } }; this.on('mamUploadEvent', handleUploadEvent); this.on('mamUploadComplete', handleUploadComplete); this.on('renderComplete', handleRenderComplete); return { trackingId, stop: () => { this.off('mamUploadEvent', handleUploadEvent); this.off('mamUploadComplete', handleUploadComplete); this.off('renderComplete', handleRenderComplete); } }; } /** * Start render with MAM upload tracking */ async startRenderWithMAMUpload(renderOptions, mamUploadCallbacks = {}) { console.log('🎬 Starting render with MAM upload tracking...'); // Start the render await this.startRender(renderOptions); // Wait for render to start and get job ID from render progress return new Promise((resolve) => { const handleRenderProgress = (data) => { if (data.jobId) { // Set up MAM upload tracking const tracker = this.trackMAMUpload(data.jobId, mamUploadCallbacks); // Remove this temporary listener this.off('renderProgress', handleRenderProgress); resolve({ jobId: data.jobId, tracker }); } }; this.on('renderProgress', handleRenderProgress); }); } /** * Quick render current workspace with mi