(() => { 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' ? `preview` : ``; }; 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 ? `` : ''} `; 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 = `preview`; } 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 中的