studio: video lane — generate/card/ingest/judge over the shared library and flywheel

This commit is contained in:
studio-impl
2026-07-18 02:02:30 -07:00
committed by Hanzo AI
parent 193aee3aac
commit 3565974382
11 changed files with 1112 additions and 111 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gp
if not isinstance(n, dict):
continue
ct = n.get("class_type", "")
if ct == "SaveImage":
if ct in ("SaveImage", "SaveVideo", "SaveWEBM"):
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
elif "LoadImage" in ct:
im = n.get("inputs", {}).get("image")
+102 -18
View File
@@ -50,6 +50,15 @@ PROMPT = (
"poster border, or glyph/pixel-garbage regions. Otherwise PASS."
)
# The motion judge — the SAME strict binary contract as PROMPT, but the failure classes
# are the observed corruption modes of the video pipeline (it is shown start/mid/end frames).
MOTION = (
"You are a render QA gate for generated video, shown frames sampled across its "
"duration. Answer exactly PASS or FAIL. FAIL if: frame-to-frame flicker, "
"identity/clothing morphing between frames, frozen/static motion where movement is "
"expected, temporal ghosting or trails, or glyph/pixel-garbage regions. Otherwise PASS."
)
_OFF = ("off", "0", "false", "no")
_FAIL_RE = re.compile(r"\bfail\b", re.I)
_PASS_RE = re.compile(r"\bpass\b", re.I)
@@ -88,12 +97,11 @@ def parse_verdict(reply: str) -> str:
return "inconclusive"
def _downscale(path: Path) -> str | None:
"""The stored render as a ~512px JPEG, base64 — small enough for a fast vision
call. Returns None if the image can't be read (→ the gate fails open)."""
def _encode(im) -> str | None:
"""A PIL image as a ~512px JPEG, base64 — the ONE frame-encode contract, small
enough for a fast vision call. Returns None on any failure."""
try:
from PIL import Image
im = Image.open(path).convert("RGB")
im = im.convert("RGB")
im.thumbnail((512, 512))
buf = io.BytesIO()
im.save(buf, "JPEG", quality=80)
@@ -102,6 +110,56 @@ def _downscale(path: Path) -> str | None:
return None
def _downscale(path: Path) -> str | None:
"""The stored image as a ~512px JPEG, base64. None if it can't be read (→ fail open)."""
try:
from PIL import Image
return _encode(Image.open(path))
except Exception:
return None
def _video_frame(path: str, t: float) -> str | None:
"""One ~512px JPEG (base64) decoded at ``t`` seconds — seek then take the next frame.
A fresh container per frame keeps the decode robust (no abandoned-generator state)."""
try:
import av
with av.open(path) as c:
st = c.streams.video[0]
if t > 0 and st.time_base:
c.seek(int(t / st.time_base), stream=st)
for frame in c.decode(st):
return _encode(frame.to_image())
except Exception:
return None
return None
def _frames(path: Path) -> list[str] | None:
"""Up to three ~512px JPEG frames (base64) to judge, or None when unreadable. A video
(.mp4/.webm) is sampled at start / middle / end so the motion judge sees temporal
coherence; any other file is the single downscaled image. None → the caller decides
(an image fails open; a video is marked ungated, never a silent pass)."""
if path.suffix.lower() in (".mp4", ".webm"):
try:
import av
with av.open(str(path)) as probe:
st = probe.streams.video[0]
if st.duration and st.time_base:
dur = float(st.duration * st.time_base)
elif probe.duration:
dur = float(probe.duration) / 1_000_000
else:
dur = 0.0
targets = [0.0, dur / 2, dur] if dur > 0 else [0.0]
out = [b for b in (_video_frame(str(path), t) for t in targets) if b]
return out or None
except Exception:
return None
b = _downscale(path)
return [b] if b else None
def _reply_text(data: dict) -> str:
"""The assistant text from an OpenAI-style completion, tolerating a content
string, the content-parts array some gateways return, and REASONING models
@@ -120,15 +178,16 @@ def _reply_text(data: dict) -> str:
return str(content)
async def judge(image_b64: str, bearer: str) -> str | None:
"""Ask the vision judge to score one image. Returns its reply text, or None on
ANY error (non-200, bad JSON, timeout, unreachable) — the caller fails open."""
async def judge(images: list[str], bearer: str, prompt: str) -> str | None:
"""Ask the vision judge to score N frames against ``prompt`` (PROMPT for a still image,
MOTION for a video's sampled frames) — one text part + N image parts. Returns its reply
text, or None on ANY error (non-200, bad JSON, timeout, unreachable) — caller fails open."""
content = [{"type": "text", "text": prompt}]
for b64 in images:
content.append({"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64}})
payload = {
"model": _model(),
"messages": [{"role": "user", "content": [
{"type": "text", "text": PROMPT},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + image_b64}},
]}],
"messages": [{"role": "user", "content": content}],
"temperature": 0,
"max_tokens": 768, # reasoning models think before the verdict — 8 starved them into fail-open
}
@@ -166,18 +225,43 @@ def _flag(org: str, root: Path, rel: str, reply: str) -> bool:
return False
def _skip(org: str, root: Path, rel: str) -> None:
"""A video whose frames could not be sampled is marked visibly UNGATED — NEVER a
silent pass: set the asset row's ``gate`` field to ``skipped`` (the card renders an
'ungated' badge from it) and log an ``ungated`` work-log event. Same single-writer
library pattern as ``_flag``; leaves status untouched."""
from middleware import studio_home as sh # lazy: avoid an import cycle
lib = sh._load_library(root)
hit = False
for a in lib.get("assets", []):
if a.get("path") == rel:
a["gate"] = "skipped"
a["updatedAt"] = int(time.time())
hit = True
if hit:
sh._save_library(root, lib)
worklog.add_event(org, rel, "ungated", "gate could not sample frames")
log.info("gate: UNGATED %s/%s", org, rel)
async def run(org: str, root: Path, rel: str, bearer: str) -> None:
"""Judge one just-stored render and flag it on a clear FAIL. Never raises — a
background task that fails open on everything else."""
"""Judge one just-stored render — a still image against PROMPT, a video against MOTION
over its sampled frames — and flag it on a clear FAIL. A video whose frames can't be
sampled is marked UNGATED (never a silent pass); an unreadable image fails open. Never
raises — a background task that fails open on everything else."""
try:
p = Path(root) / rel
if not p.is_file():
return
image_b64 = _downscale(p)
if image_b64 is None:
log.info("gate: unreadable %s/%s — pass (fail-open)", org, rel)
video = p.suffix.lower() in (".mp4", ".webm")
frames = _frames(p)
if frames is None:
if video:
_skip(org, root, rel) # honest: an ungated video is badged, never passed silently
else:
log.info("gate: unreadable %s/%s — pass (fail-open)", org, rel)
return
reply = await judge(image_b64, bearer)
reply = await judge(frames, bearer, MOTION if video else PROMPT)
verdict = parse_verdict(reply or "")
if verdict == "fail":
_flag(org, root, rel, reply or "")
+53 -20
View File
@@ -376,6 +376,7 @@ body.immersive #stage{display:block}
<div id="zoom" onclick="zoomBg(event)">
<button class="viewbtn" id="zprev" onclick="event.stopPropagation();svStep(-1)" style="position:absolute;left:18px;top:50%;transform:translateY(-50%);display:none;font-size:1.4rem" aria-label="Previous image"></button>
<img id="zoomimg" onclick="event.stopPropagation()">
<video id="zoomvid" controls playsinline style="display:none;max-width:92vw;max-height:88vh" onclick="event.stopPropagation()"></video>
<button class="viewbtn" id="znext" onclick="event.stopPropagation();svStep(1)" style="position:absolute;right:18px;top:50%;transform:translateY(-50%);display:none;font-size:1.4rem" aria-label="Next image"></button>
</div>
<dialog id="fixdlg">
@@ -485,6 +486,10 @@ const $=id=>document.getElementById(id);
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000)};
const imgURL=a=>`/v1/library/file?path=${encodeURIComponent(a.path)}`;
const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
// A video asset: the SAME URL scheme (poster = thumbURL ?thumb=1, playback = imgURL).
const isVideo=p=>/\.(mp4|webm)$/i.test(p||'');
// The output-node in a graph, any medium — used to title runs by their real prefix.
const saver=g=>Object.values(g||{}).find(n=>n&&['SaveImage','SaveVideo','SaveWEBM'].includes(n.class_type));
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
// Leaving for another tab closes an open Stack detail (it lives over Assets).
@@ -526,7 +531,8 @@ function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeUR
// public write token. Fails silent — telemetry must never break the product.
const DID=localStorage.hz_did||(localStorage.hz_did=(crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)));
function track(ev,props){try{navigator.sendBeacon('https://insights.hanzo.ai/v1/e',JSON.stringify({api_key:'hi_nq2pI13AO3Yj5mN15uWAtK1ZvubclMpq08Ca9kn3eUO',event:ev,properties:{distinct_id:DID,product:'studio',org:(window.DATA&&DATA.org)||'',...(props||{})}}))}catch(_){}}
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t); track('template_use',{template:t});}
let KIND=''; // the picked template lane ('' = default fix/compose composer; 'video' = Wan)
function pickTemplate(k,t){KIND=k;$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t); track('template_use',{template:t});}
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
// The ONE create path: the top composer's ↑ generates from its prompt + the photos
// attached to it (via + / drag / paste / a gallery card's +). 1 photo → edit (fix),
@@ -535,6 +541,22 @@ async function create(){
const t=$('pin').value.trim();
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
const n=libs.length+ups.length;
if(KIND==='video'){
// The video lane: text-to-video, or ONE reference image (library or upload). Same
// queued-toast / GPU advisory path as fix/compose — no second box, no flags.
if(t.length<3){ toast('Describe the video you want'); return; }
if(n>1){ toast('Video takes at most one reference'); return; }
const vbtn=document.querySelector('.psend');
await enqueue(vbtn,'…',async()=>{
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
const body={prompt:t}; if(libs.length)body.path=libs[0]; else if(ups.length)body.upload=ups[0];
const r=await fetch('/v1/library/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(r.ok){ toast('Video queued → see Queue & History'); track('video',{from:'composer'}); $('pin').value=''; clearTray(); }
else if(!(await healStale(j))) toast('Failed: '+(errMsg(j)||('error '+r.status)));
});
return;
}
if(!n){ toast(t.length<3?'Describe what to create, then attach a photo with +':'Attach a photo with + to edit or compose it'); return; }
if(t.length<3){ toast('Describe what to make from your photo'+(n>1?'s':'')); return; }
// Same latch as the dialog queue buttons: the ↑ / Enter can fire twice before the
@@ -618,6 +640,8 @@ function render(){
const cards=rows.map(a=>{
const i=DATA.assets.indexOf(a);
const img=/\.(png|jpe?g|webp)$/i.test(a.path||'');
const vid=isVideo(a.path);
const media=img||vid; // shares votes / archive / Re-run / Versions
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
const picked=SEL.has(a.path);
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
@@ -629,24 +653,25 @@ function render(){
<div class="droplbl">Create Stack</div>
<div class="pick" role="checkbox" aria-checked="${picked}" aria-label="Select image" onclick="pickToggle(event,'${pe}')">${picked?'✓':''}</div>
${img&&!SELMODE?`<button class="addref ${inTray?'on':''}" title="Add to next generation" onclick="event.stopPropagation();addRef('${pe}')">${inTray?'✓':'+'}</button>`:''}
${img&&!SELMODE&&!review?`<button class="delx" title="Move to Archive (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
${media&&!SELMODE&&!review?`<button class="delx" title="Move to Archive (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
${img?`<div class="im" style="background-image:url('${thumbURL(a)}')" ${SELMODE?'':`onclick="zoom('${imgURL(a)}')"`}></div>`
:`<div class="doc">${esc((a.path||'').split('/').pop())}</div>`}
:vid?`<div class="im" style="position:relative"><video src="${imgURL(a)}" poster="${thumbURL(a)}" preload="none" muted playsinline style="width:100%;height:100%;object-fit:cover;display:block" ${SELMODE?'':`onclick="zoom('${imgURL(a)}',true)"`}></video><span style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.7rem;color:#fff;text-shadow:0 2px 10px #000c;pointer-events:none">▶</span></div>`
:`<div class="doc">${esc((a.path||'').split('/').pop())}</div>`}
<div class="meta">
<span class="t">${esc((a.path||'').split('/').pop())}</span>
<span class="sub">${a.design||a.kind||''}${prov}</span>
<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':a.status}</span></div>
${img&&!SELMODE?`<div class="fbrow" onclick="event.stopPropagation()">
<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':a.status}</span>${a.gate==='skipped'?`<span class="st" style="background:#7a6329;color:#ffd98a" title="Auto-judge could not sample this video — review manually">ungated</span>`:''}</div>
${media&&!SELMODE?`<div class="fbrow" onclick="event.stopPropagation()">
<button class="vote up ${(RATINGS[a.path]||{}).vote===1?'on':''}" title="Good result — 👍 teaches the model" onclick="castVote('${pe}',1)">👍</button>
<button class="vote down ${(RATINGS[a.path]||{}).vote===-1?'on':''}" title="Wrong result — 👎 teaches the model" onclick="castVote('${pe}',-1)">👎</button>
<button class="vote note ${(RATINGS[a.path]||{}).notes?'has':''}" title="Critique this image — the AI sees it, replies, and learns" onclick="openFeedback('${pe}')">💬</button>
<button class="vote note ${(RATINGS[a.path]||{}).notes?'has':''}" title="Critique this — the AI sees it, replies, and learns" onclick="openFeedback('${pe}')">💬</button>
</div>`:''}
${SELMODE?'':`<div class="row tools">
${review?`<button class="mini" onclick="setSt(${i},'draft')">${showDel?'Recover':'Restore'}</button>${showDel?`<button class="mini hot" onclick="purgeOne('${pe}')" title="Permanently delete — cannot be undone">🗑 Delete forever</button>`:''}`:`
${a.status!=='approved'?`<button class="mini" onclick="setSt(${i},'approved')">Approve</button>`:''}
${a.status!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
<button class="mini" onclick="addToStackPrompt('${pe}',event)" title="Add this image to a Stack">🗂 Stack</button>
${img?`<button class="mini" onclick="rerun(${i},this)">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this image's versions (what it came from + later fixes)">Versions</button><button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
${media?`<button class="mini" onclick="rerun(${i},this)">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this item's versions (what it came from + later fixes)">Versions</button>`:''}${img?`<button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
<button class="mini" onclick="setSt(${i},'deleted')" title="Move to Archive (recoverable)">🗑</button>`}
</div>`}
</div></div>`;}).join('');
@@ -775,10 +800,9 @@ function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class=
// ── YouTube-style Queue & History ───────────────────────────────────────────
let RATINGS={};let FAVS={};
const vURL=im=>`/view?filename=${encodeURIComponent(im.filename)}&subfolder=${encodeURIComponent(im.subfolder||'')}&type=${im.type||'output'}`;
const promptTitle=v=>{ // pull the SaveImage prefix / positive text from the run's graph
const g=(v.prompt&&v.prompt[2])||{};
for(const n of Object.values(g)){if(n&&n.class_type==='SaveImage')return (n.inputs.filename_prefix||'render').split('/').pop();}
return 'render';
const promptTitle=v=>{ // pull the saver prefix from the run's graph — image OR video
const s=saver((v.prompt&&v.prompt[2])||{});
return s?(s.inputs.filename_prefix||'render').split('/').pop():'render';
};
// ── Work log: the org's dispatched renders with real status + full context ────
let WL=[], CTX=null;
@@ -908,7 +932,7 @@ async function runs(){
const qitem=(it,running,pos)=>{
const id=it[1], g=it[2]||{};
const nodes=Object.values(g);
const title=(nodes.find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||id+'').split('/').pop();
const title=(saver(g)?.inputs?.filename_prefix||id+'').split('/').pop();
// reference image(s) attached to this job (fix/compose sources live in input dir)
const refs=nodes.filter(n=>n.class_type==='LoadImage').map(n=>n.inputs?.image).filter(Boolean);
const thumb=refs.length?`/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(refs[0])}`:'';
@@ -939,7 +963,10 @@ async function runs(){
const rt=RATINGS[r.id]||{}; const st=rt.stars||0;
const stars=[1,2,3,4,5].map(n=>`<span class="${n<=st?'on':''}" onclick="rate('${r.id}',${n})">★</span>`).join('');
return `<div class="card">
${r.im?`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
${r.im?(isVideo(r.im.filename)
?`<div class="im" style="display:flex;align-items:center;justify-content:center;font-size:1.7rem;color:#fff;background:#151519" onclick="zoom('${vURL(r.im)}',true)">▶</div>`
:`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`)
:`<div class="doc">no output</div>`}
<div class="meta">
<span class="t">${r.title}</span>
<div class="row" style="justify-content:space-between">
@@ -1174,9 +1201,13 @@ async function cancelJob(id){
document.addEventListener('click',e=>{const m=$('acctmenu');if(m&&m.classList.contains('on')&&!m.contains(e.target))m.classList.remove('on');});}
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
})();
function zoom(u){const z=$('zoom');z.classList.remove('stackctx');$('zprev').style.display='none';$('znext').style.display='none';$('zoomimg').src=u;z.style.display='flex';}
function setZoomMedia(u,video){const im=$('zoomimg'),vd=$('zoomvid');
if(video){im.style.display='none';im.removeAttribute('src');if(vd){vd.src=u;vd.style.display='';vd.play&&vd.play().catch(()=>{});}}
else{if(vd){vd.pause&&vd.pause();vd.removeAttribute('src');vd.style.display='none';}im.src=u;im.style.display='';}}
function zoom(u,video){const z=$('zoom');z.classList.remove('stackctx');$('zprev').style.display='none';$('znext').style.display='none';setZoomMedia(u,video);z.style.display='flex';}
function zoomBg(){closeZoom();}
function closeZoom(){const z=$('zoom');z.style.display='none';z.classList.remove('stackctx');$('zprev').style.display='none';$('znext').style.display='none';SVI=-1;}
function closeZoom(){const z=$('zoom');z.style.display='none';z.classList.remove('stackctx');$('zprev').style.display='none';$('znext').style.display='none';
const vd=$('zoomvid');if(vd){vd.pause&&vd.pause();vd.removeAttribute('src');vd.style.display='none';}$('zoomimg').style.display='';SVI=-1;}
const ta=$('pin'); ta.addEventListener('input',()=>{ta.style.height='auto';ta.style.height=Math.min(ta.scrollHeight,180)+'px';});
async function loadSession(){
let s={}; try{ s=await (await fetch('/v1/session')).json(); }catch(e){ return; }
@@ -1204,7 +1235,7 @@ async function updateLivebar(){
const run=q.queue_running||[], pend=q.queue_pending||[];
const gpuJobs=rq.jobs||[];
const lb=$('livebar'), st=$('lbstatus');
const name=it=>{try{return (Object.values(it[2]).find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
const name=it=>{try{return (saver(it[2])?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
if(gpuJobs.length){
lb.classList.remove('idle');
const first=gpuJobs[0], more=gpuJobs.length-1;
@@ -1348,19 +1379,21 @@ function svRender(){
$('svgrid').innerHTML=s.assets.map((m,idx)=>svCard(m.assetId,idx)).join('');
}
function svCard(path,idx){
const a=assetByPath(path),img=/\.(png|jpe?g|webp)$/i.test(path),picked=SEL.has(path),pe=qq(path),iscover=CURSTACK.coverAssetId===path;
const a=assetByPath(path),img=/\.(png|jpe?g|webp)$/i.test(path),vid=isVideo(path),picked=SEL.has(path),pe=qq(path),iscover=CURSTACK.coverAssetId===path;
const cardClick=SELMODE?`onclick="pick(event,'${pe}')"`:'';
const dnd=(!SELMODE)?`draggable="true" ondragstart="svDragStart(event,${idx})" ondragend="svDragEnd(event)" ondragover="svDragOver(event,${idx})" ondragleave="dragLeaveCard(event)" ondrop="svDrop(event,${idx})"`:'';
const thumb='/v1/library/file?thumb=1&path='+encodeURIComponent(path);
return `<div class="card ${SELMODE?'sel-mode':''} ${picked?'picked':''} ${iscover?'iscover':''}" data-path="${pe}" data-idx="${idx}" ${dnd} ${cardClick} aria-label="${esc(path.split('/').pop())}${iscover?' (cover)':''}">
<div class="droplbl">Reorder</div><div class="pick" role="checkbox" aria-checked="${picked}" aria-label="Select image" onclick="pickToggle(event,'${pe}')">${picked?'✓':''}</div>${iscover?'<span class="covertag">Cover</span>':''}
${img?`<div class="im" style="background-image:url('${thumb}')" ${SELMODE?'':`onclick="svOpen(${idx})"`}></div>`:`<div class="doc">${esc(path.split('/').pop())}</div>`}
${img?`<div class="im" style="background-image:url('${thumb}')" ${SELMODE?'':`onclick="svOpen(${idx})"`}></div>`
:vid?`<div class="im" style="position:relative"><video src="${imgURL({path})}" poster="${thumb}" preload="none" muted playsinline style="width:100%;height:100%;object-fit:cover;display:block" ${SELMODE?'':`onclick="svOpen(${idx})"`}></video><span style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.6rem;color:#fff;text-shadow:0 2px 10px #000c;pointer-events:none">▶</span></div>`
:`<div class="doc">${esc(path.split('/').pop())}</div>`}
<div class="meta"><span class="t">${esc(path.split('/').pop())}</span>
${a?`<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':a.status}</span></div>`:'<span class="sub">unavailable — asset deleted</span>'}
${SELMODE?'':`<div class="row tools">
<button class="mini" onclick="setCover('${pe}')">Set cover</button>
<button class="mini" onclick="svRemove(['${pe}'])" title="Return to gallery">Remove</button>
<button class="mini hot" onclick="svFix('${pe}')" title="Create variations in this Stack">Fix</button>
${!vid?`<button class="mini hot" onclick="svFix('${pe}')" title="Create variations in this Stack">Fix</button>`:''}
<button class="mini" onclick="svImgMenu('${pe}',${idx},event)" aria-haspopup="menu">⋯</button>
</div>`}
</div></div>`;
@@ -1409,7 +1442,7 @@ async function svDrop(e,idx){e.preventDefault();e.currentTarget.classList.remove
}
// ── In-Stack image viewer with previous / next (preserves Stack context) ───────
function svOpen(i){if(!CURSTACK||!CURSTACK.assets[i])return;SVI=i;const path=CURSTACK.assets[i].assetId;
$('zoomimg').src=imgURL({path});const z=$('zoom');z.classList.add('stackctx');
setZoomMedia(imgURL({path}),isVideo(path));const z=$('zoom');z.classList.add('stackctx');
$('zprev').style.display=$('znext').style.display=CURSTACK.assets.length>1?'block':'none';z.style.display='flex';
announce('Image '+(i+1)+' of '+CURSTACK.assets.length);}
function svStep(d){if(SVI<0||!CURSTACK)return;const n=CURSTACK.assets.length;SVI=(SVI+d+n)%n;svOpen(SVI);}
+272 -61
View File
@@ -71,6 +71,20 @@ _QWEN_UNET = "qwen-image-edit-2511.safetensors"
_QWEN_CLIP = "qwen2.5-vl-7b.safetensors"
_QWEN_VAE = "qwen-image-vae.safetensors"
# The Wan 2.2 TI2V-5B model set, by the EXACT filenames present on the render worker
# (spark) — the same locality rule as the Qwen set: a graph naming a missing checkpoint
# fails ComfyUI validation instantly. _WAN_NEG is the proven Chinese negative from the
# spark video proof (== zen-video DEFAULT_NEGATIVE): the overbright / static / blurry /
# caption / worst-quality / deformed-limb corruption classes for Wan.
_WAN_UNET = "wan2.2_ti2v_5B_fp16.safetensors"
_WAN_CLIP = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
_WAN_VAE = "wan2.2_vae.safetensors"
_WAN_NEG = "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
_VIDEO_EXT = (".mp4", ".webm")
_SAVE_EXT = (".png",) + _VIDEO_EXT # the extensions the containers' savers write
_SAVERS = ("SaveImage", "SaveVideo", "SaveWEBM") # the output-node class types — ONE list
def _home_default() -> bool:
return os.environ.get("STUDIO_HOME_DEFAULT", "").lower() in ("1", "true", "yes")
@@ -122,8 +136,39 @@ def _view_params(abs_path: Path) -> dict:
return {"filename": rel.name, "subfolder": str(rel.parent) if str(rel.parent) != "." else "", "type": "output"}
def _embedded(path: Path) -> dict:
"""The metadata a saver embedded in an output, for BOTH media, as a {key: raw-json}
mapping: a PNG carries it in PIL image ``info`` (SaveImage), an mp4/webm carries it in
the CONTAINER metadata (SaveVideo/SaveWEBM write the SAME JSON strings PIL returns).
Returns {} when absent/unreadable. The ONE reader provenance, Open-in-Editor and
re-run all parse the ``prompt`` (API graph) out of it (Open-in-Editor also reads the UI
``workflow``), so video never silently loses its Versions / Re-run / lessons surface."""
suf = path.suffix.lower()
try:
if suf == ".png":
from PIL import Image
return dict(Image.open(path).info)
if suf in _VIDEO_EXT:
import av
with av.open(str(path)) as c:
return dict(c.metadata)
except Exception: # non-Comfy media, truncated file — metadata is optional
pass
return {}
def _poster(p: Path):
"""The first video frame as a PIL image — the card poster. PyAV in-process (the studio
image ships no ffmpeg binary, and SaveVideo already encodes with av, so av is present).
Raises on any decode failure; the caller turns that into a 404, never the whole file."""
import av
with av.open(str(p)) as c:
frame = next(c.decode(video=0))
return frame.to_image()
def _provenance(abs_path: Path) -> dict:
"""What made this image, from the graph ComfyUI embeds in the PNG."""
"""What made this output, from the graph the saver embeds in it — PNG or video."""
try:
mtime = abs_path.stat().st_mtime
except OSError:
@@ -133,23 +178,26 @@ def _provenance(abs_path: Path) -> dict:
if hit and hit[0] == mtime:
return hit[1]
prov: dict = {}
if abs_path.suffix.lower() == ".png":
try:
from PIL import Image
info = Image.open(abs_path).info
graph = json.loads(info.get("prompt", "null"))
if isinstance(graph, dict):
for node in graph.values():
ct = node.get("class_type", "")
ins = node.get("inputs", {})
if ct == "SaveImage":
prov["workflow"] = ins.get("filename_prefix", "")
elif "KSampler" in ct:
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
seed=ins.get("seed", ins.get("noise_seed")))
prov["nodes"] = len(graph)
except Exception: # non-Comfy PNG, truncated file — provenance is optional
prov = {}
try:
graph = json.loads(_embedded(abs_path).get("prompt", "null"))
if isinstance(graph, dict) and graph:
for node in graph.values():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
ins = node.get("inputs", {})
if ct in _SAVERS:
prov["workflow"] = ins.get("filename_prefix", "")
elif "KSampler" in ct:
prov.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
seed=ins.get("seed", ins.get("noise_seed")))
elif ct == "Wan22ImageToVideoLatent":
prov["length"] = ins.get("length")
elif ct == "CreateVideo":
prov["fps"] = ins.get("fps")
prov["nodes"] = len(graph)
except Exception: # non-Comfy output, truncated file — provenance is optional
prov = {}
_PROV_CACHE[key] = (mtime, prov)
return prov
@@ -307,14 +355,14 @@ def _reseed(graph: dict) -> dict:
def _retag(graph: dict, prefix: str) -> dict:
"""Point every SaveImage in a re-run / template graph at a UNIQUE output prefix.
Without this a re-run replays the source graph's own SaveImage prefix, so its
file lands in the SOURCE's namespace and N re-runs share one prefix — the
library then attributes the newest sibling to every row (a job shows a photo
it did not make). fix/compose/amend already tag per dispatch; this gives
re-run and template-render the same job-identity guarantee."""
"""Point every saver (SaveImage / SaveVideo / SaveWEBM) in a re-run / template graph
at a UNIQUE output prefix. Without this a re-run replays the source graph's own saver
prefix, so its file lands in the SOURCE's namespace and N re-runs share one prefix —
the library then attributes the newest sibling to every row (a job shows a photo it
did not make). fix/compose/amend already tag per dispatch; this gives re-run and
template-render the same job-identity guarantee, for image AND video outputs."""
for node in graph.values():
if isinstance(node, dict) and node.get("class_type") == "SaveImage":
if isinstance(node, dict) and node.get("class_type") in _SAVERS:
node.setdefault("inputs", {})["filename_prefix"] = prefix
return graph
@@ -363,12 +411,25 @@ def _lessons(org: str, root: Path | None, rels: list[str]) -> list[str]:
def _job_tag() -> str:
"""Six hex chars appended to every SaveImage prefix so each dispatch owns a
"""Six hex chars appended to every saver prefix so each dispatch owns a
UNIQUE output namespace a re-fix of the same source can never prefix-match an
earlier run's (possibly rejected) file as its own result."""
return uuid.uuid4().hex[:6]
_TAG_RE = re.compile(r"_[0-9a-f]{6}$")
def _stamp(prefix: str) -> str:
"""Give an output prefix a FRESH job identity: strip a trailing job tag from its LAST
path segment if present (so a re-run of a re-run, or a re-render of a saved template,
does not accumulate tags), then append a new one. Every dispatch of a STORED graph
template render, re-run thus owns a unique output namespace (the house job rule)."""
head, sep, tail = prefix.rpartition("/")
tail = _TAG_RE.sub("", tail) + f"_{_job_tag()}"
return f"{head}/{tail}" if sep else tail
# View/pose-change intent: these instructions need the empty-latent reference
# family — the anchored fix latent (VAEEncode of the base) preserves composition
# by design, which is exactly wrong when the user asks for a different view.
@@ -450,6 +511,53 @@ def _fix_graph(ref_name: str, instruction: str, prefix: str, *, size: tuple[int,
}
def _video_graph(prompt: str, prefix: str, *, ref_name: str | None = None,
width: int = 640, height: int = 640, length: int = 73,
seed: int | None = None, lessons: list[str] | None = None) -> dict:
"""The proven Wan 2.2 TI2V-5B text/image-to-video graph, lifted node-for-node from the
spark video proof. UNETLoader ModelSamplingSD3(shift 8) KSampler(uni_pc / simple, 20
steps, cfg 5.0, denoise 1.0); CLIPLoader(type ``wan``) + VAELoader feed a
Wan22ImageToVideoLatent whose width/height clamp to [256,1280] and snap to the 32-px grid
the sampler needs, and whose length clamps to [9,121] and snaps to 4n+1. A reference
image, when given, wires LoadImage start_image (image-to-video); with NONE the node
AND its start_image key are omitted, for pure text-to-video (proof caveat). VAEDecode
CreateVideo(24 fps) SaveVideo(mp4/h264). Pure dict builder, same shape as _fix_graph;
cross-checked against zen-video/zen_wan_workflow.py build_prompt (not imported)."""
w = max(256, min(1280, int(width)))
w -= w % 32 # snap to the 32-px grid (proof caveat)
h = max(256, min(1280, int(height)))
h -= h % 32
n = max(9, min(121, int(length)))
n = ((n - 1) // 4) * 4 + 1 # snap to 4n+1
pos = prompt.strip()
neg = _WAN_NEG
if lessons:
pos += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
neg = _WAN_NEG + ", " + ", ".join(lessons)
latent = {"vae": ["3", 0], "width": w, "height": h, "length": n, "batch_size": 1}
g = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": _WAN_UNET, "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": _WAN_CLIP, "type": "wan"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": _WAN_VAE}},
"4": {"class_type": "ModelSamplingSD3", "inputs": {"model": ["1", 0], "shift": 8}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": pos}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": neg}},
"8": {"class_type": "Wan22ImageToVideoLatent", "inputs": latent},
"9": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "positive": ["5", 0],
"negative": ["6", 0], "latent_image": ["8", 0],
"seed": seed or random.randrange(2**31), "steps": 20, "cfg": 5.0,
"sampler_name": "uni_pc", "scheduler": "simple", "denoise": 1.0}},
"10": {"class_type": "VAEDecode", "inputs": {"samples": ["9", 0], "vae": ["3", 0]}},
"11": {"class_type": "CreateVideo", "inputs": {"images": ["10", 0], "fps": 24}},
"12": {"class_type": "SaveVideo", "inputs": {"video": ["11", 0], "filename_prefix": prefix,
"format": "mp4", "codec": "h264"}},
}
if ref_name:
g["7"] = {"class_type": "LoadImage", "inputs": {"image": ref_name}}
g["8"]["inputs"]["start_image"] = ["7", 0] # TI2V; omitted entirely for pure t2v
return g
def _compose_graph(ref_names: list[str], instruction: str, prefix: str,
lessons: list[str] | None = None) -> dict:
"""Multi-reference compose: several chosen assets become the references and the
@@ -559,6 +667,10 @@ def _graph_params(graph: dict) -> dict:
seed=ins.get("seed", ins.get("noise_seed")))
elif ct == "EmptySD3LatentImage":
p.update(width=ins.get("width"), height=ins.get("height"))
elif ct == "Wan22ImageToVideoLatent":
p.update(width=ins.get("width"), height=ins.get("height"), length=ins.get("length"))
elif ct == "CreateVideo":
p.update(fps=ins.get("fps"))
return {k: v for k, v in p.items() if v is not None}
@@ -663,11 +775,11 @@ def _landed_output(root: Path | None, prefix: str) -> str | None:
try:
parent = root / os.path.dirname(prefix)
stem = os.path.basename(prefix)
# Match SaveImage's exact "<prefix>_<counter>_.png" — NOT a loose "<prefix>*"
# which also matches a CHILD prefix ("a" would swallow "a_00001__00001_"),
# collapsing distinct lineage steps onto one output.
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.png$")
hits = [p for p in parent.glob(stem + "_*_.png") if pat.match(p.name)]
# Match a saver's exact "<prefix>_<counter>_.<ext>" (png / mp4 / webm) — NOT a
# loose "<prefix>*" which also matches a CHILD prefix ("a" would swallow
# "a_00001__00001_"), collapsing distinct lineage steps onto one output.
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.(?:png|mp4|webm)$")
hits = [p for p in parent.glob(stem + "_*_.*") if pat.match(p.name)]
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if hits:
return str(hits[0].relative_to(root))
@@ -816,6 +928,61 @@ async def dispatch_fix(request: web.Request, server, org: str, rel: str,
return out
async def dispatch_video(request: web.Request, server, org: str, prompt: str,
rel: str | None = None, upload: str | None = None,
width: int | None = None, height: int | None = None,
length: int | None = None, seed: int | None = None,
stack: str | None = None) -> dict:
"""Generate a video from a text prompt and AT MOST ONE reference image — the proven
Wan 2.2 TI2V-5B graph. The reference is EITHER a library asset (``rel``, staged into the
org input dir like fix) OR an image just uploaded via ``/upload/image`` (``upload``);
with neither it is pure text-to-video. The ONE video path: the /v1/library/video route
(and, later, the chat `create` capability) both call this.
No lane-forcing here _dispatch's self-POST already routes to gpu-jobs when a BYO GPU is
online and surfaces the real DispatchError ("unet_name … not in []" / "no GPU worker")
when not, so the model-locality reality (wan2.2 lives on spark) rides the existing
advisory rather than a second gate."""
prompt = (prompt or "").strip()
if not 3 <= len(prompt) <= 500:
raise DispatchError("prompt required (3-500 chars)")
if upload and rel:
raise DispatchError("use at most one reference (a library image OR an upload)")
root = _library_root(org)
in_dir = Path(folder_paths.get_org_input_directory(org))
in_dir.mkdir(parents=True, exist_ok=True)
ref_name = None
staged = False
if upload:
names = _resolve_uploads(org, [upload])
if not names:
raise DispatchError(f"unknown upload: {upload}")
ref_name = names[0]
elif rel:
# Iteration hygiene, same as fix: a corrupt base falls back to its clean ancestor.
rel, _sub = _clean_base(org, root, rel)
p = _safe_asset(root, rel) if root else None
if p is None or p.suffix.lower() != ".png":
raise DispatchError(f"unknown asset: {rel}")
ref_name = f"video_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / ref_name).write_bytes(p.read_bytes())
staged = True
base = Path(ref_name).stem if ref_name else " ".join(prompt.split()[:8])
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", base)[:48] + f"_{_job_tag()}"
lessons = _lessons(org, root, [rel] if rel else [])
pid = await _dispatch(
request, server, org,
_video_graph(prompt, f"video/{stem}", ref_name=ref_name,
width=width or 640, height=height or 640, length=length or 73,
seed=seed, lessons=lessons),
lessons=lessons, kind="video", prompt=prompt,
refs=[ref_name] if staged else [], uploads=[upload] if upload else [],
parents=[rel] if rel else [], output_prefix=f"video/{stem}")
if stack:
stacks_store.route_prefix(org, f"video/{stem}", stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": f"video/{stem}"}
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
instruction: str, uploads: list | None = None,
stack: str | None = None) -> dict:
@@ -1062,11 +1229,12 @@ async def _wallet(request: web.Request, org: str) -> dict:
return out
_MAX_UPLOAD = 100 * 1024 * 1024 # 100 MB — 4k masters run 30-60MB; the cap must hold the largest real render
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp")
_MAX_UPLOAD = 256 * 1024 * 1024 # 256 MB — ONE ceiling, equal to the worker mirror cap (gpu.go outputMax); holds a 4k master OR a short render's video
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm")
# Explicit types for served assets: the runtime's mimetypes table lacks webp, and
# octet-stream + nosniff breaks <img> rendering of attached webp references.
_CTYPE = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
# octet-stream + nosniff breaks <img>/<video> rendering of attached references.
_CTYPE = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
".mp4": "video/mp4", ".webm": "video/webm"}
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
@@ -1282,13 +1450,9 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
_finalize_landed(org, root, rows, int(time.time()))
landed = _row_output(root, row.get("output_prefix")) if row.get("status") == "done" else None
p = _safe_asset(root, landed) if (root and landed) else None
if p is None or p.suffix.lower() != ".png":
if p is None or p.suffix.lower() not in _SAVE_EXT:
return web.json_response({"error": "workflow not available yet"}, status=404)
try:
from PIL import Image
info = Image.open(p).info
except Exception:
info = {}
info = _embedded(p) # PNG info OR video-container metadata — the ONE reader
for key, fmt in (("workflow", "ui"), ("prompt", "api")):
try:
g = json.loads(info.get(key, "null"))
@@ -1369,9 +1533,9 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
{"error": "this template has no runnable graph — open it in the Editor to run"}, status=422)
out_prefix = ""
for n in prompt.values():
if isinstance(n, dict) and n.get("class_type") == "SaveImage":
if isinstance(n, dict) and n.get("class_type") in _SAVERS:
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
out_prefix = (out_prefix or f"templates/{slug}") + f"_{_job_tag()}" # unique per render
out_prefix = _stamp(out_prefix or f"templates/{slug}") # unique per render (image OR video)
sid = _dest_stack(org, body.get("stack"), prompt=rec.get("name", slug),
multi=_graph_is_batch(prompt), owner=_owner_of(request),
workspace=_workspace_of(request))
@@ -1445,7 +1609,8 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
row = dict(a)
row["view"] = _view_params(p)
row["mtime"] = int(p.stat().st_mtime)
if p.suffix.lower() == ".png":
suf = p.suffix.lower()
if suf == ".png" or suf in _VIDEO_EXT:
row["prov"] = _provenance(p)
assets.append(row)
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)},
@@ -1473,7 +1638,8 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
p = _safe_asset(root, request.query.get("path", "")) if root else None
if p is None:
return web.Response(status=404)
if request.query.get("thumb") and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
suf = p.suffix.lower()
if request.query.get("thumb") and suf in _REF_EXT:
try:
tdir = root / ".thumbs"
tdir.mkdir(exist_ok=True)
@@ -1487,7 +1653,25 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
except Exception:
pass # fall through to full-res on any thumbnail failure
ct = _CTYPE.get(p.suffix.lower())
elif request.query.get("thumb") and suf in _VIDEO_EXT:
# A video's poster is its first frame — the SAME cache scheme as images. On ANY
# failure return 404, NEVER the full file (falling through would serve the whole
# mp4 as an <img>). root is the tenant library here, so it is never None.
try:
if root is None:
return web.Response(status=404)
tdir = root / ".thumbs"
tdir.mkdir(exist_ok=True)
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
tp = tdir / f"{key}.jpg"
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
im = _poster(p)
im.thumbnail((480, 720))
im.convert("RGB").save(tp, "JPEG", quality=82)
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
except Exception:
return web.Response(status=404)
ct = _CTYPE.get(suf)
return web.FileResponse(p, headers={"Content-Type": ct} if ct else None)
@routes.post("/v1/library/upload")
@@ -1518,7 +1702,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
data = await request.read()
name = os.path.basename(request.query.get("name", ""))
if not name or os.path.splitext(name)[1].lower() not in _UPLOAD_EXT:
return web.json_response({"error": "image name required (.png/.jpg/.jpeg/.webp)"}, status=400)
return web.json_response({"error": "file name required (.png/.jpg/.jpeg/.webp/.mp4/.webm)"}, status=400)
if name.startswith("."):
# `._foo.png` (AppleDouble fork) and other dotfiles pass the extension
# check but are not renders.
@@ -1535,9 +1719,11 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
return web.json_response({"error": "file too large"}, status=413)
if not (data[:8] == b"\x89PNG\r\n\x1a\n"
or data[:3] == b"\xff\xd8\xff"
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")):
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")
or (len(data) > 12 and data[4:8] == b"ftyp") # mp4/mov: brand box at OFFSET 4
or data[:4] == b"\x1a\x45\xdf\xa3"): # webm/mkv: EBML header
# The name lies sometimes; the bytes never do.
return web.json_response({"error": "not an image"}, status=400)
return web.json_response({"error": "not a supported media file"}, status=400)
out_root = Path(folder_paths.get_org_output_directory(org))
dest = (out_root / sub / name).resolve()
if not str(dest).startswith(str(out_root.resolve()) + os.sep):
@@ -1552,10 +1738,16 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(dest)
# index at ingest → instantly findable in Assets + Queue & History (with node)
# A FIX render arriving through this door (the worker mirror) keeps its colour
# restore — self-scoped to fixes/, fail-safe, image-only in practice.
colormatch_fix_ingest(request, sub, name)
# index at ingest → instantly findable in Assets + Queue & History (with node).
# kind defaults by suffix so a video row selects the motion judge + the video card.
kind = request.query.get("kind") or (
"video" if os.path.splitext(name)[1].lower() in _VIDEO_EXT else "render")
created = _index_upload(org, out_root, rel,
design=request.query.get("design") or None,
kind=request.query.get("kind") or "render",
kind=kind,
role=request.query.get("role") or None,
node=request.query.get("node") or "upload")
if created:
@@ -1616,7 +1808,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
try:
parent = root / os.path.dirname(out_prefix)
stem = os.path.basename(out_prefix)
landed = any(parent.glob(stem + "*.png"))
landed = any(any(parent.glob(stem + "*" + e)) for e in _SAVE_EXT)
except Exception:
landed = False
if landed and age > 20:
@@ -1666,7 +1858,11 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
if p is None:
continue
try:
h = hashlib.md5(p.read_bytes()).hexdigest()
digest = hashlib.md5()
with p.open("rb") as fh: # bounded chunks — a 256MB video never lands in memory
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
h = digest.hexdigest()
except OSError:
continue
if h in seen:
@@ -1750,26 +1946,25 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
org = _org_of(request)
root = _library_root(org)
p = _safe_asset(root, body.get("path", "")) if root else None
if p is None or p.suffix.lower() != ".png":
if p is None or p.suffix.lower() not in _SAVE_EXT:
return web.json_response({"error": "unknown asset"}, status=404)
try:
from PIL import Image
graph = json.loads(Image.open(p).info.get("prompt", "null"))
graph = json.loads(_embedded(p).get("prompt", "null")) # PNG OR video metadata
except Exception:
graph = None
if not isinstance(graph, dict):
return web.json_response({"error": "no embedded workflow in this image"}, status=422)
if not isinstance(graph, dict) or not graph:
return web.json_response({"error": "no embedded workflow in this output"}, status=422)
rel = body.get("path", "")
out_prefix = ""
instr = ""
for n in graph.values():
if not isinstance(n, dict):
continue
if n.get("class_type") == "SaveImage":
if n.get("class_type") in _SAVERS:
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
elif "TextEncode" in n.get("class_type", "") and not instr:
t = n.get("inputs", {}).get("prompt") or n.get("inputs", {}).get("text") or ""
if t and t != _NEG:
if t and t != _NEG and t != _WAN_NEG: # the positive, not either negative
instr = t
count = max(1, min(int(body.get("count", 1)), 4))
base = out_prefix or f"reruns/{re.sub(r'[^a-zA-Z0-9_-]', '_', p.stem)[:40]}"
@@ -1781,7 +1976,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
ids = []
try:
for _ in range(count):
tag = f"{base}_{_job_tag()}" # each rerun owns a unique namespace
tag = _stamp(base) # each rerun owns a unique namespace (no tag accumulation)
g = _retag(_reseed(json.loads(json.dumps(graph))), tag)
ids.append(await _dispatch(
request, server, org, g,
@@ -1915,6 +2110,22 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/video")
async def video(request: web.Request):
"""Generate a video from {prompt, path?, upload?, width?, height?, length?, seed?,
stack?}. Org is the bearer-token claim (IAM middleware) ONLY an ``org`` field in
the body or query is never read. The proven Wan 2.2 graph, dispatched through the
ONE funnel; a bad request surfaces as {"error": } 400, never a silent nothing."""
body = await request.json()
try:
return web.json_response(await dispatch_video(
request, server, _org_of(request), body.get("prompt", ""),
rel=body.get("path"), upload=body.get("upload"),
width=body.get("width"), height=body.get("height"),
length=body.get("length"), seed=body.get("seed"), stack=body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/compose")
async def compose(request: web.Request):
"""Select N references (library assets and/or freshly uploaded images) + say
+10 -6
View File
@@ -65,6 +65,7 @@ def library_lock(root: str):
fh.close()
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
VIDEO_EXTS = {".mp4", ".webm"}
# Written marketing content (blog posts, campaign briefs, social ad copy) is
# first-class in the ONE index too — a .md under marketing/ is a queue asset
# whose body is the post/brief and whose caption/hashtags(=tags) are set with
@@ -132,9 +133,10 @@ def scan(root: str) -> list[str]:
full = os.path.join(base, f)
rel = os.path.relpath(full, root)
is_image = ext in IMAGE_EXTS
is_video = ext in VIDEO_EXTS
is_mktg_text = (ext in MARKETING_TEXT_EXTS
and rel.replace(os.sep, "/").startswith("marketing/"))
if not (is_image or is_mktg_text):
if not (is_image or is_video or is_mktg_text):
continue
try:
if os.path.getsize(full) == 0: # skip a render caught mid-write
@@ -170,11 +172,13 @@ def build(root: str) -> dict:
info = classify(norm, slugs)
prev = prior.get(norm, {})
if info is None:
# Taxonomy enriches, never gates: an image outside the naming
# scheme is still an asset. Dropping it here erased every
# ingest-indexed render on each sweep cycle.
info = {"design": prev.get("design"), "kind": prev.get("kind") or "render",
"role": prev.get("role")}
# Taxonomy enriches, never gates: a file outside the naming scheme is
# still an asset. Dropping it here erased every ingest-indexed render on
# each sweep cycle. A video keeps kind="video" so it survives every sweep
# as the motion-judged, video-card kind.
kind = prev.get("kind") or (
"video" if os.path.splitext(norm)[1].lower() in VIDEO_EXTS else "render")
info = {"design": prev.get("design"), "kind": kind, "role": prev.get("role")}
entry = {
"path": norm,
"design": info["design"],
@@ -89,6 +89,15 @@ def test_count_outputs_handles_empty_and_none():
assert bm.count_outputs({"outputs": {"1": {"text": "no media here"}}}) == 0
def test_count_outputs_counts_video_extension_agnostic():
"""Metering is extension-agnostic: a SaveVideo history entry (its output reported
through `images` by ui.PreviewVideo) meters exactly like an image render the RFC's
shared-untouched claim."""
hist = {"outputs": {"12": {"images": [
{"filename": "video/x_00001_.mp4", "subfolder": "video", "type": "output"}]}}}
assert bm.count_outputs(hist) == 1
# ---------------------------------------------------------------------------
# record_render — the fire-and-forget metered event
# ---------------------------------------------------------------------------
@@ -160,3 +160,16 @@ def test_advisory_gate_false_when_nothing_online(monkeypatch):
monkeypatch.delenv("STUDIO_COMMERCE_TOKEN", raising=False)
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [])
assert g.has_online_gpu_advisory("Bearer user") is False
# ── A BYO-GPU video job records its REAL prefix, not the "render" fallback ─────────
def test_track_savevideo(tmp_path, monkeypatch):
import json
monkeypatch.setattr(g.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
graph = {"7": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
"12": {"class_type": "SaveVideo", "inputs": {"filename_prefix": "video/beach_ab12cd"}}}
g._track_dispatched("acme", "pid-v", graph, node="spark")
jobs = json.loads((tmp_path / "render_jobs.json").read_text())
assert jobs[-1]["outPrefix"] == "video/beach_ab12cd" # the video prefix, not "render"
assert jobs[-1]["prefix"] == "beach_ab12cd"
assert jobs[-1]["refs"] == ["ref.png"] and jobs[-1]["node"] == "spark"
@@ -144,6 +144,14 @@ def test_protected_paths(path):
assert m._is_public_path(path) is False
def test_video_never_public():
"""A tenant video is only ever served by /v1/library/file?path=… (the URL path never
ends in a media suffix), so the suffix bypass cannot apply. ".mp4" must NEVER join the
static-suffix allowlist, and the org-scoped file route stays auth-gated pinned here."""
assert m._is_public_path("/a/b.mp4") is False
assert m._is_public_path("/v1/library/file") is False
# --- server-owned login entry point (/login → OIDC code flow) ---
def test_login_path_is_public():
@@ -0,0 +1,157 @@
"""Quality gate — the VIDEO judge (middleware/quality_gate.py).
The motion gate samples frames across a clip and asks the MOTION judge; a clear FAIL
flags the row exactly like the image gate. A video whose frames can't be sampled is
marked visibly UNGATED (gate='skipped' + an 'ungated' work-log event) NEVER a silent
pass. The image path still fails open, byte-for-byte unchanged.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import quality_gate as qg
from middleware import worklog as wl
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
def _write_mp4(path: Path, frames: int = 6) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
c = av.open(str(path), mode="w")
st = c.add_stream("libx264", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
@pytest.fixture
def orgdir(tmp_path, monkeypatch):
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", lambda o=None: str(tmp_path))
return tmp_path
# ── frame sampling ────────────────────────────────────────────────────────────────
def test_frames_samples_video(tmp_path):
mp4 = tmp_path / "clip.mp4"
_write_mp4(mp4)
frames = qg._frames(mp4)
assert frames is not None and 1 <= len(frames) <= 3
assert all(isinstance(b, str) and b for b in frames) # base64 JPEG strings
# a garbage container yields None (→ ungated, never a silent pass)
bad = tmp_path / "bad.mp4"
bad.write_bytes(b"\x00\x00\x00 ftyp" + b"garbage")
assert qg._frames(bad) is None
# ── the payload carries N frames + the MOTION prompt ──────────────────────────────
@pytest.mark.asyncio
async def test_video_judge_payload(monkeypatch):
seen = {}
class _Resp:
status = 200
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def json(self):
return {"choices": [{"message": {"content": "PASS"}}]}
class _Session:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def post(self, url, json=None, headers=None):
seen["payload"] = json
return _Resp()
monkeypatch.setattr(qg.aiohttp, "ClientSession", _Session)
reply = await qg.judge(["b64one", "b64two"], "Bearer u", qg.MOTION)
assert reply == "PASS"
content = seen["payload"]["messages"][0]["content"]
texts = [c for c in content if c["type"] == "text"]
images = [c for c in content if c["type"] == "image_url"]
assert len(texts) == 1 and texts[0]["text"] == qg.MOTION
assert len(images) == 2 # one part per frame
# ── a clear FAIL flags the row ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_video_fail_flags(orgdir, monkeypatch):
root = orgdir
_write_mp4(root / "video" / "c.mp4")
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "video/c.mp4", "status": "draft"}]}))
async def fake_judge(images, bearer, prompt):
assert prompt == qg.MOTION # the motion contract, not PROMPT
return "FAIL — hair morphs between frames"
monkeypatch.setattr(qg, "judge", fake_judge)
await qg.run("acme", root, "video/c.mp4", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0]["status"] == "flagged"
# ── an unsamplable video is UNGATED — badge + event, never silent ─────────────────
@pytest.mark.asyncio
async def test_unreadable_video_is_ungated_not_silent(orgdir, monkeypatch):
root = orgdir
(root / "video").mkdir(parents=True, exist_ok=True)
(root / "video" / "broken.mp4").write_bytes(b"\x00\x00\x00 ftyp" + b"not decodable")
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "video/broken.mp4", "status": "draft"}]}))
# a work-log row for this output so the 'ungated' event has somewhere to land
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
output_prefix="video/broken.mp4", pid="up-x", status="done")
async def never(*a, **k):
raise AssertionError("judge must not be called for an unsamplable video")
monkeypatch.setattr(qg, "judge", never)
await qg.run("acme", root, "video/broken.mp4", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0].get("gate") == "skipped" # the visible badge field
assert lib["assets"][0]["status"] == "draft" # status untouched
events = [e["s"] for r in wl.load("acme") for e in r.get("events", [])]
assert "ungated" in events # the work-log trail, not silent
# ── the image path still fails OPEN, unchanged ────────────────────────────────────
@pytest.mark.asyncio
async def test_image_still_fails_open(orgdir, monkeypatch):
root = orgdir
(root / "x.png").write_bytes(b"not a real png") # unreadable → _frames None
(root / "library.json").write_text(json.dumps(
{"assets": [{"path": "x.png", "status": "draft"}]}))
async def never(*a, **k):
raise AssertionError("judge must not run on an unreadable image")
monkeypatch.setattr(qg, "judge", never)
await qg.run("acme", root, "x.png", "")
lib = json.loads((root / "library.json").read_text())
assert lib["assets"][0]["status"] == "draft" # fail open: not flagged
assert "gate" not in lib["assets"][0] # and NOT marked skipped (images fail open)
@@ -0,0 +1,482 @@
"""Unit tests for the Studio video lane (middleware/studio_home.py):
* the Wan 2.2 TI2V-5B graph builder (t2v omits the reference node; clamps + snaps);
* dispatch + the /v1/library/video route (org from the token, never the body);
* landed-output + queue detection across .mp4/.webm (the honesty guard covers video);
* the ONE indexed ingest door accepts video, defaults kind, judges, colour-matches;
* the manifest sweeper keeps a video as kind="video";
* serving + the first-frame poster (404 on decode failure, never the raw mp4);
* embedded-metadata read parity (Versions / Re-run / provenance) and job re-tagging.
Filesystem + av only no torch, no GPU, no network. av/numpy are hard studio deps.
"""
import json
import os
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from aiohttp import web
from middleware import studio_home as sh
from middleware import worklog as wl
av = pytest.importorskip("av")
np = pytest.importorskip("numpy")
# ── media fixtures: real, tiny, in-test-encoded ───────────────────────────────────
def _write_mp4(path: Path, frames: int = 6, meta: dict | None = None) -> None:
"""A tiny real mp4 (ftyp at offset 4). ``meta`` embeds container metadata the way
SaveVideo does via movflags=use_metadata_tags, so av reads it back."""
path.parent.mkdir(parents=True, exist_ok=True)
opts = {"movflags": "use_metadata_tags"} if meta else {}
c = av.open(str(path), mode="w", options=opts)
for k, v in (meta or {}).items():
c.metadata[k] = v
st = c.add_stream("libx264", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
def _write_webm(path: Path, frames: int = 6) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
c = av.open(str(path), mode="w")
st = c.add_stream("libvpx-vp9", rate=24)
st.width, st.height, st.pix_fmt = 64, 64, "yuv420p"
for i in range(frames):
arr = np.full((64, 64, 3), (i * 40) % 255, dtype=np.uint8)
fr = av.VideoFrame.from_ndarray(arr, format="rgb24")
for pkt in st.encode(fr):
c.mux(pkt)
for pkt in st.encode():
c.mux(pkt)
c.close()
# ── request + server doubles ──────────────────────────────────────────────────────
class _Req:
"""A minimal stand-in for an aiohttp request the route handlers read: JSON body,
query, headers, cookies, and the iam_user the middleware would have attached."""
def __init__(self, *, body=None, raw=b"", query=None, headers=None,
org="acme", cookies=None, content_length=None):
self._body = body
self._raw = raw
self.query = query or {}
self.headers = headers or {}
self._user = {"org_id": org, "sub": f"u@{org}"} if org else None
self.cookies = cookies or {}
self.content_length = content_length if content_length is not None else (len(raw) or None)
def get(self, key, default=None):
return self._user if key == "iam_user" else default
async def json(self):
return self._body if self._body is not None else {}
async def read(self):
return self._raw
class _FakeQueue:
def get_current_queue_volatile(self):
return ([], [])
class _FakeServer:
port = 1
def __init__(self):
self.prompt_queue = _FakeQueue()
@pytest.fixture
def orgaware(tmp_path, monkeypatch):
def outd(o=None):
d = tmp_path / "orgs" / (o or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def ind(o=None):
d = tmp_path / "orgs" / (o or "default") / "input"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
monkeypatch.setattr(wl.folder_paths, "get_org_output_directory", outd)
return outd, ind
def _handler(server, method, path, monkeypatch):
"""Extract ONE registered route handler, without the stacks sub-router or a live app."""
monkeypatch.setattr(sh.stacks_store, "add_stacks_routes", lambda *a, **k: None)
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
for r in routes:
if getattr(r, "method", None) == method and getattr(r, "path", None) == path:
return r.handler
raise AssertionError(f"no route {method} {path}")
def _lib(root: Path, assets):
(root / "library.json").write_text(json.dumps({"assets": assets}))
# ── 1. the graph builder ──────────────────────────────────────────────────────────
def test_video_graph():
# text-to-video: NO LoadImage node, and NO start_image key on the latent
t2v = sh._video_graph("a woman walks on a sunlit beach", "video/x", length=73)
assert "7" not in t2v
assert "start_image" not in t2v["8"]["inputs"]
assert t2v["8"]["inputs"]["length"] == 73 # 4n+1, unchanged
# image-to-video: LoadImage → start_image; clamp/snap of the dims + length
g = sh._video_graph("beach scene", "video/y_ab12cd", ref_name="ref.png",
width=650, height=641, length=72)
assert g["7"]["class_type"] == "LoadImage" and g["7"]["inputs"]["image"] == "ref.png"
assert g["8"]["inputs"]["start_image"] == ["7", 0]
assert (g["8"]["inputs"]["width"], g["8"]["inputs"]["height"]) == (640, 640) # step of 32
assert g["8"]["inputs"]["length"] == 69 # 72 → nearest 4n+1 below
ks = g["9"]["inputs"]
assert ks["steps"] == 20 and ks["cfg"] == 5.0
assert ks["sampler_name"] == "uni_pc" and ks["scheduler"] == "simple"
assert g["4"]["class_type"] == "ModelSamplingSD3" and g["4"]["inputs"]["shift"] == 8
sv = g["12"]
assert sv["class_type"] == "SaveVideo"
assert sv["inputs"] == {"video": ["11", 0], "filename_prefix": "video/y_ab12cd",
"format": "mp4", "codec": "h264"}
assert g["6"]["inputs"]["text"] == sh._WAN_NEG
assert sh._WAN_NEG.startswith("色调艳丽") and "倒着走" in sh._WAN_NEG
# lessons condition BOTH encoders, exactly like _fix_graph
gl = sh._video_graph("beach", "video/z", lessons=["hair morphs between frames"])
assert "do not repeat" in gl["5"]["inputs"]["text"]
assert "hair morphs between frames" in gl["5"]["inputs"]["text"]
assert "hair morphs between frames" in gl["6"]["inputs"]["text"]
# ── 2. dispatch + the route (org from the token, not the body) ─────────────────────
@pytest.mark.asyncio
async def test_dispatch_route(orgaware, monkeypatch):
outd, _ = orgaware
captured = {}
async def fake_queue(request, server, graph):
captured["graph"] = graph
return "pid-v"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
video = _handler(_FakeServer(), "POST", "/v1/library/video", monkeypatch)
# an 'org' field in the body is IGNORED — the row lands under the TOKEN org
resp = await video(_Req(body={"prompt": "a woman walking on a beach at sunset",
"org": "evil"}, org="acme"))
assert resp.status == 200
out = json.loads(resp.text)
import re
assert re.match(r"^video/.+_[0-9a-f]{6}$", out["output_prefix"])
rows = wl.load("acme")
assert len(rows) == 1 and rows[0]["kind"] == "video"
assert rows[0]["lane"] in ("local", "gpu")
assert wl.load("evil") == [] # never under the body's org
# the graph that queued is the Wan graph (t2v: no LoadImage)
assert any(n.get("class_type") == "SaveVideo" for n in captured["graph"].values())
assert not any(n.get("class_type") == "LoadImage" for n in captured["graph"].values())
# prompt < 3 chars → 400
r2 = await video(_Req(body={"prompt": "hi"}, org="acme"))
assert r2.status == 400
# two references (a library path AND an upload) → 400
r3 = await video(_Req(body={"prompt": "a valid video prompt", "path": "a.png",
"upload": "u.png"}, org="acme"))
assert r3.status == 400
# ── 3. landed-output + the honesty guard, over video ──────────────────────────────
def test_landed_video(orgaware):
outd, _ = orgaware
root = Path(outd("acme"))
(root / "video").mkdir(parents=True, exist_ok=True)
(root / "video" / "x_ab12cd_00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
assert sh._landed_output(root, "video/x_ab12cd") == "video/x_ab12cd_00001_.mp4"
(root / "video" / "w_00001_.webm").write_bytes(b"\x1a\x45\xdf\xa3")
assert sh._landed_output(root, "video/w") == "video/w_00001_.webm"
# child-prefix must NOT be swallowed: "a" never claims "a_00001__00001_.mp4"
(root / "video" / "a_00001__00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
assert sh._landed_output(root, "video/a") is None
# png unchanged
(root / "fixes").mkdir(parents=True, exist_ok=True)
(root / "fixes" / "y_00001_.png").write_bytes(b"\x89PNG\r\n")
assert sh._landed_output(root, "fixes/y") == "fixes/y_00001_.png"
# _finalize_landed marks the video row done at the file mtime …
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/done", params={"steps": 20}, pid="dv", status="queued")
ts = next(r["ts"] for r in wl.load("acme") if r["id"] == "dv")
dp = root / "video" / "done_00001_.mp4"
dp.write_bytes(b"\x00\x00\x00 ftyp")
os.utime(dp, (ts + 200, ts + 200))
rows = wl.load("acme")
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
assert next(r for r in wl.load("acme") if r["id"] == "dv")["status"] == "done"
# … but a <5s landing for a 20-step render is impossible → failed
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/fast", params={"steps": 20}, pid="fv", status="queued")
tsf = next(r["ts"] for r in wl.load("acme") if r["id"] == "fv")
fp = root / "video" / "fast_00001_.mp4"
fp.write_bytes(b"\x00\x00\x00 ftyp")
os.utime(fp, (tsf + 2, tsf + 2))
rows = wl.load("acme")
sh._finalize_landed("acme", root, rows, int(time.time()) + 500)
failed = next(r for r in wl.load("acme") if r["id"] == "fv")
assert failed["status"] == "failed"
assert any("did not sample" in (e.get("note") or "") for e in failed.get("events", []))
# ── 4. the live BYO-GPU queue drops a video job when its mp4 lands ─────────────────
@pytest.mark.asyncio
async def test_render_queue_video(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
_lib(root, [])
now = int(time.time())
(root / "render_jobs.json").write_text(json.dumps(
[{"id": "p", "outPrefix": "video/x", "ts": now - 40, "node": "spark"}]))
render_queue = _handler(_FakeServer(), "GET", "/v1/render-queue", monkeypatch)
resp = await render_queue(_Req(org="acme"))
assert len(json.loads(resp.text)["jobs"]) == 1 # not landed yet → live
(root / "video").mkdir(parents=True, exist_ok=True)
(root / "video" / "x_00001_.mp4").write_bytes(b"\x00\x00\x00 ftyp")
resp = await render_queue(_Req(org="acme"))
assert json.loads(resp.text)["jobs"] == [] # mp4 landed → dropped
# ── 5. worklog params + per-kind ETA bucketing ────────────────────────────────────
def test_graph_params_video(orgaware):
outd, _ = orgaware
g = sh._video_graph("a beach", "video/x", seed=7, length=73)
p = sh._graph_params(g)
for k in ("steps", "cfg", "sampler", "scheduler", "seed", "width", "height", "length", "fps"):
assert k in p, k
assert p["fps"] == 24 and p["length"] == 73 and p["seed"] == 7
wl.record("acme", kind="video", prompt="", refs=[], uploads=[], parents=[],
output_prefix="video/a", pid="v1", status="queued")
wl.mark_started("acme", "v1", 1000)
wl.mark_finished("acme", "v1", 1120)
wl.record("acme", kind="fix", prompt="", refs=[], uploads=[], parents=[],
output_prefix="fixes/a", pid="f1", status="queued")
wl.mark_started("acme", "f1", 1000)
wl.mark_finished("acme", "f1", 1030)
medians = wl.median_durations(wl.load("acme"))
assert medians.get("video") == 120 and medians.get("fix") == 30 # bucketed apart
# ── 6. the ONE indexed ingest door accepts video ──────────────────────────────────
@pytest.mark.asyncio
async def test_upload_video(orgaware, tmp_path, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
_lib(root, [])
scheduled = {}
cm = {}
monkeypatch.setattr(sh.quality_gate, "schedule",
lambda request, org, r, rel: scheduled.update(rel=rel, org=org))
monkeypatch.setattr(sh, "colormatch_fix_ingest",
lambda request, sub, name: cm.update(sub=sub, name=name))
monkeypatch.setattr(sh.stacks_store, "absorb", lambda *a, **k: None) # no stacks DB in this unit
upload = _handler(_FakeServer(), "POST", "/v1/library/upload", monkeypatch)
src = tmp_path / "clip.mp4"
_write_mp4(src)
mp4 = src.read_bytes()
resp = await upload(_Req(raw=mp4, query={"name": "clip.mp4"}, org="acme"))
body = json.loads(resp.text)
assert resp.status == 200 and body["path"] == "renders/clip.mp4"
assert (root / "renders" / "clip.mp4").is_file()
# kind defaults to "video" by suffix
lib = json.loads((root / "library.json").read_text())
row = next(a for a in lib["assets"] if a["path"] == "renders/clip.mp4")
assert row["kind"] == "video"
# the judge fired on the created row; the colour-restore hook saw (sub, name)
assert scheduled.get("rel") == "renders/clip.mp4"
assert cm == {"sub": "renders", "name": "clip.mp4"}
# webm (EBML) accepted too
wsrc = tmp_path / "clip.webm"
_write_webm(wsrc)
r_web = await upload(_Req(raw=wsrc.read_bytes(), query={"name": "clip.webm"}, org="acme"))
assert r_web.status == 200
# garbage named .mp4 → rejected; ftyp at OFFSET 0 (not 4) → rejected (regression pin)
r_bad = await upload(_Req(raw=b"totally not a video, just text bytes here", query={"name": "x.mp4"}, org="acme"))
assert r_bad.status == 400
r_off0 = await upload(_Req(raw=b"ftyp" + b"\x00" * 40, query={"name": "y.mp4"}, org="acme"))
assert r_off0.status == 400
# over the 256MB ceiling → 413 (checked via content_length, no giant allocation)
r_big = await upload(_Req(raw=mp4, query={"name": "big.mp4"}, org="acme",
content_length=sh._MAX_UPLOAD + 1))
assert r_big.status == 413
# ── 7. the manifest sweeper keeps a video as kind="video" ─────────────────────────
def test_manifest_video(tmp_path):
from scripts import library_manifest as lm
root = tmp_path
(root / "video").mkdir()
(root / "video" / "x.mp4").write_bytes(b"\x00\x00\x00 ftyp")
(root / "video" / "empty.mp4").write_bytes(b"") # zero-byte → skipped
assert "video/x.mp4" in lm.scan(str(root))
assert "video/empty.mp4" not in lm.scan(str(root))
doc = lm.build(str(root))
row = next(a for a in doc["assets"] if a["path"] == "video/x.mp4")
assert row["kind"] == "video" and row["status"] == "draft"
# a curated status + tags survive the next sweep (the erase-every-cycle bug stays dead)
lm.write_atomic(str(root), {"assets": [
{"path": "video/x.mp4", "kind": "video", "status": "approved", "tags": ["hero"]}]})
doc2 = lm.build(str(root))
row2 = next(a for a in doc2["assets"] if a["path"] == "video/x.mp4")
assert row2["kind"] == "video" and row2["status"] == "approved" and row2["tags"] == ["hero"]
# ── 8. serving + the first-frame poster ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_serve_and_poster(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
_write_mp4(root / "video" / "clip.mp4")
_lib(root, [{"path": "video/clip.mp4", "kind": "video", "status": "draft"}])
get_file = _handler(_FakeServer(), "GET", "/v1/library/file", monkeypatch)
# full file served as video/mp4
resp = await get_file(_Req(query={"path": "video/clip.mp4"}, org="acme"))
assert resp.content_type == "video/mp4"
# ?thumb=1 → a cached JPEG poster in .thumbs/<key>.jpg
tresp = await get_file(_Req(query={"path": "video/clip.mp4", "thumb": "1"}, org="acme"))
assert isinstance(tresp, web.FileResponse)
key = __import__("re").sub(r"[^a-zA-Z0-9]", "_", str((root / "video" / "clip.mp4").resolve()))[-120:]
assert (root / ".thumbs" / f"{key}.jpg").is_file()
# a poster that cannot decode → 404, NEVER the raw mp4 bytes (use an un-cached path)
_write_mp4(root / "video" / "clip2.mp4")
_lib(root, [{"path": "video/clip.mp4"}, {"path": "video/clip2.mp4"}])
def boom(*a, **k):
raise RuntimeError("cannot decode")
monkeypatch.setattr(av, "open", boom)
r404 = await get_file(_Req(query={"path": "video/clip2.mp4", "thumb": "1"}, org="acme"))
assert isinstance(r404, web.Response) and r404.status == 404
assert not isinstance(r404, web.FileResponse)
# ── 9. embedded-metadata read parity: provenance, Open-in-Editor, Re-run ──────────
@pytest.mark.asyncio
async def test_embedded_and_rerun(orgaware, monkeypatch):
outd, _ = orgaware
root = Path(outd("acme"))
graph = sh._video_graph("a woman on a beach", "video/x_ab12cd", seed=42, length=73)
mp4 = root / "video" / "x_ab12cd_00001_.mp4"
_write_mp4(mp4, meta={"prompt": json.dumps(graph)})
_lib(root, [{"path": "video/x_ab12cd_00001_.mp4", "kind": "video", "status": "draft"}])
# _embedded reads the container metadata back
info = sh._embedded(mp4)
assert "prompt" in info and json.loads(info["prompt"])["12"]["class_type"] == "SaveVideo"
# _provenance yields the video params
prov = sh._provenance(mp4)
assert prov["workflow"] == "video/x_ab12cd" and prov["steps"] == 20
assert prov["cfg"] == 5.0 and prov["seed"] == 42 and prov["length"] == 73 and prov["fps"] == 24
# GET /v1/workflow serves format 'api' for the done video row. The dispatch ts must
# predate the file mtime (else finalize reads the mp4 as a pre-existing sibling).
ts = int(mp4.stat().st_mtime) - 100
wl.record("acme", kind="video", prompt="p", refs=[], uploads=[], parents=[],
output_prefix="video/x_ab12cd", params={"steps": 20}, pid="pv", status="queued")
rows = wl.load("acme")
for x in rows:
if x["id"] == "pv":
x["ts"] = ts
wl._save("acme", rows)
get_wf = _handler(_FakeServer(), "GET", "/v1/workflow", monkeypatch)
wfresp = await get_wf(_Req(query={"id": "pv"}, org="acme"))
wf = json.loads(wfresp.text)
assert wf["format"] == "api" and wf["graph"]["12"]["class_type"] == "SaveVideo"
# POST /v1/library/rerun dispatches kind='rerun' with a FRESH SaveVideo tag
captured = {}
async def fake_queue(request, server, g):
captured["graph"] = g
return "pid-rerun"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
rerun = _handler(_FakeServer(), "POST", "/v1/library/rerun", monkeypatch)
rr = await rerun(_Req(body={"path": "video/x_ab12cd_00001_.mp4"}, org="acme"))
assert rr.status == 200
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
import re
assert re.match(r"^video/x_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
assert sv["inputs"]["filename_prefix"] != "video/x_ab12cd" # a fresh namespace
assert any(r2["kind"] == "rerun" for r2 in wl.load("acme"))
# ── 10. template re-tag: video AND image savers get a fresh job identity ──────────
@pytest.mark.asyncio
async def test_template_retag(orgaware, monkeypatch):
import re
outd, _ = orgaware
root = Path(outd("acme"))
tdir = Path(outd("acme")).parent / "templates"
tdir.mkdir(parents=True, exist_ok=True)
captured = {}
async def fake_queue(request, server, g):
captured["graph"] = g
return "pid-t"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
render = _handler(_FakeServer(), "POST", "/v1/templates/render", monkeypatch)
# a template whose graph saves via SaveVideo
vgraph = sh._video_graph("a beach", "video/tmpl", length=73)
(tdir / "clip.json").write_text(json.dumps({"name": "Clip", "slug": "clip", "graph": vgraph}))
resp = await render(_Req(body={"name": "clip"}, org="acme"))
assert resp.status == 200
sv = next(n for n in captured["graph"].values() if n["class_type"] == "SaveVideo")
assert re.match(r"^video/tmpl_[0-9a-f]{6}$", sv["inputs"]["filename_prefix"])
# a SaveImage template is re-tagged too (the unique-job-tag rule holds for images)
igraph = {"9": {"class_type": "KSampler", "inputs": {"seed": 1, "steps": 30}},
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "templates/pic"}}}
(tdir / "pic.json").write_text(json.dumps({"name": "Pic", "slug": "pic", "graph": igraph}))
resp2 = await render(_Req(body={"name": "pic"}, org="acme"))
assert resp2.status == 200
si = next(n for n in captured["graph"].values() if n["class_type"] == "SaveImage")
assert re.match(r"^templates/pic_[0-9a-f]{6}$", si["inputs"]["filename_prefix"])
@@ -50,7 +50,7 @@ def test_config_defaults_and_overrides(monkeypatch):
def _const(v):
async def j(image_b64, bearer):
async def j(images, bearer, prompt):
return v
return j
@@ -78,8 +78,8 @@ async def test_run_flags_on_fail_and_annotates_worklog(tmp_path, monkeypatch):
output_prefix="renders/r.png", node="spark", status="done")
seen = {}
async def fake_judge(image_b64, bearer):
seen["b64"], seen["bearer"] = image_b64, bearer
async def fake_judge(images, bearer, prompt):
seen["b64"], seen["bearer"], seen["prompt"] = images, bearer, prompt
return "FAIL - mottled, speckled skin all over the arms and shoulders here"
monkeypatch.setattr(qg, "judge", fake_judge)
@@ -103,7 +103,7 @@ async def test_run_fails_open_on_pass_inconclusive_and_error(tmp_path, monkeypat
await qg.run("acme", root, "renders/r.png", "Bearer t")
assert _status(root) == "draft"
async def boom(image_b64, bearer):
async def boom(images, bearer, prompt):
raise RuntimeError("429 rate limited") # 402/429/timeout/unreachable class
monkeypatch.setattr(qg, "judge", boom)
@@ -126,7 +126,7 @@ async def test_run_missing_or_unreadable_is_a_no_op(tmp_path, monkeypatch):
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
called = {"n": 0}
async def counting_judge(image_b64, bearer):
async def counting_judge(images, bearer, prompt):
called["n"] += 1
return "FAIL"