class VideoEditor { constructor() { this.projectId = null; this.project = null; this.clips = []; this.layers = []; this.audioTracks = []; this.playheadMs = 0; this.totalDurationMs = 0; this.isPlaying = false; this.selection = { type: "none" }; this.zoomLevel = 5; this.pixelsPerMs = 0.1; this.undoStack = []; this.redoStack = []; this.init(); } async init() { this.bindEvents(); this.updateTimeRuler(); await this.loadProjects(); } bindEvents() { document .getElementById("new-project-btn") ?.addEventListener("click", () => this.showNewProjectModal()); document .getElementById("create-project") ?.addEventListener("click", () => this.createProject()); document .getElementById("cancel-new-project") ?.addEventListener("click", () => this.hideModal("new-project-modal")); document .getElementById("close-new-project-modal") ?.addEventListener("click", () => this.hideModal("new-project-modal")); document .getElementById("btn-export") ?.addEventListener("click", () => this.showExportModal()); document .getElementById("start-export") ?.addEventListener("click", () => this.startExport()); document .getElementById("cancel-export") ?.addEventListener("click", () => this.hideModal("export-modal")); document .getElementById("close-export-modal") ?.addEventListener("click", () => this.hideModal("export-modal")); document .getElementById("btn-undo") ?.addEventListener("click", () => this.undo()); document .getElementById("btn-redo") ?.addEventListener("click", () => this.redo()); document .getElementById("btn-delete") ?.addEventListener("click", () => this.deleteSelected()); document .getElementById("btn-split") ?.addEventListener("click", () => this.splitAtPlayhead()); document .getElementById("btn-play-pause") ?.addEventListener("click", () => this.togglePlayback()); document .getElementById("btn-preview") ?.addEventListener("click", () => this.togglePlayback()); document .getElementById("zoom-slider") ?.addEventListener("input", (e) => this.setZoom(parseInt(e.target.value)), ); document .getElementById("btn-zoom-in") ?.addEventListener("click", () => this.setZoom(this.zoomLevel + 1)); document .getElementById("btn-zoom-out") ?.addEventListener("click", () => this.setZoom(this.zoomLevel - 1)); document .getElementById("volume-slider") ?.addEventListener("input", (e) => this.setVolume(parseInt(e.target.value)), ); document .getElementById("project-name") ?.addEventListener("change", (e) => this.updateProjectName(e.target.value), ); document.querySelectorAll(".element-btn").forEach((btn) => { btn.addEventListener("click", (e) => { const action = e.currentTarget.dataset.action; if (e.currentTarget.classList.contains("ai-btn")) { this.handleAITool(action); } else { this.handleAddElement(action); } }); }); document .getElementById("confirm-add-text") ?.addEventListener("click", () => this.addTextLayer()); document .getElementById("cancel-add-text") ?.addEventListener("click", () => this.hideModal("add-text-modal")); document .getElementById("close-add-text-modal") ?.addEventListener("click", () => this.hideModal("add-text-modal")); document .getElementById("btn-send-chat") ?.addEventListener("click", () => this.sendChatMessage()); document.getElementById("chat-input")?.addEventListener("keypress", (e) => { if (e.key === "Enter") this.sendChatMessage(); }); document .getElementById("toggle-chat") ?.addEventListener("click", () => this.toggleChatPanel()); document .getElementById("timeline-body") ?.addEventListener("click", (e) => this.handleTimelineClick(e)); document.querySelectorAll(".aspect-btn").forEach((btn) => { btn.addEventListener("click", (e) => { document .querySelectorAll(".aspect-btn") .forEach((b) => b.classList.remove("active")); e.currentTarget.classList.add("active"); }); }); document.addEventListener("keydown", (e) => this.handleKeyboard(e)); } handleKeyboard(e) { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; if (e.ctrlKey || e.metaKey) { switch (e.key.toLowerCase()) { case "z": e.preventDefault(); if (e.shiftKey) this.redo(); else this.undo(); break; case "y": e.preventDefault(); this.redo(); break; case "s": e.preventDefault(); this.saveProject(); break; } } else { switch (e.key) { case " ": e.preventDefault(); this.togglePlayback(); break; case "Delete": case "Backspace": if (this.selection.type !== "none") { e.preventDefault(); this.deleteSelected(); } break; case "s": case "S": this.splitAtPlayhead(); break; case "ArrowLeft": this.movePlayhead(-1000); break; case "ArrowRight": this.movePlayhead(1000); break; } } } async loadProjects() { try { const response = await fetch("/api/video/projects"); const data = await response.json(); this.renderProjectList(data.projects || []); } catch (error) { console.error("Failed to load projects:", error); } } renderProjectList(projects) { const container = document.getElementById("project-list"); if (!container) return; if (projects.length === 0) { container.innerHTML = `
No projects yet

Create your first video project

`; return; } container.innerHTML = projects .map( (p) => `
${p.thumbnail_url ? `` : "🎬"}
${this.escapeHtml(p.name)} ${this.formatDuration(p.total_duration_ms)} • ${p.clips_count} clips
`, ) .join(""); container.querySelectorAll(".project-item").forEach((item) => { item.addEventListener("click", () => this.loadProject(item.dataset.id)); }); } async loadProject(projectId) { try { const response = await fetch(`/api/video/projects/${projectId}`); const data = await response.json(); this.projectId = projectId; this.project = data.project; this.clips = data.clips || []; this.layers = data.layers || []; this.audioTracks = data.audio_tracks || []; this.playheadMs = data.project.playhead_ms || 0; this.totalDurationMs = data.project.total_duration_ms || 0; this.updateUI(); this.renderTimeline(); this.renderPreview(); } catch (error) { console.error("Failed to load project:", error); this.showNotification("Failed to load project", "error"); } } updateUI() { if (!this.project) return; document.getElementById("project-name").value = this.project.name; document.getElementById("project-status").textContent = this.project.status; document.getElementById("stat-duration").textContent = this.formatDuration( this.totalDurationMs, ); document.getElementById("stat-clips").textContent = this.clips.length; document.getElementById("stat-layers").textContent = this.layers.length; document.getElementById("stat-resolution").textContent = `${this.project.resolution_width}x${this.project.resolution_height}`; this.updateTimeDisplay(); this.updateContextDisplay(); } updateTimeDisplay() { document.getElementById("current-time").textContent = this.formatTime( this.playheadMs, ); document.getElementById("total-time").textContent = this.formatTime( this.totalDurationMs, ); document.getElementById("context-playhead").textContent = this.formatDuration(this.playheadMs); } updateContextDisplay() { const selectionContainer = document.getElementById( "context-selection-container", ); const selectionValue = document.getElementById("context-selection"); if (this.selection.type === "none") { selectionContainer.style.display = "none"; } else { selectionContainer.style.display = "flex"; switch (this.selection.type) { case "clip": const clip = this.clips.find((c) => c.id === this.selection.id); selectionValue.textContent = clip ? clip.name : "Clip"; break; case "layer": const layer = this.layers.find((l) => l.id === this.selection.id); selectionValue.textContent = layer ? layer.name : "Layer"; break; default: selectionValue.textContent = this.selection.type; } } } renderTimeline() { this.renderVideoTrack(); this.renderLayersTrack(); this.renderAudioTrack(); this.updatePlayheadPosition(); } renderVideoTrack() { const track = document.getElementById("video-track"); if (!track) return; track.innerHTML = this.clips .map( (clip) => `
${this.escapeHtml(clip.name)}
`, ) .join(""); this.bindBlockEvents(track); } renderLayersTrack() { const track = document.getElementById("layers-track"); if (!track) return; track.innerHTML = this.layers .map( (layer) => `
${this.escapeHtml(layer.name)}
`, ) .join(""); this.bindBlockEvents(track); } renderAudioTrack() { const track = document.getElementById("audio-track"); if (!track) return; track.innerHTML = this.audioTracks .map( (audio) => `
${this.escapeHtml(audio.name)}
`, ) .join(""); this.bindBlockEvents(track); } bindBlockEvents(track) { track.querySelectorAll(".timeline-block").forEach((block) => { block.addEventListener("click", (e) => { e.stopPropagation(); this.selectItem(block.dataset.type, block.dataset.id); }); block.addEventListener("mousedown", (e) => { if (e.target.classList.contains("resize-handle")) { this.startResize( block, e.target.classList.contains("left") ? "left" : "right", e, ); } else { this.startDrag(block, e); } }); }); } startDrag(block, e) { const startX = e.clientX; const startLeft = parseInt(block.style.left) || 0; const onMouseMove = (moveEvent) => { const deltaX = moveEvent.clientX - startX; const newLeft = Math.max(0, startLeft + deltaX); block.style.left = `${newLeft}px`; }; const onMouseUp = async () => { document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); const newStartMs = this.pixelsToMs(parseInt(block.style.left)); await this.updateItemPosition( block.dataset.type, block.dataset.id, newStartMs, ); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); } startResize(block, side, e) { const startX = e.clientX; const startLeft = parseInt(block.style.left) || 0; const startWidth = parseInt(block.style.width) || 100; const onMouseMove = (moveEvent) => { const deltaX = moveEvent.clientX - startX; if (side === "left") { const newLeft = Math.max(0, startLeft + deltaX); const newWidth = startWidth - deltaX; if (newWidth > 20) { block.style.left = `${newLeft}px`; block.style.width = `${newWidth}px`; } } else { const newWidth = Math.max(20, startWidth + deltaX); block.style.width = `${newWidth}px`; } }; const onMouseUp = async () => { document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); const newStartMs = this.pixelsToMs(parseInt(block.style.left)); const newDurationMs = this.pixelsToMs(parseInt(block.style.width)); await this.updateItemTiming( block.dataset.type, block.dataset.id, newStartMs, newDurationMs, ); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); e.stopPropagation(); } async updateItemPosition(type, id, startMs) { if (!this.projectId) return; try { if (type === "clip") { await fetch(`/api/video/clips/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start_ms: startMs }), }); } else if (type === "layer") { const layer = this.layers.find((l) => l.id === id); const duration = layer ? layer.end_ms - layer.start_ms : 5000; await fetch(`/api/video/layers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start_ms: startMs, end_ms: startMs + duration, }), }); } await this.loadProject(this.projectId); } catch (error) { console.error("Failed to update position:", error); } } async updateItemTiming(type, id, startMs, durationMs) { if (!this.projectId) return; try { if (type === "clip") { await fetch(`/api/video/clips/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start_ms: startMs, duration_ms: durationMs }), }); } else if (type === "layer") { await fetch(`/api/video/layers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start_ms: startMs, end_ms: startMs + durationMs, }), }); } await this.loadProject(this.projectId); } catch (error) { console.error("Failed to update timing:", error); } } selectItem(type, id) { this.selection = { type, id }; this.updateContextDisplay(); this.renderTimeline(); this.showPropertiesPanel(type, id); } clearSelection() { this.selection = { type: "none" }; this.updateContextDisplay(); this.renderTimeline(); document.getElementById("properties-panel").style.display = "none"; } showPropertiesPanel(type, id) { const panel = document.getElementById("properties-panel"); const content = document.getElementById("properties-content"); if (!panel || !content) return; let item; if (type === "clip") { item = this.clips.find((c) => c.id === id); } else if (type === "layer") { item = this.layers.find((l) => l.id === id); } if (!item) return; panel.style.display = "block"; content.innerHTML = this.renderPropertiesForm(type, item); content.querySelectorAll("input, select").forEach((input) => { input.addEventListener("change", () => this.saveProperties(type, id)); }); } renderPropertiesForm(type, item) { if (type === "clip") { return `
`; } else if (type === "layer") { return `
`; } return ""; } async saveProperties(type, id) { if (!this.projectId) return; try { if (type === "clip") { await fetch(`/api/video/clips/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: document.getElementById("prop-name")?.value, volume: parseFloat(document.getElementById("prop-volume")?.value || 100) / 100, transition_in: document.getElementById("prop-transition-in")?.value || null, }), }); } else if (type === "layer") { await fetch(`/api/video/layers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: document.getElementById("prop-name")?.value, opacity: parseFloat( document.getElementById("prop-opacity")?.value || 100, ) / 100, x: parseFloat(document.getElementById("prop-x")?.value || 50) / 100, y: parseFloat(document.getElementById("prop-y")?.value || 50) / 100, rotation: parseFloat( document.getElementById("prop-rotation")?.value || 0, ), }), }); } await this.loadProject(this.projectId); } catch (error) { console.error("Failed to save properties:", error); } } renderPreview() { const overlays = document.getElementById("layer-overlays"); if (!overlays) return; if (this.clips.length > 0 && !this.isPlaying) { this.loadPreviewFrame(); } const visibleLayers = this.layers.filter( (l) => this.playheadMs >= l.start_ms && this.playheadMs < l.end_ms, ); overlays.innerHTML = visibleLayers .map((layer) => { const style = ` left: ${layer.x * 100}%; top: ${layer.y * 100}%; width: ${layer.width * 100}%; height: ${layer.height * 100}%; opacity: ${layer.opacity}; transform: translate(-50%, -50%) rotate(${layer.rotation}deg); `; if (layer.layer_type === "text") { const props = layer.properties_json || {}; return `
${this.escapeHtml(props.content || "")}
`; } return `
`; }) .join(""); } handleTimelineClick(e) { const track = e.target.closest(".track-content"); if (!track) return; if (e.target === track) { const rect = track.getBoundingClientRect(); const x = e.clientX - rect.left; this.setPlayhead(this.pixelsToMs(x)); this.clearSelection(); } } setPlayhead(ms) { this.playheadMs = Math.max(0, Math.min(ms, this.totalDurationMs)); this.updatePlayheadPosition(); this.updateTimeDisplay(); this.renderPreview(); } movePlayhead(deltaMs) { this.setPlayhead(this.playheadMs + deltaMs); } updatePlayheadPosition() { const playhead = document.getElementById("playhead"); if (playhead) { playhead.style.left = `${140 + this.msToPixels(this.playheadMs)}px`; } } togglePlayback() { if (this.isPlaying) { this.stopPlayback(); } else { this.startPlayback(); } } startPlayback() { this.isPlaying = true; this.updatePlayButton(); const startTime = Date.now(); const startMs = this.playheadMs; this.playbackInterval = setInterval(() => { const elapsed = Date.now() - startTime; const newMs = startMs + elapsed; if (newMs >= this.totalDurationMs) { this.stopPlayback(); this.setPlayhead(0); } else { this.setPlayhead(newMs); } }, 33); } stopPlayback() { this.isPlaying = false; this.updatePlayButton(); if (this.playbackInterval) { clearInterval(this.playbackInterval); this.playbackInterval = null; } } updatePlayButton() { const btn = document.getElementById("btn-play-pause"); if (btn) { btn.innerHTML = this.isPlaying ? '' : ''; } } setZoom(level) { this.zoomLevel = Math.max(1, Math.min(10, level)); this.pixelsPerMs = 0.02 * this.zoomLevel; document.getElementById("zoom-slider").value = this.zoomLevel; this.updateTimeRuler(); this.renderTimeline(); } updateTimeRuler() { const ruler = document.getElementById("time-ruler"); if (!ruler) return; const duration = Math.max(this.totalDurationMs, 60000); const width = this.msToPixels(duration); const interval = this.getTimeInterval(); let html = ""; for (let ms = 0; ms <= duration; ms += interval) { const x = this.msToPixels(ms); html += `
${this.formatDuration(ms)}
`; } ruler.innerHTML = html; ruler.style.width = `${width}px`; } getTimeInterval() { if (this.pixelsPerMs > 0.5) return 1000; if (this.pixelsPerMs > 0.2) return 5000; if (this.pixelsPerMs > 0.1) return 10000; return 30000; } setVolume(volume) { console.log("Volume set to:", volume); } showNewProjectModal() { this.showModal("new-project-modal"); document.getElementById("new-project-name").value = ""; document.getElementById("new-project-name").focus(); } async createProject() { const name = document.getElementById("new-project-name")?.value || "Untitled Project"; const aspectBtn = document.querySelector(".aspect-btn.active"); const fps = parseInt( document.getElementById("new-project-fps")?.value || "30", ); const resolution_width = parseInt(aspectBtn?.dataset.width || "1920"); const resolution_height = parseInt(aspectBtn?.dataset.height || "1080"); try { const response = await fetch("/api/video/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, resolution_width, resolution_height, fps, }), }); const data = await response.json(); this.hideModal("new-project-modal"); await this.loadProject(data.project.id); this.showNotification("Project created", "success"); } catch (error) { console.error("Failed to create project:", error); this.showNotification("Failed to create project", "error"); } } async updateProjectName(name) { if (!this.projectId) return; try { await fetch(`/api/video/projects/${this.projectId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); } catch (error) { console.error("Failed to update project name:", error); } } handleAddElement(action) { switch (action) { case "add-clip": this.showUploadDialog(); break; case "add-text": this.showModal("add-text-modal"); document.getElementById("text-content").value = ""; document.getElementById("text-content").focus(); break; case "add-image": this.showAddImageDialog(); break; case "add-shape": this.addShape(); break; case "add-audio": this.showAddAudioDialog(); break; case "add-narration": this.showNarrationDialog(); break; } } showAddClipDialog() { const url = prompt("Enter video URL:"); if (url) { this.addClip(url); } } showUploadDialog() { const input = document.createElement("input"); input.type = "file"; input.accept = "video/*"; input.onchange = async (e) => { const file = e.target.files[0]; if (file) { await this.uploadAndAddClip(file); } }; input.click(); } async uploadAndAddClip(file) { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } this.showNotification("Uploading video...", "info"); try { const formData = new FormData(); formData.append("file", file); const response = await fetch( `/api/video/projects/${this.projectId}/upload`, { method: "POST", body: formData, }, ); const data = await response.json(); if (data.file_url) { await this.addClip(data.file_url, file.name); this.showNotification("Video uploaded and added", "success"); } else { this.showNotification( "Upload failed: " + (data.error || "Unknown error"), "error", ); } } catch (error) { console.error("Failed to upload:", error); this.showNotification("Failed to upload video", "error"); } } async addClip(sourceUrl, name = null) { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } try { await fetch(`/api/video/projects/${this.projectId}/clips`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name || "Clip", source_url: sourceUrl, at_ms: this.playheadMs, duration_ms: 5000, }), }); await this.loadProject(this.projectId); this.showNotification("Clip added", "success"); } catch (error) { console.error("Failed to add clip:", error); this.showNotification("Failed to add clip", "error"); } } async addTextLayer() { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } const content = document.getElementById("text-content")?.value || "Text"; const fontFamily = document.getElementById("text-font")?.value || "Arial"; const fontSize = parseInt( document.getElementById("text-size")?.value || "48", ); const color = document.getElementById("text-color")?.value || "#FFFFFF"; const durationSec = parseInt( document.getElementById("text-duration")?.value || "5", ); try { await fetch(`/api/video/projects/${this.projectId}/layers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: content.substring(0, 20), layer_type: "text", start_ms: this.playheadMs, end_ms: this.playheadMs + durationSec * 1000, x: 0.5, y: 0.8, width: 0.8, height: 0.1, properties: { content, font_family: fontFamily, font_size: fontSize, color, }, }), }); this.hideModal("add-text-modal"); await this.loadProject(this.projectId); this.showNotification("Text layer added", "success"); } catch (error) { console.error("Failed to add text layer:", error); this.showNotification("Failed to add text layer", "error"); } } showAddImageDialog() { const url = prompt("Enter image URL:"); if (url) { this.addImageLayer(url); } } async addImageLayer(sourceUrl) { if (!this.projectId) return; try { await fetch(`/api/video/projects/${this.projectId}/layers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Image", layer_type: "image", start_ms: this.playheadMs, end_ms: this.playheadMs + 5000, x: 0.5, y: 0.5, width: 0.5, height: 0.5, properties: { source_url: sourceUrl }, }), }); await this.loadProject(this.projectId); this.showNotification("Image added", "success"); } catch (error) { console.error("Failed to add image:", error); } } async addShape() { if (!this.projectId) return; try { await fetch(`/api/video/projects/${this.projectId}/layers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Rectangle", layer_type: "shape", start_ms: this.playheadMs, end_ms: this.playheadMs + 5000, x: 0.5, y: 0.5, width: 0.3, height: 0.2, properties: { shape_type: "rectangle", fill_color: "#3498db", stroke_color: "#2980b9", stroke_width: 2, }, }), }); await this.loadProject(this.projectId); this.showNotification("Shape added", "success"); } catch (error) { console.error("Failed to add shape:", error); } } showAddAudioDialog() { const url = prompt("Enter audio URL:"); if (url) { this.addAudioTrack(url, "music"); } } showNarrationDialog() { const text = prompt("Enter narration text:"); if (text) { this.generateNarration(text); } } async addAudioTrack(sourceUrl, trackType) { if (!this.projectId) return; try { await fetch(`/api/video/projects/${this.projectId}/audio`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: trackType === "narration" ? "Narration" : "Audio", source_url: sourceUrl, track_type: trackType, start_ms: this.playheadMs, duration_ms: 10000, volume: 1.0, }), }); await this.loadProject(this.projectId); this.showNotification("Audio track added", "success"); } catch (error) { console.error("Failed to add audio:", error); } } async generateNarration(text) { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } this.showNotification("Generating narration...", "info"); try { const response = await fetch( `/api/video/projects/${this.projectId}/tts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, voice: "alloy", speed: 1.0, language: "en", }), }, ); const data = await response.json(); if (data.audio_url) { await this.loadProject(this.projectId); this.showNotification("Narration added!", "success"); } else { this.showNotification(data.error || "TTS failed", "error"); } } catch (error) { console.error("TTS error:", error); this.showNotification("Failed to generate narration", "error"); } } handleAITool(action) { switch (action) { case "auto-captions": this.generateAutoCaptions(); break; case "tts": this.showTTSDialog(); break; case "detect-scenes": this.detectScenes(); break; case "templates": this.showTemplatesDialog(); break; case "reframe": this.showReframeDialog(); break; case "transitions": this.showTransitionsDialog(); break; } } async generateAutoCaptions() { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } if (this.clips.length === 0) { this.showNotification("Add a video clip first", "warning"); return; } this.showNotification( "Generating captions... This may take a moment.", "info", ); try { const response = await fetch( `/api/video/projects/${this.projectId}/captions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ style: "default", max_chars_per_line: 40, font_size: 32, color: "#FFFFFF", background: true, }), }, ); const data = await response.json(); if (data.captions_count) { await this.loadProject(this.projectId); this.showNotification( `Added ${data.captions_count} caption layers!`, "success", ); } else { this.showNotification( data.error || "Caption generation failed", "error", ); } } catch (error) { console.error("Caption error:", error); this.showNotification("Failed to generate captions", "error"); } } showTTSDialog() { const text = prompt("Enter text for narration:"); if (text && text.trim()) { this.generateNarration(text.trim()); } } async detectScenes() { if (!this.projectId || this.clips.length === 0) { this.showNotification("Add a video clip first", "warning"); return; } this.showNotification("Detecting scenes...", "info"); try { const response = await fetch( `/api/video/projects/${this.projectId}/scenes`, { method: "POST" }, ); const data = await response.json(); if (data.scenes && data.scenes.length > 0) { this.showNotification(`Found ${data.scenes.length} scenes!`, "success"); this.showScenesPanel(data.scenes); } else { this.showNotification("No scene changes detected", "info"); } } catch (error) { console.error("Scene detection error:", error); this.showNotification("Failed to detect scenes", "error"); } } showScenesPanel(scenes) { let html = "

Detected Scenes

"; scenes.forEach((scene, i) => { html += `
${i + 1} ${this.formatDuration(scene.start_ms)} - ${this.formatDuration(scene.end_ms)}
`; }); html += "
"; const panel = document.getElementById("properties-panel"); const content = document.getElementById("properties-content"); if (panel && content) { panel.style.display = "block"; content.innerHTML = html; } } async showTemplatesDialog() { try { const response = await fetch("/api/video/templates"); const data = await response.json(); if (data.templates && data.templates.length > 0) { const template = data.templates.find((t) => confirm(`Apply template: ${t.name}?\n${t.description}`), ); if (template) { await this.applyTemplate(template.id); } } } catch (error) { console.error("Templates error:", error); this.showNotification("Failed to load templates", "error"); } } async applyTemplate(templateId) { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } const title = prompt("Enter title for template:") || "Title"; const subtitle = prompt("Enter subtitle (optional):") || ""; try { const response = await fetch( `/api/video/projects/${this.projectId}/template`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_id: templateId, customizations: { title, subtitle }, }), }, ); const data = await response.json(); if (data.success) { await this.loadProject(this.projectId); this.showNotification("Template applied!", "success"); } else { this.showNotification( data.error || "Failed to apply template", "error", ); } } catch (error) { console.error("Template error:", error); this.showNotification("Failed to apply template", "error"); } } showReframeDialog() { if (!this.projectId || this.clips.length === 0) { this.showNotification("Add a video clip first", "warning"); return; } const aspectRatio = prompt( "Select aspect ratio:\n1. 16:9 (Landscape)\n2. 9:16 (Portrait/TikTok)\n3. 1:1 (Square)\n4. 4:5 (Instagram)", "2", ); let width, height; switch (aspectRatio) { case "1": width = 1920; height = 1080; break; case "2": width = 1080; height = 1920; break; case "3": width = 1080; height = 1080; break; case "4": width = 1080; height = 1350; break; default: return; } this.autoReframe(width, height); } async autoReframe(targetWidth, targetHeight) { this.showNotification("Auto-reframing video...", "info"); try { const response = await fetch( `/api/video/projects/${this.projectId}/reframe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target_width: targetWidth, target_height: targetHeight, }), }, ); const data = await response.json(); if (data.reframed_url) { this.showNotification( "Video reframed! Adding as new clip...", "success", ); await this.addClip(data.reframed_url, "Reframed"); } else { this.showNotification(data.error || "Reframe failed", "error"); } } catch (error) { console.error("Reframe error:", error); this.showNotification("Failed to reframe video", "error"); } } showTransitionsDialog() { if (this.clips.length < 2) { this.showNotification("Need at least 2 clips for transitions", "warning"); return; } const transitionType = prompt( "Select transition:\n1. fade\n2. dissolve\n3. wipe\n4. slide", "1", ); const types = { 1: "fade", 2: "dissolve", 3: "wipe", 4: "slide" }; const type = types[transitionType]; if (type) { this.addTransitionBetweenClips(type); } } async addTransitionBetweenClips(transitionType) { if (this.clips.length < 2) return; this.showNotification("Adding transitions...", "info"); try { for (let i = 0; i < this.clips.length - 1; i++) { await fetch( `/api/video/clips/${this.clips[i].id}/transition/${this.clips[i + 1].id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transition_type: transitionType, duration_ms: 500, }), }, ); } await this.loadProject(this.projectId); this.showNotification("Transitions added!", "success"); } catch (error) { console.error("Transition error:", error); this.showNotification("Failed to add transitions", "error"); } } async deleteSelected() { if (this.selection.type === "none") return; try { if (this.selection.type === "clip") { await fetch(`/api/video/clips/${this.selection.id}`, { method: "DELETE", }); } else if (this.selection.type === "layer") { await fetch(`/api/video/layers/${this.selection.id}`, { method: "DELETE", }); } else if (this.selection.type === "audio") { await fetch(`/api/video/audio/${this.selection.id}`, { method: "DELETE", }); } this.clearSelection(); await this.loadProject(this.projectId); this.showNotification("Item deleted", "success"); } catch (error) { console.error("Failed to delete:", error); this.showNotification("Failed to delete", "error"); } } async splitAtPlayhead() { if (this.selection.type !== "clip") { this.showNotification("Select a clip to split", "info"); return; } const clip = this.clips.find((c) => c.id === this.selection.id); if (!clip) return; if ( this.playheadMs <= clip.start_ms || this.playheadMs >= clip.start_ms + clip.duration_ms ) { this.showNotification( "Playhead must be within the selected clip", "warning", ); return; } try { const response = await fetch( `/api/video/clips/${this.selection.id}/split`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ at_ms: this.playheadMs }), }, ); const data = await response.json(); if (data.first_clip && data.second_clip) { this.clearSelection(); await this.loadProject(this.projectId); this.showNotification("Clip split successfully", "success"); } else { this.showNotification(data.error || "Failed to split clip", "error"); } } catch (error) { console.error("Failed to split clip:", error); this.showNotification("Failed to split clip", "error"); } } async loadPreviewFrame() { if (!this.projectId || this.clips.length === 0) return; try { const response = await fetch( `/api/video/projects/${this.projectId}/preview?at_ms=${this.playheadMs}&width=640&height=360`, ); const data = await response.json(); if (data.preview_url) { const canvas = document.getElementById("preview-canvas"); const placeholder = canvas?.querySelector(".preview-placeholder"); if (placeholder) { placeholder.innerHTML = `Preview`; } } } catch (error) { console.log("Preview not available:", error); } } undo() { if (this.undoStack.length === 0) return; const state = this.undoStack.pop(); this.redoStack.push(this.getCurrentState()); this.restoreState(state); } redo() { if (this.redoStack.length === 0) return; const state = this.redoStack.pop(); this.undoStack.push(this.getCurrentState()); this.restoreState(state); } getCurrentState() { return { clips: [...this.clips], layers: [...this.layers], audioTracks: [...this.audioTracks], }; } restoreState(state) { this.clips = state.clips; this.layers = state.layers; this.audioTracks = state.audioTracks; this.renderTimeline(); this.renderPreview(); } saveState() { this.undoStack.push(this.getCurrentState()); this.redoStack = []; } async saveProject() { if (!this.projectId) return; this.showNotification("Project saved", "success"); } showExportModal() { if (!this.projectId) { this.showNotification("Please create a project first", "warning"); return; } this.showModal("export-modal"); document.getElementById("export-progress").style.display = "none"; } async startExport() { const format = document.getElementById("export-format")?.value || "mp4"; const quality = document.getElementById("export-quality")?.value || "high"; try { document.getElementById("export-progress").style.display = "block"; document.getElementById("export-progress-text").textContent = "Starting export..."; const response = await fetch( `/api/video/projects/${this.projectId}/export`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ format, quality }), }, ); const data = await response.json(); this.pollExportStatus(data.export.id); } catch (error) { console.error("Failed to start export:", error); this.showNotification("Failed to start export", "error"); document.getElementById("export-progress").style.display = "none"; } } async pollExportStatus(exportId) { const poll = async () => { try { const response = await fetch(`/api/video/exports/${exportId}/status`); const data = await response.json(); document.getElementById("export-progress-fill").style.width = `${data.progress}%`; document.getElementById("export-progress-text").textContent = `${data.status}: ${data.progress}%`; if (data.status === "completed") { this.showNotification("Export complete!", "success"); if (data.output_url) { window.open(data.output_url, "_blank"); } this.hideModal("export-modal"); } else if (data.status === "failed") { this.showNotification( "Export failed: " + (data.error_message || "Unknown error"), "error", ); document.getElementById("export-progress").style.display = "none"; } else { setTimeout(poll, 2000); } } catch (error) { console.error("Failed to poll export status:", error); } }; poll(); } async sendChatMessage() { const input = document.getElementById("chat-input"); const message = input?.value?.trim(); if (!message || !this.projectId) return; input.value = ""; this.addChatMessage(message, "user"); try { const response = await fetch( `/api/video/projects/${this.projectId}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, playhead_ms: this.playheadMs, selection: this.selection, }), }, ); const data = await response.json(); if (data.success) { this.addChatMessage(data.message, "assistant"); if (data.project) { await this.loadProject(this.projectId); } } else { this.addChatMessage( "Sorry, I could not process that request.", "assistant", ); } } catch (error) { console.error("Chat error:", error); this.addChatMessage("Sorry, something went wrong.", "assistant"); } } addChatMessage(text, role) { const container = document.getElementById("chat-messages"); if (!container) return; const msg = document.createElement("div"); msg.className = `chat-message ${role}`; msg.innerHTML = `

${this.escapeHtml(text)}

`; container.appendChild(msg); container.scrollTop = container.scrollHeight; } toggleChatPanel() { const panel = document.getElementById("chat-panel"); const messages = document.getElementById("chat-messages"); const inputContainer = panel?.querySelector(".chat-input-container"); if (messages && inputContainer) { const isCollapsed = messages.style.display === "none"; messages.style.display = isCollapsed ? "flex" : "none"; inputContainer.style.display = isCollapsed ? "flex" : "none"; } } showModal(id) { const modal = document.getElementById(id); if (modal) modal.style.display = "flex"; } hideModal(id) { const modal = document.getElementById(id); if (modal) modal.style.display = "none"; } showNotification(message, type = "info") { console.log(`[${type.toUpperCase()}] ${message}`); const existing = document.querySelector(".notification"); if (existing) existing.remove(); const notification = document.createElement("div"); notification.className = `notification notification-${type}`; notification.textContent = message; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; background: ${type === "error" ? "#e74c3c" : type === "success" ? "#27ae60" : type === "warning" ? "#f39c12" : "#3498db"}; color: white; font-size: 14px; z-index: 10000; animation: slideIn 0.3s ease; `; document.body.appendChild(notification); setTimeout(() => notification.remove(), 3000); } msToPixels(ms) { return ms * this.pixelsPerMs; } pixelsToMs(pixels) { return Math.round(pixels / this.pixelsPerMs); } formatTime(ms) { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } formatDuration(ms) { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } getLayerColor(type) { switch (type) { case "text": return "#e74c3c"; case "image": return "#27ae60"; case "shape": return "#3498db"; default: return "#9b59b6"; } } escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } } document.addEventListener("DOMContentLoaded", () => { window.videoEditor = new VideoEditor(); });