const $ = (selector) => document.querySelector(selector); let selectedFile = null; let currentJob = null; let currentJobData = null; let row = 0; let frame = 0; let sheet = new Image(); let poller = null; let currentPrompts = []; let currentRefs = [ '/references/human_whale_reference_down.png', '/references/human_whale_reference_up.png', '/references/human_whale_reference_right.png', '/references/human_whale_reference_left.png', ]; const directions = ['down', 'up', 'right', 'left']; const directionLabels = ['正面', '背面', '右侧', '左侧']; const poseItems = [ { frame: 1, pose: 'neutral', label: '中立' }, { frame: 2, pose: 'first_leg', label: '动作 A' }, { frame: 4, pose: 'opposite_leg', label: '动作 B' }, ]; const stages = { queued: 4, start: 8, identity: 18, generate: 34, cutout: 54, expand: 68, assemble: 82, normalize: 90, qa: 96, done: 100, failed: 100 }; async function api(path, options) { const response = await fetch(path, options); const data = await response.json(); if (!response.ok) throw new Error(data.error || '请求失败'); return data; } function statusText(status) { return ({ queued: '排队中', running: '生成中', completed: '已完成', failed: '失败', interrupted: '已中断', awaiting_confirmation: '待确认身份', action_ready: '动作待生成' })[status] || status; } function escapeHtml(value) { const node = document.createElement('div'); node.textContent = value == null ? '' : String(value); return node.innerHTML; } async function health() { try { const state = await api('/api/health'); $('#health').className = `health ${state.worker && state.api_key ? 'ok' : 'bad'}`; $('#health span').textContent = !state.worker ? '生成脚本缺失' : !state.api_key ? '未配置 API Key' : '产线就绪'; } catch { $('#health').className = 'health bad'; $('#health span').textContent = '工具离线'; } } function setFile(file) { if (!file) return; if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 8 * 1024 * 1024) { $('#formError').textContent = '请选择 8MB 以内的 PNG、JPG 或 WebP'; return; } selectedFile = file; $('#formError').textContent = ''; $('#sourcePreview').src = URL.createObjectURL(file); $('#dropzone').classList.add('has-image'); $('#generate').disabled = false; if (!$('#name').value) $('#name').value = file.name.replace(/\.[^.]+$/, '').slice(0, 40); } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(file); }); } async function createJob() { if (!selectedFile) return; const button = $('#generate'); button.disabled = true; $('#formError').textContent = ''; try { const job = await api('/api/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: $('#name').value, quality: $('input[name=quality]:checked').value, mime: selectedFile.type, image: await fileToBase64(selectedFile) }), }); currentJob = job.id; await loadJobs(); show(job); startPoll(); } catch (error) { $('#formError').textContent = error.message; } finally { button.disabled = false; } } async function loadJobs() { const { jobs } = await api('/api/jobs'); $('#jobList').innerHTML = jobs.length ? jobs.map((job) => ``).join('') : '

还没有制作记录

'; document.querySelectorAll('.job-item').forEach((button) => { button.onclick = async () => { currentJob = button.dataset.id; show(await api(`/api/jobs/${currentJob}`)); startPoll(); loadJobs(); }; }); } function startPoll() { clearInterval(poller); poller = setInterval(async () => { if (!currentJob) return; try { show(await api(`/api/jobs/${currentJob}`)); } catch { /* health indicator handles transient outages */ } }, 1500); } function clearCanvas() { $('#canvas').getContext('2d').clearRect(0, 0, $('#canvas').width, $('#canvas').height); $('#frames').innerHTML = ''; } function drawFrame(canvas, directionRow, frameIndex) { const context = canvas.getContext('2d'); context.imageSmoothingEnabled = false; context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(sheet, frameIndex * 160, directionRow * 160, 160, 160, 0, 0, canvas.width, canvas.height); } function drawAll() { const hasSheet = sheet.complete && sheet.naturalWidth; if (hasSheet) { drawFrame($('#canvas'), row, frame); $('#frames').innerHTML = ''; for (let index = 0; index < 8; index += 1) { const button = document.createElement('button'); button.className = index === frame ? 'active' : ''; const thumbnail = document.createElement('canvas'); thumbnail.width = thumbnail.height = 80; drawFrame(thumbnail, row, index); button.append(thumbnail); button.onclick = () => { frame = index; drawAll(); }; $('#frames').append(button); } } else { $('#frames').innerHTML = ''; } $('#frameLabel').textContent = `第 ${frame + 1} / 8 帧`; const promptText = $('#promptText'); if (promptText) promptText.value = selectedPrompt(); const poseName = { 1: 'neutral', 2: 'first_leg', 4: 'opposite_leg' }[frame + 1] || 'neutral'; const state = currentJobData?.poses?.[`${directions[row]}:${poseName}`]; const poseUrl = state?.preview_url || state?.raw_url; const posePreview = $('#poseCanvasPreview'); if (!hasSheet && poseUrl) { posePreview.src = `${poseUrl}?t=${Date.now()}`; posePreview.classList.remove('hidden'); $('#canvas').classList.add('hidden'); } else { posePreview.classList.add('hidden'); $('#canvas').classList.toggle('hidden', !hasSheet); } } function selectedPrompt() { return currentPrompts?.[row]?.[frame] || '提示词尚未生成'; } function poseReference(direction, frameNumber, rowIndex) { const cellIndex = [1, 2, 4].indexOf(frameNumber); return currentJobData?.reference_cell_urls?.[rowIndex]?.[cellIndex] || `/references/cell/${direction}/${frameNumber}.png`; } function poseState(direction, pose) { return currentJobData?.poses?.[`${direction}:${pose}`] || {}; } function poseEnabled(direction, pose) { if (pose === 'neutral') return true; if (pose === 'opposite_leg') return poseState(direction, 'neutral').status === 'completed'; return poseState(direction, 'opposite_leg').status === 'completed'; } async function requestPose(direction, pose) { try { await api(`/api/jobs/${currentJob}/poses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ direction, pose }) }); show(await api(`/api/jobs/${currentJob}`)); startPoll(); } catch (error) { $('#jobMessage').textContent = error.message; } } function renderPromptGrid() { const grid = $('#jobPromptGrid'); if (!grid) return; grid.innerHTML = ''; let completed = 0; let candidates = 0; directions.forEach((direction, rowIndex) => poseItems.forEach((item) => { const state = poseState(direction, item.pose); const resultUrl = state.preview_url || state.raw_url; const imageUrl = resultUrl || poseReference(direction, item.frame, rowIndex); const status = state.status === 'completed' ? '通过 QA' : state.candidate ? 'QA 未通过 · 候选' : state.status === 'running' ? '生成中…' : '待生成'; if (state.status === 'completed') completed += 1; if (state.candidate) candidates += 1; const card = document.createElement('div'); card.className = `prompt-cell pose-cell${state.status === 'running' ? ' busy' : ''}`; card.innerHTML = `${directionLabels[rowIndex]}第${item.frame}格${resultUrl ? '生成预览' : '参考图'}${status}${resultUrl ? `查看大图` : ''}${directionLabels[rowIndex]} · 第 ${item.frame} 格 · ${item.label}

${escapeHtml(currentPrompts?.[rowIndex]?.[item.frame - 1] || '')}

${state.error ? `${escapeHtml(state.error)}` : ''}`; card.onclick = (event) => { if (event.target.closest('button,a')) return; row = rowIndex; frame = item.frame - 1; document.querySelectorAll('.pose-cell').forEach((cell) => cell.classList.remove('active')); card.classList.add('active'); drawAll(); }; card.querySelector('button').onclick = () => requestPose(direction, item.pose); if (state.inputs?.layout_guide_path) { const guideLink = document.createElement('a'); guideLink.className = 'pose-link'; guideLink.href = `/files/${currentJob}/${state.inputs.layout_guide_path}`; guideLink.target = '_blank'; guideLink.rel = 'noreferrer'; guideLink.textContent = '查看布局框'; card.append(guideLink); } if (state.qa?.warnings?.length) { const warning = document.createElement('small'); warning.className = 'qa-warning'; warning.textContent = `提示:${state.qa.warnings.join(';')}`; card.append(warning); } if (state.archived_attempts?.length) { const attempts = document.createElement('small'); attempts.className = 'attempt-note'; attempts.textContent = `已归档 ${state.archived_attempts.length} 次候选`; card.append(attempts); } grid.append(card); })); $('#poseSummary').textContent = `${completed}/12 已通过${candidates ? ` · ${candidates} 个候选待确认` : ''}`; } function show(job) { currentJobData = job; $('#empty').classList.add('hidden'); $('#jobView').classList.remove('hidden'); $('#jobName').textContent = job.name || ''; $('#jobStatus').textContent = statusText(job.status); $('#jobMessage').textContent = job.message || ''; $('#jobTime').textContent = job.created_at || ''; $('#progressBar').style.width = `${stages[job.stage] ?? 12}%`; $('#progressBar').style.background = job.status === 'failed' ? '#e7674f' : ''; $('#logs').textContent = (job.log || []).join('\n'); const identityUrl = job.file_urls?.identity_path; $('#identityResult').classList.toggle('hidden', !identityUrl); if (identityUrl) $('#identityImage').src = `${identityUrl}?t=${Date.now()}`; const actionsVisible = job.phase === 'actions' || job.stage === 'done' || Object.keys(job.poses || {}).length > 0; $('#actionTask').classList.toggle('hidden', !actionsVisible); $('#actionPreview').classList.toggle('hidden', !actionsVisible); const continueButton = $('#continueJob'); continueButton.classList.toggle('hidden', job.stage !== 'identity_done'); continueButton.onclick = async () => { const next = await api(`/api/jobs/${job.id}/continue`, { method: 'PUT' }); show(next); startPoll(); }; const sheetUrl = job.file_urls?.spritesheet_path; if (sheetUrl) { sheet.onload = drawAll; sheet.src = `${sheetUrl}?t=${Date.now()}`; $('#downloads').classList.remove('hidden'); document.querySelectorAll('#downloads a').forEach((link) => { const url = job.file_urls?.[link.dataset.file]; link.classList.toggle('hidden', !url); if (url) link.href = url; }); } else { $('#downloads').classList.add('hidden'); sheet = new Image(); clearCanvas(); } if (actionsVisible) { renderPromptGrid(); drawAll(); } if (['completed', 'failed', 'interrupted', 'awaiting_confirmation', 'action_ready'].includes(job.status)) { clearInterval(poller); poller = null; } } document.addEventListener('DOMContentLoaded', () => { $('#file').addEventListener('change', (event) => setFile(event.target.files[0])); ['dragenter', 'dragover'].forEach((eventName) => $('#dropzone').addEventListener(eventName, (event) => { event.preventDefault(); $('#dropzone').classList.add('drag'); })); ['dragleave', 'drop'].forEach((eventName) => $('#dropzone').addEventListener(eventName, (event) => { event.preventDefault(); $('#dropzone').classList.remove('drag'); })); $('#dropzone').addEventListener('drop', (event) => setFile(event.dataTransfer.files[0])); $('#generate').addEventListener('click', createJob); $('#refresh').addEventListener('click', loadJobs); $('#copyPrompt')?.addEventListener('click', () => navigator.clipboard?.writeText(selectedPrompt())); document.querySelectorAll('#directionTabs button').forEach((button) => button.addEventListener('click', () => { document.querySelectorAll('#directionTabs button').forEach((tab) => tab.classList.remove('active')); button.classList.add('active'); row = Number(button.dataset.row); drawAll(); })); health(); loadJobs().catch(() => {}); setInterval(health, 10000); api('/api/prompt-templates').then((templates) => { currentPrompts = templates.prompts || []; const identityPrompt = $('#identityPrompt'); if (identityPrompt) identityPrompt.textContent = templates.identity_prompt || ''; if (currentJobData) { renderPromptGrid(); drawAll(); } }).catch(() => {}); });