feat: integrate invitation access, world NPCs, and deployment

This commit is contained in:
2026-09-08 22:22:38 +08:00
parent 2a3125075f
commit 513a3eba31
75 changed files with 9566 additions and 1070 deletions

View File

@@ -0,0 +1,324 @@
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(() => {});
});