(() => {
const $ = (id) => document.getElementById(id);
const btnSend = $('btnSend');
const btnClear = $('btnClear');
const btnCopyLog = $('btnCopyLog'); // 可能不存在(已移除全局日志按钮)
// 旧版日志容器可能不存在,兜底创建隐藏节点以避免空引用
const out =
$('output') ||
(() => {
const el = document.createElement('pre');
el.id = 'output';
el.style.display = 'none';
document.body.appendChild(el);
return el;
})();
const logTaskPanel = $('tabPanelLog');
const logListContainer = $('logListContainer');
const logDetailId = $('logDetailId');
const logDetailStatus = $('logDetailStatus');
const logDetailMeta = $('logDetailMeta');
const logDetailContent = $('logDetailContent');
const btnCopyTaskLog = $('btnCopyTaskLog');
const previewGrid = $('previewGrid');
const previewFilterBar = $('previewFilterBar');
const previewCount = $('previewCount');
const btnPreviewBatchDownload = $('btnPreviewBatchDownload');
const previewModal = $('previewModal');
const previewModalMedia = $('previewModalMedia');
const previewModalTaskId = $('previewModalTaskId');
const previewModalStoryboard = $('previewModalStoryboard');
const previewModalWatermark = $('previewModalWatermark');
const previewModalMeta = $('previewModalMeta');
const btnPreviewClose = $('btnPreviewClose');
const btnPreviewOpenNew = $('btnPreviewOpenNew');
const btnPreviewCopyLink = $('btnPreviewCopyLink');
const btnPreviewCopyHtml = $('btnPreviewCopyHtml');
const previewModalDownload = $('previewModalDownload');
const btnPreviewLocateTask = $('btnPreviewLocateTask');
const editStoryboardModal = $('editStoryboardModal');
const editStoryboardModalBadge = $('editStoryboardModalBadge');
const editStoryboardModalMeta = $('editStoryboardModalMeta');
const editStoryboardTextarea = $('editStoryboardTextarea');
const btnEditStoryboardCancel = $('btnEditStoryboardCancel');
const btnEditStoryboardRetry = $('btnEditStoryboardRetry');
const taskList = $('taskList');
const taskCount = $('taskCount');
const dropzone = $('dropzone');
const fileInput = $('file');
const filePreviewBox = $('filePreviewBox');
const filePreviewMedia = $('filePreviewMedia');
const filePreviewName = $('filePreviewName');
const filePreviewKind = $('filePreviewKind');
const filePreviewMeta = $('filePreviewMeta');
const filePreviewHints = $('filePreviewHints');
const filePreviewList = $('filePreviewList');
const btnUseRecommendedModel = $('btnUseRecommendedModel');
const btnClearFiles = $('btnClearFiles');
const uxBanner = $('uxBanner');
const toastHost = $('toastHost') || document.getElementById('toastHost');
const promptBox = $('prompt');
const tagBar = $('tagBar');
const roleList = $('roleList');
const roleSearch = $('roleSearch');
const roleSearchClear = $('roleSearchClear');
const roleCountEl = $('roleCount');
const roleFilterBar = $('roleFilterBar');
const roleSort = $('roleSort');
const btnReloadRoles = $('btnReloadRoles');
const btnRoleDense = $('btnRoleDense');
const attachedRolesBox = $('attachedRoles');
const btnClearMainRoles = document.getElementById('btnClearMainRoles');
const multiGlobalRolesBar = document.getElementById('multiGlobalRolesBar');
const multiAttachedRolesBox = document.getElementById('multiAttachedRoles');
const btnMultiClearRoles = document.getElementById('btnMultiClearRoles');
const storyboardAttachedRolesBox = document.getElementById('storyboardAttachedRoles');
const btnStoryboardScopeRoles = document.getElementById('btnStoryboardScopeRoles');
const btnStoryboardClearRoles = document.getElementById('btnStoryboardClearRoles');
const formStorageKey = 'gen_form_v1';
const btnClearDone = $('btnClearDone');
const btnClearAll = $('btnClearAll');
const taskStorageKey = 'gen_tasks_v1';
// 角色挂载:按模式隔离,避免“分镜挂载影响单次/同提示”的错觉
const roleStorageKeyLegacy = 'gen_roles_v1';
const roleStorageKeyMain = 'gen_roles_main_v1';
const roleStorageKeyMulti = 'gen_roles_multi_v1';
const roleStorageKeyStoryboard = 'gen_roles_storyboard_v1';
const ROLE_UI_KEY = 'gen_role_ui_v2';
const ROLE_FAV_KEY = 'gen_role_fav_v1';
const ROLE_USED_KEY = 'gen_role_used_v1';
const authHeaderKey = 'adminToken';
const batchPromptList = $('batchPromptList');
const batchModeBar = $('batchModeBar');
const batchConcurrencyInput = $('batchConcurrency');
const btnApplyGlobalCountToAll = $('btnApplyGlobalCountToAll');
const batchMetaActions = document.getElementById('batchMetaActions');
const btnExportBatch = $('btnExportBatch');
const btnImportBatch = $('btnImportBatch');
const importBatchFile = $('importBatchFile');
const multiPromptList = document.getElementById('multiPromptList');
const btnAddPrompt = document.getElementById('btnAddPrompt');
const multiPromptActions = document.getElementById('multiPromptActions');
const storyboardBox = document.getElementById('storyboardBox');
const storyboardTitle = document.getElementById('storyboardTitle');
const storyboardShotCount = document.getElementById('storyboardShotCount');
const btnApplyStoryboardCount = document.getElementById('btnApplyStoryboardCount');
const storyboardSequential = document.getElementById('storyboardSequential');
const btnStoryboardFromPrompt = document.getElementById('btnStoryboardFromPrompt');
const btnStoryboardClear = document.getElementById('btnStoryboardClear');
const storyboardContext = document.getElementById('storyboardContext');
const storyboardList = document.getElementById('storyboardList');
const globalCountLabel = document.getElementById('globalCountLabel');
const uploadCard = document.getElementById('uploadCard');
const dropzoneWrap = document.getElementById('dropzoneWrap');
const btnSendPrimary = document.getElementById('btnSendPrimary');
const btnClearPrimary = document.getElementById('btnClearPrimary');
const quickModeBar = document.getElementById('quickModeBar');
const btnOpenMoreModes = document.getElementById('btnOpenMoreModes');
const quickCountWrap = document.getElementById('quickCountWrap');
const quickCountInput = document.getElementById('quickCount');
const quickCountDec = document.getElementById('quickCountDec');
const quickCountInc = document.getElementById('quickCountInc');
const quickPlan = document.getElementById('quickPlan');
const btnToggleAdvanced = $('btnToggleAdvanced');
const advancedBox = $('advancedBox');
const btnOnlyRunning = $('btnOnlyRunning');
const btnPreviewDense = $('btnPreviewDense');
const btnLogBottom = $('btnLogBottom');
const concurrencyDec = $('concurrencyDec');
const concurrencyInc = $('concurrencyInc');
const rightTabButtons = Array.from(document.querySelectorAll('[data-tab]'));
const tabPanelTasks = $('tabPanelTasks');
const tabPanelPreview = $('tabPanelPreview');
const tabPanelLog = $('tabPanelLog');
const RIGHT_TAB_KEY = 'gen_right_tab';
const PREVIEW_SEEN_KEY = 'gen_preview_seen_v1';
const PREVIEW_FILTER_KEY = 'gen_preview_filter_v1';
const PREVIEW_DENSE_KEY = 'gen_preview_dense_v1';
const ADV_OPEN_KEY = 'gen_adv_open';
const LOG_MAX_CHARS = 4000;
const LOG_MAX_LINES = 120;
const LOG_STORE_LIMIT = 20000;
const DRAFT_KEY = 'gen_prompt_draft_v1';
let draftTimer = null;
let previewHintTimer = null;
let applyingMainFiles = false; // 防止 set files 触发 change 后递归
// 高级设置默认常驻显示:减少“展开/收起”这种额外操作(更符合自用高频工作流)
let advancedOpen = true;
// “生成份数/默认份数”按模式隔离:避免单次/同提示的份数污染分镜默认份数(分镜默认应为 1)
let batchConcurrencyByType = {};
let tasks = [];
let taskIdCounter = 1;
let roles = [];
let roleUi = { query: '', filter: 'all', sort: 'smart', dense: false };
let roleFavs = new Set(); // username set
let roleUsed = {}; // { [username]: lastUsedTs }
let attachedRoles = [];
let attachedRolesMulti = [];
let attachedRolesStoryboard = [];
let multiPrompts = [];
const multiPromptRoles = {};
// storyboardShots: { text, count, fileDataUrl, fileName, roles: [], useGlobalRoles?: boolean }
// useGlobalRoles=false 表示该分镜被手动排除:不再自动挂载“全局角色”(后续全局变更也不会影响它)
let storyboardShots = [];
const STORYBOARD_RUN_KEY = 'gen_storyboard_run_v1';
let storyboardRunCounter = parseInt(localStorage.getItem(STORYBOARD_RUN_KEY) || '0', 10) || 0;
let tagFilter = '';
// 上传文件预览状态(用于“模型/横竖/提示词为空”即时提醒)
let previewObjectUrl = null;
let lastPreviewSignature = '';
let lastPreviewInfo = null; // { w, h, orientation, isImage, isVideo }
let currentRecommendedModel = null;
const getAuthHeaders = () => {
const t = localStorage.getItem(authHeaderKey);
return t ? { Authorization: `Bearer ${t}` } : {};
};
const escapeAttr = (str = '') =>
str
.replace(/"/g, "'")
.replace(/'/g, ''')
.replace(/\s+/g, ' ')
.trim();
const escapeHtml = (str = '') => {
const s = String(str || '');
return s
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// 默认头像:纯本地 data URI(避免外链占位图被拦截/离线不可用)
const DEFAULT_ROLE_AVATAR = (() => {
const svg =
'';
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
})();
// URL 白名单:Sora/OpenAI 域名或常见媒体扩展名
const isValidMediaUrl = (u) => {
if (!u) return false;
const s = u.toString();
const domainOk = /(?:^https?:\/\/)?(?:videos\.openai\.com|oscdn\d*\.dyysy\.com)/i.test(s);
const extOk = /\.(mp4|webm|mov|m4v|mpg|mpeg|avi|gif|png|jpg|jpeg|webp)(?:\?|#|$)/i.test(s);
return domainOk || extOk;
};
// ===== 下载友好命名 & 同源 /tmp 重写(解决“哈希英文名 + 手动改名”痛点) =====
const padNum = (n, width = 2) => {
const v = Math.max(0, parseInt(String(n ?? '0'), 10) || 0);
const s = String(v);
return s.length >= width ? s : `${'0'.repeat(width)}${s}`.slice(-width);
};
const sanitizeFilename = (name, fallback = 'download') => {
let s = String(name || '').trim();
if (!s) return fallback;
// 去掉控制字符,避免 Windows/浏览器保存失败
s = s.replace(/[\u0000-\u001f\u007f]/g, '');
// Windows 禁用字符:\ / : * ? " < > |
s = s.replace(/[\\/:*?"<>|]/g, '-');
// 合并空白
s = s.replace(/\s+/g, ' ').trim();
// 不允许以点或空格结尾(Windows)
s = s.replace(/[. ]+$/g, '');
if (!s) return fallback;
// 控制长度,避免过长导致系统截断/失败(保守)
if (s.length > 120) s = s.slice(0, 120).trim();
return s || fallback;
};
const mediaExtFromUrl = (url, type = 'video') => {
const s = String(url || '');
const m = s.match(/\.([a-zA-Z0-9]{2,6})(?:[?#]|$)/);
const ext = m ? String(m[1]).toLowerCase() : '';
const ok = new Set(['mp4', 'mov', 'm4v', 'webm', 'gif', 'png', 'jpg', 'jpeg', 'webp']);
if (ok.has(ext)) return ext;
return type === 'image' ? 'png' : 'mp4';
};
const normalizeTmpDownloadUrl = (url) => {
// 目标:把 `http://127.0.0.1:8000/tmp/xxx.mp4` 统一重写成 `/tmp/xxx.mp4`
// 这样无论用户用 127.0.0.1 / 局域网 IP / 域名访问,都能同源下载并应用 download 文件名。
try {
const u = new URL(String(url || ''), window.location.href);
if (u && u.pathname && u.pathname.startsWith('/tmp/')) {
return u.pathname + (u.search || '');
}
} catch (_) {
/* ignore */
}
return String(url || '');
};
const buildDownloadFilename = (task, url, type = 'video', ordinal = 1) => {
const ty = String(type || '').toLowerCase() === 'image' ? 'image' : 'video';
const ext = mediaExtFromUrl(url, ty);
const id = task && typeof task.id === 'number' ? task.id : null;
// 分镜任务:按“分镜组标题 + 镜号/总数 + 第几份 + 任务ID”命名,便于批量后按名称排序
if (task && task.storyboard) {
const sb = task.storyboard || {};
const run = parseInt(String(sb.run || '0'), 10) || 0;
const idx = parseInt(String(sb.idx || '0'), 10) || 0;
const total = parseInt(String(sb.total || '0'), 10) || 0;
const take = parseInt(String(sb.take || '1'), 10) || 1;
const takes = parseInt(String(sb.takes || '1'), 10) || 1;
const titleRaw = String(sb.title || (run ? `分镜组${run}` : '分镜')).trim();
const title = sanitizeFilename(titleRaw, run ? `分镜组${run}` : '分镜');
const shotPart = idx ? `分镜${padNum(idx, 2)}${total ? `of${padNum(total, 2)}` : ''}` : `分镜${padNum(ordinal, 2)}`;
const takePart = takes > 1 ? `第${take}份` : '';
const idPart = id ? `T${id}` : '';
const parts = [title, shotPart, takePart, idPart].filter(Boolean);
return `${sanitizeFilename(parts.join('_'), '分镜')}.${ext}`;
}
// 普通任务:任务ID + 提示词片段(可选)
const prefix = id ? `任务${id}` : `${ty === 'image' ? '图片' : '视频'}${padNum(ordinal, 3)}`;
const hintRaw = task && task.promptSnippet ? String(task.promptSnippet).trim() : '';
const hint = hintRaw ? sanitizeFilename(hintRaw.slice(0, 26), '') : '';
return `${sanitizeFilename(hint ? `${prefix}_${hint}` : prefix, prefix)}.${ext}`;
};
const triggerBrowserDownload = (url, filename) => {
const href = normalizeTmpDownloadUrl(url);
if (!href) return false;
try {
const a = document.createElement('a');
a.href = href;
if (filename) a.download = String(filename);
a.rel = 'noreferrer';
// 不强制新标签:避免被浏览器当作“弹窗”拦截
a.target = '';
document.body.appendChild(a);
a.click();
a.remove();
return true;
} catch (_) {
return false;
}
};
const showToast = (msg, type = 'info', opts = {}) => {
const host = toastHost || document.body;
const safeType = ['info', 'success', 'error', 'warn'].includes(type) ? type : 'info';
const el = document.createElement('div');
el.className = `toast toast-${safeType}`;
const title = document.createElement('div');
title.className = 'title';
title.textContent =
opts.title ||
(safeType === 'success' ? '成功' : safeType === 'error' ? '出错了' : safeType === 'warn' ? '注意' : '提示');
const desc = document.createElement('div');
desc.className = 'desc';
desc.textContent = String(msg || '');
el.appendChild(title);
el.appendChild(desc);
const duration = typeof opts.duration === 'number' ? opts.duration : 1800;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
el.classList.remove('show');
setTimeout(() => el.parentNode && el.parentNode.removeChild(el), 220);
};
const timer = setTimeout(close, duration);
// 可选操作按钮:用于“轻提醒”,不打断输入流
if (opts.action && typeof opts.action === 'object' && opts.action.text && typeof opts.action.onClick === 'function') {
const actions = document.createElement('div');
actions.className = 'actions';
const btn = document.createElement('button');
btn.className = 'toast-action-btn';
btn.type = 'button';
btn.textContent = String(opts.action.text);
btn.addEventListener('click', (e) => {
e.stopPropagation();
clearTimeout(timer);
try {
opts.action.onClick();
} catch (_) {
/* ignore */
}
close();
});
actions.appendChild(btn);
el.appendChild(actions);
}
host.appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
el.addEventListener('click', () => {
clearTimeout(timer);
close();
});
};
const copyTextSafe = async (text) => {
const content = text || '';
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content);
return true;
}
} catch (_) {
/* fallback below */
}
// 兼容 HTTP / 非安全环境:使用隐藏 textarea
const ta = document.createElement('textarea');
ta.value = content;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch (_) {
ok = false;
}
ta.parentNode && document.body.removeChild(ta);
return ok;
};
let previewModalState = null; // { url, type, taskId }
let editStoryboardModalState = null; // { taskId }
const buildEmbedHtml = (url, type) => {
const u = String(url || '');
if (!u) return '';
return type === 'image'
? `
`
: ``;
};
const closePreviewModal = () => {
if (!previewModal) return;
previewModal.classList.remove('open');
previewModal.setAttribute('aria-hidden', 'true');
if (previewModalMedia) previewModalMedia.innerHTML = '';
previewModalState = null;
};
const openPreviewModal = (url, type = 'video', taskId = null) => {
if (!previewModal || !previewModalMedia) return;
if (!url || !isValidMediaUrl(url)) {
showToast('无效的预览链接', 'warn');
return;
}
const tid = taskId ? parseInt(String(taskId), 10) : null;
const t = tid ? tasks.find((x) => x.id === tid) : null;
const metaText = t && t.meta ? [t.meta.resolution, t.meta.duration, t.meta.info].filter(Boolean).join(' · ') : '';
const stage = t && t.wmStage ? String(t.wmStage) : '';
const attempt =
t && typeof t.wmAttempt === 'number' ? t.wmAttempt : t ? parseInt(String(t.wmAttempt || '0'), 10) || 0 : 0;
previewModalState = { url: String(url), type: type === 'image' ? 'image' : 'video', taskId: tid };
// Head: badges
if (previewModalTaskId) {
if (tid) {
previewModalTaskId.style.display = 'inline-flex';
previewModalTaskId.textContent = `任务 ${tid}`;
} else {
previewModalTaskId.style.display = 'none';
previewModalTaskId.textContent = '';
}
}
if (previewModalStoryboard) {
const sbLabel = t && t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
if (sbLabel) {
previewModalStoryboard.style.display = 'inline-flex';
previewModalStoryboard.textContent = sbLabel;
} else {
previewModalStoryboard.style.display = 'none';
previewModalStoryboard.textContent = '';
}
}
if (previewModalWatermark) {
if (stage) {
previewModalWatermark.style.display = 'inline-flex';
previewModalWatermark.textContent =
stage === 'cancelled'
? '已取消去水印'
: stage === 'ready'
? '无水印'
: `去水印中${attempt > 0 ? ` · ${attempt}` : ''}`;
} else {
previewModalWatermark.style.display = 'none';
previewModalWatermark.textContent = '';
}
}
// Head: meta line (kept simple; full URL still available via copy/open)
if (previewModalMeta) {
previewModalMeta.textContent = (metaText ? `${metaText} · ` : '') + String(url);
}
// Actions
if (previewModalDownload) {
const href = normalizeTmpDownloadUrl(String(url));
previewModalDownload.setAttribute('href', href);
try {
const filename = buildDownloadFilename(t, href, previewModalState.type, 1);
previewModalDownload.setAttribute('download', filename);
previewModalDownload.title = filename;
} catch (_) {
// 至少保证有 download 属性(无值时浏览器会用 URL 文件名)
previewModalDownload.setAttribute('download', '');
previewModalDownload.title = '下载';
}
}
if (btnPreviewLocateTask) {
btnPreviewLocateTask.disabled = !tid;
}
// 兜底:无论用户是否切到“预览”Tab,只要打开了预览弹层,就视为已读(避免红点反复冒出来)
if (tid) {
try {
markPreviewSeen(tid);
} catch (_) {
/* ignore */
}
updateUnreadDots();
}
// Body: media
previewModalMedia.innerHTML = '';
if (previewModalState.type === 'image') {
const img = document.createElement('img');
img.src = String(url);
img.alt = 'preview';
previewModalMedia.appendChild(img);
} else {
const v = document.createElement('video');
v.src = String(url);
v.controls = true;
v.autoplay = true;
v.muted = true;
v.loop = true;
v.playsInline = true;
previewModalMedia.appendChild(v);
}
// Open
previewModal.classList.add('open');
previewModal.setAttribute('aria-hidden', 'false');
};
const closeEditStoryboardModal = () => {
if (!editStoryboardModal) return;
editStoryboardModal.classList.remove('open');
editStoryboardModal.setAttribute('aria-hidden', 'true');
editStoryboardModalState = null;
if (editStoryboardTextarea) editStoryboardTextarea.value = '';
};
const rebuildStoryboardPromptSend = (oldSend, oldShotText, newShotText) => {
const send = String(oldSend || '');
const oldShot = String(oldShotText || '');
const next = String(newShotText || '');
if (!send) return next;
const sendTrim = send.replace(/\s+$/, '');
const oldTrim = oldShot.replace(/\s+$/, '');
if (oldTrim && sendTrim.endsWith(oldTrim)) {
return sendTrim.slice(0, sendTrim.length - oldTrim.length) + next;
}
if (oldTrim) {
const idx = sendTrim.lastIndexOf(oldTrim);
if (idx >= 0) {
return sendTrim.slice(0, idx) + next + sendTrim.slice(idx + oldTrim.length);
}
}
// Fallback: append as a new final segment, keeping old context intact.
return sendTrim + (sendTrim ? '\n\n' : '') + next;
};
const openEditStoryboardModal = (taskId) => {
if (!editStoryboardModal || !editStoryboardTextarea) return;
const tid = taskId ? parseInt(String(taskId), 10) : 0;
const t = tid ? tasks.find((x) => x.id === tid) : null;
if (!t || !t.storyboard) {
showToast('未找到该分镜任务', 'warn');
return;
}
const sbLabel = t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
if (editStoryboardModalBadge) {
if (sbLabel) {
editStoryboardModalBadge.style.display = 'inline-flex';
editStoryboardModalBadge.textContent = sbLabel;
} else {
editStoryboardModalBadge.style.display = 'none';
editStoryboardModalBadge.textContent = '';
}
}
if (editStoryboardModalMeta) {
editStoryboardModalMeta.textContent = sbLabel ? `修改分镜提示词(${sbLabel})` : '修改分镜提示词(仅影响当前分镜任务)';
}
editStoryboardModalState = { taskId: tid };
editStoryboardTextarea.value = String(t.promptUser || '');
editStoryboardModal.classList.add('open');
editStoryboardModal.setAttribute('aria-hidden', 'false');
setTimeout(() => {
try {
editStoryboardTextarea.focus();
const len = editStoryboardTextarea.value.length;
editStoryboardTextarea.setSelectionRange(len, len);
} catch (_) {
/* ignore */
}
}, 0);
};
const submitEditStoryboardModal = async () => {
if (!editStoryboardModalState || !editStoryboardTextarea) return;
const tid = editStoryboardModalState && editStoryboardModalState.taskId ? parseInt(String(editStoryboardModalState.taskId), 10) : 0;
const t = tid ? tasks.find((x) => x.id === tid) : null;
if (!t) {
closeEditStoryboardModal();
return;
}
const nextShotText = String(editStoryboardTextarea.value || '').trim();
if (!nextShotText) {
showToast('请先修改分镜提示词(不能为空)', 'warn');
return;
}
const apiKey = $('apiKey').value.trim();
const baseUrl = getBaseUrl();
if (!apiKey || !baseUrl) {
showToast('请先填写 API Key 和服务器地址');
return;
}
const nextSend = rebuildStoryboardPromptSend(t.promptSend, t.promptUser, nextShotText);
closeEditStoryboardModal();
showToast('已提交修改,正在重试该分镜…', 'info', { title: '分镜重试' });
await runJobs(
[
{
taskId: tid,
promptSend: nextSend,
promptUser: nextShotText,
file: null,
model: t.model || $('model').value,
storyboard: t.storyboard || null
}
],
apiKey,
baseUrl,
1
);
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const sleepCancellable = async (ms, shouldStop) => {
const end = Date.now() + Math.max(0, ms || 0);
while (Date.now() < end) {
if (shouldStop && shouldStop()) return false;
const left = end - Date.now();
await sleep(Math.min(250, Math.max(0, left)));
}
return !(shouldStop && shouldStop());
};
const formatBytes = (bytes) => {
const n = Number(bytes) || 0;
if (n <= 0) return '0B';
const units = ['B', 'KB', 'MB', 'GB'];
const idx = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
const val = n / Math.pow(1024, idx);
return `${val.toFixed(idx === 0 ? 0 : 2)}${units[idx]}`;
};
const detectOrientation = (w, h) => {
const ww = Number(w) || 0;
const hh = Number(h) || 0;
if (!ww || !hh) return '';
if (Math.abs(ww - hh) <= 2) return 'square';
return ww > hh ? 'landscape' : 'portrait';
};
const parseModelId = (m = '') => {
const model = String(m || '');
const isVideo = model.startsWith('sora-video');
const isImage = model.startsWith('sora-image');
const orientation = /portrait/i.test(model) ? 'portrait' : /landscape/i.test(model) ? 'landscape' : '';
const duration = /15s/i.test(model) ? '15s' : /10s/i.test(model) ? '10s' : '';
return { isVideo, isImage, orientation, duration };
};
const getSelectedModelLabel = () => {
const sel = $('model');
return sel && sel.selectedOptions && sel.selectedOptions[0] ? sel.selectedOptions[0].textContent.trim() : $('model')?.value || '';
};
const setBannerText = (text) => {
if (!uxBanner) return;
const msg = (text || '').trim();
if (!msg) {
uxBanner.style.display = 'none';
uxBanner.textContent = '';
return;
}
uxBanner.textContent = msg;
uxBanner.style.display = 'block';
};
const clearPreviewObjectUrl = () => {
try {
if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl);
} catch (_) {
/* ignore */
}
previewObjectUrl = null;
lastPreviewSignature = '';
lastPreviewInfo = null;
};
const getImageSize = (src) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 });
img.onerror = () => resolve(null);
img.src = src;
});
const renderChips = (el, items) => {
if (!el) return;
el.innerHTML = '';
(items || []).forEach((it) => {
const chip = document.createElement('span');
const cls = it.kind ? `chip ${it.kind}` : 'chip';
chip.className = cls;
chip.textContent = it.text || '';
el.appendChild(chip);
});
};
const humanizeUpstreamError = (raw) => {
const text = String(raw?.message || raw?.error?.message || raw || '').trim();
// 尝试从 “API request failed: 400 - {json}” 中提取 JSON
let inner = null;
const jsonStart = text.indexOf('{');
if (jsonStart >= 0) {
const maybe = text.slice(jsonStart);
try {
inner = JSON.parse(maybe);
} catch (_) {
inner = null;
}
}
const err = inner && inner.error ? inner.error : raw && raw.error ? raw.error : null;
const code = err && err.code ? String(err.code) : '';
const param = err && err.param ? String(err.param) : '';
const msg = err && err.message ? String(err.message) : '';
const merged = (msg || text || '').trim();
// 典型:地区限制(用户最常见困惑点之一)
const ccFromText = (() => {
const m = merged.match(/\(([A-Za-z]{2})\)/);
return m ? m[1] : '';
})();
if (
code === 'unsupported_country_code' ||
/not available in your country/i.test(merged) ||
/国家\/地区不可用|地区不可用|Sora.*不可用/i.test(merged)
) {
const cc = param || ccFromText || '未知';
return {
type: 'error',
title: '地区限制',
message: `Sora 在你当前网络出口地区不可用(${cc})。\n解决:切换代理/机房到支持地区后再试。`
};
}
// 典型:Cloudflare challenge(Sora 网页端经常触发)
if (/Just a moment|Enable JavaScript and cookies to continue|__cf_bm|cloudflare/i.test(text)) {
return {
type: 'error',
title: 'Cloudflare 拦截',
message: '触发 Cloudflare 风控拦截。\n解决:更换更“干净”的出口 IP/代理,或降低并发与请求频率。'
};
}
// 兜底:把 JSON 里的 error.message 拿出来
if (merged) {
return {
type: /warn|limit|blocked|guardrail|违规|不支持|限制/i.test(merged) ? 'warn' : 'error',
title: '生成失败',
message: merged
};
}
return { type: 'error', title: '生成失败', message: '未知错误(上游未返回可读信息)' };
};
// 内容政策/审查命中:用于分镜兜底(出现审查报错时提供“修改分镜提示词”按钮)
const isContentPolicyViolation = (raw) => {
const s = String(raw || '').trim();
if (!s) return false;
return (
/Content Policy Violation/i.test(s) ||
/may violate our content policies/i.test(s) ||
/content policies?/i.test(s) && /violate|violation/i.test(s) ||
/内容.*(政策|审核|审查)/.test(s) ||
/审核未通过|审查未通过|内容不合规|内容违规/.test(s)
);
};
const renderFilePreview = async () => {
if (!filePreviewBox || !filePreviewMedia || !filePreviewName || !filePreviewKind || !filePreviewMeta || !filePreviewHints) return;
const files = Array.from((fileInput?.files && fileInput.files.length ? fileInput.files : []) || []);
const promptText = (promptBox?.value || '').trim();
const modelId = $('model')?.value || '';
const modelInfo = parseModelId(modelId);
currentRecommendedModel = null;
if (btnUseRecommendedModel) btnUseRecommendedModel.style.display = 'none';
if (!files.length) {
filePreviewBox.style.display = 'none';
filePreviewMedia.innerHTML = '';
filePreviewName.textContent = '未选择文件';
filePreviewKind.textContent = '素材';
filePreviewMeta.textContent = '';
renderChips(filePreviewHints, []);
setBannerText('');
clearPreviewObjectUrl();
notifyHeight();
return;
}
filePreviewBox.style.display = 'flex';
const imgCount = files.filter((f) => (f.type || '').startsWith('image')).length;
const vidCount = files.filter((f) => (f.type || '').startsWith('video')).length;
const mixed = imgCount > 0 && vidCount > 0;
const first = files[0];
const name = first?.name || '未命名文件';
filePreviewName.textContent = files.length > 1 ? `${files.length} 个文件(${name} 等)` : name;
// 素材类型标签
const kindText = mixed ? `混合(${imgCount}图/${vidCount}视频)` : vidCount ? `视频(${vidCount})` : `图片(${imgCount})`;
filePreviewKind.textContent = kindText;
const signature = `${files.length}:${name}:${first.size}:${first.lastModified}:${first.type}`;
const isImage = (first.type || '').startsWith('image');
const isVideo = (first.type || '').startsWith('video');
const needReload = signature !== lastPreviewSignature || !previewObjectUrl || !filePreviewMedia.firstChild;
let w = 0;
let h = 0;
let orientation = '';
// 预览媒体:只有文件变化才重新创建 objectURL,避免输入提示词时闪烁/浪费
if (needReload) {
// 清理旧预览
if (previewObjectUrl) {
try {
URL.revokeObjectURL(previewObjectUrl);
} catch (_) {
/* ignore */
}
}
previewObjectUrl = URL.createObjectURL(first);
lastPreviewSignature = signature;
lastPreviewInfo = null;
filePreviewMedia.innerHTML = '';
if (isImage) {
const imgEl = document.createElement('img');
imgEl.src = previewObjectUrl;
imgEl.alt = 'upload preview';
filePreviewMedia.appendChild(imgEl);
const size = await getImageSize(previewObjectUrl);
if (size) {
w = size.w;
h = size.h;
orientation = detectOrientation(w, h);
}
lastPreviewInfo = { w, h, orientation, isImage: true, isVideo: false };
} else if (isVideo) {
const v = document.createElement('video');
v.src = previewObjectUrl;
v.controls = true;
v.muted = true;
v.playsInline = true;
v.preload = 'metadata';
filePreviewMedia.appendChild(v);
lastPreviewInfo = { w: 0, h: 0, orientation: '', isImage: false, isVideo: true };
// 尽力拿到分辨率(不阻塞 UI)
v.addEventListener(
'loadedmetadata',
() => {
const vw = v.videoWidth || 0;
const vh = v.videoHeight || 0;
const o = detectOrientation(vw, vh);
const base = filePreviewMeta.textContent || '';
const extra = vw && vh ? ` · ${vw}x${vh}${o ? `(${o === 'portrait' ? '竖' : o === 'landscape' ? '横' : '方'})` : ''}` : '';
if (extra && !base.includes(`${vw}x${vh}`)) {
filePreviewMeta.textContent = base + extra;
notifyHeight();
}
},
{ once: true }
);
} else {
filePreviewMedia.innerHTML = `
无法预览该文件类型
`;
lastPreviewInfo = { w: 0, h: 0, orientation: '', isImage: false, isVideo: false };
}
} else if (lastPreviewInfo) {
w = lastPreviewInfo.w || 0;
h = lastPreviewInfo.h || 0;
orientation = lastPreviewInfo.orientation || '';
}
const sizeText = formatBytes(first.size);
const dimText = w && h ? `${w}x${h}` : '';
const orientationText = orientation === 'portrait' ? '竖' : orientation === 'landscape' ? '横' : orientation === 'square' ? '方' : '';
const modelLabel = getSelectedModelLabel();
filePreviewMeta.textContent = [
`当前模型:${modelLabel}`,
`文件:${sizeText}`,
dimText ? `分辨率:${dimText}${orientationText ? `(${orientationText})` : ''}` : ''
]
.filter(Boolean)
.join(' · ');
// 推荐模型:仅对“图片首帧”特别提示横竖匹配(最常见困惑点)
if (isImage && orientation) {
if (modelInfo.isVideo) {
const dur = modelInfo.duration || '15s';
if (orientation === 'portrait') currentRecommendedModel = `sora-video-portrait-${dur}`;
if (orientation === 'landscape') currentRecommendedModel = `sora-video-landscape-${dur}`;
// square 不强推
} else if (modelInfo.isImage) {
if (orientation === 'portrait') currentRecommendedModel = 'sora-image-portrait';
if (orientation === 'landscape') currentRecommendedModel = 'sora-image-landscape';
if (orientation === 'square') currentRecommendedModel = 'sora-image';
}
if (currentRecommendedModel && currentRecommendedModel !== modelId && btnUseRecommendedModel) {
btnUseRecommendedModel.style.display = 'inline-flex';
}
}
const chips = [];
if (mixed) chips.push({ text: '混合选择:建议不要图/视频混用(容易跑偏)', kind: 'warn' });
if (modelInfo.isImage && vidCount > 0) chips.push({ text: '图片模型 + 视频素材:视频不会被使用', kind: 'warn' });
if (modelInfo.isVideo && imgCount > 0 && !promptText) chips.push({ text: '图片首帧但提示词为空:结果可能与图无关', kind: 'warn' });
if (currentRecommendedModel && currentRecommendedModel !== modelId) chips.push({ text: `推荐模型:${currentRecommendedModel}`, kind: 'info' });
if (!chips.length) chips.push({ text: '已就绪', kind: 'ok' });
renderChips(filePreviewHints, chips);
// Banner:只保留最关键一句,避免信息噪声
if (modelInfo.isVideo && imgCount > 0 && !promptText) {
setBannerText('提示:你上传了图片但没写提示词。图片只是“参考/首帧”,建议补一句你希望画面发生什么(动作/镜头/风格),否则容易跑偏。');
} else if (modelInfo.isImage && vidCount > 0) {
setBannerText('提示:你上传的是视频,但当前模型是“图片”。视频不会参与生成;请切换到视频模型或换成图片文件。');
} else if (mixed) {
setBannerText('提示:你同时选了图片和视频。建议分开跑(同一批只放同类型文件),可减少异常与不相关结果。');
} else {
setBannerText('');
}
notifyHeight();
};
const showBubble = (msg, anchor) => {
const host = document.getElementById('logActions') || anchor?.parentElement || document.body;
const bubble = document.createElement('div');
bubble.className = 'bubble-toast';
bubble.textContent = msg;
host.appendChild(bubble);
requestAnimationFrame(() => bubble.classList.add('show'));
setTimeout(() => {
bubble.classList.remove('show');
setTimeout(() => bubble.parentNode && bubble.parentNode.removeChild(bubble), 180);
}, 1200);
};
const notifyHeight = () => {
try {
const page = document.querySelector('.page');
const h = page
? Math.ceil((page.getBoundingClientRect()?.height || 0) + (page.offsetTop || 0))
: Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0);
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'sora-generate-height', height: h }, '*');
}
} catch (_) {
/* ignore */
}
};
// ===== 预览未读红点:基于“任务 id 是否已看过” =====
const getCurrentPreviewTaskIds = () =>
(Array.isArray(tasks) ? tasks : [])
.filter((t) => t && t.url)
.map((t) => t.id)
.filter((id) => typeof id === 'number' && id > 0);
const prunePreviewSeenTaskIds = () => {
const existing = new Set((Array.isArray(tasks) ? tasks : []).map((t) => t.id).filter((id) => typeof id === 'number'));
previewSeenTaskIds = new Set(Array.from(previewSeenTaskIds).filter((id) => existing.has(id)));
};
const persistPreviewSeenTaskIds = () => {
try {
prunePreviewSeenTaskIds();
localStorage.setItem(PREVIEW_SEEN_KEY, JSON.stringify(Array.from(previewSeenTaskIds.values())));
} catch (_) {
/* ignore */
}
};
const loadPreviewSeenTaskIds = () => {
try {
const raw = localStorage.getItem(PREVIEW_SEEN_KEY) || '[]';
const arr = JSON.parse(raw);
previewSeenTaskIds = new Set(
Array.isArray(arr)
? arr
.map((x) => parseInt(String(x), 10))
.filter((n) => !isNaN(n) && n > 0)
: []
);
} catch (_) {
previewSeenTaskIds = new Set();
}
prunePreviewSeenTaskIds();
};
const markPreviewSeen = (taskId) => {
const id = typeof taskId === 'number' ? taskId : parseInt(String(taskId || '0'), 10);
if (!id) return;
previewSeenTaskIds.add(id);
persistPreviewSeenTaskIds();
};
const markAllPreviewsSeen = () => {
getCurrentPreviewTaskIds().forEach((id) => previewSeenTaskIds.add(id));
persistPreviewSeenTaskIds();
};
const hasUnseenPreviews = () => getCurrentPreviewTaskIds().some((id) => !previewSeenTaskIds.has(id));
// ===== 预览过滤(全部/视频/图片/分镜)=====
const normalizePreviewFilter = (v) => {
const s = String(v || '').toLowerCase();
return s === 'video' || s === 'image' || s === 'storyboard' ? s : 'all';
};
const previewFilterLabel = (f) =>
f === 'video' ? '视频' : f === 'image' ? '图片' : f === 'storyboard' ? '分镜' : '全部';
let previewFilter = normalizePreviewFilter(localStorage.getItem(PREVIEW_FILTER_KEY) || 'all');
const taskMatchesPreviewFilter = (t, f) => {
const filter = normalizePreviewFilter(f);
if (!t) return false;
if (filter === 'all') return true;
if (filter === 'storyboard') return (t.tag || '') === 'storyboard' || !!t.storyboard;
const ty = String(t.type || '').toLowerCase();
return filter === 'video' ? ty === 'video' : filter === 'image' ? ty === 'image' : true;
};
const syncPreviewFilterButtons = () => {
if (!previewFilterBar) return;
previewFilterBar.querySelectorAll('[data-preview-filter]').forEach((btn) => {
const val = normalizePreviewFilter(btn.getAttribute('data-preview-filter') || 'all');
btn.classList.toggle('active', val === previewFilter);
});
};
const setPreviewFilter = (next, opts = {}) => {
const persist = !(opts && opts.persist === false);
const render = !(opts && opts.render === false);
const toast = !!(opts && opts.toast);
const f = normalizePreviewFilter(next);
if (f === previewFilter) return;
previewFilter = f;
if (persist) {
try {
localStorage.setItem(PREVIEW_FILTER_KEY, previewFilter);
} catch (_) {
/* ignore */
}
}
syncPreviewFilterButtons();
if (render) renderPreviews();
if (toast) showToast(`预览过滤:${previewFilterLabel(previewFilter)}`, 'info', { duration: 1400 });
};
const updateUnreadDots = () => {
const setDot = (tab, on) => {
const btn = rightTabButtons.find((b) => b.getAttribute('data-tab') === tab);
const dot = btn?.querySelector('.dot');
btn?.classList.toggle('has-unread', on);
if (dot) dot.style.display = on ? 'block' : 'none';
};
const previewUnread = hasUnseenPreviews() && currentRightTab !== 'preview';
const logUnread = logVersion > logSeenVersion && currentRightTab !== 'log';
setDot('tasks', unread.tasks && currentRightTab !== 'tasks');
setDot('preview', previewUnread);
setDot('log', logUnread);
};
const appendLog = (text) => {
const line = `[${new Date().toLocaleTimeString()}] ${text}`;
const existing = (out.textContent || '').split('\n').filter(Boolean);
existing.push(line);
const trimmed = existing.slice(-LOG_MAX_LINES).join('\n');
out.textContent = trimmed.slice(-LOG_MAX_CHARS) + '\n';
out.scrollTop = out.scrollHeight;
logVersion += 1;
if (currentRightTab === 'log') {
logSeenVersion = logVersion;
}
updateUnreadDots();
};
const log = (msg) => appendLog(msg);
const logTask = (taskId, msg) => {
appendLog(`任务#${taskId} | ${msg}`);
taskLogBuffer[taskId] = (taskLogBuffer[taskId] || '') + `[${new Date().toLocaleTimeString()}] ${msg}\n`;
const t = tasks.find((x) => x.id === taskId);
if (t) {
const mergedLog = (t.logFull || '') + '\n' + `[${new Date().toLocaleTimeString()}] ${msg}`;
updateTask(taskId, { logFull: mergedLog });
}
};
const getTaskLogText = (t) => {
if (!t) return '';
const merged =
(taskLogBuffer[t.id] || '')
.split('\n')
.filter(Boolean)
.join('\n') ||
t.logFull ||
t.logTail ||
'';
return merged.trim();
};
const renderLogPanel = () => {
if (!logListContainer || !logDetailContent) return;
if (!tasks.length) {
logListContainer.innerHTML = '暂无任务
';
logDetailId.textContent = '';
logDetailStatus.textContent = '';
logDetailMeta.textContent = '';
logDetailContent.textContent = '暂无日志';
return;
}
// 确保当前选中任务合法
if (!currentLogTaskId || !tasks.find((t) => t.id === currentLogTaskId)) {
currentLogTaskId = tasks[0].id;
}
// 渲染左侧列表
const statusMap = {
queue: '排队中',
running: '生成中',
retrying: '重试中',
done: '已完成',
error: '失败',
stalled: '中断',
character_done: '角色卡成功',
character_error: '角色卡失败'
};
logListContainer.innerHTML = tasks
.map((t) => {
const active = t.id === currentLogTaskId;
const statusText =
t.type === 'character'
? t.status === 'done'
? statusMap.character_done
: statusMap.character_error
: statusMap[t.status] || '未知';
const msg = t.message || '';
return `
#${t.id}
${statusText}
${escapeHtml(t.promptSnippet || '(空提示)')}
${msg ? `
${escapeHtml(msg)}
` : ''}
`;
})
.join('');
logListContainer.querySelectorAll('[data-logitem]').forEach((el) => {
el.addEventListener('click', () => {
const id = parseInt(el.getAttribute('data-logitem'), 10);
if (!isNaN(id)) {
currentLogTaskId = id;
renderLogPanel();
}
});
});
// 渲染右侧详情
const current = tasks.find((t) => t.id === currentLogTaskId) || tasks[0];
if (current) {
const statusText =
current.type === 'character'
? current.status === 'done'
? statusMap.character_done
: statusMap.character_error
: statusMap[current.status] || '未知';
logDetailId.textContent = `#${current.id}`;
logDetailStatus.textContent = statusText;
logDetailMeta.textContent =
(current.meta && [current.meta.resolution, current.meta.duration, current.meta.info].filter(Boolean).join(' · ')) ||
current.message ||
'';
logDetailContent.textContent = getTaskLogText(current) || '暂无日志';
logDetailContent.scrollTop = logDetailContent.scrollHeight;
}
};
const renderTaskLogContent = renderLogPanel;
const renderTaskLogList = renderLogPanel;
const setTaskCount = () => {
taskCount.textContent = `${tasks.length} 个任务`;
};
const renderTasks = () => {
const baseList = onlyRunning
? tasks.filter((t) => t.status === 'running' || t.status === 'retrying' || t.status === 'queue' || t.status === 'stalled')
: tasks;
const byTag = tagFilter
? baseList.filter((t) => (tagFilter === 'storyboard' ? (t.tag === 'storyboard' || !!t.storyboard) : false))
: baseList;
const filtered = statusFilter
? byTag.filter((t) =>
statusFilter === 'running' ? t.status === 'running' || t.status === 'retrying' : t.status === statusFilter
)
: byTag;
const counts = {
running: tasks.filter((t) => t.status === 'running' || t.status === 'retrying').length,
queue: tasks.filter((t) => t.status === 'queue').length,
done: tasks.filter((t) => t.status === 'done').length,
error: tasks.filter((t) => t.status === 'error').length
};
const tagCounts = {
storyboard: tasks.filter((t) => t.tag === 'storyboard' || !!t.storyboard).length
};
const totalCount = tasks.length;
const hiddenCount = baseList.length - filtered.length;
const groupBar = `
标签
${hiddenCount > 0 ? `已隐藏 ${hiddenCount} 条不匹配的任务
` : ''}
`;
const html = filtered
.map((t) => {
const statusText =
t.timedOut
? '网络超时'
: t.type === 'character' && t.status === 'done'
? '角色卡创建成功'
: t.type === 'character' && t.status === 'error'
? '角色卡创建失败'
: (() => {
const retryCount =
typeof t.retryCount === 'number' ? t.retryCount : parseInt(String(t.retryCount || '0'), 10) || 0;
const statusMap = {
queue: '排队中',
running: '生成中',
retrying: `重试中${retryCount > 0 ? ` · ${retryCount}` : ''}`,
done: '已完成',
error: '失败',
stalled: '中断'
};
return statusMap[t.status] || '未知';
})();
const statusClass = `status ${t.timedOut ? 'timedout' : t.status}`;
const msg = t.message || '';
const msgColor = t.status === 'retrying' ? '#b45309' : '#f87171';
const metaText = t.meta ? [t.meta.resolution, t.meta.duration].filter(Boolean).join(' · ') : '';
const stepIdx = t.status === 'queue' ? 1 : t.status === 'running' || t.status === 'retrying' ? 2 : 3;
const stepClass = t.status === 'error' ? 'error' : 'active';
const missingUrlWarn =
t.type !== 'character' && t.status === 'done' && !t.url
? '未返回视频链接,可能生成失败或后端未返回地址
'
: '';
const progress = t.progress ?? (t.status === 'done' ? 100 : 0);
const safeTitle = escapeAttr(t.promptUser || t.promptSnippet || '-');
const displayTitle = escapeHtml(t.promptSnippet || '-');
const safeMsg = escapeHtml(msg);
const metaChip = metaText ? `${escapeHtml(metaText)}` : '';
const sb = t.storyboard;
const policyHit =
t.status === 'error' &&
(t.errorKind === 'policy' ||
isContentPolicyViolation(t.message || '') ||
isContentPolicyViolation(t.logTail || '') ||
isContentPolicyViolation(String(t.logFull || '').slice(-800)));
const canEditStoryboardPrompt = !!(policyHit && sb && sb.label);
const sbChip =
sb && sb.label
? `${escapeHtml(sb.label)}`
: '';
const sbTitleChip =
sb && sb.title
? `${escapeHtml(sb.title)}`
: '';
const wmStage = t.wmStage ? String(t.wmStage) : '';
const wmAttempt =
typeof t.wmAttempt === 'number' ? t.wmAttempt : parseInt(String(t.wmAttempt || '0'), 10) || 0;
const wmLabel = wmStage
? wmStage === 'cancelled'
? '已取消去水印'
: wmStage === 'ready'
? '无水印已就绪'
: '等待去水印'
: '';
const wmChip = wmStage
? `${wmLabel}${wmAttempt > 0 ? ` · ${wmAttempt}` : ''}`
: '';
const progressWidth = Math.max(0, Math.min(100, progress));
if (t.collapsed && t.status === 'done') {
return `
任务 ${t.id}
${sbChip}
${wmChip}
${statusText}
${metaChip}
${sbTitleChip}
${displayTitle}
已折叠,点击展开查看详情
${t.url ? `` : ''}
`;
}
return `
任务 ${t.id}
${sbChip}
${wmChip}
${statusText}
${metaChip}
${sbTitleChip}
${displayTitle}
${safeMsg}
${missingUrlWarn}
进度:${progress}%
排队 · 生成 · 完成
${t.url ? `` : ''}
${
t.status === 'running' && t.wmCanCancel && t.remoteTaskId
? ``
: ''
}
${canEditStoryboardPrompt ? `` : ''}
${
t.status === 'retrying' &&
t.retryMode === 'submit' &&
(typeof t.retryCount === 'number' ? t.retryCount : parseInt(String(t.retryCount || '0'), 10) || 0) >= 3
? ``
: ''
}
${t.timedOut || t.status === 'error' || (!t.url && t.status === 'done') ? `` : ''}
${t.status === 'stalled' ? `` : ''}
${t.promptUser ? `` : ''}
`;
})
.join('');
taskList.innerHTML = groupBar + (html || '暂无任务
');
const flashCard = (btn) => {
const card = btn.closest('.task-card');
if (!card) return;
card.classList.add('flash', 'flash-bg');
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => card.classList.remove('flash', 'flash-bg'), 800);
};
const smoothFocus = (el) => {
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (el.focus) el.focus({ preventScroll: true });
el.classList.add('flash-bg');
setTimeout(() => el.classList.remove('flash-bg'), 600);
};
const flashPreview = (url, info = null) => {
setRightTab('preview');
try {
// 若当前过滤会把目标结果隐藏,则自动切换到可见的过滤条件(避免“点了查看但预览空白”)
const tid = info && typeof info.taskId === 'number' ? info.taskId : null;
const hintType = info && info.type ? String(info.type) : '';
const t = tid ? tasks.find((x) => x.id === tid) : tasks.find((x) => x && x.url === url);
const desired =
t && ((t.tag || '') === 'storyboard' || t.storyboard)
? 'storyboard'
: String((t && t.type) || hintType || '').toLowerCase() === 'image'
? 'image'
: 'video';
if (t && !taskMatchesPreviewFilter(t, previewFilter)) {
setPreviewFilter(desired, { toast: true });
} else {
// 兜底:确保 DOM 已按当前过滤重建
renderPreviews();
}
} catch (_) {
renderPreviews();
}
requestAnimationFrame(() => {
const cards = Array.from(previewGrid.querySelectorAll('.preview-card'));
const target = cards.find((c) => {
const media = c.querySelector('video,img');
return media && media.getAttribute('src') === url;
});
const el = target || previewGrid;
cards.forEach((c) => c.classList.remove('spotlight'));
el.classList.add('spotlight');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => el.classList.remove('spotlight'), 1300);
});
};
taskList.querySelectorAll('.link-btn[data-url]').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url');
const type = btn.getAttribute('data-type');
const card = btn.closest('.task-card');
const tid = card ? parseInt(card.getAttribute('data-id'), 10) : null;
flashPreview(url, { taskId: !isNaN(tid) ? tid : null, type });
flashCard(btn);
});
});
taskList.querySelectorAll('[data-reuse]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-reuse'), 10);
const t = tasks.find((x) => x.id === id);
if (t && t.promptUser) {
promptBox.value = t.promptUser;
analyzePromptHints();
showToast('提示已填充');
smoothFocus(promptBox);
flashCard(btn);
}
});
});
taskList.querySelectorAll('[data-edit-storyboard]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-edit-storyboard'), 10);
if (!id) return;
openEditStoryboardModal(id);
flashCard(btn);
});
});
taskList.querySelectorAll('[data-abort-retry]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-abort-retry'), 10);
const ctl = taskRetryControls.get(id);
if (ctl) {
ctl.cancelled = true;
try {
if (typeof ctl.abortFetch === 'function') ctl.abortFetch();
} catch (_) {
/* ignore */
}
}
updateTask(id, { status: 'error', message: '已中断自动重试(可点击“重试”再次发起)' });
showToast('已中断自动重试', 'warn', { title: '已中断' });
flashCard(btn);
});
});
taskList.querySelectorAll('[data-retry]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.getAttribute('data-retry'), 10);
const t = tasks.find((x) => x.id === id);
const apiKey = $('apiKey').value.trim();
const baseUrl = getBaseUrl();
if (!apiKey || !baseUrl) {
showToast('请先填写 API Key 和服务器地址');
return;
}
if (!t) {
showToast('未找到该任务,无法重试', 'error', { title: '重试失败', duration: 2600 });
return;
}
const job = {
taskId: id,
promptSend: t.promptSend || '',
promptUser: t.promptUser || '',
// 允许“空提示词 + 仅素材”的任务重试:素材仅保留在内存(刷新后不保证存在)
file: t._inputFile || null,
fileDataUrl: t._inputFileDataUrl || null,
model: t.model || $('model').value,
storyboard: t.storyboard || null
};
if (!job.promptSend && !job.file && !job.fileDataUrl) {
showToast('该任务没有可复用的提示词/素材,仍将尝试重试(可能失败)', 'warn', {
title: '空输入重试',
duration: 4200
});
} else if (!job.promptSend && (job.file || job.fileDataUrl)) {
showToast('空提示词重试:将只带素材提交(允许)', 'info', { title: '正在重试', duration: 2200 });
} else {
showToast('正在重试该任务', 'info');
}
await runJobs(
[job],
apiKey,
baseUrl,
1
);
flashCard(btn);
});
});
taskList.querySelectorAll('[data-continue]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.getAttribute('data-continue'), 10);
const t = tasks.find((x) => x.id === id);
const apiKey = $('apiKey').value.trim();
const baseUrl = getBaseUrl();
if (!apiKey || !baseUrl) {
showToast('请先填写 API Key 和服务器地址');
return;
}
if (!t) {
showToast('未找到该任务,无法继续', 'error', { title: '继续失败', duration: 2600 });
return;
}
const job = {
taskId: id,
promptSend: t.promptSend || '',
promptUser: t.promptUser || '',
file: t._inputFile || null,
fileDataUrl: t._inputFileDataUrl || null,
model: t.model || $('model').value,
storyboard: t.storyboard || null
};
if (!job.promptSend && !job.file && !job.fileDataUrl) {
showToast('该任务没有可复用的提示词/素材,仍将尝试继续(可能失败)', 'warn', {
title: '空输入继续',
duration: 4200
});
} else if (!job.promptSend && (job.file || job.fileDataUrl)) {
showToast('空提示词继续:将只带素材提交(允许)', 'info', { title: '正在继续', duration: 2200 });
} else {
showToast('正在继续该任务', 'info');
}
await runJobs(
[job],
apiKey,
baseUrl,
1
);
flashCard(btn);
});
});
taskList.querySelectorAll('[data-log]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-log'), 10);
const t = tasks.find((x) => x.id === id);
if (t) {
currentLogTaskId = t.id;
renderTaskLogList();
renderTaskLogContent();
setRightTab('log');
smoothFocus(logTaskPanel || out);
} else {
showToast('未找到该任务日志');
}
flashCard(btn);
});
});
taskList.querySelectorAll('[data-cancel-wm]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.getAttribute('data-cancel-wm'), 10);
const t = tasks.find((x) => x.id === id);
if (!t || !t.remoteTaskId) {
showToast('缺少 task_id,无法取消去水印等待');
return;
}
const apiKey = $('apiKey').value.trim();
const baseUrl = getBaseUrl();
if (!apiKey || !baseUrl) {
showToast('请先填写 API Key 和服务器地址');
return;
}
if (t.wmCancelling) return;
updateTask(id, { wmCancelling: true });
try {
const resp = await fetch(
`${baseUrl}/v1/tasks/${encodeURIComponent(t.remoteTaskId)}/watermark/cancel`,
{
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json'
}
}
);
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
showToast('已发送取消去水印请求', 'success');
} catch (e) {
updateTask(id, { wmCancelling: false });
showToast(`取消失败: ${e?.message || String(e)}`, 'error');
}
flashCard(btn);
});
});
taskList.querySelectorAll('[data-expand]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-expand'), 10);
updateTask(id, { collapsed: false });
});
});
taskList.querySelectorAll('[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.getAttribute('data-filter') || '';
statusFilter = statusFilter === target ? '' : target;
renderTasks();
});
});
taskList.querySelectorAll('[data-tag]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.getAttribute('data-tag') || '';
tagFilter = tagFilter === target ? '' : target;
renderTasks();
});
});
setTaskCount();
updateTaskBubble();
// 日志面板只有在用户正在查看时才更新,避免流式更新导致每个 chunk 都重绘日志列表
if (currentRightTab === 'log') renderLogPanel();
// 任务状态同步给管理台(任务球/抽屉),用节流发送避免流式每个 chunk 都跨 iframe 重绘
schedulePostTaskState({ immediate: true });
};
const renderPreviews = () => {
if (!previewGrid) return;
const fullList = tasks.filter((t) => t && t.url && isValidMediaUrl(t.url));
const list = fullList.filter((t) => taskMatchesPreviewFilter(t, previewFilter));
previewGrid.innerHTML = '';
// 防止 URL 去重集合无限增长(任务多、URL 长时会占内存)
try {
const limit = 1200;
while (previewKnown.size > limit) {
const first = previewKnown.values().next().value;
previewKnown.delete(first);
}
} catch (_) {
/* ignore */
}
if (previewCount) {
const nextText = !fullList.length
? ''
: `显示 ${list.length}/${fullList.length}${previewFilter === 'all' ? '' : ` · ${previewFilterLabel(previewFilter)}`}`;
const prevText = previewCountLastText || (previewCount.textContent || '');
if (prevText !== nextText) {
previewCount.textContent = nextText;
previewCountLastText = nextText;
try {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) throw new Error('reduced-motion');
if (nextText) {
// 轻防抖:避免流式频繁重绘导致“闪烁噪声”
if (previewCountFlashTimer) clearTimeout(previewCountFlashTimer);
previewCount.classList.remove('count-flash');
void previewCount.offsetWidth;
previewCount.classList.add('count-flash');
previewCountFlashTimer = setTimeout(() => {
try {
previewCount.classList.remove('count-flash');
} catch (_) {}
previewCountFlashTimer = null;
}, 1900);
}
} catch (_) {
/* ignore */
}
}
}
if (fullList.length === 0) {
// 预览为空:清空 URL 去重集合即可;未读红点由“已看过的任务 id 集合”控制
previewGrid.innerHTML = '暂无预览结果。生成完成后会在这里出现。
';
previewsHydrated = true;
updateUnreadDots();
return;
}
if (list.length === 0) {
previewGrid.innerHTML =
'当前过滤条件下暂无结果。可切换到“全部”查看。
';
previewsHydrated = true;
updateUnreadDots();
return;
}
// Tasks are stored newest-first (unshift). We render oldest-first and prepend each card,
// so the final DOM order stays newest-first.
list
.slice()
.reverse()
.forEach((t) => {
const metaText = t.meta ? [t.meta.resolution, t.meta.duration, t.meta.info].filter(Boolean).join(' · ') : '';
addPreviewCard(t.url, t.type, false, metaText || null, t.id);
});
previewsHydrated = true;
updateUnreadDots();
};
const addPreviewCard = (url, type = 'video', push = true, meta = null, taskId = null) => {
if (!url || !isValidMediaUrl(url)) return false;
const exists = Array.from(previewGrid.querySelectorAll('.preview-card')).some((card) => {
const el = card.querySelector('video,img');
const src = el ? el.getAttribute('src') : '';
return src === url;
});
if (exists) return false;
const isNew = !previewKnown.has(url);
previewKnown.add(url);
const card = document.createElement('div');
card.className = 'preview-card';
try {
// Set 有插入顺序:只保留最近一段时间的 URL,避免无上限增长
const limit = 1200;
while (previewKnown.size > limit) {
const first = previewKnown.values().next().value;
previewKnown.delete(first);
}
} catch (_) {
/* ignore */
}
if (previewsHydrated && isNew) {
card.classList.add('preview-new');
setTimeout(() => {
try {
card.classList.remove('preview-new');
} catch (_) {}
}, 3600);
}
// Escape URLs for HTML attributes/text (avoid `&bar` style entity decoding).
const safeUrlAttr = escapeHtml(url);
const safeUrlText = safeUrlAttr;
if (type === 'image') {
card.innerHTML = `
`;
} else {
card.innerHTML = ``;
}
if (taskId) {
const wrap = document.createElement('div');
wrap.style.position = 'absolute';
wrap.style.top = '10px';
wrap.style.left = '10px';
wrap.style.zIndex = '2';
wrap.style.display = 'flex';
wrap.style.flexDirection = 'column';
wrap.style.gap = '6px';
const badge = document.createElement('div');
badge.className = 'task-id-pill'; // 统一编号视觉
badge.textContent = `任务 ${taskId}`;
badge.style.cursor = 'pointer';
badge.title = '点击定位到任务卡片';
wrap.appendChild(badge);
const t = tasks.find((x) => x.id === taskId);
const sbLabel = t && t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
if (sbLabel) {
const sb = document.createElement('div');
sb.className = 'task-tag-chip storyboard';
sb.textContent = sbLabel;
wrap.appendChild(sb);
}
const wmStage = t && t.wmStage ? String(t.wmStage) : '';
const wmAttempt =
t && typeof t.wmAttempt === 'number' ? t.wmAttempt : t ? parseInt(String(t.wmAttempt || '0'), 10) || 0 : 0;
if (wmStage) {
const wm = document.createElement('div');
wm.className = 'task-tag-chip watermark';
wm.textContent =
wmStage === 'cancelled'
? '已取消去水印'
: wmStage === 'ready'
? '无水印'
: `去水印中${wmAttempt > 0 ? ` · ${wmAttempt}` : ''}`;
wrap.appendChild(wm);
}
card.style.position = 'relative';
card.appendChild(wrap);
// Clicking the task badge focuses the corresponding task card.
badge.addEventListener('click', (e) => {
e.stopPropagation();
setRightTab('tasks');
requestAnimationFrame(() => {
const el = taskList?.querySelector(`.task-card[data-id="${taskId}"]`);
if (!el) return;
el.classList.add('spotlight');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => el.classList.remove('spotlight'), 1300);
});
});
}
const info = document.createElement('div');
info.className = 'preview-info';
const downloadHrefRaw = normalizeTmpDownloadUrl(url);
const downloadHref = escapeHtml(downloadHrefRaw);
let downloadName = '';
try {
const t = taskId ? tasks.find((x) => x.id === taskId) : null;
downloadName = buildDownloadFilename(t, downloadHrefRaw, type, 1);
} catch (_) {
downloadName = '';
}
info.innerHTML = `
${safeUrlText}
${meta ? `${escapeHtml(meta)}` : ''}
${taskId ? `
` : ''}
下载
`;
card.appendChild(info);
previewGrid.prepend(card);
// 如果用户正在看“预览”页,新产出的预览默认视为已读(避免离开后红点又冒出来)
if (taskId && currentRightTab === 'preview') {
markPreviewSeen(taskId);
}
updateUnreadDots();
// 隐藏原生控件后仍支持点击播放/暂停
if (type !== 'image') {
const v = card.querySelector('video');
if (v) {
v.controls = false;
v.addEventListener('click', () => {
if (v.paused) v.play();
else v.pause();
});
}
}
card.querySelectorAll('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(
() => showToast('已复制链接'),
() => showToast('复制失败')
);
});
});
card.querySelectorAll('[data-focus-task]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tid = parseInt(btn.getAttribute('data-focus-task') || '0', 10);
if (!tid) return;
setRightTab('tasks');
requestAnimationFrame(() => {
const el = taskList?.querySelector(`.task-card[data-id="${tid}"]`);
if (!el) return;
el.classList.add('spotlight');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => el.classList.remove('spotlight'), 1300);
});
});
});
card.querySelectorAll('[data-open]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openPreviewModal(url, type, taskId);
});
});
if (push) {
// 预览仅用于展示,不写回任务
}
return isNew;
};
const syncRoleCardToLibrary = (card) => {
if (!card) return;
const username = card.username || card.display_name || '';
if (!username) return;
const exists = roles.some((r) => (r.username || r.display_name) === username);
if (exists) return;
roles.unshift({
username,
display_name: card.display_name || username,
description: card.bio || card.instruction_set || card.description || '',
avatar_path: card.avatar || card.avatar_url || ''
});
renderRoles();
};
const persistTasks = () => {
const compact = tasks
.slice(0, 20)
.map(
({
id,
status,
promptSnippet,
promptUser,
promptSend,
url,
type,
message,
meta,
logTail,
logFull,
progress,
collapsed,
tag,
storyboard
}) => ({
id,
status,
promptSnippet,
promptUser,
promptSend,
url,
type,
message,
meta,
logTail,
logFull: (logFull || '').slice(-LOG_MAX_CHARS),
progress,
collapsed: !!collapsed,
tag: tag || '',
storyboard: storyboard || null
})
);
localStorage.setItem(taskStorageKey, JSON.stringify(compact));
};
const loadTasksFromStorage = () => {
try {
const saved = JSON.parse(localStorage.getItem(taskStorageKey) || '[]');
if (Array.isArray(saved)) {
tasks = saved.map((t) => {
const base = {
...t,
promptUser: t.promptUser ?? t.promptFull ?? '',
promptSend: t.promptSend ?? t.promptFull ?? '',
promptFull: undefined,
logFull: t.logFull || '',
collapsed: !!t.collapsed,
tag: t.tag || '',
storyboard: t.storyboard || null
};
if (base.status === 'running' || base.status === 'queue') {
return { ...base, status: 'stalled', message: '刷新后任务可能中断,请点击继续或重试', progress: base.progress ?? 0 };
}
return base;
});
if (tasks.length) {
taskIdCounter = Math.max(...tasks.map((t) => t.id)) + 1;
if (currentLogTaskId === null) currentLogTaskId = tasks[0].id;
}
}
} catch (_) {
tasks = [];
}
};
const persistRoles = () => {
try {
localStorage.setItem(roleStorageKeyMain, JSON.stringify(attachedRoles));
} catch (_) {
/* ignore */
}
};
const persistRolesMulti = () => {
try {
localStorage.setItem(roleStorageKeyMulti, JSON.stringify(attachedRolesMulti));
} catch (_) {
/* ignore */
}
};
const persistRolesStoryboard = () => {
try {
localStorage.setItem(roleStorageKeyStoryboard, JSON.stringify(attachedRolesStoryboard));
} catch (_) {
/* ignore */
}
};
const loadRolesFromStorage = () => {
// 主提示(单次/同提示)全局挂载:兼容旧 key,避免升级后丢失
try {
const rawMain = localStorage.getItem(roleStorageKeyMain);
const rawLegacy = localStorage.getItem(roleStorageKeyLegacy);
const parsed = JSON.parse((rawMain || rawLegacy || '[]').toString());
attachedRoles = Array.isArray(parsed) ? parsed : [];
// 首次迁移:把 legacy 写回 main,后续就只读 main
if (!rawMain && rawLegacy) {
try {
localStorage.setItem(roleStorageKeyMain, JSON.stringify(attachedRoles));
} catch (_) {
/* ignore */
}
}
} catch (_) {
attachedRoles = [];
}
// 多提示/分镜:各自独立的“本模式全局角色”
try {
const parsed = JSON.parse((localStorage.getItem(roleStorageKeyMulti) || '[]').toString());
attachedRolesMulti = Array.isArray(parsed) ? parsed : [];
} catch (_) {
attachedRolesMulti = [];
}
try {
const parsed = JSON.parse((localStorage.getItem(roleStorageKeyStoryboard) || '[]').toString());
attachedRolesStoryboard = Array.isArray(parsed) ? parsed : [];
} catch (_) {
attachedRolesStoryboard = [];
}
};
const addTask = (promptSnippet, promptUser, promptSend, extra = null) => {
const modelId = extra && extra.model ? String(extra.model) : '';
const modelInfo = parseModelId(modelId);
const t = {
id: taskIdCounter++,
status: 'queue',
model: modelId,
promptSnippet,
promptUser: promptUser || '',
promptSend: promptSend || '',
url: null,
// 预设 mediaType:用于预览区正确选择 img/video 组件(避免“生成图片但套用视频逻辑”)
// 后续会在流式解析出真实 URL 后再次校正。
type: modelInfo.isImage ? 'image' : modelInfo.isVideo ? 'video' : 'video',
meta: null,
logTail: '',
logFull: '',
// Retry UX (submit retry / manual retry). Kept lightweight and persisted.
retryMode: '',
retryCount: 0,
// Used to decide whether to show special “edit storyboard prompt” button, etc.
errorKind: '',
// Sora task_id (from backend) - used for watermark-free cancel endpoint.
remoteTaskId: null,
// Watermark-free waiting UI state (filled from streaming delta.wm).
wmStage: '',
wmAttempt: 0,
wmCanCancel: false,
wmCancelling: false,
// 任务标签/分组:用于“分镜”筛选与编号展示
tag: extra && extra.storyboard ? 'storyboard' : '',
storyboard: extra && extra.storyboard ? extra.storyboard : null
};
tasks.unshift(t);
// 流式/并发下 addTask 也可能很频繁:用节流渲染与节流持久化避免卡顿
scheduleRender({ tasks: true, previews: false });
// 占位卡创建属于“对象恒常性”关键节点:尽量立即落盘,避免用户刷新后丢失
schedulePersistTasks({ immediate: true });
if (currentRightTab !== 'tasks') {
unread.tasks = true;
}
updateUnreadDots();
return t.id;
};
const collapseTimers = new Map();
// 任务级“自动重试/中断重试”控制柄(避免 UI 与运行时脱钩)
// Map void) }>
const taskRetryControls = new Map();
// ===== 渲染/持久化节流(关键:解决流式每 chunk 全量重绘导致的卡顿) =====
let renderQueued = false;
let needRenderTasks = false;
let needRenderPreviews = false;
const scheduleRender = (opts = { tasks: true, previews: false }) => {
if (opts && opts.tasks) needRenderTasks = true;
if (opts && opts.previews) needRenderPreviews = true;
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => {
renderQueued = false;
const doTasks = needRenderTasks;
const doPreviews = needRenderPreviews;
needRenderTasks = false;
needRenderPreviews = false;
if (doTasks) renderTasks();
if (doPreviews) renderPreviews();
updateUnreadDots();
});
};
let persistTasksTimer = null;
const schedulePersistTasks = (opts = { immediate: false }) => {
if (opts && opts.immediate) {
if (persistTasksTimer) clearTimeout(persistTasksTimer);
persistTasksTimer = null;
persistTasks();
return;
}
if (persistTasksTimer) return;
// 轻微延迟把多次 updateTask 合并成一次 localStorage 写入
persistTasksTimer = setTimeout(() => {
persistTasksTimer = null;
persistTasks();
}, 400);
};
// ===== 任务卡“增量 DOM 更新”(关键:解决流式每个 chunk 全量重绘导致的卡顿) =====
let taskDomSyncQueued = false;
const taskDomSyncMap = new Map(); // Map
const syncTaskCardDom = (t) => {
if (!taskList || !t) return;
const id = parseInt(String(t.id || '0'), 10) || 0;
if (!id) return;
const card = taskList.querySelector(`.task-card[data-id="${id}"]`);
if (!card) return;
// 进度条(仅更新数值/宽度,不重建整卡)
const progress = Math.max(0, Math.min(100, parseInt(String(t.progress ?? (t.status === 'done' ? 100 : 0)), 10) || 0));
const bar = card.querySelector('[data-task-progress-bar="1"]');
if (bar) bar.style.width = `${progress}%`;
const shell = card.querySelector('[data-task-progress-shell="1"]');
if (shell) shell.setAttribute('aria-valuenow', String(progress));
const pText = card.querySelector('[data-task-progress-text="1"]');
if (pText) pText.textContent = `进度:${progress}%`;
// 任务消息(运行中/重试中会变化很频繁,改为只更新这一行)
const msgEl = card.querySelector('[data-task-msg="1"]');
if (msgEl) {
const msg = String(t.message || '');
if (msg) {
msgEl.textContent = msg;
msgEl.style.display = '';
msgEl.style.color = t.status === 'retrying' ? '#b45309' : '#f87171';
} else {
msgEl.textContent = '';
msgEl.style.display = 'none';
}
}
};
const scheduleTaskCardDomSync = (taskId, taskSnapshot) => {
if (!taskList) return;
const id = parseInt(String(taskId || '0'), 10) || 0;
if (!id) return;
taskDomSyncMap.set(id, taskSnapshot);
if (taskDomSyncQueued) return;
taskDomSyncQueued = true;
requestAnimationFrame(() => {
taskDomSyncQueued = false;
taskDomSyncMap.forEach((t) => {
try {
syncTaskCardDom(t);
} catch (_) {
/* ignore */
}
});
taskDomSyncMap.clear();
});
};
// 日志 Tab:仅当用户正在查看时才更新,并做 rAF 合并,避免 logFull 每条都重绘
let logPanelSyncQueued = false;
const scheduleLogPanelSync = () => {
if (logPanelSyncQueued) return;
logPanelSyncQueued = true;
requestAnimationFrame(() => {
logPanelSyncQueued = false;
try {
if (currentRightTab === 'log') renderLogPanel();
} catch (_) {
/* ignore */
}
});
};
const updateTask = (id, patch) => {
const idx = tasks.findIndex((t) => t && t.id === id);
if (idx < 0) return;
const base = tasks[idx];
const merged = { ...base, ...patch };
// 若后续补打的 message 表明角色卡成功,则校正状态
if (patch.message && /角色卡创建成功/.test(patch.message)) {
merged.status = 'done';
merged.type = merged.type || 'character';
}
// 合并日志:保留完整日志并截断
if (patch.logTail !== undefined) {
merged.logTail = patch.logTail;
}
if (patch.logFull !== undefined) {
merged.logFull = (patch.logFull || '').slice(-LOG_STORE_LIMIT);
}
if (patch.timedOut !== undefined) {
merged.timedOut = patch.timedOut;
}
tasks[idx] = merged;
const changed = merged;
if (patch.status === 'done' && changed && !changed.collapsed) {
if (!collapseTimers.has(id)) {
const timer = setTimeout(() => {
tasks = tasks.map((t) => (t.id === id ? { ...t, collapsed: true } : t));
collapseTimers.delete(id);
scheduleRender({ tasks: true, previews: false });
schedulePersistTasks();
}, 3000);
collapseTimers.set(id, timer);
}
}
// 任务列表基本每次都要更新(进度/状态/消息),但预览墙只在 url/meta/tag 等关键字段变化时更新
const affectsPreview =
patch.url !== undefined ||
patch.type !== undefined ||
patch.meta !== undefined ||
patch.wmStage !== undefined ||
patch.wmAttempt !== undefined ||
patch.storyboard !== undefined ||
patch.tag !== undefined;
// “全量重绘任务列表”很贵:流式输出时只做“增量 DOM 更新”,把全量 render 留给结构性变化
const patchKeys = patch && typeof patch === 'object' ? Object.keys(patch) : [];
const onlyLogPatch =
patchKeys.length > 0 && patchKeys.every((k) => k === 'logFull' || k === 'logTail');
const heavyKeys = new Set([
'status',
'url',
'type',
'meta',
'tag',
'storyboard',
'collapsed',
'retryMode',
'retryCount',
'timedOut',
'wmStage',
'wmAttempt',
'wmCanCancel',
'wmCancelling',
'remoteTaskId'
]);
let needFullTasksRender = patchKeys.some((k) => heavyKeys.has(k));
// 兜底:某些情况下会通过 message 修正 status(例如“角色卡创建成功”)
if ((merged && merged.status) !== (base && base.status)) needFullTasksRender = true;
if ((merged && !!merged.timedOut) !== (base && !!base.timedOut)) needFullTasksRender = true;
if (needFullTasksRender) {
scheduleRender({ tasks: true, previews: affectsPreview });
schedulePostTaskState({ immediate: true });
} else {
// 增量更新:只更新该任务卡的进度/消息(不卡 UI、不重绑事件)
const needDom = patch.progress !== undefined || patch.message !== undefined;
if (needDom) scheduleTaskCardDomSync(id, merged);
if (affectsPreview) scheduleRender({ tasks: false, previews: true });
// 日志 Tab:用户正在查看时才刷新(logFull 每条都更新会非常卡)
if (
currentRightTab === 'log' &&
(patch.logFull !== undefined || patch.logTail !== undefined || patch.message !== undefined)
) {
scheduleLogPanelSync();
}
// 给管理台任务抽屉同步:logFull/logTail 不需要(抽屉不展示日志),避免无意义跨 iframe 重绘
if (!onlyLogPatch) schedulePostTaskState({ immediate: false });
}
// 同步内存日志缓存,便于复制与展示
if (patch.logFull !== undefined || patch.logTail !== undefined) {
const logText =
(patch.logFull || patch.logTail || taskLogBuffer[id] || '').slice(-LOG_STORE_LIMIT);
taskLogBuffer[id] = logText;
}
schedulePersistTasks();
};
const updateTaskBubble = () => {
const running = tasks.filter((t) => t.status === 'running' || t.status === 'retrying' || t.status === 'queue').length;
const total = tasks.length;
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'task_count', running, total }, '*');
}
} catch (_) {}
};
// 任务列表状态(给管理台任务抽屉用):节流发送,避免流式每个 chunk 都触发父页面重渲染
let postTaskStateTimer = null;
const postTaskStateNow = () => {
try {
if (!(window.parent && window.parent !== window)) return;
const summary = tasks.map((t) => ({
id: t.id,
status: t.status,
prompt: t.promptSnippet,
url: t.url,
meta: t.meta,
message: t.message,
progress: t.progress ?? 0,
tag: t.tag || '',
storyboard: t.storyboard || null
}));
window.parent.postMessage({ type: 'task_state', tasks: summary }, '*');
} catch (_) {
/* ignore */
}
};
const schedulePostTaskState = (opts = { immediate: false }) => {
const immediate = !!(opts && opts.immediate);
if (immediate) {
if (postTaskStateTimer) clearTimeout(postTaskStateTimer);
postTaskStateTimer = null;
postTaskStateNow();
return;
}
if (postTaskStateTimer) return;
postTaskStateTimer = setTimeout(() => {
postTaskStateTimer = null;
postTaskStateNow();
}, 450);
};
// 右侧 tab 切换
let currentRightTab = localStorage.getItem(RIGHT_TAB_KEY) || 'tasks';
const unread = { tasks: false, preview: false, log: false };
let onlyRunning = false;
let densePreview = localStorage.getItem(PREVIEW_DENSE_KEY) === '1';
let statusFilter = '';
// 预览未读:用“已看过的任务 id”做集合判定,避免 URL 变化/重渲染导致红点反复出现
let previewSeenTaskIds = new Set();
let logVersion = 0;
let logSeenVersion = 0;
const previewKnown = new Set(); // 仅用于避免同一 URL 重复加卡
let previewsHydrated = false;
let previewCountLastText = '';
let previewCountFlashTimer = null;
let currentLogTaskId = null;
let taskLogBuffer = {};
const setRightTab = (tab) => {
currentRightTab = tab;
localStorage.setItem(RIGHT_TAB_KEY, tab);
rightTabButtons.forEach((btn) => btn.classList.toggle('active', btn.getAttribute('data-tab') === tab));
rightTabButtons.forEach((btn) => btn.classList.toggle('has-unread', unread[btn.getAttribute('data-tab')] && tab !== btn.getAttribute('data-tab')));
tabPanelTasks.classList.toggle('active', tab === 'tasks');
tabPanelPreview.classList.toggle('active', tab === 'preview');
tabPanelLog.classList.toggle('active', tab === 'log');
if (tab === 'tasks') unread.tasks = false;
if (tab === 'preview') markAllPreviewsSeen();
if (tab === 'log') {
logSeenVersion = logVersion;
renderTaskLogList();
renderTaskLogContent();
}
unread[tab] = false;
updateUnreadDots();
};
// 核心:执行一组任务(支持并发)
const runJobs = async (jobs, apiKey, baseUrl, concurrency = 1) => {
if (!jobs || !jobs.length) return;
const poolSize = Math.min(concurrency, jobs.length);
let cursor = 0;
const runJob = async (job) => {
const promptSend = job.promptSend ?? job.prompt ?? '';
const promptUser = job.promptUser ?? job.prompt ?? '';
const promptSnippet = promptUser.slice(0, 80) || (job.file ? job.file.name : '(空提示)');
const extra = { storyboard: job.storyboard || null, model: job.model };
// 任务热启动:先创建占位任务,避免并发时日志串号 & 增强“对象恒常性”
// 但“重试/继续”要求不改变任务卡位置:允许复用现有 taskId,原地变为“重试中/生成中”。
let taskId =
typeof job.taskId === 'number' ? job.taskId : parseInt(String(job.taskId || ''), 10) || null;
if (taskId && !tasks.find((t) => t && t.id === taskId)) {
taskId = null;
}
if (!taskId) {
taskId = addTask(promptSnippet, promptUser, promptSend, extra);
} else {
// 若同一任务正在跑(比如用户连续点“重试”),先中断旧的,再启动新的。
const prev = taskRetryControls.get(taskId);
if (prev) {
prev.cancelled = true;
try {
if (typeof prev.abortFetch === 'function') prev.abortFetch();
} catch (_) {}
}
taskLogBuffer[taskId] = '';
updateTask(taskId, {
status: 'queue',
progress: 0,
timedOut: false,
message: '准备中…',
model: job.model,
// 复用 taskId 时同步刷新媒体类型:避免上一轮是视频,本轮切到图片后预览仍按视频渲染
type: parseModelId(job.model).isImage ? 'image' : 'video',
promptSnippet,
promptUser,
promptSend,
url: null,
meta: null,
logTail: '',
logFull: '',
retryMode: 'manual',
retryCount: 0,
errorKind: '',
remoteTaskId: null,
wmStage: '',
wmAttempt: 0,
wmCanCancel: false,
wmCancelling: false
});
if (extra && extra.storyboard) {
updateTask(taskId, { tag: 'storyboard', storyboard: extra.storyboard });
}
}
// 占位态:让用户立刻看到“任务已入队”,避免误以为只生成了分镜 1。
updateTask(taskId, { status: 'queue', model: job.model, errorKind: '', progress: 0, timedOut: false, message: '准备中…' });
// 记录本次任务的输入素材(用于“空提示也能重试/继续”)。
// 注意:这里只保留在内存中,避免把大文件 dataURL 写进 localStorage(防卡顿/超额)。
try {
const tRef = tasks.find((x) => x && x.id === taskId);
if (tRef) {
if (job.file) {
tRef._inputFile = job.file;
tRef._inputFileName = job.file.name || '';
if (tRef._inputFileDataUrl) tRef._inputFileDataUrl = null;
} else if (job.fileDataUrl) {
tRef._inputFile = null;
tRef._inputFileName = '';
tRef._inputFileDataUrl = job.fileDataUrl;
}
tRef._inputHasFile = !!(job.file || job.fileDataUrl);
}
} catch (_) {
/* ignore */
}
const contentArr = [];
if (promptSend) contentArr.push({ type: 'text', text: promptSend });
// 读文件(可能比较慢)
try {
if (job.file) {
logTask(taskId, `读取文件: ${job.file.name}`);
const dataUrl = await fileToDataUrl(job.file);
if ((job.file.type || '').startsWith('video')) {
contentArr.push({ type: 'video_url', video_url: { url: dataUrl } });
} else {
contentArr.push({ type: 'image_url', image_url: { url: dataUrl } });
}
} else if (job.fileDataUrl) {
const url = job.fileDataUrl;
const isVideo = url.startsWith('data:video') || /\.(mp4|mov|m4v|webm)$/i.test(url);
if (isVideo) {
contentArr.push({ type: 'video_url', video_url: { url } });
} else {
contentArr.push({ type: 'image_url', image_url: { url } });
}
}
} catch (_) {
updateTask(taskId, { status: 'error', message: '读取文件失败(请重试或更换文件)', progress: 0 });
showToast('读取文件失败(请重试或更换文件)', 'error', { title: '文件读取失败', duration: 4200 });
return;
}
const body = {
model: job.model,
stream: true,
messages: [
{
role: 'user',
content: contentArr.length ? contentArr : promptSend
}
]
};
// 手动“重试/继续”必须原地变为“重试中”标签(不再保留失败标签)
if (job.taskId) {
updateTask(taskId, { status: 'retrying', retryMode: 'manual', retryCount: 0, progress: 0, message: '' });
} else {
updateTask(taskId, { status: 'running', retryMode: '', retryCount: 0, progress: 0, message: '' });
}
const url = `${baseUrl}/v1/chat/completions`;
const isRetryable = (errMsg) =>
/timeout|timed out|HTTP\s*5\d\d|503|502|504|bad gateway|gateway time-out|ENETUNREACH|ECONNRESET|ECONNABORTED|ETIMEDOUT|Failed to connect|network|cloudflare|curl|connection closed|closed abruptly/i.test(
errMsg || ''
);
const retryCtl = { cancelled: false, abortFetch: null };
taskRetryControls.set(taskId, retryCtl);
try {
// 提交上游阶段:不轻易判失败(自动重试,3 次后提供"中断重试"按钮)
const MAX_RETRY = 9999;
for (let attempt = 1; attempt <= MAX_RETRY + 1; attempt++) {
let lastChunk = '';
let contentAccumulated = ''; // 累积所有 content 字段
let characterCreated = false;
let characterCardInfo = null;
let hadError = false;
let finished = false;
let logBufferAttempt = '';
let watermarkWaitSeen = false; // once seen, disable the 10-min hard timeout and rely on explicit cancel
let progressMarkerSeen = false; // once seen, do NOT auto-resubmit (avoid duplicates)
const controller = new AbortController();
retryCtl.abortFetch = () => controller.abort();
const HARD_TIMEOUT = 600000; // 10 分钟总超时
let hardTimer = null;
const clearTimers = () => {
if (hardTimer) clearTimeout(hardTimer);
};
try {
if (retryCtl.cancelled) {
updateTask(taskId, { status: 'error', message: '已中断自动重试(可点击“重试”再次发起)' });
return;
}
// attempt=1:正常生成(或手动重试的首次尝试)
// attempt>1:仅用于“提交上游失败”类可重试错误的自动重试
if (attempt > 1) {
updateTask(taskId, {
status: 'retrying',
retryMode: 'submit',
retryCount: attempt - 1,
timedOut: false,
progress: 0
});
} else if (job.taskId) {
updateTask(taskId, { status: 'retrying', retryMode: 'manual', retryCount: 0, timedOut: false, progress: 0 });
} else {
updateTask(taskId, { status: 'running', timedOut: false, progress: 0 });
}
const resp = await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
Accept: 'text/event-stream'
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!resp.ok || !resp.body) {
throw new Error('HTTP ' + resp.status);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let mediaUrl = null;
// 默认按模型推断:避免 URL 无扩展名时误判(图片任务却用 video 预览)
let mediaType = parseModelId(job.model).isImage ? 'image' : 'video';
let mediaMeta = null;
hardTimer = setTimeout(() => controller.abort(), HARD_TIMEOUT);
logTask(taskId, '连接成功,开始接收流...');
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
lastChunk = chunk || lastChunk;
chunk.split(/\n\n/).forEach((line) => {
if (!line.startsWith('data:')) return;
const data = line.replace(/^data:\s*/, '');
if (data === '[DONE]') {
logTask(taskId, '[DONE]');
finished = true;
return;
}
logTask(taskId, data);
logBufferAttempt = (logBufferAttempt + data + '\n').slice(-LOG_STORE_LIMIT);
try {
const obj = JSON.parse(data);
const choice = (obj.choices && obj.choices[0]) || {};
const delta = choice.delta || {};
if (obj.error) {
const pretty = humanizeUpstreamError(obj.error);
const errMsg = pretty.message || obj.error.message || obj.error.code || '生成失败';
// 仅“提交上游失败/网络瞬断(未进入进度阶段)”自动重试;避免已提交后重复下单
if (isRetryable(errMsg) && !progressMarkerSeen && !watermarkWaitSeen) {
const retryErr = new Error(errMsg);
retryErr.__submitRetryable = true;
throw retryErr;
}
// 内容审查命中:不要自动重试,直接给“可修改分镜提示词”的兜底入口
if (isContentPolicyViolation(errMsg)) {
hadError = true;
const isSb = !!(job.storyboard && job.storyboard.label);
const msg = isSb ? '内容审查未通过(可修改分镜提示词后重试)' : '内容审查未通过(请调整提示词后重试)';
updateTask(taskId, {
status: 'error',
errorKind: 'policy',
message: msg,
logTail: lastChunk,
logFull: logBufferAttempt,
progress: 0
});
showToast(msg, 'warn', { title: '内容审查未通过', duration: 5200 });
return;
}
hadError = true;
updateTask(taskId, { status: 'error', message: errMsg, logTail: lastChunk, logFull: logBufferAttempt });
showToast(errMsg || '生成失败', pretty.type === 'warn' ? 'warn' : 'error', {
title: pretty.title || '生成失败',
duration: 4200
});
return;
}
const rc = delta.reasoning_content || (choice.message && choice.message.content) || '';
// Watermark-free waiting (structured, from backend delta.wm)
if (delta && delta.wm && typeof delta.wm === 'object') {
const wm = delta.wm || {};
const stage = wm.stage ? String(wm.stage) : '';
const attempt =
typeof wm.attempt === 'number' ? wm.attempt : parseInt(String(wm.attempt || '0'), 10) || 0;
const canCancel = !!wm.can_cancel;
const remoteTaskId = wm.task_id ? String(wm.task_id) : '';
const patch = { wmStage: stage, wmAttempt: attempt, wmCanCancel: canCancel };
if (remoteTaskId) patch.remoteTaskId = remoteTaskId;
updateTask(taskId, patch);
// Once we enter watermark-free waiting, do not enforce the 10-min hard timeout.
if (!watermarkWaitSeen) {
watermarkWaitSeen = true;
if (hardTimer) {
clearTimeout(hardTimer);
hardTimer = null;
}
}
}
// 解析 delta.content 里嵌入的 JSON(character_card)
const rawContent =
delta.content ||
(choice.message && choice.message.content) ||
obj.content ||
'';
const finishReason = choice.finish_reason || choice.native_finish_reason || delta.finish_reason;
const deltaContent = typeof delta.content === 'string' ? delta.content : '';
const deltaReasoning = typeof delta.reasoning_content === 'string' ? delta.reasoning_content : '';
// 累积 content 字段
if (deltaContent) {
contentAccumulated += deltaContent;
}
// 内容审查:Sora 可能以 reasoning/content 形式返回(不一定走 obj.error)
const policyText = [deltaReasoning, deltaContent, rc, rawContent].filter(Boolean).join('\n');
if (!hadError && isContentPolicyViolation(policyText)) {
hadError = true;
const isSb = !!(job.storyboard && job.storyboard.label);
const msg = isSb ? '内容审查未通过(可修改分镜提示词后重试)' : '内容审查未通过(请调整提示词后重试)';
updateTask(taskId, {
status: 'error',
errorKind: 'policy',
message: msg,
logTail: lastChunk,
logFull: logBufferAttempt,
progress: 0
});
showToast(msg, 'warn', { title: '内容审查未通过', duration: 5200 });
return;
}
const characterFailHit =
/角色卡创建失败|Character creation failed/i.test(deltaContent) ||
/角色卡创建失败|Character creation failed/i.test(deltaReasoning) ||
/角色卡创建失败|Character creation failed/i.test(rawContent || '') ||
(/character_card/i.test(rawContent || '') && finishReason === 'STOP' && !characterCreated && !mediaUrl);
if (!hadError && characterFailHit) {
const msg =
(deltaContent || deltaReasoning || rawContent || '角色卡创建失败')
.replace(/^❌\s*/, '')
.trim();
hadError = true;
updateTask(taskId, {
status: 'error',
type: 'character',
message: msg,
logTail: lastChunk,
logFull: logBufferAttempt,
progress: 0
});
return;
}
let innerObj = null;
if (typeof rawContent === 'string' && rawContent.trim().startsWith('{')) {
try {
innerObj = JSON.parse(rawContent);
} catch (_) {
innerObj = null;
}
}
if (typeof rc === 'string' && /(blocked|guardrail|违规|不支持|限制)/i.test(rc)) {
hadError = true;
const pretty = humanizeUpstreamError(rc);
updateTask(taskId, {
status: 'error',
message: pretty.message || rc.trim(),
logTail: lastChunk,
logFull: logBufferAttempt
});
showToast(pretty.message || rc.trim(), pretty.type === 'warn' ? 'warn' : 'error', {
title: pretty.title || '生成失败',
duration: 4200
});
return;
}
// 角色卡事件:直接标记为角色卡成功
const cardPayload = obj.event === 'character_card' || obj.card ? obj : innerObj && innerObj.event === 'character_card' ? innerObj : null;
if (!cardPayload && typeof data === 'string' && data.includes('"character_card"')) {
try {
const temp = JSON.parse(data);
if (temp && (temp.event === 'character_card' || temp.card)) {
cardPayload = temp;
}
} catch (_) {}
}
if (cardPayload && (cardPayload.event === 'character_card' || cardPayload.card)) {
const card = cardPayload.card || {};
characterCreated = true;
characterCardInfo = card;
syncRoleCardToLibrary(card);
showToast(`角色卡创建成功:@${card.username || card.display_name || '角色'}`);
updateTask(taskId, {
status: 'done',
type: 'character',
message: `角色卡创建成功:@${card.username || '角色'}`,
meta: { display: card.display_name || card.username || '' },
logTail: lastChunk,
logFull: logBufferAttempt
});
return;
}
// 进度:结构化字段或 reasoning_content 中的百分比
const currentProgress =
tasks.find((t) => t.id === taskId && !isNaN(parseFloat(t.progress)))?.progress ?? 0;
let progressVal = null;
const pctMatch = data.match(/(\d{1,3})%/);
if (pctMatch) progressMarkerSeen = true;
if (obj.progress !== undefined && !isNaN(parseFloat(obj.progress))) {
progressVal = parseFloat(obj.progress);
progressMarkerSeen = true;
}
if (obj.delta && typeof obj.delta.reasoning_content === 'string') {
const m = obj.delta.reasoning_content.match(/(\d{1,3})%/);
if (m) progressVal = Math.max(progressVal ?? 0, parseFloat(m[1]));
if (m) progressMarkerSeen = true;
}
if (!progressVal && pctMatch) {
progressVal = Math.min(100, parseFloat(pctMatch[1]));
}
if (!isNaN(progressVal)) {
const merged = Math.max(currentProgress, progressVal);
updateTask(taskId, { progress: merged });
}
// 结构化字段优先
const output0 = (obj.output && obj.output[0]) || null;
const deltaOut0 = (delta.output && delta.output[0]) || null;
// 上游有时会给出明确 type(image/video),即使 URL 没有扩展名也应信任它。
const declaredTypeRaw = (output0 && output0.type) || (deltaOut0 && deltaOut0.type) || obj.type || '';
const declaredType = String(declaredTypeRaw || '').toLowerCase();
const declaredHint = declaredType === 'image' || declaredType === 'video' ? declaredType : '';
const typeHintFromFields =
declaredHint ||
(obj.image_url && obj.image_url.url ? 'image' : '') ||
(obj.video_url && obj.video_url.url ? 'video' : '') ||
(output0 && output0.image_url ? 'image' : '') ||
(output0 && output0.video_url ? 'video' : '') ||
(deltaOut0 && deltaOut0.image_url ? 'image' : '') ||
(deltaOut0 && deltaOut0.video_url ? 'video' : '') ||
'';
const candidates = [
obj.url,
obj.video_url && obj.video_url.url,
obj.image_url && obj.image_url.url,
output0 && (output0.url || output0.video_url || output0.image_url),
deltaOut0 && (deltaOut0.url || deltaOut0.video_url || deltaOut0.image_url)
].filter(Boolean);
// Capture remote task_id from delta.output if present (used by watermark cancel button)
if (delta.output && delta.output[0] && delta.output[0].task_id) {
updateTask(taskId, { remoteTaskId: String(delta.output[0].task_id) });
progressMarkerSeen = true;
}
let extractedUrl = candidates[0];
// content/markdown 中的