(() => { const DEFAULT_FPS = 24; const DEFAULT_TOTAL = 360; const LANE_COUNT = 5; const DEFAULT_LANE_H = 74; const LEFT_PAD = 150; const SNAP_STEP = 8; const TRANSITION_TYPES = [ {value:'none', label:'None (Hard Cut)'}, {value:'fade', label:'Dissolve (Cross Fade)'}, {value:'fadeblack', label:'Fade to Black'}, {value:'fadewhite', label:'Fade to White'}, {value:'wipeleft', label:'Wipe Left'}, {value:'wiperight', label:'Wipe Right'}, {value:'wipeup', label:'Wipe Up'}, {value:'wipedown', label:'Wipe Down'}, {value:'slideleft', label:'Slide Left'}, {value:'slideright', label:'Slide Right'}, {value:'slideup', label:'Slide Up'}, {value:'slidedown', label:'Slide Down'}, {value:'circleopen', label:'Circle Open'}, {value:'circleclose', label:'Circle Close'}, {value:'smoothleft', label:'Smooth Left'}, {value:'smoothright', label:'Smooth Right'}, {value:'pixelize', label:'Pixelize'}, {value:'radial', label:'Radial'}, ]; const state = { project:null, media:[], selectedClipId:null, selectedClipIds:[], currentFrame:0, pxPerFrame:4, snap:true, peakSnap:false, loop:false, mute:false, previewMode:'single', wipePos:50, range:{start:null,end:null}, mediaView:'grid', mediaThumb:104, drag:null, fonts:[], versionCompareSel:[], versionCompareClips:null, prerender:null, totalFrames:DEFAULT_TOTAL, audioMonitor:'auto', monitorVolume:1.0, scrubAudio:false, lockedLanes:{}, hiddenLanes:{}, playing:false, laneHeight:DEFAULT_LANE_H }; const $ = id => document.getElementById(id); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const clamp = (v,min,max) => Math.max(min, Math.min(max, Number(v)||0)); const trunc = (s,n) => String(s||'').length>n ? String(s).slice(0,n-1)+'…' : String(s||''); const status = msg => { const el=$('status'); if(el) el.textContent=msg; }; const fps = () => Number(state.project?.settings?.fps || DEFAULT_FPS) || DEFAULT_FPS; const totalFrames = () => Math.max(1, Number(state.project?.settings?.total_frames || state.totalFrames || DEFAULT_TOTAL)); const frameToSec = frame => frame / fps(); const fmtTime = frame => { const sec=Math.max(0, frameToSec(frame)); const m=Math.floor(sec/60); const s=Math.floor(sec%60); const ms=Math.round((sec-Math.floor(sec))*1000); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')}`; }; const maxFrame = () => Math.max(totalFrames(), ...((state.project?.clips||[]).map(c=>c.start+c.length)), Math.round(fps()*15)); const findClip = id => (state.project?.clips||[]).find(c=>c.id===id); const isSelected = id => state.selectedClipIds.includes(id); const selectedClip = () => findClip(state.selectedClipId); const selectedClips = () => (state.project?.clips||[]).filter(c=>isSelected(c.id)); const mediaFor = clip => clip ? state.media.find(m=>m.id===clip.media_id) : null; // A clip's active Version Stack entry (see ensureVersions/setActiveVersion) // overrides the shared media-bin item's path for playback/export, without // touching the shared media entry other clips of the same source still use. const monitorMediaFor = clip => { const base=mediaFor(clip) || clip; return (clip && clip.versions && clip.versions.length) ? {...base, path:clip.path, url:clip.url||null} : base; }; const audioCapable = c => c && ['video','audio'].includes(c.kind); const fileUrl = item => !item ? '' : item.url ? item.url : item.path ? `/itda/api/file?path=${encodeURIComponent(item.path)}&project=${encodeURIComponent(state.project?.name || 'itda-project-1')}` : ''; const snapFrame = f => state.snap ? Math.round(f / SNAP_STEP) * SNAP_STEP : Math.round(f); // Trimming must stay frame-accurate. Snap is for moving/placing clips only. const trimFrame = f => Math.round(f); const clipEnd = c => c.start + c.length; const laneH = () => Number(state.laneHeight || DEFAULT_LANE_H); function sourceTotalFrames(c){ if(!c) return 1; const m = mediaFor(c); return Math.max(1, Math.round(Number(m?.total_frames || c.source_total_frames || c.source_frames || c.source_out || c.length || 1))); } function isTimeBoundClip(c){ return c && ['video','audio'].includes(c.kind); } function maxClipLength(c, srcIn=null){ if(!isTimeBoundClip(c)) return Number.MAX_SAFE_INTEGER; const total = sourceTotalFrames(c); const input = srcIn == null ? Number(c.source_in || 0) : Number(srcIn || 0); return Math.max(1, total - Math.max(0, Math.min(total - 1, input))); } function normalizeClipBounds(c){ if(!c) return c; if(!isTimeBoundClip(c)){ c.length = Math.max(1, Math.round(Number(c.length || 1))); c.source_in = Math.max(0, Math.round(Number(c.source_in || 0))); c.source_out = Math.max(c.source_in + 1, Math.round(Number(c.source_out || (c.source_in + c.length)))); return c; } const total = sourceTotalFrames(c); c.source_total_frames = total; c.source_in = Math.max(0, Math.min(total - 1, Math.round(Number(c.source_in || 0)))); c.length = Math.max(1, Math.min(Math.round(Number(c.length || 1)), total - c.source_in)); c.source_out = Math.max(c.source_in + 1, Math.min(total, c.source_in + c.length)); return c; } function hexToRgba(hex, alpha=1){ const h=String(hex||'#000000').replace('#',''); const full=h.length===3 ? h.split('').map(x=>x+x).join('') : h.padEnd(6,'0').slice(0,6); const n=parseInt(full,16); const r=(n>>16)&255, g=(n>>8)&255, b=n&255; return `rgba(${r},${g},${b},${Math.max(0,Math.min(1,Number(alpha)||0))})`; } async function api(path, opts={}) { const res=await fetch(path, opts); if(!res.ok){ let txt=''; try{txt=await res.text();}catch{} throw new Error(`${res.status} ${res.statusText}${txt?': '+txt:''}`); } return await res.json(); } function normalizeProject(p){ p.settings = {...{fps:24,total_frames:360,snap:true,loop:false,mute:false,preview_mode:'single',scrub_audio:false}, ...(p.settings||{})}; p.range = p.range || {start:null,end:null}; p.media = p.media || []; p.clips = p.clips || []; const oldLanes = Array.isArray(p.lanes) ? p.lanes : []; p.lanes = Array.from({length:LANE_COUNT}, (_,i)=>{ const old = oldLanes.find(l=>Number(l.index)===i) || oldLanes[i] || {}; const locked = !!old.locked; const visible = old.visible !== false; state.lockedLanes[i] = locked; state.hiddenLanes[i] = !visible; return {id:`lane_${i}`,index:i,locked,visible}; }); return p; } async function initProject(nameOverride=null){ const name=nameOverride || state.project?.name || $('projectName').value || 'itda-project-1'; const data=await api('/itda/api/init',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project:name})}); state.project=normalizeProject(data.project); state.range=state.project.range; state.snap=state.project.settings.snap!==false; state.loop=!!state.project.settings.loop; state.mute=!!state.project.settings.mute; state.previewMode=state.project.settings.preview_mode||'single'; state.scrubAudio=!!state.project.settings.scrub_audio; state.totalFrames=state.project.settings.total_frames||DEFAULT_TOTAL; $('projectName').value=state.project.name || 'itda-project-1'; await loadFonts(); await scanMedia(); renderAll(); status(`Loaded ${state.project.name}`); } async function saveProject(){ if(!state.project) return; state.project.name=state.project.name || $('projectName').value || 'itda-project-1'; state.project.range=state.range; state.project.media=state.media.filter(m=>!m.local); state.project.settings={...state.project.settings,fps:fps(),total_frames:totalFrames(),snap:state.snap,loop:state.loop,mute:state.mute,preview_mode:state.previewMode,scrub_audio:state.scrubAudio}; await api(`/itda/api/project/${encodeURIComponent(state.project.name)}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(state.project)}); showModal('Save', `

${esc(state.project.name)} saved.

ComfyUI/input/ITDA/projects/${esc(state.project.name)}.itda.json

`); status('Saved'); } async function scanMedia(){ if(!state.project) return; try{ const data=await api(`/itda/api/media/${encodeURIComponent(state.project.name)}`); const local=state.media.filter(m=>m.local); state.media=[...(data.items||[]),...local]; restoreBeats(); }catch(e){ status(`Media scan failed: ${e.message}`); } } // scanMedia() rebuilds state.media from a fresh server scan, so anything // computed client-side (detected beats) has to be re-attached from the // saved project afterwards, keyed by path since media ids are regenerated. function restoreBeats(){ const saved=state.project?.settings?.beats || {}; state.media.forEach(m=>{ if(m.path && Array.isArray(saved[m.path])) m.beats=saved[m.path]; }); } function rememberBeats(m, frames){ m.beats=frames; if(!state.project) return; state.project.settings=state.project.settings||{}; state.project.settings.beats={...(state.project.settings.beats||{}), [m.path]:frames}; } async function loadFonts(){ try{ const data = await api('/itda/api/fonts'); state.fonts = data.items || []; let style = document.getElementById('itda-font-face-style'); if(!style){ style = document.createElement('style'); style.id='itda-font-face-style'; document.head.appendChild(style); } style.textContent = state.fonts.map(f=>`@font-face{font-family:'${String(f.family).replace(/'/g,"\\'")}';src:url('${f.url}') format('${f.format||'truetype'}');font-display:swap;}`).join('\n'); }catch(e){ state.fonts=[]; status(`Font scan failed: ${e.message}`); } } function renderAll(){ autoLanes(); renderMedia(); renderTimeline(); updateControls(); updateProps(); updatePreview(); } function renderMedia(){ state.media.forEach(m=>ensureWaveform(m)); const list=$('mediaList'); list.innerHTML=''; list.classList.toggle('list-view', state.mediaView==='list'); list.style.setProperty('--thumb', `${state.mediaThumb || 104}px`); const items=state.media; $('mediaCount').textContent=`${items.length} item${items.length===1?'':'s'}`; if(!items.length){ list.innerHTML='
Put media in ComfyUI/input/ITDA, or add session media with the + buttons above.
'; return; } for(const item of items){ const div=document.createElement('div'); div.className='media-item'; div.draggable=true; div.title=`${item.name}\n${item.fps?Number(item.fps).toFixed(3)+' fps':''} ${item.total_frames||''} frames`; const src=fileUrl(item); let thumb=''; if(item.kind==='video') thumb=item.thumb_url ? `` : ``; else if(item.kind==='image') thumb=``; else if(item.kind==='text') thumb=`
T
`; else thumb=`
♪
`; div.innerHTML=`
${thumb}
${esc(item.name)}
${item.kind}${item.fps?` · ${Number(item.fps).toFixed(2)}fps`:''}${item.total_frames?` · ${item.total_frames}f`:''}
`; div.querySelector('.media-delete').onclick=e=>{e.stopPropagation(); removeMedia(item.id);}; div.addEventListener('dragstart', e=>e.dataTransfer.setData('application/itda-media', JSON.stringify(item))); div.addEventListener('dblclick', ()=>mediaPreview(item)); list.appendChild(div); } } // A native confirm()/alert() is a blocking browser dialog owned by the top // window - inside a cross-origin/sandboxed iframe (e.g. the ComfyUI // preview node embeds ITDA in one) browsers are free to suppress it // silently, which reads as "the delete button does nothing." Routing // through the app's own modal sidesteps that entirely. function confirmModal(title, bodyHtml, confirmLabel='Delete'){ return new Promise(resolve=>{ showModal(title, bodyHtml, ``); $('confirmYes').onclick=()=>{ closeModal(); resolve(true); }; $('confirmNo').onclick=()=>{ closeModal(); resolve(false); }; }); } async function removeMedia(id){ const item=state.media.find(m=>m.id===id); if(!item) return; const ok=await confirmModal('Delete Media', `

Removing this from the Media Bin also permanently deletes the library file.

${esc(item.name)}

Delete it?

`); if(!ok) return; if(item.path && !item.local){ try{ await api('/itda/api/media/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project:state.project?.name||'itda-project-1',path:item.path})}); }catch(e){ status(`Media delete failed: ${e.message}`); return; } } state.media=state.media.filter(m=>m.id!==id); if(state.project) state.project.clips=(state.project.clips||[]).filter(c=>c.media_id!==id); setSelection(null); renderAll(); status('Media deleted from library'); } function mediaPreview(item){ const temp={id:'preview',media_id:item.id,name:item.name,kind:item.kind,start:0,length:item.total_frames||Math.round((item.duration||5)*fps()),source_in:0,lane:0,path:item.path,url:item.url||null,text:item.text||'',font_family:item.font_family||'system',shadow_enabled:false,shadow_color:'#000000',shadow_opacity:0.6,fps:item.fps||fps()}; const v=$('previewVideo'); const vb=$('previewVideoB'); if(v){ v.controls = item.kind==='video' || item.kind==='audio'; v.muted=false; v.volume=state.monitorVolume; } if(vb){ vb.controls=false; } loadPreviewForClip(temp,v,$('previewImage'),$('textOverlay')); if(item.kind==='audio'){ const src=fileUrl(item); if(v){ v.pause(); v.src=src; v.dataset.src=src; v.controls=true; v.load(); } } status('Media preview loaded. Use native controls, Space, or click preview to play.'); } async function addLocalFiles(files, kindHint=null){ if(!state.project) return; const fd=new FormData(); [...files].forEach(f=>fd.append('files',f)); fd.append('project', state.project.name || $('projectName').value || 'itda-project-1'); try{ const data=await fetch('/itda/api/media/upload',{method:'POST',body:fd}).then(r=>{if(!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json();}); await scanMedia(); renderAll(); status(`Imported ${(data.items||[]).length} file(s) to input/ITDA/media/${state.project.name}`); }catch(err){ [...files].forEach(file=>{ const kind=file.type.startsWith('video/')?'video':file.type.startsWith('audio/')?'audio':file.type.startsWith('image/')?'image':kindHint; if(!kind) return; const item={id:`local:${Date.now()}:${Math.random().toString(16).slice(2)}`,name:file.name,kind,path:null,url:URL.createObjectURL(file),local:true,fps:fps(),total_frames:Math.round(fps()*5),duration:5,width:null,height:null}; if(kind==='video') loadVideoMeta(item); state.media.push(item); }); renderMedia(); status(`Upload failed, using temporary session media: ${err.message}`); } } function loadVideoMeta(item){ const v=document.createElement('video'); v.preload='metadata'; v.src=item.url; v.onloadedmetadata=()=>{ item.duration=v.duration||item.duration||5; item.width=v.videoWidth; item.height=v.videoHeight; item.original_fps=item.fps||fps(); item.fps=fps(); item.total_frames=Math.round(item.duration*fps()); renderAll(); }; } function addTextMedia(){ const item={id:`text:${Date.now()}`,name:'Text Clip',kind:'text',local:true,text:'Subtitle Text',fps:fps(),total_frames:Math.round(fps()*3),duration:3}; state.media.push(item); renderMedia(); addClipFromMedia(item,state.currentFrame); } function addClipFromMedia(media,startFrame){ const length=media.total_frames||Math.round((media.duration||5)*fps())||120; const clip={id:`clip_${Date.now()}_${Math.random().toString(16).slice(2)}`,media_id:media.id,name:media.name,kind:media.kind,path:media.path,url:media.url||null,fps:fps(),width:media.width||null,height:media.height||null,start:Math.max(0,snapFrame(startFrame)),length,source_in:0,source_out:length,source_total_frames:media.total_frames||length,lane:0,group_id:null,audio_detached:false,text:media.text||'',x:50,y:88,size:42,opacity:1,color:'#ffffff',font_family:'system',shadow_enabled:false,shadow_color:'#000000',shadow_opacity:0.6}; normalizeClipBounds(clip); state.project.clips.push(clip); setSelection(clip.id); renderAll(); } function setSelection(id,additive=false){ // Any new clip selection invalidates a version-compare in progress - // it's scoped to whichever clip's Properties panel it was started from. state.versionCompareSel=[]; state.versionCompareClips=null; if(!id){state.selectedClipId=null;state.selectedClipIds=[];return;} const c=findClip(id); const groupIds = c?.group_id ? (state.project?.clips||[]).filter(x=>x.group_id===c.group_id).map(x=>x.id) : [id]; if(additive){ const allSelected=groupIds.every(x=>isSelected(x)); state.selectedClipIds = allSelected ? state.selectedClipIds.filter(x=>!groupIds.includes(x)) : [...new Set([...state.selectedClipIds, ...groupIds])]; state.selectedClipId=state.selectedClipIds[state.selectedClipIds.length-1]||null; } else { state.selectedClipId=id; state.selectedClipIds=groupIds; } } function autoLanes(){ if(!state.project) return; for(const c of state.project.clips||[]) c.lane=clamp(c.lane??0,0,LANE_COUNT-1); state.project.lanes=Array.from({length:LANE_COUNT},(_,i)=>({id:`lane_${i}`,index:i,locked:!!state.lockedLanes[i],visible:!state.hiddenLanes[i]})); } function setLaneState(index, patch){ if(!state.project) return; const i=Number(index); if(patch.locked !== undefined) state.lockedLanes[i]=!!patch.locked; if(patch.visible !== undefined) state.hiddenLanes[i]=!patch.visible; autoLanes(); const row=document.querySelector(`.lane[data-lane="${i}"]`); if(row){ row.classList.add('state-refresh'); setTimeout(()=>row.classList.remove('state-refresh'),140); } renderTimeline(); updatePreview(); updateProps(); updateControls(); } function resamplePeaks(peaks, count){ if(!Array.isArray(peaks) || !peaks.length) return []; count = Math.max(16, Math.min(640, Math.round(count || 160))); if(peaks.length === count) return peaks; const out=[]; for(let i=0;i${beats.map(bf=>``).join('')}`; } function waveformMarkup(c){ if(!audioCapable(c)) return ''; const m=mediaFor(c) || c; const peaks=m.waveform || c.waveform; if(Array.isArray(peaks) && peaks.length) return ''; return '
'; } // Draws a real, screen-pixel-dense amplitude envelope (one column per device // pixel, mirrored around the vertical center) instead of one DOM element per // source frame — the old per-frame bars were sub-pixel wide on anything // longer than a few seconds and rendered as illegible gray noise rather than // a recognizable waveform shape. function drawClipWaveform(canvas,c){ const m=mediaFor(c) || c; const peaks=m.waveform || c.waveform; if(!Array.isArray(peaks) || !peaks.length) return; // .clip-bars (canvas's parent) has a fixed 8px left/right inset; a // is a replaced element, so CSS "width:auto" between left/right offsets does // NOT stretch it like a normal div — it silently falls back to intrinsic // canvas size. So the canvas must be sized explicitly here, in real CSS // pixels, from the .clip element's own width (not the padded parent). const clipEl=canvas.closest('.clip'); const clipRect=(clipEl||canvas).getBoundingClientRect(); const barsRect=canvas.parentElement.getBoundingClientRect(); const cssW=Math.max(1, Math.round(clipRect.width)); const cssH=Math.max(1, Math.round(barsRect.height)); canvas.style.width=`${cssW}px`; canvas.style.height=`${cssH}px`; const dpr=window.devicePixelRatio || 1; canvas.width=Math.max(1, Math.round(cssW*dpr)); canvas.height=Math.max(1, Math.round(cssH*dpr)); const ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,cssW,cssH); const sourceTotal=Math.max(1, Number(m.total_frames || c.source_total_frames || c.source_out || c.length || peaks.length)); const srcIn=Math.max(0, Math.min(sourceTotal-1, Math.round(Number(c.source_in || 0)))); const length=Math.max(1, Math.min(Math.round(Number(c.length || 1)), sourceTotal-srcIn)); ctx.fillStyle = c.kind==='audio' ? 'rgba(205,255,235,.92)' : 'rgba(238,222,255,.92)'; const mid=cssH/2; for(let x=0;x=1){ // Zoomed out enough that this pixel covers >=1 source sample - take // the max over that span so a brief transient between samples still // shows up, rather than being averaged away. const a=Math.max(0, Math.floor(posA)); const b=Math.min(peaks.length, Math.ceil(posB)); peak=0; for(let j=a;jpeak) peak=v; } } else { // Zoomed in past the data's native resolution: multiple pixel // columns fall inside the same single sample, so a nearest-sample // lookup repeats one flat value across all of them and steps to the // next - the "square staircase" look. Interpolating between the two // neighboring samples draws a continuous slope instead. const pos=(posA+posB)/2; const i0=Math.max(0, Math.min(peaks.length-1, Math.floor(pos))); const i1=Math.min(peaks.length-1, i0+1); const frac=pos-i0; const v0=Math.abs(Number(peaks[i0])||0), v1=Math.abs(Number(peaks[i1])||0); peak=v0*(1-frac)+v1*frac; } const h=Math.max(1, peak*mid); ctx.fillRect(x, mid-h, 1, h*2); } } function drawAllWaveforms(){ document.querySelectorAll('#lanes .wf-canvas').forEach(canvas=>{ const clipEl=canvas.closest('.clip'); const c=clipEl && findClip(clipEl.dataset.clipId); if(c) drawClipWaveform(canvas,c); }); } async function ensureWaveform(item){ if(!item || item.waveform || item.waveformLoading || !['video','audio'].includes(item.kind) || !item.path) return; item.waveformLoading = true; try{ const bars = Math.max(240, Math.min(1200, Math.round((item.total_frames || totalFrames()) * 2))); // dense source-aligned peak cache const data = await api('/itda/api/waveform', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({project:state.project?.name||'itda-project-1', path:item.path, bars}) }); item.waveformLoading = false; if(data && data.ok && Array.isArray(data.peaks)){ item.waveform = data.peaks; item.waveform_cache_path = data.cache_path || null; renderTimeline(); renderMedia(); } else { item.waveform = []; status(`Waveform unavailable: ${item.name}`); } }catch(e){ item.waveformLoading = false; item.waveform = []; status(`Waveform failed: ${item.name} · ${e.message}`); } } function renderRuler(){ const ruler=$('ruler'); ruler.innerHTML=''; const total=maxFrame(); ruler.style.width=`${LEFT_PAD+total*state.pxPerFrame+500}px`; const major=Math.max(1,Math.round(fps())); const minor=Math.max(1,Math.round(major/4)); for(let f=0; f<=total; f+=minor){ const tick=document.createElement('div'); const isMajor=f%major===0; tick.className=isMajor?'tick':'tick minor'; tick.style.left=`${LEFT_PAD+f*state.pxPerFrame}px`; if(isMajor) tick.innerHTML=`${fmtTime(f).slice(0,5)}${Math.round(f)}f`; ruler.appendChild(tick); } const projectEnd=document.createElement('div'); projectEnd.className='total-frame-marker'; projectEnd.style.left=`${LEFT_PAD+totalFrames()*state.pxPerFrame}px`; projectEnd.innerHTML='END'; ruler.appendChild(projectEnd); } function renderTimeline(){ if(!state.project) return; autoLanes(); renderRuler(); const lanes=$('lanes'); lanes.innerHTML=''; lanes.style.width=$('ruler').style.width; lanes.style.height=`${LANE_COUNT*laneH()}px`; $('rangeLayer').style.height=`${LANE_COUNT*laneH()}px`; for(const lane of state.project.lanes){ const row=document.createElement('div'); row.className=`lane ${lane.locked?'locked':''} ${!lane.visible?'hidden-lane preview-off':''}`; row.dataset.lane=lane.index; row.style.height=`${laneH()}px`; const visIcon = lane.visible ? '👁' : '◌'; const lockIcon = lane.locked ? '🔒' : '🔓'; const visTitle = lane.visible ? 'Preview ON - click to disable' : 'Preview OFF - click to enable'; const lockTitle = lane.locked ? 'Track Locked - click to unlock' : 'Track Unlocked - click to lock'; row.innerHTML=`
T${lane.index+1}
`; lanes.appendChild(row); } lanes.querySelectorAll('[data-lock]').forEach(b=>b.onclick=e=>{e.preventDefault(); e.stopPropagation(); const i=Number(b.dataset.lock); setLaneState(i,{locked:!state.lockedLanes[i]}); status(`T${i+1} ${state.lockedLanes[i]?'locked':'unlocked'}`);}); lanes.querySelectorAll('[data-vis]').forEach(b=>b.onclick=e=>{e.preventDefault(); e.stopPropagation(); const i=Number(b.dataset.vis); setLaneState(i,{visible:!!state.hiddenLanes[i]}); status(`T${i+1} preview ${state.hiddenLanes[i]?'OFF':'ON'}`);}); const hasSel=state.selectedClipIds.length>0; for(const c of state.project.clips||[]){ normalizeClipBounds(c); const row=lanes.children[c.lane]; if(!row) continue; const el=document.createElement('div'); const stitched=!!(c.kind==='stitched'||c.children); el.className=`clip ${c.kind||'video'} ${c.group_id?'grouped':''} ${stitched?'stitched':''} ${isSelected(c.id)?'selected':''} ${hasSel&&!isSelected(c.id)?'dimmed':''}`; el.dataset.clipId=c.id; el.style.left=`${LEFT_PAD+c.start*state.pxPerFrame}px`; el.style.width=`${Math.max(32,c.length*state.pxPerFrame)}px`; el.style.height=`${Math.max(34,laneH()-19)}px`; el.title=`${c.name} Trim ${c.source_in||0}f–${c.source_out||c.length}f Timeline ${c.start}f–${c.start+c.length}f`; const icon=stitched?'◆':c.kind==='audio'?'♫':c.kind==='image'?'▧':c.kind==='text'?'T':'▣'; el.innerHTML=`
${icon} ${esc(trunc(c.name,24))}${['video','audio'].includes(c.kind) ? `${c.audio_enabled===false?'\u{1F507}':'\u{1F50A}'}` : ''}${c.source_in||0}f to ${c.source_out||c.length}f / ${fmtTime(c.length)}
${waveformMarkup(c)}
${beatTicksMarkup(c)}
`; el.addEventListener('pointerdown', startClipPointer); row.appendChild(el); } renderRange(); updatePlayhead(); drawAllWaveforms(); } function renderRange(){ const layer=$('rangeLayer'); layer.innerHTML=''; if(state.range.start==null || state.range.end==null) return; const a=Math.min(state.range.start,state.range.end), b=Math.max(state.range.start,state.range.end); const box=document.createElement('div'); box.className='range-box'; box.style.left=`${LEFT_PAD+a*state.pxPerFrame}px`; box.style.width=`${Math.max(1,(b-a)*state.pxPerFrame)}px`; layer.appendChild(box); } function updatePlayhead(){ const ph=$('playhead'); ph.style.left=`${LEFT_PAD+state.currentFrame*state.pxPerFrame}px`; ph.style.height=`${48 + LANE_COUNT * laneH()}px`; $('frameLabel').textContent=`Frame ${Math.round(state.currentFrame)}`; $('timeLabel').textContent=fmtTime(state.currentFrame); updatePreview(); updatePlaybackAudio(); scheduleAudioScrub(); } function startClipPointer(e){ e.preventDefault(); e.stopPropagation(); const el=e.currentTarget; const c=findClip(el.dataset.clipId); if(!c || state.lockedLanes[c.lane]) return; const trim=e.target?.dataset?.trim; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey && state.selectedClipId && state.selectedClipId!==c.id){ // Range-select: everything spanning the time x lane rectangle between // the last-selected clip (anchor) and the shift-clicked one - same // rectangle-intersection rule as drag Box Select, just anchored by a // click instead of a mouse-drag corner. const anchor=findClip(state.selectedClipId); if(anchor){ const frameLo=Math.min(anchor.start,c.start), frameHi=Math.max(clipEnd(anchor),clipEnd(c)); const laneLo=Math.min(anchor.lane,c.lane), laneHi=Math.max(anchor.lane,c.lane); const hits=(state.project?.clips||[]).filter(x=>x.lane>=laneLo && x.lane<=laneHi && x.startframeLo); if(hits.length){ setSelection(hits[0].id,false); for(let i=1;i({id:x.id,start:x.start,lane:x.lane,length:x.length,source_in:x.source_in||0,source_out:x.source_out||x.length})) }; renderTimeline(); updateProps(); updateControls(); updatePreview(); document.addEventListener('pointermove', onClipPointer, {passive:false}); document.addEventListener('pointerup', endClipPointer, {once:true}); } // Snap means "magnetically attach to a neighboring clip's edge," not "only // allow moving in fixed N-frame increments." There is no grid fallback: // with snap on, a clip within reach of another clip's start/end attaches to // it exactly; otherwise it moves at plain frame-accurate (unsnapped) // precision, same as with snap off. // Peak Match: local maxima in a clip's cached waveform (item.waveform, from // ensureWaveform), mapped from source-frame position to this clip instance's // absolute timeline position - i.e. "where a transient/beat in this clip's // audio actually lands once placed on the timeline," clipped to the portion // of the source currently visible through this clip's trim (source_in/out). function peakFramesForClip(c){ if(!audioCapable(c)) return []; const m=mediaFor(c) || c; const peaks=m.waveform; if(!Array.isArray(peaks) || peaks.length<3) return []; const sourceTotal=Math.max(1, Number(m.total_frames || c.source_total_frames || c.length || peaks.length)); const srcIn=Number(c.source_in||0), srcOut=Number(c.source_out||c.length); const out=[]; for(let i=1;i=Math.abs(peaks[i-1]) && v>=Math.abs(peaks[i+1])){ const srcFrame=(i/peaks.length)*sourceTotal; if(srcFrame>=srcIn && srcFrame Beat Detect, librosa-backed) live on the // media item as SOURCE frame numbers, since they describe the underlying // audio, not any one clip's placement - same source/timeline remap as // peakFramesForClip. These are real tracked beats rather than raw waveform // maxima, so they're the better snap target when available. function beatFramesForClip(c){ if(!audioCapable(c)) return []; const m=mediaFor(c) || c; const beats=m.beats; if(!Array.isArray(beats) || !beats.length) return []; const srcIn=Number(c.source_in||0), srcOut=Number(c.source_out||c.length); return beats.filter(bf=>bf>=srcIn && bfc.start+(bf-srcIn)); } function snapMoveStart(rawStart, length, excludeIds){ if(!state.snap) return Math.round(rawStart); const thresholdFrames = Math.max(4, 14/state.pxPerFrame); let bestEdge=null, bestEdgeDist=Infinity; for(const o of (state.project?.clips||[])){ if(excludeIds.has(o.id)) continue; const cands=[o.start, clipEnd(o), o.start-length, clipEnd(o)-length]; if(state.peakSnap){ for(const pf of peakFramesForClip(o)){ cands.push(pf, pf-length); } for(const bf of beatFramesForClip(o)){ cands.push(bf, bf-length); } } for(const cand of cands){ const d=Math.abs(cand-rawStart); if(d<=thresholdFrames && do.id!==id && o.lane===lane && starto.start); } function fitSingleMove(c, proposedStart, proposedLane, dxFrames){ let ns=Math.max(0, proposedStart), lane=clamp(proposedLane,0,LANE_COUNT-1); if(!wouldOverlap(c.id,lane,ns,c.length)) return {start:ns,lane}; const others=(state.project?.clips||[]).filter(o=>o.id!==c.id && o.lane===lane).sort((a,b)=>a.start-b.start); if(dxFrames>=0){ for(const o of others){ if(ns < clipEnd(o) && ns+c.length > o.start) ns=clipEnd(o); } }else{ for(const o of [...others].reverse()){ if(ns < clipEnd(o) && ns+c.length > o.start) ns=o.start-c.length; } } ns=Math.max(0,ns); return wouldOverlap(c.id,lane,ns,c.length) ? {start:c.start,lane:c.lane} : {start:ns,lane}; } function canPlaceGroup(proposals){ const ids=new Set(proposals.map(p=>p.id)); for(const p of proposals){ if(p.start<0 || p.lane<0 || p.lane>=LANE_COUNT || state.lockedLanes[p.lane]) return false; } for(let i=0;i b.start) return false; } for(const p of proposals){ if((state.project?.clips||[]).some(o=>!ids.has(o.id) && o.lane===p.lane && p.start < clipEnd(o) && p.start+p.length > o.start)) return false; } return true; } function onClipPointer(e){ if(!state.drag) return; e.preventDefault(); const c=findClip(state.drag.clipId); if(!c) return; const dxFrames=Math.round((e.clientX-state.drag.startX)/state.pxPerFrame); if(Math.abs(e.clientX-state.drag.startX)>2 || Math.abs(e.clientY-state.drag.startY)>2) state.drag.moved=true; if(state.drag.trim==='left'){ const oldEnd=state.drag.origStart+state.drag.origLen; let ns=clamp(trimFrame(state.drag.origStart+dxFrames),0,oldEnd-1); let diff=ns-state.drag.origStart; let srcIn=Math.max(0,Math.round(state.drag.origSourceIn+diff)); if(isTimeBoundClip(c)) srcIn=Math.min(sourceTotalFrames(c)-1,srcIn); let newLen=Math.max(1,oldEnd-ns); const maxLen=maxClipLength(c,srcIn); if(newLen>maxLen){ newLen=maxLen; ns=oldEnd-newLen; diff=ns-state.drag.origStart; srcIn=Math.max(0,Math.round(state.drag.origSourceIn+diff)); if(isTimeBoundClip(c)) srcIn=Math.min(sourceTotalFrames(c)-1,srcIn); } if(!wouldOverlap(c.id,c.lane,ns,newLen)){ c.start=ns; c.length=newLen; c.source_in=srcIn; c.source_out=c.source_in+c.length; normalizeClipBounds(c); } } else if(state.drag.trim==='right'){ let newLen=Math.max(1,trimFrame(state.drag.origLen+dxFrames)); newLen=Math.min(newLen,maxClipLength(c,c.source_in||0)); if(!wouldOverlap(c.id,c.lane,c.start,newLen)){ c.length=newLen; c.source_out=(c.source_in||0)+c.length; normalizeClipBounds(c); } } else { const targetLane=laneFromClientY(e.clientY); const laneDelta=targetLane-state.drag.origLane; const movingSelected=state.drag.items.length>1 && isSelected(c.id); if(movingSelected){ // Snap the dragged (primary) clip's edge to the nearest grid/neighbor // target, then shift the whole group by that same amount so their // relative spacing to each other doesn't change. const excludeIds=new Set(state.drag.items.map(it=>it.id)); const primarySnapped=snapMoveStart(state.drag.origStart+dxFrames, c.length, excludeIds); const snapDx=primarySnapped-state.drag.origStart; const proposals=state.drag.items.map(it=>({id:it.id,start:Math.max(0,Math.round(it.start+snapDx)),lane:clamp(it.lane+laneDelta,0,LANE_COUNT-1),length:it.length})); if(canPlaceGroup(proposals)) proposals.forEach(p=>{ const clip=findClip(p.id); if(clip){clip.start=p.start; clip.lane=p.lane;} }); } else { const target=snapMoveStart(state.drag.origStart+dxFrames, c.length, new Set([c.id])); const fitted=fitSingleMove(c,target,targetLane,dxFrames); c.start=fitted.start; c.lane=fitted.lane; } } renderTimeline(); updateProps(); updatePlayhead(); } function endClipPointer(e){ document.removeEventListener('pointermove',onClipPointer); state.drag=null; renderAll(); } function frameFromTimelineEvent(e){ const rect=$('timeline').getBoundingClientRect(); return clamp(Math.round((e.clientX-rect.left+$('timeline').scrollLeft-LEFT_PAD)/state.pxPerFrame),0,maxFrame()); } // #lanes is a normal-flow (non-sticky) child, so its own getBoundingClientRect() // already reflects the current scroll position - it visually shifts as // #timeline scrolls, unlike #timeline's own rect (which never moves for its // own internal scrolling). Adding scrollTop again here double-counts it and // drifts the hit-test away from the cursor by exactly that scroll amount. function laneFromClientY(y){ const rect=$('lanes').getBoundingClientRect(); return clamp(Math.floor((y-rect.top)/laneH()),0,LANE_COUNT-1); } function laneFromTimelineEvent(e){ return laneFromClientY(e.clientY); } function childClipAtFrame(stitched, frame, kinds=null){ if(!stitched?.children) return null; const local=frame-stitched.start; const child=(stitched.children||[]).filter(ch=>local>=ch.rel_start && local(a.lane??0)-(b.lane??0))[0]; if(!child) return null; return {...child,id:`${stitched.id}:${child.id}`,start:stitched.start+child.rel_start,media_id:child.media_id,path:child.path,url:child.url}; } function activeClipsAtFrame(frame){ return (state.project?.clips||[]).filter(c=>frame>=c.start && framea.lane-b.lane); } function topVisualClip(frame){ const base = topBaseVisualClip(frame); return base || topTextClip(frame); } function topBaseVisualClip(frame){ for(const c of activeClipsAtFrame(frame)){ if(c.kind==='stitched'||c.children){ const ch=childClipAtFrame(c,frame,['video','image']); if(ch) return ch; } if(['video','image'].includes(c.kind)) return c; } return null; } function topTextClip(frame){ for(const c of activeClipsAtFrame(frame)){ if(c.kind==='stitched'||c.children){ const ch=childClipAtFrame(c,frame,['text']); if(ch) return ch; } if(c.kind==='text') return c; } return null; } function topAudioClip(frame){ for(const c of activeClipsAtFrame(frame)){ if(c.kind==='stitched'||c.children){ const ch=childClipAtFrame(c,frame,['video','audio']); if(ch && ch.audio_enabled!==false) return ch; } if(['video','audio'].includes(c.kind) && c.audio_enabled!==false) return c; } return null; } // A pre-render is only valid for the exact timeline it was baked from, so // it carries a signature of everything that can change what the composite // looks or sounds like. Any edit changes the signature and the cache stops // being used - silently showing a stale render would be worse than the // stutter pre-rendering exists to remove. function projectSignature(){ return (state.project?.clips||[]).map(c=> `${c.id}|${c.lane}|${c.start}|${c.length}|${c.source_in}|${c.source_out}|${c.path||c.url||''}|${c.kind}|${c.text||''}|${c.x}|${c.y}|${c.size}|${c.opacity}|${c.color}|${c.font_family}|${c.shadow_enabled}|${c.shadow_color}|${c.shadow_opacity}|${c.audio_enabled}|${c.volume}|${c.solo}` ).join(';'); } function prerenderStale(){ return !!state.prerender && state.prerender.signature !== projectSignature(); } function activePrerender(){ const pr=state.prerender; if(!pr || prerenderStale()) return null; if(state.currentFrame < pr.start_frame || state.currentFrame >= pr.end_frame) return null; return pr; } function updatePreview(){ const pv=$('previewVideo'), pvb=$('previewVideoB'); if(pv && document.activeElement!==pv) pv.controls=false; if(pvb) pvb.controls=false; const stage=$('previewStage'); const v=$('previewVideo'), vB=$('previewVideoB'), img=$('previewImage'), imgB=$('previewImageB'), t=$('textOverlay'), tB=$('textOverlayB'); stage.className='preview-stage'; [v,vB,img,imgB].forEach(el=>{el.style.display='';}); clearTextOverlay(t); clearTextOverlay(tB); // Pre-rendered range: one already-composited file replaces the whole // layer stack (and its baked-in mix replaces the per-clip audio monitor). // Only in Single - Compare/Overlay/Wipe are about seeing two specific // clips, which a flattened composite cannot show. const pr = state.previewMode==='single' ? activePrerender() : null; if(pr){ const src=pr.url; if(v.dataset.src!==src){ v.pause(); v.removeAttribute('src'); v.load(); v.src=src; v.dataset.src=src; } [vB,img,imgB].forEach(x=>{ if(x){ x.pause?.(); x.removeAttribute('src'); if(x.dataset) x.dataset.src=''; x.load?.(); } }); const local=(state.currentFrame - pr.start_frame)/fps(); if(Number.isFinite(local) && Math.abs((v.currentTime||0)-local)>.08){ try{ v.currentTime=local; }catch{} } v.muted=state.mute; v.volume=state.monitorVolume; stage.classList.add('has-content','has-primary-video','prerendered'); updatePlaybackAudio(); return; } let primary=null, secondary=null, overlayText=null; const sel=selectedClips(); if(state.previewMode==='wipe' && state.versionCompareClips && state.versionCompareClips.length===2){ [primary,secondary]=state.versionCompareClips; } else if((state.previewMode==='compare'||state.previewMode==='overlay'||state.previewMode==='wipe') && sel.length===2){ [primary,secondary]=sel; } else { // Normal Preview = layer renderer, not selected-clip renderer. // Text clips are transparent overlays over the highest video/image layer below them. primary=topBaseVisualClip(state.currentFrame); // While a text clip is selected (being edited in Clip Properties), keep // showing ITS live values even if the playhead sits outside its range or // another text clip has layer priority there — otherwise every commit // (onchange/onblur) snaps the overlay back to whatever's under the // playhead, making edits look like they silently revert. const selForText=selectedClip(); overlayText=(selForText && selForText.kind==='text') ? selForText : topTextClip(state.currentFrame); secondary=null; } if(!primary){ [v,vB].forEach(x=>{x.pause(); x.removeAttribute('src'); x.dataset.src=''; x.load();}); [img,imgB].forEach(x=>x.removeAttribute('src')); } else loadPreviewForClip(primary,v,img,t); if(overlayText){ loadTextOverlay(overlayText,t); stage.classList.add('has-text'); } if(secondary) loadPreviewForClip(secondary,vB,imgB,tB,true); if(primary || overlayText) stage.classList.add('has-content'); if(primary?.kind==='video') stage.classList.add('has-primary-video'); if(primary?.kind==='image') stage.classList.add('has-primary-image'); if(secondary?.kind==='video') stage.classList.add('has-secondary-video'); if(secondary?.kind==='image') stage.classList.add('has-secondary-image'); if(secondary?.kind==='text') stage.classList.add('has-text-b'); if(state.previewMode==='compare' && secondary) stage.classList.add('compare'); if(state.previewMode==='overlay' && secondary) stage.classList.add('overlay'); if(state.previewMode==='wipe' && secondary){ stage.classList.add('wipe'); stage.style.setProperty('--wipe-pos', `${state.wipePos}%`); } updateAudioMonitor(); } function clearTextOverlay(textEl){ if(!textEl) return; textEl.textContent=''; textEl.style.display=''; } function loadTextOverlay(c, textEl){ if(!textEl || !c) return; textEl.textContent=c.text||c.name||'Text'; textEl.style.left=`${c.x??50}%`; textEl.style.bottom=`${100-(c.y??88)}%`; textEl.style.fontSize=`${c.size||42}px`; textEl.style.opacity=c.opacity??1; textEl.style.color=c.color||'#ffffff'; textEl.style.fontFamily=(c.font_family && c.font_family !== 'system') ? `'${String(c.font_family).replace(/'/g,"\'")}', sans-serif` : 'Inter, Segoe UI, Arial, sans-serif'; textEl.style.background='transparent'; if(c.shadow_enabled){ const alpha = Math.max(0, Math.min(1, Number(c.shadow_opacity ?? 0.6))); const hex = c.shadow_color || '#000000'; textEl.style.textShadow = `0 2px 4px ${hex}${Math.round(alpha*255).toString(16).padStart(2,'0')}, 0 0 8px ${hex}${Math.round(alpha*210).toString(16).padStart(2,'0')}`; }else{ textEl.style.textShadow = 'none'; } } function loadPreviewForClip(c, videoEl, imgEl, textEl, secondary=false){ if(c.kind==='stitched'||c.children){ const ch=childClipAtFrame(c,state.currentFrame,['video','image','text']); if(ch) return loadPreviewForClip(ch,videoEl,imgEl,textEl,secondary); } const m=monitorMediaFor(c); const src=fileUrl(m); clearTextOverlay(textEl); if(c.kind==='video'){ if(imgEl) imgEl.removeAttribute('src'); if(videoEl.dataset.src!==src){ videoEl.pause(); videoEl.removeAttribute('src'); videoEl.load(); videoEl.src=src; videoEl.dataset.src=src; videoEl.onerror=()=>status('Preview video load failed'); } videoEl.volume = clamp(state.monitorVolume * ((c.volume??100)/100), 0, 1); videoEl.muted = secondary || state.mute || audioMutedForClip(c); seekElementToFrame(videoEl,c,state.currentFrame); } else if(c.kind==='image'){ if(videoEl){ videoEl.pause(); videoEl.removeAttribute('src'); videoEl.dataset.src=''; videoEl.load(); } if(imgEl.src!==src) imgEl.src=src; } else if(c.kind==='text'){ if(videoEl){ videoEl.pause(); videoEl.removeAttribute('src'); videoEl.dataset.src=''; videoEl.load(); } if(imgEl) imgEl.removeAttribute('src'); loadTextOverlay(c,textEl); } } function seekElementToFrame(video,c,frame){ if(!video.src) return; const local=Math.max(0,(frame-c.start+(c.source_in||0))/(c.fps||fps())); if(Number.isFinite(local) && Math.abs((video.currentTime||0)-local)>.08){ try{ video.currentTime=local; }catch{} } } // Stitched clips get resolved (via childClipAtFrame) into synthetic child // objects whose id is `${stitched.id}:${child.id}` - a different string // than the top-level clip's own id. Matching must tolerate that compound // form, or every solo/selection check below silently falls through. function idMatches(topId, resolvedId){ return resolvedId===topId || (typeof resolvedId==='string' && resolvedId.startsWith(topId+':')); } function audioMutedForClip(c){ if(c.audio_enabled===false) return true; if(state.mute) return true; // Solo is a persistent per-clip flag, independent of selection - once any // clip is soloed, only soloed clips are audible regardless of what's // selected (unlike the selection-based monitor rule below). const soloed=(state.project?.clips||[]).filter(x=>x.solo && x.audio_enabled!==false); if(soloed.length) return !soloed.some(x=>idMatches(x.id,c.id)); const sel=selectedClips(); if(sel.length===0){ const top=topAudioClip(state.currentFrame); return !top || !idMatches(top.id,c.id); } if(sel.length===1) return !idMatches(sel[0].id,c.id); if(sel.length===2) return !sel.some(x=>idMatches(x.id,c.id)); return true; } function clipAudibleAtFrame(c, frame=state.currentFrame){ if(!c || !audioCapable(c)) return false; if(state.hiddenLanes[c.lane]) return false; const local = frame - c.start; if(local < 0 || local >= c.length) return false; const srcIn = Number(c.source_in || 0); const srcOut = Number(c.source_out || (srcIn + c.length)); return srcIn + local < srcOut; } function monitoredAudioClips(){ if(state.mute) return []; // Solo is a persistent per-clip flag that overrides selection entirely - // matches the export-time behavior in export.py and the primary-preview // rule in audioMutedForClip. const soloed=(state.project?.clips||[]).filter(x=>x.solo && x.audio_enabled!==false && ['video','audio'].includes(x.kind)); if(soloed.length) return soloed.filter(c=>clipAudibleAtFrame(c)).slice(0,2); const selAll=selectedClips().filter(c=>['video','audio'].includes(c.kind)); if(selAll.length===1) return selAll.filter(c=>clipAudibleAtFrame(c)); if(selAll.length===2) return selAll.filter(c=>clipAudibleAtFrame(c)); if(selAll.length>=3) return []; const top=topAudioClip(state.currentFrame); return top && clipAudibleAtFrame(top) ? [top] : []; } function updateAudioMonitor(){ const v=$('previewVideo'), vB=$('previewVideoB'); [v,vB].forEach(x=>{ if(x){ x.volume=state.monitorVolume; x.muted=true; }}); updatePlaybackAudio(); } function ensurePlaybackAudioElements(){ if(state.monitorAudios) return state.monitorAudios; state.monitorAudios=[new Audio(),new Audio()]; state.monitorAudios.forEach(a=>{a.preload='auto'; a.volume=state.monitorVolume;}); return state.monitorAudios; } function syncAudioElement(a,c,autoplay){ if(!c || !clipAudibleAtFrame(c)){ a.pause(); if(!c){ a.removeAttribute('src'); a.dataset.src=''; } return; } const m=monitorMediaFor(c), src=fileUrl(m); if(!src){ a.pause(); return; } if(a.dataset.src!==src){ a.pause(); a.src=src; a.dataset.src=src; a.load(); } const sec=Math.max(0,(state.currentFrame-c.start+(c.source_in||0))/(c.fps||fps())); if(Number.isFinite(sec) && Math.abs((a.currentTime||0)-sec)>.12){ try{ a.currentTime=sec; }catch{} } const gain=Math.max(0, (c.volume??100)/100); a.volume=clamp(state.monitorVolume * gain, 0, 1); a.muted=state.mute || state.monitorVolume<=0; if(autoplay && !a.muted){ a.play().catch(()=>{}); } else if(!autoplay){ a.pause(); } } function updatePlaybackAudio(){ const els=ensurePlaybackAudioElements(); // The pre-rendered file already contains the finished mix, so the // per-clip monitor would double every sound on top of it. if(activePrerender()){ els.forEach(a=>a.pause()); return; } const clips=monitoredAudioClips().slice(0,2); els.forEach((a,i)=>syncAudioElement(a,clips[i],state.playing)); } function startPlaybackClock(){ state.playStartFrame=state.currentFrame; state.playStartedAt=performance.now(); cancelAnimationFrame(state.playRaf); const tick=()=>{ if(!state.playing) return; const elapsed=(performance.now()-state.playStartedAt)/1000; let next=state.playStartFrame + Math.floor(elapsed*fps()); if(state.loop && state.range.start!=null && state.range.end!=null){ const a=Math.min(state.range.start,state.range.end), b=Math.max(state.range.start,state.range.end); if(b>a && next>=b){ const span=b-a; next=a + ((next-a) % span); state.playStartFrame=next; state.playStartedAt=performance.now(); } } if(next>=maxFrame()){ state.currentFrame=maxFrame(); stopPlaybackClock(); updatePlayhead(); return; } if(next!==state.currentFrame){ state.currentFrame=next; updatePlayhead(); } state.playRaf=requestAnimationFrame(tick); }; state.playRaf=requestAnimationFrame(tick); } function stopPlaybackClock(){ state.playing=false; cancelAnimationFrame(state.playRaf); $('playPause').textContent='▶'; $('previewVideo').pause(); $('previewVideoB').pause(); (state.monitorAudios||[]).forEach(a=>a.pause()); } function togglePlay(){ const v=$('previewVideo'), vB=$('previewVideoB'); if(!state.playing){ if(v && !v.muted) v.play().catch(()=>{}); if(state.previewMode!=='single' && vB && !vB.muted) vB.play().catch(()=>{}); updatePlaybackAudio(); state.playing=true; $('playPause').textContent='❚❚'; startPlaybackClock(); } else { stopPlaybackClock(); } } function ensureScrubAudioElements(){ if(state.scrubAudios) return state.scrubAudios; state.scrubAudios=[new Audio(),new Audio()]; state.scrubAudios.forEach(a=>{a.preload='auto'; a.volume=state.monitorVolume;}); return state.scrubAudios; } function scheduleAudioScrub(){ if(state.playing || state.mute || !state.scrubAudio) return; clearTimeout(state.audioScrubTimer); state.audioScrubTimer=setTimeout(playAudioScrub,35); } function playAudioScrub(){ if(state.playing || state.mute || !state.scrubAudio) return; const clips=monitoredAudioClips().slice(0,2); const els=ensureScrubAudioElements(); els.forEach((a,i)=>{ const c=clips[i]; if(!c){ a.pause(); return; } const m=monitorMediaFor(c), src=fileUrl(m); if(!src){ a.pause(); return; } if(a.dataset.src!==src){ a.pause(); a.src=src; a.dataset.src=src; } const sec=Math.max(0,(state.currentFrame-c.start+(c.source_in||0))/(c.fps||fps())); try{ a.currentTime=sec; a.volume=state.monitorVolume; a.muted=false; a.play().then(()=>setTimeout(()=>{ if(!state.playing) a.pause(); },180)).catch(()=>{}); }catch{} }); } function stepFrame(delta){ if(delta==='first') state.currentFrame=0; else if(delta==='last') state.currentFrame=maxFrame(); else state.currentFrame=clamp(state.currentFrame+Number(delta),0,maxFrame()); updatePlayhead(); } function gotoSelectedStart(){ const c=selectedClip(); if(!c) return; state.currentFrame=c.start; updatePlayhead(); } function gotoSelectedEnd(){ const c=selectedClip(); if(!c) return; state.currentFrame=clipEnd(c)-1; updatePlayhead(); } function playSelectedFromStart(){ const c=selectedClip(); if(c){ state.currentFrame=c.start; updatePlayhead(); } togglePlay(); } function splitSelected(){ const c=selectedClip(); if(!c || c.kind==='stitched' || state.currentFrame<=c.start || state.currentFrame>=clipEnd(c)) return; const leftLen=state.currentFrame-c.start, rightLen=clipEnd(c)-state.currentFrame; const left=normalizeClipBounds({...c,id:`clip_${Date.now()}_L`,length:leftLen,source_out:(c.source_in||0)+leftLen,group_id:null,stitch_id:null}); const right=normalizeClipBounds({...c,id:`clip_${Date.now()}_R`,start:state.currentFrame,length:rightLen,source_in:(c.source_in||0)+leftLen,source_out:(c.source_in||0)+leftLen+rightLen,group_id:null,stitch_id:null}); state.project.clips=(state.project.clips||[]).filter(x=>x.id!==c.id).concat([left,right]); setSelection(right.id); renderAll(); status('Split'); } function stitchSelected(){ const cs=selectedClips().filter(c=>c.kind!=='stitched').sort((a,b)=>a.start-b.start || a.lane-b.lane); if(cs.length<2) return; const minStart=Math.min(...cs.map(c=>c.start)); const maxEnd=Math.max(...cs.map(c=>clipEnd(c))); const lane=Math.min(...cs.map(c=>c.lane)); const children=cs.map(c=>({...c,rel_start:c.start-minStart,orig_start:c.start,orig_lane:c.lane,group_id:null,stitch_id:null})); const stitched={id:`stitched_${Date.now()}`,name:`Stitched Clip (${cs.length})`,kind:'stitched',media_id:null,start:minStart,length:maxEnd-minStart,lane,source_in:0,source_out:maxEnd-minStart,children,stitch_id:`stitch_${Date.now()}`}; state.project.clips=(state.project.clips||[]).filter(c=>!cs.some(x=>x.id===c.id)); state.project.clips.push(stitched); setSelection(stitched.id); renderAll(); status('Stitched as layer container'); } // Auto Stitch (v0.7 Frame Stitching Engine, first pass): analyzes the // overlap between two selected video clips near their shared boundary and // recommends where to cut the earlier one (A) and where to start the // later one (B) so the join reads as continuous, using frame similarity // (primary) and audio continuity (secondary) - see itda/stitch.py. Motion // matching is intentionally deferred; this is a recommend-then-Apply flow, // never an automatic edit. function autoStitchSelected(){ const cs=selectedClips().filter(c=>c.kind==='video').sort((x,y)=>x.start-y.start); if(cs.length!==2){ status('Select exactly 2 video clips (in time order) for Auto Stitch'); return; } const [a,b]=cs; if(!a.path || !b.path){ status('Both clips need a source file for Auto Stitch'); return; } showModal('Auto Stitch - Analyze', `

Compares the tail of "${esc(trunc(a.name,30))}" against the head of "${esc(trunc(b.name,30))}" to find the best overlap cut point.

`, ``); $('stitchAnalyzeBtn').onclick=async()=>{ const windowSec=Number($('stitchWindowSec').value)||2; showModal('Auto Stitch - Analyzing…', `

Extracting frames and audio near the boundary, this can take a few seconds…

`, ''); try{ const data=await api('/itda/api/stitch_analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ project:state.project?.name||'itda-project-1', path_a:a.path, source_out_a:a.source_out||a.length, path_b:b.path, source_in_b:b.source_in||0, fps:a.fps||fps(), window_sec:windowSec, })}); if(!data.ok || !data.candidates?.length){ showModal('Auto Stitch', `

${esc(data.error||'No candidates found - try a larger window.')}

`); return; } showStitchResults(a,b,data.candidates); }catch(e){ showModal('Auto Stitch', `

Analysis failed: ${esc(e.message)}

`); } }; } function showStitchResults(a,b,candidates){ const rows=candidates.map((c,i)=>`
${i===0?'★ Best':`#${i+1}`} Frame ${(c.frame_score*100).toFixed(0)}% · Motion ${c.motion_score!=null?`${(c.motion_score*100).toFixed(0)}%`:'n/a'} · ${(c.combined_score*100).toFixed(0)}%
Audio level ${c.audio_score!=null?`${(c.audio_score*100).toFixed(0)}%`:'n/a'} (info only)
Cut A @ frame ${c.frame_a} → Start B @ frame ${c.frame_b}
`).join(''); showModal('Auto Stitch - Recommendations', `
${rows}
`, ``); $('modalBody').querySelectorAll('.stitch-apply-btn').forEach(btn=>{ btn.onclick=()=>{ applyStitchCandidate(a,b,candidates[Number(btn.dataset.idx)]); closeModal(); }; }); } function applyStitchCandidate(a,b,cand){ a.source_out=cand.frame_a; a.length=Math.max(1, a.source_out-(a.source_in||0)); normalizeClipBounds(a); b.source_in=cand.frame_b; b.length=Math.max(1, (b.source_out||b.length)-b.source_in); b.start=a.start+a.length; normalizeClipBounds(b); setSelection(a.id,false); setSelection(b.id,true); renderAll(); status(`Auto Stitch applied: A cut @${cand.frame_a}, B starts @${cand.frame_b}`); } // Add Transition: a separate action from Auto Stitch - takes two clips // that are already chronologically adjacent (B starts exactly where A // ends) and turns their join into a real blended transition on export. // The export-time xfade compositor (itda/export.py) only ever detects a // transition between clips whose active windows *overlap* (same-lane clips // can't overlap at all - wouldOverlap forbids it), so this action moves B // to a free lane and pulls it backward by the chosen duration to create // that overlap, then tags it with transition_type/transition_frames. function adjacentClipPair(){ const cs=selectedClips().filter(c=>['video','image'].includes(c.kind)).sort((x,y)=>x.start-y.start); if(cs.length!==2) return null; const [a,b]=cs; if(b.start!==clipEnd(a)) return null; return [a,b]; } // A clip carrying transition_type only means something relative to // whichever clip it currently overlaps - if that pair drifts apart (one // side dragged off on its own), the transition tag silently stops doing // anything on export rather than erroring, which is safe but confusing. // Sharing a group_id (the same mechanism Group/Ungroup already uses) keeps // them moving together by construction, so that drift shouldn't happen // through normal dragging. function existingTransitionPair(){ const cs=selectedClips().filter(c=>['video','image'].includes(c.kind)); if(cs.length!==2) return null; const [x,y]=cs; const b = (x.transition_type && x.transition_type!=='none') ? x : ((y.transition_type && y.transition_type!=='none') ? y : null); if(!b) return null; const a = b===x ? y : x; if(!a.group_id || a.group_id!==b.group_id) return null; // [a,b] roles come from which one actually carries transition_type, not // from start order - reordering by start here would let removeTransition // clear the wrong (untagged) clip whenever the tagged one drifts to an // earlier position than its partner. return [a,b]; } function addTransitionSelected(){ const pair=adjacentClipPair(); if(!pair){ status('Select 2 adjacent video/image clips (B starting exactly where A ends) to add a transition'); return; } const [a,b]=pair; showModal('Add Transition', `

Pulls "${esc(trunc(b.name,30))}" back to overlap "${esc(trunc(a.name,30))}"'s tail by this many frames and blends across the join on export. A and B are linked afterward (like Group) so dragging one drags both, keeping the overlap intact.

`, ``); $('transApply').onclick=()=>{ const type=$('transType').value; const dur=Math.max(1,Math.min(Number($('transFrames').value)||12, a.length-1, b.length-1)); const freeLane=[0,1,2,3,4].find(l=>l!==a.lane && !state.lockedLanes[l] && !wouldOverlap(b.id,l,a.start+a.length-dur,b.length)); if(freeLane==null){ status('No free lane available to overlap B onto for the transition'); closeModal(); return; } if((a.group_id && b.group_id && a.group_id!==b.group_id)){ status('A and B already belong to different groups - ungroup one first, then add the transition'); closeModal(); return; } b.lane=freeLane; b.start=a.start+a.length-dur; normalizeClipBounds(b); b.transition_type=type; b.transition_frames=dur; // Reuse whichever clip's group is already there (extending it to the // other) instead of always minting a fresh id, so this doesn't quietly // detach either clip from an unrelated group it was already part of. const gid=a.group_id || b.group_id || `trans_${Date.now()}`; a.group_id=gid; b.group_id=gid; setSelection(a.id,false); setSelection(b.id,true); renderAll(); closeModal(); status(`Transition added: ${type}, ${dur}f (B moved to T${freeLane+1}, linked to A)`); }; } function removeTransitionSelected(){ const pair=existingTransitionPair(); if(!pair) return; const [a,b]=pair; const gid=b.group_id; b.transition_type=null; b.transition_frames=null; b.group_id=null; // Only clear A's group_id too if nothing else still shares it - A may // have been part of a larger pre-existing group that B was merged into // when the transition was added, and that group should survive B leaving. const groupHasOtherMembers=gid && (state.project?.clips||[]).some(c=>c.id!==a.id && c.id!==b.id && c.group_id===gid); if(!groupHasOtherMembers) a.group_id=null; const restoreStart=clipEnd(a); const restoreLane=[a.lane,0,1,2,3,4].find(l=>!state.lockedLanes[l] && !wouldOverlap(b.id,l,restoreStart,b.length)); if(restoreLane!=null){ b.lane=restoreLane; b.start=restoreStart; normalizeClipBounds(b); setSelection(a.id,false); setSelection(b.id,true); renderAll(); status('Transition removed'); } else { setSelection(a.id,false); setSelection(b.id,true); renderAll(); status('Transition removed, but no free spot to move B back next to A - it stayed in place, drag it manually'); } } // AI Detect (v0.8 AI Layer, first pass): scene-change detection and beat // tracking for the selected clip. Both are recommend-then-apply like Auto // Stitch - detection never edits the timeline on its own. function aiDetectSelected(){ const c=selectedClip(); if(!c || !['video','audio'].includes(c.kind)){ status('Select a video or audio clip for AI Detect'); return; } if(!c.path){ status('AI Detect needs a clip with a source file'); return; } const isVideo=c.kind==='video'; showModal('AI Detect', ` ${isVideo ? `
Scene Detect

Finds shot changes inside this clip, then splits it at each one.

0.30
` : ''}
Beat Detect

Tracks tempo in this clip's audio and marks the beats on the timeline.

`, ``); const thr=$('sceneThreshold'); if(thr) thr.oninput=()=>{ $('sceneThresholdLabel').textContent=Number(thr.value).toFixed(2); }; const sceneBtn=$('sceneDetectBtn'); if(sceneBtn) sceneBtn.onclick=async()=>{ $('sceneResult').innerHTML='

Analyzing…

'; try{ const data=await api('/itda/api/scene_detect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ project:state.project?.name||'itda-project-1', path:c.path, fps:c.fps||fps(), threshold:Number(thr.value)||0.3, })}); if(!data.ok){ $('sceneResult').innerHTML=`

${esc(data.error||'Scene detection failed')}

`; return; } const srcIn=Number(c.source_in||0), srcOut=Number(c.source_out||c.length); const cuts=(data.frames||[]).filter(f=>f>srcIn && f${cuts.length} scene change(s) found.

`; $('sceneSplitBtn').onclick=()=>{ splitClipAtSourceFrames(c, cuts); closeModal(); }; }catch(e){ $('sceneResult').innerHTML=`

Failed: ${esc(e.message)}

`; } }; $('beatDetectBtn').onclick=async()=>{ $('beatResult').innerHTML='

Analyzing…

'; try{ const data=await api('/itda/api/beat_detect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ project:state.project?.name||'itda-project-1', path:c.path, fps:c.fps||fps(), })}); if(!data.ok){ $('beatResult').innerHTML=`

${esc(data.error||'Beat detection failed')}

`; return; } const m=mediaFor(c)||c; rememberBeats(m, data.frames||[]); renderTimeline(); $('beatResult').innerHTML=`

${data.bpm} BPM · ${(data.frames||[]).length} beats marked.

Turn on 〰 Peak Match to snap clips to these beats.

`; }catch(e){ $('beatResult').innerHTML=`

Failed: ${esc(e.message)}

`; } }; } function splitClipAtSourceFrames(c, srcFrames){ const srcIn=Number(c.source_in||0), srcOut=Number(c.source_out||c.length); const cuts=[...new Set(srcFrames)].filter(f=>f>srcIn && fa-b); if(!cuts.length) return; const bounds=[srcIn, ...cuts, srcOut]; const pieces=[]; for(let i=0;ix.id!==c.id).concat(pieces); setSelection(pieces[0].id,false); for(let i=1;ic.kind==='stitched'||c.children); if(!cs.length) return; const restored=[]; for(const m of cs){ (m.children||[]).forEach(ch=>{ const c={...ch,id:`clip_${Date.now()}_${Math.random().toString(16).slice(2)}`,start:m.start+(ch.rel_start||0),lane:(ch.orig_lane ?? ch.lane ?? m.lane),group_id:null,stitch_id:null}; delete c.rel_start; delete c.orig_start; restored.push(c); }); } state.project.clips=(state.project.clips||[]).filter(c=>!cs.some(m=>m.id===c.id)).concat(restored); setSelection(restored[0]?.id||null); renderAll(); status('UnStitched'); } function deleteSelected(){ if(!state.selectedClipIds.length) return; state.project.clips=state.project.clips.filter(c=>!isSelected(c.id)); setSelection(null); renderAll(); status('Deleted selected clips'); } function duplicateSelected(){ const cs=selectedClips(); if(!cs.length) return; const newIds=cs.map(c=>{ const clone={...c,id:`clip_${Date.now()}_${Math.random().toString(16).slice(2)}`,start:c.start+c.length,group_id:null,stitch_id:null}; normalizeClipBounds(clone); state.project.clips.push(clone); return clone.id; }); state.selectedClipIds=newIds; state.selectedClipId=newIds[newIds.length-1]||null; renderAll(); status(`Duplicated ${cs.length} clip(s)`); } function copySelected(){ const cs=selectedClips(); if(!cs.length) return; state.clipboard=cs.map(c=>({...c})); status(`Copied ${cs.length} clip(s)`); } function pasteClipboard(){ const cb=state.clipboard; if(!cb || !cb.length) return; const minStart=Math.min(...cb.map(c=>c.start)); const target=snapFrame(state.currentFrame); const newIds=cb.map(c=>{ const clone={...c,id:`clip_${Date.now()}_${Math.random().toString(16).slice(2)}`,start:Math.max(0,target+(c.start-minStart)),group_id:null,stitch_id:null}; normalizeClipBounds(clone); state.project.clips.push(clone); return clone.id; }); state.selectedClipIds=newIds; state.selectedClipId=newIds[newIds.length-1]||null; renderAll(); status(`Pasted ${cb.length} clip(s)`); } function groupSelected(){ const cs=selectedClips(); if(cs.length){ const gid=`group_${Date.now()}`; cs.forEach(c=>c.group_id=gid); renderAll(); status('Grouped'); } } function ungroupSelected(){ selectedClips().forEach(c=>c.group_id=null); renderAll(); status('Ungrouped'); } function detachAudio(){ const c=selectedClip(); if(!c || c.kind!=='video') return; // Detaching audio must silence the video's own track - otherwise the // original video clip keeps playing its native audio right alongside // the newly split-out audio clip, doubling it up. c.audio_enabled=false; const audio={...c,id:`clip_${Date.now()}_audio`,kind:'audio',name:`${c.name} (Audio)`,lane:clamp(c.lane+1,0,4),audio_detached:true,audio_enabled:true,detached_from:c.id}; state.project.clips.push(audio); setSelection(audio.id); renderAll(); status('Audio detached'); } function mergeAudioBack(){ const c=selectedClip(); if(!c) return; // Works from either side: select the detached audio clip, or the video // clip it was split from. let audioClip=null, videoClip=null; if(c.kind==='audio' && c.detached_from){ audioClip=c; videoClip=findClip(c.detached_from); } else if(c.kind==='video'){ audioClip=(state.project.clips||[]).find(x=>x.kind==='audio' && x.detached_from===c.id); videoClip=c; } if(!audioClip || !videoClip){ status('No matching detached audio clip found for this selection'); return; } videoClip.audio_enabled=true; state.project.clips=(state.project.clips||[]).filter(x=>x.id!==audioClip.id); setSelection(videoClip.id); renderAll(); status('Audio merged back into video'); } // Version Stack: alternate ComfyUI-generated takes of the same clip, kept // as a list on the clip itself (not the shared media-bin item, since two // clips can point at the same source media but hold different version // history). setActiveVersion overwrites the clip's own path/url in place - // that's the one field both the editor's playback path (monitorMediaFor) // and the exporter (export.py reads clip["path"] directly, no separate // media lookup) already treat as the source of truth, so no other code // needs to know versions exist. function ensureVersions(c){ if(!c.versions || !c.versions.length){ c.versions=[{id:'orig', path:c.path, url:c.url||null, name:c.name}]; c.active_version_id='orig'; } return c.versions; } function setActiveVersion(c, versionId){ const v=(c.versions||[]).find(x=>x.id===versionId); if(!v) return; c.active_version_id=versionId; c.path=v.path; c.url=v.url||null; renderAll(); status(`Active version: ${v.name}`); } async function addVersionToClip(c, file){ const fd=new FormData(); fd.append('files', file); fd.append('project', state.project?.name || 'itda-project-1'); try{ const data=await fetch('/itda/api/media/upload',{method:'POST',body:fd}).then(r=>{if(!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json();}); const path=(data.items||[])[0]; if(!path){ status('Version upload failed'); return; } ensureVersions(c); const vid=`v_${Date.now()}`; c.versions.push({id:vid, path, name:file.name}); setActiveVersion(c, vid); }catch(e){ status(`Add Version failed: ${e.message}`); } } function deleteVersion(c, versionId){ ensureVersions(c); if(c.versions.length<=1) return; const wasActive=c.active_version_id===versionId; c.versions=c.versions.filter(v=>v.id!==versionId); state.versionCompareSel=(state.versionCompareSel||[]).filter(id=>id!==versionId); if(wasActive) setActiveVersion(c, c.versions[0].id); else renderAll(); } function compareVersions(c, idA, idB){ const vA=(c.versions||[]).find(v=>v.id===idA), vB=(c.versions||[]).find(v=>v.id===idB); if(!vA || !vB) return; state.versionCompareClips=[{...c,path:vA.path,url:vA.url||null,name:vA.name},{...c,path:vB.path,url:vB.url||null,name:vB.name}]; state.previewMode='wipe'; renderAll(); } function renderVersionsSection(c){ ensureVersions(c); const compareSel=state.versionCompareSel||[]; const rows=c.versions.map(v=>{ const active=v.id===c.active_version_id, marked=compareSel.includes(v.id); return `
${esc(trunc(v.name,26))}${active?' ✓':''} ${c.versions.length>1?``:''}
`; }).join(''); return `
Versions
${rows}
`; } function sliderField(prop,label,min,max,step,value){ return `
`; } function toggleField(prop,label,checked){ return ``; } function updateProps(){ const body=$('clipProps'); const c=selectedClip(); if(!c){ body.innerHTML='No clip selected.'; return; } const fontOptions = [``].concat((state.fonts||[]).map(f=>``)).join(''); body.innerHTML=`
Clip
Timing
${['video','audio'].includes(c.kind) ? `
Audio
${toggleField('audio_enabled','Audio',c.audio_enabled!==false)}${toggleField('solo','Solo',!!c.solo)}${sliderField('volume','Gain %',0,200,1,c.volume??100)}` : ''} ${['video','image'].includes(c.kind) ? `
Transition In
Blends this clip's head with whatever other clip's active window it starts inside of - drag it onto a different lane so it overlaps that clip's tail.
` : ''} ${['video','audio','image'].includes(c.kind) ? renderVersionsSection(c) : ''}
Text / Overlay
${sliderField('x','X %',0,100,1,c.x??50)}${sliderField('y','Y %',0,100,1,c.y??88)}${sliderField('size','Size',8,220,1,c.size||42)}${sliderField('opacity','Opacity',0,1,0.05,c.opacity??1)}${toggleField('shadow_enabled','Shadow',c.shadow_enabled)}${sliderField('shadow_opacity','Shadow Opacity',0,1,0.05,c.shadow_opacity??0.6)}
`; const kind=body.querySelector('[data-prop="kind"]'); if(kind) kind.value=c.kind||'video'; const font=body.querySelector('[data-prop="font_family"]'); if(font) font.value=c.font_family||'system'; body.addEventListener('mousedown', e=>e.stopPropagation(), true); body.addEventListener('pointerdown', e=>e.stopPropagation(), true); body.addEventListener('keydown', e=>e.stopPropagation(), true); body.querySelectorAll('.slider-field').forEach(wrap=>{ const range=wrap.querySelector('input[type="range"]'); const num=wrap.querySelector('input.mirror-num'); if(!range||!num) return; range.addEventListener('input', ()=>{ num.value=range.value; }); num.addEventListener('input', ()=>{ range.value=num.value; range.dispatchEvent(new Event('input',{bubbles:true})); }); num.addEventListener('change', ()=>{ range.dispatchEvent(new Event('change',{bubbles:true})); }); }); { const versionList=body.querySelector('#versionList'); if(versionList){ versionList.querySelectorAll('.version-row').forEach(row=>{ row.addEventListener('click', e=>{ if(e.target.closest('.version-delete')) return; const vid=row.dataset.vid; if(e.ctrlKey||e.metaKey||e.shiftKey){ const sel=state.versionCompareSel||[]; state.versionCompareSel = sel.includes(vid) ? sel.filter(x=>x!==vid) : [...sel, vid].slice(-2); updateProps(); } else setActiveVersion(c, vid); }); const del=row.querySelector('.version-delete'); if(del) del.addEventListener('click', e=>{ e.stopPropagation(); deleteVersion(c, row.dataset.vid); }); }); const addBtn=body.querySelector('#addVersionBtn'), fp=body.querySelector('#versionFilePicker'); if(addBtn && fp){ addBtn.onclick=e=>{ e.stopPropagation(); fp.click(); }; fp.onchange=e=>{ const f=e.target.files[0]; if(f) addVersionToClip(c,f); fp.value=''; }; } const cmpBtn=body.querySelector('#compareVersionsBtn'); if(cmpBtn) cmpBtn.onclick=e=>{ e.stopPropagation(); const sel=state.versionCompareSel||[]; if(sel.length===2) compareVersions(c, sel[0], sel[1]); }; } } body.querySelectorAll('[data-prop]').forEach(input=>{ const readValue=()=>{ const p=input.dataset.prop; let v=input.type==='checkbox' ? input.checked : input.value; if(['start','length','source_in','source_out','x','y','size','opacity','shadow_opacity','volume','transition_frames'].includes(p)) v=Number(v); return {p,v}; }; const applyModelOnly=()=>{ const {p,v}=readValue(); if(p==='lane') c.lane=clamp(Number(v)-1,0,4); else c[p]=v; if(p==='kind') c.kind=v; normalizeClipBounds(c); }; const commit=()=>{ applyModelOnly(); renderAll(); }; // IMPORTANT: never render timeline/preview on every keystroke. // Rendering while input/textarea is focused replaces DOM and drops the caret after one key. input.oninput=()=>{ applyModelOnly(); // Live-update only the visible text overlay without rebuilding Clip Properties. if(input.dataset.prop==='text' || input.dataset.prop==='color' || input.dataset.prop==='opacity' || input.dataset.prop==='size' || input.dataset.prop==='x' || input.dataset.prop==='y' || input.dataset.prop==='shadow_enabled' || input.dataset.prop==='shadow_color' || input.dataset.prop==='shadow_opacity' || input.dataset.prop==='font_family'){ updatePreviewTextOnly(); } }; input.onchange=commit; input.onblur=commit; }); } function updatePreviewTextOnly(){ const frame = state.currentFrame; // While a text clip is selected (i.e. being edited in Clip Properties), show // its live values regardless of the playhead's position: otherwise, editing a // text clip that isn't the layer-priority clip under the current playhead // silently updates its data with no visible feedback, which reads as "the // sliders don't do anything." const sel = selectedClip(); const txt = (sel && sel.kind==='text') ? sel : topTextClip(frame); const t = $('textOverlay'); if(!t) return; if(!txt){ clearTextOverlay(t); return; } // Delegate to the same function the full render path (updatePreview) uses. // This used to duplicate loadTextOverlay's styling inline and had drifted: // different font-family fallback ('system-ui' vs 'Inter, Segoe UI, Arial'), // different text-shadow formula, and top-vs-bottom positioning. That drift // meant every commit (releasing a slider) visibly snapped the font/shadow/ // position back to a different look than what was shown while dragging. loadTextOverlay(txt, t); } function updateControls(){ const two=selectedClips().length===2; const hasVersionCompare=!!(state.versionCompareClips && state.versionCompareClips.length===2); $('compareTop').disabled=!two; $('overlayTop').disabled=!two; const wipeTop=$('wipeTop'); if(wipeTop) wipeTop.disabled=!two && !hasVersionCompare; if(!two && !hasVersionCompare && state.previewMode!=='single') state.previewMode='single'; $('previewMode').classList.toggle('active',state.previewMode==='single'); $('compareTop').classList.toggle('active',state.previewMode==='compare'); $('overlayTop').classList.toggle('active',state.previewMode==='overlay'); if(wipeTop) wipeTop.classList.toggle('active',state.previewMode==='wipe'); $('snapToggle').classList.toggle('active',state.snap); const peakBtn=$('peakSnapToggle'); if(peakBtn) peakBtn.classList.toggle('active',state.peakSnap); const prBtn=$('prerender'); if(prBtn){ const stale=prerenderStale(); prBtn.classList.toggle('active', !!state.prerender && !stale); prBtn.textContent = state.prerender ? (stale?'Pre-render (stale)':`Pre-rendered ${state.prerender.start_frame}–${state.prerender.end_frame}f`) : 'Pre-render'; } const autoStitchBtn=$('autoStitchClip'); if(autoStitchBtn){ const svids=selectedClips().filter(c=>c.kind==='video'); autoStitchBtn.disabled=svids.length!==2; } const transBtn=$('addTransition'); if(transBtn){ const existingTrans=existingTransitionPair(); if(existingTrans){ transBtn.disabled=false; transBtn.textContent='🎞 Remove Transition'; transBtn.dataset.mode='remove'; } else { transBtn.disabled=!adjacentClipPair(); transBtn.textContent='🎞 Transition'; transBtn.dataset.mode='add'; } } const aiBtn=$('aiDetect'); if(aiBtn){ const sc=selectedClip(); aiBtn.disabled=!(sc && ['video','audio'].includes(sc.kind) && sc.path); } $('loopToggle').classList.toggle('active',state.loop); $('muteToggle').classList.toggle('active',state.mute); const sab=$('scrubAudioToggle'); if(sab) sab.classList.toggle('active',state.scrubAudio); $('snapStatus').textContent=state.snap?'ON':'OFF'; $('statusbarFps').textContent=`Project FPS: ${fps().toFixed(3)}`; $('totalStatus').textContent=`Total: ${totalFrames()}f / ${fmtTime(totalFrames())}`; $('projectFpsStatus').textContent=`FPS ${fps().toFixed(3)} · Total ${totalFrames()}f`; } function showModal(title,html,footer=''){ $('modalTitle').textContent=title; $('modalBody').innerHTML=html; $('modalFooter').innerHTML=footer||''; $('modal').classList.remove('hidden'); const ok=$('modalOk'); if(ok) ok.onclick=closeModal; } function closeModal(){ $('modal').classList.add('hidden'); } async function showProjectPopup(){ showModal('Project Library', `

Projects are stored in ComfyUI/input/ITDA/projects.

Loading...
`, ``); let selected = state.project?.name || 'itda-project-1'; const listEl = $('projectLibraryList'); async function refreshList(){ try{ const data=await api('/itda/api/projects'); const items=data.items||[]; if(!items.length){ listEl.innerHTML='
No projects yet.
'; return; } listEl.innerHTML=items.map(it=>`
${esc(it.name)}
`).join(''); listEl.querySelectorAll('.project-row').forEach(row=>row.onclick=()=>{selected=row.dataset.project; refreshList();}); }catch(e){ listEl.innerHTML=`
Project list failed: ${esc(e.message)}
`; } } await refreshList(); $('projectNew').onclick=async()=>{ const base=prompt('New Project Name','itda-project-1'); if(!base) return; const data=await api('/itda/api/project/new',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:base})}); closeModal(); await initProject(data.project?.name || base); }; $('projectOpen').onclick=()=>{ if(!selected) return; closeModal(); initProject(selected); }; $('projectDuplicate').onclick=async()=>{ if(!selected) return; const target=prompt('Duplicate Project Name', `${selected}-copy`); if(!target) return; const data=await api('/itda/api/project/duplicate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:selected,target})}); selected=data.project||target; await refreshList(); status(`Duplicated: ${selected}`); }; $('projectRename').onclick=async()=>{ if(!selected) return; const target=prompt('Rename Project', selected); if(!target || target===selected) return; try{ const data=await api('/itda/api/project/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:selected,target})}); const renamed=data.project||target; if(state.project?.name===selected){ closeModal(); await initProject(renamed); } else { selected=renamed; await refreshList(); } }catch(e){ status(`Rename failed: ${e.message}`); } }; $('projectDelete').onclick=async()=>{ if(!selected) return; const target=selected; const ok=await confirmModal('Delete Project', `

Delete project ${esc(target)}?

Project file, media folder, and cache folder will be deleted.

`); if(!ok){ await showProjectPopup(); return; } try{ await api('/itda/api/project/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project:target})}); if(state.project?.name===target){ await initProject('itda-project-1'); } else { selected=state.project?.name||'itda-project-1'; } await showProjectPopup(); }catch(e){ status(`Delete failed: ${e.message}`); await showProjectPopup(); } }; $('modalOk').onclick=closeModal; } // Only meaningful when ITDA is running standalone (no filesystem shared // with ComfyUI, see itda_standalone.py) - "Send To ComfyUI" then uploads // over ComfyUI's own /upload/image API instead of writing a local file. // Empty = same-process mode, i.e. the existing local-write behavior. function getComfyUrl(){ return localStorage.getItem('itda_comfy_url') || ''; } function setComfyUrl(v){ v ? localStorage.setItem('itda_comfy_url', v) : localStorage.removeItem('itda_comfy_url'); } function showSettingsPopup(){ showModal('Project Settings', `

Below 16fps is not allowed; 60fps and above is flagged as a warning. Changing these rescales the timeline ruler and every clip's frame positions together.

ComfyUI Server URL is only needed in the standalone app - it's where "Send To ComfyUI" uploads clips to, since a standalone instance has no folder shared with ComfyUI. Leave blank when running inside ComfyUI itself.

`, ``); $('settingsApply').onclick=()=>{ const oldFps=fps(); let nf=Number($('settingsFps').value||24); if(nf<16){nf=16;status('Below 16fps is not allowed - clamped to 16fps');} if(nf>=60) status('Warning: 60fps or higher'); const total=Number($('settingsTotal').value||DEFAULT_TOTAL); const ratio=nf/oldFps; state.project.settings={...state.project.settings,fps:nf,total_frames:Math.max(1,Math.round(total))}; state.totalFrames=state.project.settings.total_frames; (state.project.clips||[]).forEach(c=>{ c.start=Math.round(c.start*ratio); c.length=Math.max(1,Math.round(c.length*ratio)); c.source_in=Math.round((c.source_in||0)*ratio); c.source_out=Math.round((c.source_out||c.length)*ratio); c.fps=nf; normalizeClipBounds(c); }); setComfyUrl($('settingsComfyUrl').value.trim()); closeModal(); renderAll(); }; $('modalOk').onclick=closeModal; } function snapshotClipCandidate(){ const sel=selectedClips(); let c=sel.find(x=>state.currentFrame>=x.start && state.currentFrametoast.classList.add('hidden'),1600); status(`Snapshot saved: source frame ${payload.source_frame}`); return; }catch(e){ status(`Original frame snapshot failed, fallback viewport: ${e.message}`); } } const stage=$('previewStage'); const rect=stage.getBoundingClientRect(); const canvas=document.createElement('canvas'); canvas.width=Math.max(2,Math.round(rect.width)); canvas.height=Math.max(2,Math.round(rect.height)); const ctx=canvas.getContext('2d'); ctx.clearRect(0,0,canvas.width,canvas.height); const drawContain=(el)=>{ if(!el || getComputedStyle(el).display==='none') return; const vw=el.videoWidth||el.naturalWidth, vh=el.videoHeight||el.naturalHeight; if(!vw||!vh) return; const scale=Math.min(canvas.width/vw, canvas.height/vh); const w=vw*scale,h=vh*scale,x=(canvas.width-w)/2,y=(canvas.height-h)/2; try{ctx.drawImage(el,x,y,w,h);}catch{} }; drawContain($('previewVideo')); drawContain($('previewImage')); drawContain($('previewVideoB')); drawContain($('previewImageB')); const text=$('textOverlay'); if(text.textContent){ ctx.font=`${parseInt(text.style.fontSize||42,10)}px sans-serif`; ctx.fillStyle=(state.selectedClipId && findClip(state.selectedClipId)?.color) || '#fff'; ctx.textAlign='center'; ctx.shadowColor='transparent'; ctx.shadowBlur=0; ctx.fillText(text.textContent, canvas.width/2, canvas.height*.82); } const blob=await new Promise(res=>canvas.toBlob(res,'image/png')); try{ const fd=new FormData(); fd.append('project',state.project?.name||'itda-project-1'); fd.append('image',blob,'snapshot.png'); await fetch('/itda/api/snapshot',{method:'POST',body:fd}).then(r=>{if(!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json();}); const toast=$('snapshotToast'); toast.textContent='Snapshot saved'; toast.classList.remove('hidden'); setTimeout(()=>toast.classList.add('hidden'),1600); status('Snapshot saved to input/ITDA-SNAPSHOT'); }catch(e){ status(`Snapshot failed: ${e.message}`); } } async function exportProject(){ if(!state.project) return; showModal('Export', ``, ''); $('exportGo').onclick=()=>runExport($('exportFormat').value); } async function runExport(fmt){ status(`Exporting timeline to ${fmt.toUpperCase()}... this can take a while for long timelines.`); // Real per-frame progress isn't available without parsing ffmpeg's own // progress stream, which the export request doesn't surface yet - but // silence with no feedback at all reads as "is this even doing // anything?" for a render that can take a while. A spinner + elapsed // timer at least confirms it's alive and shows how long it's been. const startedAt = Date.now(); showModal('Export', `

Rendering ${esc(state.project.name)} to ${fmt.toUpperCase()}...

0s elapsed

Compositing every layer (video/image/text) and mixing audio via ffmpeg.

`, ''); const elapsedTimer = setInterval(()=>{ const el = document.getElementById('exportElapsed'); if(el) el.textContent = `${Math.round((Date.now()-startedAt)/1000)}s elapsed`; }, 1000); try{ const data=await api('/itda/api/export',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project:state.project.name,format:fmt})}); clearInterval(elapsedTimer); const secs=Math.round((Date.now()-startedAt)/1000); showModal('Export Complete', `

✅ ${esc(state.project.name)} exported in ${secs}s.

${esc(data.path)}

${data.width}x${data.height} · ${data.total_frames}f @ ${data.fps}fps ${data.has_audio?'· with audio':'· no audio'}

`); status(`Exported to ${data.path}`); }catch(e){ clearInterval(elapsedTimer); showModal('Export Failed', `

${esc(e.message)}

`); status(`Export failed: ${e.message}`); } } async function sendAllTimelineToComfy(){ const clips=(state.project?.clips||[]).filter(c=>c.path && ['video','image','audio'].includes(c.kind)); if(!clips.length){ showModal('Send To ComfyUI', '

No sendable clips (video/image/audio) on this timeline.

'); return; } const comfyUrl=getComfyUrl(); status(`Sending ${clips.length} clip(s) to ComfyUI...`); const items=[]; for(const c of clips){ try{ const payload={project:state.project?.name||'itda-project-1',path:c.path,kind:c.kind,source_in:c.source_in||0,source_out:c.source_out||c.length,fps:c.fps||fps(),name:c.name,comfy_url:comfyUrl}; const data=await api('/itda/api/send_to_comfy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); items.push(comfyUrl ? {name:data.comfy_name, subfolder:data.comfy_subfolder, type:data.comfy_type, kind:c.kind} : {relative:data.relative, kind:c.kind}); }catch(e){ status(`Send failed for ${c.name}: ${e.message}`); } } if(!items.length){ showModal('Send To ComfyUI', '

All clips failed to export - see status bar.

'); return; } if(comfyUrl){ // Standalone: nothing to auto-open across origins for a batch - the // files are on ComfyUI's side now, ready to pick from its own node UI. showModal('Send To ComfyUI', `

Sent ${items.length}/${clips.length} clip(s) to ComfyUI at ${esc(comfyUrl)}.

Pick them up from a Load node's file list there (subfolder: ITDA/send/${esc(state.project?.name||'')}).

`); } else { const url=`${location.origin}/?itda_send_batch=${encodeURIComponent(JSON.stringify(items))}`; window.open(url,'_blank'); } status(`Sent ${items.length}/${clips.length} clip(s) to ComfyUI`); } async function sendToComfy(){ const sel=selectedClips(); if(sel.length===0){ return sendAllTimelineToComfy(); } if(sel.length!==1){ showModal('Send To ComfyUI', `

Select exactly one Video, Image, or Audio clip to send that clip/range, or clear selection to send the whole timeline as a batch.

`); return; } const c=sel[0]; if(!c.path || !['video','image','audio'].includes(c.kind)){ showModal('Send To ComfyUI', `

Text/stitched clips cannot be sent yet. Select a Video, Image, or Audio clip.

`); return; } let srcIn=c.source_in||0, srcOut=c.source_out||c.length, mode='clip'; if(state.range.start!=null && state.range.end!=null){ const a=Math.min(state.range.start,state.range.end), b=Math.max(state.range.start,state.range.end); const clipStart=c.start, clipEnd=c.start+c.length; const overlapStart=Math.max(a,clipStart), overlapEnd=Math.min(b,clipEnd); if(overlapEnd>overlapStart){ srcIn=(c.source_in||0)+(overlapStart-clipStart); srcOut=(c.source_in||0)+(overlapEnd-clipStart); mode='range'; } } const comfyUrl=getComfyUrl(); status(`Sending ${mode} to ComfyUI...`); try{ const payload={project:state.project?.name||'itda-project-1',path:c.path,kind:c.kind,source_in:srcIn,source_out:srcOut,fps:c.fps||fps(),name:c.name,comfy_url:comfyUrl}; const data=await api('/itda/api/send_to_comfy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); if(comfyUrl){ status(`Sent ${mode} to ComfyUI (${comfyUrl}): ${data.comfy_subfolder}/${data.comfy_name}`); } else { const url=`${location.origin}/?itda_send=${encodeURIComponent(data.relative)}&itda_kind=${encodeURIComponent(c.kind)}`; window.open(url,'_blank'); status(`Sent ${mode} to ComfyUI input: ${data.relative}`); } }catch(e){ status(`Send To ComfyUI failed: ${e.message}`); } } async function runPrerender(){ if(state.prerender && !prerenderStale()){ // Second press on a still-valid render clears it and returns the // preview to live compositing. state.prerender=null; renderAll(); status('Pre-render cleared'); return; } if(state.range.start==null || state.range.end==null){ showModal('Pre-render', '

Mark an In (I) and Out (O) point on the timeline first.

Pre-render flattens that range into one cached file so it plays back smoothly.

'); return; } const a=Math.min(state.range.start,state.range.end), b=Math.max(state.range.start,state.range.end); if(b-a<1){ showModal('Pre-render', '

That range is empty.

'); return; } const started=Date.now(); showModal('Pre-render', `

Rendering ${b-a} frames (${fmtTime(b-a)})…

0.0s

`, ''); const timer=setInterval(()=>{ const el=$('prTimer'); if(el) el.textContent=`${((Date.now()-started)/1000).toFixed(1)}s`; },100); try{ const data=await api('/itda/api/prerender',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({project:state.project?.name, range:{start:a, end:b}})}); clearInterval(timer); if(!data.ok){ showModal('Pre-render', `

Failed: ${esc(data.error||'unknown error')}

`); return; } state.prerender={ start_frame:data.start_frame, end_frame:data.end_frame, path:data.path, url:`/itda/api/file?path=${encodeURIComponent(data.path)}&project=${encodeURIComponent(state.project?.name||'')}&_cb=${Date.now()}`, signature:projectSignature(), }; renderAll(); showModal('Pre-render', `

${data.start_frame}f – ${data.end_frame}f pre-rendered in ${((Date.now()-started)/1000).toFixed(1)}s.

Playback inside that range now uses the cached composite. Any edit invalidates it automatically; press R again to clear it.

`); }catch(e){ clearInterval(timer); showModal('Pre-render', `

Failed: ${esc(e.message)}

`); } } function applyTooltips(){ const tips={settingsTop:'Settings (Ctrl+,)',projectMenu:'Project Library (Ctrl+P)',saveProject:'Save Project (Ctrl+S)',exportProject:'Export (Ctrl+E)',sendComfy:'Send To ComfyUI (Ctrl+Enter)',previewMode:'Single Preview (1)',compareTop:'Compare Preview (2) - requires 2 selected clips',overlayTop:'Overlay Preview (3) - requires 2 selected clips',wipeTop:'Wipe Compare (4) - requires 2 selected clips, drag the split line',snapshotTop:'Snapshot (P)',fullscreenTop:'Fullscreen (F)',gotoClipStart:'Selected Clip Start',gotoClipEnd:'Selected Clip End',loopToggle:'Loop Range (L)',muteToggle:'Mute Monitor (M)',markIn:'Mark In (I)',markOut:'Mark Out (O)',clearRange:'Clear Range (Alt+X)',snapToggle:'Snap Toggle (S)',splitClip:'Split / Cut (C)',stitchClip:'Stitch selected clips (Shift+M)',autoStitchClip:'Auto Stitch - analyze the best overlap cut point between 2 selected video clips',addTransition:'Add Transition - select 2 adjacent video/image clips and insert a blended transition at the join',aiDetect:'AI Detect - scene changes / beat tracking for the selected clip',unstitchClip:'UnStitch selected clips (Shift+U)',groupClip:'Group selected clips (G)',ungroupClip:'Ungroup selected clips (Shift+G)',detachAudio:'Detach Audio (D)',mergeAudio:'Merge Audio back into video',prerender:'Pre-render the marked In/Out range into one cached file for smooth playback (R). Press again to clear.',deleteClip:'Delete selected clip (Delete/Backspace)',hZoom:'Horizontal Timeline Zoom',vZoom:'Vertical Track Zoom'}; Object.entries(tips).forEach(([id,t])=>{const el=$(id); if(el) el.title=t;}); } function bind(){ applyTooltips(); document.addEventListener('pointerup', e=>{ if(e.target.closest('button,input[type="range"]')) requestAnimationFrame(()=>document.activeElement?.blur?.()); }, true); $('saveProject').onclick=saveProject; $('projectMenu').onclick=showProjectPopup; $('settingsTop').onclick=showSettingsPopup; $('exportProject').onclick=exportProject; $('sendComfy').onclick=sendToComfy; $('clearMedia').onclick=()=>{state.media=[]; if(state.project){state.project.media=[];state.project.clips=[];} setSelection(null); renderAll();}; $('addVideo').onclick=()=>{const fp=$('filePicker'); fp.accept='video/*'; fp.dataset.kind='video'; fp.click();}; $('addAudio').onclick=()=>{const fp=$('filePicker'); fp.accept='audio/*'; fp.dataset.kind='audio'; fp.click();}; $('addImage').onclick=()=>{const fp=$('filePicker'); fp.accept='image/*'; fp.dataset.kind='image'; fp.click();}; $('addText').onclick=addTextMedia; $('filePicker').onchange=e=>{addLocalFiles(e.target.files,e.target.dataset.kind); e.target.value='';}; $('gridView').onclick=()=>{state.mediaView='grid'; $('gridView').classList.add('active'); $('listView').classList.remove('active'); renderMedia();}; $('listView').onclick=()=>{state.mediaView='list'; $('listView').classList.add('active'); $('gridView').classList.remove('active'); renderMedia();}; $('thumbScale').oninput=e=>{state.mediaThumb=Number(e.target.value)||104; renderMedia();}; const mediaBin=document.querySelector('.media-bin'); if(mediaBin){ mediaBin.addEventListener('dragover',e=>{ if(e.dataTransfer?.types?.includes('Files')){ e.preventDefault(); mediaBin.classList.add('drag-over'); }}); mediaBin.addEventListener('dragleave',()=>mediaBin.classList.remove('drag-over')); mediaBin.addEventListener('drop',e=>{ if(e.dataTransfer?.files?.length){ e.preventDefault(); mediaBin.classList.remove('drag-over'); addLocalFiles(e.dataTransfer.files,null); }}); } $('playPause').onclick=playSelectedFromStart; $('previewStage').onclick=e=>{ if(e.target.id!=='snapshotToast' && !e.target.closest('#wipeHandle')) togglePlay(); }; { const wh=$('wipeHandle'), stage=$('previewStage'); wh.addEventListener('pointerdown', e=>{ e.preventDefault(); e.stopPropagation(); const move=ev=>{ const rect=stage.getBoundingClientRect(); state.wipePos=clamp(((ev.clientX-rect.left)/rect.width)*100, 2, 98); stage.style.setProperty('--wipe-pos', `${state.wipePos}%`); }; move(e); document.addEventListener('pointermove', move); document.addEventListener('pointerup', ()=>document.removeEventListener('pointermove', move), {once:true}); }); } $('gotoClipStart').onclick=gotoSelectedStart; $('gotoClipEnd').onclick=gotoSelectedEnd; $('compareTop').onclick=()=>{ if(selectedClips().length===2){state.previewMode=state.previewMode==='compare'?'single':'compare'; renderAll();} }; $('overlayTop').onclick=()=>{ if(selectedClips().length===2){state.previewMode=state.previewMode==='overlay'?'single':'overlay'; renderAll();} }; const wipeTop=$('wipeTop'); if(wipeTop) wipeTop.onclick=()=>{ if(selectedClips().length===2){state.previewMode=state.previewMode==='wipe'?'single':'wipe'; renderAll();} }; $('previewMode').onclick=()=>{state.previewMode='single'; renderAll();}; $('snapshotTop').onclick=snapshot; $('fullscreenTop').onclick=()=>{ const el=$('previewStage'); if(document.fullscreenElement) document.exitFullscreen(); else el.requestFullscreen?.(); }; document.querySelectorAll('[data-step]').forEach(btn=>btn.onclick=()=>stepFrame(btn.dataset.step)); $('snapToggle').onclick=()=>{state.snap=!state.snap; updateControls();}; const peakBtn=$('peakSnapToggle'); if(peakBtn) peakBtn.onclick=()=>{state.peakSnap=!state.peakSnap; updateControls(); status(`Peak Match ${state.peakSnap?'ON':'OFF'}`);}; $('loopToggle').onclick=()=>{state.loop=!state.loop; updateControls();}; $('muteToggle').onclick=()=>{state.mute=!state.mute; updateControls(); updatePreview();}; $('scrubAudioToggle').onclick=()=>{state.scrubAudio=!state.scrubAudio; updateControls(); if(!state.scrubAudio)(state.scrubAudios||[]).forEach(a=>a.pause());}; $('monitorVolume').oninput=e=>{const v=Math.min(100,Math.max(0,Number(e.target.value)||0)); e.target.value=v; state.monitorVolume=v/100; $('volumeLabel').textContent=`${v}%`; [$('previewVideo'),$('previewVideoB'),...(state.scrubAudios||[]),...(state.monitorAudios||[])].forEach(a=>{ if(a){ a.volume=state.monitorVolume; if(v>0 && !state.mute) a.muted=false; }});}; $('markIn').onclick=()=>{state.range.start=state.currentFrame; renderRange();}; $('markOut').onclick=()=>{state.range.end=state.currentFrame; renderRange();}; $('clearRange').onclick=()=>{state.range={start:null,end:null}; renderRange();}; $('splitClip').onclick=splitSelected; $('stitchClip').onclick=stitchSelected; $('unstitchClip').onclick=unstitchSelected; const autoStitchBtn2=$('autoStitchClip'); if(autoStitchBtn2) autoStitchBtn2.onclick=autoStitchSelected; const transBtn2=$('addTransition'); if(transBtn2) transBtn2.onclick=()=>{ if(transBtn2.dataset.mode==='remove') removeTransitionSelected(); else addTransitionSelected(); }; const aiDetectBtn2=$('aiDetect'); if(aiDetectBtn2) aiDetectBtn2.onclick=aiDetectSelected; $('groupClip').onclick=groupSelected; $('ungroupClip').onclick=ungroupSelected; $('detachAudio').onclick=detachAudio; $('mergeAudio').onclick=mergeAudioBack; $('prerender').onclick=runPrerender; $('deleteClip').onclick=deleteSelected; const hz=$('hZoom'); if(hz) hz.oninput=e=>{state.pxPerFrame=Number(e.target.value)||state.pxPerFrame; renderTimeline();}; const vz=$('vZoom'); if(vz) vz.oninput=e=>{state.laneHeight=Number(e.target.value)||DEFAULT_LANE_H; renderTimeline();}; const timeline=$('timeline'), ruler=$('ruler'); let scrubbing=false; const scrub=e=>{state.currentFrame=frameFromTimelineEvent(e); if(state.playing){state.playStartFrame=state.currentFrame; state.playStartedAt=performance.now();} updatePlayhead();}; ruler.addEventListener('mousedown',e=>{e.preventDefault(); e.stopPropagation(); scrubbing=true; scrub(e);}); document.addEventListener('mousemove',e=>{if(scrubbing){e.preventDefault(); scrub(e);}}); document.addEventListener('mouseup',e=>{ if(scrubbing){ e.preventDefault?.(); e.stopPropagation?.(); } scrubbing=false;}); timeline.addEventListener('mousedown',e=>{ if(e.target.closest('.clip')||e.target.closest('.lane-label')||e.target.closest('#ruler')) return; e.preventDefault(); const additive=e.ctrlKey||e.metaKey||e.shiftKey; const box={startX:e.clientX,startY:e.clientY,moved:false,additive}; const onMove=ev=>{ if(Math.abs(ev.clientX-box.startX)>3 || Math.abs(ev.clientY-box.startY)>3) box.moved=true; if(!box.moved) return; const rect=timeline.getBoundingClientRect(); const x1=Math.min(box.startX,ev.clientX)-rect.left+timeline.scrollLeft, x2=Math.max(box.startX,ev.clientX)-rect.left+timeline.scrollLeft; // y1/y2 are already correct #timeline-relative content coordinates - // #boxSelect is positioned absolute within #timeline, whose content // space starts at its own top (the sticky ruler still reserves its // 48px of flow space there). The old "-48" here subtracted that // reserved space a second time, drawing the box a constant 48px // above the actual cursor regardless of scroll. const y1=Math.min(box.startY,ev.clientY)-rect.top+timeline.scrollTop, y2=Math.max(box.startY,ev.clientY)-rect.top+timeline.scrollTop; const bs=$('boxSelect'); bs.style.display='block'; bs.style.left=`${x1}px`; bs.style.width=`${Math.max(1,x2-x1)}px`; bs.style.top=`${Math.max(0,y1)}px`; bs.style.height=`${Math.max(1,y2-y1)}px`; }; const onUp=ev=>{ document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); $('boxSelect').style.display='none'; if(!box.moved){ setSelection(null); state.currentFrame=frameFromTimelineEvent(e); renderAll(); return; } const fA=frameFromTimelineEvent({clientX:box.startX}), fB=frameFromTimelineEvent(ev); const frameLo=Math.min(fA,fB), frameHi=Math.max(fA,fB); const laneA=laneFromClientY(box.startY), laneB=laneFromClientY(ev.clientY); const laneLo=Math.min(laneA,laneB), laneHi=Math.max(laneA,laneB); const hits=(state.project?.clips||[]).filter(c=>c.lane>=laneLo && c.lane<=laneHi && c.startframeLo); if(!hits.length){ if(!box.additive) setSelection(null); renderAll(); return; } if(box.additive){ hits.forEach(c=>{ if(!isSelected(c.id)) setSelection(c.id,true); }); } else { setSelection(hits[0].id,false); for(let i=1;i{e.preventDefault();}); timeline.addEventListener('drop',e=>{e.preventDefault(); const raw=e.dataTransfer.getData('application/itda-media'); if(!raw) return; const item=JSON.parse(raw); addClipFromMedia(item,frameFromTimelineEvent(e)); const c=selectedClip(); if(c){ const lane=laneFromTimelineEvent(e); if(!state.lockedLanes[lane]){ const fitted=fitSingleMove(c,c.start,lane,1); c.lane=fitted.lane; c.start=fitted.start; } renderAll(); }}); timeline.addEventListener('wheel',e=>{ if(!e.ctrlKey) return; e.preventDefault(); const old=state.pxPerFrame; state.pxPerFrame=Math.max(.5,Math.min(20,state.pxPerFrame+(e.deltaY<0?.5:-.5))); timeline.scrollLeft=timeline.scrollLeft*(state.pxPerFrame/old); renderTimeline(); },{passive:false}); function isShortcutBlockedByTextInput(e){ if(e.isComposing) return true; const editableTypes = new Set(['text','number','search','url','email','password','tel','color','date','datetime-local','month','week','time']); const nodes = []; if(e.target) nodes.push(e.target); if(document.activeElement) nodes.push(document.activeElement); if(typeof e.composedPath === 'function') nodes.push(...e.composedPath()); return nodes.some(el=>{ if(!el || el === window || el === document || !el.tagName) return false; const tag = el.tagName.toUpperCase(); if(el.isContentEditable) return true; if(tag === 'TEXTAREA' || tag === 'SELECT') return true; if(tag === 'INPUT'){ const type = (el.getAttribute('type') || 'text').toLowerCase(); return editableTypes.has(type); } return false; }); } document.addEventListener('keydown',e=>{ if(isShortcutBlockedByTextInput(e)) return; const k=e.key.toLowerCase(); if((e.ctrlKey||e.metaKey) && k===','){e.preventDefault(); showSettingsPopup(); return;} if((e.ctrlKey||e.metaKey) && k==='p'){e.preventDefault(); showProjectPopup(); return;} if((e.ctrlKey||e.metaKey) && k==='s'){e.preventDefault(); saveProject(); return;} if((e.ctrlKey||e.metaKey) && k==='e'){e.preventDefault(); $('exportProject').click(); return;} if((e.ctrlKey||e.metaKey) && e.key==='Enter'){e.preventDefault(); $('sendComfy').click(); return;} if((e.ctrlKey||e.metaKey) && k==='d'){e.preventDefault(); duplicateSelected(); return;} if((e.ctrlKey||e.metaKey) && k==='c'){e.preventDefault(); copySelected(); return;} if((e.ctrlKey||e.metaKey) && k==='v'){e.preventDefault(); pasteClipboard(); return;} if(e.code==='Space'){e.preventDefault(); togglePlay();} else if(e.key==='Escape'){setSelection(null); renderAll();} else if(e.key==='Delete'||e.key==='Backspace') deleteSelected(); else if(e.key==='ArrowLeft'&&e.altKey) stepFrame('first'); else if(e.key==='ArrowRight'&&e.altKey) stepFrame('last'); else if(e.key==='ArrowLeft'&&e.shiftKey) stepFrame(-5); else if(e.key==='ArrowRight'&&e.shiftKey) stepFrame(5); else if(e.key==='ArrowLeft') stepFrame(-1); else if(e.key==='ArrowRight') stepFrame(1); else if(k==='s' && !e.ctrlKey && !e.metaKey){state.snap=!state.snap; updateControls();} else if(k==='i'){state.range.start=state.currentFrame; renderRange();} else if(k==='o'){state.range.end=state.currentFrame; renderRange();} else if(k==='x'&&e.altKey){state.range={start:null,end:null}; renderRange();} else if(k==='l'){state.loop=!state.loop; updateControls();} else if(k==='m'&&e.shiftKey){stitchSelected();} else if(k==='u'&&e.shiftKey){unstitchSelected();} else if(k==='c'){splitSelected();} else if(k==='g'&&e.shiftKey){ungroupSelected();} else if(k==='g'){groupSelected();} else if(k==='d'&&!e.ctrlKey&&!e.metaKey){detachAudio();} else if(k==='r'){ $('prerender').click();} else if(k==='m'){state.mute=!state.mute; updateControls(); updatePreview();} else if(k==='a'){state.scrubAudio=!state.scrubAudio; updateControls(); if(!state.scrubAudio)(state.scrubAudios||[]).forEach(x=>x.pause());} else if(k==='f'){$('fullscreenTop').click();} else if(k==='1'){state.previewMode='single'; renderAll();} else if(k==='2'){if(selectedClips().length===2){state.previewMode=state.previewMode==='compare'?'single':'compare'; renderAll();}} else if(k==='3'){if(selectedClips().length===2){state.previewMode=state.previewMode==='overlay'?'single':'overlay'; renderAll();}} else if(k==='4'){if(selectedClips().length===2){state.previewMode=state.previewMode==='wipe'?'single':'wipe'; renderAll();}} else if(k==='p') snapshot(); else if(e.key==='='||e.key==='+'){state.pxPerFrame=Math.min(20,state.pxPerFrame+.5); if($('hZoom')) $('hZoom').value=state.pxPerFrame; renderTimeline();} else if(e.key==='-'){state.pxPerFrame=Math.max(.5,state.pxPerFrame-.5); if($('hZoom')) $('hZoom').value=state.pxPerFrame; renderTimeline();} }); $('previewVideo').addEventListener('timeupdate',()=>{ /* playhead is driven by frame clock, not video end events */ }); let resizing=false; $('resizeHandle').addEventListener('mousedown',()=>resizing=true); document.addEventListener('mousemove',e=>{ if(!resizing) return; const y=e.clientY-46,h=window.innerHeight-46,upper=Math.max(250,Math.min(h-210,y)); $('upperPane').style.height=`${upper}px`; document.querySelector('.lower').style.height=`${h-upper-5}px`; }); document.addEventListener('mouseup',()=>resizing=false); $('modalClose').onclick=closeModal; $('modal').addEventListener('click',e=>{if(e.target.id==='modal') closeModal();}); } bind(); initProject(new URLSearchParams(location.search).get('project')).catch(e=>status(`Init failed: ${e.message}`)); })();