diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 4b06b90..2482937 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,24 +1,22 @@ { "schema": 1, - "product_version": "0.2.0", + "product_version": "0.2.1", "en": { - "summary": "Four quality levels, a new two-pass refinement and sharing exports.", + "summary": "A redesigned quality slider, result view and sharing card.", "changes": [ - "Choose Light, Medium, High or Max with the quality slider. Light uses two-pass acceleration; the higher levels sample 12, 16 or 20 steps.", - "Two-pass generation now refines with three independent steps after the first 8.", - "Setup installs every quality level by default. Existing installations download only what a generation needs, before it starts; download time is not counted as generation time.", - "Export a sharing image or video with generation statistics. The original video stays unchanged.", - "The launcher shows the FreeVideo wordmark." + "The quality slider is larger and easier to drag, with clearer motion between levels.", + "A finished video now has one action bar: Download video, Share, and a Report menu that holds both the diagnostic report and the generation record.", + "Generation times and memory peaks sit together in one card, in Create and in Creations.", + "Sharing images and videos are simpler: generation time, GPU and quality level." ] }, "zh": { - "summary": "四档质量、全新二次采样与分享导出。", + "summary": "质量滑块、生成结果区和分享卡片重新设计。", "changes": [ - "质量滑块提供轻量、标准、精细、极致四档。轻量使用二次采样加速,其余三档采样 12、16、20 步。", - "二次采样改为一采 8 步后,独立二采 3 步。", - "安装时默认下载全部质量档位。已安装的版本只在生成前补装当次需要的文件,下载时间不计入生成耗时。", - "可导出带生成统计的分享图片或视频,原视频保持不变。", - "启动器左上角显示 FreeVideo 字标。" + "质量滑块更大、更好拖动,切换档位的动效更清晰。", + "视频生成后只有一排操作:下载视频、分享,以及一个报告菜单,诊断报告和生成记录都在里面。", + "耗时和显存、内存峰值合并为一张卡片,创作页和作品库一致。", + "分享图片和视频更简洁,只保留生成耗时、显卡和质量档位。" ] } } diff --git a/web/generation_progress.css b/web/generation_progress.css index 403e342..539c493 100644 --- a/web/generation_progress.css +++ b/web/generation_progress.css @@ -28,6 +28,6 @@ .fv-generation-compact .fv-generation-detail,.fv-generation-compact:not([data-phase=encoding]) .fv-generation-silence,.fv-generation-compact .fv-generation-note:not(.fv-generation-retry){display:none} .fv-generation-compact .fv-generation-retry{font-size:12px;margin-top:14px} .fv-report-download{display:block;align-self:center;width:fit-content;max-width:100%;margin:8px auto 0;font-size:12px} -.fv-report-download[hidden]{display:none!important} +.fv-report-download[hidden],.fv-report-download[data-covered]{display:none!important} @keyframes fv-generation-sweep{0%{left:-20%;width:20%}50%{left:33%;width:34%}100%{left:100%;width:20%}} @media(prefers-reduced-motion:reduce){.fv-generation-fill,.fv-generation-fill::before,.fv-generation-fill::after{transition:none!important;animation:none!important}.fv-generation-fill::after{opacity:0}.fv-generation-progress[data-counted=false] .fv-generation-fill{opacity:.5}} diff --git a/web/generation_progress.js b/web/generation_progress.js index 4c25d11..97e2de2 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -98,18 +98,24 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // only a new request clears it. return reportId; } + // Download the redacted diagnostic export; throws when it is unavailable. + async function downloadReport() { + const id = reportId; + if (!id) throw new Error('Report unavailable'); + const path = `/freevideo/report/${id}`; + const response = await (api ? api.fetchApi(path) : fetch(path)); + if (!response.ok) throw new Error('Report unavailable'); + const blob = await response.blob(), url = URL.createObjectURL(blob); + const link = document.createElement('a'); link.href = url; link.download = 'video.debug.json'; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } report.onclick = async () => { if (!reportId || report.disabled) return; const id = reportId; report.disabled = true; report.textContent = t('Preparing report…', '正在整理报告…'); try { - const path = `/freevideo/report/${id}`; - const response = await (api ? api.fetchApi(path) : fetch(path)); - if (!response.ok) throw new Error('Report unavailable'); - const blob = await response.blob(), url = URL.createObjectURL(blob); - const link = document.createElement('a'); link.href = url; link.download = 'video.debug.json'; - document.body.append(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); + await downloadReport(); if (id === reportId) report.textContent = reportLabel(); } catch { if (id === reportId) report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); @@ -323,5 +329,5 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (timer !== null) clearInterval(timer); timer = null; state = {}; overall = {}; shownFraction = null; stopCount(); countShown = null; } - return {element, report, updateReport, update, hide, dispose: hide}; + return {element, report, updateReport, downloadReport, hasReport: () => reportId !== null, update, hide, dispose: hide}; } diff --git a/web/library.js b/web/library.js index 1795931..ee8396d 100644 --- a/web/library.js +++ b/web/library.js @@ -1,7 +1,6 @@ import { api } from '../../scripts/api.js'; import { closeDialog } from './motion.js'; -import { outputDownloadURL } from './output_download.js'; -import { shareButton } from './share.js'; +import { resultActions } from './result_actions.js'; const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL('./library.css', import.meta.url).href; document.head.append(style); @@ -45,7 +44,7 @@ export function openLibrary(t) { stats.setAttribute('aria-label', t('Generation statistics', '生成统计')); const budget = el('div', '', 'fv-budget'); const links = el('div', null, 'fv-result-links'); - detail.append(all, frame, caption, stats, budget, links); + detail.append(all, frame, caption, links, stats, budget); body.append(collection, detail); dialog.append(header, body); let disposed = false, loading = false, next = null, selected = null, player = null, arrivals = []; const rows = new Map(), cards = new Map(); @@ -80,12 +79,7 @@ export function openLibrary(t) { } }, {once: true}); date.textContent = timestamp(row); geometry.textContent = dimensions(row); - links.replaceChildren(); - for (const [label, file, cls] of [[t('Download video', '下载视频'), row.video, 'fv-primary'], [t('View report', '查看报告'), row.report, 'fv-quiet']]) { - if (!file) continue; - const a = el('a', label, cls); a.href = outputDownloadURL(api, file); a.download = file === row.video ? '' : file.split('/').pop(); links.append(a); - } - links.append(shareButton(row, t)); + links.replaceChildren(resultActions(row, t)); for (const [file, card] of cards) card.setAttribute('aria-pressed', String(file === selected)); } // These are the saved generation's measurements, including when the diff --git a/web/result_actions.css b/web/result_actions.css new file mode 100644 index 0000000..935bd9e --- /dev/null +++ b/web/result_actions.css @@ -0,0 +1,23 @@ +/* One action bar: the primary download, then share and the reports. */ +.fv-actions{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px} +.fv-actions .fv-action{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:38px;min-height:38px;margin:0;padding:0 15px;border:1px solid color-mix(in srgb,var(--fv-ink,#eee) 10%,transparent);border-radius:11px;background:color-mix(in srgb,var(--fv-ink,#eee) 6%,transparent);color:var(--fv-ink,#eee);font:600 13px/1 var(--fv-font,system-ui);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none;transition:background .18s,border-color .18s,transform .15s ease} +.fv-actions .fv-action:hover{background:color-mix(in srgb,var(--fv-ink,#eee) 11%,transparent);border-color:color-mix(in srgb,var(--fv-ink,#eee) 16%,transparent);color:var(--fv-ink,#eee)} +.fv-actions .fv-action:active{transform:scale(.98)} +.fv-actions .fv-action:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:2px} +.fv-actions .fv-action-primary{padding:0 18px;border-color:transparent;background:var(--fv-accent,#6db8fa);color:var(--fv-bg,#111720)} +.fv-actions .fv-action-primary:hover{border-color:transparent;background:var(--fv-accent-hover,#91cbff);color:var(--fv-bg,#111720)} +.fv-actions .fv-action[hidden]{display:none} +.fv-icon{display:inline-flex;width:16px;height:16px;flex:none}.fv-icon svg{width:100%;height:100%} +.fv-action-menu{position:relative} +.fv-action-menu>summary{list-style:none}.fv-action-menu>summary::-webkit-details-marker{display:none} +.fv-action-menu>summary .fv-icon:last-child{width:14px;height:14px;margin-left:-2px;opacity:.7;transition:transform .2s ease} +.fv-action-menu[open]>summary .fv-icon:last-child{transform:rotate(180deg)} +.fv-action-menu[open]>summary{background:color-mix(in srgb,var(--fv-ink,#eee) 11%,transparent)} +.fv-menu{position:absolute;right:0;bottom:calc(100% + 8px);z-index:5;min-width:236px;padding:6px;border:1px solid var(--fv-border,#2a3443);border-radius:13px;background:var(--fv-raised,#212a37);box-shadow:0 14px 36px #00000073;animation:fv-menu-enter .16s ease} +.fv-actions .fv-menu-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;margin:0;padding:9px 11px;border:0;border-radius:9px;background:none;color:var(--fv-ink,#eee);text-align:left;text-decoration:none;cursor:pointer;box-shadow:none;font:inherit} +.fv-actions .fv-menu-item:hover,.fv-actions .fv-menu-item:focus-visible{background:var(--fv-hover,#2a3341);outline:none} +.fv-actions .fv-menu-item strong{font-size:13px;font-weight:600} +.fv-actions .fv-menu-item small{font-size:11px;color:var(--fv-muted,#a5b3c6);white-space:nowrap} +.fv-actions .fv-menu-item:disabled{opacity:.7;cursor:progress} +@keyframes fv-menu-enter{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +@media(prefers-reduced-motion:reduce){.fv-actions *{transition:none!important;animation:none!important}} diff --git a/web/result_actions.js b/web/result_actions.js new file mode 100644 index 0000000..17ce058 --- /dev/null +++ b/web/result_actions.js @@ -0,0 +1,98 @@ +import { api } from '../../scripts/api.js'; +import { outputDownloadURL } from './output_download.js'; +import { shareButton } from './share.js'; + +const css = document.createElement('link'); +css.rel = 'stylesheet'; css.href = new URL('./result_actions.css', import.meta.url).href; document.head.append(css); + +const ICONS = { + download: '', + share: '', + report: '', + chevron: '', +}; + +export function icon(name) { + const span = document.createElement('span'); span.className = 'fv-icon'; span.setAttribute('aria-hidden', 'true'); + span.innerHTML = `${ICONS[name]}`; + return span; +} + +function action(tag, label, name, cls) { + const e = document.createElement(tag); e.className = 'fv-action ' + cls; + const text = document.createElement('span'); text.textContent = label; + e.append(icon(name), text); + if (tag === 'button') e.type = 'button'; + return e; +} + +// Download the video first; share and the reports follow, one entry each. +// `diagnostic` is an async callback for the redacted support report, when one exists. +export function resultActions(record, t, {diagnostic = null} = {}) { + const bar = document.createElement('div'); bar.className = 'fv-actions'; + if (record.video) { + const download = action('a', t('Download video', '下载视频'), 'download', 'fv-action-primary'); + download.href = outputDownloadURL(api, record.video); download.download = ''; + bar.append(download); + } + const share = shareButton(record, t); + share.className = 'fv-action fv-action-secondary fv-share-trigger'; + const label = document.createElement('span'); label.textContent = share.textContent; + share.replaceChildren(icon('share'), label); + bar.append(share); + const recordLink = record.report ? outputDownloadURL(api, record.report) : null; + if (!diagnostic) { + if (recordLink) { + const report = action('a', t('Report', '报告'), 'report', 'fv-action-secondary'); + report.href = recordLink; report.download = record.report.split('/').pop(); report.title = t('Generation record (JSON)', '生成记录(JSON)'); + bar.append(report); + } + return bar; + } + const menu = document.createElement('details'); menu.className = 'fv-action-menu'; + const summary = document.createElement('summary'); summary.className = 'fv-action fv-action-secondary'; + const text = document.createElement('span'); text.textContent = t('Report', '报告'); + summary.append(icon('report'), text, icon('chevron')); + const list = document.createElement('div'); list.className = 'fv-menu'; + const item = (tag, title, hint) => { + const e = document.createElement(tag); e.className = 'fv-menu-item'; + const strong = document.createElement('strong'); strong.textContent = title; + const small = document.createElement('small'); small.textContent = hint; + e.append(strong, small); + return e; + }; + const supportLabel = t('Diagnostic report', '诊断报告'); + const support = item('button', supportLabel, t('Redacted, for problem reports', '已脱敏,可用于反馈问题')); support.type = 'button'; + const supportTitle = support.querySelector('strong'); + support.onclick = async () => { + if (support.disabled) return; + support.disabled = true; supportTitle.textContent = t('Preparing…', '正在整理…'); + try { await diagnostic(); supportTitle.textContent = supportLabel; menu.open = false; } + catch { supportTitle.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); } + finally { support.disabled = false; } + }; + list.append(support); + if (recordLink) { + const full = item('a', t('Generation record', '生成记录'), t('Every setting of this run (JSON)', '本次生成的完整参数(JSON)')); + full.href = recordLink; full.download = record.report.split('/').pop(); + full.onclick = () => { menu.open = false; }; + list.append(full); + } + menu.append(summary, list); + // Close on an outside click or Escape, like a native menu. Escape must not + // also reach the dialog, which would close the whole window. + const outside = event => { if (menu.open && !menu.contains(event.target)) menu.open = false; }; + menu.addEventListener('toggle', () => { + if (menu.open) document.addEventListener('pointerdown', outside, true); + else { + document.removeEventListener('pointerdown', outside, true); + if (!support.disabled) supportTitle.textContent = supportLabel; + } + }); + menu.addEventListener('keydown', event => { + if (event.key !== 'Escape' || !menu.open) return; + event.preventDefault(); event.stopPropagation(); menu.open = false; summary.focus(); + }); + bar.append(menu); + return bar; +} diff --git a/web/sampling_effort.css b/web/sampling_effort.css index f02b5d2..1d68b87 100644 --- a/web/sampling_effort.css +++ b/web/sampling_effort.css @@ -1,28 +1,37 @@ .fv-sampling-settings{margin-top:18px;padding:14px 16px;border:1px solid var(--fv-border,#ffffff0f);border-radius:14px;background:var(--fv-card,#1e2024)} -.fv-effort{--fv-effort-color:#90b9b2} .fv-studio .fv-sampling-settings .fv-two-pass{margin:10px 0 0;padding:0;border:0;border-radius:0;background:none;min-height:28px} .fv-studio .fv-sampling-settings .fv-two-pass>span{font-size:12px;font-weight:400;color:var(--fv-muted,#969a9f)} .fv-studio .fv-sampling-settings .fv-two-pass input{width:34px;height:20px;min-height:20px} .fv-studio .fv-sampling-settings .fv-two-pass input::before{width:14px;height:14px} .fv-studio .fv-sampling-settings .fv-two-pass input:checked::before{transform:translateX(14px)} -.fv-effort-heading{display:flex;align-items:center;gap:8px;margin:0 0 6px;color:var(--fv-muted,#969a9f);font:12px/1.5 var(--fv-font,system-ui)} -.fv-effort-value{font-weight:550;color:var(--fv-effort-color);transition:color .25s} +.fv-effort{--fv-position:0;--fv-move:cubic-bezier(.25,1.15,.45,1);--fv-tone:var(--fv-accent,#6db8fa)} +.fv-effort-heading{display:flex;align-items:baseline;gap:8px;margin:0 0 10px;color:var(--fv-muted,#969a9f);font:12px/1.5 var(--fv-font,system-ui)} .fv-effort-estimate{margin-left:auto;font-variant-numeric:tabular-nums;text-align:right;font-size:11px} -.fv-effort-rail{position:relative;height:32px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none;outline:none;border-radius:8px} -.fv-effort-track{position:absolute;left:10px;right:10px;top:14px;height:4px;border-radius:8px;background:color-mix(in srgb,var(--fv-ink,#eee) 12%,transparent);overflow:hidden} -.fv-effort-fill{display:block;height:100%;border-radius:inherit;background:var(--fv-effort-color);transition:width .38s cubic-bezier(.22,1,.36,1),background .25s} -.fv-effort-tick{position:absolute;top:14px;width:4px;height:4px;transform:translateX(-50%);border-radius:50%;background:var(--fv-muted,#888);opacity:.55;pointer-events:none} -.fv-effort-pill{position:absolute;left:calc(10px + (100% - 20px) * var(--fv-position,0));top:6px;transform:translateX(-50%);width:20px;height:20px;border-radius:50%;box-sizing:border-box;background:#edf2f8;border:1px solid #fff8;box-shadow:0 1px 4px #0005,0 0 0 4px color-mix(in srgb,var(--fv-effort-color) 9%,transparent);transition:left .38s cubic-bezier(.22,1,.36,1),box-shadow .2s,opacity .18s;pointer-events:none} -.fv-effort-rail:hover .fv-effort-pill,.fv-effort-rail[data-dragging=true] .fv-effort-pill{box-shadow:0 2px 6px #0005,0 0 0 6px color-mix(in srgb,var(--fv-effort-color) 14%,transparent)} -.fv-effort-rail[data-dragging=true] .fv-effort-pill{transition:left 60ms linear,box-shadow .2s} -.fv-effort-rail[data-dragging=true] .fv-effort-fill{transition:width 60ms linear,background .2s} -.fv-effort-rail:focus-visible{outline:2px solid var(--fv-effort-color);outline-offset:3px} -.fv-effort[data-custom=true] .fv-effort-pill{opacity:.45} -.fv-effort-custom{display:block;margin-top:2px;color:var(--fv-muted,#888);font:11px/1.4 var(--fv-font,system-ui)} +.fv-effort-rail{position:relative;cursor:pointer;touch-action:pan-y;user-select:none;-webkit-user-select:none;outline:none;border-radius:20px} +/* A thick track in the interface's own accent; the knob travels inside it. */ +.fv-effort-track{position:relative;display:block;height:36px;border-radius:18px;background:color-mix(in srgb,var(--fv-ink,#eee) 7%,transparent);box-shadow:inset 0 1px 2px #0005} +.fv-effort-fill{position:absolute;inset:0;border-radius:inherit;background:linear-gradient(90deg,color-mix(in srgb,var(--fv-tone) 55%,transparent),var(--fv-tone));clip-path:inset(0 calc((1 - var(--fv-position)) * (100% - 36px)) 0 0 round 18px);transition:clip-path .5s var(--fv-move)} +.fv-effort-stop{position:absolute;top:50%;left:calc(18px + (100% - 36px) * var(--fv-stop));width:5px;height:5px;margin:-2.5px 0 0 -2.5px;border-radius:50%;background:color-mix(in srgb,var(--fv-ink,#eee) 26%,transparent);transition:background .3s;pointer-events:none} +.fv-effort-stop[data-filled]{background:#ffffffb8} +.fv-effort-knob{position:absolute;top:4px;left:calc(4px + (100% - 36px) * var(--fv-position));width:28px;height:28px;border-radius:50%;background:#fff;box-shadow:0 1px 4px #0006,0 0 0 .5px #0000001f;transition:left .5s var(--fv-move),box-shadow .25s,transform .25s ease;pointer-events:none} +.fv-effort-rail:hover .fv-effort-knob{box-shadow:0 2px 6px #0006,0 0 0 5px color-mix(in srgb,var(--fv-tone) 20%,transparent)} +.fv-effort-rail[data-dragging=true] .fv-effort-knob{transform:scale(1.08);transition:left 40ms linear,box-shadow .2s,transform .2s ease;box-shadow:0 3px 10px #0007,0 0 0 6px color-mix(in srgb,var(--fv-tone) 26%,transparent)} +.fv-effort-rail[data-dragging=true] .fv-effort-fill{transition:clip-path 40ms linear} +.fv-effort-knob.fv-effort-pop{animation:fv-effort-pop .36s ease} +@keyframes fv-effort-pop{50%{transform:scale(1.1)}} +.fv-studio .fv-effort .fv-effort-rail:focus-visible,.fv-effort .fv-effort-rail:focus-visible{outline:none} +.fv-effort-rail[data-keyboard]:focus-visible .fv-effort-track{outline:1.5px solid color-mix(in srgb,var(--fv-tone) 70%,transparent);outline-offset:3px} +.fv-effort-names{position:relative;height:20px;margin-top:8px} +.fv-studio .fv-effort .fv-effort-name,.fv-effort .fv-effort-name{position:absolute;top:0;left:calc(18px + (100% - 36px) * var(--fv-stop));transform:translateX(-50%);min-height:0;margin:0;padding:0 5px;border:0;border-radius:5px;background:none;box-shadow:none;color:var(--fv-muted,#969a9f);font:500 12px/1.6 var(--fv-font,system-ui);white-space:nowrap;cursor:pointer;transition:color .2s} +.fv-studio .fv-effort .fv-effort-name:hover,.fv-effort .fv-effort-name:hover{color:var(--fv-ink,#eee);background:none} +.fv-studio .fv-effort .fv-effort-name[data-active],.fv-effort .fv-effort-name[data-active]{color:var(--fv-tone);font-weight:600} +.fv-effort[data-custom=true] .fv-effort-knob{opacity:.5} +.fv-effort[data-custom=true] .fv-effort-name[data-active]{color:var(--fv-muted,#969a9f);font-weight:500} +.fv-effort-custom{display:block;margin-top:4px;color:var(--fv-muted,#888);font:11px/1.4 var(--fv-font,system-ui)} .fv-effort-custom[hidden]{display:none} .fv-effort[data-disabled=true]{opacity:.5} -.fv-effort[data-disabled=true] .fv-effort-rail{cursor:default} -@media(prefers-reduced-motion:reduce){.fv-effort *{transition:none!important}} +.fv-effort[data-disabled=true] .fv-effort-rail,.fv-effort[data-disabled=true] .fv-effort-name{cursor:default} +@media(prefers-reduced-motion:reduce){.fv-effort *{transition:none!important;animation:none!important}} .fv-two-pass-status{margin-left:auto;color:var(--fv-muted);font-size:11px;transition:opacity .2s} .fv-two-pass-unavailable input{opacity:.4;cursor:not-allowed} diff --git a/web/sampling_effort.js b/web/sampling_effort.js index f21e1c0..9780a84 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -1,10 +1,10 @@ // One editor control. The queued request remains an immutable graph snapshot. // Each level is a complete plan: Light is two-pass 8 + 3; the others sample once. export const SAMPLING_EFFORTS = Object.freeze([ - {name: 'Light', zh: '轻量', steps: 8, twoPass: true, color: '#90b9b2'}, - {name: 'Medium', zh: '标准', steps: 12, twoPass: false, color: '#8aafd3'}, - {name: 'High', zh: '精细', steps: 16, twoPass: false, color: '#a8a6d4'}, - {name: 'Max', zh: '极致', steps: 20, twoPass: false, color: '#c4ae8d'}, + {name: 'Light', zh: '轻量', steps: 8, twoPass: true, color: '#3dd6b5'}, + {name: 'Medium', zh: '标准', steps: 12, twoPass: false, color: '#4ea8ff'}, + {name: 'High', zh: '精细', steps: 16, twoPass: false, color: '#9d7bff'}, + {name: 'Max', zh: '极致', steps: 20, twoPass: false, color: '#ffa94d'}, ].map(Object.freeze)); export const effortName = (t, tier) => t(tier.name, tier.zh); @@ -20,35 +20,45 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { const header = el('div', 'fv-effort-heading'); const label = el('span', '', t('Quality', '质量')); const estimate = el('span', 'fv-effort-estimate'); - const selectedLabel = el('span', 'fv-effort-value'); - header.append(label, selectedLabel, estimate); + // The names under the track show the level; the heading does not repeat it. + header.append(label, estimate); const rail = el('div', 'fv-effort-rail'); rail.tabIndex = 0; rail.setAttribute('role', 'slider'); rail.setAttribute('aria-label', label.textContent); rail.setAttribute('aria-valuemin', '0'); rail.setAttribute('aria-valuemax', '3'); rail.setAttribute('aria-orientation', 'horizontal'); + // A thick track with the knob inside it; stops sit at the knob's centers. const track = el('span', 'fv-effort-track'); track.setAttribute('aria-hidden', 'true'); - const fill = el('span', 'fv-effort-fill'); track.append(fill); rail.append(track); - for (let i=0;i<4;i++) { const tick=el('span','fv-effort-tick'); tick.style.left=`calc(10px + (100% - 20px) * ${i / 3})`; tick.setAttribute('aria-hidden','true'); rail.append(tick); } - const pill = el('span', 'fv-effort-pill'); pill.setAttribute('aria-hidden', 'true'); rail.append(pill); + const fill = el('span', 'fv-effort-fill'); track.append(fill); + const stops = [0, 1, 2, 3].map(i => { const stop=el('span','fv-effort-stop'); stop.style.setProperty('--fv-stop', i / 3); track.append(stop); return stop; }); + const knob = el('span', 'fv-effort-knob'); track.append(knob); rail.append(track); + knob.addEventListener('animationend', () => knob.classList.remove('fv-effort-pop')); + const names = el('div', 'fv-effort-names'); + const nameButtons = SAMPLING_EFFORTS.map((tier, i) => { + const b = el('button', 'fv-effort-name', effortName(t, tier)); b.type = 'button'; b.tabIndex = -1; + b.style.setProperty('--fv-stop', i / 3); + b.onclick = () => { if (!disabled && pointer === null) commit(i); }; + names.append(b); return b; + }); const custom = el('span', 'fv-effort-custom', t('Custom steps', '自定义步数')); custom.hidden = true; - element.append(header, rail, custom); + element.append(header, rail, names, custom); let selected = 0, disabled = false, pointer = null, draft = 0, currentSteps = 8, currentRefine = 3, twoPass = true; function paint(position) { - rail.style.setProperty('--fv-position', position / 3); - fill.style.width = `${position / 3 * 100}%`; + element.style.setProperty('--fv-position', position / 3); const index = Math.round(position), tier = SAMPLING_EFFORTS[index]; - element.style.setProperty('--fv-effort-color', tier.color); - selectedLabel.textContent = effortName(t, tier); + nameButtons.forEach((b, i) => b.toggleAttribute('data-active', i === index)); + stops.forEach((stop, i) => stop.toggleAttribute('data-filled', i <= position + .01)); rail.setAttribute('aria-valuenow', String(index)); rail.setAttribute('aria-valuetext', `${effortName(t, tier)}, ${tier.steps}${tier.twoPass ? ' + 3' : ''} ${t('steps', '步')}`); } function position(event) { const box = rail.getBoundingClientRect(); - return Math.max(0, Math.min(3, (event.clientX - box.left - 10) / (box.width - 20) * 3)); + // The knob's center travels between 18 px from either end (4 px inset, 28 px knob). + return Math.max(0, Math.min(3, (event.clientX - box.left - 18) / (box.width - 36) * 3)); } function commit(index) { selected = index; custom.hidden = true; delete element.dataset.custom; paint(index); onChange(SAMPLING_EFFORTS[index]); + knob.classList.remove('fv-effort-pop'); void knob.offsetWidth; knob.classList.add('fv-effort-pop'); } function cancel() { const id = pointer; pointer = null; delete rail.dataset.dragging; @@ -57,8 +67,9 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { onPreview(null); } rail.onpointerdown = event => { + delete rail.dataset.keyboard; // The focus ring is for keyboard use only. if (disabled || event.button !== 0 || pointer !== null) return; - event.preventDefault(); rail.focus(); pointer = event.pointerId; + event.preventDefault(); rail.focus({preventScroll: true, focusVisible: false}); pointer = event.pointerId; rail.setPointerCapture(pointer); rail.dataset.dragging = 'true'; draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)]); }; @@ -75,6 +86,7 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { rail.onlostpointercapture = () => { if (pointer !== null) cancel(); }; rail.onblur = () => { if (pointer !== null) cancel(); }; rail.onkeydown = event => { + rail.dataset.keyboard = 'true'; if (event.key === 'Escape' && pointer !== null) { event.preventDefault(); event.stopPropagation(); cancel(); return; } if (disabled || pointer !== null) return; const next = {ArrowRight: selected + 1, ArrowUp: selected + 1, ArrowLeft: selected - 1, ArrowDown: selected - 1, Home: 0, End: 3}[event.key]; @@ -94,7 +106,7 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { const unit = currentSteps === 1 && !twoPass ? t('step', '步') : t('steps', '步'); custom.textContent = `${t('Custom', '自定义')} · ${currentSteps}${twoPass ? ' + ' + currentRefine : ''} ${unit}`; paint(selected); - if (isCustom) { rail.setAttribute('aria-valuetext', custom.textContent); selectedLabel.textContent=t('Custom','自定义'); } + if (isCustom) rail.setAttribute('aria-valuetext', custom.textContent); rail.title = disabled ? t('Controlled by connected nodes.', '由连接的节点控制。') : t('Same model. Higher effort uses more sampling steps; results vary by scene.', '使用同一模型,提高档位会增加采样步数;效果因场景而异。'); } diff --git a/web/share.css b/web/share.css index 9e9e850..67fc8a4 100644 --- a/web/share.css +++ b/web/share.css @@ -1,20 +1,24 @@ .fv-studio.fv-share{width:min(980px,calc(100vw - 64px));height:fit-content;max-height:calc(100dvh - 64px);padding:0;border:1px solid var(--fv-border);border-radius:22px;background:var(--fv-bg);color:var(--fv-ink);box-shadow:0 30px 100px #0008;overflow:hidden;display:flex;flex-direction:column} -.fv-studio.fv-share:not([open]){display:none}.fv-studio.fv-share::backdrop{background:#05080db3;backdrop-filter:blur(12px)} +.fv-studio.fv-share:not([open]){display:none}.fv-studio.fv-share:focus{outline:none}.fv-studio.fv-share::backdrop{background:#05080db3;backdrop-filter:blur(12px)} .fv-studio.fv-share[open]{animation:fv-share-enter .24s cubic-bezier(.2,.7,.2,1)} .fv-share-header{display:flex;align-items:center;gap:24px;padding:20px 26px;border-bottom:1px solid var(--fv-border)} .fv-share-header h2{font-size:16px;font-weight:550;margin:0;margin-right:auto} -.fv-share-tabs{display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--fv-card)} -.fv-share-tabs button{min-width:68px;border:0;border-radius:7px;padding:6px 14px;font-size:12px;transition:background .2s,color .2s} -.fv-share-tabs button[aria-selected=true]{background:var(--fv-hover);color:var(--fv-ink);box-shadow:0 1px 3px #0003} +.fv-share-tabs{position:relative;display:flex;gap:2px;padding:3px;border-radius:12px;background:color-mix(in srgb,var(--fv-ink,#eee) 7%,transparent);box-shadow:inset 0 1px 2px #0005} +.fv-share-indicator{position:absolute;top:3px;left:3px;bottom:3px;border-radius:9px;background:linear-gradient(135deg,#3dd6b5,#4ea8ff);box-shadow:0 2px 10px #4ea8ff55;transition:transform .45s cubic-bezier(.25,1.18,.45,1),width .45s cubic-bezier(.25,1.18,.45,1)} +.fv-studio .fv-share-tabs button{position:relative;z-index:1;min-width:72px;border:0;border-radius:9px;padding:6px 16px;font-size:12px;font-weight:550;background:none;color:var(--fv-muted);box-shadow:none;transition:color .25s} +.fv-studio .fv-share-tabs button[aria-selected=true]{background:none;color:#06121c;box-shadow:none} .fv-share-close{font-size:24px!important;width:32px;height:32px;padding:0!important;line-height:1} .fv-share-body{overflow:auto;flex-shrink:1;padding:26px;display:flex;justify-content:center;background:radial-gradient(ellipse at 50% 20%,#20344b66,transparent 72%),#0a1017;min-height:160px} -.fv-share-preview{position:relative;align-self:start;width:auto;max-width:100%;max-height:calc(100dvh - 256px);box-shadow:0 12px 40px #0005;overflow:hidden;border-radius:10px} +.fv-share-preview{position:relative;align-self:start;width:auto;max-width:100%;max-height:calc(100dvh - 256px);box-shadow:0 18px 50px #0007,0 0 0 1px #ffffff14;overflow:hidden;border-radius:14px} .fv-share-preview canvas{position:relative;display:block;max-width:100%;max-height:calc(100dvh - 256px);width:auto;height:auto;pointer-events:none} .fv-share-preview video{position:absolute;object-fit:contain;background:#000}.fv-share-preview video[hidden]{display:none} .fv-share-footer{display:flex;flex-shrink:0;align-items:center;justify-content:flex-end;gap:12px;padding:18px 26px;border-top:1px solid var(--fv-border)} .fv-share-status{font-size:12px;color:var(--fv-muted);margin-right:auto;max-width:65%} .fv-share[data-busy=true] .fv-share-status:before{content:'';display:inline-block;width:10px;height:10px;margin-right:8px;border:1.5px solid #fff3;border-top-color:var(--fv-accent);border-radius:50%;animation:fv-share-spin .8s linear infinite} .fv-share[data-error=true] .fv-share-status{color:var(--fv-danger)} +.fv-studio .fv-share-footer .fv-primary{border:0;background:linear-gradient(135deg,#3dd6b5,#4ea8ff);color:#06121c;font-weight:650;box-shadow:0 4px 14px #4ea8ff40;transition:transform .2s cubic-bezier(.25,1.18,.45,1),box-shadow .2s,filter .2s} +.fv-studio .fv-share-footer .fv-primary:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 8px 22px #4ea8ff55;filter:brightness(1.05)} +.fv-studio .fv-share-footer .fv-primary:active:not(:disabled){transform:translateY(0) scale(.98)} @keyframes fv-share-spin{to{transform:rotate(360deg)}} @keyframes fv-share-enter{from{opacity:0;transform:translateY(8px) scale(.99)}to{opacity:1;transform:translateY(0) scale(1)}} @media(max-width:600px){.fv-studio.fv-share{width:calc(100vw - 20px);max-height:calc(100dvh - 20px)}.fv-share-header{padding:16px;gap:12px}.fv-share-body{padding:14px}.fv-share-footer{padding:14px}.fv-share-status{max-width:50%}.fv-share-tabs button{min-width:54px;padding:6px 10px}} diff --git a/web/share.js b/web/share.js index 5def439..61561c4 100644 --- a/web/share.js +++ b/web/share.js @@ -8,52 +8,65 @@ const button=(text,action,cls='fv-quiet')=>{const b=el('button',cls,text);b.type const identity=r=>/^FreeVideo\/(\d{4}-\d{2}-\d{2}\/[a-f0-9]{32})\/video\.mp4$/.exec(r?.video||'')?.[1]; const image=src=>new Promise((resolve,reject)=>{const im=new Image();im.onload=()=>resolve(im);im.onerror=reject;im.src=src;}); const duration=n=>Number.isFinite(n)&&n>=0?`${n.toFixed(1)} s`:'—'; -const memory=n=>Number.isFinite(n)&&n>=0?`${(n/2**30).toFixed(1)} GiB`:'—'; +const tint=(hex,alpha)=>{const v=parseInt(hex.slice(1),16);return `rgba(${v>>16},${v>>8&255},${v&255},${alpha})`;}; let opened; // Deterministic Canvas rendering: browser fonts cover CJK and no external // resources or user prompt are included. The image window preserves every pixel. export function shareLayout(width,height) { - const w=width>=height?1200:900, scale=w/width; + const w=width>=height?1200:900, scale=w/width, footer=w>=1000?148:200; const h=Math.round(Math.min(1800,height*scale)/2)*2, artWidth=Math.round(h*width/height/2)*2; - return {width:w,height:h+228,rect:[Math.floor((w-artWidth)/2),0,artWidth,h],footer:228}; + return {width:w,height:h+footer,rect:[Math.floor((w-artWidth)/2),0,artWidth,h],footer}; } export function drawShareCard(canvas,frame,logo,record,t) { const g=record.geometry||{}, shape=shareLayout(g.width||frame?.naturalWidth||1344,g.height||frame?.naturalHeight||768); canvas.width=shape.width;canvas.height=shape.height; - const ctx=canvas.getContext('2d'),w=shape.width,y=shape.rect[3],pad=40; - ctx.fillStyle='#101720';ctx.fillRect(0,0,w,shape.height);ctx.clearRect(...shape.rect); - const bg=ctx.createLinearGradient(0,y,w,shape.height);bg.addColorStop(0,'#192330');bg.addColorStop(1,'#101720'); - ctx.fillStyle=bg;ctx.fillRect(0,y,w,shape.footer); - if(frame)ctx.drawImage(frame,...shape.rect); - ctx.drawImage(logo,pad,y+27,168,168*337/2016); - const font='"Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif'; - const fit=(text,size,max)=>{while(size>12){ctx.font=`500 ${size}px ${font}`;if(ctx.measureText(text).width<=max)break;size--;}return size;}; - const gpu=(record.gpu||t('GPU unavailable','显卡信息未记录')).replace(/^NVIDIA\s+/,'').replace(/GeForce\s+/,''); - fit(gpu,21,w-320);ctx.fillStyle='#a9b8ca';ctx.textAlign='right';ctx.fillText(gpu,w-pad,y+52);ctx.textAlign='left'; - ctx.fillStyle='#2a3746';ctx.fillRect(pad,y+82,w-pad*2,1); + const ctx=canvas.getContext('2d'),w=shape.width,y=shape.rect[3],H=shape.footer,pad=44,wide=w>=1000; const p=record.sampling_plan||{},steps=p.base_steps,refine=p.enabled?p.refine_steps:0; // Results made with the earlier 8 + 2 default count as Light. - const tier=effortFor(steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine); - const tierName=tier?effortName(t,tier):(steps?t('Custom','自定义'):'—'); - const unified=record.memory_model==='unified'; - const items=[ - [t('Generation time','生成耗时'),duration(record.request_seconds),t('Sampling ','采样 ')+duration(record.sample_seconds),'#edf3fc'], - [unified?t('Process RAM peak','进程内存峰值'):t('Peak VRAM · PyTorch','显存峰值 · PyTorch'), - memory(unified?record.ram_peak_bytes:record.vram_peak_bytes), - unified?t('Unified memory ','统一内存 ')+memory(record.unified_total_bytes):'RAM '+memory(record.ram_peak_bytes),'#edf3fc'], - [t('Quality','质量'),tierName,steps?`${steps}${refine?' + '+refine:''} ${t('steps','步')}`:'',tier?.color||'#b4bed0'], - ]; - const col=(w-pad*2)/3; - items.forEach(([label,value,detail,color],i)=>{ - const x=pad+col*i;fit(label,18,col-24);ctx.fillStyle='#93a3b8';ctx.fillText(label,x,y+118); - fit(value,36,col-24);ctx.fillStyle=color;ctx.fillText(value,x,y+163); - ctx.font=`400 16px ${font}`;ctx.fillStyle='#8595aa';ctx.fillText(detail,x,y+196); - }); + const tier=effortFor(steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine), accent=tier?.color||'#8fa3bb'; + ctx.fillStyle='#0b1016';ctx.fillRect(0,0,w,shape.height);ctx.clearRect(...shape.rect); + const bg=ctx.createLinearGradient(0,y,0,y+H);bg.addColorStop(0,'#131b26');bg.addColorStop(1,'#0b1016'); + ctx.fillStyle=bg;ctx.fillRect(0,y,w,H); + const glow=ctx.createRadialGradient(w*.86,y+H*.55,0,w*.86,y+H*.55,w*.42); + glow.addColorStop(0,tint(accent,.16));glow.addColorStop(1,tint(accent,0));ctx.fillStyle=glow;ctx.fillRect(0,y,w,H); + const line=ctx.createLinearGradient(0,0,w,0);line.addColorStop(0,tint(accent,0));line.addColorStop(.5,tint(accent,.75));line.addColorStop(1,tint(accent,0)); + ctx.fillStyle=line;ctx.fillRect(0,y,w,2); + if(frame)ctx.drawImage(frame,...shape.rect); + const font='"Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif'; + const fit=(text,weight,size,max)=>{while(size>12){ctx.font=`${weight} ${size}px ${font}`;if(ctx.measureText(text).width<=max)break;size--;}return ctx.measureText(text).width;}; + const logoWidth=wide?156:140, logoTop=wide?y+H/2-26:y+30; + ctx.drawImage(logo,pad,logoTop,logoWidth,logoWidth*337/2016); const seconds=g.seconds||g.frames/(g.fps||24); - ctx.font=`400 14px ${font}`;ctx.fillStyle='#667b91';ctx.textAlign='right'; - ctx.fillText(`${g.width} × ${g.height}${Number.isFinite(seconds)?' · '+seconds.toFixed(1)+' s':''}`,w-pad,y+216); + ctx.font=`400 14px ${font}`;ctx.fillStyle='#6f8399';ctx.textAlign=wide?'left':'right'; + ctx.fillText(`${g.width} × ${g.height}${Number.isFinite(seconds)?' · '+seconds.toFixed(1)+' s':''}`,wide?pad:w-pad,wide?y+H/2+24:y+46); + ctx.textAlign='left'; + const gpu=(record.gpu||t('GPU not recorded','显卡未记录')).replace(/^NVIDIA\s+/,'').replace(/GeForce\s+/,''); + const stats=[[t('Generation time','生成耗时'),duration(record.request_seconds)],[t('GPU','显卡'),gpu], + [t('Quality','质量'),tier?effortName(t,tier):(steps?t('Custom','自定义'):'—'),true]]; + const labelY=wide?y+H/2-16:y+H-80, valueY=wide?y+H/2+26:y+H-38; + const drawStat=([label,value,pill],x,max)=>{ + ctx.font=`500 13px ${font}`;ctx.fillStyle='#7d90a6';ctx.fillText(label,x,labelY); + if(!pill){fit(value,600,28,max);ctx.fillStyle='#eef3fa';ctx.fillText(value,x,valueY);return;} + const text=fit(value,650,20,max-32),pw=text+32; + ctx.beginPath();ctx.roundRect(x,valueY-27,pw,34,17);ctx.fillStyle=tint(accent,.16);ctx.fill(); + ctx.lineWidth=1.2;ctx.strokeStyle=tint(accent,.6);ctx.stroke(); + ctx.fillStyle=accent;ctx.fillText(value,x+16,valueY-3); + }; + if(wide){ + // Right-aligned columns; each takes its own width, separated by hairlines. + const widths=stats.map(([label,value,pill])=>{ctx.font=`500 13px ${font}`;const l=ctx.measureText(label).width; + const v=pill?Math.min(fit(value,650,20,220),220)+32:Math.min(fit(value,600,28,300),300);return Math.max(l,v);}); + let x=w-pad; + for(let i=stats.length-1;i>=0;i--){ + x-=widths[i];drawStat(stats[i],x,i===2?220+32:300); + if(i){x-=28;ctx.fillStyle='#ffffff14';ctx.fillRect(x,labelY-12,1,valueY-labelY+20);x-=28;} + } + } else { + const col=(w-pad*2)/3; + stats.forEach((stat,i)=>drawStat(stat,pad+col*i,col-20)); + } return shape; } @@ -65,10 +78,12 @@ export function shareButton(record,t){ export async function openShare(record,t){ if(opened?.open){opened.focus();return;} const id=identity(record);if(!id)return; - const dialog=el('dialog','fv-studio fv-share');opened=dialog;dialog.setAttribute('aria-label',t('Share','分享')); + const dialog=el('dialog','fv-studio fv-share');opened=dialog;dialog.setAttribute('aria-label',t('Share','分享'));dialog.tabIndex=-1; const heading=el('header','fv-share-header'),title=el('h2','',t('Share','分享')); const close=button('×',()=>closeDialog(dialog),'fv-quiet fv-share-close');close.setAttribute('aria-label',t('Close','关闭')); const tabs=el('div','fv-share-tabs');tabs.setAttribute('role','tablist'); + const indicator=el('span','fv-share-indicator');indicator.setAttribute('aria-hidden','true');tabs.append(indicator); + const slide=()=>{const b=tabs.querySelector('[aria-selected=true]');if(b)indicator.style.cssText=`transform:translateX(${b.offsetLeft-3}px);width:${b.offsetWidth}px`;}; let kind='image',ready=false,busy=false,disposed=false,metadata,shape,template,first,logo,exported; let exportRequest; const abort=new AbortController(); @@ -87,6 +102,7 @@ export async function openShare(record,t){ b.setAttribute('aria-selected',String(b.dataset.kind===kind));b.tabIndex=b.dataset.kind===kind?0:-1; }); save.textContent=kind==='image'?t('Save image','保存分享图'):t('Save video','保存分享视频'); + slide(); if(!ready)return; drawShareCard(canvas,kind==='image'?first:null,logo,metadata,t); player.hidden=kind!=='video'; @@ -123,7 +139,8 @@ export async function openShare(record,t){ } dialog.addEventListener('cancel',event=>{event.preventDefault();closeDialog(dialog);}); dialog.onclose=()=>{disposed=true;abort.abort();exportRequest?.abort();player.pause();player.removeAttribute('src');player.load();dialog.remove();if(opened===dialog)opened=null;}; - document.body.append(dialog);dialog.showModal();message.textContent=t('Preparing preview…','正在准备预览…'); + // Start focus on the dialog itself, not with a focus ring on the first tab. + document.body.append(dialog);dialog.showModal();dialog.focus();slide();message.textContent=t('Preparing preview…','正在准备预览…'); try{ const response=await api.fetchApi('/freevideo/share?'+new URLSearchParams({id}),{signal:abort.signal}); if(!response.ok)throw new Error(t('This saved video is unavailable.','这个已保存的视频暂时无法读取。')); diff --git a/web/studio.css b/web/studio.css index c9572da..859e017 100644 --- a/web/studio.css +++ b/web/studio.css @@ -76,8 +76,10 @@ .fv-studio .fv-stage[data-short=true] .fv-generation-times{display:none} .fv-studio .fv-stage-media video{animation:fv-preview-appear .55s ease} @keyframes fv-preview-appear{from{opacity:0}to{opacity:1}} -.fv-studio .fv-stats{animation:fv-panel-enter .35s ease;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;text-align:center;margin:0}.fv-studio .fv-stat{padding:12px 4px;background:var(--fv-card);border-radius:var(--fv-r-md)} -.fv-studio .fv-stat strong{font-size:var(--fv-strong);font-weight:var(--fv-semibold);display:block;color:var(--fv-ink);font-variant-numeric:tabular-nums}.fv-studio .fv-stat span{font-size:var(--fv-micro);color:var(--fv-muted)} +/* One metrics card; the 1 px gaps draw the dividers in any column count. */ +.fv-studio .fv-stats{animation:fv-panel-enter .35s ease;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;text-align:center;margin:0;border:1px solid var(--fv-border);border-radius:14px;background:var(--fv-border);overflow:hidden}.fv-studio .fv-stat{padding:13px 6px 12px;background:var(--fv-card)} +.fv-studio .fv-stat strong{font-size:var(--fv-strong);font-weight:var(--fv-semibold);display:block;color:var(--fv-ink);font-variant-numeric:tabular-nums;white-space:nowrap}.fv-studio .fv-stat span{display:block;margin-top:3px;font-size:var(--fv-micro);color:var(--fv-muted)} +.fv-studio .fv-stats+.fv-budget{margin-top:-9px} .fv-studio .fv-result-links{animation:fv-panel-enter .35s ease;display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap} .fv-studio .fv-runbar{position:sticky;bottom:0;padding:16px 0 22px;background:linear-gradient(transparent,var(--fv-bg) 12%);z-index:2} .fv-studio .fv-runbar .fv-primary{width:100%;min-height:var(--fv-h-lg);font-size:14px} diff --git a/web/studio.js b/web/studio.js index ba7e38a..62012b1 100644 --- a/web/studio.js +++ b/web/studio.js @@ -11,9 +11,8 @@ import { animateDetails, closeDialog } from './motion.js'; import { openLibrary, latestVideo } from './library.js'; import { createStudioQueue, randomSeed } from './studio_queue.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt } from './prompt_references.js'; -import { outputDownloadURL } from './output_download.js'; import { createSamplingEffort } from './sampling_effort.js'; -import { shareButton } from './share.js'; +import { resultActions } from './result_actions.js'; const languageOverride = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('freevideo_lang') : null; @@ -388,6 +387,7 @@ export function openStudio(node) { stats.hidden = true; budget.textContent = ''; links.replaceChildren(); + delete progress.report.dataset.covered; prewarm.textContent = ''; clearTimeout(revealTimer); revealTimer = null; delete stage.dataset.revealing; if (progress.element.hidden) stage.querySelector('video')?.pause(); @@ -408,7 +408,7 @@ export function openStudio(node) { catch (error) { regenerate.disabled = false; status.dataset.error = 'true'; status.textContent = error.message; } }, 'fv-quiet'); reuseRow.append(reuseNotice, regenerate); - output.append(status, reuseRow, stats, budget, links, progress.report, prewarm); + output.append(status, reuseRow, links, stats, budget, progress.report, prewarm); const failure = createErrorPanel(t); output.append(failure.element); if (node.freevideoFailure) failure.show(node.freevideoFailureReport || node.freevideoFailure, false); let result = node.freevideoLastResult || app.nodeOutputs?.[node.id]?.freevideo_summary?.[0]; @@ -428,9 +428,9 @@ export function openStudio(node) { budget.textContent = r.result_cache_hit ? '' : unified ? (Number.isFinite(r.unified_reserve_bytes) ? `${t('Reserved unified memory', '预留统一内存')} ${number(r.unified_reserve_bytes, 2 ** 30, 'GiB')}` : '') : (Number.isFinite(r.gpu_budget_bytes) ? `${t('VRAM budget', '可用显存预算')} ${number(r.gpu_budget_bytes, 2 ** 30, 'GiB')} · ${t('Device', '显卡总量')} ${number(r.gpu_total_bytes, 2 ** 30, 'GiB')}` : ''); - links.replaceChildren(); - for (const [label, file, cls] of [[t('Download video', '下载视频'), r.video, 'fv-primary'], [t('Report', '查看报告'), r.report, 'fv-quiet']]) { if (!file) continue; const a = el('a', label, cls); a.href = outputDownloadURL(api, file); a.download = file === r.video ? '' : file.split('/').pop(); links.append(a); } - links.append(shareButton(r, t)); + // One report entry: the diagnostic export joins the report menu once a result exists. + progress.report.dataset.covered = 'true'; + links.replaceChildren(resultActions(r, t, {diagnostic: progress.hasReport() ? progress.downloadReport : null})); const g = r.geometry; if (g?.width && g?.height) previewSize(g.width, g.height); if (!progress.element.hidden) { progress.update({phase: 'complete', overall: {status: 'complete', fraction: 1, remaining_seconds: 0}});