From 00026ae870742f202f7fd3f82b6ffc6b6e20ce60 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 6 Oct 2026 01:28:18 +0000 Subject: [PATCH] Redesign the quality slider, result actions and sharing card (0.2.1) Quality slider: a thick track in the interface accent with the knob inside it, level names under the stops (click to choose), a short pop on commit and a focus ring only for keyboard use. The heading no longer repeats the level. Result area: one action bar right under the video. Download video is the primary action, then Share, then one Report menu that holds the diagnostic report and the generation record, so the Studio no longer shows "View report" and "Download report" side by side. Escape closes the menu without closing the Studio, and the menu says when the diagnostic report is unavailable. Measurements sit in one card under the actions, in Create and in Creations. Sharing card: generation time, GPU and quality level on an accent footer. The share dialog opens without a stray focus ring. Release 0.2.1. --- freevideo_engine/release_notes.json | 24 ++++--- web/generation_progress.css | 2 +- web/generation_progress.js | 22 ++++--- web/library.js | 12 +--- web/result_actions.css | 23 +++++++ web/result_actions.js | 98 +++++++++++++++++++++++++++++ web/sampling_effort.css | 41 +++++++----- web/sampling_effort.js | 46 +++++++++----- web/share.css | 14 +++-- web/share.js | 85 +++++++++++++++---------- web/studio.css | 6 +- web/studio.js | 12 ++-- 12 files changed, 274 insertions(+), 111 deletions(-) create mode 100644 web/result_actions.css create mode 100644 web/result_actions.js 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}});