104 lines
21 KiB
JavaScript
104 lines
21 KiB
JavaScript
import { mountFreezeControls, setAhead } from './iamccs_ahead_controls.js';
|
|
import { api } from '/scripts/api.js';
|
|
|
|
export async function openCutEditor(node, host) {
|
|
let abandoned=false;host.addEventListener('ahead-dispose',()=>{abandoned=true;},{once:true});
|
|
const run=node.properties?.iamccs_lga_run;
|
|
if(!run){
|
|
const empty=document.createElement('section');empty.style.cssText='max-width:900px;margin:24px auto;padding:24px;background:#13232c;color:#e5f5f2;border:1px solid #6ba99c;border-radius:9px';
|
|
const title=document.createElement('h2');title.textContent='TRIM / SET OUT';
|
|
const text=document.createElement('p');text.textContent='The editor is ready, but this branch has not received a completed run in the current page yet. Queue this workflow once, then reopen this tab to get its numbered filmstrip, audio preview and OUT handle. Only the new run from this branch is attached; an older Shotboard or another branch is never loaded automatically.';
|
|
const guide=document.createElement('p');guide.textContent='For the next run choose AUTO FREEZE-AWARE below, or KEEP ALL by setting Max tail frames to 0. A manual OUT becomes available after the delivered intervals exist.';
|
|
empty.append(title,text,guide);mountFreezeControls(empty,node);host.append(empty);return;
|
|
}
|
|
let info;
|
|
try {const response=await api.fetchApi('/iamccs/ahead/cuts/'+encodeURIComponent(run));info=await response.json();if(!response.ok)throw Error(info.error||'Restart ComfyUI to load the cut editor endpoint.');}
|
|
catch(error){const message=document.createElement('p');message.textContent=String(error);message.style.cssText='margin:24px;padding:18px;background:#472a2a;color:#ffdada';host.append(message);return;}
|
|
if(abandoned)return;
|
|
const widget=node.widgets?.find(w=>w.name==='cut_plan');
|
|
const dialog=document.createElement('section');dialog.style.cssText='width:100%;min-height:600px;box-sizing:border-box;overflow:auto;background:#13232c;color:#e5f5f2;border:1px solid #6ba99c;padding:22px';
|
|
const el=(tag,text)=>{const e=document.createElement(tag);if(text)e.textContent=text;return e;};
|
|
const button=(text,action)=>{const b=el('button',text);b.onclick=action;return b;};
|
|
const title=el('h2','TRIM / SET OUT · choose where this clip ends');
|
|
const video=el('video');video.controls=true;video.preload='auto';video.style.cssText='width:100%;height:300px;background:black';
|
|
const decoder=el('video');decoder.muted=true;decoder.preload='auto';
|
|
const select=el('select');info.intervals.forEach((v,i)=>{const o=el('option',`Chunk ${i+1} · ${v.frames} frame`);o.value=i;select.append(o);});
|
|
const number=el('input');number.type='number';number.min=2;number.step=1;
|
|
const slider=el('input');slider.type='range';slider.min=2;slider.step=1;slider.style.width='100%';
|
|
const strip=el('div');strip.className='ahead-cut-filmstrip';strip.title='Drag left/right across the numbered frames to place OUT. Use the scrollbar or mouse wheel to navigate the strip.';
|
|
const status=el('p'),warning=el('p');warning.style.color='#ffd29a';
|
|
const analysis=el('div');analysis.className='ahead-cut-analysis';
|
|
const policy=el('div');policy.style.cssText='padding:10px;border:2px solid #7ad9bd;border-radius:7px;background:#163b35;font-weight:700';
|
|
let cuts={},index=0,revision=0,closed=false,playingCut=false,filmstripCards=new Map();
|
|
const timeline=el('div');timeline.className='ahead-cut-timeline';
|
|
const retained=el('div');retained.className='ahead-cut-kept';
|
|
const keepLabel=el('span','KEPT');keepLabel.className='ahead-cut-zone-label';retained.append(keepLabel);
|
|
const removeLabel=el('span','REMOVED ON NEXT QUEUE');removeLabel.className='ahead-cut-remove-label';
|
|
const playhead=el('i');playhead.className='ahead-cut-playhead';
|
|
const out=el('div','OUT');out.className='ahead-cut-out';timeline.append(retained,removeLabel,playhead,out);
|
|
let dragging=false;
|
|
const fraction=e=>Math.max(0,Math.min(1,(e.clientX-timeline.getBoundingClientRect().left)/timeline.clientWidth));
|
|
out.onpointerdown=e=>{e.stopPropagation();dragging=true;out.setPointerCapture(e.pointerId);};out.onpointermove=e=>{if(dragging){number.value=Math.round(fraction(e)*current().frames);update();}};out.onpointerup=()=>{dragging=false;syncFilmstrip(true);};out.onpointercancel=()=>{dragging=false;};
|
|
timeline.onpointerdown=e=>{if(e.target===out)return;video.pause();const frame=Math.min(current().frames-1,Math.floor(fraction(e)*current().frames));video.currentTime=(frame+0.5)/current().fps;playhead.style.left=`${100*(frame+0.5)/current().frames}%`;};
|
|
|
|
try {const saved=JSON.parse(widget?.value||'{}');if(saved.contract===info.contract)cuts={...saved.cuts};}catch{}
|
|
const current=()=>info.intervals[index];
|
|
const keep=()=>Number(number.value);
|
|
const url=v=>api.apiURL('/view?'+new URLSearchParams({filename:v.filename,subfolder:'IAMCCS/LatentGoAhead/'+run,type:'output'}));
|
|
const tc=(frames,fps)=>{const total=frames/fps,m=Math.floor(total/60),s=total-m*60;return `${String(m).padStart(2,'0')}:${s.toFixed(3).padStart(6,'0')}`;};
|
|
function update(){const v=current(),k=Math.max(2,Math.min(v.frames,Math.round(keep())||2)),pct=100*k/v.frames;number.value=slider.value=k;retained.style.width=`${pct}%`;out.style.left=`calc(${pct}% - 3px)`;removeLabel.style.display=k===v.frames?'none':'block';status.textContent=`OUT ${tc(k,v.fps)} · frame ${k} INCLUDED · ${v.frames-k} frames removed (${tc(v.frames-k,v.fps)}) · delivered length ${tc(k,v.fps)}. Audio ends on the same clock. ${v.raw_frames>v.frames?'This source was already auto-trimmed; regenerate with auto trim OFF to recover its original tail.':''}`;syncFilmstrip(false);}
|
|
function dependency(){
|
|
const keys=Object.keys(cuts).map(Number).sort((a,b)=>a-b),auto=Number(node.widgets?.find(w=>w.name==='freeze_tail_max_frames')?.value||0);
|
|
policy.textContent=Object.hasOwn(cuts,index)
|
|
? `CURRENT: MANUAL OUT · clip ${index+1} keeps frame ${cuts[index]} included`
|
|
: auto>0?`CURRENT: AUTO FREEZE-AWARE · inspect up to ${auto} terminal frames`:'CURRENT: KEEP ALL · automatic trim disabled';
|
|
warning.textContent=keys.length
|
|
? `Manual OUT clips: ${keys.map(i=>i+1).join(', ')}. The next Queue regenerates the complete chain. Clips after the first edited boundary also change because their latent history changes.`
|
|
: auto>0?'AUTO removes only a detected terminal freeze. The OUT handle is a manual override for this selected clip.':'No automatic or manual trim: every decoded frame and its matching audio duration are kept.';
|
|
const result=current().analysis||{},detected=Boolean(result.detected);
|
|
analysis.className=`ahead-cut-analysis ${detected?'detected':'clear'}`;
|
|
analysis.innerHTML=detected
|
|
? `<b>AUTO ANALYSIS · TERMINAL FREEZE FOUND</b><span>${result.explanation||''}</span><small>First suspect frame: #${result.first_frozen_frame} · Recommended OUT: #${result.suggested_out} (included). Review the gold boundary before saving.</small>`
|
|
: `<b>AUTO ANALYSIS · MOTION REACHES THE END</b><span>${result.explanation||'No repeated terminal frames were detected.'}</span><small>The detector is a guide. Move OUT only when the filmstrip shows a real hold or duplicate.</small>`;
|
|
}
|
|
function seek(time){return new Promise((resolve,reject)=>{if(Math.abs(decoder.currentTime-time)<0.00001&&decoder.readyState>=2){resolve();return;}const timer=setTimeout(()=>done(Error('Frame preview timeout')),5000);function done(error){clearTimeout(timer);decoder.removeEventListener('seeked',ok);decoder.removeEventListener('error',fail);error?reject(error):resolve();}const ok=()=>done(),fail=()=>done(Error('Video decode failed'));decoder.addEventListener('seeked',ok,{once:true});decoder.addEventListener('error',fail,{once:true});decoder.currentTime=time;});}
|
|
function syncFilmstrip(scroll=false){const k=keep(),result=current().analysis||{},firstFrozen=Number(result.first_frozen_frame||0),hasFreeze=Boolean(result.detected&&firstFrozen>0);for(const [frame,card] of filmstripCards){const selected=frame===k;card.classList.toggle('selected',selected);card.classList.toggle('removed',frame>k);card.classList.toggle('suspect',hasFreeze&&frame>=firstFrozen);card.querySelector('strong').textContent=`#${frame}${selected?' · OUT':''}`;}if(scroll){filmstripCards.get(k)?.scrollIntoView({behavior:'smooth',block:'nearest',inline:'center'});}}
|
|
let pending=Promise.resolve();
|
|
function thumbnails(){const token=++revision,selected=index;pending=pending.catch(()=>{}).then(async()=>{if(closed||token!==revision)return;strip.replaceChildren();filmstripCards=new Map();const v=info.intervals[selected];const first=Math.max(1,v.frames-95);for(let f=first;f<=v.frames;f++){const card=button('',()=>{number.value=f;update();syncFilmstrip(true);});card.className='ahead-cut-frame';card.dataset.frame=String(f);const canvas=el('canvas');canvas.width=144;canvas.height=81;const caption=el('strong',`#${f}`);const time=el('small',tc(f,v.fps));card.append(canvas,caption,time);strip.append(card);filmstripCards.set(f,card);}syncFilmstrip(true);for(let f=first;f<=v.frames;f++){if(closed||token!==revision)return;await seek((f-0.5)/v.fps);if(closed||token!==revision)return;const canvas=filmstripCards.get(f)?.querySelector('canvas');if(canvas){canvas.height=Math.max(50,Math.round(canvas.width*decoder.videoHeight/Math.max(1,decoder.videoWidth)));canvas.getContext('2d').drawImage(decoder,0,0,canvas.width,canvas.height);}}syncFilmstrip(true);}).catch(error=>{status.textContent=String(error);});}
|
|
function load(){revision++;video.pause();playingCut=false;index=Number(select.value);filmstripCards=new Map();const v=current();number.max=slider.max=v.frames;number.value=cuts[index]??v.frames;update();const reviewFilename=v.filmstrip_filename||v.filename;video.src=url({...v,filename:reviewFilename});decoder.onloadeddata=()=>thumbnails();decoder.src=url({...v,filename:reviewFilename});}
|
|
select.onchange=load;number.onchange=()=>{update();syncFilmstrip(true);};slider.oninput=()=>{number.value=slider.value;update();};slider.onchange=()=>syncFilmstrip(true);
|
|
let stripSelecting=false;
|
|
const placeOutFromFilmstrip=(event)=>{const hit=document.elementFromPoint(event.clientX,event.clientY)?.closest?.('.ahead-cut-frame');if(!hit||!strip.contains(hit))return;const frame=Number(hit.dataset.frame||0);if(frame<2||frame>current().frames)return;video.pause();playingCut=false;number.value=frame;update();};
|
|
strip.onpointerdown=(event)=>{if(event.button!==0)return;event.preventDefault();stripSelecting=true;strip.setPointerCapture?.(event.pointerId);placeOutFromFilmstrip(event);};
|
|
strip.onpointermove=(event)=>{if(stripSelecting)placeOutFromFilmstrip(event);};
|
|
strip.onpointerup=(event)=>{if(!stripSelecting)return;stripSelecting=false;strip.releasePointerCapture?.(event.pointerId);placeOutFromFilmstrip(event);syncFilmstrip(true);};
|
|
strip.onpointercancel=()=>{stripSelecting=false;};
|
|
video.ontimeupdate=()=>{playhead.style.left=`${100*Math.min(video.currentTime*current().fps,current().frames)/current().frames}%`;if(playingCut&&video.currentTime>=keep()/current().fps){video.pause();playingCut=false;}};
|
|
const presets=el('div');[0,2,6,12].forEach(n=>presets.append(button(n?`REMOVE ${n} frames`:'KEEP ENTIRE CLIP',()=>{number.value=Math.max(2,current().frames-n);update();syncFilmstrip(true);})));
|
|
const save=button('SAFE · SAVE OUT FOR NEXT QUEUE',()=>{if(!widget){alert('Restart ComfyUI and reload this workflow: cut_plan widget is missing.');return;}cuts[index]=keep();setAhead(node,'cut_plan',JSON.stringify({contract:info.contract,cuts}));dependency();status.textContent=`SAVED · Chunk ${index+1} OUT is frame ${keep()} (included). This exact configuration will be used by the next Queue.`;});save.className='ahead-cut-safe';
|
|
const clear=button('CLEAR MANUAL OUT · USE POLICY',()=>{delete cuts[index];setAhead(node,'cut_plan',JSON.stringify({contract:info.contract,cuts}));number.value=current().frames;update();dependency();syncFilmstrip(true);});
|
|
const useAnalysis=button('USE RECOMMENDED DETECTOR OUT',()=>{const suggested=Number(current().analysis?.suggested_out||current().frames);number.value=Math.max(2,Math.min(current().frames,suggested));update();syncFilmstrip(true);});
|
|
const automatic=button('AUTO SAFE · MAX 6 TERMINAL FRAMES',()=>{cuts={};setAhead(node,'freeze_tail_max_frames',6);number.value=current().frames;update();dependency();syncFilmstrip(true);});
|
|
const keepAll=button('KEEP ALL · NO TRIM',()=>{cuts={};setAhead(node,'freeze_tail_max_frames',0);number.value=current().frames;update();dependency();syncFilmstrip(true);});
|
|
const playThrough=button('PLAY LAST 4s · THROUGH AI FREEZE',()=>{video.currentTime=Math.max(0,(current().frames-96)/current().fps);playingCut=false;video.play().catch(error=>{status.textContent=String(error);});});
|
|
dialog.append(title,el('p','1. Choose a clip. 2. Inspect with the video or timeline. 3. SET OUT at the last frame you want to KEEP, or drag the gold OUT handle. 4. Save for the next generation. GREEN stays; RED is removed. Opening this tab does not cut anything. OUT at the right edge keeps the entire clip.'),select,video,button('LISTEN · LAST 2 SECONDS TO OUT',()=>{video.currentTime=Math.max(0,keep()/current().fps-2);playingCut=true;video.play().catch(error=>{status.textContent=String(error);});}),button('SET OUT AT PLAYHEAD',()=>{number.value=Math.min(current().frames,Math.floor(video.currentTime*current().fps)+1);update();syncFilmstrip(true);}),el('p','OUT: last INCLUDED frame, counted from 1. Video and audio share this timeline.'),timeline,number,slider,presets,strip,status,warning,save,clear,button('CLOSE EDITOR',()=>host.closest('dialog')?.close()));
|
|
host.addEventListener('ahead-dispose',()=>{closed=true;revision++;video.pause();decoder.pause();video.removeAttribute('src');decoder.removeAttribute('src');video.load();decoder.load();dialog.remove();},{once:true});
|
|
const guide=dialog.querySelector('p');
|
|
const buttons=[...dialog.querySelectorAll('button')];
|
|
const listen=buttons.find(b=>b.textContent==='LISTEN · LAST 2 SECONDS TO OUT');
|
|
const setOut=buttons.find(b=>b.textContent==='SET OUT AT PLAYHEAD');
|
|
const style=el('style');style.textContent=`.ahead-cut-editor{font:13px/1.45 Inter,Segoe UI,sans-serif!important;display:grid;grid-template-columns:minmax(0,1fr) 330px;grid-template-rows:44px minmax(0,1fr);gap:12px;height:78vh;min-height:540px!important;overflow:hidden!important;padding:12px!important;background:#09141d!important}.ahead-cut-editor h2{grid-column:1/-1;font-size:17px;letter-spacing:.08em;margin:0;padding:9px 12px;background:#112531;border:1px solid #294957;color:#a7eee0}.ahead-cut-editor main{min-width:0;overflow:auto;background:#0d1b24;border:1px solid #294552;padding:12px}.ahead-cut-editor aside{min-width:0;overflow-y:auto;overflow-x:hidden;background:#132733;padding:12px;display:flex;flex-direction:column;gap:9px;border:1px solid #355666}.ahead-cut-editor p{margin:0}.ahead-cut-editor button,.ahead-cut-editor select,.ahead-cut-editor input{font:inherit;color:#edf9f4;background:#203e4c;border:1px solid #527b87;border-radius:5px;padding:7px;box-sizing:border-box}.ahead-cut-editor button:hover{border-color:#8fe7d3;background:#295365}.ahead-cut-editor aside button{white-space:normal;width:100%}.ahead-cut-editor video{height:min(34vh,320px)!important;min-height:180px;border:1px solid #34515f}.ahead-cut-editor nav{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}.ahead-cut-editor aside>p:first-child{border-left:3px solid #82dfc8;padding:8px 10px;background:#0d1c25}.ahead-cut-editor aside input{width:100%;max-width:100%}.ahead-cut-editor aside div{min-width:0;max-width:100%}.ahead-cut-timeline{height:92px;position:relative;margin:18px 8px 34px;background:repeating-linear-gradient(90deg,#4a2830 0,#4a2830 calc(10% - 1px),#93606a calc(10% - 1px),#93606a 10%);touch-action:none;cursor:crosshair;border:1px solid #8d6068;box-shadow:inset 0 0 0 4px #09141d}.ahead-cut-kept{position:absolute;left:0;top:0;bottom:0;background:repeating-linear-gradient(90deg,#1d6959 0,#1d6959 calc(10% - 1px),#57aa95 calc(10% - 1px),#57aa95 10%);pointer-events:none;border-right:1px solid #b8ffe9}.ahead-cut-zone-label,.ahead-cut-remove-label{position:absolute;top:8px;left:10px;font:700 11px sans-serif;letter-spacing:.08em;color:#d9fff4;white-space:nowrap;pointer-events:none}.ahead-cut-remove-label{left:auto;right:10px;color:#ffd3d7}.ahead-cut-playhead{position:absolute;top:-5px;bottom:-5px;width:1px;background:white;box-shadow:0 0 5px #fff;pointer-events:none}.ahead-cut-playhead:before{content:'';position:absolute;left:-5px;top:-1px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:8px solid white}.ahead-cut-out{position:absolute;top:-9px;bottom:-9px;width:6px;background:#ffd56f;color:#ffd56f;cursor:ew-resize;padding-top:108px;font-weight:800;touch-action:none;filter:drop-shadow(0 0 3px #000)}.ahead-cut-filmstrip{display:flex;gap:7px;overflow-x:auto;scroll-snap-type:x proximity;padding:12px 4px 16px;border:1px solid #294b58;background:#081118;scrollbar-color:#cda75d #142631}.ahead-cut-frame{position:relative;flex:0 0 154px;display:flex!important;flex-direction:column;gap:4px;scroll-snap-align:center;padding:5px!important;border:2px solid #425c68!important;background:#0f202a!important}.ahead-cut-frame canvas{display:block;width:144px;background:#05090c}.ahead-cut-frame strong{color:#d8e5e7;font:800 11px Consolas,monospace}.ahead-cut-frame small{color:#78939d;font:9px Consolas,monospace}.ahead-cut-frame.selected{border-color:#ffd56f!important;box-shadow:0 0 0 2px #ffd56f44,0 0 12px #ffd56f33}.ahead-cut-frame.selected strong{color:#ffe6a5}.ahead-cut-frame.removed{border-color:#87535d!important;filter:saturate(.55);opacity:.58}.ahead-cut-frame.removed:after{content:'REMOVED';position:absolute;right:7px;top:7px;padding:2px 4px;background:#6b2734;color:#ffd8dc;font:800 8px sans-serif}.ahead-cut-editor .ahead-cut-policies{display:grid;grid-template-columns:1fr 1fr;gap:6px}.ahead-cut-editor main>p{color:#a8d4cf}.ahead-cut-editor main>div:last-child button{background:#122b35}@media(max-width:900px){.ahead-cut-editor{grid-template-columns:minmax(0,1fr);height:auto;overflow:auto!important}.ahead-cut-editor aside{overflow:visible}}`;
|
|
dialog.className='ahead-cut-editor';
|
|
const center=el('main'),side=el('aside'),transport=el('nav');transport.append(listen,playThrough,setOut);
|
|
const ruler=el('p','PROGRAM IN 00:00.000 · WHITE = PLAYHEAD · GOLD = OUT (last included frame) · GREEN = delivered · RED = removed');ruler.style.color='#a2d8cf';
|
|
center.append(select,video,transport,ruler,timeline,el('p','CUT FINDER · the final 96 numbered frames. DRAG left/right directly across the filmstrip to place OUT; use its scrollbar or mouse wheel to navigate. The translucent AI FREEZE band is a detector guide. Gold is the last included frame.'),strip);
|
|
const modes=el('div');modes.className='ahead-cut-policies';modes.append(automatic,keepAll);
|
|
guide.textContent='WORKFLOW · 1 Select the chunk. 2 Use PLAY THROUGH AI FREEZE. 3 Drag across the filmstrip or drag GOLD OUT. 4 Check the last included frame. 5 Press the green SAFE · SAVE OUT button. The frame at OUT stays in the movie.';
|
|
side.append(guide,analysis,useAnalysis,policy,el('p','POLICY · AUTO SAFE can remove only a detected terminal freeze up to 6 frames. MANUAL OUT overrides AUTO for this chunk. KEEP ALL retains every decoded frame.'),modes,el('strong','MANUAL OUT · last INCLUDED frame'),number,slider,presets,status,warning,save,clear);
|
|
mountFreezeControls(side,node);
|
|
const syncCuts=event=>{if(event.detail.node!==node)return;try{const value=JSON.parse(widget?.value||'{}');cuts=value.contract===info.contract?{...value.cuts}:{};}catch{cuts={};}dependency();};document.addEventListener('iamccs:ahead-controls',syncCuts);host.addEventListener('ahead-dispose',()=>document.removeEventListener('iamccs:ahead-controls',syncCuts),{once:true});
|
|
style.textContent+=`.ahead-cut-analysis{display:flex;flex-direction:column;gap:5px;padding:10px;border:1px solid #3d6671;border-left:4px solid #69c8d3;border-radius:7px;background:#10252e}.ahead-cut-analysis.detected{border-color:#a87942;border-left-color:#f0b45e;background:#302316}.ahead-cut-analysis b{font-size:10px;letter-spacing:.05em;color:#cceff0}.ahead-cut-analysis.detected b{color:#ffd28d}.ahead-cut-analysis span{color:#e7f1f1;font-size:9px}.ahead-cut-analysis small{color:#88a9ae;font-size:8px}.ahead-cut-safe{background:linear-gradient(180deg,#2b8e62,#176044)!important;border:2px solid #9ff1c7!important;color:#fff!important;font-weight:950!important;letter-spacing:.045em;box-shadow:0 0 0 2px #54d99c22,0 5px 14px #0008}.ahead-cut-frame.suspect{background:linear-gradient(rgba(155,34,48,.48),rgba(95,18,29,.62))!important;border-color:#c75a68!important}.ahead-cut-frame.suspect:before{content:'AI FREEZE';position:absolute;left:7px;top:7px;padding:2px 4px;background:rgba(161,31,48,.78);color:#ffe8eb;font:900 8px sans-serif;z-index:2}.ahead-cut-frame.selected.suspect{border-color:#ffd56f!important}`;
|
|
dialog.replaceChildren(style,title,center,side);
|
|
host.append(dialog);dependency();load();
|
|
}
|