diff --git a/demo/index.html b/demo/index.html index 1f23f9c..5e74e73 100644 --- a/demo/index.html +++ b/demo/index.html @@ -3,17 +3,17 @@ :root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#10131b;color:#f2f5fb}body{margin:0;overflow:hidden;background:#10131b}main{width:100vw;height:100vh;display:grid;grid-template-columns:360px minmax(0,1fr)}.sidebar{padding:24px 18px;display:flex;flex-direction:column;gap:18px;overflow:auto;background:radial-gradient(circle at 0 0,#293b62,transparent 32rem),#141a27;border-right:1px solid #2b354b}h1{font-size:2rem;letter-spacing:-.05em;margin:0}.eyebrow{font-size:.7rem;text-transform:uppercase;letter-spacing:.14em;color:#9bb8ff;margin-bottom:8px}.card{background:#171c28dd;border:1px solid #2b354b;border-radius:14px;overflow:hidden}.stage{min-width:0;min-height:0;display:flex;flex-direction:column;background:#0c1019}canvas{display:block;width:100%;height:100%;flex:1;min-height:0;background:linear-gradient(#111b31,#0c1019);cursor:grab;touch-action:none}canvas.dragging{cursor:grabbing}.controls{padding:14px 18px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-top:1px solid #2b354b}.promptbox{padding:16px;display:grid;gap:10px}.promptbox textarea{min-height:110px;resize:vertical}textarea,input{box-sizing:border-box;width:100%;border:1px solid #35415b;border-radius:10px;background:#0d121d;color:#f2f5fb;padding:10px;font:inherit}button{border:0;border-radius:999px;padding:10px 16px;background:#9bb8ff;color:#10131b;font:inherit;font-weight:700;cursor:pointer}button:disabled{opacity:.5;cursor:wait}.readout,.hint{color:#a9b3c8;font-size:.88rem}.gallery{padding:4px;display:grid;gap:8px;align-content:start}.history{flex:1;min-height:150px;overflow:auto}.gallery h2{font-size:1rem;margin:8px}.item{width:100%;background:#101622;border:1px solid #2c3750;border-radius:12px;padding:11px;text-align:left;color:#e8eefc}.item:hover,.item.active{border-color:#9bb8ff;background:#19233a}.item p{margin:0 0 6px;font-size:.88rem;line-height:1.35}.status{font-size:.75rem;color:#a9b3c8}.error{color:#ffacac;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.camera{flex:1 1 100%;color:#a9b3c8;font-size:.82rem}.progress{display:inline-flex;align-items:center;gap:7px;color:#d9e5ff;font-weight:600}.progress::before{content:'';width:8px;height:8px;border-radius:50%;background:#9bb8ff;box-shadow:0 0 0 0 #9bb8ff;animation:pulse 1.25s infinite}@keyframes pulse{70%{box-shadow:0 0 0 8px #9bb8ff00}}.item.running{border-color:#739cf7}@media(max-width:850px){body{overflow:auto}main{height:auto;min-height:100vh;grid-template-columns:1fr}.sidebar{overflow:visible}.stage{height:min(70vh,700px)}} /* LocalAI house style: dark graphite surfaces with a cyan/violet edge. */ :root{--la-bg:#080b0f;--la-panel:#10151b;--la-panel-2:#151c23;--la-line:#25313b;--la-text:#f3f6f4;--la-muted:#8b9aa5;--la-cyan:#3bd3d0;--la-cyan-2:#5ee6be;--la-violet:#9f7aea}body{background:var(--la-bg);color:var(--la-text)}.sidebar{background:radial-gradient(circle at 18% 0,rgba(59,211,208,.12),transparent 26rem),var(--la-panel);border-color:var(--la-line)}.card{background:var(--la-panel-2);border-color:var(--la-line)}.stage{background:#090d12}canvas{background:radial-gradient(circle at 50% 25%,#102632 0,#090d12 56%)}.eyebrow{color:var(--la-cyan);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.product-title{display:flex;align-items:center;gap:11px}.product-mark{display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:linear-gradient(135deg,var(--la-cyan),#218eac 55%,var(--la-violet));color:#051013;font-size:20px;font-weight:900;box-shadow:0 0 28px rgba(59,211,208,.2)}.localai-brand{display:flex;align-items:center;gap:9px;color:#687983;font-size:10px;letter-spacing:.1em;text-transform:uppercase}.localai-brand img{width:56px;height:56px;object-fit:contain}.promptbox label,.gallery h2{color:var(--la-text)}textarea,input{border-color:var(--la-line);background:#0b1015}button{background:linear-gradient(135deg,var(--la-cyan),var(--la-cyan-2));color:#051013;border-radius:8px;box-shadow:0 8px 22px rgba(59,211,208,.12)}.item{background:#0b1015;border-color:var(--la-line)}.item:hover,.item.active{border-color:var(--la-cyan);background:#111d24}.status,.readout,.hint,.camera{color:var(--la-muted)}.progress{color:var(--la-cyan-2)}.progress::before{background:var(--la-cyan);box-shadow:0 0 0 0 var(--la-cyan)}.item.running{border-color:var(--la-cyan)} -
Drag: rotate · wheel: zoom · Shift/right-drag: pan · double-click: reset
+
Camera tracks motion · Drag: rotate · wheel: zoom · Shift/right-drag: pan · double-click: reset
diff --git a/demo/main.go b/demo/main.go index c52b428..703a459 100644 --- a/demo/main.go +++ b/demo/main.go @@ -360,8 +360,8 @@ func main() { if request.Segments[index].Frames == 0 { request.Segments[index].Frames = 150 } - if request.Segments[index].Prompt == "" || len(request.Segments[index].Prompt) > 4096 || request.Segments[index].Frames < 60 || request.Segments[index].Frames > 300 { - http.Error(w, "each prompt segment must be 60..300 frames and 1..4096 bytes", 400) + if request.Segments[index].Prompt == "" || len(request.Segments[index].Prompt) > 4096 || request.Segments[index].Frames < 60 || request.Segments[index].Frames > 150 { + http.Error(w, "each prompt segment must be 60..150 frames and 1..4096 bytes", 400) return } } diff --git a/demo/models.js b/demo/models.js index 983e9c2..95c09eb 100644 --- a/demo/models.js +++ b/demo/models.js @@ -28,12 +28,20 @@ window.addEventListener('load', async () => { sequence.style.cssText = 'display:grid;gap:10px;width:100%'; prompt.before(sequence); prompt.classList.add('sequence-prompt'); + const minFrames = 60, maxFrames = 150; const segmentControls = new Map(); + const validFrames = value => Number.isInteger(value) && value >= minFrames && value <= maxFrames; + const clampFrames = value => validFrames(Number(value)) ? Number(value) : Math.max(minFrames, Math.min(maxFrames, Number(value) || 150)); + const configureDuration = (duration, frames) => { + duration.type = 'number'; duration.min = String(minFrames); duration.max = String(maxFrames); duration.step = '1'; duration.value = String(clampFrames(frames)); + duration.title = `Frames (${minFrames}–${maxFrames})`; + duration.addEventListener('change', () => { duration.value = String(clampFrames(duration.value)); duration.setCustomValidity(''); }); + duration.addEventListener('invalid', () => duration.setCustomValidity(`Use a whole number from ${minFrames} to ${maxFrames} frames.`)); + }; const primaryRow = document.createElement('div'); primaryRow.style.cssText = 'display:grid;grid-template-columns:1fr 74px;gap:7px;align-items:start'; const primaryDuration = document.createElement('input'); - primaryDuration.type = 'number'; primaryDuration.min = '60'; primaryDuration.max = '300'; primaryDuration.step = '30'; primaryDuration.value = '150'; - primaryDuration.title = 'Frames (60–300)'; + configureDuration(primaryDuration, 150); primaryRow.append(prompt, primaryDuration); sequence.append(primaryRow); segmentControls.set(primaryRow, primaryDuration); const count = document.createElement('div'); count.className = 'hint'; @@ -45,7 +53,7 @@ window.addEventListener('load', async () => { const row = document.createElement('div'); row.style.cssText = 'display:grid;grid-template-columns:1fr 74px auto;gap:7px;align-items:start'; const textArea = document.createElement('textarea'); textArea.className = 'sequence-prompt'; textArea.value = text; textArea.placeholder = 'Describe the next motion'; textArea.style.minHeight = '64px'; - const duration = document.createElement('input'); duration.type = 'number'; duration.min = '60'; duration.max = '300'; duration.step = '30'; duration.value = String(frames); duration.title = 'Frames (60–300)'; + const duration = document.createElement('input'); configureDuration(duration, frames); const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.title = 'Remove segment'; remove.style.cssText = 'padding:8px 12px;background:#24313a;color:#dce9e8'; remove.onclick = () => { row.remove(); updateCount(); }; row.append(textArea, duration, remove); sequence.append(row); segmentControls.set(row, duration); @@ -68,12 +76,12 @@ window.addEventListener('load', async () => { : [{prompt: prompt.value, frames: 150}]; const first = restored[0]; prompt.value = first.prompt || ''; - primaryDuration.value = String(first.frames || 150); + primaryDuration.value = String(clampFrames(first.frames)); for (const row of [...sequence.children]) { if (row !== primaryRow) row.remove(); } for (const segment of restored.slice(1)) { - addSegment(segment.prompt || '', segment.frames || 150); + addSegment(segment.prompt || '', clampFrames(segment.frames)); } updateCount(); }); @@ -87,7 +95,9 @@ window.addEventListener('load', async () => { body.segments = [...sequence.querySelectorAll('.sequence-prompt')].map(area => { const row = area.closest('div'); const duration = segmentControls.get(row); - return {prompt: area.value, frames: Number(duration?.value || 150)}; + const frames = Number(duration?.value); + if (!validFrames(frames)) throw new Error(`Each segment must be a whole number from ${minFrames} to ${maxFrames} frames.`); + return {prompt: area.value, frames}; }); return nativeFetch(input, {...init, body: JSON.stringify(body)}); }