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 压缩、卷轴贴图裁剪及黑白分镜效果。剧本卡片文字为首页自行编写的演示文字,并非原片剧本或对白。

+
+

Spring · 奇幻版本

+

© Blender Foundation | cloud.blender.org/spring。官方署名与授权说明。

+ +
+
+

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)} />} +
+ +
+

让想象,有处落笔。

+
{showcases.map((item, index) => )}
进入{active.name}
+
{`影策${active.name}界面`}
+

{active.detail}

+
+ +

你的故事,
现在开始。

开始创作GitHub
+
+ + {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 { if (event.target === event.currentTarget) onClose(); }}>
; +} + +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); + }); +});