feat(docs): add interactive demo components for technical appendices
Add placeholder Vue components for visualizing technical concepts across multiple domains including frontend routing, browser rendering, cache design, queue design, database principles, API design, cloud services, and backend evolution. These components provide interactive educational content for the documentation. Update documentation structure to include new appendix sections and enhance existing content with visual components. Remove unused 'codex' dependency from package.json.
This commit is contained in:
+231
-281
@@ -1,333 +1,283 @@
|
||||
<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 class="diffusion-magic">
|
||||
<div class="magic-frame">
|
||||
<!-- The Canvas -->
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="canvasRef" width="300" height="300"></canvas>
|
||||
|
||||
<!-- Overlay Status -->
|
||||
<div class="status-overlay" :class="{ visible: isProcessing }">
|
||||
<div class="step-counter">Step {{ currentStep }} / {{ totalSteps }}</div>
|
||||
<div class="step-desc">{{ stepDescription }}</div>
|
||||
</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>
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<button class="magic-btn" @click="startDenoise" :disabled="isProcessing">
|
||||
<span class="icon">✨</span>
|
||||
{{ isProcessing ? '去噪中...' : '开始去噪 (Denoise)' }}
|
||||
</button>
|
||||
|
||||
<button class="reset-btn" @click="reset" :disabled="isProcessing">
|
||||
<span class="icon">🔄</span> 重置
|
||||
</button>
|
||||
</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 class="info-bar">
|
||||
<span class="icon">💡</span>
|
||||
<span>
|
||||
<strong>观察重点:</strong>
|
||||
注意看,图像不是一下子变出来的,而是像在雾气中慢慢显影。这就是 Diffusion 的核心——它在不断猜测“噪声背后的真相”。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
VideoPlay,
|
||||
VideoPause,
|
||||
TopRight,
|
||||
BottomLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const mode = ref('reverse')
|
||||
const isProcessing = ref(false)
|
||||
const currentStep = ref(0)
|
||||
const totalSteps = 100
|
||||
const isPlaying = ref(false)
|
||||
const totalSteps = 50
|
||||
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
|
||||
// Use a simple gradient pattern as the "Target Image" to avoid external assets
|
||||
const drawTargetImage = (ctx) => {
|
||||
// Draw a sunset landscape
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
gradient.addColorStop(0, '#87CEEB')
|
||||
gradient.addColorStop(1, '#E0F7FA')
|
||||
gradient.addColorStop(0, '#2c3e50')
|
||||
gradient.addColorStop(0.5, '#e67e22')
|
||||
gradient.addColorStop(1, '#f1c40f')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, 300, 300)
|
||||
|
||||
// Sun
|
||||
|
||||
// Draw a sun
|
||||
ctx.beginPath()
|
||||
ctx.arc(240, 60, 30, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#FFD700'
|
||||
ctx.arc(150, 200, 60, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#f39c12'
|
||||
ctx.fill()
|
||||
|
||||
// Mountains
|
||||
|
||||
// Draw mountains
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 300)
|
||||
ctx.lineTo(100, 150)
|
||||
ctx.lineTo(100, 200)
|
||||
ctx.lineTo(200, 250)
|
||||
ctx.lineTo(300, 100)
|
||||
ctx.lineTo(300, 150)
|
||||
ctx.lineTo(300, 300)
|
||||
ctx.fillStyle = '#4CAF50'
|
||||
ctx.fillStyle = '#2c3e50'
|
||||
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
|
||||
const drawNoise = (ctx, amount) => {
|
||||
const w = 300
|
||||
const h = 300
|
||||
const idata = ctx.getImageData(0, 0, w, h)
|
||||
const buffer = new Uint32Array(idata.data.buffer)
|
||||
|
||||
// We need to blend the target image with noise based on 'amount' (0 to 1)
|
||||
// But since we can't easily read back the target image every frame efficiently without offscreen canvas,
|
||||
// let's do a simpler trick: Draw target, then draw semi-transparent noise on top.
|
||||
|
||||
// Actually, let's generate noise overlay.
|
||||
// Amount 1.0 = Full Noise (Opaque)
|
||||
// Amount 0.0 = No Noise (Transparent)
|
||||
|
||||
// Clear and draw target first
|
||||
drawTargetImage(ctx)
|
||||
|
||||
if (amount <= 0) return
|
||||
|
||||
const noiseCanvas = document.createElement('canvas')
|
||||
noiseCanvas.width = w
|
||||
noiseCanvas.height = h
|
||||
const nCtx = noiseCanvas.getContext('2d')
|
||||
const nImgData = nCtx.createImageData(w, h)
|
||||
const data = nImgData.data
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const gray = Math.random() * 255
|
||||
data[i] = gray // R
|
||||
data[i+1] = gray // G
|
||||
data[i+2] = gray // B
|
||||
data[i+3] = 255 // Alpha
|
||||
}
|
||||
return new ImageData(data, width, height)
|
||||
nCtx.putImageData(nImgData, 0, 0)
|
||||
|
||||
ctx.globalAlpha = amount
|
||||
ctx.drawImage(noiseCanvas, 0, 0)
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
|
||||
// Pre-generate noise to keep it consistent
|
||||
let noiseImage = null
|
||||
const stepDescription = computed(() => {
|
||||
if (currentStep.value === 0) return '纯噪声 (Pure Noise)'
|
||||
if (currentStep.value < 10) return '隐约出现轮廓...'
|
||||
if (currentStep.value < 30) return '色彩开始浮现...'
|
||||
if (currentStep.value < 50) return '细节逐渐清晰...'
|
||||
return '生成完成 (Done)!'
|
||||
})
|
||||
|
||||
const startDenoise = () => {
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
currentStep.value = 0
|
||||
|
||||
const animate = () => {
|
||||
if (currentStep.value >= totalSteps) {
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
currentStep.value++
|
||||
const noiseLevel = 1 - (currentStep.value / totalSteps)
|
||||
// Non-linear ease out for better visual
|
||||
const visualNoise = Math.pow(noiseLevel, 1.5)
|
||||
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
drawNoise(ctx, visualNoise)
|
||||
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||
isProcessing.value = false
|
||||
currentStep.value = 0
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
drawNoise(ctx, 1.0)
|
||||
}
|
||||
|
||||
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()
|
||||
reset()
|
||||
})
|
||||
|
||||
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 {
|
||||
.diffusion-magic {
|
||||
margin: 20px 0;
|
||||
max-width: 400px; /* Compact width */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.magic-frame {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
.canvas-wrapper {
|
||||
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%;
|
||||
padding-bottom: 100%; /* Square aspect ratio */
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
.status-overlay {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-overlay.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.step-counter {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8em;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: -10px;
|
||||
gap: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.explanation-alert {
|
||||
margin-top: 20px;
|
||||
button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.magic-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.magic-btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.magic-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-1);
|
||||
flex: 0.4;
|
||||
}
|
||||
|
||||
.reset-btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-bg-mute);
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,364 +1,306 @@
|
||||
<template>
|
||||
<div class="flow-matching-demo">
|
||||
<el-card shadow="never">
|
||||
<div class="demo-card">
|
||||
<div class="controls">
|
||||
<el-button type="primary" @click="startAnimation" :disabled="isPlaying">
|
||||
<el-icon><VideoPlay /></el-icon> 开始对比演示 (Start Demo)
|
||||
</el-button>
|
||||
<button class="play-btn" @click="startRace" :disabled="isPlaying">
|
||||
<span class="icon">{{ isPlaying ? 'Running...' : '🚀 开始比赛 (Start Race)' }}</span>
|
||||
</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 class="track-container">
|
||||
<!-- Track 1: Diffusion -->
|
||||
<div class="track">
|
||||
<div class="track-info">
|
||||
<span class="track-name">Diffusion (迷宫模式)</span>
|
||||
<span class="step-count">{{ diffSteps }} Steps</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>
|
||||
<canvas ref="diffCanvasRef" width="400" height="100"></canvas>
|
||||
<div class="marker start">噪声</div>
|
||||
<div class="marker end">图像</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flow Matching -->
|
||||
<div class="viz-panel">
|
||||
<div class="panel-header">
|
||||
<el-icon color="#67C23A"><Right /></el-icon>
|
||||
<span>Flow Matching (流匹配)</span>
|
||||
<!-- Track 2: Flow Matching -->
|
||||
<div class="track">
|
||||
<div class="track-info">
|
||||
<span class="track-name">Flow Matching (直通模式)</span>
|
||||
<span class="step-count highlight">{{ flowSteps }} Steps</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>
|
||||
<canvas ref="flowCanvasRef" width="400" height="100"></canvas>
|
||||
<div class="marker start">噪声</div>
|
||||
<div class="marker end">图像</div>
|
||||
</div>
|
||||
</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 class="info-bar">
|
||||
<span class="icon">💡</span>
|
||||
<span>
|
||||
<strong>核心区别:</strong>
|
||||
Diffusion 就像在走迷宫,虽然也能到终点,但绕了很多弯路(步数多)。Flow Matching 则是直接修了一条直线高速公路,所以 8 步就能走完别人 50 步的路。
|
||||
</span>
|
||||
</div>
|
||||
</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 animationId = null
|
||||
|
||||
let animationFrame = null
|
||||
// Constants
|
||||
const TARGET_STEPS_DIFF = 50
|
||||
const TARGET_STEPS_FLOW = 8
|
||||
const DURATION = 3000 // 3 seconds for the whole race
|
||||
|
||||
// Animation State
|
||||
let diffProgress = 0
|
||||
let flowProgress = 0
|
||||
const diffSpeed = 0.005 // Slow
|
||||
const flowSpeed = 0.02 // Fast
|
||||
// Particles state
|
||||
let particles = []
|
||||
const NUM_PARTICLES = 5
|
||||
|
||||
// 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++
|
||||
class Particle {
|
||||
constructor(type) {
|
||||
this.type = type // 'diff' or 'flow'
|
||||
this.progress = 0
|
||||
this.path = []
|
||||
this.noiseOffset = Math.random() * 1000
|
||||
this.yOffset = (Math.random() - 0.5) * 60 // Spread vertically
|
||||
}
|
||||
|
||||
// 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++
|
||||
update(dt) {
|
||||
// Speed varies: Flow is faster because it covers distance in fewer steps?
|
||||
// Actually, let's make them finish at the same TIME, but show the path difference.
|
||||
// Or make Flow finish faster. Let's make Flow finish faster.
|
||||
|
||||
const speed = this.type === 'flow' ? 0.8 : 0.3
|
||||
this.progress += speed * dt
|
||||
|
||||
if (this.progress > 1) this.progress = 1
|
||||
|
||||
// Calculate Position
|
||||
const startX = 20
|
||||
const endX = 380
|
||||
const startY = 50 + this.yOffset
|
||||
const endY = 50
|
||||
|
||||
// Linear interpolation base
|
||||
let x = startX + (endX - startX) * this.progress
|
||||
let y = startY + (endY - startY) * this.progress
|
||||
|
||||
if (this.type === 'diff') {
|
||||
// Add noise to path
|
||||
if (this.progress < 1) {
|
||||
const noise = Math.sin(this.progress * 20 + this.noiseOffset) * 30 * (1 - this.progress)
|
||||
y += noise
|
||||
}
|
||||
}
|
||||
|
||||
this.path.push({x, y})
|
||||
return {x, y}
|
||||
}
|
||||
|
||||
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)
|
||||
draw(ctx) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(this.path[0].x, this.path[0].y)
|
||||
for (let p of this.path) {
|
||||
ctx.lineTo(p.x, p.y)
|
||||
}
|
||||
} else {
|
||||
ctx.moveTo(30, h - 30)
|
||||
ctx.lineTo(pos.x, pos.y)
|
||||
ctx.strokeStyle = this.type === 'flow' ? '#10b981' : '#f43f5e'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
const current = this.path[this.path.length - 1]
|
||||
ctx.beginPath()
|
||||
ctx.arc(current.x, current.y, 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = this.type === 'flow' ? '#10b981' : '#f43f5e'
|
||||
ctx.fill()
|
||||
}
|
||||
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'
|
||||
const startRace = () => {
|
||||
if (isPlaying.value) return
|
||||
isPlaying.value = true
|
||||
diffSteps.value = 0
|
||||
flowSteps.value = 0
|
||||
particles = []
|
||||
|
||||
// Create particles
|
||||
for(let i=0; i<NUM_PARTICLES; i++) {
|
||||
particles.push(new Particle('diff'))
|
||||
particles.push(new Particle('flow'))
|
||||
}
|
||||
|
||||
let lastTime = performance.now()
|
||||
|
||||
const animate = (time) => {
|
||||
const dt = (time - lastTime) / 1000
|
||||
lastTime = time
|
||||
|
||||
const dCtx = diffCanvasRef.value.getContext('2d')
|
||||
const fCtx = flowCanvasRef.value.getContext('2d')
|
||||
|
||||
// Clear
|
||||
dCtx.clearRect(0, 0, 400, 100)
|
||||
fCtx.clearRect(0, 0, 400, 100)
|
||||
|
||||
// Draw Guidelines
|
||||
drawGuide(dCtx)
|
||||
drawGuide(fCtx)
|
||||
|
||||
let allFinished = true
|
||||
|
||||
particles.forEach(p => {
|
||||
p.update(dt)
|
||||
if (p.progress < 1) allFinished = false
|
||||
|
||||
if (p.type === 'diff') p.draw(dCtx)
|
||||
else p.draw(fCtx)
|
||||
})
|
||||
|
||||
// Update steps counter simulation
|
||||
// Flow finishes in 8 steps, Diff in 50
|
||||
// Map progress to steps
|
||||
const flowP = particles.find(p => p.type === 'flow')
|
||||
const diffP = particles.find(p => p.type === 'diff')
|
||||
|
||||
if (flowP) flowSteps.value = Math.floor(flowP.progress * TARGET_STEPS_FLOW)
|
||||
if (diffP) diffSteps.value = Math.floor(diffP.progress * TARGET_STEPS_DIFF)
|
||||
|
||||
if (!allFinished) {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
isPlaying.value = false
|
||||
flowSteps.value = TARGET_STEPS_FLOW
|
||||
diffSteps.value = TARGET_STEPS_DIFF
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const drawGuide = (ctx) => {
|
||||
ctx.strokeStyle = 'rgba(128,128,128,0.1)'
|
||||
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.beginPath()
|
||||
ctx.moveTo(20, 50)
|
||||
ctx.lineTo(380, 50)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
const getPosition = (t, type, w, h) => {
|
||||
const startX = 30
|
||||
const startY = h - 30
|
||||
const endX = w - 30
|
||||
const endY = 30
|
||||
onMounted(() => {
|
||||
// Initial draw
|
||||
const dCtx = diffCanvasRef.value.getContext('2d')
|
||||
const fCtx = flowCanvasRef.value.getContext('2d')
|
||||
drawGuide(dCtx)
|
||||
drawGuide(fCtx)
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-matching-demo {
|
||||
margin: 20px 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
.play-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.play-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.play-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.track {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
color: var(--el-text-color-primary);
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-count {
|
||||
font-family: monospace;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.step-count.highlight {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.marker.start { left: 10px; }
|
||||
.marker.end { right: 10px; }
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.labels {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
.info-bar {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
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;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+155
-516
@@ -1,569 +1,208 @@
|
||||
<!--
|
||||
ImageGenQuickStartDemo.vue
|
||||
AI 绘画快速体验组件
|
||||
|
||||
用途:
|
||||
让用户在文章开头就能体验 AI 绘画的魅力,通过交互式演示理解文生图的基本概念。
|
||||
|
||||
交互功能:
|
||||
- 预设提示词选择:快速体验不同风格的图像生成
|
||||
- 模拟生成过程:展示从文本到图像的渐进过程
|
||||
- 参数调节:调整生成步数、CFG Scale 等参数
|
||||
- 对比展示:对比不同参数下的生成效果
|
||||
-->
|
||||
<template>
|
||||
<div class="image-gen-quickstart">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="header-title">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>🎨 AI 绘画体验室</span>
|
||||
<div class="quick-start-demo">
|
||||
<div class="preset-row">
|
||||
<div
|
||||
v-for="(preset, index) in presets"
|
||||
:key="index"
|
||||
class="preset-card"
|
||||
:class="{ active: selectedPreset === index }"
|
||||
@click="selectPreset(index)"
|
||||
>
|
||||
<span class="preset-icon">{{ preset.icon }}</span>
|
||||
<span class="preset-name">{{ preset.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-area">
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="canvasRef" width="400" height="300"></canvas>
|
||||
<div v-if="!isGenerating && !hasGenerated" class="placeholder-text">
|
||||
👈 点击上方风格,开始创作
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="demo-layout">
|
||||
<!-- 左侧:控制面板 -->
|
||||
<div class="control-panel">
|
||||
<div class="input-section">
|
||||
<label>提示词 (Prompt)</label>
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="描述你想生成的图像..."
|
||||
/>
|
||||
<div class="prompt-tags">
|
||||
<el-tag
|
||||
v-for="tag in presetPrompts"
|
||||
:key="tag.label"
|
||||
size="small"
|
||||
class="prompt-tag"
|
||||
@click="prompt = tag.prompt"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="params-section">
|
||||
<div class="param-row">
|
||||
<label>生成步数</label>
|
||||
<el-slider v-model="steps" :min="10" :max="50" :step="5" show-stops />
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<label>CFG Scale (提示词遵循度)</label>
|
||||
<el-slider v-model="cfgScale" :min="1" :max="15" :step="0.5" />
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<label>采样器</label>
|
||||
<el-select v-model="sampler" size="small">
|
||||
<el-option label="Euler" value="euler" />
|
||||
<el-option label="DPM++" value="dpm" />
|
||||
<el-option label="DDIM" value="ddim" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="isGenerating"
|
||||
@click="startGeneration"
|
||||
class="generate-btn"
|
||||
>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
{{ isGenerating ? '生成中...' : '开始生成' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:生成展示 -->
|
||||
<div class="display-panel">
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
width="400"
|
||||
height="400"
|
||||
class="gen-canvas"
|
||||
/>
|
||||
<div v-if="isGenerating" class="progress-overlay">
|
||||
<el-progress
|
||||
type="circle"
|
||||
:percentage="progress"
|
||||
:status="progress === 100 ? 'success' : ''"
|
||||
/>
|
||||
<p class="step-info">Step {{ currentStep }} / {{ steps }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info" v-if="!isGenerating && hasGenerated">
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="分辨率">512 × 512</el-descriptions-item>
|
||||
<el-descriptions-item label="生成步数">{{ steps }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CFG Scale">{{ cfgScale }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采样器">{{ sampler }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<div v-if="isGenerating" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div>AI 正在绘制 {{ presets[selectedPreset].name }}...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>小提示:</strong>
|
||||
提示词越详细,生成效果越好。尝试使用 "风格词"(如 "赛博朋克"、"水彩画")来控制图像风格。
|
||||
</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Picture, MagicStick } from '@element-plus/icons-vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const prompt = ref('一只戴着墨镜的猫,赛博朋克风格,霓虹灯光')
|
||||
const steps = ref(20)
|
||||
const cfgScale = ref(7.5)
|
||||
const sampler = ref('euler')
|
||||
const isGenerating = ref(false)
|
||||
const progress = ref(0)
|
||||
const currentStep = ref(0)
|
||||
const hasGenerated = ref(false)
|
||||
const selectedPreset = ref(-1)
|
||||
|
||||
const presetPrompts = [
|
||||
{ label: '🐱 赛博朋克猫', prompt: '一只戴着墨镜的猫,赛博朋克风格,霓虹灯光' },
|
||||
{ label: '🏔️ 山水画', prompt: '中国山水画,云雾缭绕,水墨风格' },
|
||||
{ label: '🚀 太空', prompt: '宇航员在火星表面,日落时分,科幻风格' },
|
||||
{ label: '🌸 樱花', prompt: '樱花树下,日本传统建筑,春天,柔和光线' },
|
||||
const presets = [
|
||||
{ name: '赛博朋克 (Cyberpunk)', icon: '🌃', color: ['#2b0055', '#ff00aa', '#00ffff'] },
|
||||
{ name: '油画风景 (Oil Painting)', icon: '🎨', color: ['#556b2f', '#8b4513', '#ffdead'] },
|
||||
{ name: '二次元 (Anime)', icon: '🌸', color: ['#ffb7c5', '#87ceeb', '#ffffff'] }
|
||||
]
|
||||
|
||||
// 模拟生成过程
|
||||
const startGeneration = async () => {
|
||||
const selectPreset = (index) => {
|
||||
if (isGenerating.value) return
|
||||
selectedPreset.value = index
|
||||
generate(presets[index])
|
||||
}
|
||||
|
||||
const generate = (preset) => {
|
||||
isGenerating.value = true
|
||||
progress.value = 0
|
||||
currentStep.value = 0
|
||||
hasGenerated.value = false
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillRect(0, 0, 400, 300)
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 生成噪声图像作为起点
|
||||
const generateNoise = () => {
|
||||
const imageData = ctx.createImageData(400, 400)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const val = Math.random() * 255
|
||||
imageData.data[i] = val
|
||||
imageData.data[i + 1] = val
|
||||
imageData.data[i + 2] = val
|
||||
imageData.data[i + 3] = 255
|
||||
let progress = 0
|
||||
const totalSteps = 60
|
||||
|
||||
const animate = () => {
|
||||
progress++
|
||||
|
||||
// Draw Noise mixed with colors
|
||||
const noiseLevel = 1 - (progress / totalSteps)
|
||||
|
||||
// Draw base colors (simple composition)
|
||||
const gradient = ctx.createLinearGradient(0, 0, 400, 300)
|
||||
gradient.addColorStop(0, preset.color[0])
|
||||
gradient.addColorStop(0.5, preset.color[1])
|
||||
gradient.addColorStop(1, preset.color[2])
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, 400, 300)
|
||||
|
||||
// Add noise overlay
|
||||
if (noiseLevel > 0) {
|
||||
const imgData = ctx.getImageData(0, 0, 400, 300)
|
||||
const data = imgData.data
|
||||
for(let i=0; i<data.length; i+=4) {
|
||||
if (Math.random() < noiseLevel) {
|
||||
const gray = Math.random() * 255
|
||||
data[i] = (data[i] + gray) / 2
|
||||
data[i+1] = (data[i+1] + gray) / 2
|
||||
data[i+2] = (data[i+2] + gray) / 2
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0)
|
||||
}
|
||||
|
||||
if (progress < totalSteps) {
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
isGenerating.value = false
|
||||
hasGenerated.value = true
|
||||
}
|
||||
return imageData
|
||||
}
|
||||
|
||||
// 模拟渐进生成
|
||||
for (let i = 0; i <= steps.value; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
currentStep.value = i
|
||||
progress.value = Math.round((i / steps.value) * 100)
|
||||
|
||||
// 绘制渐进图像(模拟)
|
||||
const noiseRatio = 1 - (i / steps.value)
|
||||
drawSimulatedImage(ctx, noiseRatio)
|
||||
}
|
||||
|
||||
isGenerating.value = false
|
||||
hasGenerated.value = true
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
// 根据提示词绘制模拟图像
|
||||
const drawSimulatedImage = (ctx, noiseRatio) => {
|
||||
const width = 400
|
||||
const height = 400
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#1a1a2e'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// 根据提示词关键词绘制不同内容
|
||||
const promptLower = prompt.value.toLowerCase()
|
||||
|
||||
// 赛博朋克风格
|
||||
if (promptLower.includes('赛博') || promptLower.includes('cyber')) {
|
||||
drawCyberpunkScene(ctx, width, height, noiseRatio)
|
||||
}
|
||||
// 山水画
|
||||
else if (promptLower.includes('山水') || promptLower.includes('水墨')) {
|
||||
drawLandscape(ctx, width, height, noiseRatio)
|
||||
}
|
||||
// 太空
|
||||
else if (promptLower.includes('太空') || promptLower.includes('宇航员')) {
|
||||
drawSpaceScene(ctx, width, height, noiseRatio)
|
||||
}
|
||||
// 樱花
|
||||
else if (promptLower.includes('樱花') || promptLower.includes('日本')) {
|
||||
drawSakuraScene(ctx, width, height, noiseRatio)
|
||||
}
|
||||
// 默认:抽象艺术
|
||||
else {
|
||||
drawAbstractArt(ctx, width, height, noiseRatio)
|
||||
}
|
||||
|
||||
// 添加噪声
|
||||
if (noiseRatio > 0) {
|
||||
addNoise(ctx, width, height, noiseRatio)
|
||||
}
|
||||
}
|
||||
|
||||
const drawCyberpunkScene = (ctx, w, h, noise) => {
|
||||
// 霓虹背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h)
|
||||
gradient.addColorStop(0, '#0a0a1a')
|
||||
gradient.addColorStop(1, '#1a0a2e')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// 霓虹灯条
|
||||
ctx.shadowBlur = 20 * (1 - noise)
|
||||
ctx.shadowColor = '#ff00ff'
|
||||
ctx.fillStyle = `rgba(255, 0, 255, ${0.8 * (1 - noise)})`
|
||||
ctx.fillRect(50, 100, 300, 5)
|
||||
|
||||
ctx.shadowColor = '#00ffff'
|
||||
ctx.fillStyle = `rgba(0, 255, 255, ${0.8 * (1 - noise)})`
|
||||
ctx.fillRect(100, 200, 200, 5)
|
||||
|
||||
// 猫的形状(简化)
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = `rgba(100, 100, 150, ${0.9 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(200, 280, 60, 50, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 耳朵
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(160, 250)
|
||||
ctx.lineTo(150, 200)
|
||||
ctx.lineTo(180, 240)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(240, 250)
|
||||
ctx.lineTo(250, 200)
|
||||
ctx.lineTo(220, 240)
|
||||
ctx.fill()
|
||||
|
||||
// 墨镜
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${0.9 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(180, 270, 20, 12, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(220, 270, 20, 12, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 镜片反光
|
||||
ctx.fillStyle = `rgba(255, 0, 255, ${0.6 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(175, 268, 5, 3, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(215, 268, 5, 3, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawLandscape = (ctx, w, h, noise) => {
|
||||
// 天空渐变
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h)
|
||||
gradient.addColorStop(0, '#e8f4f8')
|
||||
gradient.addColorStop(0.5, '#c8e0e8')
|
||||
gradient.addColorStop(1, '#a8c8d8')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// 远山
|
||||
ctx.fillStyle = `rgba(100, 120, 140, ${0.5 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 250)
|
||||
ctx.lineTo(100, 150)
|
||||
ctx.lineTo(200, 200)
|
||||
ctx.lineTo(300, 120)
|
||||
ctx.lineTo(400, 180)
|
||||
ctx.lineTo(400, 400)
|
||||
ctx.lineTo(0, 400)
|
||||
ctx.fill()
|
||||
|
||||
// 近山
|
||||
ctx.fillStyle = `rgba(60, 80, 100, ${0.7 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 300)
|
||||
ctx.lineTo(150, 220)
|
||||
ctx.lineTo(300, 280)
|
||||
ctx.lineTo(400, 240)
|
||||
ctx.lineTo(400, 400)
|
||||
ctx.lineTo(0, 400)
|
||||
ctx.fill()
|
||||
|
||||
// 云雾
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.4 * (1 - noise)})`
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(80 + i * 70, 180 + i * 20, 40, 20, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const drawSpaceScene = (ctx, w, h, noise) => {
|
||||
// 太空背景
|
||||
ctx.fillStyle = '#0a0a15'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// 星星
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${1 - noise})`
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = (i * 37) % w
|
||||
const y = (i * 73) % h
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, Math.random() * 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// 火星地面
|
||||
ctx.fillStyle = `rgba(180, 80, 40, ${0.8 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 350)
|
||||
ctx.lineTo(100, 320)
|
||||
ctx.lineTo(200, 340)
|
||||
ctx.lineTo(300, 310)
|
||||
ctx.lineTo(400, 330)
|
||||
ctx.lineTo(400, 400)
|
||||
ctx.lineTo(0, 400)
|
||||
ctx.fill()
|
||||
|
||||
// 宇航员(简化)
|
||||
ctx.fillStyle = `rgba(220, 220, 230, ${0.9 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(200, 220, 40, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 头盔反光
|
||||
ctx.fillStyle = `rgba(255, 200, 100, ${0.5 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(185, 210, 15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 日落
|
||||
const sunGradient = ctx.createRadialGradient(300, 100, 0, 300, 100, 60)
|
||||
sunGradient.addColorStop(0, `rgba(255, 200, 100, ${1 - noise})`)
|
||||
sunGradient.addColorStop(1, `rgba(255, 100, 50, ${0.5 * (1 - noise)})`)
|
||||
ctx.fillStyle = sunGradient
|
||||
ctx.beginPath()
|
||||
ctx.arc(300, 100, 50, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawSakuraScene = (ctx, w, h, noise) => {
|
||||
// 天空
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h)
|
||||
gradient.addColorStop(0, '#ffe4e1')
|
||||
gradient.addColorStop(1, '#ffb6c1')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// 传统建筑屋顶
|
||||
ctx.fillStyle = `rgba(80, 60, 60, ${0.9 * (1 - noise)})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(100, 300)
|
||||
ctx.lineTo(150, 200)
|
||||
ctx.lineTo(250, 200)
|
||||
ctx.lineTo(300, 300)
|
||||
ctx.fill()
|
||||
|
||||
// 樱花树
|
||||
ctx.fillStyle = `rgba(139, 90, 43, ${0.9 * (1 - noise)})`
|
||||
ctx.fillRect(50, 200, 20, 200)
|
||||
|
||||
// 樱花
|
||||
ctx.fillStyle = `rgba(255, 183, 197, ${0.8 * (1 - noise)})`
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const x = 30 + (i * 13) % 80
|
||||
const y = 150 + (i * 17) % 100
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 8 + Math.random() * 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// 飘落的樱花
|
||||
ctx.fillStyle = `rgba(255, 192, 203, ${0.6 * (1 - noise)})`
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const x = (i * 23) % w
|
||||
const y = 250 + (i * 31) % 150
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, 4, 2, i, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const drawAbstractArt = (ctx, w, h, noise) => {
|
||||
// 渐变背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, w, h)
|
||||
gradient.addColorStop(0, '#667eea')
|
||||
gradient.addColorStop(1, '#764ba2')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// 抽象形状
|
||||
const colors = ['#f093fb', '#f5576c', '#4facfe', '#00f2fe']
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.fillStyle = colors[i % colors.length] + Math.floor((1 - noise) * 255).toString(16).padStart(2, '0')
|
||||
ctx.beginPath()
|
||||
const x = 100 + (i * 50) % 300
|
||||
const y = 100 + (i * 70) % 250
|
||||
const r = 30 + i * 5
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const addNoise = (ctx, w, h, ratio) => {
|
||||
const imageData = ctx.getImageData(0, 0, w, h)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const noise = (Math.random() - 0.5) * 50 * ratio
|
||||
imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise))
|
||||
imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise))
|
||||
imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise))
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始绘制
|
||||
const canvas = canvasRef.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
drawSimulatedImage(ctx, 1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-gen-quickstart {
|
||||
margin: 1rem 0;
|
||||
.quick-start-demo {
|
||||
margin: 20px 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
.preset-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.preset-card.active {
|
||||
background: var(--vp-c-brand-dimm);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.preset-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
.preview-area {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-section label,
|
||||
.params-section label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.prompt-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prompt-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prompt-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.param-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.param-row label {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.display-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-canvas {
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.progress-overlay {
|
||||
.placeholder-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.step-info {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2em;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,290 +1,304 @@
|
||||
<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 class="viz-card">
|
||||
<!-- Left: The Output (Pixel Space) -->
|
||||
<div class="preview-section">
|
||||
<div class="emoji-display" :style="{ transform: `scale(${1 + zoomLevel})` }">
|
||||
{{ currentEmoji }}
|
||||
</div>
|
||||
<div class="label">像素空间 (Pixel Space)</div>
|
||||
<div class="sub-label">最终看到的图像</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<!-- Center: The Mechanism -->
|
||||
<div class="connection">
|
||||
<div class="arrow">← 映射 →</div>
|
||||
<div class="vae-tag">VAE Decoder</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Right: The Input (Latent Space) -->
|
||||
<div class="control-section">
|
||||
<div class="latent-grid" ref="gridRef" @mousedown="startDrag" @touchstart="startDrag">
|
||||
<div class="grid-lines"></div>
|
||||
<div class="axis-label x-axis">开心值 (Happiness)</div>
|
||||
<div class="axis-label y-axis">惊讶值 (Surprise)</div>
|
||||
|
||||
<!-- The Latent Point -->
|
||||
<div
|
||||
class="latent-point"
|
||||
:style="{ left: `${point.x}%`, top: `${point.y}%` }"
|
||||
>
|
||||
<div class="tooltip">Latent Vector: [{{ ((point.x-50)/50).toFixed(1) }}, {{ ((50-point.y)/50).toFixed(1) }}]</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">潜空间 (Latent Space)</div>
|
||||
<div class="sub-label">拖动红点改变特征</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-bar">
|
||||
<span class="icon">💡</span>
|
||||
<span>
|
||||
<strong>核心原理:</strong>
|
||||
在像素空间里修改图片很难(要改几千个像素)。但在潜空间里,我们只需要修改两个坐标(开心值、惊讶值),就能生成完全不同的表情。这就是 AI "画画" 的本质——在数学空间里找坐标。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Picture, Cpu, Right, Filter, Scissor } from '@element-plus/icons-vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from '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 gridRef = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const point = ref({ x: 50, y: 50 }) // Percentage 0-100
|
||||
const zoomLevel = ref(0)
|
||||
|
||||
// Emoji map based on quadrants
|
||||
// X: Unhappy -> Happy
|
||||
// Y: Calm -> Surprised (Top is 0 in CSS, so small Y is high surprise?)
|
||||
// Let's map:
|
||||
// X (0-100): Sad -> Happy
|
||||
// Y (0-100): Surprised -> Sleepy (Top is 0, so 0 is Surprised, 100 is Sleepy)
|
||||
|
||||
const currentEmoji = computed(() => {
|
||||
const x = point.value.x // 0 (Sad) to 100 (Happy)
|
||||
const y = point.value.y // 0 (Surprised) to 100 (Sleepy)
|
||||
|
||||
if (x < 33) { // Sad Zone
|
||||
if (y < 33) return '😨' // Sad + Surprised = Fear
|
||||
if (y > 66) return '😪' // Sad + Sleepy = Tired
|
||||
return '😢' // Just Sad
|
||||
} else if (x > 66) { // Happy Zone
|
||||
if (y < 33) return '🤩' // Happy + Surprised = Starstruck
|
||||
if (y > 66) return '😌' // Happy + Sleepy = Relieved
|
||||
return '😃' // Just Happy
|
||||
} else { // Neutral Zone
|
||||
if (y < 33) return '😮' // Neutral + Surprised
|
||||
if (y > 66) return '😴' // Neutral + Sleepy
|
||||
return '😐' // Just Neutral
|
||||
}
|
||||
})
|
||||
|
||||
const handleMove = (event) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const grid = gridRef.value.getBoundingClientRect()
|
||||
const clientX = event.touches ? event.touches[0].clientX : event.clientX
|
||||
const clientY = event.touches ? event.touches[0].clientY : event.clientY
|
||||
|
||||
let newX = ((clientX - grid.left) / grid.width) * 100
|
||||
let newY = ((clientY - grid.top) / grid.height) * 100
|
||||
|
||||
// Clamp
|
||||
newX = Math.max(0, Math.min(100, newX))
|
||||
newY = Math.max(0, Math.min(100, newY))
|
||||
|
||||
point.value = { x: newX, y: newY }
|
||||
}
|
||||
|
||||
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%)`
|
||||
}
|
||||
const startDrag = (event) => {
|
||||
isDragging.value = true
|
||||
handleMove(event)
|
||||
// Prevent default to stop scrolling on mobile
|
||||
if (event.type === 'touchstart') event.preventDefault()
|
||||
}
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMove)
|
||||
window.addEventListener('mouseup', stopDrag)
|
||||
window.addEventListener('touchmove', handleMove)
|
||||
window.addEventListener('touchend', stopDrag)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMove)
|
||||
window.removeEventListener('mouseup', stopDrag)
|
||||
window.removeEventListener('touchmove', handleMove)
|
||||
window.removeEventListener('touchend', stopDrag)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.latent-space-viz {
|
||||
margin: 20px 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.viz-container {
|
||||
.viz-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.space-block {
|
||||
.preview-section, .control-section {
|
||||
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);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.process-arrow {
|
||||
.emoji-display {
|
||||
font-size: 80px;
|
||||
line-height: 1;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
cursor: default;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.vae-box {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
.latent-grid {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(var(--vp-c-divider) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--vp-c-divider) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.latent-point {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--vp-c-brand);
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.latent-point:hover {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.compress-tag {
|
||||
transform: scale(0.9);
|
||||
.latent-point:hover .tooltip,
|
||||
.latent-point:active .tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
color: var(--vp-c-text-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.x-axis {
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.y-axis {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin-bottom: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vae-tag {
|
||||
background: var(--vp-c-brand-dimm);
|
||||
color: var(--vp-c-brand-dark);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.sub-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
margin-top: 16px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.viz-container {
|
||||
flex-direction: column;
|
||||
.viz-card {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.transform-process {
|
||||
|
||||
.connection {
|
||||
transform: rotate(90deg);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.compress-tag {
|
||||
display: none; /* Hide tag when rotated to avoid layout issues */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,45 @@
|
||||
<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 class="viz-card">
|
||||
<div class="input-display">
|
||||
<span class="label">Prompt:</span>
|
||||
<span class="text">"cyberpunk cat, neon lights, futuristic city"</span>
|
||||
</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 class="tokens-row">
|
||||
<div
|
||||
v-for="(token, index) in tokens"
|
||||
:key="index"
|
||||
class="token-pill"
|
||||
:style="{ opacity: 0.4 + (token.weight * 0.6) }"
|
||||
>
|
||||
{{ token.text }}
|
||||
<div class="tooltip">关注度: {{ (token.weight * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-down">⬇️ CLIP Encoding & Attention</div>
|
||||
|
||||
<div class="image-map">
|
||||
<!-- Abstract representation of an image being attended to -->
|
||||
<div class="map-layer" style="background: #2b0055; opacity: 0.9;">
|
||||
<span>City Base</span>
|
||||
</div>
|
||||
<div class="map-layer" style="background: #ff00aa; width: 60%; height: 60%; opacity: 0.8;">
|
||||
<span>Neon Glow</span>
|
||||
</div>
|
||||
<div class="map-layer" style="background: #fff; width: 30%; height: 30%; border-radius: 50%;">
|
||||
<span>Cat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>交叉注意力机制</strong>让 AI 理解提示词的每个词。
|
||||
当生成图片时,AI 会"关注"不同的词: "cyberpunk" 影响整体风格,"cat"
|
||||
决定主体,"neon lights" 控制灯光效果。 词的顺序和权重都会影响最终画面!
|
||||
</p>
|
||||
<div class="info-bar">
|
||||
<span class="icon">💡</span>
|
||||
<span>
|
||||
<strong>交叉注意力 (Cross-Attention):</strong>
|
||||
AI 在画画时,每画一笔都会回头看一眼 Prompt。当它画背景时,"city" 单词会亮起来;当它画主角时,"cat" 单词会亮起来。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -62,153 +48,128 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const tokens = ref([
|
||||
{ text: 'cyberpunk', weight: 0.9 },
|
||||
{ text: 'cyberpunk', weight: 0.8 },
|
||||
{ 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 }
|
||||
{ text: 'futuristic', weight: 0.5 },
|
||||
{ text: 'city', weight: 0.9 }
|
||||
])
|
||||
</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;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.viz-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-display {
|
||||
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;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attention-grid {
|
||||
.label {
|
||||
color: var(--vp-c-text-2);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tokens-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attention-cell {
|
||||
.token-pill {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
cursor: help;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.token-pill:hover {
|
||||
transform: scale(1.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.token-pill:hover .tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-map {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #000;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.85rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell-token {
|
||||
width: 80px;
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 600;
|
||||
.map-layer {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.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;
|
||||
.info-bar {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user