diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index b00554948..133cebed3 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -1550,3 +1550,15 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme
- [ ] 让 AI 回复含多行代码(如 markdown 围栏 ```ts```):代码块顶部有语言标签 + 复制按钮,点复制后变「已复制」,粘贴内容正确(不含语言标记行)。
- [ ] 代码块圆角/边框/底色在明暗主题均可辨;长代码横向滚动不破版;流式输出中代码块容器稳定不跳动、复制按钮可用。
- [ ] 消息中的行内代码(如 `const x`)样式不受影响;画布 Agent 消息里的代码块同样获得新外壳。
+
+## 影策品牌首页
+
+- 新增 `/welcome` 独立公开首页、六幕电影卷轴、工作台截图切换和创作入口,保留既有工作台路由。
+- 用户已确认品牌名影策,保留 Spring(默认)、Charge、Wing It! 三个素材版本;没有竹影选项或专用素材。
+- 首屏影像背景随滚动渐隐为黑底,返回顶部时恢复;故事章节保留九张电影画面和三张叠字镜头。
+- 来源、作者署名、CC BY 4.0 许可和改编说明见 `/welcome/credits.html`,不将第三方电影素材宣称为影策生成案例。
+- 设计与实现说明:[影策品牌首页](../../../design/story-creation-homepage.md)。
+- [x] 上游基线独立分支的完整前端构建(指定 Go 1.25.0 工具链)、素材与启动清理专项测试通过;生产预览通过 1440px、390px 三版本切换、六幕滚动、非空画布与动画、Charge 视频、历史返回、减少动态效果与署名页检查,无页面异常、资源 HTTP 错误或后端 API 请求。
+- [ ] iOS Safari 真机地址栏变化、长时间浏览帧率与设备发热。
+- [ ] Wing It! 部分低分辨率色彩分镜后续升级为高清成片镜头。
+- [ ] 是否将品牌首页设为根路径入口,由项目维护者另行决定。
diff --git a/docs/design/story-creation-homepage.md b/docs/design/story-creation-homepage.md
new file mode 100644
index 000000000..70553995f
--- /dev/null
+++ b/docs/design/story-creation-homepage.md
@@ -0,0 +1,51 @@
+# 影策品牌首页
+
+## 定位与入口
+
+新增公开首页 `/welcome`,产品名为「影策」,核心文案是「让一个故事,从文字走向银幕」。首页讲述一个创作过程,并提供进入实际工作台的入口。
+
+现有 `/`、`/create` 创作页与 `/home` 项目概览仍按原路由运行。工作台左上品牌菜单新增「品牌首页」链接。新首页通过独立 React 入口加载,以普通链接进入工作台,避免等待登录恢复、加载模型目录或连接本机 Agent。账户认证与业务权限继续由工作台负责。
+
+## 内容与视觉
+
+| 幕 | 主标题 | 素材与镜头 |
+| --- | --- | --- |
+| 序幕 | 影策 | 同一故事的影像组成旋转卷轴,首屏保留全屏影像背景 |
+| 故事 | 一念,成故事。 | 九张电影画面与三张叠字剧本镜头交错,摄影机升高 |
+| 角色 | 让想象,有了面孔。 | 当前故事中的人物与环境参考 |
+| 分镜 | 字里行间,皆是镜头。 | 黑白影像卡,标记 SHOT、景别与运镜 |
+| 生成 | 这一刻,动起来。 | 摄影机靠近;Charge 版本的部分画面播放对应影片片段 |
+| 画布 | 一个世界,一张画布。 | 同一批画面展开成节点矩阵与连线 |
+
+六幕后进入真实工作台截图,提供自由画布、即时创作、项目工作台三个可切换视图与对应业务入口。页尾收束为「你的故事,现在开始」,提供开始创作、GitHub 和 MIT License 链接。
+
+第二版以中性近黑与白为底,图片承担主要色彩。摄影机拉近,卷轴略微倾斜并占满主画面;首屏品牌移到左下,保留一句主张,主入口集中于顶部。每幕后只保留一句标题,去掉英文眉题、解释段落和影片角标;底部导航改为可点击刻度,悬停显示章节名称。下方删除三栏功能说明,保留工作台实景。全屏场景无装饰性容器,中文字号按断点设置,不随视口线性缩放。文字为真实 DOM,场景为 Three.js;动画可暂停。手机缩短滚动距离并调整摄影机,减少动态效果和 WebGL 不可用时使用静态画面。标题与 CTA 在资源加载前即可显示。
+
+## 素材
+
+提供 Spring(默认)、Charge、Wing It! 三个素材版本,使用 `?look=spring|charge|wing-it` 直接打开,菜单切换保留滚动位置并支持浏览器前进、后退。首屏背景随向下滚动渐隐为黑底,返回顶部时恢复,而不是在 WebGL 加载后立即消失。
+
+素材保存在 `web/public/welcome/`:Spring 官方五张剧照、Charge 五张预览与截帧、Wing It! 六张绘画与裁图分别循环编排为十二个卷轴位置。Charge 有对应的静音预告片节选;其他版本不混用视频,也不显示无内容的播放入口。Wing It! 部分彩稿分辨率较低,适合方向预览,最终上线可继续补充高清成片镜头。
+
+这些素材来自 Blender 开放电影,资源页采用 CC BY 4.0;完整作者、来源、许可和裁剪处理说明在 `/welcome/credits.html`,入口同时出现在版本菜单下方与页尾。应用代码的 MIT 许可不覆盖这些第三方素材。剧本为本页原创演示文字,黑白分镜为图片处理,不宣称是影策生成的作品,也不宣称是原片剧本。
+
+自由画布、项目工作台截图来自仓库 `assets/`;即时创作使用用户提供并确认的工作台截图,保留完整比例。
+
+## 实现
+
+- `web/src/main.tsx`:精确匹配公开入口,仍保留启动 URL 清理流程。
+- `web/src/welcome-application.tsx`:独立 React 挂载。
+- `web/src/pages/welcome/index.tsx`:内容、滚动进度、章节导航、视频弹窗与实景切换。
+- `web/src/pages/welcome/story.ts`:文案与素材清单。
+- `web/src/pages/welcome/story-reel.tsx`:Three.js 影像卷轴、阶段材质、视频、轨迹与节点展开。
+- `web/src/pages/welcome/welcome.css`:仅作用于本页的视觉 token 和响应式布局。
+
+设计参考 [Codrops Demo 1](https://tympanus.net/Tutorials/Cinematic3DScroll/) 与 [源码](https://github.com/JosephASG/codrops-cinematic-scroll-animations)。原 Demo 使用 OGL + GSAP;本实现使用已有 Three.js 与原生滚动进度,无新增依赖,不复制其图片、字体、全局 CSS 或广告脚本。
+
+## 验收
+
+需覆盖:无后端访问、三版本切换、六幕跳转、影片弹窗、工作台标签切换、业务跳转、桌面与移动端、低动态偏好、纹理加载失败、WebGL 丢失、卸载后的资源释放。用户已确认保留三组素材并使用影策品牌;真机性能仍需确认。
+
+第二版已通过 TypeScript 检查、Vite 打包,以及 Chromium 375、390、1440、1920 宽度下的浏览器检查:六幕切换、非空画面与动画像素变化、影片播放和关闭、工作台标签切换、无横向溢出、无后端 API 请求,以及减少动态效果、图片失败和 WebGL 创建失败的降级。真机性能与最终视觉接受度仍待确认。
+
+最终三版本在上游主线的独立 PR 分支再次通过完整前端构建(`GOTOOLCHAIN=go1.25.0 bun run build`)、素材与启动清理专项测试(`bun test test/welcome-story.test.ts test/local-runtime-bootstrap.test.ts`)。生产预览验证了 1440px、390px 的三版本切换、六幕滚动、画布像素与运动、Charge 视频、刷新与历史返回、减少动态效果及署名页,未出现页面异常或后端 API 请求。
diff --git a/docs/index.md b/docs/index.md
index 420341222..a99ac6b78 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,6 +4,8 @@
## 设计沉淀
+- [影策品牌首页](design/story-creation-homepage.md):六幕电影卷轴、公开入口、故事素材、工作台预览与响应式降级。
+
- [创作 Agent 第一期版本基线](design/creative-agent-phase-one-checkpoint.mdx):用户基本体验验收反馈、已实现范围、执行与资源边界核对、待验收项及代码恢复说明。
- [通用创作 Agent 动态规划与画布协作需求设计](design/creative-agent-dynamic-planning.mdx):基于原会话的专业角色、动态问答与计划、创意方案和执行批次分离、统一画布与费用授权、历史恢复及分期验收(待审阅,未实施)。
diff --git a/web/public/welcome/charge/character-final.webp b/web/public/welcome/charge/character-final.webp
new file mode 100644
index 000000000..006cf0004
Binary files /dev/null and b/web/public/welcome/charge/character-final.webp differ
diff --git a/web/public/welcome/charge/einar.webp b/web/public/welcome/charge/einar.webp
new file mode 100644
index 000000000..1410af347
Binary files /dev/null and b/web/public/welcome/charge/einar.webp differ
diff --git a/web/public/welcome/charge/factory-final.webp b/web/public/welcome/charge/factory-final.webp
new file mode 100644
index 000000000..f66abe8f1
Binary files /dev/null and b/web/public/welcome/charge/factory-final.webp differ
diff --git a/web/public/welcome/charge/factory.webp b/web/public/welcome/charge/factory.webp
new file mode 100644
index 000000000..9a583bc37
Binary files /dev/null and b/web/public/welcome/charge/factory.webp differ
diff --git a/web/public/welcome/charge/robot.webp b/web/public/welcome/charge/robot.webp
new file mode 100644
index 000000000..ccc195913
Binary files /dev/null and b/web/public/welcome/charge/robot.webp differ
diff --git a/web/public/welcome/charge/sequence.mp4 b/web/public/welcome/charge/sequence.mp4
new file mode 100644
index 000000000..3ef64c116
Binary files /dev/null and b/web/public/welcome/charge/sequence.mp4 differ
diff --git a/web/public/welcome/credits.html b/web/public/welcome/credits.html
new file mode 100644
index 000000000..810e6ca88
--- /dev/null
+++ b/web/public/welcome/credits.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ 首页演示素材 · 署名与许可
+
+
+
+ 返回影策
+ 首页演示素材 · 署名与许可
+ Spring、Charge 与 Wing It! 的画面来自 Blender 开放电影及其制作素材,不是影策生成的案例,也不表示原作者对本产品的认可。
+ 以下对应资源页标注 Creative Commons Attribution 4.0(CC BY 4.0)。保留作者、来源与许可链接,标明修改后,可以分享和改编,包括商业使用。资源不适用应用代码的 MIT 许可;商标、Logo 与未列出的第三方内容不在本说明范围内。
+ 本页面演示对图片做了缩放、WebP 压缩、卷轴贴图裁剪及黑白分镜效果。剧本卡片文字为首页自行编写的演示文字,并非原片剧本或对白。
+
+
+ Charge · 科幻版本
+ Blender Studio / Charge。所有下列资源采用 CC BY 4.0。
+
+
+
+ Wing It! · 明亮动画版本
+ Blender Studio / Wing It!。所有下列资源采用 CC BY 4.0。
+
+
+
+
diff --git a/web/public/welcome/spring/1669.webp b/web/public/welcome/spring/1669.webp
new file mode 100644
index 000000000..488f1f5b4
Binary files /dev/null and b/web/public/welcome/spring/1669.webp differ
diff --git a/web/public/welcome/spring/1807.webp b/web/public/welcome/spring/1807.webp
new file mode 100644
index 000000000..6abd9c9e4
Binary files /dev/null and b/web/public/welcome/spring/1807.webp differ
diff --git a/web/public/welcome/spring/3191.webp b/web/public/welcome/spring/3191.webp
new file mode 100644
index 000000000..1dd499e54
Binary files /dev/null and b/web/public/welcome/spring/3191.webp differ
diff --git a/web/public/welcome/spring/3250.webp b/web/public/welcome/spring/3250.webp
new file mode 100644
index 000000000..fd7374ad1
Binary files /dev/null and b/web/public/welcome/spring/3250.webp differ
diff --git a/web/public/welcome/spring/4336.webp b/web/public/welcome/spring/4336.webp
new file mode 100644
index 000000000..d14cb7739
Binary files /dev/null and b/web/public/welcome/spring/4336.webp differ
diff --git a/web/public/welcome/wing-it/banner.webp b/web/public/welcome/wing-it/banner.webp
new file mode 100644
index 000000000..2136f90f4
Binary files /dev/null and b/web/public/welcome/wing-it/banner.webp differ
diff --git a/web/public/welcome/wing-it/barn.webp b/web/public/welcome/wing-it/barn.webp
new file mode 100644
index 000000000..1c9c3a82a
Binary files /dev/null and b/web/public/welcome/wing-it/barn.webp differ
diff --git a/web/public/welcome/wing-it/pitch.webp b/web/public/welcome/wing-it/pitch.webp
new file mode 100644
index 000000000..7743e3754
Binary files /dev/null and b/web/public/welcome/wing-it/pitch.webp differ
diff --git a/web/public/welcome/wing-it/sky-01.webp b/web/public/welcome/wing-it/sky-01.webp
new file mode 100644
index 000000000..d43a02e11
Binary files /dev/null and b/web/public/welcome/wing-it/sky-01.webp differ
diff --git a/web/public/welcome/wing-it/sky-04.webp b/web/public/welcome/wing-it/sky-04.webp
new file mode 100644
index 000000000..b3875bbe8
Binary files /dev/null and b/web/public/welcome/wing-it/sky-04.webp differ
diff --git a/web/public/welcome/wing-it/sky-06.webp b/web/public/welcome/wing-it/sky-06.webp
new file mode 100644
index 000000000..4c88ac6bc
Binary files /dev/null and b/web/public/welcome/wing-it/sky-06.webp differ
diff --git a/web/public/welcome/workbench-canvas.webp b/web/public/welcome/workbench-canvas.webp
new file mode 100644
index 000000000..b9fd1baf6
Binary files /dev/null and b/web/public/welcome/workbench-canvas.webp differ
diff --git a/web/public/welcome/workbench-create.webp b/web/public/welcome/workbench-create.webp
new file mode 100644
index 000000000..6e8faa9a1
Binary files /dev/null and b/web/public/welcome/workbench-create.webp differ
diff --git a/web/public/welcome/workbench-project.webp b/web/public/welcome/workbench-project.webp
new file mode 100644
index 000000000..3fb7d541a
Binary files /dev/null and b/web/public/welcome/workbench-project.webp differ
diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx
index f3e887115..96288e246 100644
--- a/web/src/components/layout/workspace-sidebar-nav.tsx
+++ b/web/src/components/layout/workspace-sidebar-nav.tsx
@@ -126,6 +126,9 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
创作工作台
+
+ 品牌首页
+
{[
{ label: "首页", to: "/" },
{ label: "画布", to: "/canvas" },
diff --git a/web/src/main.tsx b/web/src/main.tsx
index c943790ed..d9ee668f1 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -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"));
},
);
diff --git a/web/src/pages/welcome/index.tsx b/web/src/pages/welcome/index.tsx
new file mode 100644
index 000000000..b4197fc3c
--- /dev/null
+++ b/web/src/pages/welcome/index.tsx
@@ -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 ;
+}
+
+function WelcomeExperience({ look, onLookChange }: { look: WelcomeLook; onLookChange: (id: string) => void }) {
+ const storyRef = useRef(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(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 (
+
+
前往工作台介绍
+
+
+
+
+
+
+
+ {!staticScene &&
setFailed(true)}> setReady(true)} onError={() => setFailed(true)} />}
+
+ {chapters.map((item, index) => (
+
+ {index === 0 ? {item.title}
: {item.title.split(",").map((part, partIndex, parts) => {part}{partIndex < parts.length - 1 ? "," : ""})}
}
+ {"subtitle" in item && <>{item.subtitle}
{look.video && }>}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ 让想象,有处落笔。
+ {showcases.map((item, index) => )}
进入{active.name}
+
+
+
+
+
+
+
+ {look.credit &&
}
+ {playing && look.video &&
setPlaying(false)} videoRef={videoRef} />}
+
+ );
+}
+
+function FilmDialog({ look, onClose, videoRef }: { look: WelcomeLook; onClose: () => void; videoRef: React.RefObject }) {
+ const dialogRef = useRef(null);
+ useEffect(() => { const dialog = dialogRef.current; dialog?.showModal(); return () => dialog?.close(); }, []);
+ return ;
+}
+
+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; }
+}
diff --git a/web/src/pages/welcome/story-reel.tsx b/web/src/pages/welcome/story-reel.tsx
new file mode 100644
index 000000000..1a3c673b5
--- /dev/null
+++ b/web/src/pages/welcome/story-reel.tsx
@@ -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; 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(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[] = [];
+ const meshes: THREE.Mesh[] = [];
+ 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((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>();
+ 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 ", 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 ;
+}
diff --git a/web/src/pages/welcome/story.ts b/web/src/pages/welcome/story.ts
new file mode 100644
index 000000000..85b288a47
--- /dev/null
+++ b/web/src/pages/welcome/story.ts
@@ -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;
diff --git a/web/src/pages/welcome/welcome.css b/web/src/pages/welcome/welcome.css
new file mode 100644
index 000000000..60da52fd4
--- /dev/null
+++ b/web/src/pages/welcome/welcome.css
@@ -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; }
+}
diff --git a/web/src/welcome-application.tsx b/web/src/welcome-application.tsx
new file mode 100644
index 000000000..18d0e91d4
--- /dev/null
+++ b/web/src/welcome-application.tsx
@@ -0,0 +1,5 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import WelcomePage from "@/pages/welcome";
+
+createRoot(document.getElementById("root")!).render();
diff --git a/web/test/welcome-story.test.ts b/web/test/welcome-story.test.ts
new file mode 100644
index 000000000..273f3e005
--- /dev/null
+++ b/web/test/welcome-story.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { chapters, getWelcomeLook, showcases, welcomeLooks } from "../src/pages/welcome/story";
+
+const publicFile = (url: string) => resolve(import.meta.dir, "../public", url.replace(/^\//, ""));
+
+describe("welcome story", () => {
+ test("uses Yingce and only the three approved looks", () => {
+ expect(chapters[0].title).toBe("影策");
+ expect(welcomeLooks.map((look) => look.id)).toEqual(["spring", "charge", "wing-it"]);
+ expect(getWelcomeLook("").id).toBe("spring");
+ expect(getWelcomeLook("?look=unknown").id).toBe("spring");
+ for (const look of welcomeLooks) expect(getWelcomeLook(`?look=${look.id}`)).toBe(look);
+ });
+
+ test("every reel slot has local media and screenplay", () => {
+ for (const look of welcomeLooks) {
+ expect(look.frames).toHaveLength(12);
+ expect(look.screenplay).toHaveLength(12);
+ expect(look.screenplay.every((line) => line.trim().length > 0)).toBe(true);
+ for (const frame of look.frames) expect(existsSync(publicFile(frame))).toBe(true);
+ if (look.video) expect(existsSync(publicFile(look.video))).toBe(true);
+ }
+ expect(welcomeLooks.filter((look) => look.video).map((look) => look.id)).toEqual(["charge"]);
+ for (const showcase of showcases) expect(existsSync(publicFile(showcase.image))).toBe(true);
+ });
+
+ test("credits cover all looks separately from the code license", () => {
+ const credits = readFileSync(publicFile("/welcome/credits.html"), "utf8");
+ expect(credits).toContain("https://creativecommons.org/licenses/by/4.0/");
+ for (const look of welcomeLooks) expect(credits).toContain(`id="${look.id}"`);
+ expect(credits).not.toContain("竹影");
+ expect(existsSync(publicFile("/welcome/sequence.mp4"))).toBe(false);
+ });
+});