studio: video lane — generate/card/ingest/judge over the shared library and flywheel
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user