325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
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) => `<button class="job-item ${job.id === currentJob ? 'active' : ''}" data-id="${escapeHtml(job.id)}"><strong>${escapeHtml(job.name)}</strong><span>${statusText(job.status)} · ${escapeHtml(job.created_at)}</span></button>`).join('') : '<p class="error">还没有制作记录</p>';
|
||
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 = `<img class="pose-image ${resultUrl ? '' : 'reference'}" src="${imageUrl}${imageUrl.includes('?') ? '&' : '?'}t=${Date.now()}" alt="${directionLabels[rowIndex]}第${item.frame}格${resultUrl ? '生成预览' : '参考图'}"><span class="pose-badge ${state.candidate ? 'candidate' : state.status === 'completed' ? '' : 'failed'}">${status}</span>${resultUrl ? `<a class="pose-link" href="${resultUrl}" target="_blank" rel="noreferrer">查看大图</a>` : ''}<b>${directionLabels[rowIndex]} · 第 ${item.frame} 格 · ${item.label}</b><p>${escapeHtml(currentPrompts?.[rowIndex]?.[item.frame - 1] || '')}</p><button class="pose-generate" ${!poseEnabled(direction, item.pose) || state.status === 'running' ? 'disabled' : ''}>${state.status === 'completed' ? '重新生成' : state.status === 'running' ? '生成中…' : state.candidate ? '重新生成' : '生成这一格'}</button>${state.error ? `<small>${escapeHtml(state.error)}</small>` : ''}`;
|
||
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(() => {});
|
||
});
|