feat: save current work to dev branch
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="diffusion-process-demo">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header-controls">
|
||||
<div class="mode-toggles">
|
||||
<el-radio-group v-model="mode" size="large">
|
||||
<el-radio-button label="forward">
|
||||
<el-icon><TopRight /></el-icon> 加噪 (Forward)
|
||||
</el-radio-button>
|
||||
<el-radio-button label="reverse">
|
||||
<el-icon><BottomLeft /></el-icon> 去噪 (Reverse)
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-button type="primary" circle @click="togglePlay">
|
||||
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
|
||||
<el-icon v-else><VideoPlay /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvasRef" width="300" height="300" class="noise-canvas"></canvas>
|
||||
<div class="step-indicator">
|
||||
<span class="step-text">Step: {{ currentStep }} / {{ totalSteps }}</span>
|
||||
<el-progress
|
||||
:percentage="mode === 'forward' ? (currentStep / totalSteps * 100) : ((totalSteps - currentStep) / totalSteps * 100)"
|
||||
:status="mode === 'forward' ? 'exception' : 'success'"
|
||||
:show-text="false"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<el-slider
|
||||
v-model="currentStep"
|
||||
:min="0"
|
||||
:max="totalSteps"
|
||||
:format-tooltip="formatTooltip"
|
||||
@input="draw"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>{{ mode === 'forward' ? '原图 (Original)' : '纯噪声 (Noise)' }}</span>
|
||||
<span>{{ mode === 'forward' ? '纯噪声 (Noise)' : '原图 (Original)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="mode === 'forward' ? '训练阶段:破坏数据' : '生成阶段:创造数据'"
|
||||
:type="mode === 'forward' ? 'warning' : 'success'"
|
||||
:description="mode === 'forward' ? 'AI 通过学习如何「一点点加噪」,掌握了噪声的规律。这就像教它把积木推倒。' : 'AI 通过预测并减去噪声,从混沌中还原出图像。这就像它学会了把推倒的积木重新搭好。'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="explanation-alert"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { VideoPlay, VideoPause, TopRight, BottomLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const mode = ref('reverse')
|
||||
const currentStep = ref(0)
|
||||
const totalSteps = 100
|
||||
const isPlaying = ref(false)
|
||||
let animationFrame = null
|
||||
let originalImage = null
|
||||
|
||||
// Load a base image (using a generated pattern to avoid external dependencies)
|
||||
const loadBaseImage = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 300
|
||||
canvas.height = 300
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Draw a simple landscape
|
||||
// Sky
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
gradient.addColorStop(0, '#87CEEB')
|
||||
gradient.addColorStop(1, '#E0F7FA')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, 300, 300)
|
||||
|
||||
// Sun
|
||||
ctx.beginPath()
|
||||
ctx.arc(240, 60, 30, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#FFD700'
|
||||
ctx.fill()
|
||||
|
||||
// Mountains
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 300)
|
||||
ctx.lineTo(100, 150)
|
||||
ctx.lineTo(200, 250)
|
||||
ctx.lineTo(300, 100)
|
||||
ctx.lineTo(300, 300)
|
||||
ctx.fillStyle = '#4CAF50'
|
||||
ctx.fill()
|
||||
|
||||
// House
|
||||
ctx.fillStyle = '#795548'
|
||||
ctx.fillRect(50, 220, 60, 60)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(40, 220)
|
||||
ctx.lineTo(80, 180)
|
||||
ctx.lineTo(120, 220)
|
||||
ctx.fillStyle = '#F44336'
|
||||
ctx.fill()
|
||||
|
||||
originalImage = ctx.getImageData(0, 0, 300, 300)
|
||||
}
|
||||
|
||||
const generateNoise = (width, height) => {
|
||||
const size = width * height * 4
|
||||
const data = new Uint8ClampedArray(size)
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
const val = Math.random() * 255
|
||||
data[i] = val // R
|
||||
data[i + 1] = val // G
|
||||
data[i + 2] = val // B
|
||||
data[i + 3] = 255 // A
|
||||
}
|
||||
return new ImageData(data, width, height)
|
||||
}
|
||||
|
||||
// Pre-generate noise to keep it consistent
|
||||
let noiseImage = null
|
||||
|
||||
onMounted(() => {
|
||||
loadBaseImage()
|
||||
noiseImage = generateNoise(300, 300)
|
||||
// Always start from step 0 (Start of the process)
|
||||
// Forward: Step 0 = Clean Image
|
||||
// Reverse: Step 0 = Noisy Image
|
||||
currentStep.value = 0
|
||||
draw()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
})
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || !originalImage) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Calculate noise ratio based on mode and step
|
||||
// Forward: 0 -> 100 (Clean -> Noisy)
|
||||
// Reverse: 100 -> 0 (Noisy -> Clean)
|
||||
// But wait, the slider is just "Progress".
|
||||
// Let's define currentStep as "Amount of Noise" for simplicity in logic,
|
||||
// but for UI, we want:
|
||||
// Forward Mode: Slider 0 (Clean) -> 100 (Noisy)
|
||||
// Reverse Mode: Slider 0 (Noisy) -> 100 (Clean)
|
||||
|
||||
let noiseRatio = 0
|
||||
if (mode.value === 'forward') {
|
||||
noiseRatio = currentStep.value / totalSteps
|
||||
} else {
|
||||
// In reverse mode, slider 0 means start (Noisy), 100 means end (Clean)
|
||||
// So noise amount is 1 - slider
|
||||
noiseRatio = 1 - (currentStep.value / totalSteps)
|
||||
}
|
||||
|
||||
// Non-linear interpolation for better visual effect
|
||||
// noiseRatio = Math.pow(noiseRatio, 1.5)
|
||||
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
const output = ctx.createImageData(w, h)
|
||||
const d = output.data
|
||||
const o = originalImage.data
|
||||
const n = noiseImage.data
|
||||
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
// Simple linear interpolation
|
||||
// Pixel = (1 - alpha) * Original + alpha * Noise
|
||||
// Note: This is a simplified diffusion visualization.
|
||||
// Real diffusion adds noise: x_t = sqrt(alpha_bar) * x_0 + sqrt(1 - alpha_bar) * epsilon
|
||||
|
||||
// Using simple blending for visualization
|
||||
d[i] = o[i] * (1 - noiseRatio) + n[i] * noiseRatio
|
||||
d[i+1] = o[i+1] * (1 - noiseRatio) + n[i+1] * noiseRatio
|
||||
d[i+2] = o[i+2] * (1 - noiseRatio) + n[i+2] * noiseRatio
|
||||
d[i+3] = 255
|
||||
}
|
||||
|
||||
ctx.putImageData(output, 0, 0)
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isPlaying.value) {
|
||||
stopAnimation()
|
||||
} else {
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
isPlaying.value = true
|
||||
// Reset if at end
|
||||
if (currentStep.value >= totalSteps) {
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
if (currentStep.value < totalSteps) {
|
||||
currentStep.value += 1
|
||||
draw()
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
} else {
|
||||
stopAnimation()
|
||||
}
|
||||
}
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
isPlaying.value = false
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(mode, () => {
|
||||
stopAnimation()
|
||||
currentStep.value = 0
|
||||
draw()
|
||||
})
|
||||
|
||||
const formatTooltip = (val) => {
|
||||
return `Step ${val}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diffusion-process-demo {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.noise-canvas {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.step-indicator .el-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8em;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.explanation-alert {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="flow-matching-demo">
|
||||
<el-card shadow="never">
|
||||
<div class="controls">
|
||||
<el-button type="primary" @click="startAnimation" :disabled="isPlaying">
|
||||
<el-icon><VideoPlay /></el-icon> 开始对比演示 (Start Demo)
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="comparison-grid">
|
||||
<!-- Diffusion -->
|
||||
<div class="viz-panel">
|
||||
<div class="panel-header">
|
||||
<el-icon color="#F56C6C"><RefreshLeft /></el-icon>
|
||||
<span>Diffusion (扩散模型)</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="diffCanvasRef" width="300" height="200"></canvas>
|
||||
<div class="labels">
|
||||
<span class="label-noise">噪声 (Noise)</span>
|
||||
<span class="label-img">图像 (Image)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-box">
|
||||
<el-statistic title="步数 (Steps)" :value="diffSteps" />
|
||||
<el-tag type="danger">路径弯曲 (Curved)</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flow Matching -->
|
||||
<div class="viz-panel">
|
||||
<div class="panel-header">
|
||||
<el-icon color="#67C23A"><Right /></el-icon>
|
||||
<span>Flow Matching (流匹配)</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="flowCanvasRef" width="300" height="200"></canvas>
|
||||
<div class="labels">
|
||||
<span class="label-noise">噪声 (Noise)</span>
|
||||
<span class="label-img">图像 (Image)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-box">
|
||||
<el-statistic title="步数 (Steps)" :value="flowSteps" />
|
||||
<el-tag type="success">路径直线 (Straight)</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-alert
|
||||
title="为什么 Flow Matching 更快?"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<p>
|
||||
<strong>Diffusion</strong> 就像在迷雾中摸索,路径充满了随机性,需要走很多弯路(步数多)才能到达终点。
|
||||
<br>
|
||||
<strong>Flow Matching</strong> 就像使用了 GPS 导航,直接找到了从噪声到图像的<strong>直线最优路径 (Optimal Transport)</strong>,因此只需要极少的步数。
|
||||
</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { VideoPlay, RefreshLeft, Right } from '@element-plus/icons-vue'
|
||||
|
||||
const diffCanvasRef = ref(null)
|
||||
const flowCanvasRef = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const diffSteps = ref(0)
|
||||
const flowSteps = ref(0)
|
||||
|
||||
let animationFrame = null
|
||||
|
||||
// Animation State
|
||||
let diffProgress = 0
|
||||
let flowProgress = 0
|
||||
const diffSpeed = 0.005 // Slow
|
||||
const flowSpeed = 0.02 // Fast
|
||||
|
||||
// Particles
|
||||
const particles = []
|
||||
|
||||
onMounted(() => {
|
||||
drawStatic(diffCanvasRef.value, 'curve')
|
||||
drawStatic(flowCanvasRef.value, 'line')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
})
|
||||
|
||||
const startAnimation = () => {
|
||||
if (isPlaying.value) return
|
||||
isPlaying.value = true
|
||||
diffProgress = 0
|
||||
flowProgress = 0
|
||||
diffSteps.value = 0
|
||||
flowSteps.value = 0
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
isPlaying.value = false
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
let finished = 0
|
||||
|
||||
// Update Diffusion
|
||||
if (diffProgress < 1) {
|
||||
diffProgress += diffSpeed
|
||||
diffSteps.value = Math.floor(diffProgress * 50) // Simulate 50 steps
|
||||
drawFrame(diffCanvasRef.value, diffProgress, 'curve')
|
||||
} else {
|
||||
diffSteps.value = 50
|
||||
drawFrame(diffCanvasRef.value, 1, 'curve')
|
||||
finished++
|
||||
}
|
||||
|
||||
// Update Flow
|
||||
if (flowProgress < 1) {
|
||||
flowProgress += flowSpeed
|
||||
flowSteps.value = Math.floor(flowProgress * 8) // Simulate 8 steps
|
||||
drawFrame(flowCanvasRef.value, flowProgress, 'line')
|
||||
} else {
|
||||
flowSteps.value = 8
|
||||
drawFrame(flowCanvasRef.value, 1, 'line')
|
||||
finished++
|
||||
}
|
||||
|
||||
if (finished < 2) {
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
} else {
|
||||
isPlaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const drawStatic = (canvas, type) => {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
drawBackground(ctx, w, h)
|
||||
drawPath(ctx, w, h, type, false)
|
||||
drawEndpoints(ctx, w, h)
|
||||
}
|
||||
|
||||
const drawFrame = (canvas, progress, type) => {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
drawBackground(ctx, w, h)
|
||||
drawPath(ctx, w, h, type, true) // active path
|
||||
drawEndpoints(ctx, w, h)
|
||||
|
||||
// Draw Particle
|
||||
const pos = getPosition(progress, type, w, h)
|
||||
|
||||
// Draw Trail
|
||||
ctx.beginPath()
|
||||
if (type === 'curve') {
|
||||
ctx.moveTo(30, h - 30)
|
||||
// Re-calculate curve up to progress
|
||||
for(let t=0; t<=progress; t+=0.01) {
|
||||
const p = getPosition(t, type, w, h)
|
||||
ctx.lineTo(p.x, p.y)
|
||||
}
|
||||
} else {
|
||||
ctx.moveTo(30, h - 30)
|
||||
ctx.lineTo(pos.x, pos.y)
|
||||
}
|
||||
ctx.strokeStyle = type === 'curve' ? '#F56C6C' : '#67C23A'
|
||||
ctx.lineWidth = 3
|
||||
ctx.stroke()
|
||||
|
||||
// Draw Head
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2)
|
||||
ctx.fillStyle = type === 'curve' ? '#F56C6C' : '#67C23A'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const drawBackground = (ctx, w, h) => {
|
||||
ctx.fillStyle = '#f9f9f9' // Light bg
|
||||
// Grid
|
||||
ctx.strokeStyle = '#eee'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
for(let x=0; x<=w; x+=20) { ctx.moveTo(x,0); ctx.lineTo(x,h); }
|
||||
for(let y=0; y<=h; y+=20) { ctx.moveTo(0,y); ctx.lineTo(w,y); }
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const drawEndpoints = (ctx, w, h) => {
|
||||
// Start (Noise)
|
||||
ctx.beginPath()
|
||||
ctx.arc(30, h - 30, 8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#909399'
|
||||
ctx.fill()
|
||||
|
||||
// End (Image)
|
||||
ctx.beginPath()
|
||||
ctx.arc(w - 30, 30, 8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#409EFF'
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawPath = (ctx, w, h, type, isActive) => {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(30, h - 30)
|
||||
|
||||
if (type === 'line') {
|
||||
ctx.lineTo(w - 30, 30)
|
||||
} else {
|
||||
// Bezier curve for diffusion
|
||||
ctx.quadraticCurveTo(w * 0.2, 30, w - 30, 30)
|
||||
}
|
||||
|
||||
ctx.strokeStyle = isActive ? 'rgba(0,0,0,0.1)' : '#ddd'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([5, 5])
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
const getPosition = (t, type, w, h) => {
|
||||
const startX = 30
|
||||
const startY = h - 30
|
||||
const endX = w - 30
|
||||
const endY = 30
|
||||
|
||||
if (type === 'line') {
|
||||
return {
|
||||
x: startX + (endX - startX) * t,
|
||||
y: startY + (endY - startY) * t
|
||||
}
|
||||
} else {
|
||||
// Quadratic Bezier: (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2
|
||||
// Control Point
|
||||
const cpX = w * 0.2
|
||||
const cpY = 30
|
||||
|
||||
const x = Math.pow(1-t, 2) * startX + 2 * (1-t) * t * cpX + Math.pow(t, 2) * endX
|
||||
const y = Math.pow(1-t, 2) * startY + 2 * (1-t) * t * cpY + Math.pow(t, 2) * endY
|
||||
|
||||
// Add some random jitter for diffusion look if t < 1
|
||||
// const jitter = t < 1 ? (Math.random() - 0.5) * 5 : 0
|
||||
// return { x: x + jitter, y: y + jitter }
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-matching-demo {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-panel {
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.labels {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="image-gen-architecture">
|
||||
<el-card shadow="never">
|
||||
<div class="flow-container">
|
||||
<!-- Step 1: Prompt -->
|
||||
<div class="flow-item">
|
||||
<el-card shadow="hover" class="node-card">
|
||||
<template #header>
|
||||
<div class="node-header">
|
||||
<el-icon :size="20"><EditPen /></el-icon>
|
||||
<span>提示词 (Prompt)</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="node-content">
|
||||
<el-tag type="info" effect="plain">"一只可爱的猫"</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
<el-icon :size="24"><Right /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Text Encoder -->
|
||||
<div class="flow-item">
|
||||
<el-card shadow="hover" class="node-card">
|
||||
<template #header>
|
||||
<div class="node-header">
|
||||
<el-icon :size="20"><Microphone /></el-icon>
|
||||
<span>文本编码器</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="node-content">
|
||||
<div class="model-name">CLIP / T5</div>
|
||||
<div class="data-shape">Vector [768]</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
<el-icon :size="24"><Right /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: UNet/DiT -->
|
||||
<div class="flow-item main-node">
|
||||
<el-card shadow="hover" class="node-card highlight">
|
||||
<template #header>
|
||||
<div class="node-header">
|
||||
<el-icon :size="20" color="#E6A23C"><Cpu /></el-icon>
|
||||
<span>生成模型</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="node-content">
|
||||
<div class="model-name">UNet / DiT</div>
|
||||
<div class="action-badge">
|
||||
<el-tag type="warning" size="small" effect="dark">去噪 (Denoise)</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
<el-icon :size="24"><Right /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: VAE Decoder -->
|
||||
<div class="flow-item">
|
||||
<el-card shadow="hover" class="node-card">
|
||||
<template #header>
|
||||
<div class="node-header">
|
||||
<el-icon :size="20"><View /></el-icon>
|
||||
<span>图像解码器</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="node-content">
|
||||
<div class="model-name">VAE Decoder</div>
|
||||
<div class="final-output">
|
||||
<el-icon><Picture /></el-icon> Image
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="explanation-item">
|
||||
<div class="exp-icon"><el-icon color="#409EFF"><Microphone /></el-icon></div>
|
||||
<div class="exp-text">
|
||||
<h4>耳朵 (Text Encoder)</h4>
|
||||
<p>负责"听懂"你的描述,把它翻译成计算机能理解的数学向量。</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="explanation-item">
|
||||
<div class="exp-icon"><el-icon color="#E6A23C"><Cpu /></el-icon></div>
|
||||
<div class="exp-text">
|
||||
<h4>大脑 (UNet/DiT)</h4>
|
||||
<p>核心创造者。在潜空间(Latent Space)中通过预测噪声来构思画面。</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="explanation-item">
|
||||
<div class="exp-icon"><el-icon color="#67C23A"><View /></el-icon></div>
|
||||
<div class="exp-text">
|
||||
<h4>眼睛 (VAE)</h4>
|
||||
<p>负责"翻译"回图像。把大脑构思的模糊特征还原成高清像素图片。</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EditPen, Microphone, Right, Cpu, View, Picture } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-gen-architecture {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.arrow-connector {
|
||||
color: var(--el-text-color-placeholder);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 0.85em;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-color: var(--el-color-warning);
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data-shape {
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.explanation-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.exp-icon {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.exp-text h4 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 0.95em;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.exp-text p {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.flow-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.arrow-connector {
|
||||
transform: rotate(90deg);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.explanation-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="latent-space-viz">
|
||||
<el-card shadow="never">
|
||||
<div class="viz-container">
|
||||
<!-- Pixel Space -->
|
||||
<div class="space-block">
|
||||
<div class="space-header">
|
||||
<el-icon :size="20"><Picture /></el-icon>
|
||||
<span class="space-title">像素空间 (Pixel Space)</span>
|
||||
</div>
|
||||
<div class="grid-wrapper pixel-wrapper">
|
||||
<div class="pixel-grid">
|
||||
<div v-for="n in 256" :key="n" class="pixel-cell" :style="getPixelStyle(n)"></div>
|
||||
</div>
|
||||
<div class="grid-overlay">
|
||||
<span>HD Image</span>
|
||||
<span class="res-tag">1024x1024</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-stats">
|
||||
<div class="stat-row">
|
||||
<span class="label">维度:</span>
|
||||
<span class="value">3 (RGB)</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="label">数据量:</span>
|
||||
<span class="value">~300万</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transformation -->
|
||||
<div class="transform-process">
|
||||
<div class="process-arrow">
|
||||
<div class="vae-box">
|
||||
<span class="vae-label">VAE Encoder</span>
|
||||
<el-icon><Filter /></el-icon>
|
||||
</div>
|
||||
<el-icon :size="24" class="arrow-icon"><Right /></el-icon>
|
||||
</div>
|
||||
<el-tag type="danger" size="small" effect="dark" class="compress-tag">压缩 48x</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Latent Space -->
|
||||
<div class="space-block highlight">
|
||||
<div class="space-header">
|
||||
<el-icon :size="20" color="#E6A23C"><Cpu /></el-icon>
|
||||
<span class="space-title">潜空间 (Latent Space)</span>
|
||||
</div>
|
||||
<div class="grid-wrapper latent-wrapper">
|
||||
<div class="latent-grid">
|
||||
<div v-for="n in 16" :key="n" class="latent-cell" :style="getLatentStyle(n)"></div>
|
||||
</div>
|
||||
<div class="grid-overlay">
|
||||
<span>Latent Feature</span>
|
||||
<span class="res-tag">64x64</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-stats">
|
||||
<div class="stat-row">
|
||||
<span class="label">维度:</span>
|
||||
<span class="value">4 (Channels)</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="label">数据量:</span>
|
||||
<span class="value">~1.6万</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-statistic title="压缩比" value="1:48">
|
||||
<template #suffix>
|
||||
<el-icon style="vertical-align: -0.125em"><Scissor /></el-icon>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-alert
|
||||
title="为什么要压缩?"
|
||||
type="success"
|
||||
:closable="false"
|
||||
description="直接处理 300 万个像素太慢了。VAE 把图像压缩成「压缩饼干」(潜变量),保留了核心特征(语义、构图),扔掉了冗余细节。AI 在这个小空间里画画,速度飞快!"
|
||||
show-icon
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Picture, Cpu, Right, Filter, Scissor } from '@element-plus/icons-vue'
|
||||
|
||||
const getPixelStyle = (n) => {
|
||||
// Simulate a natural image with smooth color transitions
|
||||
const r = 100 + Math.sin(n * 0.1) * 50
|
||||
const g = 150 + Math.cos(n * 0.1) * 50
|
||||
const b = 200 + Math.sin(n * 0.05) * 50
|
||||
return {
|
||||
backgroundColor: `rgb(${r}, ${g}, ${b})`,
|
||||
opacity: 0.8 + Math.random() * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
const getLatentStyle = (n) => {
|
||||
// Simulate high-level features (more abstract, high contrast colors)
|
||||
const hue = (n * 137) % 360
|
||||
return {
|
||||
backgroundColor: `hsl(${hue}, 70%, 60%)`,
|
||||
boxShadow: `0 0 5px hsl(${hue}, 70%, 60%)`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.latent-space-viz {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.viz-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.space-block {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.space-block.highlight {
|
||||
border-color: var(--el-color-warning-light-5);
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.space-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.grid-wrapper {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pixel-wrapper {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.latent-wrapper {
|
||||
height: 100px; /* Smaller representation */
|
||||
width: 100px;
|
||||
margin: 0 auto 15px auto;
|
||||
}
|
||||
|
||||
.pixel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pixel-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.latent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.latent-cell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
font-size: 0.75em;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.res-tag {
|
||||
font-family: monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.data-stats {
|
||||
font-size: 0.85em;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.transform-process {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.process-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.vae-box {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compress-tag {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.viz-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.transform-process {
|
||||
transform: rotate(90deg);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.compress-tag {
|
||||
display: none; /* Hide tag when rotated to avoid layout issues */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="prompt-visualizer">
|
||||
<div class="demo-container">
|
||||
<!-- Left: Prompt -->
|
||||
<div class="prompt-section">
|
||||
<div class="section-title">📝 提示词</div>
|
||||
<div class="prompt-input">
|
||||
"cyberpunk cat, neon lights, futuristic city"
|
||||
</div>
|
||||
|
||||
<div class="token-list">
|
||||
<div
|
||||
v-for="(token, index) in tokens"
|
||||
:key="index"
|
||||
class="token-item"
|
||||
:style="{ opacity: token.weight }"
|
||||
>
|
||||
<div class="token-text">{{ token.text }}</div>
|
||||
<div class="token-weight">权重: {{ (token.weight * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Attention Visualization -->
|
||||
<div class="attention-section">
|
||||
<div class="section-title">🎯 交叉注意力可视化</div>
|
||||
<div class="attention-grid">
|
||||
<div v-for="(item, index) in attentionMap" :key="index" class="attention-cell">
|
||||
<div class="cell-token">{{ item.token }}</div>
|
||||
<div class="cell-bar">
|
||||
<div class="bar-fill" :style="{ width: item.attention * 100 + '%' }"></div>
|
||||
</div>
|
||||
<div class="cell-value">{{ (item.attention * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>交叉注意力机制</strong>让 AI 理解提示词的每个词。
|
||||
当生成图片时,AI 会"关注"不同的词:
|
||||
"cyberpunk" 影响整体风格,"cat" 决定主体,"neon lights" 控制灯光效果。
|
||||
词的顺序和权重都会影响最终画面!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const tokens = ref([
|
||||
{ text: 'cyberpunk', weight: 0.9 },
|
||||
{ text: 'cat', weight: 1.0 },
|
||||
{ text: 'neon', weight: 0.7 },
|
||||
{ text: 'lights', weight: 0.6 },
|
||||
{ text: 'futuristic', weight: 0.8 },
|
||||
{ text: 'city', weight: 0.5 }
|
||||
])
|
||||
|
||||
const attentionMap = ref([
|
||||
{ token: 'cyberpunk', attention: 0.9 },
|
||||
{ token: 'cat', attention: 1.0 },
|
||||
{ token: 'neon', attention: 0.7 },
|
||||
{ token: 'lights', attention: 0.6 },
|
||||
{ token: 'futuristic', attention: 0.8 },
|
||||
{ token: 'city', attention: 0.5 }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-visualizer {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.prompt-section {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.token-text {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token-weight {
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.attention-section {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.attention-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attention-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cell-token {
|
||||
width: 80px;
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cell-bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user