feat(welcome): 影策首页 - 新增三主题电影卷轴与公开入口
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
);
|
||||
|
||||
153
web/src/pages/welcome/index.tsx
Normal file
153
web/src/pages/welcome/index.tsx
Normal 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; }
|
||||
}
|
||||
275
web/src/pages/welcome/story-reel.tsx
Normal file
275
web/src/pages/welcome/story-reel.tsx
Normal 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="随着故事章节旋转并展开的电影影像卷轴" />;
|
||||
}
|
||||
52
web/src/pages/welcome/story.ts
Normal file
52
web/src/pages/welcome/story.ts
Normal 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;
|
||||
156
web/src/pages/welcome/welcome.css
Normal file
156
web/src/pages/welcome/welcome.css
Normal 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; }
|
||||
}
|
||||
5
web/src/welcome-application.tsx
Normal file
5
web/src/welcome-application.tsx
Normal 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>);
|
||||
Reference in New Issue
Block a user