feat(ui): add article TOC and interactive VRAM calculator for Wan 2.2
Deploy / build (push) Successful in 22s

This commit is contained in:
hoelee committed 2026-10-21 06:44:04 +08:00
1 parent 0dae8b0daa
commit 2c10cad52b
4 files changed
+609 -1

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
---
import type { MarkdownHeading } from 'astro';
interface Props {
headings: MarkdownHeading[];
lang?: 'en' | 'zh';
}
const { headings, lang = 'en' } = Astro.props;
// Filter for h2 and h3 headings only (level 2 & 3)
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
const hasHeadings = tocHeadings.length >= 3;
---
{hasHeadings && (
<nav class="toc-container" aria-label={lang === 'zh' ? '文章目录' : 'Table of Contents'}>
<details class="toc-details" open>
<summary class="toc-summary">
<span class="toc-title">
<svg class="toc-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
{lang === 'zh' ? '目录' : 'Table of Contents'}
</span>
<span class="toc-count">({tocHeadings.length})</span>
</summary>
<ul class="toc-list">
{tocHeadings.map((h) => (
<li class={`toc-item depth-${h.depth}`}>
<a href={`#${h.slug}`} class="toc-link">
{h.text}
</a>
</li>
))}
</ul>
</details>
</nav>
)}
<style>
.toc-container {
margin: 2rem 0;
padding: 0;
border-radius: var(--radius, 8px);
background: var(--surface, #16181d);
border: 1px solid var(--border, #2d3139);
overflow: hidden;
}
.toc-details {
padding: 0;
}
.toc-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.15rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text, #e2e8f0);
cursor: pointer;
user-select: none;
list-style: none;
transition: background 0.15s ease;
}
.toc-summary::-webkit-details-marker {
display: none;
}
.toc-summary:hover {
background: rgba(109, 141, 255, 0.06);
}
.toc-title {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.toc-icon {
color: var(--brand, #6d8dff);
flex-shrink: 0;
}
.toc-count {
font-size: 0.82rem;
color: var(--muted, #94a3b8);
font-weight: 400;
}
.toc-list {
margin: 0;
padding: 0.5rem 1.15rem 1rem 1.15rem;
list-style: none;
border-top: 1px solid var(--border, #2d3139);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.toc-item {
font-size: 0.9rem;
line-height: 1.45;
}
.toc-item.depth-3 {
margin-left: 1.25rem;
font-size: 0.85rem;
opacity: 0.9;
}
.toc-link {
color: var(--muted, #94a3b8);
text-decoration: none;
transition: color 0.15s ease, transform 0.15s ease;
display: inline-block;
}
.toc-link:hover {
color: var(--brand, #6d8dff);
text-decoration: underline;
}
@media (max-width: 640px) {
.toc-container {
margin: 1.5rem 0;
}
.toc-summary {
padding: 0.75rem 1rem;
}
.toc-list {
padding: 0.5rem 1rem 0.85rem 1rem;
}
}
</style>
@@ -61,6 +61,240 @@ Halving the pixel count does not halve the time — it cuts it to about a third.
That is the usual superlinear scaling of a diffusion transformer, and it is the
reason 480p exists as a draft tier at all.
<div class="interactive-calc" id="wan-vram-calc">
<div class="calc-header">
<span class="calc-badge">Interactive Tool</span>
<h4>Wan 2.2 TI2V-5B Render Time & VRAM Estimator</h4>
<p>Simulate sampling duration, decode memory, and safe upscale segmentation based on my RTX 3060 12 GB benchmarks.</p>
</div>
<div class="calc-grid">
<div class="calc-field">
<label for="calc-res">Resolution Tier</label>
<select id="calc-res">
<option value="480">832×480 (Fast Draft — ~6 min / 5s)</option>
<option value="704" selected>1280×704 (Production Base — ~18 min / 5s)</option>
<option value="736">1280×736 (Multiple of 32 for true 1080p — ~19.5 min / 5s)</option>
</select>
</div>
<div class="calc-field">
<label for="calc-sec">Clip Duration (seconds)</label>
<div class="calc-slider-wrap">
<input type="range" id="calc-sec" min="1" max="10" step="1" value="5">
<span class="calc-val" id="calc-sec-val">5 s (121 frames @ 24fps)</span>
</div>
</div>
<div class="calc-field">
<label for="calc-steps">Sampling Steps</label>
<div class="calc-slider-wrap">
<input type="range" id="calc-steps" min="10" max="30" step="5" value="20">
<span class="calc-val" id="calc-steps-val">20 steps</span>
</div>
</div>
</div>
<div class="calc-results">
<div class="calc-stat">
<span class="stat-label">Estimated Total Render</span>
<span class="stat-num" id="stat-time">~17.9 min</span>
<span class="stat-sub" id="stat-breakdown">15.1m sampling + 2.8m decode</span>
</div>
<div class="calc-stat">
<span class="stat-label">Peak VRAM Usage</span>
<span class="stat-num" id="stat-vram">12.1 GB</span>
<span class="stat-sub" id="stat-vram-note">Tight fit on 12 GB card</span>
</div>
<div class="calc-stat">
<span class="stat-label">SeedVR2 1.5× Split Strategy</span>
<span class="stat-num" id="stat-split">3 segments</span>
<span class="stat-sub" id="stat-split-note">Max 41 frames/segment to prevent crash</span>
</div>
</div>
</div>
<style>
.interactive-calc {
margin: 2.2rem 0;
padding: 1.4rem;
border-radius: var(--radius, 8px);
background: var(--surface, #16181d);
border: 1px solid var(--border, #2d3139);
}
.calc-header {
margin-bottom: 1.2rem;
}
.calc-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
font-size: 0.72rem;
font-family: var(--font-mono);
text-transform: uppercase;
font-weight: 600;
color: var(--brand, #6d8dff);
background: rgba(109, 141, 255, 0.12);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.calc-header h4 {
margin: 0.2rem 0 0.4rem;
font-size: 1.15rem;
color: var(--text, #e2e8f0);
}
.calc-header p {
margin: 0;
font-size: 0.88rem;
color: var(--muted, #94a3b8);
}
.calc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.1rem;
margin-bottom: 1.4rem;
}
.calc-field label {
display: block;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted, #94a3b8);
margin-bottom: 0.45rem;
}
.calc-field select {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 6px;
background: var(--bg, #0b0c0e);
border: 1px solid var(--border, #2d3139);
color: var(--text, #e2e8f0);
font-size: 0.88rem;
}
.calc-slider-wrap {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.calc-slider-wrap input[type="range"] {
width: 100%;
accent-color: var(--brand, #6d8dff);
cursor: pointer;
}
.calc-val {
font-size: 0.82rem;
font-family: var(--font-mono);
color: var(--brand, #6d8dff);
}
.calc-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
padding-top: 1.2rem;
border-top: 1px solid var(--border, #2d3139);
}
.calc-stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted, #94a3b8);
margin-bottom: 0.25rem;
}
.stat-num {
font-size: 1.35rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text, #e2e8f0);
}
.stat-sub {
font-size: 0.78rem;
color: var(--muted, #94a3b8);
margin-top: 0.2rem;
}
@media (max-width: 600px) {
.interactive-calc { padding: 1rem; }
.calc-results { grid-template-columns: 1fr; }
}
</style>
<script>
(function initCalc() {
const resEl = document.getElementById('calc-res');
const secEl = document.getElementById('calc-sec');
const secVal = document.getElementById('calc-sec-val');
const stepsEl = document.getElementById('calc-steps');
const stepsVal = document.getElementById('calc-steps-val');
const timeEl = document.getElementById('stat-time');
const bdEl = document.getElementById('stat-breakdown');
const vramEl = document.getElementById('stat-vram');
const vramNote = document.getElementById('stat-vram-note');
const splitEl = document.getElementById('stat-split');
const splitNote = document.getElementById('stat-split-note');
if (!resEl || !secEl || !stepsEl) return;
function update() {
const res = resEl.value;
const sec = parseInt(secEl.value, 10);
const steps = parseInt(stepsEl.value, 10);
// 1s = 25 frames, 5s = 121 frames
const frames = sec === 1 ? 25 : (sec - 1) * 24 + 25;
secVal.textContent = `${sec} s (${frames} frames @ 24fps)`;
stepsVal.textContent = `${steps} steps`;
// Measured constants for 5s (121 frames, 20 steps):
// 480p: sample 4.8m, decode 1.3m
// 704p: sample 15.1m, decode 2.8m
// 736p: ~16.5m, decode 3.0m
let baseSamplePerFrameStep = 0;
let baseDecodePerFrame = 0;
let vramPeak = 0;
if (res === '480') {
baseSamplePerFrameStep = 4.8 / (121 * 20);
baseDecodePerFrame = 1.3 / 121;
vramPeak = 10.3;
} else if (res === '704') {
baseSamplePerFrameStep = 15.1 / (121 * 20);
baseDecodePerFrame = 2.8 / 121;
vramPeak = 12.1;
} else {
baseSamplePerFrameStep = 16.5 / (121 * 20);
baseDecodePerFrame = 3.0 / 121;
vramPeak = 12.2;
}
const sampleTime = frames * steps * baseSamplePerFrameStep;
const decodeTime = frames * baseDecodePerFrame;
const totalTime = sampleTime + decodeTime;
timeEl.textContent = `~${totalTime.toFixed(1)} min`;
bdEl.textContent = `${sampleTime.toFixed(1)}m sampling + ${decodeTime.toFixed(1)}m decode`;
vramEl.textContent = `${vramPeak} GB`;
if (vramPeak > 12.0) {
vramNote.textContent = 'Requires tiled VAE decode to survive 12 GB';
vramNote.style.color = '#f59e0b';
} else {
vramNote.textContent = 'Fits in 12 GB with clean cache';
vramNote.style.color = 'var(--muted)';
}
// SeedVR2 segmenting (limit 41 frames)
const segments = Math.ceil(frames / 41);
splitEl.textContent = `${segments} segment${segments > 1 ? 's' : ''}`;
if (frames > 41) {
splitNote.textContent = `Cut into ≤41 frames (prevents access violation)`;
} else {
splitNote.textContent = `Single pass is safe (≤41 frames)`;
}
}
resEl.addEventListener('change', update);
secEl.addEventListener('input', update);
stepsEl.addEventListener('input', update);
update();
})();
</script>
## Failure one: the decode that runs out of host memory
The first 720p attempt sampled fine and then died in the VAE decode:
@@ -43,6 +43,234 @@ banner: /banners/running-ai-video-locally-on-a-12gb-gpu.jpg
像素数减半,时间不是减半,而是降到约三分之一。这是扩散 Transformer 典型的超线性关系,也正是 480p 这档草稿存在的理由。
<div class="interactive-calc" id="wan-vram-calc">
<div class="calc-header">
<span class="calc-badge">交互工具</span>
<h4>Wan 2.2 TI2V-5B 渲染时间与显存估算器</h4>
<p>基于 RTX 3060 12GB 实测基准数据,实时模拟不同分辨率、秒数与步数下的采样时长、显存需求与分段策略。</p>
</div>
<div class="calc-grid">
<div class="calc-field">
<label for="calc-res">生成分辨率档位</label>
<select id="calc-res">
<option value="480">832×480(快速草稿档 — 约 6 分钟 / 5秒)</option>
<option value="704" selected>1280×704(生产基础档 — 约 18 分钟 / 5秒)</option>
<option value="736">1280×736(32整倍数,真1080p — 约 19.5 分钟 / 5秒)</option>
</select>
</div>
<div class="calc-field">
<label for="calc-sec">镜头时长(秒)</label>
<div class="calc-slider-wrap">
<input type="range" id="calc-sec" min="1" max="10" step="1" value="5">
<span class="calc-val" id="calc-sec-val">5 秒(121 帧 @ 24fps)</span>
</div>
</div>
<div class="calc-field">
<label for="calc-steps">采样步数(Steps)</label>
<div class="calc-slider-wrap">
<input type="range" id="calc-steps" min="10" max="30" step="5" value="20">
<span class="calc-val" id="calc-steps-val">20 步</span>
</div>
</div>
</div>
<div class="calc-results">
<div class="calc-stat">
<span class="stat-label">预估总渲染耗时</span>
<span class="stat-num" id="stat-time">~17.9 分钟</span>
<span class="stat-sub" id="stat-breakdown">15.1分 采样 + 2.8分 解码</span>
</div>
<div class="calc-stat">
<span class="stat-label">峰值显存占用</span>
<span class="stat-num" id="stat-vram">12.1 GB</span>
<span class="stat-sub" id="stat-vram-note">12GB 显卡极限贴线</span>
</div>
<div class="calc-stat">
<span class="stat-label">SeedVR2 1.5× 放大分段策略</span>
<span class="stat-num" id="stat-split">3 个切片</span>
<span class="stat-sub" id="stat-split-note">每段 ≤41 帧,防止原生崩溃</span>
</div>
</div>
</div>
<style>
.interactive-calc {
margin: 2.2rem 0;
padding: 1.4rem;
border-radius: var(--radius, 8px);
background: var(--surface, #16181d);
border: 1px solid var(--border, #2d3139);
}
.calc-header {
margin-bottom: 1.2rem;
}
.calc-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
font-size: 0.72rem;
font-family: var(--font-mono);
text-transform: uppercase;
font-weight: 600;
color: var(--brand, #6d8dff);
background: rgba(109, 141, 255, 0.12);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.calc-header h4 {
margin: 0.2rem 0 0.4rem;
font-size: 1.15rem;
color: var(--text, #e2e8f0);
}
.calc-header p {
margin: 0;
font-size: 0.88rem;
color: var(--muted, #94a3b8);
}
.calc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.1rem;
margin-bottom: 1.4rem;
}
.calc-field label {
display: block;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted, #94a3b8);
margin-bottom: 0.45rem;
}
.calc-field select {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 6px;
background: var(--bg, #0b0c0e);
border: 1px solid var(--border, #2d3139);
color: var(--text, #e2e8f0);
font-size: 0.88rem;
}
.calc-slider-wrap {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.calc-slider-wrap input[type="range"] {
width: 100%;
accent-color: var(--brand, #6d8dff);
cursor: pointer;
}
.calc-val {
font-size: 0.82rem;
font-family: var(--font-mono);
color: var(--brand, #6d8dff);
}
.calc-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
padding-top: 1.2rem;
border-top: 1px solid var(--border, #2d3139);
}
.calc-stat {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted, #94a3b8);
margin-bottom: 0.25rem;
}
.stat-num {
font-size: 1.35rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text, #e2e8f0);
}
.stat-sub {
font-size: 0.78rem;
color: var(--muted, #94a3b8);
margin-top: 0.2rem;
}
@media (max-width: 600px) {
.interactive-calc { padding: 1rem; }
.calc-results { grid-template-columns: 1fr; }
}
</style>
<script>
(function initCalc() {
const resEl = document.getElementById('calc-res');
const secEl = document.getElementById('calc-sec');
const secVal = document.getElementById('calc-sec-val');
const stepsEl = document.getElementById('calc-steps');
const stepsVal = document.getElementById('calc-steps-val');
const timeEl = document.getElementById('stat-time');
const bdEl = document.getElementById('stat-breakdown');
const vramEl = document.getElementById('stat-vram');
const vramNote = document.getElementById('stat-vram-note');
const splitEl = document.getElementById('stat-split');
const splitNote = document.getElementById('stat-split-note');
if (!resEl || !secEl || !stepsEl) return;
function update() {
const res = resEl.value;
const sec = parseInt(secEl.value, 10);
const steps = parseInt(stepsEl.value, 10);
const frames = sec === 1 ? 25 : (sec - 1) * 24 + 25;
secVal.textContent = `${sec} 秒(${frames} 帧 @ 24fps)`;
stepsVal.textContent = `${steps} 步`;
let baseSamplePerFrameStep = 0;
let baseDecodePerFrame = 0;
let vramPeak = 0;
if (res === '480') {
baseSamplePerFrameStep = 4.8 / (121 * 20);
baseDecodePerFrame = 1.3 / 121;
vramPeak = 10.3;
} else if (res === '704') {
baseSamplePerFrameStep = 15.1 / (121 * 20);
baseDecodePerFrame = 2.8 / 121;
vramPeak = 12.1;
} else {
baseSamplePerFrameStep = 16.5 / (121 * 20);
baseDecodePerFrame = 3.0 / 121;
vramPeak = 12.2;
}
const sampleTime = frames * steps * baseSamplePerFrameStep;
const decodeTime = frames * baseDecodePerFrame;
const totalTime = sampleTime + decodeTime;
timeEl.textContent = `~${totalTime.toFixed(1)} 分钟`;
bdEl.textContent = `${sampleTime.toFixed(1)}分 采样 + ${decodeTime.toFixed(1)}分 解码`;
vramEl.textContent = `${vramPeak} GB`;
if (vramPeak > 12.0) {
vramNote.textContent = '需要开启分块 VAE 解码才能跑通 12GB';
vramNote.style.color = '#f59e0b';
} else {
vramNote.textContent = '缓存清理后可直接装进 12GB';
vramNote.style.color = 'var(--muted)';
}
const segments = Math.ceil(frames / 41);
splitEl.textContent = `${segments} 个切片`;
if (frames > 41) {
splitNote.textContent = '必须切成 ≤41 帧(防止原生异常崩溃)';
} else {
splitNote.textContent = '单次直出安全(≤41 帧)';
}
}
resEl.addEventListener('change', update);
secEl.addEventListener('input', update);
stepsEl.addEventListener('input', update);
update();
})();
</script>
## 第一次失败:解码把主机内存吃光
第一次 720p 采样很顺利,然后死在 VAE 解码:
+4 -1
View File
@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthorCard from '../../components/AuthorCard.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import { getCollection, render } from 'astro:content';
import { postLang } from '../../lib/lang';
import { countWords, readingMinutes } from '../../lib/reading-time';
@@ -16,7 +17,7 @@ export async function getStaticPaths() {
}
const { post } = Astro.props;
const { Content } = await render(post);
const { Content, headings } = await render(post);
const lang = postLang(post.slug);
@@ -119,6 +120,8 @@ const jsonLd = {
</figure>
)}
<TableOfContents headings={headings} lang={lang} />
<Content />
<AuthorCard />