feat(welcome): 影策首页 - 新增三主题电影卷轴与公开入口

This commit is contained in:
kinoko
2026-09-10 09:11:16 +08:00
parent cb8b874f1f
commit e3a941dff9
32 changed files with 796 additions and 1 deletions

View File

@@ -126,6 +126,9 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
<div className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/45">创作工作台</div>
</div>
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
<a href="/welcome" className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground">
品牌首页
</a>
{[
{ label: "首页", to: "/" },
{ label: "画布", to: "/canvas" },

View File

@@ -16,6 +16,8 @@ runLocalRuntimeBootstrap(
},
},
() => {
void bootstrapAppearance().finally(() => import("./application"));
// Keep the public film page independent of workspace and appearance requests.
if (/^\/welcome\/?$/.test(window.location.pathname)) void import("./welcome-application");
else void bootstrapAppearance().finally(() => import("./application"));
},
);

View File

@@ -0,0 +1,153 @@
import { Component, lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
import { ArrowDown, ArrowRight, ArrowUpRight, Code2, Menu, Pause, Play, X } from "lucide-react";
import { chapters, getWelcomeLook, showcases, welcomeLooks, type WelcomeLook } from "./story";
import "./welcome.css";
const StoryReel = lazy(() => import("./story-reel"));
const github = "https://github.com/ddcat-ai/open-ai-canvas";
export default function WelcomePage() {
const [look, setLook] = useState(getWelcomeLook);
const restorePickerFocus = useRef(false);
useEffect(() => {
if (!restorePickerFocus.current) return;
document.getElementById("welcome-look-select")?.focus({ preventScroll: true });
restorePickerFocus.current = false;
}, [look]);
useEffect(() => {
const onHistory = () => setLook(getWelcomeLook());
window.addEventListener("popstate", onHistory);
return () => window.removeEventListener("popstate", onHistory);
}, []);
const changeLook = (id: string) => {
const next = welcomeLooks.find((item) => item.id === id);
if (!next || next.id === look.id) return;
const url = new URL(window.location.href);
url.searchParams.set("look", id);
window.history.pushState(null, "", url);
restorePickerFocus.current = true;
setLook(next);
};
return <WelcomeExperience key={look.id} look={look} onLookChange={changeLook} />;
}
function WelcomeExperience({ look, onLookChange }: { look: WelcomeLook; onLookChange: (id: string) => void }) {
const storyRef = useRef<HTMLElement>(null);
const progressRef = useRef(0);
const [chapter, setChapter] = useState(0);
const [reduced, setReduced] = useState(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const [paused, setPaused] = useState(false);
const [failed, setFailed] = useState(false);
const [ready, setReady] = useState(false);
const [menu, setMenu] = useState(false);
const [showcase, setShowcase] = useState(0);
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
document.title = "影策 · 让一个故事从文字走向银幕";
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const onMotion = () => setReduced(media.matches);
media.addEventListener("change", onMotion);
const onScroll = () => {
if (!storyRef.current) return;
const rect = storyRef.current.getBoundingClientRect();
const progress = Math.max(0, Math.min(1, -rect.top / (rect.height - window.innerHeight)));
progressRef.current = progress;
storyRef.current.style.setProperty("--story-progress", String(progress));
setChapter(Math.min(5, Math.floor(progress * 6)));
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
onScroll();
return () => {
media.removeEventListener("change", onMotion);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
useEffect(() => {
if (!playing) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setPlaying(false); };
window.addEventListener("keydown", close);
return () => window.removeEventListener("keydown", close);
}, [playing]);
const jumpTo = (index: number) => {
if (!storyRef.current) return;
const height = storyRef.current.offsetHeight - window.innerHeight;
window.scrollTo({ top: storyRef.current.offsetTop + height * (index === 5 ? 0.97 : index / 6 + (index ? 0.04 : 0)), behavior: reduced ? "instant" : "smooth" });
setMenu(false);
};
const staticScene = reduced || failed;
const active = showcases[showcase];
return (
<div className="welcome-page">
<a className="welcome-skip" href="#workbench">前往工作台介绍</a>
<header className="welcome-header">
<a className="welcome-brand" href="/welcome" aria-label="影策首页"><img src="/logo.svg" alt="" />影策</a>
<nav className={menu ? "welcome-nav is-open" : "welcome-nav"} aria-label="首页导航">
<a href="#workbench" onClick={() => setMenu(false)}>工作台</a>
<a href={github} target="_blank" rel="noreferrer">GitHub<ArrowUpRight size={13} /></a>
</nav>
<a className="welcome-header-cta" href="/create">开始创作<ArrowUpRight size={16} /></a>
<button className="welcome-icon mobile-menu" aria-label={menu ? "关闭菜单" : "打开菜单"} aria-expanded={menu} onClick={() => setMenu(!menu)}>{menu ? <X /> : <Menu />}</button>
</header>
<aside className="welcome-look-picker" aria-label="首页素材版本">
<select id="welcome-look-select" aria-label="素材版本" value={look.id} onChange={(event) => onLookChange(event.target.value)}>
{welcomeLooks.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
{look.credit && <a href={`/welcome/credits.html#${look.id}`} target="_blank" rel="noreferrer" title={look.credit}>演示素材 · CC BY<ArrowUpRight size={12} /></a>}
</aside>
<main>
<section ref={storyRef} id="story" className="welcome-story" aria-label="影策创作之旅">
<div className={`welcome-stage chapter-${chapter}${staticScene ? " is-static" : ""}`}>
<div className={`welcome-poster${ready && !staticScene ? " is-ready" : ""}`} aria-hidden="true"><img src={look.frames[staticScene ? chapter * 2 : 0]} alt="" fetchPriority="high" /></div>
{!staticScene && <SceneBoundary onError={() => setFailed(true)}><Suspense fallback={null}><StoryReel look={look} progress={progressRef} paused={paused} onReady={() => setReady(true)} onError={() => setFailed(true)} /></Suspense></SceneBoundary>}
<div className="welcome-stage-shade" aria-hidden="true" />
{chapters.map((item, index) => (
<section key={item.id} className={`welcome-chapter ${index === 0 ? "welcome-opening" : ""} ${index === chapter ? "is-active" : ""}`} aria-hidden={index !== chapter}>
{index === 0 ? <h1>{item.title}</h1> : <h2>{item.title.split(",").map((part, partIndex, parts) => <span key={part}>{part}{partIndex < parts.length - 1 ? "," : ""}</span>)}</h2>}
{"subtitle" in item && <><p className="welcome-subtitle">{item.subtitle}</p>{look.video && <button className="welcome-film-link" onClick={() => setPlaying(true)} tabIndex={chapter === 0 ? 0 : -1}><span className="welcome-play-mark"><Play size={17} fill="currentColor" /></span><span>观看片段</span></button>}</>}
</section>
))}
<div className="welcome-stage-bottom">
<button className="welcome-next" aria-label={chapter === 5 ? "进入创作现场" : "下一幕"} onClick={() => chapter < 5 ? jumpTo(chapter + 1) : document.getElementById("workbench")?.scrollIntoView({ behavior: reduced ? "instant" : "smooth" })}><ArrowDown size={20} /></button>
<nav className="welcome-chapter-nav" aria-label="故事章节">{chapters.map((item, index) => <button key={item.id} className={chapter === index ? "is-current" : ""} aria-label={item.label} title={item.label} aria-current={chapter === index ? "step" : undefined} onClick={() => jumpTo(index)}><span /></button>)}</nav>
<button className="welcome-icon" title={paused || staticScene ? "播放动画" : "暂停动画"} aria-label={paused || staticScene ? "播放动画" : "暂停动画"} aria-pressed={paused || staticScene} disabled={staticScene} onClick={() => setPaused(!paused)}>{paused || staticScene ? <Play size={16} /> : <Pause size={16} />}</button>
</div>
<div className="welcome-scroll-progress" aria-hidden="true" />
</div>
</section>
<section id="workbench" className="welcome-workbench">
<div className="welcome-section-heading"><h2>让想象,有处落笔。</h2></div>
<div className="welcome-workbench-bar"><div role="tablist" aria-label="工作台预览">{showcases.map((item, index) => <button key={item.name} role="tab" id={`preview-tab-${index}`} aria-controls="workbench-preview" aria-selected={showcase === index} tabIndex={showcase === index ? 0 : -1} onClick={() => setShowcase(index)} onKeyDown={(event) => { if (event.key === "ArrowRight" || event.key === "ArrowLeft") { event.preventDefault(); const next = (index + (event.key === "ArrowRight" ? 1 : 2)) % 3; setShowcase(next); document.getElementById(`preview-tab-${next}`)?.focus(); } }}>{item.name}</button>)}</div><a href={active.href}>进入{active.name}<ArrowUpRight size={16} /></a></div>
<div id="workbench-preview" role="tabpanel" aria-labelledby={`preview-tab-${showcase}`} className="welcome-workbench-preview"><img src={active.image} alt={`影策${active.name}界面`} loading="lazy" /></div>
<div className="welcome-workbench-caption"><p>{active.detail}</p></div>
</section>
<section className="welcome-ending"><h2>你的故事,<br />现在开始。</h2><a className="welcome-primary" href="/create">开始创作<ArrowRight size={20} /></a><a className="welcome-source" href={github} target="_blank" rel="noreferrer"><Code2 size={16} />GitHub<ArrowUpRight size={14} /></a></section>
</main>
<footer className="welcome-footer"><a href="/welcome">影策</a><span>开源 AI 影视创作工作台</span><a href={`${github}/blob/main/LICENSE`} target="_blank" rel="noreferrer">Open Source · MIT License<ArrowUpRight size={12} /></a></footer>
{look.credit && <div className="welcome-media-credit"><a href={`/welcome/credits.html#${look.id}`} target="_blank" rel="noreferrer">{look.credit} · 署名与许可<ArrowUpRight size={12} /></a></div>}
{playing && look.video && <FilmDialog look={look} onClose={() => setPlaying(false)} videoRef={videoRef} />}
</div>
);
}
function FilmDialog({ look, onClose, videoRef }: { look: WelcomeLook; onClose: () => void; videoRef: React.RefObject<HTMLVideoElement | null> }) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => { const dialog = dialogRef.current; dialog?.showModal(); return () => dialog?.close(); }, []);
return <dialog ref={dialogRef} className="welcome-film-dialog" aria-label={`${look.title}影片片段`} onCancel={onClose} onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}><div><button autoFocus className="welcome-icon" aria-label="关闭影片" onClick={onClose}><X /></button><video ref={videoRef} src={look.video} poster={look.frames[0]} controls autoPlay muted playsInline /><p>{look.credit ?? look.title}</p></div></dialog>;
}
class SceneBoundary extends Component<{ children: ReactNode; onError: () => void }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch() { this.props.onError(); }
render() { return this.state.failed ? null : this.props.children; }
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useRef, type RefObject } from "react";
import * as THREE from "three";
import type { WelcomeLook } from "./story";
type Props = { look: WelcomeLook; progress: RefObject<number>; paused: boolean; onReady: () => void; onError: () => void };
const count = 12;
const radius = 4.6;
const tileWidth = 2.25;
const tileHeight = 1.5;
const smoothstep = (x: number) => { const t = THREE.MathUtils.clamp(x, 0, 1); return t * t * (3 - 2 * t); };
// The film strip bends into a reel, then unfolds into the same assets on a canvas.
function tileGeometry(index: number) {
const geometry = new THREE.PlaneGeometry(tileWidth, tileHeight, 16, 1);
const flat = new Float32Array(geometry.attributes.position.array);
const target = new Float32Array(flat.length);
for (let i = 0; i < flat.length; i += 3) {
target[i] = flat[i] + ((index % 4) - 1.5) * 2.65;
target[i + 1] = flat[i + 1] + (1 - Math.floor(index / 4)) * 2;
target[i + 2] = 0;
}
return { geometry, flat, target };
}
function imageTexture(image: HTMLImageElement, index: number, type: "film" | "script" | "board", look: WelcomeLook) {
const canvas = document.createElement("canvas");
canvas.width = 768;
canvas.height = 512;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#101211";
ctx.fillRect(0, 0, 768, 512);
ctx.filter = type === "board" ? "grayscale(1) contrast(1.5)" : "none";
const imageHeight = type === "board" ? 462 : 512;
const scale = Math.max(768 / image.width, imageHeight / image.height);
ctx.drawImage(image, (768 - image.width * scale) / 2, (imageHeight - image.height * scale) / 2, image.width * scale, image.height * scale);
ctx.filter = "none";
if (type === "board") {
ctx.fillStyle = "#d8dfd9";
ctx.font = "18px sans-serif";
ctx.fillText(`SHOT ${String(index + 1).padStart(2, "0")} / MEDIUM SHOT`, 22, 494);
ctx.fillStyle = "#929c96";
ctx.textAlign = "right";
ctx.fillText("DOLLY IN", 746, 494);
} else if (type === "script") {
const shade = ctx.createLinearGradient(0, 0, 0, 512);
shade.addColorStop(0, "#08080880");
shade.addColorStop(0.35, "#08080810");
shade.addColorStop(0.65, "#080808a0");
shade.addColorStop(1, "#080808ed");
ctx.fillStyle = shade;
ctx.fillRect(0, 0, 768, 512);
ctx.fillStyle = "#f3f2ef";
ctx.font = "20px sans-serif";
ctx.fillText(`${look.title} / 场 ${String(Math.floor(index / 4) + 1).padStart(2, "0")}`, 48, 65);
ctx.font = "32px serif";
ctx.fillText(look.screenplay[index], 48, 366, 672);
ctx.font = "23px serif";
ctx.fillStyle = "#d0ceca";
ctx.fillText(look.screenplay[(index + 1) % count], 48, 413, 672);
ctx.fillStyle = "#b4b2ae";
ctx.font = "16px monospace";
ctx.fillText(`SCENE / ${String(index + 1).padStart(2, "0")}`, 48, 475);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export default function StoryReel({ look, progress, paused, onReady, onError }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const pausedRef = useRef(paused);
const callbacks = useRef({ onReady, onError });
callbacks.current = { onReady, onError };
pausedRef.current = paused;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
let frame = 0;
let renderer: THREE.WebGLRenderer;
try { renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: "low-power" }); }
catch { callbacks.current.onError(); return; }
renderer.setClearColor(0x090c0a, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 80);
const tiles: ReturnType<typeof tileGeometry>[] = [];
const meshes: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>[] = [];
const textures: THREE.Texture[] = [];
const films: THREE.Texture[] = [];
const scripts: THREE.Texture[] = [];
const boards: THREE.Texture[] = [];
const images: HTMLImageElement[] = [];
const video = document.createElement("video");
if (look.video) video.src = look.video;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "none";
const videoTexture = new THREE.VideoTexture(video);
videoTexture.colorSpace = THREE.SRGBColorSpace;
textures.push(videoTexture);
const linesGeometry = new THREE.BufferGeometry();
const linePositions: number[] = [];
for (let row = 0; row < 3; row++) for (let col = 0; col < 3; col++) {
const x = (col - 1.5) * 2.65;
const y = (1 - row) * 2;
linePositions.push(x + tileWidth / 2, y, -0.02, x + 2.65 - tileWidth / 2, y, -0.02);
}
for (let row = 0; row < 2; row++) {
linePositions.push(-3.975, (1 - row) * 2 - 0.75, -0.02, -3.975, -row * 2 + 0.75, -0.02);
}
linesGeometry.setAttribute("position", new THREE.Float32BufferAttribute(linePositions, 3));
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x9bb5a4, transparent: true, opacity: 0 });
scene.add(new THREE.LineSegments(linesGeometry, lineMaterial));
const tracks = new THREE.Group();
const trackMaterials: THREE.LineBasicMaterial[] = [];
const trackGeometries: THREE.BufferGeometry[] = [];
for (let i = 0; i < 12; i++) {
const points = Array.from({ length: 20 }, (_, j) => {
const angle = i * Math.PI / 6 + j * 0.015;
return new THREE.Vector3(Math.sin(angle) * 5.2, i % 2 ? 1.1 : -1.1, Math.cos(angle) * 5.2);
});
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: i % 3 ? 0xc3d4c9 : 0xd7ba80, transparent: true, opacity: 0.2 });
tracks.add(new THREE.Line(geometry, material));
trackMaterials.push(material);
trackGeometries.push(geometry);
}
scene.add(tracks);
let width = 0;
let height = 0;
const resize = () => {
width = canvas.clientWidth;
height = canvas.clientHeight;
if (!width || !height) return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, width < 768 ? 1.25 : 1.7));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
let visible = true;
const visibility = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (!visible) video.pause(); });
visibility.observe(canvas);
const contextLost = (event: Event) => { event.preventDefault(); callbacks.current.onError(); };
canvas.addEventListener("webglcontextlost", contextLost);
const loadImage = (src: string) => new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
images.push(image);
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
const timeout = window.setTimeout(() => { if (!disposed) callbacks.current.onError(); }, 15000);
const loads = new Map<string, Promise<HTMLImageElement>>();
Promise.all(look.frames.map((src) => {
if (!loads.has(src)) loads.set(src, loadImage(src));
return loads.get(src)!;
})).then((loaded) => {
if (disposed) return;
window.clearTimeout(timeout);
loaded.forEach((image, index) => {
const film = imageTexture(image, index, "film", look);
const script = imageTexture(image, index, "script", look);
const board = imageTexture(image, index, "board", look);
films.push(film); scripts.push(script); boards.push(board);
textures.push(film, script, board);
const tile = tileGeometry(index);
tiles.push(tile);
const material = new THREE.MeshBasicMaterial({ map: film, side: THREE.DoubleSide });
// Both sides of the reel must keep screenplay and shot labels readable.
material.onBeforeCompile = (shader) => {
shader.fragmentShader = shader.fragmentShader.replace("#include <map_fragment>", THREE.ShaderChunk.map_fragment.replaceAll("vMapUv", "(gl_FrontFacing ? vMapUv : vec2(1.0 - vMapUv.x, vMapUv.y))"));
};
const mesh = new THREE.Mesh(tile.geometry, material);
meshes.push(mesh);
scene.add(mesh);
});
let last = performance.now();
let current = progress.current;
let idle = 0;
let previousStage = -1;
let previousVideoReady = false;
let playbackBlocked = false;
let frozen = current;
let wasPaused = pausedRef.current;
const animate = (now: number) => {
if (disposed) return;
frame = requestAnimationFrame(animate);
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (!visible || document.hidden) { video.pause(); return; }
if (pausedRef.current && !wasPaused) frozen = current;
wasPaused = pausedRef.current;
const target = pausedRef.current ? frozen : progress.current;
const velocity = target - current;
current += velocity * (1 - Math.exp(-dt * 7));
if (!pausedRef.current) idle += dt * 0.035;
const stage = Math.min(5, Math.floor(current * 6));
const videoReady = video.readyState >= 2 && !playbackBlocked;
const unfold = smoothstep((current - 0.81) / 0.13);
const spin = current * Math.PI * 5 + idle;
const mobile = width < 768;
const cameraStops = mobile
? [[0, 4, 12], [0, 8, 12], [1, 1.5, 11], [0, 3.5, 10.5], [0, 0.6, 9], [0, 0, Math.max(34, 12 / (2 * Math.tan(THREE.MathUtils.degToRad(21)) * camera.aspect))]]
: [[0, 3.5, 8.8], [0, 7.8, 7], [2.2, 1.7, 7.8], [0.5, 1.5, 6.8], [0, 0.5, 5.8], [0, 0, 14]];
const stop = current * 5;
const from = Math.floor(stop);
const to = Math.min(5, from + 1);
const mix = smoothstep(stop - from);
camera.position.set(...cameraStops[from].map((value, i) => THREE.MathUtils.lerp(value, cameraStops[to][i], mix)) as [number, number, number]);
camera.lookAt(0, mobile ? THREE.MathUtils.lerp(-0.9, 4.9, unfold) : THREE.MathUtils.lerp(-0.5, 1.55, unfold), 0);
scene.rotation.z = -0.14 * (1 - unfold);
meshes.forEach((mesh, index) => {
const tile = tiles[index];
const positions = tile.geometry.attributes.position;
const angle = index / count * Math.PI * 2 + spin;
for (let i = 0; i < positions.count; i++) {
const offset = i * 3;
const theta = angle + tile.flat[offset] / radius;
positions.setXYZ(i,
THREE.MathUtils.lerp(Math.sin(theta) * radius, tile.target[offset], unfold),
THREE.MathUtils.lerp(tile.flat[offset + 1], tile.target[offset + 1], unfold),
THREE.MathUtils.lerp(Math.cos(theta) * radius, tile.target[offset + 2], unfold));
}
positions.needsUpdate = true;
// Deformed vertices move outside the original plane bounds.
mesh.frustumCulled = false;
if (stage !== previousStage || videoReady !== previousVideoReady) {
const showScript = stage === 1 && index % 4 === 0 || stage === 5 && index < 3;
const showBoard = stage === 3 || stage === 5 && index >= 6 && index <= 8;
mesh.material.map = showScript ? scripts[index] : showBoard ? boards[index] : stage === 4 && videoReady && index % 3 === 0 ? videoTexture : films[index];
}
});
previousStage = stage;
previousVideoReady = videoReady;
if (look.video && stage === 4 && !pausedRef.current && !playbackBlocked && video.paused) void video.play().catch(() => {
playbackBlocked = true;
meshes.forEach((mesh, index) => { if (mesh.material.map === videoTexture) mesh.material.map = films[index]; });
});
else if (stage !== 4 || pausedRef.current) video.pause();
tracks.rotation.y = spin;
tracks.visible = unfold < 0.99;
trackMaterials.forEach((material) => { material.opacity = Math.min(0.6, 0.13 + Math.abs(velocity) * 12) * (1 - unfold); });
lineMaterial.opacity = unfold * 0.65;
renderer.render(scene, camera);
};
animate(performance.now());
callbacks.current.onReady();
}).catch(() => { if (!disposed) callbacks.current.onError(); });
return () => {
disposed = true;
window.clearTimeout(timeout);
cancelAnimationFrame(frame);
observer.disconnect();
visibility.disconnect();
canvas.removeEventListener("webglcontextlost", contextLost);
images.forEach((image) => { image.onload = null; image.onerror = null; });
video.pause(); video.removeAttribute("src"); video.load();
meshes.forEach((mesh) => { mesh.geometry.dispose(); mesh.material.dispose(); });
textures.forEach((texture) => texture.dispose());
trackMaterials.forEach((material) => material.dispose());
trackGeometries.forEach((geometry) => geometry.dispose());
linesGeometry.dispose(); lineMaterial.dispose();
renderer.dispose();
};
}, [progress, look]);
return <canvas ref={canvasRef} className="welcome-webgl" aria-label="随着故事章节旋转并展开的电影影像卷轴" />;
}

View File

@@ -0,0 +1,52 @@
export const chapters = [
{ id: "opening", label: "序幕", title: "影策", subtitle: "让一个故事,从文字走向银幕。", description: "面向 AI 影视与短剧创作的开源工作台。" },
{ id: "story", label: "故事", title: "一念,成故事。" },
{ id: "world", label: "角色", title: "让想象,有了面孔。" },
{ id: "shots", label: "分镜", title: "字里行间,皆是镜头。" },
{ id: "motion", label: "生成", title: "这一刻,动起来。" },
{ id: "canvas", label: "画布", title: "一个世界,一张画布。" },
] as const;
export type WelcomeLook = {
id: string;
label: string;
title: string;
frames: string[];
screenplay: string[];
video?: string;
credit?: string;
};
const reelFrames = (folder: string, names: string[]) => Array.from({ length: 12 }, (_, index) => `/welcome/${folder}/${names[index % names.length]}.webp`);
export const welcomeLooks: WelcomeLook[] = [
{
id: "spring", label: "Spring · 奇幻", title: "山谷之春",
frames: reelFrames("spring", ["1807", "1669", "4336", "3191", "3250"]),
screenplay: ["外景 · 山谷 · 黎明", "云海漫过山脊。", "她握紧手中的木杖。", "一条小路,通往谷底。", "石壁投下巨大的影子。", "她停下,望向高处。", "风声忽然安静。", "微光落在她的脸上。", "沉睡的山谷开始苏醒。", "她向前迈出一步。", "远处,第一片新叶展开。", "春天,终于来到。"],
credit: "Spring · Blender 开放电影演示素材",
},
{
id: "charge", label: "Charge · 科幻", title: "最后的电量",
frames: reelFrames("charge", ["factory-final", "einar", "robot", "character-final", "factory"]),
screenplay: ["外景 · 能源工厂 · 傍晚", "最后一盏灯闪了一下。", "他望向远处的高塔。", "机械手臂缓缓收紧。", "工厂大门留着一道缝。", "他走进无人值守的走廊。", "警示灯突然亮起。", "一个身影挡住去路。", "电池就在玻璃后面。", "他没有后退。", "微弱的电流重新接通。", "黑暗中,多了一点光。"],
video: "/welcome/charge/sequence.mp4", credit: "Charge · Blender 开放电影演示素材",
},
{
id: "wing-it", label: "Wing It! · 动画", title: "飞向云端",
frames: reelFrames("wing-it", ["banner", "pitch", "sky-01", "barn", "sky-04", "sky-06"]),
screenplay: ["内景 · 谷仓 · 午后", "飞行器还差最后一颗螺丝。", "他拧紧旋钮。", "仪表盘上的指针跳了起来。", "屋顶外,是辽阔的天空。", "引擎轰鸣,尘土飞扬。", "他们终于离开地面。", "一片云从舷窗掠过。", "飞行器向左倾斜。", "他重新握住操纵杆。", "阳光照亮了整片云海。", "下一站,还没有名字。"],
credit: "Wing It! · Blender 开放电影演示素材",
},
];
export function getWelcomeLook(search = window.location.search) {
const id = new URLSearchParams(search).get("look");
return welcomeLooks.find((look) => look.id === id) ?? welcomeLooks[0];
}
export const showcases = [
{ name: "自由画布", image: "/welcome/workbench-canvas.webp", description: "把灵感连成作品。", detail: "整理参考、连接节点、比较结果,沿着自己的思路继续创作。", href: "/canvas" },
{ name: "即时创作", image: "/welcome/workbench-create.webp", description: "从一句话开始。", detail: "选择模型与参考素材,在对话中逐步完成图片和视频。", href: "/create" },
{ name: "项目工作台", image: "/welcome/workbench-project.webp", description: "让长故事有条理。", detail: "围绕章节、人物与分镜组织制作,随时回到正在推进的故事。", href: "/projects" },
] as const;

View File

@@ -0,0 +1,156 @@
.welcome-page {
--welcome-black: #080808;
--welcome-paper: #f3f2ef;
--welcome-muted: #aaa9a6;
--welcome-accent: #eee7d7;
--welcome-border: #ffffff24;
--welcome-radius: 3px;
--welcome-header-height: 88px;
--welcome-content: 1380px;
--welcome-gutter: 64px;
--welcome-display: 128px;
--welcome-heading: 80px;
--welcome-z-navigation: 30;
--welcome-z-copy: 3;
background: var(--welcome-black);
color: var(--welcome-paper);
font-family: "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.6;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
}
body:has(.welcome-page) { margin: 0; background: #080808; }
.welcome-page *, .welcome-page *::before, .welcome-page *::after { box-sizing: border-box; }
.welcome-page h1, .welcome-page h2, .welcome-page h3, .welcome-page p { margin: 0; }
.welcome-page button, .welcome-page a { -webkit-tap-highlight-color: transparent; }
.welcome-page button { font: inherit; cursor: pointer; color: inherit; }
.welcome-page a { text-decoration: none; color: inherit; }
.welcome-page a:focus-visible, .welcome-page button:focus-visible { outline: 2px solid var(--welcome-accent); outline-offset: 6px; }
.welcome-page select:focus-visible { outline: 2px solid var(--welcome-accent); outline-offset: 4px; }
.welcome-page button:disabled { cursor: default; opacity: .4; }
.welcome-page svg { flex-shrink: 0; }
.welcome-skip { position: fixed; top: -100px; left: 20px; background: var(--welcome-paper); color: var(--welcome-black) !important; padding: 12px; z-index: 50; }
.welcome-skip:focus { top: 12px; }
.welcome-header { height: var(--welcome-header-height); position: fixed; inset: 0 0 auto; z-index: var(--welcome-z-navigation); display: flex; align-items: center; gap: 42px; padding: 0 var(--welcome-gutter); background: linear-gradient(#080808cc, #08080800); }
.welcome-brand { display: flex; align-items: center; gap: 12px; font-size: 18px; font-weight: 500; white-space: nowrap; }
.welcome-brand img { width: 26px; height: 26px; filter: grayscale(1) brightness(0) invert(1); }
.welcome-nav { margin-left: auto; display: flex; align-items: center; gap: 34px; }
.welcome-nav a { display: inline-flex; align-items: center; gap: 5px; color: var(--welcome-paper); font-size: 13px; transition: opacity 180ms; }
.welcome-nav a:hover { opacity: .65; }
.welcome-header-cta { display: flex; align-items: center; justify-content: center; gap: 24px; padding: 11px 19px; background: var(--welcome-paper); color: var(--welcome-black) !important; border-radius: var(--welcome-radius); font-size: 12px; }
.welcome-header-cta:hover { background: var(--welcome-accent); }
.welcome-look-picker { position: fixed; top: calc(var(--welcome-header-height) + 6px); right: var(--welcome-gutter); z-index: calc(var(--welcome-z-navigation) - 1); width: 164px; }
.welcome-look-picker select { display: block; width: 100%; height: 38px; padding: 0 8px; border: 1px solid var(--welcome-border); border-radius: var(--welcome-radius); background: var(--welcome-black); color: var(--welcome-paper); font: inherit; font-size: 12px; color-scheme: dark; cursor: pointer; }
.welcome-look-picker > a { display: flex; align-items: center; justify-content: flex-end; gap: 4px; padding: 7px 0; font-size: 10px; color: var(--welcome-muted); text-shadow: 0 1px 4px var(--welcome-black); }
.welcome-media-credit { padding: 0 var(--welcome-gutter) 26px; color: var(--welcome-muted); font-size: 11px; }
.welcome-media-credit a { display: inline-flex; align-items: center; gap: 5px; }
.welcome-icon { display: grid; place-items: center; background: transparent; border: 0; width: 44px; height: 44px; border-radius: 50%; }
.welcome-icon:hover { background: #ffffff12; }
.welcome-icon svg { width: 16px; height: 16px; }
.mobile-menu { display: none; }
.welcome-story { position: relative; height: 570svh; }
.welcome-stage { height: 100svh; min-height: 560px; position: sticky; top: 0; overflow: hidden; isolation: isolate; background: var(--welcome-black); }
.welcome-webgl, .welcome-poster { position: absolute; inset: 0; width: 100%; height: 100%; }
.welcome-poster { pointer-events: none; }
.welcome-poster img { width: 100%; height: 100%; object-fit: cover; }
.welcome-poster.is-ready { opacity: clamp(0, calc(1 - var(--story-progress, 0) * 9), 1); }
.welcome-stage-shade { pointer-events: none; position: absolute; inset: 0; background: linear-gradient(180deg, #08080830 0%, transparent 23%, transparent 50%, #08080844 70%, #080808e8 100%); }
.chapter-3 .welcome-stage-shade { background: linear-gradient(180deg, #08080870 0%, #080808c7 28%, transparent 50%, #080808c7 100%); }
.welcome-chapter { position: absolute; z-index: var(--welcome-z-copy); bottom: 17%; left: var(--welcome-gutter); right: var(--welcome-gutter); opacity: 0; transform: translateY(25px); pointer-events: none; transition: opacity .6s, transform .8s; text-align: left; visibility: hidden; }
.welcome-chapter.is-active { opacity: 1; transform: translateY(0); visibility: visible; }
.welcome-chapter h1 { font-size: var(--welcome-display); font-weight: 400; line-height: 1.12; margin-bottom: 26px; }
.welcome-chapter h2 { font-size: var(--welcome-heading); font-weight: 400; line-height: 1.25; }
.welcome-chapter h2 > span { display: inline-block; }
.welcome-opening { bottom: 15%; }
.welcome-subtitle { font-size: 19px; color: var(--welcome-paper); }
.chapter-1 .welcome-chapter { bottom: auto; top: 44%; text-align: center; }
.chapter-3 .welcome-chapter { bottom: auto; top: 20%; }
.chapter-2 .welcome-chapter { text-align: right; }
.chapter-5 .welcome-chapter { top: 17%; bottom: auto; text-align: center; }
.chapter-5 .welcome-chapter h2 { font-size: 58px; }
.welcome-film-link { position: absolute; right: 0; bottom: 0; border: 0; background: none; padding: 10px 0 10px 16px; display: flex; align-items: center; gap: 15px; font-size: 12px !important; pointer-events: auto; }
.welcome-play-mark { display: grid; place-items: center; width: 64px; height: 64px; border: 1px solid #ffffff70; border-radius: 50%; transition: background 180ms, transform 180ms; }
.welcome-film-link:hover .welcome-play-mark { background: #ffffff20; transform: scale(1.06); }
.welcome-stage-bottom { position: absolute; inset: auto var(--welcome-gutter) 25px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.welcome-next { border: 0; background: none; display: grid; place-items: center; width: 44px; height: 44px; }
.welcome-chapter-nav { display: flex; gap: 4px; }
.welcome-chapter-nav button { display: grid; place-items: center; border: 0; background: none; width: 32px; height: 44px; padding: 0; }
.welcome-chapter-nav button span { display: block; width: 22px; height: 1px; background: #ffffff48; transition: background .2s, height .2s; }
.welcome-chapter-nav .is-current span { height: 2px; background: var(--welcome-paper); }
.welcome-chapter-nav button:hover span { background: var(--welcome-paper); }
.welcome-scroll-progress { position: absolute; bottom: 0; left: 0; width: 100%; height: 1px; background: #ffffff50; transform-origin: left; transform: scaleX(var(--story-progress, 0)); }
.welcome-workbench { position: relative; padding: 100px var(--welcome-gutter) 70px; max-width: calc(var(--welcome-content) + var(--welcome-gutter) * 2); margin: auto; scroll-margin-top: var(--welcome-header-height); }
.welcome-section-heading { margin-bottom: 50px; }
.welcome-section-heading h2 { font-size: 56px; font-weight: 400; line-height: 1.4; }
.welcome-workbench-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }
.welcome-workbench-bar [role="tablist"] { display: flex; gap: 32px; }
.welcome-workbench-bar button { background: none; border: 0; padding: 12px 0; border-bottom: 1px solid transparent; color: var(--welcome-muted); }
.welcome-workbench-bar button[aria-selected="true"] { border-bottom-color: var(--welcome-paper); color: var(--welcome-paper); }
.welcome-workbench-bar a { display: flex; gap: 9px; align-items: center; font-size: 12px; color: var(--welcome-muted); }
.welcome-workbench-preview { aspect-ratio: 16/9; background: #141414; overflow: hidden; border-radius: var(--welcome-radius); }
.welcome-workbench-preview img { width: 100%; height: 100%; object-fit: contain; }
.welcome-workbench-caption { padding: 24px 0 0; }
.welcome-workbench-caption p { color: var(--welcome-muted); font-size: 13px; }
.welcome-ending { text-align: center; padding: 120px 24px 125px; }
.welcome-ending h2 { font-weight: 400; font-size: 92px; line-height: 1.2; margin-bottom: 42px; }
.welcome-primary { display: inline-flex; align-items: center; justify-content: center; gap: 42px; height: 52px; padding: 0 27px; background: var(--welcome-paper); color: var(--welcome-black) !important; border-radius: var(--welcome-radius); font-size: 14px; font-weight: 500; transition: background 180ms, transform 180ms; }
.welcome-primary:hover { background: var(--welcome-accent); transform: translateY(-2px); }
.welcome-source { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 27px; font-size: 12px; color: var(--welcome-muted) !important; }
.welcome-footer { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 25px var(--welcome-gutter); color: var(--welcome-muted); font-size: 11px; }
.welcome-footer > a:first-child { color: var(--welcome-paper); font-size: 15px; font-weight: 500; }
.welcome-footer a:last-child { display: flex; gap: 5px; align-items: center; }
.welcome-film-dialog { border: 0; padding: 0; max-width: min(1000px, 94%); width: 100%; background: #111; color: #f3f2ef; border-radius: 3px; overflow: visible; }
.welcome-film-dialog::backdrop { background: #000d; }
.welcome-film-dialog video { width: 100%; display: block; }
.welcome-film-dialog p { font-size: 12px; padding: 14px 20px; }
.welcome-film-dialog .welcome-icon { position: absolute; right: 10px; top: -50px; }
@media (min-width: 1600px) { .welcome-page { --welcome-gutter: 80px; --welcome-display: 160px; --welcome-heading: 96px; } }
@media (max-height: 760px) and (min-width: 768px) { .welcome-page { --welcome-display: 104px; --welcome-heading: 64px; } .welcome-opening { bottom: 17%; } }
@media (max-width: 1100px) { .welcome-page { --welcome-gutter: 36px; --welcome-display: 100px; --welcome-heading: 64px; } }
@media (max-width: 767px) {
.welcome-page { --welcome-gutter: 24px; --welcome-header-height: 76px; --welcome-display: 74px; --welcome-heading: 44px; }
.welcome-header { gap: 14px; }
.welcome-look-picker { width: 148px; }
.welcome-brand { font-size: 15px; gap: 8px; }
.welcome-brand img { width: 22px; height: 22px; }
.welcome-header-cta { margin-left: auto; padding: 9px 12px; gap: 10px; font-size: 11px; }
.mobile-menu { display: grid; width: 34px; height: 44px; }
.welcome-nav { display: none; position: absolute; top: var(--welcome-header-height); left: 0; right: 0; padding: 25px; background: var(--welcome-black); }
.welcome-nav.is-open { display: flex; justify-content: space-between; gap: 12px; }
.welcome-story { height: 470svh; }
.welcome-stage { min-height: 500px; }
.welcome-chapter { bottom: 22%; }
.welcome-chapter h1 { margin-bottom: 20px; }
.welcome-chapter h2 { max-width: 350px; }
.welcome-opening { bottom: 19%; }
.welcome-subtitle { font-size: 14px; }
.welcome-film-link { bottom: -65px; right: auto; left: 0; padding: 0; font-size: 11px !important; gap: 10px; }
.welcome-play-mark { width: 38px; height: 38px; }
.welcome-play-mark svg { width: 12px; height: 12px; }
.chapter-1 .welcome-chapter { top: 43%; }
.chapter-3 .welcome-chapter { top: max(16%, calc(var(--welcome-header-height) + 94px)); }
.chapter-2 .welcome-chapter { text-align: left; }
.chapter-5 .welcome-chapter { top: max(18%, calc(var(--welcome-header-height) + 94px)); }
.chapter-5 .welcome-chapter h2 { font-size: 36px; max-width: 100%; }
.welcome-stage-bottom { bottom: 18px; gap: 12px; }
.welcome-chapter-nav { gap: 0; }
.welcome-chapter-nav button { width: 28px; }
.welcome-chapter-nav button span { width: 16px; }
.welcome-workbench { padding-top: 68px; padding-bottom: 40px; }
.welcome-section-heading { margin-bottom: 28px; }
.welcome-section-heading h2 { font-size: 32px; }
.welcome-workbench-bar [role="tablist"] { gap: 21px; }
.welcome-workbench-bar button { font-size: 12px; }
.welcome-workbench-bar > a { font-size: 0; gap: 0; }
.welcome-workbench-caption p { font-size: 12px; }
.welcome-ending { padding: 65px 24px 70px; }
.welcome-ending h2 { font-size: 58px; margin-bottom: 30px; }
.welcome-footer { flex-wrap: wrap; gap: 12px; font-size: 10px; }
.welcome-footer > span { display: none; }
}
@media (max-width: 360px) { .welcome-page { --welcome-display: 66px; --welcome-gutter: 18px; } }
@media (prefers-reduced-motion: reduce) {
.welcome-page *, .welcome-page *::before, .welcome-page *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
.welcome-story { height: 400svh; }
}

View File

@@ -0,0 +1,5 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import WelcomePage from "@/pages/welcome";
createRoot(document.getElementById("root")!).render(<StrictMode><WelcomePage /></StrictMode>);