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 = ``;
+ 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}});