ems-editor
Version:
EMS Video Editor SDK - Universal JavaScript SDK for external video editor integration
1,269 lines (1,263 loc) • 71.3 kB
JavaScript
'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