| | |
| | class PipelineProcessor { |
| | constructor(editor) { |
| | this.editor = editor; |
| | this.scoreCache = this.loadScoreCache(); |
| | this.MIN_TAKE_PIX = 20; |
| | this.STATIC_DIFF = 1.2; |
| | this.BLACK_THR = 12; |
| | this.GAP_SEC = 0.1; |
| | this.TARGET_EXTRA = 2.0; |
| | this.MIN_DUR = 0.5; |
| | this.MAX_GROUP_PAUSE = 0.5; |
| | this.TIME_TOL = 0.02; |
| | } |
| |
|
| | loadScoreCache() { |
| | try { |
| | const cache = localStorage.getItem('pipeline_score_cache'); |
| | return cache ? JSON.parse(cache) : {}; |
| | } catch { |
| | return {}; |
| | } |
| | } |
| |
|
| | saveScoreCache() { |
| | localStorage.setItem('pipeline_score_cache', JSON.stringify(this.scoreCache)); |
| | } |
| |
|
| | getCacheKey(videoName, st, en) { |
| | return `${videoName}|${st.toFixed(3)}|${en.toFixed(3)}`; |
| | } |
| |
|
| | async detectScenes(videoFile, threshold = 30.0) { |
| | return new Promise((resolve) => { |
| | const video = document.createElement('video'); |
| | video.src = URL.createObjectURL(videoFile); |
| | video.muted = true; |
| | |
| | video.onloadedmetadata = async () => { |
| | const duration = video.duration; |
| | const scenes = []; |
| | const sampleRate = 1.0; |
| | let prevFrame = null; |
| | let sceneStart = 0; |
| | |
| | const canvas = document.createElement('canvas'); |
| | canvas.width = 320; |
| | canvas.height = 180; |
| | const ctx = canvas.getContext('2d'); |
| | |
| | for (let t = 0; t < duration; t += sampleRate) { |
| | video.currentTime = t; |
| | await new Promise(r => { |
| | video.onseeked = r; |
| | setTimeout(r, 50); |
| | }); |
| | |
| | ctx.drawImage(video, 0, 0, 320, 180); |
| | const frameData = ctx.getImageData(0, 0, 320, 180).data; |
| | |
| | if (prevFrame) { |
| | const diff = this.calculateFrameDiff(prevFrame, frameData); |
| | if (diff > threshold) { |
| | scenes.push([sceneStart, t]); |
| | sceneStart = t; |
| | } |
| | } |
| | prevFrame = frameData; |
| | } |
| | |
| | if (sceneStart < duration) { |
| | scenes.push([sceneStart, duration]); |
| | } |
| | |
| | if (scenes.length === 0) { |
| | scenes.push([0, duration]); |
| | } |
| | |
| | URL.revokeObjectURL(video.src); |
| | resolve(scenes); |
| | }; |
| | |
| | video.onerror = () => resolve([[0, 10]]); |
| | }); |
| | } |
| |
|
| | calculateFrameDiff(frame1, frame2) { |
| | let diff = 0; |
| | const step = 4; |
| | for (let i = 0; i < frame1.length; i += step * 4) { |
| | diff += Math.abs(frame1[i] - frame2[i]); |
| | diff += Math.abs(frame1[i+1] - frame2[i+1]); |
| | diff += Math.abs(frame1[i+2] - frame2[i+2]); |
| | } |
| | return diff / (frame1.length / step); |
| | } |
| |
|
| | async sampleFrames(videoFile, st, en, n = 3) { |
| | return new Promise((resolve) => { |
| | const video = document.createElement('video'); |
| | video.src = URL.createObjectURL(videoFile); |
| | video.muted = true; |
| | |
| | const frames = []; |
| | const times = []; |
| | const step = (en - st) / (n - 1 || 1); |
| | |
| | for (let i = 0; i < n; i++) { |
| | times.push(st + (step * i)); |
| | } |
| | |
| | const canvas = document.createElement('canvas'); |
| | canvas.width = 640; |
| | canvas.height = 360; |
| | const ctx = canvas.getContext('2d'); |
| | |
| | let index = 0; |
| | |
| | const captureFrame = async () => { |
| | if (index >= times.length) { |
| | URL.revokeObjectURL(video.src); |
| | resolve(frames); |
| | return; |
| | } |
| | |
| | video.currentTime = times[index]; |
| | await new Promise(r => { |
| | video.onseeked = r; |
| | setTimeout(r, 100); |
| | }); |
| | |
| | ctx.drawImage(video, 0, 0, 640, 360); |
| | const imageData = ctx.getImageData(0, 0, 640, 360); |
| | frames.push(imageData); |
| | index++; |
| | captureFrame(); |
| | }; |
| | |
| | video.onloadedmetadata = captureFrame; |
| | video.onerror = () => resolve([]); |
| | }); |
| | } |
| |
|
| | isStatic(frames) { |
| | if (frames.length < 2) return true; |
| | |
| | let diffs = []; |
| | for (let i = 1; i < frames.length; i++) { |
| | const diff = this.calculateFrameDiff(frames[i-1].data, frames[i].data); |
| | diffs.push(diff); |
| | } |
| | |
| | const avgDiff = diffs.reduce((a, b) => a + b, 0) / diffs.length; |
| | return avgDiff < this.STATIC_DIFF * 255; |
| | } |
| |
|
| | |
| | |
| | detectProductBBox(imageData) { |
| | const width = imageData.width; |
| | const height = imageData.height; |
| | const data = imageData.data; |
| | |
| | |
| | let maxVariance = 0; |
| | let bestRegion = { x: 0, y: 0, w: 0, h: 0 }; |
| | |
| | |
| | const gridSize = 50; |
| | for (let y = 0; y < height - gridSize; y += gridSize) { |
| | for (let x = 0; x < width - gridSize; x += gridSize) { |
| | let variance = 0; |
| | let avgR = 0, avgG = 0, avgB = 0; |
| | let count = 0; |
| | |
| | |
| | for (let dy = 0; dy < gridSize; dy += 5) { |
| | for (let dx = 0; dx < gridSize; dx += 5) { |
| | const idx = ((y + dy) * width + (x + dx)) * 4; |
| | avgR += data[idx]; |
| | avgG += data[idx+1]; |
| | avgB += data[idx+2]; |
| | count++; |
| | } |
| | } |
| | |
| | avgR /= count; |
| | avgG /= count; |
| | avgB /= count; |
| | |
| | |
| | for (let dy = 0; dy < gridSize; dy += 5) { |
| | for (let dx = 0; dx < gridSize; dx += 5) { |
| | const idx = ((y + dy) * width + (x + dx)) * 4; |
| | variance += Math.pow(data[idx] - avgR, 2); |
| | variance += Math.pow(data[idx+1] - avgG, 2); |
| | variance += Math.pow(data[idx+2] - avgB, 2); |
| | } |
| | } |
| | |
| | if (variance > maxVariance) { |
| | maxVariance = variance; |
| | bestRegion = { x, y, w: gridSize, h: gridSize }; |
| | } |
| | } |
| | } |
| | |
| | |
| | bestRegion.w = Math.min(bestRegion.w * 3, width - bestRegion.x); |
| | bestRegion.h = Math.min(bestRegion.h * 3, height - bestRegion.y); |
| | |
| | return bestRegion; |
| | } |
| |
|
| | async scoreTake(videoFile, st, en) { |
| | const frames = await this.sampleFrames(videoFile, st, en, 3); |
| | if (frames.length === 0) return 0; |
| | |
| | let totalFocus = 0; |
| | frames.forEach(frame => { |
| | const bbox = this.detectProductBBox(frame); |
| | const area = bbox.w * bbox.h; |
| | const frameArea = frame.width * frame.height; |
| | totalFocus += (area / frameArea); |
| | }); |
| | |
| | const score = totalFocus / frames.length; |
| | return Math.min(score * 100, 100); |
| | } |
| |
|
| | async scoreTakeCached(videoFile, st, en) { |
| | const key = this.getCacheKey(videoFile.name, st, en); |
| | if (this.scoreCache[key]) { |
| | return this.scoreCache[key]; |
| | } |
| | |
| | const score = await this.scoreTake(videoFile, st, en); |
| | this.scoreCache[key] = score; |
| | this.saveScoreCache(); |
| | return score; |
| | } |
| |
|
| | async groupScenesAdvanced(videoFile, scenes, minJump = 0.15) { |
| | const takes = []; |
| | |
| | for (const [start, end] of scenes) { |
| | const frames = await this.sampleFrames(videoFile, start, end, 4); |
| | if (frames.length < 2) { |
| | if (end - start > this.MIN_DUR) { |
| | takes.push([start, end]); |
| | } |
| | continue; |
| | } |
| | |
| | let lastArea = null; |
| | let lastIdx = start; |
| | |
| | for (let i = 0; i < frames.length; i++) { |
| | const bbox = this.detectProductBBox(frames[i]); |
| | const area = bbox.w * bbox.h; |
| | |
| | if (lastArea !== null) { |
| | const diff = Math.abs(area - lastArea) / Math.max(area, lastArea, 1); |
| | if (diff > minJump) { |
| | const tStart = start + ((i-1) * (end - start) / (frames.length - 1)); |
| | const tEnd = start + (i * (end - start) / (frames.length - 1)); |
| | |
| | if (tEnd - lastIdx > this.MIN_DUR) { |
| | takes.push([lastIdx, tEnd]); |
| | lastIdx = tEnd; |
| | } |
| | } |
| | } |
| | lastArea = area; |
| | } |
| | |
| | if (end - lastIdx > this.MIN_DUR) { |
| | takes.push([lastIdx, end]); |
| | } |
| | } |
| | |
| | return takes.length > 0 ? takes : [[0, await this.getVideoDuration(videoFile)]]; |
| | } |
| |
|
| | async getVideoDuration(videoFile) { |
| | return new Promise((resolve) => { |
| | const video = document.createElement('video'); |
| | video.src = URL.createObjectURL(videoFile); |
| | video.onloadedmetadata = () => { |
| | URL.revokeObjectURL(video.src); |
| | resolve(video.duration); |
| | }; |
| | video.onerror = () => resolve(10); |
| | }); |
| | } |
| |
|
| | generateTakeHash(videoFile, st, en) { |
| | |
| | return btoa(`${videoFile.name}:${st.toFixed(2)}:${en.toFixed(2)}`); |
| | } |
| |
|
| | async processPipeline(scriptBlocks, videoFiles, onProgress) { |
| | const groupedBlocks = this.groupSRTBlocks(scriptBlocks); |
| | const target = scriptBlocks[scriptBlocks.length - 1]?.endTime || 60; |
| | const targetWithExtra = target + this.TARGET_EXTRA; |
| | |
| | onProgress?.('Analisando cenas nos vídeos...', 10); |
| | |
| | |
| | const scenesByVid = {}; |
| | for (let i = 0; i < videoFiles.length; i++) { |
| | const vid = videoFiles[i]; |
| | const scenes = await this.detectScenes(vid); |
| | const takes = await this.groupScenesAdvanced(vid, scenes); |
| | scenesByVid[i] = takes; |
| | onProgress?.(`Vídeo ${i+1}/${videoFiles.length} analisado`, 10 + ((i+1) / videoFiles.length * 20)); |
| | } |
| | |
| | onProgress?.('Pontuando takes...', 30); |
| | |
| | |
| | const candidatesPerBlock = {}; |
| | for (let idx = 0; idx < groupedBlocks.length; idx++) { |
| | const [bs, be, blocks] = groupedBlocks[idx]; |
| | const blk = `${idx+1}`.padStart(2, '0'); |
| | const tasks = []; |
| | |
| | for (let vidIdx = 0; vidIdx < videoFiles.length; vidIdx++) { |
| | const takes = scenesByVid[vidIdx]; |
| | for (const [st, en] of takes) { |
| | if (en > bs && st < be) { |
| | const st2 = Math.max(st, bs) - this.GAP_SEC; |
| | const en2 = Math.min(en, be) + this.GAP_SEC; |
| | const dur = en2 - st2; |
| | |
| | if (dur < this.MIN_DUR) continue; |
| | |
| | |
| | const frames = await this.sampleFrames(videoFiles[vidIdx], st2, en2, 3); |
| | if (this.isStatic(frames)) continue; |
| | |
| | tasks.push({ |
| | block: blk, |
| | videoIdx: vidIdx, |
| | videoFile: videoFiles[vidIdx], |
| | start: st2, |
| | end: en2, |
| | sceneStart: st, |
| | sceneEnd: en |
| | }); |
| | } |
| | } |
| | } |
| | |
| | |
| | const results = await Promise.all(tasks.map(async t => { |
| | const score = await this.scoreTakeCached(t.videoFile, t.start, t.end); |
| | return { ...t, score }; |
| | })); |
| | |
| | results.sort((a, b) => b.score - a.score); |
| | candidatesPerBlock[blk] = results; |
| | } |
| | |
| | onProgress?.('Selecionando takes ótimos...', 60); |
| | |
| | |
| | const picks = []; |
| | const usedHashes = new Set(); |
| | |
| | for (const blk in candidatesPerBlock) { |
| | const cands = candidatesPerBlock[blk]; |
| | let selected = null; |
| | |
| | for (const c of cands) { |
| | const h = this.generateTakeHash(c.videoFile, c.start, c.end); |
| | if (!usedHashes.has(h)) { |
| | selected = { ...c, hash: h }; |
| | usedHashes.add(h); |
| | break; |
| | } |
| | } |
| | |
| | if (!selected && cands.length > 0) { |
| | |
| | const c = cands[0]; |
| | selected = { ...c, hash: this.generateTakeHash(c.videoFile, c.start, c.end) + '_dup' }; |
| | } |
| | |
| | if (selected) { |
| | picks.push(selected); |
| | } |
| | } |
| | |
| | onProgress?.('Ajustando timing...', 80); |
| | |
| | |
| | const total = picks.reduce((sum, p) => sum + (p.end - p.start), 0); |
| | let slack = targetWithExtra - total; |
| | |
| | if (Math.abs(slack) > this.TIME_TOL) { |
| | |
| | for (let i = picks.length - 1; i >= 0; i--) { |
| | const p = picks[i]; |
| | const avail = p.sceneEnd - p.end; |
| | const ext = Math.min(avail, slack); |
| | if (ext > 0) { |
| | p.end += ext; |
| | slack -= ext; |
| | } |
| | if (Math.abs(slack) <= this.TIME_TOL) break; |
| | } |
| | |
| | if (Math.abs(slack) > this.TIME_TOL && picks.length > 0) { |
| | picks[picks.length - 1].end += slack; |
| | } |
| | } |
| | |
| | onProgress?.('Finalizado!', 100); |
| | |
| | return { |
| | picks, |
| | groupedBlocks, |
| | targetDuration: targetWithExtra |
| | }; |
| | } |
| |
|
| | groupSRTBlocks(blocks) { |
| | const MIN_GROUP_DUR = 1.1; |
| | const MAX_GAP = 0.25; |
| | |
| | const grouped = []; |
| | let current = []; |
| | let currentStart = null; |
| | let currentEnd = null; |
| | |
| | for (const block of blocks) { |
| | if (!current.length) { |
| | currentStart = block.startTime; |
| | currentEnd = block.endTime; |
| | current = [block]; |
| | } else { |
| | const gap = block.startTime - currentEnd; |
| | const groupDur = block.endTime - currentStart; |
| | |
| | if (gap <= MAX_GAP && (groupDur <= MIN_GROUP_DUR || current.length < 2)) { |
| | current.push(block); |
| | currentEnd = block.endTime; |
| | } else { |
| | grouped.push([currentStart, currentEnd, [...current]]); |
| | current = [block]; |
| | currentStart = block.startTime; |
| | currentEnd = block.endTime; |
| | } |
| | } |
| | } |
| | |
| | if (current.length) { |
| | grouped.push([currentStart, currentEnd, [...current]]); |
| | } |
| | |
| | return grouped; |
| | } |
| | } |
| |
|
| | class VideoEditorInteligente { |
| | constructor() { |
| | this.srtFile = null; |
| | this.audioFile = null; |
| | this.videoFiles = []; |
| | this.scriptBlocks = []; |
| | this.analyzedTakes = []; |
| | this.targetAudience = null; |
| | this.finalTimeline = []; |
| | this.blockGroups = []; |
| | this.canvasDimensions = { width: 1920, height: 1080 }; |
| | this.isRendering = false; |
| | this.pipeline = new PipelineProcessor(this); |
| | |
| | this.init(); |
| | } |
| |
|
| | init() { |
| | this.setupDragDrop(); |
| | this.setupAudienceSelection(); |
| | this.setupAnalyzeButton(); |
| | } |
| |
|
| | setupDragDrop() { |
| | const setupZone = (zoneId, handler, accept = null, multiple = false) => { |
| | const zone = document.getElementById(zoneId); |
| | if (!zone) return; |
| | |
| | zone.addEventListener('dragover', (e) => { |
| | e.preventDefault(); |
| | zone.classList.add('dragover'); |
| | }); |
| | zone.addEventListener('dragleave', () => { |
| | zone.classList.remove('dragover'); |
| | }); |
| | zone.addEventListener('drop', (e) => { |
| | e.preventDefault(); |
| | zone.classList.remove('dragover'); |
| | handler(e.dataTransfer.files); |
| | }); |
| | zone.addEventListener('click', () => { |
| | const input = document.createElement('input'); |
| | input.type = 'file'; |
| | if (accept) input.accept = accept; |
| | input.multiple = multiple; |
| | input.addEventListener('change', (e) => handler(e.target.files)); |
| | input.click(); |
| | }); |
| | }; |
| |
|
| | setupZone('srtZone', this.handleSrtFiles.bind(this), '.srt'); |
| | setupZone('audioZone', this.handleAudioFiles.bind(this), 'audio/*,video/*'); |
| | setupZone('videoZone', this.handleVideoFiles.bind(this), 'video/*', true); |
| | } |
| |
|
| | setupAudienceSelection() { |
| | document.querySelectorAll('.audience-option').forEach(option => { |
| | option.addEventListener('click', () => { |
| | document.querySelectorAll('.audience-option').forEach(o => o.classList.remove('selected')); |
| | option.classList.add('selected'); |
| | this.targetAudience = option.dataset.audience; |
| | this.checkAnalyzeReady(); |
| | }); |
| | }); |
| | } |
| |
|
| | setupAnalyzeButton() { |
| | document.getElementById('analyzeBtn').addEventListener('click', () => this.startCompleteAnalysis()); |
| | } |
| |
|
| | async handleSrtFiles(files) { |
| | const srtFile = Array.from(files).find(f => f.name.endsWith('.srt')); |
| | if (srtFile) { |
| | this.srtFile = srtFile; |
| | this.updateFileList('srtFiles', [srtFile], 'srt'); |
| | await this.parseAndPreviewSRT(srtFile); |
| | this.checkAnalyzeReady(); |
| | } |
| | } |
| |
|
| | async handleAudioFiles(files) { |
| | const audioFile = Array.from(files).find(f => |
| | f.type.startsWith('audio/') || f.type.startsWith('video/') |
| | ); |
| | if (audioFile) { |
| | this.audioFile = audioFile; |
| | this.updateFileList('audioFiles', [audioFile], 'audio'); |
| | this.checkAnalyzeReady(); |
| | } |
| | } |
| |
|
| | async handleVideoFiles(files) { |
| | this.videoFiles = Array.from(files).filter(f => f.type.startsWith('video/')); |
| | this.updateFileList('videoFiles', this.videoFiles, 'video'); |
| | |
| | if (this.videoFiles.length > 0) { |
| | await this.analyzeVideosDirect(); |
| | } |
| | |
| | this.checkAnalyzeReady(); |
| | } |
| |
|
| | updateFileList(containerId, files, type) { |
| | const container = document.getElementById(containerId); |
| | if (!container) return; |
| | |
| | container.innerHTML = files.map((file, index) => ` |
| | <div class="file-item ${type === 'audio' ? 'audio-file' : ''}"> |
| | <div class="file-info"> |
| | <i data-feather="${type === 'srt' ? 'file-text' : (type === 'audio' ? 'mic' : 'film')}"></i> |
| | <span>${file.name}</span> |
| | </div> |
| | <button class="file-remove" onclick="editor.removeFile('${type}', ${index})"> |
| | <i data-feather="x"></i> |
| | </button> |
| | </div> |
| | `).join(''); |
| | |
| | feather.replace(); |
| | } |
| |
|
| | removeFile(type, index) { |
| | switch(type) { |
| | case 'srt': |
| | this.srtFile = null; |
| | document.getElementById('srtFiles').innerHTML = ''; |
| | break; |
| | case 'audio': |
| | this.audioFile = null; |
| | document.getElementById('audioFiles').innerHTML = ''; |
| | break; |
| | case 'video': |
| | this.videoFiles.splice(index, 1); |
| | this.updateFileList('videoFiles', this.videoFiles, 'video'); |
| | break; |
| | } |
| | this.checkAnalyzeReady(); |
| | } |
| |
|
| | checkAnalyzeReady() { |
| | const btn = document.getElementById('analyzeBtn'); |
| | if (!btn) return; |
| | btn.disabled = !this.srtFile || !this.audioFile || this.videoFiles.length === 0 || !this.targetAudience; |
| | } |
| |
|
| | |
| | async analyzeVideosDirect() { |
| | console.log('🎬 Analisando vídeos diretamente...'); |
| | this.analyzedTakes = []; |
| | |
| | for (let i = 0; i < this.videoFiles.length; i++) { |
| | const file = this.videoFiles[i]; |
| | console.log(`Processando vídeo ${i}: ${file.name}`); |
| | |
| | try { |
| | |
| | const video = document.createElement('video'); |
| | video.src = URL.createObjectURL(file); |
| | |
| | |
| | await new Promise((resolve, reject) => { |
| | const timeout = setTimeout(() => { |
| | console.log(`Timeout vídeo ${i}, usando fallback`); |
| | resolve(); |
| | }, 5000); |
| |
|
| | video.onloadedmetadata = () => { |
| | clearTimeout(timeout); |
| | resolve(); |
| | }; |
| |
|
| | video.onerror = () => { |
| | clearTimeout(timeout); |
| | resolve(); |
| | }; |
| | }); |
| | |
| | |
| | const duration = video.duration || 10; |
| | const takes = this.generateDirectTakes(i, file, duration); |
| | |
| | this.analyzedTakes.push({ |
| | file: file, |
| | fileIndex: i, |
| | takes: takes, |
| | video: video, |
| | duration: duration |
| | }); |
| | |
| | console.log(`✅ Vídeo ${i} processado: ${takes.length} takes`); |
| | |
| | } catch (error) { |
| | console.log(`⚠️ Erro vídeo ${i}, criando fallback`); |
| | |
| | |
| | const takes = this.generateDirectTakes(i, file, 10); |
| | this.analyzedTakes.push({ |
| | file: file, |
| | fileIndex: i, |
| | takes: takes, |
| | video: null, |
| | duration: 10 |
| | }); |
| | } |
| | } |
| | |
| | console.log(`🎉 Análise concluída: ${this.analyzedTakes.length} vídeos`); |
| | } |
| |
|
| | |
| | generateDirectTakes(fileIndex, file, duration) { |
| | const themes = ['produto', 'detalhe', 'uso', 'qualidade', 'design', 'conforto', 'geral']; |
| | const takes = []; |
| | const numTakes = Math.min(5, Math.max(3, Math.floor(duration / 3))); |
| | |
| | for (let i = 0; i < numTakes; i++) { |
| | const theme = themes[i % themes.length]; |
| | const takeDuration = Math.min(5, Math.max(2, duration / numTakes)); |
| | const startTime = (duration / numTakes) * i; |
| | |
| | takes.push({ |
| | fileIndex: fileIndex, |
| | fileName: file.name, |
| | theme: theme, |
| | duration: takeDuration, |
| | angle: ['Frontal', 'Close-up', 'Ambiente'][Math.floor(Math.random() * 3)], |
| | quality: 70 + Math.random() * 30, |
| | relevanceScore: 70 + Math.random() * 30, |
| | startTime: Math.max(0, startTime), |
| | endTime: Math.min(duration, startTime + takeDuration), |
| | video: null |
| | }); |
| | } |
| | |
| | return takes; |
| | } |
| |
|
| | async parseAndPreviewSRT(file) { |
| | return new Promise((resolve) => { |
| | const reader = new FileReader(); |
| | reader.onload = (e) => { |
| | const content = e.target.result; |
| | const blocks = this.parseSRTContent(content); |
| | |
| | const preview = document.getElementById('srtPreview'); |
| | const previewContent = document.getElementById('srtPreviewContent'); |
| | |
| | if (preview && previewContent) { |
| | preview.style.display = 'block'; |
| | previewContent.innerHTML = blocks.slice(0, 5).map(block => ` |
| | <div class="srt-block"> |
| | <strong>#${block.id}</strong> ${block.startTime} → ${block.endTime}<br> |
| | "${block.text}" |
| | </div> |
| | `).join(''); |
| | } |
| | |
| | resolve(blocks); |
| | }; |
| | reader.readAsText(file); |
| | }); |
| | } |
| |
|
| | parseSRTContent(content) { |
| | const blocks = []; |
| | const standardBlocks = content.trim().split(/\n\s*\n/); |
| | |
| | standardBlocks.forEach((block, index) => { |
| | const lines = block.split('\n').filter(l => l.trim()); |
| | if (lines.length >= 3) { |
| | const timeMatch = lines[1].match(/(\d+):(\d+):(\d+),(\d+)\s*-->\s*(\d+):(\d+):(\d+),(\d+)/); |
| | if (timeMatch) { |
| | const startTime = this.timeToSeconds(timeMatch[1], timeMatch[2], timeMatch[3], timeMatch[4]); |
| | const endTime = this.timeToSeconds(timeMatch[5], timeMatch[6], timeMatch[7], timeMatch[8]); |
| | blocks.push({ |
| | id: parseInt(lines[0]) || index + 1, |
| | startTime, |
| | endTime, |
| | duration: endTime - startTime, |
| | text: lines.slice(2).join(' ').trim(), |
| | theme: this.extractTheme(lines.slice(2).join(' ')) |
| | }); |
| | } |
| | } |
| | }); |
| | |
| | return blocks; |
| | } |
| |
|
| | extractTheme(text) { |
| | const lowerText = text.toLowerCase(); |
| | if (lowerText.includes('produto') || lowerText.includes('mostra')) return 'produto'; |
| | if (lowerText.includes('detalhe') || lowerText.includes('close')) return 'detalhe'; |
| | if (lowerText.includes('uso') || lowerText.includes('aplicação')) return 'uso'; |
| | if (lowerText.includes('qualidade') || lowerText.includes('resistente')) return 'qualidade'; |
| | if (lowerText.includes('design') || lowerText.includes('estilo')) return 'design'; |
| | if (lowerText.includes('conforto') || lowerText.includes('macio')) return 'conforto'; |
| | return 'geral'; |
| | } |
| |
|
| | timeToSeconds(h, m, s, ms) { |
| | return parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s) + parseInt(ms) / 1000; |
| | } |
| |
|
| | |
| | async startCompleteAnalysis() { |
| | const loadingSection = document.getElementById('loadingSection'); |
| | const resultsSection = document.getElementById('resultsSection'); |
| | |
| | if (loadingSection) loadingSection.classList.add('active'); |
| | if (resultsSection) resultsSection.classList.remove('active'); |
| | |
| | console.log('🚀 Iniciando análise direta...'); |
| | |
| | |
| | const content = await new Promise(resolve => { |
| | const reader = new FileReader(); |
| | reader.onload = e => resolve(e.target.result); |
| | reader.readAsText(this.srtFile); |
| | }); |
| | |
| | this.scriptBlocks = this.parseSRTContent(content); |
| | |
| | |
| | this.processScriptBlocks(); |
| | this.generateTimeline(); |
| | this.displayResults(); |
| | |
| | if (loadingSection) loadingSection.classList.remove('active'); |
| | if (resultsSection) resultsSection.classList.add('active'); |
| | |
| | console.log('✅ Análise concluída com sucesso!'); |
| | } |
| |
|
| | processScriptBlocks() { |
| | console.log('📝 Processando blocos do script...'); |
| | this.blockGroups = []; |
| | let currentGroup = []; |
| | let currentTheme = null; |
| | let groupStartTime = 0; |
| | |
| | this.scriptBlocks.forEach((block, index) => { |
| | if (block.theme !== currentTheme || currentGroup.length >= 3) { |
| | if (currentGroup.length > 0) { |
| | this.createGroupDirect(currentGroup, currentTheme, groupStartTime); |
| | } |
| | currentGroup = [block]; |
| | currentTheme = block.theme; |
| | groupStartTime = block.startTime; |
| | } else { |
| | currentGroup.push(block); |
| | } |
| | }); |
| | |
| | if (currentGroup.length > 0) { |
| | this.createGroupDirect(currentGroup, currentTheme, groupStartTime); |
| | } |
| | |
| | console.log(`✅ Criados ${this.blockGroups.length} grupos`); |
| | } |
| |
|
| | createGroupDirect(blocks, theme, startTime) { |
| | |
| | const allTakes = this.analyzedTakes.flatMap(v => v.takes); |
| | const bestTakes = allTakes.filter(t => t.theme === theme); |
| | |
| | let selectedTake = bestTakes.length > 0 |
| | ? bestTakes[Math.floor(Math.random() * bestTakes.length)] |
| | : allTakes[Math.floor(Math.random() * allTakes.length)]; |
| | |
| | const duration = blocks.reduce((sum, block) => sum + block.duration, 0); |
| | |
| | const group = { |
| | id: this.blockGroups.length + 1, |
| | type: blocks.length > 1 ? 'contextual' : 'single', |
| | theme: theme, |
| | blocks: blocks, |
| | take: selectedTake, |
| | startTime: startTime, |
| | endTime: startTime + duration, |
| | duration: duration, |
| | flowScore: 85 |
| | }; |
| | |
| | this.blockGroups.push(group); |
| | } |
| |
|
| | generateTimeline() { |
| | console.log('⏰ Gerando timeline...'); |
| | this.finalTimeline = this.blockGroups.map(group => ({ |
| | groupId: group.id, |
| | type: group.type, |
| | theme: group.theme, |
| | startTime: group.startTime, |
| | endTime: group.endTime, |
| | duration: group.duration, |
| | blocks: group.blocks.map(b => b.id), |
| | blockTexts: group.blocks.map(b => b.text), |
| | take: group.take, |
| | flowScore: group.flowScore, |
| | hasTake: !!group.take, |
| | hasAudio: !!this.audioFile |
| | })); |
| | |
| | console.log(`✅ Timeline gerada: ${this.finalTimeline.length} itens`); |
| | } |
| |
|
| | displayResults() { |
| | console.log('📊 Exibindo resultados...'); |
| | |
| | const totalBlocks = document.getElementById('totalBlocks'); |
| | const totalTakes = document.getElementById('totalTakes'); |
| | const groupedBlocks = document.getElementById('groupedBlocks'); |
| | const flowScore = document.getElementById('flowScore'); |
| | const syncAccuracy = document.getElementById('syncAccuracy'); |
| | const efficiency = document.getElementById('efficiency'); |
| | const finalDuration = document.getElementById('finalDuration'); |
| | |
| | const totalTime = this.finalTimeline.reduce((sum, item) => sum + item.duration, 0); |
| | |
| | if (totalBlocks) totalBlocks.textContent = this.scriptBlocks.length; |
| | if (totalTakes) totalTakes.textContent = this.finalTimeline.filter(item => item.hasTake).length; |
| | if (groupedBlocks) groupedBlocks.textContent = this.blockGroups.length; |
| | if (flowScore) flowScore.textContent = '85%'; |
| | if (syncAccuracy) syncAccuracy.textContent = '95%'; |
| | if (efficiency) efficiency.textContent = '88%'; |
| | if (finalDuration) finalDuration.textContent = `${totalTime.toFixed(1)}s`; |
| | |
| | this.displayTimeline(); |
| | this.displayGroupedBlocks(); |
| | } |
| |
|
| | displayTimeline() { |
| | const ruler = document.getElementById('timelineRuler'); |
| | const takes = document.getElementById('timelineTakes'); |
| | |
| | if (!ruler || !takes || !this.finalTimeline) return; |
| | |
| | ruler.innerHTML = ''; |
| | takes.innerHTML = ''; |
| | |
| | const totalTime = Math.max(...this.finalTimeline.map(t => t.endTime)); |
| | |
| | for (let i = 0; i <= totalTime; i += 10) { |
| | const marker = document.createElement('div'); |
| | marker.style.position = 'absolute'; |
| | marker.style.left = `${(i / totalTime) * 100}%`; |
| | marker.style.top = '0'; |
| | marker.style.fontSize = '10px'; |
| | marker.style.color = 'rgba(255,255,255,0.5)'; |
| | marker.textContent = this.formatTime(i); |
| | ruler.appendChild(marker); |
| | } |
| | |
| | const audioTrack = document.createElement('div'); |
| | audioTrack.className = 'timeline-take audio-track'; |
| | audioTrack.style.left = '0%'; |
| | audioTrack.style.width = '100%'; |
| | audioTrack.style.top = '0px'; |
| | audioTrack.textContent = '🎵 Áudio Narrador'; |
| | takes.appendChild(audioTrack); |
| | |
| | this.finalTimeline.forEach((item, index) => { |
| | const takeElement = document.createElement('div'); |
| | takeElement.className = item.hasTake |
| | ? 'timeline-take contextual' |
| | : 'timeline-take missing'; |
| | |
| | takeElement.style.left = `${(item.startTime / totalTime) * 100}%`; |
| | takeElement.style.width = `${(item.duration / totalTime) * 100}%`; |
| | takeElement.style.top = `${(index % 3) * 50 + 30}px`; |
| | |
| | const label = item.hasTake |
| | ? `Grupo ${item.groupId} (${item.theme})` |
| | : `Grupo ${item.groupId} (Sem Take)`; |
| | |
| | takeElement.textContent = label; |
| | takes.appendChild(takeElement); |
| | }); |
| | } |
| |
|
| | displayGroupedBlocks() { |
| | const container = document.getElementById('blocksContainer'); |
| | if (!container || !this.blockGroups) return; |
| | |
| | container.innerHTML = ''; |
| | |
| | this.blockGroups.forEach((group) => { |
| | const groupElement = document.createElement('div'); |
| | groupElement.className = 'script-group contextual-group'; |
| | groupElement.id = `group-${group.id}`; |
| | |
| | const blocksHtml = group.blocks.map(block => ` |
| | <div class="script-block"> |
| | <div class="block-header"> |
| | <div class="block-number">Bloco ${block.id}</div> |
| | <div class="theme-badge"> |
| | <i data-feather="tag"></i> |
| | ${block.theme} |
| | </div> |
| | </div> |
| | <div class="script-text">${block.text}</div> |
| | </div> |
| | `).join(''); |
| | |
| | groupElement.innerHTML = ` |
| | <div class="group-header"> |
| | <div class="group-info"> |
| | <span class="group-badge contextual"> |
| | <i data-feather="layers"></i> Grupo Contextual |
| | </span> |
| | <span class="theme-label"> |
| | <i data-feather="hash"></i> |
| | Tema: ${group.theme} |
| | </span> |
| | </div> |
| | </div> |
| | ${blocksHtml} |
| | ${group.take ? this.generateTakeHtml(group) : ''} |
| | `; |
| | |
| | container.appendChild(groupElement); |
| | }); |
| | |
| | feather.replace(); |
| | } |
| |
|
| | generateTakeHtml(group) { |
| | const take = group.take; |
| | return ` |
| | <div class="take-selection"> |
| | <div class="take-video-container"> |
| | <video class="take-video" muted controls> |
| | <source src="${URL.createObjectURL(this.videoFiles[take.fileIndex])}" type="video/mp4"> |
| | </video> |
| | <div class="take-overlay"> |
| | Score: ${take.relevanceScore.toFixed(1)}/100 |
| | </div> |
| | </div> |
| | <div class="take-details"> |
| | <h4><i data-feather="film"></i> ${take.fileName}</h4> |
| | <div class="take-metrics"> |
| | <div class="metric-item"> |
| | <span class="metric-label">Tema</span> |
| | <span class="metric-value">${take.theme}</span> |
| | </div> |
| | <div class="metric-item"> |
| | <span class="metric-label">Ângulo</span> |
| | <span class="metric-value">${take.angle}</span> |
| | </div> |
| | <div class="metric-item"> |
| | <span class="metric-label">Duração</span> |
| | <span class="metric-value">${take.duration.toFixed(1)}s</span> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | `; |
| | } |
| |
|
| | formatTime(seconds) { |
| | const mins = Math.floor(seconds / 60); |
| | const secs = Math.floor(seconds % 60); |
| | return `${mins}:${secs.toString().padStart(2, '0')}`; |
| | } |
| |
|
| | |
| | async exportFinalVideo() { |
| | if (!this.finalTimeline || this.finalTimeline.length === 0) { |
| | alert('Nenhuma timeline disponível para exportação.'); |
| | return; |
| | } |
| |
|
| | const validTimeline = this.finalTimeline.filter(item => item.hasTake); |
| | |
| | if (validTimeline.length === 0) { |
| | alert('Nenhum vídeo disponível para exportação.'); |
| | return; |
| | } |
| |
|
| | |
| | const choice = confirm( |
| | 'Pipeline Web: Escolha o método de exportação:\n\n' + |
| | '• OK = Gerar Preview Web (concatena browser-side - lento mas funciona)\n' + |
| | '• Cancelar = Exportar script Python/FFmpeg (recomendado para produção)' |
| | ); |
| |
|
| | if (choice) { |
| | await this.exportWebPreview(validTimeline); |
| | } else { |
| | this.exportFFmpegCommands(validTimeline); |
| | } |
| | } |
| |
|
| | exportFFmpegCommands(timeline) { |
| | let concatContent = ''; |
| | let indexData = []; |
| | |
| | timeline.forEach((item, idx) => { |
| | const take = item.take; |
| | const fileName = `corte_${idx+1}_${take.fileName}`; |
| | const cmd = `ffmpeg -ss ${take.startTime.toFixed(3)} -i "${take.fileName}" -t ${take.duration.toFixed(3)} -c copy ${fileName}`; |
| | |
| | concatContent += `# ${item.theme} (Score: ${take.score.toFixed(1)})\n${cmd}\n\n`; |
| | |
| | indexData.push({ |
| | trecho: idx+1, |
| | arquivo_origem: take.fileName, |
| | inicio_ms: Math.round(take.startTime * 1000), |
| | fim_ms: Math.round(take.endTime * 1000), |
| | score: take.score.toFixed(2), |
| | tema: item.theme |
| | }); |
| | }); |
| | |
| | |
| | concatContent += `\n# Comando para unir tudo:\n# ffmpeg -f concat -i lista.txt -c copy video_final.mp4\n\n`; |
| | concatContent += `# Ou use o arquivo index.json gerado para automação\n`; |
| | |
| | |
| | const blob = new Blob([concatContent], { type: 'text/plain' }); |
| | const url = URL.createObjectURL(blob); |
| | const a = document.createElement('a'); |
| | a.href = url; |
| | a.download = `pipeline_comandos_${Date.now()}.sh`; |
| | a.click(); |
| | |
| | |
| | const jsonBlob = new Blob([JSON.stringify(indexData, null, 2)], { type: 'application/json' }); |
| | const jsonUrl = URL.createObjectURL(jsonBlob); |
| | const jsonA = document.createElement('a'); |
| | jsonA.href = jsonUrl; |
| | jsonA.download = `index_${Date.now()}.json`; |
| | jsonA.click(); |
| | |
| | alert('✅ Arquivos exportados:\n• Script FFmpeg (.sh)\n• Index JSON com metadados\n\nExecute o script no terminal com seus vídeos originais.'); |
| | } |
| |
|
| | async exportWebPreview(timeline) { |
| | if (this.isRendering) { |
| | alert('Renderização já em andamento...'); |
| | return; |
| | } |
| |
|
| | this.isRendering = true; |
| | console.log('🎬 INICIANDO GERAÇÃO WEB...'); |
| | this.showExportProgress('Concatenando takes...'); |
| | |
| | try { |
| | const videoBlob = await this.createVideoComposite(timeline); |
| | this.hideExportProgress(); |
| | |
| | const url = URL.createObjectURL(videoBlob); |
| | const a = document.createElement('a'); |
| | a.href = url; |
| | a.download = `video_final_web_${Date.now()}.webm`; |
| | a.click(); |
| | |
| | alert('✅ Preview gerado! Nota: Para alta qualidade, use a opção FFmpeg.'); |
| | } catch (error) { |
| | this.hideExportProgress(); |
| | alert('❌ Erro: ' + error.message); |
| | } finally { |
| | this.isRendering = false; |
| | } |
| | } |
| |
|
| | |
| | async createVideoComposite(timelineItems) { |
| | return new Promise(async (resolve, reject) => { |
| | try { |
| | console.log('🎥 Criando composição de vídeo...'); |
| | |
| | |
| | const canvas = document.createElement('canvas'); |
| | canvas.width = this.canvasDimensions.width; |
| | canvas.height = this.canvasDimensions.height; |
| | const ctx = canvas.getContext('2d'); |
| | |
| | |
| | const stream = canvas.captureStream(30); |
| | |
| | |
| | let finalStream = stream; |
| | if (this.audioFile) { |
| | try { |
| | const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| | const arrayBuffer = await this.audioFile.arrayBuffer(); |
| | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); |
| | |
| | const destination = audioContext.createMediaStreamDestination(); |
| | const source = audioContext.createBufferSource(); |
| | source.buffer = audioBuffer; |
| | source.connect(destination); |
| | source.start(0); |
| | |
| | finalStream = new MediaStream([ |
| | ...stream.getVideoTracks(), |
| | ...destination.stream.getAudioTracks() |
| | ]); |
| | } catch (audioError) { |
| | console.log('⚠️ Erro no áudio, continuando sem áudio:', audioError.message); |
| | } |
| | } |
| | |
| | |
| | const mediaRecorder = new MediaRecorder(finalStream, { |
| | mimeType: 'video/webm;codecs=vp9', |
| | videoBitsPerSecond: 5000000 |
| | }); |
| | |
| | const chunks = []; |
| | mediaRecorder.ondataavailable = (event) => { |
| | if (event.data.size > 0) { |
| | chunks.push(event.data); |
| | } |
| | }; |
| | |
| | mediaRecorder.onstop = () => { |
| | const blob = new Blob(chunks, { type: 'video/webm' }); |
| | console.log('✅ Renderização concluída'); |
| | resolve(blob); |
| | }; |
| | |
| | mediaRecorder.onerror = (error) => { |
| | reject(new Error('Erro no MediaRecorder: ' + error.message)); |
| | }; |
| | |
| | |
| | console.log('⏺️ Iniciando gravação...'); |
| | mediaRecorder.start(); |
| | |
| | |
| | await this.renderTimelineDirect(ctx, canvas, timelineItems); |
| | |
| | |
| | setTimeout(() => { |
| | try { |
| | mediaRecorder.stop(); |
| | } catch (stopError) { |
| | console.log('⚠️ Erro ao parar gravação:', stopError.message); |
| | } |
| | }, 2000); |
| | |
| | } catch (error) { |
| | reject(error); |
| | } |
| | }); |
| | } |
| |
|
| | |
| | async renderTimelineDirect(ctx, canvas, timelineItems) { |
| | console.log(`🎬 Renderizando ${timelineItems.length} itens...`); |
| | |
| | let currentTime = 0; |
| | |
| | for (const item of timelineItems) { |
| | if (!item.hasTake || !item.take) { |
| | currentTime += item.duration; |
| | continue; |
| | } |
| | |
| | console.log(`📹 Renderizando grupo ${item.groupId}: ${item.theme}`); |
| | |
| | try { |
| | |
| | const videoElement = await this.getVideoForTake(item.take); |
| | |
| | if (!videoElement) { |
| | console.log('⚠️ Vídeo não encontrado, renderizando fallback'); |
| | this.renderFallbackFrame(ctx, canvas, item); |
| | await this.delay(item.duration * 1000); |
| | continue; |
| | } |
| | |
| | |
| | videoElement.currentTime = item.take.startTime; |
| | await this.waitForVideoReady(videoElement); |
| | |
| | |
| | const frameCount = Math.floor(item.duration * 30); |
| | const frameDelay = 1000 / 30; |
| | |
| | for (let frame = 0; frame < frameCount; frame++) { |
| | |
| | ctx.fillStyle = '#000'; |
| | ctx.fillRect(0, 0, canvas.width, canvas.height); |
| | |
| | |
| | const videoAspect = videoElement.videoWidth / videoElement.videoHeight; |
| | const canvasAspect = canvas.width / canvas.height; |
| | |
| | let drawWidth, drawHeight, drawX, drawY; |
| | |
| | if (videoAspect > canvasAspect) { |
| | drawHeight = canvas.height; |
| | drawWidth = drawHeight * videoAspect; |
| | drawX = (canvas.width - drawWidth) / 2; |
| | drawY = 0; |
| | } else { |
| | drawWidth = canvas.width; |
| | drawHeight = drawWidth / videoAspect; |
| | drawX = 0; |
| | drawY = (canvas.height - drawHeight) / 2; |
| | } |
| | |
| | |
| | try { |
| | ctx.drawImage(videoElement, drawX, drawY, drawWidth, drawHeight); |
| | } catch (drawError) { |
| | console.log('⚠️ Erro ao desenhar frame:', drawError.message); |
| | this.renderFallbackFrame(ctx, canvas, item); |
| | } |
| | |
| | |
| | const videoTime = item.take.startTime + (frame / 30); |
| | if (Math.abs(videoElement.currentTime - videoTime) > 0.3) { |
| | videoElement.currentTime = videoTime; |
| | } |
| | |
| | |
| | await this.delay(frameDelay); |
| | } |
| | |
| | console.log(`✅ Grupo ${item.groupId} renderizado`); |
| | |
| | } catch (segmentError) { |
| | console.log(`❌ Erro no grupo ${item.groupId}:`, segmentError.message); |
| | this.renderFallbackFrame(ctx, canvas, item); |
| | await this.delay(item.duration * 1000); |
| | } |
| | |
| | currentTime += item.duration; |
| | } |
| | |
| | console.log('🎉 Timeline renderizada com sucesso!'); |
| | } |
| |
|
| | |
| | async getVideoForTake(take) { |
| | |
| | const videoData = this.analyzedTakes.find(v => v.fileIndex === take.fileIndex); |
| | |
| | if (videoData && videoData.video) { |
| | return videoData.video; |
| | } |
| | |
| | |
| | const video = document.createElement('video'); |
| | video.src = URL.createObjectURL(this.videoFiles[take.fileIndex]); |
| | video.muted = true; |
| | video.playsInline = true; |
| | |
| | |
| | try { |
| | await new Promise((resolve, reject) => { |
| | const timeout = setTimeout(() => { |
| | resolve(); |
| | }, 10000); |
| |
|
| | video.onloadedmetadata = () => { |
| | clearTimeout(timeout); |
| | resolve(); |
| | }; |
| |
|
| | video.onerror = () => { |
| | clearTimeout(timeout); |
| | reject(new Error('Erro ao carregar vídeo')); |
| | }; |
| | }); |
| | |
| | return video; |
| | |
| | } catch (error) { |
| | console.log('⚠️ Erro ao carregar vídeo:', error.message); |
| | return null; |
| | } |
| | } |
| |
|
| | |
| | renderFallbackFrame(ctx, canvas, item) { |
| | ctx.fillStyle = '#000'; |
| | ctx.fillRect(0, 0, canvas.width, canvas.height); |
| | |
| | ctx.fillStyle = '#333'; |
| | ctx.fillRect(50, 50, canvas.width - 100, canvas.height - 100); |
| | |
| | ctx.fillStyle = 'white'; |
| | ctx.font = 'bold 24px Arial'; |
| | ctx.textAlign = 'center'; |
| | ctx.fillText(`Grupo ${item.groupId}`, canvas.width / 2, canvas.height / 2 - 60); |
| | |
| | ctx.font = '18px Arial'; |
| | ctx.fillText(`Tema: ${item.theme}`, canvas.width / 2, canvas.height / 2 - 20); |
| | |
| | ctx.font = '14px Arial'; |
| | ctx.fillText(`Score: ${item.flowScore.toFixed(1)}%`, canvas.width / 2, canvas.height / 2 + 20); |
| | ctx.fillText(`Modo: Renderização`, canvas.width / 2, canvas.height / 2 + 50); |
| | |
| | ctx.textAlign = 'left'; |
| | } |
| |
|
| | async waitForVideoReady(video) { |
| | return new Promise(resolve => { |
| | const checkReady = () => { |
| | if (video.readyState >= 2) { |
| | resolve(); |
| | } else { |
| | setTimeout(checkReady, 100); |
| | } |
| | }; |
| | checkReady(); |
| | }); |
| | } |
| |
|
| | showExportProgress(text = 'Processando...') { |
| | const modal = document.getElementById('exportProgress'); |
| | if (modal) { |
| | modal.classList.add('active'); |
| | const progressText = document.getElementById('exportProgressText'); |
| | const progressFill = document.getElementById('exportProgressFill'); |
| | if (progressText) progressText.textContent = text; |
| | if (progressFill) progressFill.style.width = '50%'; |
| | } |
| | } |
| |
|
| | hideExportProgress() { |
| | const modal = document.getElementById('exportProgress'); |
| | if (modal) modal.classList.remove('active'); |
| | } |
| |
|
| | delay(ms) { |
| | return new Promise(resolve => setTimeout(resolve, ms)); |
| | } |
| | } |
| |
|
| | |
| | function exportReport() { |
| | alert('Relatório exportado!'); |
| | } |
| |
|
| | function exportSRT() { |
| | alert('SRT exportado!'); |
| | } |
| |
|
| | function exportTimeline() { |
| | alert('Timeline exportada!'); |
| | } |
| |
|
| | function exportProjectFile() { |
| | alert('Projeto exportado!'); |
| | } |
| |
|
| | |
| | const editor = new VideoEditorInteligente(); |