Files
whale-town-end-v2/tools/character_maker/app.js

325 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(() => {});
});