diff --git a/.gitignore b/.gitignore index 14f5ae0..7bbf24a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,17 @@ projects/* __pycache__/ *.pyc -# showcase 04: proxies rebuilt by tools/make_clips.sh from showcase 00–03; the film's regenerable audio +# showcase 04: proxies rebuilt by tools/make_clips.sh from showcase 00–03; the film's regenerable audio; +# the film atlases (tools/opening_export.sh, film_atlas.py, ai_atlas.py), the Blender frames and plate, the grid state showcase/04-intro-film/assets/clips/ showcase/04-intro-film/audio/*.wav showcase/04-intro-film/audio/sfx/ +showcase/04-intro-film/audio/sfxlib/ +showcase/04-intro-film/audio/stems/ +showcase/04-intro-film/assets/plate.mp4 +showcase/04-intro-film/assets/state.json +showcase/04-intro-film/assets/hero-earth.png +showcase/04-intro-film/assets/films*.jpg +showcase/04-intro-film/assets/films*.png +showcase/04-intro-film/blender/out/ +showcase/04-intro-film/opening/node_modules diff --git a/AGENTS.md b/AGENTS.md index fbb3087..533d04a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ effort 管 agent 自己查得多细,导演模式管人拍板哪些事,两个 |---|---|---|---| | 数学、物理、算法的原理讲解,3b1b 风格 | `video-types/01-math-science-explainer.md` | Manim CE | `showcase/03-math-fourier/`、`cases/explainer-code2video.md`、`cases/opus55-gallery.md` 第 7 节(抽象代数宣传片深读) | | 知识科普短视频,竖屏或横屏(抖音、B站、小红书、视频号、Shorts) | `video-types/02-knowledge-short.md` | HyperFrames | `showcase/02-short-leo-doppler/`、`cases/explainer-interstellar-blackhole.md` | -| 产品宣传、发布片、App 或 SaaS 介绍、功能演示 | `video-types/03-product-promo.md` | HyperFrames | `showcase/00-promo-launch-film/`、`showcase/04-intro-film/`(一镜到底 3D + 代码作曲)、`cases/promo-hyperframes-launches.md` | +| 产品宣传、发布片、App 或 SaaS 介绍、功能演示 | `video-types/03-product-promo.md` | HyperFrames | `showcase/00-promo-launch-film/`、`showcase/04-intro-film/`(Blender 星河开场 + 一镜到底 3D + 代码作曲)、`cases/promo-hyperframes-launches.md` | | 歌词视频、MV、配合音乐的动画 | `video-types/04-lyric-music-video.md` | ClaudeAnimationBase(手绘)或 HyperFrames(排版) | `cases/mv-pdoom.md`、`cases/mv-functional-emotions.md`、`cases/mv-claude-pop.md` | | 数据故事、动画图表、数字可视化 | `video-types/05-data-story.md` | HyperFrames + SVG | — | | 论文讲解、会议视频、学术报告 | `video-types/06-paper-explainer.md` | Manim(机制)或 HyperFrames(结构、结果) | `cases/paper-paper2video.md`、`cases/explainer-code2video.md` | @@ -116,7 +116,7 @@ effort 管 agent 自己查得多细,导演模式管人拍板哪些事,两个 | 3 分钟以上的长片,或靠故事、谜题推进的片子(角色短片、长讲解) | 主类型文档,加上 `playbook/09-narrative.md`(骨架、节拍表、张力曲线、换挡) | 按主类型 | — | | 要发短视频平台:开头钩子、标题、封面 | `playbook/10-hooks-and-packaging.md`,加上主类型文档 | 按主类型 | — | | 要写有篇章、有主题的配乐(MV、介绍片和发布片、45 s 以上靠音乐撑起结构的片子、`studio` 档位,或者人要亲自定主题和 BGM) | `playbook/11-composition.md`;`score.json` 的写法见 `playbook/04-audio.md` | `bin/vh music` | — | -| 3D 场景、着色器短片(Three.js) | 暂无专门的类型文档:以 `03-product-promo.md` 的运动规则为准,加上 `playbook/08-vfx-and-motion-sources.md`(一镜到底、特效预设栈、子帧运动模糊)。要路径追踪的光影(玻璃、皮肤、体积光)、物理模拟或真实景深时,读 `engines/blender.md`(部分验证:风格样片 `tabletop-miniature` 已用 Blender 5.2.2 渲染,项目用的命令还有几条没跑过;渲染时间先渲 3–5 帧校准) | HyperFrames + Three.js 层;重光影的镜头用 Blender | `showcase/04-intro-film/`、`cases/opus55-gallery.md` 的 3D 一节和第 6 节(Austerlitz 长片深读) | +| 3D 场景、着色器短片(Three.js) | 暂无专门的类型文档:以 `03-product-promo.md` 的运动规则为准,加上 `playbook/08-vfx-and-motion-sources.md`(一镜到底、特效预设栈、子帧运动模糊)。要路径追踪的光影(玻璃、皮肤、体积光)、物理模拟或真实景深时,读 `engines/blender.md`(部分验证:风格样片 `tabletop-miniature` 和介绍片 v5 的开场已用 Blender 5.2.2 渲染,项目用的命令还有几条没跑过;渲染时间先渲 3–5 帧校准) | HyperFrames + Three.js 层;重光影的镜头用 Blender | `showcase/04-intro-film/`、`cases/opus55-gallery.md` 的 3D 一节和第 6 节(Austerlitz 长片深读) | | 想要新点子、立意,一句话需求想做得出彩,或者不想千篇一律 | `playbook/12-ideation.md`,再加主类型文档 | 按主类型 | `cases/oneshot-five.md`、`cases/explainer-interstellar-blackhole.md` | | 想要某种风格、参考某部名作,或者不想每支片子都一个口味 | `styles/README.md`,再读选中预设的 `styles//STYLE.md` | 随主引擎 | 每个预设的 `media/swatch.mp4`,总览 `styles/gallery.jpg` | | 想知道某种镜头怎么动(开场、字卡、转场、卡点、收尾),或想要"专业的节奏" | `recipes/README.md`,再读 `recipes/sequences/` 里合适的骨架 | 随主引擎 | `cases/promo-video-shotcraft.md` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e60dc..72f3a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +**The intro film v5: a Blender-rendered galaxy of films, then one take through the repo (150.5 s)** +- Why: at gate ① the maintainer found the old opening "not stunning enough… the waterfall looks like the same film over and over" and asked for glass, cosmos and a starry sky that makes you dizzy; after a WebGL look-dev, "a bit of a cheap-effects feel… could the films be shown as a galaxy, as stars? Or use Blender? Hollywood blockbuster quality"; after the first Blender version, less jitter, Blender only for the first 15 s, "just make the final version". The old film also showed stale numbers (8 types, 9 playbook docs). +- `showcase/04-intro-film/` is now v5 (v3's docs and media are kept in `v3/`, its source at 7057c74). The opening (0–15.8 s) is path-traced in Blender 5.2 (Cycles, Metal) by `blender/galaxy.py` (GPL-3.0-or-later): numpy computes 1.18 million stars and 16,000 film cards per frame, with `velocity` attributes for motion blur and volume shaders written as expressions (`blender/nodexpr.py`); it runs sandboxed in resumable chunks (`tools/bl.sh`, `tools/bl_render.sh`). Over 15.0–15.6 s it dissolves into a WebGL grid that redraws the same cards through the same camera (`tools/export_state.py` exports both from `galaxy.py` without Blender). The body is v3's one-take world at 80 BPM instead of 90, with 26 holds stretched so every read meets the reading-time floor (`js/tmap.js`; `tools/retime.py` moves the score and the SFX by the same map), today's counts, a wall of the 31 style samples, the opening's films drifting beside the path, and a workflow section rebuilt after review (9 reads instead of 17; gates as doors made of what they review). 109 film sources: 28 shaders (`opening/`), 41 cuts of the repo's own films, 40 AI stills (`assets/ai/`, prompts kept). `tools/build_audio.sh` rebuilds the soundtrack byte for byte; `tools/deliver.sh` renders and encodes. +- 150.5 s at 1080p30; the Blender opening, 475 frames, rendered in 1 h 28 min on an M3 Max; five fresh-context review rounds (the last: concept 7, hook 7, desktop read 8, motion 8, variety 6, finish 7, accuracy 7, sync 7; round 5's fixes were not re-reviewed, at the maintainer's request); −14.0 LUFS, 62/62 cues within one frame. `media/final.mp4` is a 2.5 Mbit/s two-pass encode whose x264 zones give the Blender opening 3× the bits (48 MB). Eight watermarked 1080p chapters, and the four sample films and the style reel, play inline in the READMEs (GitHub user-attachments, under 10 MB each). +- README (en/zh): the hero caption and the 04 row describe v5; the Blender section's numbers; `docs/assets/blender-strip.jpg`; galaxy.py in the license note. `video-types/03-product-promo.md`, CLAUDE.md / AGENTS.md (route table), `recipes/seam/gate-as-door.md` follow. +- Lessons into the playbooks: 08 (atmosphere layers draw first and give way to type; type that must be read stays under the glow threshold; a departure key after a hold takes the hold's tangent; 80 BPM on a 30 fps grid), 02 (camera jitter and lunges are checked with numbers: a frame-difference curve for plates, velocity reversals for keyframed cameras). + +**Docs: counts and wording after #41–#50** +- The overview and architecture diagrams, CITATION.cff, `recipes/README.md`, both READMEs, `playbook/02`, `playbook/04` and `styles/README.md` had counts and wording from before #41–#50 (28 or 29 styles, 12 playbook docs, "20+ repos", 7 scores, "3 weakest spots"). `styles/_swatch/lib.js`: a preset that names its foreground `ink` or `text` (pastel-ui) no longer falls back to magenta. + **`bin/vh -h` prints its usage instead of running the command** - Before, `-h` and `--help` reached the command as an argument: `setup -h` ran the whole install, `sync-agents -h` rewrote AGENTS.md, `doctor -h` and `types -h` ran as usual; `sheet`, `check`, `gif`, `hf-init`, `mux` and `beats` took `-h` as a video, project or audio file (`basename`, ffmpeg and libsndfile errors, "no such project", a bash parameter error, or ffprobe's own help), and `qa -h` as a mix (a JSON traceback; `qa --help` printed the docstring but exited 1); `style -h` checked the swatch renderer's npm install and printed `render.sh`'s help, and `style check -h` reached `determinism.sh` as a preset name; `new`, `effort`, `install-skill` and `music` printed a usage but exited 1 (`music --help`: "unknown option"); `tts -h` built the mlx-audio environment before showing its help. - Now the dispatcher answers `bin/vh -h|--help` with that command's lines from the help header at the top of `bin/vh` (with their continuation lines) and exits 0. `tts -h` adds `tts.py`'s full option list, read with the plain python3, so nothing is installed first. Commands with a fuller help of their own keep it: `captions`, `readcheck`, `review`, `storyboard`, `rhythm`, `cover-preview`, `voices`, `mix`, `recipes`, `sfx` and `qa`; `qa` now prints its docstring for `-h` or `--help` anywhere in the arguments (also `qa mix -h`) and exits 0. `style check -h` prints its own line, and the header's `style` entry points to `render.sh -h` for the swatch render's other options. Without `-h`, every command behaves as before. diff --git a/CITATION.cff b/CITATION.cff index b981a1d..4e3cf8d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,7 +12,7 @@ date-released: "2026-09-30" abstract: >- A video-as-code harness that turns coding agents such as Claude Code and Codex into a multi-genre video studio: every frame is a pure function of time, the agent reviews its own rendered frames, and people approve the outline, - the storyboard and the first draft. It ships 8 video types, 28 style presets with rendered swatches, and code-composed + the storyboard and the first draft. It ships 9 video types (one experimental), 31 style presets with rendered swatches, and code-composed music, voice, captions, mixing and audio QA. keywords: - video-as-code diff --git a/CLAUDE.md b/CLAUDE.md index 5e98d1b..f7afa84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ effort 管 agent 自己查得多细,导演模式管人拍板哪些事,两个 |---|---|---|---| | 数学、物理、算法的原理讲解,3b1b 风格 | `video-types/01-math-science-explainer.md` | Manim CE | `showcase/03-math-fourier/`、`cases/explainer-code2video.md`、`cases/opus55-gallery.md` 第 7 节(抽象代数宣传片深读) | | 知识科普短视频,竖屏或横屏(抖音、B站、小红书、视频号、Shorts) | `video-types/02-knowledge-short.md` | HyperFrames | `showcase/02-short-leo-doppler/`、`cases/explainer-interstellar-blackhole.md` | -| 产品宣传、发布片、App 或 SaaS 介绍、功能演示 | `video-types/03-product-promo.md` | HyperFrames | `showcase/00-promo-launch-film/`、`showcase/04-intro-film/`(一镜到底 3D + 代码作曲)、`cases/promo-hyperframes-launches.md` | +| 产品宣传、发布片、App 或 SaaS 介绍、功能演示 | `video-types/03-product-promo.md` | HyperFrames | `showcase/00-promo-launch-film/`、`showcase/04-intro-film/`(Blender 星河开场 + 一镜到底 3D + 代码作曲)、`cases/promo-hyperframes-launches.md` | | 歌词视频、MV、配合音乐的动画 | `video-types/04-lyric-music-video.md` | ClaudeAnimationBase(手绘)或 HyperFrames(排版) | `cases/mv-pdoom.md`、`cases/mv-functional-emotions.md`、`cases/mv-claude-pop.md` | | 数据故事、动画图表、数字可视化 | `video-types/05-data-story.md` | HyperFrames + SVG | — | | 论文讲解、会议视频、学术报告 | `video-types/06-paper-explainer.md` | Manim(机制)或 HyperFrames(结构、结果) | `cases/paper-paper2video.md`、`cases/explainer-code2video.md` | @@ -113,7 +113,7 @@ effort 管 agent 自己查得多细,导演模式管人拍板哪些事,两个 | 3 分钟以上的长片,或靠故事、谜题推进的片子(角色短片、长讲解) | 主类型文档,加上 `playbook/09-narrative.md`(骨架、节拍表、张力曲线、换挡) | 按主类型 | — | | 要发短视频平台:开头钩子、标题、封面 | `playbook/10-hooks-and-packaging.md`,加上主类型文档 | 按主类型 | — | | 要写有篇章、有主题的配乐(MV、介绍片和发布片、45 s 以上靠音乐撑起结构的片子、`studio` 档位,或者人要亲自定主题和 BGM) | `playbook/11-composition.md`;`score.json` 的写法见 `playbook/04-audio.md` | `bin/vh music` | — | -| 3D 场景、着色器短片(Three.js) | 暂无专门的类型文档:以 `03-product-promo.md` 的运动规则为准,加上 `playbook/08-vfx-and-motion-sources.md`(一镜到底、特效预设栈、子帧运动模糊)。要路径追踪的光影(玻璃、皮肤、体积光)、物理模拟或真实景深时,读 `engines/blender.md`(部分验证:风格样片 `tabletop-miniature` 已用 Blender 5.2.2 渲染,项目用的命令还有几条没跑过;渲染时间先渲 3–5 帧校准) | HyperFrames + Three.js 层;重光影的镜头用 Blender | `showcase/04-intro-film/`、`cases/opus55-gallery.md` 的 3D 一节和第 6 节(Austerlitz 长片深读) | +| 3D 场景、着色器短片(Three.js) | 暂无专门的类型文档:以 `03-product-promo.md` 的运动规则为准,加上 `playbook/08-vfx-and-motion-sources.md`(一镜到底、特效预设栈、子帧运动模糊)。要路径追踪的光影(玻璃、皮肤、体积光)、物理模拟或真实景深时,读 `engines/blender.md`(部分验证:风格样片 `tabletop-miniature` 和介绍片 v5 的开场已用 Blender 5.2.2 渲染,项目用的命令还有几条没跑过;渲染时间先渲 3–5 帧校准) | HyperFrames + Three.js 层;重光影的镜头用 Blender | `showcase/04-intro-film/`、`cases/opus55-gallery.md` 的 3D 一节和第 6 节(Austerlitz 长片深读) | | 想要新点子、立意,一句话需求想做得出彩,或者不想千篇一律 | `playbook/12-ideation.md`,再加主类型文档 | 按主类型 | `cases/oneshot-five.md`、`cases/explainer-interstellar-blackhole.md` | | 想要某种风格、参考某部名作,或者不想每支片子都一个口味 | `styles/README.md`,再读选中预设的 `styles//STYLE.md` | 随主引擎 | 每个预设的 `media/swatch.mp4`,总览 `styles/gallery.jpg` | | 想知道某种镜头怎么动(开场、字卡、转场、卡点、收尾),或想要"专业的节奏" | `recipes/README.md`,再读 `recipes/sequences/` 里合适的骨架 | 随主引擎 | `cases/promo-video-shotcraft.md` | diff --git a/README.md b/README.md index a4f5ff6..5ed7c3b 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ Explainers, science shorts, product films, music videos, data stories, paper tal ![License: MIT](https://img.shields.io/badge/license-MIT-black) ![Agents](https://img.shields.io/badge/agents-Claude%20Code%20%7C%20Codex-orange) -![Engines](https://img.shields.io/badge/engines-HyperFrames%20%7C%20Remotion%20%7C%20Manim%20%7C%20p5.brush-blue) +![Engines](https://img.shields.io/badge/engines-HyperFrames%20%7C%20Remotion%20%7C%20Manim%20%7C%20p5.brush%20%7C%20Blender-blue) ![Voice](https://img.shields.io/badge/voice-Qwen3--TTS%20zh%20%7C%20en-purple) ![Styles](https://img.shields.io/badge/styles-31-green) -OpenVideoHarness intro film -▶ The full intro film (81 s, with music, zh/en subtitle tracks) is in showcase/04-intro-film. An agent made it by following this repo, and the soundtrack is code too. +https://github.com/user-attachments/assets/9d305423-0e5b-4276-a347-17a072993b6f + + +▶ Chapter 1 of the intro film (the opening, 19 s, 1080p, with sound). The whole film, 150.5 s, plays in eight 1080p chapters in showcase/04-intro-film; the single file is final.mp4. Its first 15.8 s are path-traced in Blender, driven by code: a galaxy of 1.18 million stars in which every star is a film. One continuous HyperFrames + Three.js take follows. An agent made it by following this repo, and the soundtrack is code too. @@ -32,11 +34,12 @@ OpenVideoHarness is that craft, written as docs and tools an agent can follow: - **A method for each kind of video.** Say "make a science short" or "make a launch film", and it reads the workflow for that type: which engine, which steps, what looks good, what is off limits. - **It stops and asks you three times.** At the outline, the storyboard and the first draft, it waits for your go-ahead. Direction gets settled while changes are still cheap. For a quick try, switch to the `quick` level and it just renders. -- **More than one taste.** 31 styles learned from famous work, each with a real rendered sample, offered to you before anything is built. +- **More than one taste.** 31 styles learned from famous work, each with a real rendered sample. They are references a concept can borrow from, not templates. - **It checks its own work.** An agent can't watch video or hear sound, so it looks at rendered frames, measures the mix, and fixes things until a checklist passes. A reviewer who didn't make the film then scores it. - **Sound included.** Chinese and English voiceover (a local open-source model), bilingual subtitles, music and sound effects written as code, mixing and a final audio check. +- **Code-driven Blender.** When a shot needs real glass, nebulae and volumetric light, depth of field or a million particles, the agent writes Python that has Blender path-trace the plate, in a sandbox, and joins it with the web engine's pictures. The intro film opens this way; see [below](#code-driven-blender-3d-and-film-grade-effects). -It isn't a new rendering engine. It is a layer of "how to do it" on top of engines like HyperFrames, Manim, Remotion and p5.brush. +It isn't a new rendering engine. It is a layer of "how to do it" on top of engines like HyperFrames, Manim, Remotion, p5.brush and Blender. ## Start in 30 seconds @@ -45,12 +48,12 @@ curl -fsSL https://raw.githubusercontent.com/ZLHad/OpenVideoHarness/main/install ``` This one command: -- clones the repo into `~/OpenVideoHarness` (about 330 MB; the sample films are in it); +- clones the repo into `~/OpenVideoHarness` (about 530 MB; the sample films are in it); - installs the dependencies of the built-in hand-drawn engine and the style renderer (about 210 MB); - fetches 30 read-only reference repos (about 195 MB; `--no-refs` skips them); - registers the `open-video-harness` skill for Claude Code and Codex, so saying "make a video" in any folder leads the agent here. -That is about 730 MB on disk. The first render and the sound tools download more the first time you use them: [what gets downloaded](#what-gets-downloaded) lists how much and where. New here? The wiki's [Getting Started](https://github.com/ZLHad/OpenVideoHarness/wiki/Getting-Started) page goes from nothing to a first video. +That is about 935 MB on disk. The first render and the sound tools download more the first time you use them: [what gets downloaded](#what-gets-downloaded) lists how much and where. New here? The wiki's [Getting Started](https://github.com/ZLHad/OpenVideoHarness/wiki/Getting-Started) page goes from nothing to a first video. Then open Claude Code (or Codex) and say what you want: @@ -71,36 +74,66 @@ Only when you say yes does it render the final. ## See what it makes -The five films below were made by an agent **reading only this repo's docs**. Click a preview for its folder (brief, storyboard, review notes, fix-up log, full source), a good start for a similar video; ▶ is the original MP4, with sound. Each request is a short quote from a longer brief, and “Request” links to the whole text. Films 00–03 were finished silent, and their sound was fitted afterwards. +The five films below were made by an agent **reading only this repo's docs**. Every video plays right here (1080p, with sound; the 31-style reel at its native 720p), with the repo's name in a corner; click a title for its folder (brief, storyboard, review notes, fix-up log, full source), a good start for a similar video. Each request is a short quote from a longer brief, and “Request” links to the whole text. Films 00–03 were finished silent, and their sound was fitted afterwards. - + - + - + - + - - + + - +
02 · Vertical science short
▶ the original, with sound
+ +https://github.com/user-attachments/assets/1f5873bd-a83f-4d5e-96fe-08aff0c3a96c + +the original file + 02 · Vertical science short (HyperFrames · 24.8 s · 1080×1920)
A LEO satellite's Doppler shift, readable with the sound off.
Request: “为什么低轨卫星的信号会"变调"?——多普勒频移” (Why does a LEO satellite's signal "change pitch"? The Doppler shift.)
Suggested workflow (standard effort):
bin/vh new short leo-doppler --aspect 9:16
sound: bin/vh tts, music, mix … profile=short, mux
Sound: Chinese voiceover (Gemini TTS, voice Aoede), a light score, the satellite beacon made audible; zh/en soft subtitles, off by default (captions are burned in)
03 · 3b1b-style math explainer
▶ the original, with sound
+ +https://github.com/user-attachments/assets/c2368f76-b157-4a48-9945-8048a515efd4 + +the original file + 03 · 3b1b-style math explainer (Manim CE · 25 s · 1920×1080)
Sine waves stack into a square wave, with the Gibbs overshoot as the payoff.
Request: “Building a square wave from sine waves”
Suggested workflow (standard effort):
bin/vh new math fourier-square-wave --style dark-math
sound: bin/vh tts, music, mix … profile=explainer, mux
Sound: English voiceover (Gemini TTS, voice Iapetus), a piano score kept 13 LU under the voice, each harmonic sounding its own tone
01 · Hand-drawn character short
▶ the original, with sound
+ +https://github.com/user-attachments/assets/da028240-fcff-4e02-95a0-a0abd3238d07 + +the original file + 01 · Hand-drawn character short (p5.brush · 12 s · 1920×1080)
A hand-painted pantomime: no text, no voice, three shots.
Request: “Clawd tries to film a falling autumn leaf with a tiny hand-cranked movie camera; the wind keeps snatching the leaf just as Clawd frames it …”
Suggested workflow (standard effort):
bin/vh new handdrawn leaf --style watercolor-pastoral
sound: bin/vh music, sfx, mix … profile=cartoon, mux
Sound: a cartoon score (pizzicato, celesta, flute, xylophone) plus foley that follows every action
00 · Launch short
▶ the original, with sound
+ +https://github.com/user-attachments/assets/7b5e2d6a-1683-4a01-8cf8-2b27b5bf0c97 + +the original file + 00 · Launch short (HyperFrames · 20 s · 1920×1080)
Built from the repo's own terminal, folders and contact sheets.
Request: “Produce the README hero video — a short launch film for OpenVideoHarness itself …”
Suggested workflow (standard effort):
bin/vh new promo launch-film
sound: bin/vh music, sfx, mix … profile=promo, mux
Sound: a minimal electronic score plus foley on every on-screen action (typing, clicks, whooshes on the cuts), no voice
04 · Intro film
▶ the original, with sound
04 · Intro film (one-take 3D) (HyperFrames + Three.js · 81 s · 1920×1080)
The repo's own product film, one continuous 3D take.
Request, as revised at gate ①: “一镜到底 动画动效 音乐动态字等风格 叙事感 科幻感大片感” (one continuous shot, kinetic type on music, a narrative arc, a sci-fi blockbuster feel)
Suggested workflow (studio effort, --effort studio):
bin/vh new promo intro-film --style monumental-scifi
one-take 3D: playbook/08; bin/vh sfx place, sheet, check
Sound: a code-composed cinematic score (D minor, 90 BPM) and 97 sound effects; zh/en soft subtitle tracks
+ +https://github.com/user-attachments/assets/e4fa59b8-3941-4fdd-ada4-62b4be4f3a82 + +Chapter 5 of 8 (the self-review loop and the final cut); all eight · the original file +04 · Intro film (a galaxy of films, then one take) (Blender + HyperFrames + Three.js · 150.5 s · 1920×1080)
The repo's own product film. The opening is path-traced in Blender: every star is a film, the galaxy collapses into a supernova and is flattened into a sea of films. Then one continuous 3D take through the repo.
Request, at the gates: “玻璃、宇宙、星穹……令人瘫坐眩晕的感觉” (glass, cosmos, a starry sky… the feeling that makes you dizzy), then “或者使用blender?好莱坞大片质感” (or use Blender? Hollywood blockbuster quality)
Suggested workflow (studio effort, --effort studio):
bin/vh new promo intro-film --style monumental-scifi
Blender plates: engines/blender.md; one-take 3D: playbook/08; bin/vh sheet, check
Sound: a code-composed score (a 120 BPM opening, then D minor at 80 BPM) and 124 sound effects, mixed with bin/vh mix … profile=promo
The 31 style samples, all showing the same content
▶ the reel, with sound (42 s)
+ +https://github.com/user-attachments/assets/7fee4f5a-f081-4b7e-82a6-9be8be9f5b7e + +the original file + 31 styles, one reel (5-second samples)
The same content in 31 styles learned from famous work.
Borrow from one:
bin/vh new promo launch-film --style cutout-jazz
bin/vh style list shows all 31; details in styles/README.md
Sound: one score per sample (bin/vh music, mix … profile=swatch)
-The GIFs are compressed, silent previews. Films you make with it are welcome in `showcase/` as a PR. +“The original file” under each video links to the MP4 in the repo. Films you make with it are welcome in `showcase/` as a PR.
Similar work from the community, and our breakdowns of it @@ -146,6 +179,20 @@ bin/vh new promo launch-film --style cutout-jazz # attach a style as a referen We learn the *grammar* of these works; we don't copy them. No original characters, logos or shots, and the prompts never say "in the style of" a person. More in [styles/README.md](styles/README.md). +## Code-driven Blender: 3D and film-grade effects + +Some pictures a web engine can't give you: real glass refraction, nebulae and volumetric light, real depth of field, motion blur on a million particles. For those the agent writes Python that drives Blender. Every frame still depends only on time: numpy computes where each star and each card is at time t, Cycles path-traces the plate, and the plate joins HyperFrames' type and UI in the same film. + +

Moments from the intro film's opening, all rendered in Blender: the Earth inside a glass film card, the pull-back to a galaxy, the supernova, the flattened sea of films

+ +- **The intro film opens this way.** A galaxy of 1.18 million stars where every star is a film: the camera pulls back from one glass card to the whole galaxy, spirals down, the galaxy collapses into a supernova and is flattened into a sea of films. 15.8 s at 1080p (475 frames) took 1 h 28 min on an M3 Max. The grid and the terminal that follow are HyperFrames, and the two dissolve into each other through the same camera. Source in [`showcase/04-intro-film/blender/`](showcase/04-intro-film/blender/). +- **[`tabletop-miniature`](styles/tabletop-miniature/)** in the style library is rendered in Blender too. +- **It runs in a sandbox.** The agent's bpy scripts get no network, can write only to their output folder, and never see the API keys in your environment. +- **Count the time first.** Render 3–5 frames to calibrate and put "frames × seconds per frame × 1.5" in the brief; 4K takes about 4× as long as 1080p. Long renders run in chunks and can resume. +- **License.** Files that import bpy are GPL-3.0-or-later (Blender's requirement for published bpy scripts); everything else stays MIT. + +When Blender is worth it, how to bring it into a film, and what went wrong along the way: [engines/blender.md](engines/blender.md) (partly verified; it marks what has actually been run). + ## How to ask for a video You don't need a long brief. Say **what it's about, who it's for and where it goes**. @@ -188,7 +235,7 @@ Not every film deserves the full treatment. One switch controls how much effort |---|---|---|---| | For | trying a direction, drafts, casual posts | most real videos | launch films, flagship pieces | | Stops to ask you | never; it just renders | at the outline, storyboard and first draft | the same three, plus a rendered sketch of each concept and a full-length animatic | -| Checks its own work | one contact sheet for the whole film | frames and sound, section by section | plus target-screen size, determinism and a full audio check | +| Checks its own work | a contact sheet of the whole film, one at target-screen size, and a strip of the first 2 s | frames and sound, section by section | plus loop seams, determinism and a full audio check | | Outside reviewer | none | 1 round | at least 3 rounds, all 8 scores at 8+ | | A 30 s film takes about | 10–30 min | 1–2 h | 3 h or more | @@ -231,7 +278,7 @@ A few more guides: - **A story with rises and falls, or a film of 3 minutes or more:** [playbook/09](playbook/09-narrative.md) (structures, beat sheets, the tension curve, act breaks). - **Posting to short-video platforms: the opening hook, title and cover:** [playbook/10](playbook/10-hooks-and-packaging.md). - **Music with chapters, a theme you can hum, and real rises and falls:** [playbook/11](playbook/11-composition.md). -- **Research notes, what we measured and what changed because of it:** [docs/research](docs/research/en/README.md) (mix levels, soundtrack distance, render determinism, on-screen reading time, music form). +- **Research notes, what we measured and what changed because of it:** [docs/research](docs/research/en/README.md) (mix levels, soundtrack distance, render determinism, on-screen reading time, music form, a concept-first A/B test). ## Sound @@ -262,14 +309,15 @@ More in [playbook/04-audio.md](playbook/04-audio.md). | Command | What it does | |---|---| | `doctor` / `setup` | Check your setup / install dependencies and fetch references | -| `types` / `new [--style ] [--effort ]` | List the 9 types / start a new project | +| `types` / `new [--style ] [--effort ] [--aspect 9:16] [--watch phone\|feed\|desktop] [--res 1080p\|4k]` | List the 9 types / start a new project (aspect, target screen and resolution can be set up front) | | `effort [quick\|standard\|studio]` | What each effort level does | -| `style list` / `style -
+
+ + - - - - - + + + + + +
t 00.00 · f 0000 · ♩ 01.1
+
+ diff --git a/showcase/04-intro-film/js/arch.js b/showcase/04-intro-film/js/arch.js index 216161d..4098b1e 100644 --- a/showcase/04-intro-film/js/arch.js +++ b/showcase/04-intro-film/js/arch.js @@ -11,10 +11,10 @@ export function buildArch(THREE, X) { // bilingual label (screen-pixel sized by pin(): English 54 px, supporting lines 44–46 px) function label(en, zh, o = {}) { const lines = [{ text: en, size: o.enSize || 54, weight: 600, color: "#EDEDEF", font: o.mono ? FONT_MONO : undefined, track: o.mono ? 0 : -0.01 }]; - if (o.sub) lines.push({ text: o.sub, size: 46, weight: 400, color: "#C8C8D0", font: o.subMono ? FONT_MONO : undefined }); + if (o.sub) lines.push({ text: o.sub, size: 48, weight: 400, color: "#C8C8D0", font: o.subMono ? FONT_MONO : undefined }); if (zh) lines.push({ text: zh, size: o.zhSize || 48, weight: 500, font: FONT_ZH, color: "#D6D6DC" }); if (o.zhSub) lines.push({ text: o.zhSub, size: 48, weight: 400, font: FONT_ZH, color: "#C4C4CC" }); - const m = quiet(dynPlane(lines, { pxPerUnit: 100, lineGap: 0.26 }), 1.16); scene.add(m); return m; + const m = quiet(dynPlane(lines, { pxPerUnit: 100, lineGap: 0.26, decodeDur: 0.1 }), 1.4); scene.add(m); return m; } // a card frame + amber tab that scale with their label (station 3) function card(m) { @@ -22,28 +22,28 @@ export function buildArch(THREE, X) { const fr = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, h)), new THREE.LineBasicMaterial({ color: 0x4a4a52, transparent: true, opacity: 0, depthTest: false, toneMapped: false })); fr.renderOrder = 10; fr.position.z = -0.01; m.add(fr); const body = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: 0x0b0b0e, transparent: true, opacity: 0, depthTest: false, toneMapped: false })); body.renderOrder = 9; body.position.z = -0.02; m.add(body); - const tab = new THREE.Mesh(new THREE.PlaneGeometry(0.1, h * 0.8), barMat(2.0)); tab.material.depthTest = false; tab.material.transparent = true; tab.renderOrder = 11; tab.position.set(-w / 2 - 0.02, 0, 0); // the amber tab sits on the card's edge, clear of the text m.add(tab); + const tab = new THREE.Mesh(new THREE.PlaneGeometry(0.1, h * 0.8), barMat(2.0)); tab.material.depthTest = false; tab.material.transparent = true; tab.renderOrder = 11; tab.position.set(-w / 2 - 0.02, 0, 0); m.add(tab); // the amber tab sits on the card's edge, clear of the text m.userData.card = { fr, tab, body }; return m; } - const TYPES = [["explainers", "讲解"], ["science shorts", "科普"], ["launch films", "发布片"], ["music videos", "MV"], ["data stories", "数据"], ["paper talks", "论文"], ["hand-drawn shorts", "手绘"], ["memes", "梗"]]; + const TYPES = [["explainers", "讲解"], ["science shorts", "科普"], ["product films", "产品片"], ["music videos", "MV"], ["data stories", "数据"], ["paper talks", "论文"], ["hand-drawn", "手绘"], ["meme edits", "梗图快剪"], ["your footage", "素材剪辑"]]; // stations: 1 = request → agent → router; 2 = video-types/ + 8 spokes; 3 = four doc/tool cards; 4 = engines, references, projects/ (the portal) const N = [ { id: "you", p: W(-4.6, 2.5, 0), on: bar(10, 0), labAt: bar(10) + 0.55, off: bar(11) - 0.15, st: 1, L: () => label("You: a one-line request", "你:一句话需求"), pl: { dy: -58, ay: "t" } }, { id: "agent", from: "you", p: W(4.6, 2.5, 0), on: bar(10, 1), off: bar(11) - 0.15, st: 1, L: () => label("Claude Code / Codex", null, { enSize: 58 }), pl: { dy: -58, ay: "t" } }, { id: "router", from: "agent", p: W(0, -0.4, -6), on: bar(10, 2), off: bar(11) - 0.15, st: 1, shape: "diamond", R: 0.95, - L: () => label("CLAUDE.md · AGENTS.md router", null, { mono: false, sub: "pick type · hard rules · 3 human gates", zhSub: "判断类型 · 硬规则 · 三道人工关卡" }), pl: { dy: -78, ay: "t" } }, + L: () => label("CLAUDE.md router", "CLAUDE.md 路由", { enSize: 58 }), pl: { dy: -78, ay: "t" } }, // short: read in the station's hold { id: "vt", from: "router", p: W(-11.2, 1.3, -20), on: bar(11, 0), off: bar(12) - 0.15, st: 2, R: 0.85, - L: () => label("video-types/", "8 类视频工作流", { mono: true, enSize: 58, sub: "8 video workflows" }), pl: { dy: -74, ay: "t" } }, - ...TYPES.map(([en, zh], j) => ({ id: "t" + j, from: "vt", p: W(-4.6, 4.3 - j * 0.86, -20 - 0.4 * Math.sin(j * 0.9)), on: bar(11, 0.5 * j), off: bar(12) - 0.15, st: 2, shape: "dot", - L: () => label(`0${j + 1} ${en} · ${zh}`, null, { enSize: 50 }), pl: { dx: 46, ax: "l" } })), - { id: "pb", from: "router", p: W(3.1, 3.0, -36), on: bar(12, 0), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("playbook/", "通用知识 9 篇", { mono: true, sub: "9 know-how docs" })) }, - { id: "tp", from: "router", p: W(9.9, 3.0, -36), on: bar(12, 0.5), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("templates/", "7 个项目模板", { mono: true, sub: "7 project templates" })) }, - { id: "cs", from: "router", p: W(3.1, -0.1, -36), on: bar(12, 1), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("Cases & showcase", "案例与样板", { sub: "cases · showcase", subMono: true })) }, - { id: "bv", from: "router", p: W(9.9, -0.1, -36), on: bar(12, 1.5), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("bin/vh", "建项目 · 自查工具", { mono: true, sub: "scaffold · QA tools" })) }, - { id: "eng", from: "cs", p: W(-5.2, 0.9, -54), on: bar(13, 0), off: bar(13, 1.95), st: 4, L: () => label("engines/ renderers", "engines/ 渲染引擎", { mono: true, sub: "HyperFrames · Manim · p5.brush" }), pl: { dy: -56, ay: "t" } }, - { id: "ref", from: "bv", p: W(5.2, 0.9, -54), on: bar(13, 1), off: bar(13, 1.95), st: 4, L: () => label("references/repos/", "20+ 参考仓库 · 只读", { mono: true, sub: "20+ repos · read-only" }), pl: { dy: -56, ay: "t" } }, - { id: "prj", from: "bv", p: W(0, 3.0, -62), on: bar(13, 2), off: bar(13, 3.55), st: 4, shape: "portal", R: 2.4, + L: () => label("video-types/", "9 类视频工作流", { mono: true, enSize: 58, sub: "9 video workflows" }), pl: { dy: -74, ay: "t" } }, + ...TYPES.map(([en, zh], j) => ({ id: "t" + j, from: "vt", p: W(-5.6, 4.45 - j * 0.78, -20 - 0.4 * Math.sin(j * 0.9)), on: bar(11, 0.5 * j), off: bar(12) - 0.15, st: 2, shape: "dot", + L: () => label(`0${j + 1} ${en}${j === 8 ? " (exp.)" : ""} · ${zh}`, null, { enSize: 50 }), pl: { dx: 46, ax: "l" } })), + { id: "pb", from: "router", p: W(3.1, 3.0, -36), on: bar(12, 0), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("playbook/", "通用知识 13 篇", { mono: true, sub: "13 know-how docs" })) }, + { id: "tp", from: "router", p: W(9.9, 3.0, -36), on: bar(12, 0.5), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("templates/", "11 个项目模板", { mono: true, sub: "11 project templates" })) }, + { id: "cs", from: "router", p: W(3.1, -0.1, -36), on: bar(12, 2.0), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("styles/", "31 种风格 · 各带样片", { mono: true, sub: "31 styles · real samples" })) }, + { id: "bv", from: "router", p: W(9.9, -0.1, -36), on: bar(12, 2.4), off: bar(13) - 0.1, st: 3, shape: "card", L: () => card(label("bin/vh", "建项目 · 自查工具", { mono: true, sub: "scaffold · QA tools" })) }, + { id: "eng", from: "cs", p: W(-5.2, 0.9, -54), on: bar(13, 0), off: bar(13, 3.1), st: 4, L: () => label("engines/", "p5.brush · Blender", { mono: true, sub: "HyperFrames · Manim", zhSub: "渲染引擎" }), pl: { dx: 30, dy: -50, ax: "l", ay: "t" } }, // below, toward the centre: clear of its feed, the portal and the frame edge + { id: "ref", from: "bv", p: W(5.2, 0.9, -54), on: bar(13, 1.3), off: bar(13, 3.1), st: 4, L: () => label("references/", "30 个参考仓库,只读", { mono: true, sub: "30 repos, read-only" }), pl: { dx: -30, dy: -50, ax: "r", ay: "t" } }, + { id: "prj", from: "bv", p: W(0, 3.0, -62), on: bar(13, 2), off: bar(13, 2), noLab: 1, st: 4, shape: "portal", R: 2.4, L: () => label("projects/date-slug", "大纲 → 分镜 → 初版 → 成片", { mono: true, enSize: 60, sub: "outline → storyboard → draft → final", zhSize: 48 }), pl: { dy: -250, ay: "t" } }, ]; const NID = Object.fromEntries(N.map((n) => [n.id, n])); @@ -94,19 +94,23 @@ export function buildArch(THREE, X) { [bar(12, 0.75), W(6.5, 2.1, -22.4), W(6.5, 1.45, -36), 40, "f"], [bar(12, 3.3), W(6.5, 2.05, -23.6), W(6.5, 1.45, -36), 40, "b"], [bar(13, 0.7), W(0, 2.9, -39.5), W(0, 1.9, -58), 42, "f"], - [bar(13, 2.0), W(0, 3.0, -42.5), W(0, 2.3, -60), 42, "b"], + [bar(13, 2.6), W(0, 3.0, -43.0), W(0, 2.3, -60), 42, "b"], [bar(13, 3.5), W(0, 3.1, -55.5), W(0, 3.0, -70), 42, null], ]; K.forEach(([t, p, l, f, tm]) => key(t, p, l, f, false, tm)); const whips = [[bar(10) + 0.15, 0.5], [bar(11) - 0.1, 0.45], [bar(12) - 0.1, 0.45], [bar(13) - 0.05, 0.5]]; const samples = []; for (let z = 5; z > -70; z -= 2) samples.push(W((hash(z) - 0.5) * 16, 1.5, z)); - const LAB_ALL = N.map((n) => n.lab); + const LAB_ALL = N.map((n) => n.lab); const NODEC = { decode: 0 }; function update(t, ctx) { const on = t > bar(9, 3) && t < bar(14) + 0.4; root.visible = on; if (!on) { N.forEach((n) => { n.lab.visible = false; }); return; } const wide = 0; - tubes.forEach((tb) => { const U = tb.m.material.uniforms; U.uFill.value = eOutCubic(seg(t, tb.t0, tb.t1)); U.uGain.value = 1 + 1.3 * wide; tb.m.visible = !(tb.st === 1 && t > bar(11) - 0.1) && !(tb.id === "prj" && t > bar(13, 3.1)); }); + // the request's thread: once the list is up, its type (02) lights and the other eight become texture; among the + // folders, styles/ (the one a request borrows from) stays lit + const hlT = seg(t, bar(11, 4.5), bar(11, 4.5) + 0.4), hlC = seg(t, bar(12, 2.8), bar(12, 2.8) + 0.4); + const dimOf = (id) => (/^t[0-8]$/.test(id) && id !== "t1" ? 1 - 0.65 * hlT : ["pb", "tp", "bv"].includes(id) ? 1 - 0.55 * hlC : 1); + tubes.forEach((tb) => { const U = tb.m.material.uniforms; U.uFill.value = eOutCubic(seg(t, tb.t0, tb.t1)); U.uGain.value = (1 + 1.3 * wide) * (tb.id === "t1" ? 1 + 1.5 * hlT : dimOf(tb.id)); tb.m.visible = !(tb.st === 1 && t > bar(11) - 0.1) && !(tb.id === "prj" && t > bar(13, 3.1)); }); N.forEach((n) => { const age = t - n.on, powered = age >= 0, flash = powered ? Math.exp(-age / 0.22) : 0; setAmber(n.mat, powered ? 1.15 + 1.8 * flash : 0.25); @@ -118,11 +122,13 @@ export function buildArch(THREE, X) { n.g.visible = !(n.st === 1 && t > bar(11) - 0.1) && !(n.id === "prj" && t > bar(14) - 0.15); if (n.id === "prj") setAmber(n.mat, (powered ? 1.15 + 1.8 * flash : 0.25) * clamp((bar(14) - 0.1 - t) / 0.35)); const la = t - (n.labAt || n.on); // the request label waits for the whip into station 1 to land - const L = n.lab, near = pin(L, n.p, n.shape === "card" ? { dx: 0, dy: 0 } : n.pl); - const o = (la >= 0 ? eOutCubic(seg(la, 0, 0.18)) : 0) * (1 - seg(t, n.off - 0.25, n.off)) * near; - L.material.opacity = o; L.visible = o > 0.002; L.userData.quiet.material.opacity = (n.shape === "card" ? 0.85 : 0.6) * o; + const big = n.id === "t1" ? 1 + 0.15 * hlT : n.id === "cs" ? 1 + 0.08 * hlC : 1; + const L = n.lab, near = pin(L, n.p, n.shape === "card" ? { dx: 0, dy: 0, k: big } : { ...n.pl, k: big }); + const o = n.noLab ? 0 : (la >= 0 ? eOutCubic(seg(la, 0, 0.22)) : 0) * (1 - seg(t, n.off - 0.25, n.off)) * near * dimOf(n.id); + if (n.id === "t1" && hlT > 0) setAmber(n.mat, 1.15 + 2.0 * hlT); + L.material.opacity = o; L.visible = o > 0.002; L.userData.quiet.material.opacity = (n.shape === "card" ? 0.94 : 0.92) * o; if (L.visible) { - L.userData.dyn.draw(la, P); L.material.uniforms.uSweep.value = P.sweep ? lerp(-0.25, 1.25, seg(age, 0.2, 0.7)) : -1; L.material.uniforms.uFrame.value = ctx.frame; + L.userData.dyn.draw(la, NODEC); L.material.uniforms.uSweep.value = -1; L.material.uniforms.uFrame.value = ctx.frame; L.material.uniforms.uGlitch.value = 0; } if (L.userData.card) { L.userData.card.fr.material.opacity = 0.9 * o; L.userData.card.tab.material.opacity = o; L.userData.card.body.material.opacity = 0.9 * o; } @@ -130,7 +136,8 @@ export function buildArch(THREE, X) { } return { update, root, whips, samples, labels: LAB_ALL, heroes: [], - punches: [bar(13, 2)], shakes: [[bar(10, 2), 0.012], [bar(11), 0.008], [bar(13, 2), 0.02]], + punches: [], shakes: // no shock ring at the portal: it swept across the engines/references labels (round 3) + [[bar(10, 2), 0.012], [bar(11), 0.008], [bar(13, 2), 0.02]], light: (t) => (t < bar(11) ? NID.router.p : t < bar(12) ? NID.vt.p : t < bar(13) ? W(6.5, 1.4, -36) : NID.prj.p), rays: (t) => { let r = 0.12; for (const n of [NID.router, NID.vt, NID.prj]) { const u = t - n.on; if (u >= 0 && u < 2) r += 0.35 * Math.exp(-u / 0.5); } return r; }, }; diff --git a/showcase/04-intro-film/js/features.js b/showcase/04-intro-film/js/features.js index 5a05bec..af2811d 100644 --- a/showcase/04-intro-film/js/features.js +++ b/showcase/04-intro-film/js/features.js @@ -26,47 +26,30 @@ export function buildFeatures(THREE, X) { const add = (panel, m, x, y) => { m.position.set(x, y, 0.02); m.userData.base = m.position.clone(); panel.add(m); panel.userData.fades.push(m); return m; }; const lineMat = (c = 0x5a5a63) => new THREE.LineBasicMaterial({ color: c, transparent: true, opacity: 0, toneMapped: false, fog: false }); - // =============== panel 1: Taste written down as numbers =============== - const PW = 8.8, PH = 3.9; // panel sits in the lower 60% of frame; the title above it is a real main read (>= 96 px) + // =============== panel 1: 31 styles (styles/*/media/poster.jpg as one wall; tiles light on 32nds) =============== + const PW = 8.8, PH = 3.9; const p1 = shell(PW, PH); p1.position.copy(W(-6.2, 2.0, 0)); p1.rotation.y = 0.32; scene.add(p1); - // easing curve (easeOutExpo), drawn progressively with a dot riding it - const gx0 = -4.05, gx1 = -1.75, gy0 = -0.5, gy1 = 1.45; - const axes = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(gx0, gy0, 0.02), new THREE.Vector3(gx1, gy0, 0.02), new THREE.Vector3(gx0, gy0, 0.02), new THREE.Vector3(gx0, gy1, 0.02)]), lineMat(0x6a6a72)); p1.add(axes); p1.userData.fades.push(axes); - const NC = 96, cpts = []; for (let i = 0; i <= NC; i++) { const u = i / NC, v = u >= 1 ? 1 : 1 - Math.pow(2, -10 * u); cpts.push(new THREE.Vector3(lerp(gx0, gx1, u), lerp(gy0, gy1, v), 0.03)); } - const curve = new THREE.Line(new THREE.BufferGeometry().setFromPoints(cpts), new THREE.LineBasicMaterial({ color: AMBC, transparent: true, opacity: 0, toneMapped: false, fog: false })); p1.add(curve); - const cdot = new THREE.Mesh(new THREE.CircleGeometry(0.07, 20), barMat(2.4)); cdot.material.transparent = true; p1.add(cdot); - add(p1, txt([{ text: "Easing curves", size: 48, weight: 600, color: FG }, { text: "缓动曲线", size: 46, weight: 500, font: FONT_ZH, color: ZHC }]), (gx0 + gx1) / 2, -1.15); - // vertical safe box: 9:16 frame with the 810 px-wide safe area inside - const vw = 1.0, vh = vw * 16 / 9, vx = -0.3, vy = 0.5; - const vframe = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(vw, vh)), lineMat(0x7a7a82)); vframe.position.set(vx, vy, 0.02); p1.add(vframe); p1.userData.fades.push(vframe); - const sw = vw * 810 / 1080, sh = vh * 0.82; - const sbox = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(sw, sh)), new THREE.LineBasicMaterial({ color: AMBC, transparent: true, opacity: 0, toneMapped: false, fog: false })); sbox.position.set(vx, vy, 0.03); p1.add(sbox); - const sLab = add(p1, txt([{ text: "810px", size: 46, weight: 500, font: FONT_MONO, color: AMB }]), vx, vy + sh / 2 - 0.22); - add(p1, txt([{ text: "vertical-video", size: 48, weight: 600, color: FG }, { text: "safe zones", size: 48, weight: 600, color: FG }, { text: "竖屏安全区", size: 46, weight: 500, font: FONT_ZH, color: ZHC }]), vx, -1.28); - // 20-item checklist: boxes tick on 32nds - const boxes = []; const bx0 = 1.98, by0 = 1.35, bs = 0.32, bg = 0.1; - for (let i = 0; i < 20; i++) { - const c = i % 4, r = Math.floor(i / 4), x = bx0 + c * (bs + bg), y = by0 - r * (bs + bg); - const fr = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(bs, bs)), lineMat(0x6a6a72)); fr.position.set(x, y, 0.02); p1.add(fr); p1.userData.fades.push(fr); - const fill = new THREE.Mesh(new THREE.PlaneGeometry(bs * 0.62, bs * 0.62), new THREE.MeshBasicMaterial({ color: new THREE.Color(0.85, 0.6, 0.14), transparent: true, opacity: 0, toneMapped: false, fog: false })); fill.position.set(x, y, 0.03); p1.add(fill); - boxes.push({ fill, tOn: bar(19, 2) + i * S16 * 0.5 }); + const wallTex = new THREE.TextureLoader().load("assets/tex/styles-wall.jpg"); wallTex.colorSpace = THREE.SRGBColorSpace; wallTex.anisotropy = 8; + const WC = 8, WR = 4, ww = 0.96, wh = ww * 9 / 16, wg = 0.06, wx0 = -((WC - 1) / 2) * (ww + wg), wy0 = 1.28; + const wall = []; + for (let i = 0; i < WC * WR; i++) { + const c = i % WC, r = Math.floor(i / WC), g = new THREE.PlaneGeometry(ww, wh), uv = g.attributes.uv; + for (let k = 0; k < uv.count; k++) uv.setXY(k, (c + uv.getX(k)) / WC, 1 - (r + 1 - uv.getY(k)) / WR); + const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ map: wallTex, transparent: true, opacity: 0, toneMapped: false, fog: false })); + m.position.set(wx0 + c * (ww + wg), wy0 - r * (wh + wg), 0.02); p1.add(m); + wall.push({ m, tOn: bar(19, 0.5) + Math.floor(hash(i * 3.3 + 1) * 24) * S16 * 0.5 }); } - add(p1, txt([{ text: "20-item self-review", size: 48, weight: 600, color: FG }, { text: "checklist", size: 48, weight: 600, color: FG }, { text: "20 条的自查清单", size: 46, weight: 500, font: FONT_ZH, color: ZHC }]), bx0 + 1.5 * (bs + bg), -1.28); - const H1 = hero("Taste written down\nas numbers.", "把品味写成数字", { enSize: 150 }); + add(p1, txt([{ text: "each with a real sample", size: 50, weight: 600, color: FG }, { text: "每种都有真渲的样片", size: 48, weight: 500, font: FONT_ZH, color: ZHC }]), 0, -1.5); // the title above already says 31 + const H1 = hero("31 styles, not one taste.", "31 种风格,不止一种口味。", { enSize: 150 }); // =============== panel 2: Sound, end to end =============== const p2 = shell(PW, PH); p2.position.copy(W(0, 2.0, -17)); scene.add(p2); const NB = 44, bars2 = []; for (let i = 0; i < NB; i++) { const m = new THREE.Mesh(new THREE.PlaneGeometry(0.06, 1), new THREE.MeshBasicMaterial({ color: AMBC, transparent: true, opacity: 0, toneMapped: false, fog: false })); m.position.set(-4.15 + i * 0.07, 0.2, 0.02); p2.add(m); bars2.push(m); } - const wLab = add(p2, txt([{ text: "this film's own score", size: 46, weight: 400, font: FONT_MONO, color: DIMC }]), -2.6, -1.35); - const ROWS = [ - ["Chinese and English voiceover", "中英双语配音", "Qwen3-TTS"], - ["bilingual captions", "双语字幕", "bin/vh captions"], - ["code-composed music", "代码作曲", "bin/vh music"], - ["sound effects and the final mix", "音效、混音", "15 SFX · −14 LUFS"], - ]; - const rows2 = ROWS.map(([en, zh, tag], i) => { - const m = add(p2, txt([{ text: en, size: 48, weight: 600, color: FG }, { text: `${zh} · ${tag}`, size: 46, weight: 500, font: FONT_ZH, color: ZHC }], { align: "left" }), 0, 1.35 - i * 0.92); + const wLab = add(p2, txt([{ text: "this film's own score", size: 46, weight: 400, font: FONT_MONO, color: DIMC }]), -2.62, 1.55); // above the bars: clear of the sound list + const ROWS = [["Voiceover", "配音"], ["Captions", "字幕"], ["Score · 102 instruments", "作曲"], ["Mix · −14 LUFS", "混音"]]; // one line each (round 4: 25 words was too many to read) + const rows2 = ROWS.map(([en, zh], i) => { + const m = add(p2, txt([{ text: `${en} · ${zh}`, size: 54, weight: 600, color: FG }], { align: "left" }), 0, 1.35 - i * 0.92); m.position.x = -0.75 + m.geometry.parameters.width / 2; m.userData.base = m.position.clone(); const dot = new THREE.Mesh(new THREE.CircleGeometry(0.06, 16), barMat(2.4)); dot.material.transparent = true; dot.position.set(-0.85, 1.35 - i * 0.92 + 0.15, 0.03); p2.add(dot); return { m, dot, tOn: bar(20, i) }; @@ -127,13 +110,13 @@ export function buildFeatures(THREE, X) { fragmentShader: `uniform float uAmt; varying float vA; void main(){ vec2 d = gl_PointCoord - 0.5; float r = length(d); float a = smoothstep(0.5, 0.0, r); gl_FragColor = vec4(vec3(1.0, 0.78, 0.42) * a * vA * uAmt, a * vA * uAmt); }`, })); stars.frustumCulled = false; scene.add(stars); - const CASES = Array.from({ length: 11 }, (_, j) => { const i = Math.floor(hash(j * 13.7 + 2.0) * NS); const s = sprite(glowTex, 0.5); s.position.set(sp[3 * i], sp[3 * i + 1], sp[3 * i + 2]); return { s, tOn: bar(22) + (j + 1) * S16 }; }); - // chart rings in the disc plane (a star *map*), and the 11 case studies as one constellation, drawn star to star as each lights + const CASES = Array.from({ length: 13 }, (_, j) => { const i = Math.floor(hash(j * 13.7 + 2.0) * NS); const s = sprite(glowTex, 0.5); s.position.set(sp[3 * i], sp[3 * i + 1], sp[3 * i + 2]); return { s, tOn: bar(22) + (j + 1) * S16 }; }); + // chart rings in the disc plane (a star *map*), and the 13 case studies as one constellation, drawn star to star as each lights const rings8 = [5, 10, 15].map((R) => { const pts = []; for (let k = 0; k <= 128; k++) { const a = (k / 128) * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * R, 0, Math.sin(a) * R).applyAxisAngle(new THREE.Vector3(1, 0, 0), 0.62).add(CO)); } const m = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x6a5a3a, transparent: true, opacity: 0, toneMapped: false, fog: false })); scene.add(m); return m; }); const cPts = CASES.map((c) => c.s.position.clone()).sort((a, b) => Math.atan2(a.z - CO.z, a.x - CO.x) - Math.atan2(b.z - CO.z, b.x - CO.x)); const constel = new THREE.Line(new THREE.BufferGeometry().setFromPoints(cPts), new THREE.LineBasicMaterial({ color: new THREE.Color(1.0, 0.72, 0.2), transparent: true, opacity: 0, toneMapped: false, fog: false })); scene.add(constel); - const H4 = quiet(dynPlane([{ text: "11 case studies", size: 170, weight: 600, color: FG, track: -0.025 }, { text: "+ curated picks from 389 community videos", size: 96, weight: 500, color: "#E4E4E8" }, { text: "11 个案例拆解 + 389 支社区作品精选", size: 84, weight: 500, font: FONT_ZH, color: "#D6D6DC" }], { lineGap: 0.3 })); + const H4 = quiet(dynPlane([{ text: "13 case studies", size: 170, weight: 600, color: FG, track: -0.025 }, { text: "+ curated picks from 389 community videos", size: 96, weight: 500, color: "#E4E4E8" }, { text: "13 个案例拆解 + 389 支社区作品精选", size: 84, weight: 500, font: FONT_ZH, color: "#D6D6DC" }], { lineGap: 0.3 })); // ---- camera ---- const camFor = (panel, d, dy = 0.55) => { const n = new THREE.Vector3(0, 0, 1).applyAxisAngle(new THREE.Vector3(0, 1, 0), panel.rotation.y); return [panel.position.clone().addScaledVector(n, d).add(new THREE.Vector3(0, dy, 0)), panel.position.clone().add(new THREE.Vector3(0, dy - 0.05, 0))]; }; @@ -155,37 +138,32 @@ export function buildFeatures(THREE, X) { U.fades.forEach((m) => { m.material.opacity = o; }); pn.visible = o > 0.002; }); - // panel 1 + // panel 1: the style wall const o1 = win(t, bar(19) - 0.3, bar(20) + 0.25, 0.35, 0.3); - const cu = eOutCubic(seg(t, bar(19, 0.1), bar(19, 1.0))); - curve.geometry.setDrawRange(0, Math.max(2, Math.round(cu * (NC + 1)))); curve.material.opacity = o1; - const ph = ((t - bar(19)) / (BEAT * 2)) % 1, pu = t < bar(19) ? 0 : ph, pv = 1 - Math.pow(2, -10 * pu); - cdot.position.set(lerp(gx0, gx1, pu), lerp(gy0, gy1, pv), 0.04); cdot.material.opacity = o1 * (t > bar(19, 1.0) ? 1 : 0); - const sOn = t >= bar(19, 1); sbox.material.opacity = o1 * (sOn ? 0.75 + 0.25 * Math.exp(-(t - bar(19, 1)) / 0.3) : 0); sLab.material.opacity = o1 * (sOn ? 1 : 0); - boxes.forEach((b) => { b.fill.material.opacity = o1 * (t >= b.tOn ? 0.9 : 0); }); + wall.forEach((w) => { const a2 = t - w.tOn; w.m.material.opacity = o1 * (a2 < 0 ? 0.25 : 0.95); w.m.material.color.setScalar(a2 < 0 ? 0.3 : 0.78 + 0.12 * Math.exp(-a2 / 0.2)); }); // under the bloom threshold: the posters must read // panel 2: bars follow the film's own music (assets/wave.json RMS), centred on the playhead const o2 = win(t, bar(20) - 0.3, bar(21) + 0.25, 0.35, 0.3); bars2.forEach((m, i) => { - let a = 0.2; if (WAVE && WAVE.rms) { const N = WAVE.rms.length, tt = t + (i - NB / 2) * 0.05, j = clamp(Math.round((tt / DUR) * (N - 1)), 0, N - 1); a = WAVE.rms[j]; } - const h = 0.08 + 2.2 * a * (0.75 + 0.25 * Math.sin(i * 1.7 + t * 9.0)); m.scale.y = h; m.material.opacity = o2 * (i === NB / 2 ? 1 : 0.75); + let a = 0.2; if (WAVE && WAVE.rms) { const N = WAVE.rms.length, tt = ctx.ta - (window.__T0 || 0) + (i - NB / 2) * 0.05, j = clamp(Math.round((tt / (WAVE.duration || DUR)) * (N - 1)), 0, N - 1); a = WAVE.rms[j]; } // wave.json is in film time + const h = 0.08 + 2.2 * a * (0.75 + 0.25 * Math.sin(i * 1.7 + ctx.ta * 9.0)); m.scale.y = h; m.material.opacity = o2 * (i === NB / 2 ? 1 : 0.75); }); const newest = rows2.filter((r) => t >= r.tOn).length - 1; - rows2.forEach((r, i) => { const k = t < r.tOn ? 0.22 : i === newest ? 1 : 0.55; r.m.material.opacity = o2 * k; r.dot.material.opacity = o2 * (t >= r.tOn ? 1 : 0); setAmber(r.dot.material, 1.2 + 2.2 * (t >= r.tOn ? Math.exp(-(t - r.tOn) / 0.2) : 0)); }); - wLab.material.opacity = o2; + rows2.forEach((r, i) => { const k = t < r.tOn ? 0.22 : i === newest ? 1 : 0.8; r.m.material.opacity = o2 * k; r.dot.material.opacity = o2 * (t >= r.tOn ? 1 : 0); setAmber(r.dot.material, 1.2 + 2.2 * (t >= r.tOn ? Math.exp(-(t - r.tOn) / 0.2) : 0)); }); + wLab.material.opacity = 0; wLab.visible = false; // dropped in round 4 (one less thing to read) // panel 3 const o3 = win(t, bar(21) - 0.3, bar(22) + 0.25, 0.35, 0.3); drawTerm(t); term.material.opacity = o3; tbar.material.opacity = o3; dots3.forEach((d) => { d.material.opacity = o3; }); // heroes slamView(H1, t, bar(19) + 0.08, bar(20) - 0.12, 11, 0.44, 0, 0.55); - slamView(H2, t, bar(20) + 0.05, bar(21) - 0.12, 11, 0.52, 0, 0.68); + slamView(H2, t, bar(20) + 0.05, bar(20, 3.25), 11, 0.52, 0, 0.68); // leaves inside the hold that follows the fourth row: the rows hold alone slamView(H3, t, bar(21) + 0.05, bar(22) - 0.12, 11, 0.42, 0, 0.68); // stars - stars.material.uniforms.uTime.value = t; stars.material.uniforms.uAmt.value = win(t, bar(22) - 0.35, bar(23) + 0.3, 0.2, 0.4) * (1 + 0.8 * Math.exp(-Math.max(0, t - bar(22)) / 0.25)); + stars.material.uniforms.uTime.value = ctx.ta; stars.material.uniforms.uAmt.value = win(t, bar(22) - 0.35, bar(23) + 0.3, 0.2, 0.4) * (1 + 0.35 * Math.exp(-Math.max(0, t - bar(22)) / 0.25)) * (1 - 0.45 * H4.material.opacity); // the map dims under its read stars.material.uniforms.uConv.value = eOutExpo(seg(t, bar(22) - 0.35, bar(22) + 0.05)); stars.material.uniforms.uPre.value = seg(t, bar(22) - 0.35, bar(22) - 0.15); CASES.forEach((c) => { const a = t - c.tOn; c.s.visible = a >= 0 && t < bar(23) + 0.3; c.s.material.opacity = c.s.visible ? (0.7 + 0.8 * Math.exp(-a / 0.2)) * win(t, c.tOn, bar(23) + 0.3, 0.02, 0.4) : 0; c.s.scale.setScalar(0.45 + 0.6 * Math.exp(-Math.max(0, a) / 0.25)); }); rings8.forEach((m, i) => { m.material.opacity = 0.5 * win(t, bar(22) + i * S16, bar(23) + 0.3, 0.3, 0.4); m.visible = m.material.opacity > 0.002; }); const nLit = CASES.filter((c) => t >= c.tOn).length; constel.geometry.setDrawRange(0, nLit); constel.material.opacity = 0.55 * win(t, bar(22), bar(23) + 0.3, 0.1, 0.4); constel.visible = nLit > 1; - slamView(H4, t, bar(22) + 0.1, bar(23) - 0.1, 11, 0.78, 0, 0.0); + slamView(H4, t, bar(22) + 0.1, bar(23) - 0.1, 11, 0.78, 0, 0.0); if (H4.userData.quiet) H4.userData.quiet.material.opacity = 0.9 * H4.material.opacity; } return { update, whips, samples, heroes: [H1, H2, H3, H4], labels: [], diff --git a/showcase/04-intro-film/js/fx.js b/showcase/04-intro-film/js/fx.js index d1c64a8..8f585a3 100644 --- a/showcase/04-intro-film/js/fx.js +++ b/showcase/04-intro-film/js/fx.js @@ -5,7 +5,7 @@ export const PRESETS = { v2: { grade: 0, contrast: 1.0, bloom: 0.62, streak: 0.045, rays: 0, whip: 0, mblur: 0, shake: 1, hand: 0, fovPunch: 0, pulse: 0, ca: 0, ring: 0, sparks: 0, streams: 0, warp: 0, decode: 0, sweep: 0, glitchExit: 0, rgb: 0, scan: 0, hud: 0, glitchFrame: 0, flash: 0 }, A: { grade: 1, contrast: 1.12, bloom: 0.95, streak: 0.11, rays: 0.75, whip: 0.55, mblur: 0.25, shake: 1.0, hand: 0.5, fovPunch: 0.5, pulse: 0.5, ca: 0.3, ring: 0.4, sparks: 0.5, streams: 0.6, warp: 0, decode: 0, sweep: 1, glitchExit: 0, rgb: 0, scan: 0, hud: 0, glitchFrame: 0, flash: 0.5 }, - B: { grade: 1, contrast: 1.2, bloom: 1.0, streak: 0.17, rays: 1.0, whip: 1.0, mblur: 1.0, shake: 1.7, hand: 1.0, fovPunch: 1.0, pulse: 1.0, ca: 1.0, ring: 1.0, sparks: 1.0, streams: 1.0, warp: 1.0, decode: 1, sweep: 1, glitchExit: 0.35, rgb: 0, scan: 0, hud: 0.5, glitchFrame: 0, flash: 1 }, + B: { grade: 1, contrast: 1.2, bloom: 1.0, streak: 0.17, rays: 1.0, whip: 1.0, mblur: 1.0, shake: 1.7, hand: 1.0, fovPunch: 1.0, pulse: 1.0, ca: 0.45, ring: 1.0, sparks: 1.0, streams: 1.0, warp: 0.4, decode: 1, sweep: 1, glitchExit: 0.35, rgb: 0, scan: 0, hud: 0.5, glitchFrame: 0, flash: 1 }, C: { grade: 1, contrast: 1.22, bloom: 1.0, streak: 0.17, rays: 1.0, whip: 1.0, mblur: 1.0, shake: 1.7, hand: 1.0, fovPunch: 1.0, pulse: 1.0, ca: 1.0, ring: 1.0, sparks: 1.0, streams: 1.0, warp: 1.0, decode: 1, sweep: 1, glitchExit: 1, rgb: 1, scan: 1, hud: 1, glitchFrame: 1, flash: 1 }, }; export function readPreset() { diff --git a/showcase/04-intro-film/js/grid.js b/showcase/04-intro-film/js/grid.js new file mode 100644 index 0000000..9233eb4 --- /dev/null +++ b/showcase/04-intro-film/js/grid.js @@ -0,0 +1,114 @@ +// The opening from 15 s on, in WebGL: the film grid under the stars, the title, the rows collapsing into the terminal's +// lines. It takes over from the Blender plate with the same cards, films and camera (exported from blender/galaxy.py by +// tools/export_state.py), so the hand-over is a dissolve between two renders of one scene. +// Blender is Z-up with a horizontal field of view; three.js is Y-up with a vertical one: (x, y, z) → (x, z, −y). +import * as THREE from "three"; + +const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); +const seg = (t, a, b) => clamp((t - a) / (b - a)); +const smooth = (u) => { u = clamp(u); return u * u * (3 - 2 * u); }; +const lerp = (a, b, u) => a + (b - a) * u; +const B2T = (x, y, z) => new THREE.Vector3(x, z, -y); + +const VERT = /* glsl */ ` +attribute vec4 aFilm; // kind (0 procedural, 1 footage, 2 AI), row, phase, line width factor (for the collapse) +varying vec2 vUv; varying vec4 vFilm; +void main(){ vUv = uv; vFilm = aFilm; gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0); }`; +const FRAG = /* glsl */ ` +uniform sampler2D tProc, tReal, tAI; uniform float uRows0, uRows1, uRows2, uT, uVis, uAmber, uGray; +varying vec2 vUv; varying vec4 vFilm; +vec3 tap(float kind, float row, float fr, vec2 uv){ + float rows = kind < 0.5 ? uRows0 : (kind < 1.5 ? uRows1 : uRows2); + vec2 a = vec2((fr + 0.5 + (uv.x - 0.5) * 0.992) / 16.0, 1.0 - (row + 0.5 - (uv.y - 0.5) * 0.99) / rows); + if (kind < 0.5) return pow(texture2D(tProc, a).rgb, vec3(2.2)); // stored gamma-encoded, no colour tag (as in Blender) + if (kind < 1.5) return texture2D(tReal, a).rgb; + return texture2D(tAI, a).rgb; +} +float frameOf(float k, float kind){ float m = mod(k, 30.0); return kind > 0.5 ? (m < 16.0 ? m : 30.0 - m) : mod(k, 16.0); } +void main(){ + float fpos = uT * 8.0 + vFilm.z; float k0 = floor(fpos); float fm = fpos - k0; + vec3 c = mix(tap(vFilm.x, vFilm.y, frameOf(k0, vFilm.x), vUv), tap(vFilm.x, vFilm.y, frameOf(k0 + 1.0, vFilm.x), vUv), fm); + float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c = mix(c, vec3(l), uGray); + c = mix(c, vec3(l) * vec3(1.0, 0.62, 0.12), uAmber); + gl_FragColor = vec4(c * 2.2 * uVis, 1.0); + #include + #include +}`; + +export async function makeGrid(canvas) { + const S = await (await fetch("assets/state.json")).json(); + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); + renderer.setPixelRatio(1); renderer.setSize(1920, 1080, false); + renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.AgXToneMapping; renderer.toneMappingExposure = 1.0; + const scene = new THREE.Scene(); scene.background = new THREE.Color(0, 0, 0); + const camera = new THREE.PerspectiveCamera(40, 1920 / 1080, 0.3, 4000); + const load = (u, srgb) => new Promise((res) => new THREE.TextureLoader().load(u, (t) => { + t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; t.anisotropy = renderer.capabilities.getMaxAnisotropy(); + t.generateMipmaps = true; t.minFilter = THREE.LinearMipmapLinearFilter; res(t); })); + const [tProc, tReal, tAI] = await Promise.all([load("assets/films-proc.png", false), load("assets/films.jpg", true), load("assets/films-ai.jpg", true)]); + + // cards: one instanced quad each, lying on the Blender plane (z = 0.003) at its grid slot + const C = S.cards, N = C.sx.length, CW = S.grid.CW, w = CW * 0.86, h = w * 9 / 16; + const geo = new THREE.PlaneGeometry(1, 1); geo.rotateX(-Math.PI / 2); // a three.js XZ quad = a Blender XY quad + const aFilm = new Float32Array(N * 4); + for (let i = 0; i < N; i++) aFilm.set([C.kind[i], C.row[i], C.ph[i], 0.45 + 0.55 * 0.5 * (1 + Math.sin(C.sy[i] * 7.1))], i * 4); + geo.setAttribute("aFilm", new THREE.InstancedBufferAttribute(aFilm, 4)); + const mat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, + uniforms: { tProc: { value: tProc }, tReal: { value: tReal }, tAI: { value: tAI }, uRows0: { value: 28 }, uRows1: { value: 41 }, uRows2: { value: S.nai }, + uT: { value: 0 }, uVis: { value: 1 }, uAmber: { value: 0.14 }, uGray: { value: 0.78 } } }); + const cards = new THREE.InstancedMesh(geo, mat, N); cards.frustumCulled = false; scene.add(cards); + const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(), p = new THREE.Vector3(); + function placeCards(col) { + for (let i = 0; i < N; i++) { + const wf = lerp(1, aFilm[i * 4 + 3], col); + p.copy(B2T(C.sx[i], C.sy[i], 0.003)); sc.set(w * wf, 1, h * lerp(1, 0.1, col)); // Blender width → x, height → −y + m4.compose(p, q, sc); cards.setMatrixAt(i, m4); + } + cards.instanceMatrix.needsUpdate = true; + } + // grid hairlines (amber), as in Blender: every row and column boundary + const GC = S.grid.GC, GR = Math.ceil(N / GC), xs = [], ys = []; + for (let k = 0; k <= GC; k++) xs.push((k - GC / 2) * CW); + for (let r = 0; r <= GR; r++) ys.push(-24.0 - S.grid.CH / 2 + r * S.grid.CH); + // hairlines as thin quads on the plane (0.035 wide, as in Blender), so they keep their width with distance + const lp = [], lw = 0.035, quad = (a, b, c, d) => lp.push(...a, ...b, ...c, ...a, ...c, ...d); + for (const x of xs) quad(B2T(x - lw, ys[0], 0.006).toArray(), B2T(x + lw, ys[0], 0.006).toArray(), B2T(x + lw, ys[ys.length - 1], 0.006).toArray(), B2T(x - lw, ys[ys.length - 1], 0.006).toArray()); + for (const y of ys) quad(B2T(xs[0], y - lw, 0.006).toArray(), B2T(xs[xs.length - 1], y - lw, 0.006).toArray(), B2T(xs[xs.length - 1], y + lw, 0.006).toArray(), B2T(xs[0], y + lw, 0.006).toArray()); + const lg = new THREE.BufferGeometry(); lg.setAttribute("position", new THREE.Float32BufferAttribute(lp, 3)); + const lineMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(1.0, 0.62, 0.12), transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false }); + const lines = new THREE.Mesh(lg, lineMat); lines.frustumCulled = false; lines.renderOrder = 2; scene.add(lines); + // the far sky (the upper half of Blender's: same seeds) + const sky = S.sky, sp = new Float32Array(sky.length * 3), scol = new Float32Array(sky.length * 3); + sky.forEach((s, i) => { const v = B2T(s[0], s[1], s[2]).multiplyScalar(1400); sp.set(v.toArray(), i * 3); const b = Math.min(1.8, 0.25 + s[4] * s[3] * s[3] * 0.3); scol.set([b * 0.85, b * 0.9, b], i * 3); }); + const sg = new THREE.BufferGeometry(); sg.setAttribute("position", new THREE.BufferAttribute(sp, 3)); sg.setAttribute("color", new THREE.BufferAttribute(scol, 3)); + const dome = new THREE.Mesh(new THREE.SphereGeometry(1800, 48, 24), new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, + vertexShader: "varying vec3 vD; void main(){ vD = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }", + fragmentShader: `varying vec3 vD; void main(){ float h = vD.y; vec3 c = mix(vec3(0.0016, 0.0015, 0.0019), vec3(0.0003, 0.0004, 0.0008), smoothstep(0.0, 0.35, h)); + c += vec3(0.16, 0.11, 0.05) * exp(-abs(h) * 24.0) * 0.09; gl_FragColor = vec4(c, 1.0); // linear: matches the Blender sky (sRGB ~1 above, ~33 at the horizon) + #include + #include + }` })); + dome.frustumCulled = false; dome.renderOrder = -2; scene.add(dome); + const stars = new THREE.Points(sg, new THREE.PointsMaterial({ size: 2.6, sizeAttenuation: false, vertexColors: true, transparent: true, depthWrite: false })); + stars.frustumCulled = false; stars.renderOrder = -1; scene.add(stars); + + const up = new THREE.Vector3(), fw = new THREE.Vector3(), rt = new THREE.Vector3(), mm = new THREE.Matrix4(); + return (t, ta = t) => { // t: the story's time; ta: the film's own time (the cards' films keep playing in a stretched hold) + const n = clamp(Math.round((t - S.cam_t0) * 30), 0, S.cam.length - 1), c = S.cam[n]; + camera.position.copy(B2T(c[0], c[1], c[2])); + fw.copy(B2T(c[3], c[4], c[5])).normalize(); up.copy(B2T(c[6], c[7], c[8])); + rt.crossVectors(fw, up).normalize(); up.crossVectors(rt, fw).normalize(); + const roll = c[9], cr = Math.cos(roll), sr = Math.sin(roll); + const r2 = rt.clone().multiplyScalar(cr).addScaledVector(up, sr), u2 = up.clone().multiplyScalar(cr).addScaledVector(rt, -sr); + mm.makeBasis(r2, u2, fw.clone().negate()); camera.quaternion.setFromRotationMatrix(mm); + const hf = THREE.MathUtils.degToRad(c[10]); camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(hf / 2) / (1920 / 1080))); camera.updateProjectionMatrix(); + const col = smooth(seg(t, 18.55, 19.1)); + placeCards(col); + const U = mat.uniforms; U.uT.value = ta; + U.uVis.value = 0.95 * lerp(1.0, 0.62, smooth(seg(t, 15.6, 16.6))) * lerp(1.0, 1.25, col); + U.uAmber.value = Math.min(1, 0.14 + 0.7 * col); U.uGray.value = lerp(0.78, 0.45, smooth(seg(t, 15.6, 16.8))) + 0.4 * col; + lineMat.opacity = 0.42 * (1 - 0.6 * smooth(seg(t, 18.6, 19.2))); + renderer.render(scene, camera); + }; +} diff --git a/showcase/04-intro-film/js/main.js b/showcase/04-intro-film/js/main.js index 3536828..f388b5e 100644 --- a/showcase/04-intro-film/js/main.js +++ b/showcase/04-intro-film/js/main.js @@ -1,6 +1,6 @@ // OpenVideoHarness intro film: one continuous camera through one world. // Every frame is a pure function of t (HyperFrames publishes t via "hf-seek"). -// Grid: 90 BPM, 30 fps -> beat = 20 frames, 16th = 5 frames, bar = 80 frames; 30 bars (bar 11 in 6/4) = 2440 frames (v3 Phase B). +// Grid: 80 BPM in v5 (90 in v3), 30 fps -> beat = 22.5 frames, bar = 90 frames; 30 bars (bar 11 in 6/4) = 91.5 s. v5 shows it from bar 10 on. // Sections: S1 hook 1–3 · S2 program 4–6 · S3 problem 7–8 · S4 braam 9 · S5 architecture 10–13 (arch.js) · S6 workflow 14–18 (pipeline.js) // S7 features 19–21 + S8 cases 22 (features.js) · S9 proof hall 23–26 · S10 reveal 27–28 · S11 title 29–30. import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; @@ -13,9 +13,10 @@ import { buildArch } from "./arch.js"; import { buildPipeline } from "./pipeline.js"; import { buildFeatures } from "./features.js"; -export const BPM = 90, BEAT = 60 / BPM, BAR = 4 * BEAT, FPS = 30, S16 = BEAT / 4; +export const BPM = 80, BEAT = 60 / BPM, BAR = 4 * BEAT, FPS = 30, S16 = BEAT / 4; // bar k (1-based), beat offset b (0-based, may be fractional). Bar 11 is a 6/4 bar (the 8-type list holds 2 extra beats), -// so every bar from 12 on starts 2 beats later than a plain 4/4 grid: 30 bars = 81.333 s = 2440 frames. +// so every bar from 12 on starts 2 beats later than a plain 4/4 grid: 30 bars = 91.5 s = 2745 frames (the old timeline; +// js/tmap.js stretches it to the film's 150.5 s). export const bar = (k, b = 0) => (k - 1) * BAR + b * BEAT + (k >= 12 ? 2 * BEAT : 0); export const DUR = bar(31); const barBeat = (t) => { // the true bar.beat under the 6/4 bar (for the HUD) @@ -44,15 +45,23 @@ export function boot(THREE) { window.__hf = window.__hf || {}; window.__hf.buildReady = window.__hf.buildReady || {}; let renderAt = () => {}; - const ready = build(THREE).then((fn) => { renderAt = fn; renderAt((window.__T0 || 0) + (window.__hfThreeTime || 0)); if (window.__introReady) window.__introReady(); }); + // v5: the film's time tn → the story's old time (js/tmap.js stretches the holds); ambient motion keeps the film's time + const seek = (tn) => { + const old = window.__tOld || ((x) => x), to = old(tn); if (to < (window.__BODY_FROM ?? -1)) return; + const T0 = window.__T0 || 0, rate = (to - old(tn - 1 / 30)) * 30; // story seconds per film second at tn (< 1 in a stretched hold) + renderAt(T0 + to, T0 + tn, rate); + }; + const ready = build(THREE).then((fn) => { renderAt = fn; seek(window.__hfThreeTime || 0); if (window.__introReady) window.__introReady(); }, + (e) => { console.error("intro-world: build failed:", e && e.stack || e); throw e; }); // never a silent black film if (!window.__introReady) window.__hf.buildReady["intro-world"] = ready; - window.addEventListener("hf-seek", (e) => renderAt((window.__T0 || 0) + e.detail.time)); + window.addEventListener("hf-seek", (e) => seek(e.detail.time)); } async function build(THREE) { const DATA = await (await fetch("assets/data.json")).json(); const { id: FXID, P } = readPreset(); - let BEATS = null; try { const r = await fetch("audio/music.beats.json"); if (r.ok) BEATS = await r.json(); } catch (e) {} + // the body score's beat map on the old timeline (written by audio/score_engine.py from audio/score.base.json): the pulses + let BEATS = null; try { const r = await fetch("audio/music.beats.json"); if (r.ok) BEATS = await r.json(); else console.warn("intro-world: no audio/music.beats.json, the beat pulses are off"); } catch (e) { console.warn("intro-world: beat map:", e); } const PUL = makePulses(BEATS, bar); let ONSCREEN = []; try { const r = await fetch("audio/onscreen-code.txt"); if (r.ok) ONSCREEN = (await r.text()).split("\n").filter((l) => l.trim()); } catch (e) {} @@ -73,7 +82,17 @@ async function build(THREE) { const loader = new THREE.TextureLoader(); const loadTex = (url) => new Promise((res) => loader.load(url, (tx) => { tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 8; res(tx); }, undefined, () => res(null))); - const [atlas, selfSheet] = await Promise.all([loadTex("assets/tex/atlas.jpg"), loadTex("assets/tex/self-sheet.png")]); + const [atlas, selfSheet, sb02, draft02, loop02] = await Promise.all(["atlas.jpg", "self-sheet.png", "02-storyboard.png", "02-draft-sheet.png", "02-loop.png"].map((f) => loadTex("assets/tex/" + f))); + // the gates show the request's own artifacts (showcase 02, the vertical short the terminal asked for; tools/request_tex.py) + const outlineTex = (() => { // gate ① art: 02's outline, its STORYBOARD.md shot list (texture, not a read) + const c = document.createElement("canvas"); c.width = 1600; c.height = 900; const g = c.getContext("2d"); + g.fillStyle = "#0d0d10"; g.fillRect(0, 0, 1600, 900); g.strokeStyle = "#3a3a40"; g.lineWidth = 4; g.strokeRect(2, 2, 1596, 896); + g.fillStyle = "#8b8b94"; g.font = '400 40px "SF Mono", Menlo, monospace'; g.fillText("BRIEF.md · outline · 02 低轨卫星的多普勒", 90, 110); + const rows = [["S1", "钩子", "卫星信号会变调"], ["S2", "飞得快", "低轨卫星,飞得极快"], ["S3", "靠近", "波变密,频率升高"], ["S4", "过顶", "频移归零"], ["S5", "远离", "波变疏,频率降低"], ["S6", "量级", "2 GHz ±50 kHz;20 GHz 再大 10 倍"], ["S7", "补偿", "轨道已知,能提前算出"]]; // showcase/02's STORYBOARD.md shots + rows.forEach(([n, a, r], i) => { const y = 215 + i * 96; g.fillStyle = "#FFB224"; g.font = '500 46px "SF Mono", Menlo, monospace'; g.fillText(n, 90, y); + g.fillStyle = "#EDEDEF"; g.font = '500 52px "PingFang SC", sans-serif'; g.fillText(a, 190, y); + g.fillStyle = "#C4C4CC"; g.font = '400 48px "PingFang SC", sans-serif'; g.fillText(r, 520, y); }); + const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 8; return tx; })(); // ---------- text planes (canvas → texture) ---------- const FONT_EN = '"SF Pro Display", system-ui, -apple-system, sans-serif'; @@ -104,8 +123,9 @@ async function build(THREE) { } const onTop = (m) => { m.material.depthTest = false; m.renderOrder = 10; return m; }; const quietTex = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 128; const g = c.getContext("2d"); - const gr = g.createRadialGradient(128, 64, 8, 128, 64, 128); gr.addColorStop(0, "rgba(6,7,8,0.92)"); gr.addColorStop(0.55, "rgba(6,7,8,0.7)"); gr.addColorStop(1, "rgba(6,7,8,0)"); - g.fillStyle = gr; g.fillRect(0, 0, 256, 128); const tx = new THREE.CanvasTexture(c); return tx; })(); + // an ellipse that fades to nothing on all four sides (the old circle was cut by the canvas top and bottom: it read as a box) + const gr = g.createRadialGradient(128, 128, 8, 128, 128, 128); gr.addColorStop(0, "rgba(6,7,8,0.94)"); gr.addColorStop(0.62, "rgba(6,7,8,0.82)"); gr.addColorStop(1, "rgba(6,7,8,0)"); + g.setTransform(1, 0, 0, 0.5, 0, 0); g.fillStyle = gr; g.fillRect(0, 0, 256, 256); const tx = new THREE.CanvasTexture(c); return tx; })(); function quiet(m, k = 1.5) { // the background goes quiet under big type (a soft dark plate behind it, in the world) const w = m.geometry.parameters.width, h = m.geometry.parameters.height; const q = new THREE.Mesh(new THREE.PlaneGeometry(w * k, h * (k + 0.5)), new THREE.MeshBasicMaterial({ map: quietTex, transparent: true, opacity: 0, depthTest: false, depthWrite: false, toneMapped: false })); @@ -113,8 +133,8 @@ async function build(THREE) { } const fontStr = (l) => `${l.weight || 600} ${l.size}px ${l.font || FONT_EN}`; let dynSeed = 1; - function dynPlane(lines, { pxPerUnit = 100, lineGap = 0.4 } = {}) { // decode/scramble type (v3); falls back to a static look when P.decode = 0 - const D = makeDynText(THREE, lines, { lineGap, fontStr, spread: P.decode ? 0.22 : 0.0001, seed: (dynSeed += 17) }); + function dynPlane(lines, { pxPerUnit = 100, lineGap = 0.4, decodeDur = 0.2 } = {}) { // decode/scramble type (v3); falls back to a static look when P.decode = 0 + const D = makeDynText(THREE, lines, { lineGap, fontStr, spread: P.decode ? 0.22 : 0.0001, seed: (dynSeed += 17), decodeDur }); const mat = textMaterial(THREE, D.tex); mat.depthTest = false; const mesh = new THREE.Mesh(new THREE.PlaneGeometry(D.canvas.width / pxPerUnit, D.canvas.height / pxPerUnit), mat); mesh.renderOrder = 10; mesh.userData.base = mesh.position.clone(); mesh.userData.dyn = D; mesh.userData.contentFrac = D.contentFrac; return mesh; @@ -122,7 +142,8 @@ async function build(THREE) { const hero = (en, zh, { enSize = 150, zhSize = 0, enColor = FG } = {}) => quiet(dynPlane([...en.split("\n").map((l) => ({ text: l, size: enSize, weight: 600, color: enColor, track: -0.025 })), ...(zh ? [{ text: zh, size: zhSize || Math.round(enSize * 0.62), weight: 500, font: FONT_ZH, color: "#D6D6DC" }] : [])])); // kinetic "slam": rise + settle scale on entry (easeOutExpo), fade out at exit - function slam(m, t, tIn, tOut, { rise = 0.25, sc = 1.08, fi = 0.5, fo = 0.6, maxO = 1 } = {}) { + const NODEC = { decode: 0 }; // draw state for type that lands without the decode scramble + function slam(m, t, tIn, tOut, { rise = 0.25, sc = 1.08, fi = 0.5, fo = 0.6, maxO = 1, nodec = 0 } = {}) { const u = eOutExpo(seg(t, tIn, tIn + 0.9)); m.material.opacity = maxO * win(t, tIn, tOut, fi, fo); m.visible = m.material.opacity > 0.001; @@ -133,7 +154,7 @@ async function build(THREE) { const s = lerp(sc, 1, u) * (m.userData.fit || 1); m.scale.set(s, s, 1); const D = m.userData.dyn; if (D && m.visible) { - D.draw(t - tIn, P); + D.draw(t - tIn, nodec ? NODEC : P); const U = m.material.uniforms; U.uSweep.value = P.sweep ? lerp(-0.25, 1.25, seg(t, tIn + 0.35, tIn + 1.05)) : -1; U.uGlitch.value = P.glitchExit * seg(t, tOut - 0.22, tOut) + (P.glitchFrame ? 0.25 * seg(t, tIn, tIn + 0.12) * (1 - seg(t, tIn + 0.12, tIn + 0.3)) : 0); @@ -150,7 +171,7 @@ async function build(THREE) { m.position.copy(camera.position).addScaledVector(_f, d).addScaledVector(_u, dy * visW / camera.aspect / 2 - (m.userData.riseOff || 0) * 0.3).addScaledVector(_r, dx * visW / 2); m.quaternion.copy(camera.quaternion); } - const slamView = (m, t, tIn, tOut, d, frac, dx = 0, dy = 0) => { slam(m, t, tIn, tOut, { fo: 0.35 }); if (m.visible) rideView(m, d, frac, dx, dy); }; + const slamView = (m, t, tIn, tOut, d, frac, dx = 0, dy = 0, nodec = 0) => { slam(m, t, tIn, tOut, { fo: 0.35, nodec }); if (m.visible) rideView(m, d, frac, dx, dy); }; // pin: a camera-facing label anchored to a world point, sized in screen pixels (k = screen px per canvas px), // offset (dx, dy) in screen px; ax/ay choose which edge of the label sits on the offset point. Returns a near-fade 0..1. const _pf = new THREE.Vector3(), _pu = new THREE.Vector3(), _pr = new THREE.Vector3(), _pd = new THREE.Vector3(); @@ -400,7 +421,8 @@ async function build(THREE) { const key = (t, p, l, fov = 40, stop = false, tm = null) => K.push({ t, p, l, fov, stop, tm }); // --- S5–S8 live in their own modules (they add their camera keys) --- - const X = { scene, key, V, textPlane, dynPlane, quiet, hero, slamView, barMat, setAmber, sprite, glowTex, softTex, FONT_MONO, FONT_ZH, FONT_EN, P, bar, BEAT, S16, seg, win, lerp, eOutExpo, eOutCubic, eInOut, clamp, hash, pin, tubeMat, WAVE, DUR }; + const film02 = new THREE.VideoTexture(document.getElementById("v02k")); film02.colorSpace = THREE.SRGBColorSpace; VIDTEX.push(film02); // the final cut: 02 itself + const X = { gateTex: { R1: outlineTex, R2: sb02, R3: draft02, K: film02, LOOP: loop02 }, scene, key, V, textPlane, dynPlane, quiet, hero, slamView, barMat, setAmber, sprite, glowTex, softTex, FONT_MONO, FONT_ZH, FONT_EN, P, bar, BEAT, S16, seg, win, lerp, eOutExpo, eOutCubic, eInOut, clamp, hash, pin, tubeMat, WAVE, DUR }; const ARCH = buildArch(THREE, X); const PIPE = buildPipeline(THREE, X); const FEAT = buildFeatures(THREE, X); @@ -419,7 +441,7 @@ async function build(THREE) { { id: "v01", x: HX - 6.8, y: 2.3, s: 100, ry: 0.42, w: 7.2, h: 4.05, n: "01", type: "HAND-DRAWN SHORT", meta: "p5.brush · 12 s · 1080p24 | 57 s render · 3 review rounds", tFocus: bar(24, 0) }, { id: "v03", x: HX + 6.8, y: 2.6, s: 116, ry: -0.42, w: 7.2, h: 4.05, n: "03", type: "3B1B-STYLE MATH EXPLAINER", meta: "Manim CE 0.21 · 25 s · 1080p30 | 24 s render", tFocus: bar(24, 3) }, { id: "v02", x: HX - 5.6, y: 3.1, s: 132, ry: 0.38, w: 3.3, h: 5.87, n: "02", type: "VERTICAL SCIENCE SHORT", meta: "HyperFrames · 24.8 s · 1080×1920 | 46 s render", tFocus: bar(25, 2) }, - { id: "v00", x: HX + 6.4, y: 2.3, s: 148, ry: -0.42, w: 7.2, h: 4.05, n: "00", type: "LAUNCH FILM · SILENT", meta: "HyperFrames · 20 s · 1920×1080 | ~30 s render", tFocus: bar(26, 1) }, + { id: "v00", x: HX + 6.4, y: 2.3, s: 148, ry: -0.42, w: 7.2, h: 4.05, n: "00", type: "LAUNCH SHORT", meta: "HyperFrames · 20 s · 1920×1080 | ~30 s render", tFocus: bar(26, 1) }, ].map((m) => { const grp = new THREE.Group(); const scr = new THREE.Mesh(new THREE.PlaneGeometry(m.w, m.h), new THREE.MeshBasicMaterial({ map: vt(m.id), color: new THREE.Color().setScalar(m.id === "v00" ? 0.95 : 1.15), toneMapped: false, fog: false })); // lifted (the films are the evidence); 00 is white-on-black, kept under the bloom threshold @@ -427,18 +449,25 @@ async function build(THREE) { const back = new THREE.Mesh(new THREE.BoxGeometry(m.w + 0.3, m.h + 0.3, 0.4), new THREE.MeshBasicMaterial({ color: 0x0c0c0e, fog: true })); back.position.z = -0.25; const halo = new THREE.Mesh(new THREE.PlaneGeometry(m.w * 2.6, m.h * 2.6), new THREE.MeshBasicMaterial({ map: neutralTex, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, opacity: 0.0, toneMapped: false, fog: false })); halo.position.z = -0.6; grp.add(halo, back, scr, frame); + const vk = m.h > m.w ? 1.25 : 1; // the vertical film is narrower: its caption gets the same screen size as the others const lab = onTop(textPlane([ - { text: `${m.n} ${m.type}`, size: 62, weight: 600, color: FG, track: 0.04 }, - ...m.meta.split(" | ").map((ln) => ({ text: ln, size: 48, weight: 400, font: FONT_MONO, color: "#B4B4BC" })), + { text: `${m.n} ${m.type}`, size: Math.round(62 * vk), weight: 600, color: FG, track: 0.04 }, + ...m.meta.split(" | ").map((ln) => ({ text: ln, size: Math.round(48 * vk), weight: 400, font: FONT_MONO, color: "#B4B4BC" })), ], { pxPerUnit: 160, align: "left", lineGap: 0.35 })); lab.position.set(-m.w / 2 + lab.geometry.parameters.width / 2 - 0.1, -m.h / 2 - 0.15 - lab.geometry.parameters.height / 2, 0.05); lab.userData.base = lab.position.clone(); const sh = shaft(m.w * 0.9, 18); sh.position.set(0, 3.5, -1.2); sh.material.opacity = 0.1; grp.add(sh); const pool = new THREE.Mesh(new THREE.PlaneGeometry(m.w * 1.8, m.w * 0.9), new THREE.MeshBasicMaterial({ map: neutralTex, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, opacity: 0.55, toneMapped: false, fog: false })); pool.rotation.x = -Math.PI / 2; pool.position.set(0, -m.h / 2 - 1.2, 1.2); grp.add(pool); grp.add(lab); + let link = null; + if (m.id === "v02") { // 02 is the answer to the request typed at the start: say so, in the empty side of the frame + link = onTop(textPlane([{ text: "The request", size: 64, weight: 600, color: FG, track: -0.01 }, { text: "from the start", size: 64, weight: 600, color: FG, track: -0.01 }, { text: "→ this film.", size: 64, weight: 600, color: FG, track: -0.01 }, // white: amber vanished into the hall's amber streak + { text: "开头那句需求", size: 54, weight: 500, font: FONT_ZH, color: "#D6D6DC" }, { text: "就是这支片子。", size: 54, weight: 500, font: FONT_ZH, color: "#D6D6DC" }], { pxPerUnit: 150, align: "right", lineGap: 0.28 })); + link.position.set(-m.w / 2 - 0.45 - link.geometry.parameters.width / 2, 0.9, 0.05); grp.add(link); + } inLane(grp, m.x, m.y, m.s, m.ry); - return { ...m, grp, scr, halo, lab }; + return { ...m, grp, scr, halo, lab, link }; }); - const T13 = inLane(hero("Made by an agent,\nfollowing only these docs.", "都是 agent 只照着这套文档做的。", { enSize: 150 }), HX, 6.4, 106); + const T13 = inLane(hero("Made by an agent\nfrom this repo's docs alone.", "都是 agent 只照本仓库的文档做的。", { enSize: 150 }), HX, 6.4, 106); // --- S10: this film's own contact sheet as a monument; the score as code; S11 the title --- const MW = { x: HX, y: 9.8, s: 196, cols: 10, rows: 6, tw: 4.2, th: 2.3625, gap: 0.18 }; @@ -474,18 +503,18 @@ async function build(THREE) { } const playhead = new THREE.Mesh(new THREE.BoxGeometry(0.035, 1.7, 0.03), barMat(2.6)); lane2.add(playhead); const TITLE = inLane(textPlane([{ text: "OpenVideoHarness", size: 220, weight: 600, color: FG, track: -0.03 }], { pxPerUnit: 118 }), HX, 10.35, MW.s - 0.5); - const TAG = inLane(textPlane([{ text: "Video as code, for coding agents.", size: 84, weight: 400, color: "#C9C9D0" }, { text: "给 coding agent 的视频工作台", size: 84, weight: 400, font: FONT_ZH, color: "#B4B4BC" }], { pxPerUnit: 118, lineGap: 0.35 }), HX, 8.05, MW.s - 0.5); + const TAG = inLane(textPlane([{ text: "Video as code, for coding agents.", size: 100, weight: 400, color: "#D4D4DA" }, { text: "给 coding agent 的视频工作台", size: 100, weight: 400, font: FONT_ZH, color: "#C4C4CC" }], { pxPerUnit: 118, lineGap: 0.35 }), HX, 7.95, MW.s - 0.5); const CTA = inLane(textPlane([ { text: "$ bin/vh new ", size: 86, weight: 400, font: FONT_MONO, color: FG }, { text: "github.com/ZLHad/OpenVideoHarness", size: 86, weight: 400, font: FONT_MONO, color: AMB }, ], { pxPerUnit: 118, lineGap: 0.5 }), HX, 5.75, MW.s - 0.5); - const titleBar = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.9, 0.05), barMat(2.6)); lane2.add(titleBar); + const titleBar = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.9, 0.05), barMat(1.7)); lane2.add(titleBar); // the CTA types in on the score's 8 typing 16ths (29:4 → 30:1.75); same layout as textPlane, so nothing shifts const CTA_L = [{ text: "$ bin/vh new ", color: FG }, { text: "github.com/ZLHad/OpenVideoHarness", color: AMB }]; const ctaC = CTA.material.map.image, ctaG = ctaC.getContext("2d"); let ctaKey = null; function drawCTA(t) { const total = CTA_L[0].text.length + CTA_L[1].text.length; - const k = t < bar(29, 3) ? 0 : Math.min(8, Math.floor((t - bar(29, 3)) / S16 + 1e-6) + 1), n = Math.round(total * k / 8); + const k = t < bar(29, 2) ? 0 : Math.min(8, Math.floor((t - bar(29, 2)) / S16 + 1e-6) + 1), n = Math.round(total * k / 8); const cur = k < 8 ? Math.floor(t * 5) % 2 : 0, key = `${n}|${cur}`; if (key === ctaKey) return; ctaKey = key; ctaG.clearRect(0, 0, ctaC.width, ctaC.height); ctaG.font = `400 86px ${FONT_MONO}`; ctaG.textBaseline = "top"; ctaG.letterSpacing = "0px"; let y = 24, left = n; @@ -513,10 +542,10 @@ async function build(THREE) { key(bar(9) - 0.66, V(0, 1.45, -172), V(0, 1.6, -205), 40); key(bar(9), V(0, 1.55, -178), V(0, 1.9, -214), 42); key(bar(10) - 0.35, V(0, 1.95, -187), V(0, 2.0, -232), 42); - // S9 proof hall (after the star map), a slow slide past each screen + // S9 proof hall (after the star map), a slow slide past each screen (the tall film from further back: its caption stays inside the safe frame) key(bar(23, 1.2), L2(HX, 4.6, 86), L2(HX, 2.6, 128), 44); const camFor = (m, d, dy = -0.45) => [L2(m.x + Math.sin(m.ry) * d, m.y + dy + 0.2, m.s - Math.cos(m.ry) * d), L2(m.x, m.y + dy, m.s)]; - MON.forEach((m) => { const vert = m.h > m.w; const [p0, l0] = camFor(m, vert ? 12.4 : 8.9, vert ? -0.75 : -0.85); const [p1, l1] = camFor(m, vert ? 11.8 : 8.3, vert ? -0.75 : -0.85); key(m.tFocus + 0.45 * BEAT, p0, l0, 40, false, "f"); key(m.tFocus + 2.55 * BEAT, p1, l1, 40, false, "b"); }); + MON.forEach((m) => { const vert = m.h > m.w; const [p0, l0] = camFor(m, vert ? 11.8 : 8.9, vert ? -1.1 : -0.85); const [p1, l1] = camFor(m, vert ? 11.3 : 8.3, vert ? -1.1 : -0.85); key(m.tFocus + 0.45 * BEAT, p0, l0, 40, false, "f"); key(m.tFocus + 2.55 * BEAT, p1, l1, 40, false, "b"); }); // S10 reveal: rise and pull back to the monument key(bar(27, 2), L2(HX, 5.2, 163), L2(HX, 8.2, MW.s), 46); key(bar(28), L2(HX, 7.3, 174.5), L2(HX, 7.5, MW.s), 44); @@ -532,6 +561,9 @@ async function build(THREE) { const WHIPS = [[bar(3) - 0.1, 0.45], [bar(4), 0.4], [bar(7) - 0.15, 0.4], [bar(9) - 0.08, 0.3], ...ARCH.whips, ...PIPE.whips, ...FEAT.whips, [bar(23) + 0.1, 0.5], ...MON.slice(1).map((m) => [m.tFocus - 0.07, 0.4]), [bar(27, 1), 0.5], [bar(29) + 0.7, 0.4]].map(([t, h]) => ({ t, h })); const warp = makeWarp(WHIPS, P.whip); + // the three big moves keep the lens streaks (the dive into the repo, into the proof hall, into "this film, too"); the + // other whips are plain camera moves (round 3: one streak-whip repeated twelve times) + const BIGT = [bar(10) + 0.15, bar(23) + 0.1, bar(27, 1)], BIGW = (t) => Math.max(0, ...BIGT.map((w) => Math.exp(-(((t - w) / 0.6) ** 2)))); const camP = (t) => herm((k) => k.p, warp(t)), camL = (t) => herm((k) => k.l, warp(t)), camF = (t) => herm((k) => k.fov, warp(t)); function fitAt(m, tq, frac) { // size a world-fixed text so it spans `frac` of the frame width at its read moment scene.updateMatrixWorld(true); @@ -595,6 +627,38 @@ async function build(THREE) { // particles: depth sparks along the whole camera path, data streams on the t-axis, warp lines at the lens const pathSamples = []; for (let i = 0; i <= 480; i++) pathSamples.push(camP((i / 480) * DUR)); [ARCH, PIPE, FEAT].forEach((M) => M.samples.forEach((q) => pathSamples.push(q))); const sparks = makeSparks(THREE, pathSamples, 9000); scene.add(sparks); sparks.visible = P.sparks > 0; sparks.material.uniforms.uAmt.value = P.sparks; + // v5: the archive is made of films. Small frames of the opening's films (the same atlases) drift beside the camera path, + // so "every star is a film" carries on into the body. Instanced billboards; each plays its film from t. + const filmField = await (async () => { + const [tP, tR, tA] = await Promise.all([loadTex("assets/films-proc.png"), loadTex("assets/films.jpg"), loadTex("assets/films-ai.jpg")]); + if (!tP || !tR || !tA) return null; + tP.colorSpace = THREE.NoColorSpace; // stored gamma-encoded without a tag (decoded in the shader) + const NF = 1000, geo = new THREE.PlaneGeometry(1, 0.5625); + const pos = new Float32Array(NF * 3), film = new Float32Array(NF * 4); + for (let i = 0; i < NF; i++) { + const q = pathSamples[Math.floor(hash(i * 2.37 + 0.3) * pathSamples.length)]; + const a = hash(i * 3.11 + 0.7) * Math.PI * 2, r = 2.8 + 9 * Math.pow(hash(i * 4.29 + 0.1), 0.8); + pos.set([q.x + Math.cos(a) * r, q.y + Math.sin(a) * r * 0.6, q.z + (hash(i * 5.73) - 0.5) * 6], i * 3); + const k = hash(i * 6.91 + 0.2), kind = k < 0.4 ? 2 : k < 0.65 ? 1 : 0; + film.set([kind, Math.floor(hash(i * 7.77 + 0.4) * (kind === 2 ? 40 : kind === 1 ? 41 : 28)), hash(i * 8.13) * 16, 0.45 + 0.7 * hash(i * 9.37)], i * 4); + } + geo.setAttribute("aPos", new THREE.InstancedBufferAttribute(pos, 3)); geo.setAttribute("aFilm", new THREE.InstancedBufferAttribute(film, 4)); + const mat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, toneMapped: false, + uniforms: { tP: { value: tP }, tR: { value: tR }, tA: { value: tA }, uT: { value: 0 }, uAmt: { value: 0 }, uFog: { value: 0.02 } }, + vertexShader: `attribute vec3 aPos; attribute vec4 aFilm; varying vec2 vUv; varying vec4 vFilm; varying float vFade; + void main(){ vUv = uv; vFilm = aFilm; vec4 mv = modelViewMatrix * vec4(aPos, 1.0); mv.xy += position.xy * aFilm.w; + float d = -mv.z; vFade = exp(-0.012 * d * d * 0.02) * smoothstep(4.0, 9.0, d); gl_Position = projectionMatrix * mv; }`, + fragmentShader: `uniform sampler2D tP, tR, tA; uniform float uT, uAmt; varying vec2 vUv; varying vec4 vFilm; varying float vFade; + vec3 tap(float fr){ float kind = vFilm.x, rows = kind < 0.5 ? 28.0 : (kind < 1.5 ? 41.0 : 40.0); + vec2 a = vec2((fr + 0.5 + (vUv.x - 0.5) * 0.99) / 16.0, 1.0 - (vFilm.y + 0.5 - (vUv.y - 0.5) * 0.99) / rows); + if (kind < 0.5) return pow(texture2D(tP, a).rgb, vec3(2.2)); if (kind < 1.5) return texture2D(tR, a).rgb; return texture2D(tA, a).rgb; } + float frameOf(float k){ float m = mod(k, 30.0); return vFilm.x > 0.5 ? (m < 16.0 ? m : 30.0 - m) : mod(k, 16.0); } + void main(){ float f = uT * 8.0 + vFilm.z, k0 = floor(f); vec3 c = mix(tap(frameOf(k0)), tap(frameOf(k0 + 1.0)), f - k0); + vec2 e = min(vUv, 1.0 - vUv); float edge = 1.0 - smoothstep(0.0, 0.03, min(e.x * 1.78, e.y)); + c = c * 0.36 + vec3(1.0, 0.72, 0.3) * edge * 0.2; + gl_FragColor = vec4(c, uAmt * vFade); }` }); + const mesh = new THREE.InstancedMesh(geo, mat, NF); mesh.frustumCulled = false; mesh.renderOrder = -100; scene.add(mesh); return mesh; + })(); const streams = makeStreams(THREE); scene.add(streams); const warpLines = makeWarpLines(THREE); camera.add(warpLines); scene.add(camera); const hudx = document.getElementById("hudx"); @@ -605,14 +669,16 @@ async function build(THREE) { // ===================================================================== // RENDER AT t // ===================================================================== - return function renderAt(t) { - t = Math.round(clamp(t, 0, DUR) * FPS) / FPS + 1e-4; // on the frame grid, nudged so hits stored as rounded seconds land on their own frame - const frame = Math.round(t * FPS); + return function renderAt(t, ta = t, rate = 1) { // t: the story's time (old, stretched holds); ta: ambient time (the film's own clock) + // the film's time (ta) is on the frame grid; the story's time is continuous: snapping it too would make a stretched + // hold (where it runs at a fraction of real speed) hold still for frames and then step (judder) + t = clamp(t, 0, DUR) + 1e-4; + const frame = Math.round(ta * FPS); // camera: keyframed path through speed-ramped time + seeded shake + handheld drift + FOV punch on the big hits - const p = camP(t).add(shake(t)).add(hand(t)), l = camL(t); + const p = camP(t).add(shake(t)).add(hand(ta)), l = camL(t); let punch = 0; for (const h of PUNCHES) { const u = t - h; if (u >= 0 && u < 1.2) punch += 3.5 * Math.exp(-u / 0.22); } camera.position.copy(p); camera.fov = camF(t) - punch * P.fovPunch; camera.updateProjectionMatrix(); camera.lookAt(l); - if (P.hand) camera.rotateZ(0.004 * P.hand * Math.sin(0.7 * t + 0.5)); + if (P.hand) camera.rotateZ(0.004 * P.hand * Math.sin(0.7 * ta + 0.5)); camera.updateMatrixWorld(true); // global light level: blackout before the braam @@ -684,12 +750,12 @@ async function build(THREE) { // S4: rings light in a wave on the braam rings.forEach((r) => { const a = t - r.userData.tIn; const k = a < 0 ? 0 : 1.1 + 3.2 * Math.exp(-a / 0.4); setAmber(r.userData.mat, k * 0.75); r.visible = t > bar(9) - 0.01 && t < bar(10, 2); }); shafts4.forEach((s, i) => { s.material.opacity = 0.55 * win(t, bar(9) + i * 0.05, bar(10, 1), 0.3, 0.8); s.visible = s.material.opacity > 0; }); - slam(T8, t, bar(9), bar(10) - 0.12, { sc: 1.12, rise: 0.1, fi: 0.25, fo: 0.35 }); if (T8.visible) { rideView(T8, 12, 0.68, 0, 0.02); T8.userData.quiet.material.opacity = 0.85 * T8.material.opacity; } + slam(T8, t, bar(9), bar(10) - 0.12, { sc: 1.12, rise: 0.1, fi: 0.25, fo: 0.35 }); if (window.__T0) T8.visible = false; if (T8.visible) { rideView(T8, 12, 0.68, 0, 0.02); T8.userData.quiet.material.opacity = 0.85 * T8.material.opacity; } // S5–S8 modules ARCH.update(t, { frame, PUL }); PIPE.update(t, { camera, frame, PUL }); - FEAT.update(t, { frame, PUL }); + FEAT.update(t, { frame, PUL, ta }); // S9 proof hall MON.forEach((m) => { @@ -697,6 +763,7 @@ async function build(THREE) { m.halo.material.opacity = 0.1 + 0.25 * focus; m.lab.material.opacity = win(t, m.tFocus - 0.15, m.tFocus + FD + 0.05, 0.3, 0.3); m.lab.visible = m.lab.material.opacity > 0; + if (m.link) { m.link.material.opacity = win(t, m.tFocus + 0.1, m.tFocus + FD + 0.05, 0.3, 0.3); m.link.visible = m.link.material.opacity > 0; } m.grp.visible = t > bar(22, 2) && t < bar(28); }); slam(T13, t, bar(23, 0.35), bar(24) - 0.1, { fo: 0.4 }); if (T13.visible) rideView(T13, 12, 0.6, 0, 0.44); @@ -705,31 +772,32 @@ async function build(THREE) { const titleDim = (1 - 0.9 * seg(t, bar(29) - 0.1, bar(29) + 0.4)) * (1 - 0.68 * win(t, bar(27, 1), bar(29), 0.5, 0.3)); const typeOn = Math.max(win(t, bar(27, 1), bar(29) - 0.5, 0.5, 0.3), seg(t, bar(29) - 0.2, bar(29) + 0.3)); mwTiles.forEach((m) => { m.material.opacity = 0.92 * eOutCubic(seg(t, m.userData.tIn, m.userData.tIn + 0.35)) * titleDim * (1 - typeOn * (1 - m.userData.ell)); m.visible = m.material.opacity > 0.001; }); - slam(T14, t, bar(27, 1), bar(28) - 0.05, { fo: 0.3 }); + slam(T14, t, bar(27, 1) + 0.45, bar(28) - 0.05, { fo: 0.3, nodec: 1 }); // lands after the whip, no scramble const revealEnd = bar(29) - 0.04; // the 4th code line (stem:lead, 28:4) gets its read before the title lands slam(T15, t, bar(28), revealEnd, { fo: 0.25 }); codeLines.forEach((m, i) => { const a = m.userData.tIn - 1 / FPS; m.material.opacity = t >= a ? win(t, a, revealEnd, 0.1, 0.25) : 0; m.visible = m.material.opacity > 0; codeMarks[i].visible = t >= m.userData.tIn && t < revealEnd; }); if (waveLine) { waveLine.material.opacity = 0.85 * win(t, bar(27, 2), revealEnd, 0.8, 0.25); waveLine.visible = waveLine.material.opacity > 0; } playhead.visible = t > bar(27, 2) && t < revealEnd; - playhead.position.set(HX - 9.5 + 19 * (t / DUR), 5.45, -(MW.s - 9.55)); + playhead.position.set(HX - 9.5 + 19 * clamp((ta - (window.__T0 || 0)) / ((WAVE && WAVE.duration) || DUR)), 5.45, -(MW.s - 9.55)); // the playhead sits on the film's own time in the waveform { const u = eOutExpo(seg(t, bar(29), bar(29) + 1.0)); const s2 = lerp(1.1, 1, u); TITLE.scale.set(s2, s2, 1); } TITLE.material.opacity = t >= bar(29) ? eOutCubic(seg(t, bar(29) - 1 / FPS, bar(29) + 0.3)) : 0; TITLE.visible = TITLE.material.opacity > 0; TAG.material.opacity = win(t, bar(29, 1), DUR + 5, 0.5, 0.1); TAG.visible = TAG.material.opacity > 0; - CTA.material.opacity = t >= bar(29, 3) ? 1 : 0; CTA.visible = CTA.material.opacity > 0; if (CTA.visible) drawCTA(t); + CTA.material.opacity = t >= bar(29, 2) ? 1 : 0; CTA.visible = CTA.material.opacity > 0; if (CTA.visible) drawCTA(t); const tw = TITLE.geometry.parameters.width * TITLE.scale.x; - titleBar.position.set(HX - tw / 2 - 0.55, 10.35, -(MW.s - 0.6)); titleBar.visible = t >= bar(29); - setAmber(titleBar.material, 2.6 + 3 * Math.exp(-Math.max(0, t - bar(29)) / 0.3)); + titleBar.position.set(HX - tw / 2 - 0.75, 10.35, -(MW.s - 0.6)); titleBar.visible = t >= bar(29); + setAmber(titleBar.material, 1.7 + 1.6 * Math.exp(-Math.max(0, t - bar(29)) / 0.3)); // the bar's flare must never reach the O // footage: re-upload the frame HyperFrames seeked for this t VIDTEX.forEach((tx) => { const v = tx.image; if (v && v.readyState >= 2) tx.needsUpdate = true; }); // FX: particles, pulses, motion blur, rays, shockwave, CA, grade (all from t and the camera state at t) const down = PUL.down(t), big = PUL.big(t); - sparks.material.uniforms.uTime.value = t; sparks.material.uniforms.uPulse.value = P.pulse * (0.6 * down + big); sparks.material.uniforms.uFog.value = scene.fog.density; + if (filmField) { filmField.material.uniforms.uT.value = ta; filmField.material.uniforms.uAmt.value = (window.__T0 ? 1 : 0) * win(t, bar(10) - 0.3, bar(27, 2), 0.6, 0.8); filmField.visible = filmField.material.uniforms.uAmt.value > 0.002; } + sparks.material.uniforms.uTime.value = ta; sparks.material.uniforms.uPulse.value = P.pulse * (0.6 * down + big); sparks.material.uniforms.uFog.value = scene.fog.density; streams.userData.update(t, P.streams * (t < bar(9) ? win(t, 1.5, bar(9) - 0.7, 1.0, 0.4) : 0), bar(9)); - const pPrev = camP(Math.max(0, t - 1 / FPS)), lPrev = camL(Math.max(0, t - 1 / FPS)); + const dtS = rate / FPS, pPrev = camP(Math.max(0, t - dtS)), lPrev = camL(Math.max(0, t - dtS)); // one film frame ago, in story time const fwd = l.clone().sub(p).normalize(), fwdPrev = lPrev.clone().sub(pPrev).normalize(); const vel = p.clone().sub(pPrev).multiplyScalar(FPS), speed = vel.length(), vFwd = vel.dot(fwd); - warpLines.userData.update(t, speed, P.warp); + warpLines.userData.update(t, speed, P.warp * (0.15 + 0.85 * BIGW(t))); // the lens streaks only on the three big moves if (FXID === "v2") { grainPass.uniforms.uFrame.value = frame; } else { const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion), up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion); @@ -744,17 +812,17 @@ async function build(THREE) { : t < bar(14) ? ARCH.light(t) : t < bar(19) ? PIPE.light(t) : t < bar(23) ? FEAT.light(t) : t < bar(29) - 0.2 ? p.clone().addScaledVector(fwd, 40) : TITLE_LW; const ndc = LW.clone().project(camera), inFront = LW.clone().sub(p).dot(fwd) > 0; M.uLight.value = [ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5]; - const rayEnv = t < bar(3) ? 0.45 + 0.45 * seg(t, bar(2) + 0.3, bar(3)) : t < bar(9) - 0.66 ? 0.25 : t < bar(10) ? 0.45 + 0.35 * big : t < bar(14) ? ARCH.rays(t) : t < bar(19) ? PIPE.rays(t) : t < bar(23) ? FEAT.rays(t) : t < bar(29) - 0.2 ? 0.25 : 0.55 + 0.9 * big; + const rayEnv = t < bar(3) ? 0.45 + 0.45 * seg(t, bar(2) + 0.3, bar(3)) : t < bar(9) - 0.66 ? 0.25 : t < bar(10) ? 0.45 + 0.35 * big : t < bar(14) ? ARCH.rays(t) : t < bar(19) ? PIPE.rays(t) : t < bar(23) ? FEAT.rays(t) : t < bar(29) - 0.2 ? 0.25 : 0.32 + 0.45 * big; M.uRays.value = inFront ? P.rays * rayEnv : 0; const netSec = t > bar(10) && t < bar(23); - M.uStreak.value = P.streak * (netSec ? 0.5 : 1) * (1 + 1.2 * big + 0.5 * PUL.hit(t)) * (1 - 0.8 * seg(t, bar(29, 2.4), bar(29, 3))); // the URL must stay clean - M.uCA.value = P.ca * (0.1 + 0.35 * down + 1.2 * big + PIPE.ca(t)) * (1 - 0.6 * guardO); + M.uStreak.value = P.streak * (netSec ? 0.5 : 1) * (1 + 1.2 * big + 0.5 * PUL.hit(t)) * (1 - 0.8 * seg(t, bar(29, 1.4), bar(29, 2))); // the URL must stay clean + M.uCA.value = P.ca * (0.1 + 0.35 * down + 1.2 * big + PIPE.ca(t)) * (1 - 0.95 * guardO); let ring = [0.5, 0.5, 0, 0]; - for (const h of PUNCHES) { const u = (t - h) / 0.75; if (u >= 0 && u < 1) ring = [M.uLight.value[0], M.uLight.value[1], 0.03 + 1.3 * Math.pow(u, 0.7), P.ring * (1 - u)]; } + for (const h of PUNCHES) { const u = (t - h) / 0.75; if (u >= 0 && u < 1 && h < bar(28)) ring = [M.uLight.value[0], M.uLight.value[1], 0.03 + 1.3 * Math.pow(u, 0.7), 0.6 * P.ring * (1 - u)]; } M.uRing.value = ring; bloom.strength = P.bloom * (1 + 0.2 * P.pulse * down + 0.3 * big); const G = gradePass.uniforms; - G.uGrade.value = P.grade; G.uContrast.value = P.contrast; G.uExposure.value = 1 + 0.12 * P.pulse * down; G.uFlash.value = P.flash * PUL.flash(t); + G.uGrade.value = P.grade; G.uContrast.value = P.contrast; G.uExposure.value = 1 + 0.12 * P.pulse * down; G.uFlash.value = P.flash * PUL.flash(t) * (t > bar(10) ? 0.3 : 1); G.uScan.value = P.scan; G.uRGB.value = P.rgb; G.uGlitch.value = P.glitchFrame * (0.12 + 0.9 * Math.max(0, ...WHIPS.map((w) => 1 - Math.abs(t - w.t) / 0.12))); G.uFrame.value = frame; if (hudx) { @@ -774,7 +842,7 @@ async function build(THREE) { // HUD: the timecode motif (film t, frame, bar.beat) const [bb, be] = barBeat(t); - hud.textContent = `t ${t.toFixed(2).padStart(5, "0")} · f ${String(frame).padStart(4, "0")} · ♩ ${String(Math.min(30, bb)).padStart(2, "0")}.${be}`; + const tf = t - (window.__T0 || 0), ff = Math.round(tf * FPS); hud.textContent = `t ${tf.toFixed(2).padStart(5, "0")} · f ${String(ff).padStart(4, "0")} · ♩ ${String(Math.min(30, bb)).padStart(2, "0")}.${be}`; hud.style.opacity = (t > bar(29, 2) ? 1 - seg(t, bar(29, 2), bar(30)) * 0.6 : 1 - win(t, bar(23) + 0.2, bar(27), 0.3, 0.3)).toFixed(3); }; } diff --git a/showcase/04-intro-film/js/open-overlay.js b/showcase/04-intro-film/js/open-overlay.js new file mode 100644 index 0000000..b19a2fc --- /dev/null +++ b/showcase/04-intro-film/js/open-overlay.js @@ -0,0 +1,57 @@ +// The reads (DOM, never motion-blurred), the terminal, the HUD. Same words and windows as the gate-① animatic. +import { TL, BEAT, clamp, seg, eOut, eIn, eOutExpo, eInOut } from "./open-time.js"; + +const win = (t, a, b, fi = 0.3, fo = 0.3) => Math.min(eOut(seg(t, a - fi, a)), 1 - eIn(seg(t, b, b + fo))); +export const TXT = { + t1: { en: "So many AI video tools.", zh: "AI 视频工具,多到数不清。" }, + t2: { en: "What’s missing?", zh: "差的是什么?" }, + t3: { title: "OpenVideoHarness", en: "Not more tools. Know-how.", zh: "不缺工具,缺门道。" }, + req: { en: "Make a vertical science short: why does a low-orbit satellite’s signal change pitch?", + zh: "做一个竖屏科普:为什么低轨卫星的信号会“变调”。" }, // the request showcase 02 answers (it runs 24.8 s, so no "30-second") + resp: { en: "→ type 02 of 9 · standard", zh: "→ 9 类里的 02 类 · standard" }, +}; + +export function makeOverlay(root, opts) { + const div = (id, css, html = "") => { const d = document.createElement("div"); d.id = id; d.style.cssText = css; d.innerHTML = html; root.appendChild(d); return d; }; + const EN = "'SF Pro Display', system-ui, -apple-system, sans-serif", ZH = "'PingFang SC', 'Hiragino Sans GB', sans-serif", MONO = "'SF Mono', Menlo, monospace"; + // a soft dark bed under every big read ("type wants a quiet background") + const bed = (a) => `position:absolute;left:160px;right:160px;text-align:center;pointer-events:none;padding:56px 0 64px;` + + `background:radial-gradient(ellipse at 50% 50%,rgba(4,5,8,${a}) 0%,rgba(4,5,8,${a * 0.7}) 40%,rgba(4,5,8,0) 72%);`; + const t1 = div("o-t1", bed(0.55) + "top:690px;", `
${TXT.t1.en}
+
${TXT.t1.zh}
`); + const t2 = div("o-t2", bed(0.62) + "top:330px;", `
${TXT.t2.en}
+
${TXT.t2.zh}
`); + const t3 = div("o-t3", bed(0.66) + "top:270px;", `
${TXT.t3.title}
+
${TXT.t3.en}
+
${TXT.t3.zh}
`); + const term = div("o-term", "position:absolute;left:192px;top:236px;width:1536px;height:536px;border:2px solid #5A5A63;border-radius:12px;background:rgba(8,9,11,0.94);pointer-events:none;transform-origin:50% 45%;box-shadow:0 0 120px rgba(255,178,36,0.10);", + `
+ ▍ OpenVideoHarness + ~/projects · t 00.00
+
› ▍
+
${TXT.req.zh}
+
${TXT.resp.en}
${TXT.resp.zh}
`); + const hud = div("o-hud", `position:absolute;left:40px;bottom:28px;font:400 22px ${MONO};color:#6b6b73;white-space:pre;pointer-events:none;`); + hud.style.display = opts.hud ? "block" : "none"; + const typed = term.querySelector("#typed"), caret = term.querySelector("#caret"), gloss = term.querySelector("#gloss"), resp = term.querySelector("#resp"), tt = term.querySelector("#tt"); + // a read arrives: rises 14 px, unblurs, tracks in; leaves by fading + const arrive = (el, t, a, k = 1) => { const u = eOutExpo(seg(t, a - 0.35, a + 0.5)); + el.style.transform = `translateY(${(14 * (1 - u) * k).toFixed(1)}px)`; el.style.filter = u < 0.999 ? `blur(${(6 * (1 - u)).toFixed(2)}px)` : "none"; + el.style.letterSpacing = ""; }; + return (t, tn = t) => { // t: the story's time (the terminal's hold is stretched, js/tmap.js); tn: the film's own time + t1.style.opacity = win(t, TL.t1[0], TL.t1[1]).toFixed(3); arrive(t1, t, TL.t1[0]); + t2.style.opacity = win(t, TL.t2[0], TL.t2[1]).toFixed(3); arrive(t2, t, TL.t2[0]); + t3.style.opacity = win(t, TL.t3[0], TL.t3[1], 0.3, 0.15).toFixed(3); arrive(t3, t, TL.t3[0]); + const ot = eOut(seg(t, TL.term[0], TL.term[0] + 0.45)); + const hold = 0.03 * eInOut(seg(tn, 21.3, 27.3)); // a slow push while the request holds (the story is nearly still there) + const push = eIn(seg(t, TL.enter, 23.0)); term.style.transform = `scale(${(0.97 + 0.03 * ot + hold + 0.12 * push + 1.4 * eIn(seg(t, 22.45, 23.0))).toFixed(4)})`; + term.style.opacity = (ot * (1 - seg(t, 22.72, 22.95))).toFixed(3); + const n = Math.floor(clamp((t - 19.15) / 1.85) * TXT.req.en.length); typed.textContent = TXT.req.en.slice(0, n); + caret.style.opacity = t < 21.0 || Math.floor((t - 21.0) / 0.5) % 2 === 0 ? "1" : "0"; + gloss.style.opacity = win(t, 19.6, 99, 0.3).toFixed(3); + resp.style.opacity = win(t, TL.answer, 99, 0.15).toFixed(3); + tt.textContent = tn.toFixed(2).padStart(5, "0"); + const bb = Math.floor(t / BEAT + 1e-6); + hud.textContent = `look-dev ${opts.fx} · t ${t.toFixed(2).padStart(5, "0")} · f ${String(Math.round(t * 30)).padStart(3, "0")} · ♩ ${Math.floor(bb / 4) + 1}.${(bb % 4) + 1}`; + }; +} diff --git a/showcase/04-intro-film/js/open-time.js b/showcase/04-intro-film/js/open-time.js new file mode 100644 index 0000000..b7fe81b --- /dev/null +++ b/showcase/04-intro-film/js/open-time.js @@ -0,0 +1,8 @@ +// The opening's timeline (0–23 s, 120 BPM: a bar is 2 s), shared by the reads and the terminal. +export const BEAT = 0.5, bar = (k, b = 0) => (k - 1) * 2 + b * BEAT; +export const TL = { t1: [0.6, 4.4], burst: bar(5), t2: [10.4, 13.8], reveal: bar(8), t3: [14.5, 18.8], term: [18.95, 23.0], enter: bar(12), key: 21.2, answer: 21.35 }; // Enter is pressed at 21.2; the push into the terminal still starts on bar 12 +export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); +export const seg = (t, a, b) => clamp((t - a) / (b - a)); +export const eOut = (u) => 1 - Math.pow(1 - u, 3), eIn = (u) => u * u * u; +export const eOutExpo = (u) => (u >= 1 ? 1 : 1 - Math.pow(2, -10 * u)); +export const eInOut = (u) => u * u * (3 - 2 * u); diff --git a/showcase/04-intro-film/js/pipeline.js b/showcase/04-intro-film/js/pipeline.js index 37f9b27..3119ad0 100644 --- a/showcase/04-intro-film/js/pipeline.js +++ b/showcase/04-intro-film/js/pipeline.js @@ -3,8 +3,12 @@ // Local beat b = (t - T0) / BEAT, T0 = bar(14) = 36.0 s (bar 11 is 6/4); 20 beats = 5 bars. Hits (score v3.3): // gate ① approve b4 (38.667), gate ② approve b8 (41.333); fail b10 / b11 / b12 (42.667 / 43.333 / 44.0); // pass b16 (46.667); gate ③ approve b17 (47.333); final node b18 (48.0), held through b20 (49.333). +// v5 r2: fewer, longer reads. The router and types were just shown (S5), so A–F light without labels; each gate is a +// caption in the lower third ("human review ① · outline") that gets its ✓ on the approve beat, and a door made of the +// artifact under review (this film's outline, storyboard sheet, draft sheet) that the camera flies through. +const AMB = "#FFB224", CAP = "#DCA234"; // CAP: amber type kept under the streak pass's threshold (linear max < 0.82), so captions never flare export function buildPipeline(THREE, X) { - const { scene, key, dynPlane, quiet, barMat, setAmber, sprite, glowTex, softTex, FONT_MONO, FONT_ZH, P, bar, BEAT, seg, win, lerp, eOutExpo, eOutCubic, clamp, pin, tubeMat } = X; + const { scene, key, dynPlane, quiet, barMat, gateTex, setAmber, sprite, glowTex, softTex, FONT_MONO, FONT_ZH, P, bar, BEAT, seg, win, lerp, eOutExpo, eOutCubic, clamp, pin, tubeMat } = X; const T0 = bar(14), O = new THREE.Vector3(0, 0, -345); const W = (x, y, z) => new THREE.Vector3(O.x + x, O.y + y, O.z + z); const at = (b) => T0 + b * BEAT; // local beat → film time @@ -12,29 +16,33 @@ export function buildPipeline(THREE, X) { // ---- nodes (mermaid A..K), local positions; `off` = when the label leaves (camera has moved on) ---- const N = [ - { id: "A", en: "One-line request", zh: "一句话需求", p: [-3.0, 0.2, 0], on: at(0), off: at(3.2), pl: { dy: -52, ay: "t" } }, - { id: "B", en: "CLAUDE.md router", zh: "CLAUDE.md 路由", p: [2.4, 0.6, -7], on: at(0.5), off: at(3.6), shape: "diamond", pl: { dy: -52, ay: "t" } }, - { id: "C", en: "video-types/*.md", sub: "engine · steps · taste · bans", zh: "引擎·步骤·审美·禁止项", p: [-2.8, 0.2, -14], on: at(1.0), off: at(4.2), pl: { dy: -52, ay: "t" } }, - { id: "D", en: "BRIEF + outline", zh: "BRIEF + 大纲", p: [2.6, 0.5, -21], on: at(1.5), off: at(4.6), pl: { dy: -52, ay: "t" } }, - { id: "R1", en: "human review ①", zh: "人工审阅 ①", p: [0, 1.0, -32], on: at(3.0), off: at(4.5), gate: true, pl: { dy: 120, ay: "b" } }, - { id: "E", en: "STORYBOARD + keyframe preview", zh: "STORYBOARD + 分镜预览图", p: [-2.4, 0.6, -40], on: at(4.5), off: at(7.9), pl: { dy: 52, ay: "b" } }, - { id: "R2", en: "human review ②", zh: "人工审阅 ②", p: [0, 1.0, -48], on: at(7.0), off: at(8.5), gate: true, pl: { dy: 120, ay: "b" } }, - { id: "F", en: "Sound first", sub: "voice · score · beat map", zh: "声音先行 · 配音 · 配乐 · 节拍表", p: [2.3, 0.9, -56], on: at(8.0), off: at(9.3), pl: { dy: 44, ay: "b" } }, - { id: "G", en: "Scene code,\neach frame f(t)", zh: "逐场景写 f(t) 代码", p: [-4.4, 2.6, -68], on: at(8.75), off: at(12.6), loop: 0, pl: { dx: -48, ax: "r" } }, - { id: "H", en: "Contact sheet / strip / crop", zh: "联系表 / 逐帧条 / 局部放大", p: [0, 7.0, -68], on: at(9.0), off: at(12.6), loop: 1, pl: { dy: 44, ay: "b" } }, - { id: "I", en: "Taste checklist\n· 20 items", zh: "自查清单 · 20 条", p: [4.4, 2.6, -68], on: at(9.25), off: at(12.6), loop: 2, shape: "diamond", pl: { dx: 48, ax: "l" } }, - { id: "J", en: "Draft + contact sheet", zh: "初版 + 联系表", p: [-2.2, 0.6, -80], on: at(16.25), off: at(18.2), pl: { dy: 48, ay: "b" } }, - { id: "R3", en: "human review ③", zh: "人工审阅 ③", p: [0, 1.0, -88], on: at(15.6), off: at(17.5), gate: true, pl: { dy: 120, ay: "b" } }, - { id: "K", en: "Final cut + LESSONS.md", zh: "成片 + LESSONS.md", p: [0, 1.4, -96], on: at(18), off: at(20.4), shape: "final", pl: { dy: 150, ay: "b", k: 1.2 } }, + { id: "A", nolab: 1, en: "One-line request", zh: "一句话需求", p: [-3.0, 0.2, 0], on: at(0), off: at(3.2), pl: { dy: -52, ay: "t" } }, + { id: "B", nolab: 1, en: "CLAUDE.md router", zh: "CLAUDE.md 路由", p: [2.4, 0.6, -7], on: at(0.5), off: at(3.6), shape: "diamond", pl: { dy: -52, ay: "t" } }, + { id: "C", nolab: 1, en: "video-types/*.md", sub: "engine · steps · taste · bans", zh: "引擎·步骤·审美·禁止项", p: [-2.8, 0.2, -14], on: at(1.0), off: at(4.2), pl: { dy: -52, ay: "t" } }, + { id: "D", nolab: 1, en: "BRIEF + outline", zh: "BRIEF + 大纲", p: [2.6, 0.5, -21], on: at(1.5), off: at(4.6), pl: { dy: -52, ay: "t" } }, + { id: "R1", en: "human review ① · outline", zh: "人工审阅 ① · 大纲", p: [0, 1.0, -32], on: at(3.0), capOn: at(3.55), off: at(7.0), gate: true, pl: { dy: 120, ay: "b" } }, + { id: "E", nolab: 1, en: "STORYBOARD + keyframe preview", zh: "STORYBOARD + 分镜预览图", p: [-2.4, 0.6, -40], on: at(4.5), off: at(7.9), pl: { dy: 52, ay: "b" } }, + { id: "R2", en: "human review ② · storyboard", zh: "人工审阅 ② · 分镜", p: [0, 1.0, -48], on: at(7.0), capOn: at(7.0), off: at(8.95), gate: true, pl: { dy: 120, ay: "b" } }, // off before the loop's labels come in + { id: "F", nolab: 1, en: "Sound first", sub: "voice · score · beat map", zh: "声音先行 · 配音 · 配乐 · 节拍表", p: [2.3, 0.9, -56], on: at(8.0), off: at(9.3), pl: { dy: 44, ay: "b" } }, + { id: "G", en: "Scene code · f(t)", zh: "逐场景写代码", p: [-4.4, 2.6, -68], on: at(8.75), labAt: at(9.0), off: at(12.6), loop: 0, pl: { dx: -48, ax: "r" } }, + { id: "H", en: "Contact sheets", zh: "看联系表", p: [0, 7.0, -68], on: at(9.0), labAt: at(9.45), off: at(12.6), loop: 1, pl: { dy: -64, ay: "t" } }, // inside the loop: clear of the frame edge + { id: "I", en: "20-item checklist", zh: "20 条自查清单", p: [4.4, 2.6, -68], on: at(9.25), labAt: at(9.9), off: at(12.6), loop: 2, shape: "diamond", pl: { dx: 48, ax: "l" } }, + { id: "J", nolab: 1, en: "Draft + contact sheet", zh: "初版 + 联系表", p: [-2.2, 0.6, -80], on: at(16.25), off: at(18.2), pl: { dy: 48, ay: "b" } }, + { id: "R3", en: "human review ③ · draft", zh: "人工审阅 ③ · 初版", p: [0, 1.0, -88], on: at(15.6), capOn: at(16.85), off: at(18.55), gate: true, pl: { dy: 120, ay: "b" } }, + { id: "K", en: "Final cut + LESSONS.md", zh: "成片 + LESSONS.md", p: [0, 1.4, -96], on: at(18), labAt: at(18.62), off: at(20.8), shape: "final", pl: { dy: 270, ay: "b", k: 1.45 } }, ]; const NID = Object.fromEntries(N.map((n) => [n.id, n])); - const AMB = "#FFB224"; N.forEach((n) => { const g = new THREE.Group(); g.position.set(...n.p); root.add(g); n.g = g; n.w = W(...n.p); const mat = barMat(0.35); n.mat = mat; if (n.gate) { const Wd = 17, Hd = 9.5, T = 0.09; [[0, Hd / 2, Wd, T], [0, -Hd / 2, Wd, T], [-Wd / 2, 0, T, Hd], [Wd / 2, 0, T, Hd]].forEach(([x, y, w, h]) => { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, T), mat); b.position.set(x, y, 0); g.add(b); }); + const tx = gateTex && gateTex[n.id]; + if (tx) { // the door is the artifact under review; the camera flies through it once it is approved + const art = new THREE.Mesh(new THREE.PlaneGeometry(Wd - 0.5, (Wd - 0.5) * 9 / 16), new THREE.MeshBasicMaterial({ map: tx, color: new THREE.Color().setScalar(0.75), transparent: true, opacity: 0, depthWrite: false, toneMapped: false, fog: false })); + art.position.z = -0.15; art.renderOrder = 1; g.add(art); n.art = art; + } } else { const sides = n.shape === "diamond" ? 4 : 6, R = n.shape === "final" ? 1.3 : 0.62; const pts = []; for (let k = 0; k <= sides; k++) { const a = (k / sides) * Math.PI * 2 + (sides === 4 ? 0 : Math.PI / 6); pts.push(new THREE.Vector3(Math.cos(a) * R, Math.sin(a) * R, 0)); } @@ -44,16 +52,45 @@ export function buildPipeline(THREE, X) { } const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: softTex, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, toneMapped: false, opacity: 0 })); halo.scale.setScalar(n.gate ? 4 : n.shape === "final" ? 2.0 : 2.4); g.add(halo); n.halo = halo; + if (n.shape === "final" && gateTex && gateTex.K) { // the end of the line is the film the request asked for: showcase 02, playing (9:16) + const scr = new THREE.Mesh(new THREE.PlaneGeometry(2.48, 4.4), new THREE.MeshBasicMaterial({ map: gateTex.K, color: new THREE.Color().setScalar(1.05), transparent: true, opacity: 0, toneMapped: false, fog: false })); + scr.position.set(0, 0.2, -0.4); scr.renderOrder = 1; g.add(scr); n.screen = scr; + const fr = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(2.58, 4.5)), new THREE.LineBasicMaterial({ color: new THREE.Color(1.6, 1.1, 0.25), transparent: true, opacity: 0, toneMapped: false, fog: false })); + fr.position.copy(scr.position); g.add(fr); n.screenFrame = fr; + } // label: screen-pixel sized (English ≥ 52 px, Chinese ≥ 46 px), pinned beside its node + if (n.nolab) { n.lab = null; return; } + if (n.gate) { // caption: the same words twice, the second with its ✓ in amber (crossfaded on the approve beat) + const cap = (ok) => { const m = quiet(dynPlane([{ text: (ok ? "✓ " : "○ ") + n.en, size: 58, weight: 600, color: ok ? CAP : "#EDEDEF", track: 0 }, + { text: n.zh, size: 50, weight: 500, font: FONT_ZH, color: ok ? "#DCC08A" : "#D6D6DC" }], { pxPerUnit: 100, lineGap: 0.26, decodeDur: 0.1 }), 1.9); scene.add(m); return m; }; + n.lab = cap(false); n.lab2 = cap(true); return; + } const lines = n.en.split("\n").map((tx) => ({ text: tx, size: 56, weight: 600, color: "#EDEDEF", track: -0.01, font: n.en.includes("/*") || n.en.includes(".md") && !n.en.includes("+") ? FONT_MONO : undefined })); - if (n.sub) lines.push({ text: n.sub, size: 44, weight: 400, color: "#C4C4CC" }); + if (n.sub) lines.push({ text: n.sub, size: 48, weight: 400, color: "#C4C4CC" }); lines.push({ text: n.zh, size: 48, weight: 500, font: FONT_ZH, color: "#D6D6DC" }); - n.lab = quiet(dynPlane(lines, { pxPerUnit: 100, lineGap: 0.26 }), 1.16); scene.add(n.lab); + n.lab = quiet(dynPlane(lines, { pxPerUnit: 100, lineGap: 0.26, decodeDur: 0.1 }), 1.4); scene.add(n.lab); }); // gate stamps sit in the lower third of the portal, clear of the flash at its centre; loop edge labels - const stamp = (txt, size = 60) => { const m = quiet(dynPlane([{ text: txt, size, weight: 600, font: FONT_ZH, color: AMB, track: 0.03 }], { pxPerUnit: 100 }), 1.3); scene.add(m); return m; }; - ["R1", "R2", "R3"].forEach((id) => { NID[id].stamp = stamp("✓ approved · 通过"); NID[id].stampAt = W(NID[id].p[0] + (id === "R3" ? 4.5 : 0), NID[id].p[1] - (id === "R3" ? 2.9 : 0), NID[id].p[2]); NID[id].stampPl = id === "R3" ? {} : { dy: -40, ay: "t" }; }); - const failLab = stamp("✕ fail · 不合格", 58), passLab = stamp("pass · 通过", 58); + const stamp = (txt, size = 60) => { const m = quiet(dynPlane([{ text: txt, size, weight: 600, font: FONT_ZH, color: CAP, track: 0.03 }], { pxPerUnit: 100 }), 1.5); scene.add(m); return m; }; + const failLabs = [1, 2, 3].map((k) => stamp(`✕ fail · 不合格 ×${k}`, 58)), passLab = stamp("✓ pass · 通过", 58); + // inside the loop: nine frames of 02 (its contact sheet, 3×3); each fail turns one frame red with an ✕, the pass turns + // the three amber with a ✓ (the error sounds and the pass get something to see) + const sheet = gateTex && gateTex.LOOP ? new THREE.Group() : null, marks = []; + if (sheet) { + const SW = 2.05, SH = SW * 1008 / 588; + const pl = new THREE.Mesh(new THREE.PlaneGeometry(SW, SH), new THREE.MeshBasicMaterial({ map: gateTex.LOOP, color: new THREE.Color().setScalar(0.9), transparent: true, opacity: 0, depthWrite: false, toneMapped: false, fog: false })); + pl.renderOrder = 2; sheet.add(pl); sheet.userData.plane = pl; + [1, 5, 6].forEach((ti) => { // the tiles that fail: px rects in tools/request_tex.py (12 + c·192, 12 + r·332, 180×320 of 588×1008) + const c = ti % 3, r = Math.floor(ti / 3), cx = ((12 + c * 192 + 90) / 588 - 0.5) * SW, cy = (0.5 - (12 + r * 332 + 160) / 1008) * SH; + const tw = 180 / 588 * SW + 0.04, th = 320 / 1008 * SH + 0.04; + const box = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(tw, th)), new THREE.LineBasicMaterial({ color: 0xff3b30, transparent: true, opacity: 0, depthTest: false, toneMapped: false, fog: false })); + box.position.set(cx, cy, 0.02); box.renderOrder = 3; sheet.add(box); + const x = quiet(dynPlane([{ text: "✕", size: 90, weight: 700, color: "#FF5A4F" }], { pxPerUnit: 160 }), 1.2), ok = quiet(dynPlane([{ text: "✓", size: 90, weight: 700, color: CAP }], { pxPerUnit: 160 }), 1.2); + [x, ok].forEach((m) => { m.position.set(cx, cy, 0.04); sheet.add(m); }); + marks.push({ box, x, ok }); + }); + sheet.position.set(0, 2.6, -68 + 0.6); root.add(sheet); // the loop's centre (LC below), just in front + } const LC = new THREE.Vector3(0, 2.6, -68), LR = 4.4; // loop G(left) → H(top) → I(right) → bottom arc → G const failAt = W(LC.x, LC.y - LR, LC.z), passAt = W(3.4, 0.6, -74); @@ -70,7 +107,7 @@ export function buildPipeline(THREE, X) { m.userData.src = src; root.add(m); return m; }); const head = sprite(glowTex, 0.9); root.add(head); const title = quiet(dynPlane([{ text: "How it works", size: 150, weight: 600, color: "#EDEDEF", track: -0.025 }, { text: "它是怎么工作的", size: 93, weight: 500, font: FONT_ZH, color: "#D6D6DC" }])); - const rule = quiet(dynPlane([{ text: "Review every scene against the checklist,", size: 120, weight: 600, color: "#EDEDEF", track: -0.02 }, { text: "and fix it until it passes.", size: 120, weight: 600, color: "#EDEDEF", track: -0.02 }, { text: "每个场景都要自查,对着清单改到合格为止。", size: 76, weight: 500, font: FONT_ZH, color: "#D6D6DC" }], { lineGap: 0.3 })); + const rule = quiet(dynPlane([{ text: "Check every scene.", size: 130, weight: 600, color: "#EDEDEF", track: -0.02 }, { text: "Fix until it passes.", size: 130, weight: 600, color: "#EDEDEF", track: -0.02 }, { text: "每个场景都自查,改到合格为止。", size: 80, weight: 500, font: FONT_ZH, color: "#D6D6DC" }], { lineGap: 0.3 })); scene.add(title); scene.add(rule); // ---- energy timeline (local beats) ---- @@ -93,13 +130,13 @@ export function buildPipeline(THREE, X) { [0, [0, 3.0, 12], [0, 1.0, -8], 44], [1.6, [8.05, 6.00, -2], [-0.5, 0.6, -20], 44], [3.4, [4.14, 4.80, -18.5], [0, 1.6, -34], 44], [4.3, [2.76, 4.60, -24.5], [0, 1.6, -38], 44], [5.2, [7.82, 5.80, -33], [-1.0, 0.6, -46], 44], [7.4, [3.68, 4.60, -38.5], [0, 1.6, -50], 44], [8.6, [3.3, 4.4, -44.5], [0.6, 1.8, -58], 44, "b"], [9.3, [0.69, 4.40, -52.6], [0, 2.9, -68], 46, "f"], - [12.1, [0.42, 4.30, -54.4], [0, 2.8, -68], 46], [13.3, [15.5, 25, -16], [0, -0.5, -52], 50], [15.2, [12.5, 22, -28], [0, -0.5, -58], 50], + [12.1, [0.42, 4.30, -54.4], [0, 2.8, -68], 46, "b"], // "b": leaves the loop hold on its own tangent (no lunge in and back before the crane) [13.3, [15.5, 25, -16], [0, -0.5, -52], 50], [15.2, [12.5, 22, -28], [0, -0.5, -58], 50], [16.5, [6.4, 9.0, -64], [0, 1.0, -88], 46], [17.0, [5.2, 6.6, -71], [0, 1.0, -90], 44], [18.2, [2.3, 4.6, -85.2], [0, 1.6, -96], 42], [19.8, [2.15, 4.45, -86.4], [0, 1.6, -96], 42], ]; C.forEach(([b, p, l, f, tm]) => key(at(b), W(...p), W(...l), f, false, tm || null)); const whips = [[at(0.8), 0.4], [at(4.8), 0.42], [at(8.95), 0.3], [at(17.6), 0.35]]; // no whip under the rule line: a slow crane instead const samples = []; for (let z = 10; z > -100; z -= 1.5) samples.push(W(0, 1, z)); - const LABELS = [...N.map((n) => n.lab), ...["R1", "R2", "R3"].map((id) => NID[id].stamp), failLab, passLab]; + const LABELS = [...N.flatMap((n) => [n.lab, n.lab2]).filter(Boolean), ...failLabs, passLab]; const _sd = new THREE.Vector3(); function drawDyn(m, age, frame, decode = true) { @@ -116,18 +153,26 @@ export function buildPipeline(THREE, X) { setAmber(n.mat, powered ? 1.1 + (n.shape === "final" ? 1.0 : 1.6) * flash : 0.25); if (n.line) n.line.material.color.setRGB(powered ? 1.6 : 0.35, powered ? 1.1 : 0.35, powered ? 0.25 : 0.38); n.halo.material.opacity = powered ? 0.05 + (n.shape === "final" ? 0.16 : 0.25) * flash + 0.35 * pano : 0; - const L = n.lab, near = pin(L, n.w, n.pl); - const o = (powered ? eOutCubic(seg(age, 0, 0.18)) : 0) * (1 - seg(t, n.off - 0.3, n.off)) * near; - L.material.opacity = o; L.visible = o > 0.002; L.userData.quiet.material.opacity = 0.6 * o; - if (L.visible) drawDyn(L, age, frame); - if (n.stamp) { - const s = n.stamp, a2 = t - GATES[n.id]; - // the approval stamp rides the view (lower third) so it stays sharp even when the camera whips on to the next node - const sa = ctx.camera.getWorldDirection(_sd).multiplyScalar(10).add(ctx.camera.position); - const so = a2 >= -0.5 / 30 ? (1 - seg(a2, 0.5, 0.65)) * pin(s, sa, { dy: -300, k: lerp(1.25, 1, eOutExpo(seg(a2, 0, 0.3))) }) : 0; - s.material.opacity = so; s.visible = so > 0.002; s.userData.quiet.material.opacity = 0.85 * so; if (s.visible) drawDyn(s, a2, frame, false); + if (n.gate) { + const a2 = t - GATES[n.id]; + if (a2 >= 0) setAmber(n.mat, 0.95 + 0.7 * Math.exp(-a2 / 0.2)); else if (powered) setAmber(n.mat, 0.8); + // the door: the artifact fills the frame as the camera comes up to it, and thins out before the lens passes through + if (n.art) { const d = n.w.distanceTo(ctx.camera.position), aa = t - (n.id === "R1" ? at(3.5) : n.on); n.art.material.opacity = (aa >= 0 ? eOutCubic(seg(aa, 0, 0.4)) : 0) * 0.5 * clamp((d - 10) / 8) * (a2 >= 0 ? 1 + 0.2 * Math.exp(-a2 / 0.25) : 1); n.art.visible = n.art.material.opacity > 0.002; } + // caption in the lower third, riding the view; pending (○) until the approve beat, then ✓ in amber + const ca = t - n.capOn, sa = ctx.camera.getWorldDirection(_sd).multiplyScalar(10).add(ctx.camera.position); + const co = ca >= 0 ? eOutCubic(seg(ca, 0, 0.2)) * (1 - seg(t, n.off - 0.3, n.off)) : 0, ok = a2 >= -0.5 / 30 ? 1 : 0; // a cut, not a crossfade (no double image) + [[n.lab, 1 - ok], [n.lab2, ok]].forEach(([m, w]) => { + const o2 = co * w * pin(m, sa, { dy: -330, k: m === n.lab2 ? lerp(1.12, 1, eOutExpo(seg(a2, 0, 0.3))) : 1 }); + m.material.opacity = o2; m.visible = o2 > 0.002; m.userData.quiet.material.opacity = 0.97 * co; if (m.visible) drawDyn(m, 9, frame, false); + }); + return; } - if (n.gate) { const a2 = t - GATES[n.id]; if (a2 >= 0) setAmber(n.mat, 0.95 + 1.1 * Math.exp(-a2 / 0.2)); else if (powered) setAmber(n.mat, 0.8); } + if (n.screen) { const so = (powered ? eOutCubic(seg(age, 0, 0.5)) : 0) * (1 - seg(t, n.off - 0.3, n.off)); n.screen.material.opacity = so; n.screenFrame.material.opacity = 0.8 * so; n.screen.visible = n.screenFrame.visible = so > 0.002; } + if (!n.lab) return; + const L = n.lab, near = pin(L, n.w, n.pl), la = t - (n.labAt || n.on); + const o = (la >= 0 ? eOutCubic(seg(la, 0, 0.22)) : 0) * (1 - seg(t, n.off - 0.3, n.off)) * near; + L.material.opacity = o; L.visible = o > 0.002; L.userData.quiet.material.opacity = 0.96 * o; + if (L.visible) drawDyn(L, la, frame, false); }); // conduits + the head of the energy [tube1, tube2, tubeL].forEach((m) => { m.material.uniforms.uGain.value = 1 + 1.6 * pano; }); @@ -144,15 +189,27 @@ export function buildPipeline(THREE, X) { // fail label: on from the first fail to the rule line, a pop on each fail (never flashes out mid-read) let pop = 0; FAILS.forEach((h) => { const u = t - h; if (u >= 0 && u < 0.5) pop = Math.max(pop, 1 - u / 0.5); }); const fo = (t >= FAILS[0] - 1 / 30 ? win(t, FAILS[0] - 1 / 30, at(12.7), 0.06, 0.3) : 0); - const fa = fo * pin(failLab, failAt, { dy: -90, ay: "t", k: 1 + 0.12 * pop }); - failLab.material.opacity = fa; failLab.visible = fa > 0.002; failLab.userData.quiet.material.opacity = 0.85 * fa; if (fa) drawDyn(failLab, 9, frame, false); + const fk = FAILS.filter((f) => t >= f - 1 / 30).length - 1; // which count is showing (×1, ×2, ×3) + failLabs.forEach((m, k) => { + const fa = k === Math.max(0, fk) ? fo * pin(m, failAt, { dy: -150, ay: "t", k: 1 + 0.12 * pop }) : 0; // below the loop's bottom arc + m.material.opacity = fa; m.visible = fa > 0.002; m.userData.quiet.material.opacity = 0.9 * fa; if (fa) drawDyn(m, 9, frame, false); + }); if (pop) setAmber(NID.I.mat, 1.15 + 1.8 * pop); + if (sheet) { // the 3×3 of 02 inside the loop: from the loop lighting up to the rule line's end + const so = win(t, at(8.9), at(16.6), 0.3, 0.4); sheet.userData.plane.material.opacity = 0.9 * so; sheet.visible = so > 0.002; + marks.forEach((mk, k) => { + const failed = t >= FAILS[k] - 1 / 30, passed = t >= PASS - 1 / 30, fp = failed ? Math.exp(-Math.max(0, t - FAILS[k]) / 0.25) : 0; + mk.box.material.opacity = so * (failed ? 1 : 0); mk.box.material.color.setRGB(passed ? 1.0 : 1.0 + 1.2 * fp, passed ? 0.7 : 0.23, passed ? 0.14 : 0.19); + mk.x.material.opacity = so * (failed && !passed ? 1 : 0); mk.ok.material.opacity = so * (passed ? 1 : 0); + [mk.x, mk.ok].forEach((m) => { m.visible = m.material.opacity > 0.002; m.userData.quiet.material.opacity = 0.5 * m.material.opacity; if (m.visible) drawDyn(m, 9, frame, false); }); + }); + } // "pass" rides the view like the approval stamps (same place, in sequence: pass → approved ③ → final node) - const pa = (t >= PASS - 1 / 30 ? win(t, PASS - 1 / 30, PASS + 0.62, 0.05, 0.12) : 0) * pin(passLab, ctx.camera.getWorldDirection(_sd).multiplyScalar(10).add(ctx.camera.position), { dy: -300, k: lerp(1.25, 1, eOutExpo(seg(t - PASS, 0, 0.25))) }); - passLab.material.opacity = pa; passLab.visible = pa > 0.002; passLab.userData.quiet.material.opacity = 0.85 * pa; if (pa) drawDyn(passLab, 9, frame, false); + const pa = (t >= PASS - 1 / 30 ? win(t, PASS - 1 / 30, at(16.75), 0.05, 0.12) : 0) * pin(passLab, ctx.camera.getWorldDirection(_sd).multiplyScalar(10).add(ctx.camera.position), { dy: -300, k: lerp(1.25, 1, eOutExpo(seg(t - PASS, 0, 0.25))) }); + passLab.material.opacity = pa; passLab.visible = pa > 0.002; passLab.userData.quiet.material.opacity = 0.92 * pa; if (pa) drawDyn(passLab, 9, frame, false); // heroes (camera-stable, in the world) - X.slamView(title, t, T0 + 0.1, at(3.3), 11, 0.42, -0.42, 0.62); - X.slamView(rule, t, at(12.3), at(15.8), 11, 0.72, 0, 0.62); + X.slamView(title, t, T0 + 0.1, at(3.45), 11, 0.42, -0.42, 0.62); + X.slamView(rule, t, at(12.65), at(15.8), 11, 0.72, 0, 0.36, 1); // no scramble: it lands where the loop's labels just were if (rule.visible) rule.userData.quiet.material.opacity = 0.9 * rule.material.opacity; // inside the title-safe frame; the ring passes behind a scrim } return { whips, samples, update, root, heroes: [title, rule], labels: LABELS, diff --git a/showcase/04-intro-film/js/tmap.js b/showcase/04-intro-film/js/tmap.js new file mode 100644 index 0000000..153a769 --- /dev/null +++ b/showcase/04-intro-film/js/tmap.js @@ -0,0 +1,12 @@ +// The film's time map (review rounds 3–5: every read must hold long enough, TASTE_CHECKLIST #5). +// The picture is written on the "old" 87.5 s timeline; 26 holds are stretched to give their reads time: +// [a, b, add] in old film seconds: the hold a..b plays over (b − a + add) s and everything after it moves later by add. +// The music has the same map (audio/score.json: the bar that holds a stretch is longer by add / beat, its added beats +// are a held breath, drums out; the opening sketch plays its bar 11 four times for the first one), and so do the SFX +// (audio/events.json). Ambient motion (sparks, the films' playback, the hand-held drift) runs on the new time, +// so a stretched hold slows the story, never the world. +window.__STRETCH = [[21.3, 22.0, 6.0], [24.75, 25.4, 3.75], [29.25, 29.975, 2.25], [31.1, 31.95, 2.25], [32.5, 32.975, 3.0], [34.3, 35.45, 3.0], [37.4, 38.3, 1.5], [39.52, 39.72, 1.5], [42.54, 42.95, 1.5], [45.52, 45.575, 2.25], [46.475, 47.9, 2.25], [48.54, 49.02, 2.25], [49.29, 49.55, 2.25], [50.375, 51.35, 2.25], [52.4, 53.975, 2.25], [56.825, 56.975, 4.5], [59.79, 59.975, 3.0], [62.6, 62.975, 2.25], [64.625, 66.125, 2.25], [66.95, 68.4125, 1.5], [69.5, 70.6625, 1.5], [71.45, 72.5, 3.0], [74.8, 75.1625, 1.5], [76.625, 78.125, 1.5], [80.825, 81.425, 1.5], [85.5, 87.0, 2.25]]; +window.__OLD_DUR = 87.5; +window.__tNew = (o) => { let s = 0; for (const [a, b, add] of window.__STRETCH) { if (o < a) return o + s; if (o < b) return a + s + (o - a) * (b - a + add) / (b - a); s += add; } return o + s; }; +window.__tOld = (f) => { let s = 0; for (const [a, b, add] of window.__STRETCH) { const na = a + s, nb = b + s + add; if (f < na) return f - s; if (f < nb) return a + (f - na) * (b - a) / (b - a + add); s += add; } return f - s; }; +window.__NEW_DUR = window.__tNew(window.__OLD_DUR); diff --git a/showcase/04-intro-film/media/final.mp4 b/showcase/04-intro-film/media/final.mp4 index 438e765..7af23d3 100644 Binary files a/showcase/04-intro-film/media/final.mp4 and b/showcase/04-intro-film/media/final.mp4 differ diff --git a/showcase/04-intro-film/media/poster.png b/showcase/04-intro-film/media/poster.png index 8cf6b95..7a9848c 100644 Binary files a/showcase/04-intro-film/media/poster.png and b/showcase/04-intro-film/media/poster.png differ diff --git a/showcase/04-intro-film/media/sheet.jpg b/showcase/04-intro-film/media/sheet.jpg new file mode 100644 index 0000000..3489e13 Binary files /dev/null and b/showcase/04-intro-film/media/sheet.jpg differ diff --git a/showcase/04-intro-film/opening/films.js b/showcase/04-intro-film/opening/films.js new file mode 100644 index 0000000..48a31f8 --- /dev/null +++ b/showcase/04-intro-film/opening/films.js @@ -0,0 +1,457 @@ +// The films on the waterfall cards: 28 procedural shots (cosmos, glass, landscapes …) rendered once into an atlas at build, +// plus assets/films.jpg (41 cuts of this repo's own swatches and showcases, tools/film_atlas.py). +// Atlas layout for both: one film per row, 16 frames per row, 256x144 per tile. +// Procedural shots loop seamlessly over their 16 frames (all motion is periodic in ph = 2π·k/16); real cuts ping-pong. +import * as THREE from "three"; + +export const TW = 256, TH = 144, NF = 16, FPS = 8; +export const PROC = ["nebula", "galaxy", "blackhole", "planet", "glass", "prism", "aurora", "ocean", "dunes", "bokeh", + "fire", "ink", "geode", "sun", "warp", "forest", "chrome", "bloom", "underwater", "lake", "jelly", "storm", "snow", + "trails", "mesh", "rain", "twinsuns", "earth"]; + +const FRAG = /* glsl */ ` +precision highp float; +uniform float uFilm; uniform float uK; varying vec2 vUv; +#define PI 3.14159265 +#define TAU 6.2831853 +float h21(vec2 p){ vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } +vec2 h22(vec2 p){ vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.xx + p3.yz) * p3.zy); } +float h31(vec3 p){ p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); } +float vn(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3. - 2. * f); + return mix(mix(h21(i), h21(i + vec2(1, 0)), u.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), u.x), u.y); } +float vn3(vec3 p){ vec3 i = floor(p), f = fract(p); vec3 u = f * f * (3. - 2. * f); + float a = mix(mix(h31(i), h31(i + vec3(1,0,0)), u.x), mix(h31(i + vec3(0,1,0)), h31(i + vec3(1,1,0)), u.x), u.y); + float b = mix(mix(h31(i + vec3(0,0,1)), h31(i + vec3(1,0,1)), u.x), mix(h31(i + vec3(0,1,1)), h31(i + vec3(1,1,1)), u.x), u.y); + return mix(a, b, u.z); } +float fbm(vec2 p){ float a = .5, s = 0.; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); for (int i = 0; i < 6; i++) { s += a * vn(p); p = m * p; a *= .5; } return s; } +float fbm3(vec3 p){ float a = .5, s = 0.; for (int i = 0; i < 5; i++) { s += a * vn3(p); p = p * 2.03 + vec3(1.7, 9.2, 3.1); a *= .5; } return s; } +mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, -s, s, c); } +vec3 hue(float h){ return clamp(abs(mod(h * 6. + vec3(0, 4, 2), 6.) - 3.) - 1., 0., 1.); } +float stars(vec2 p, float d, float ph, float sd){ vec2 g = p * d; vec2 i = floor(g), f = fract(g); vec2 o = h22(i + sd); + float r = length(f - o); float b = pow(h21(i + 7.1 + sd), 9.) * (0.75 + 0.25 * sin(ph + h21(i) * 30.)); return b * smoothstep(0.08, 0.0, r) * 2.2; } +float tri(vec2 p, float r){ const float k = sqrt(3.0); p.x = abs(p.x) - r; p.y = p.y + r / k; if (p.x + k * p.y > 0.) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.; p.x -= clamp(p.x, -2. * r, 0.); return -length(p) * sign(p.y); } + +vec3 nebula(vec2 p, vec2 c, float ph){ + vec2 q = p * 1.5 + c * 0.12; float w = fbm(q * 1.3 + 3.1); + float n = fbm(q + w * 1.4), m = fbm(q * 2.2 - 4.0 + w); + vec3 col = mix(vec3(0.01, 0.015, 0.04), vec3(0.06, 0.18, 0.36), smoothstep(0.32, 0.78, n)); + col = mix(col, vec3(0.95, 0.42, 0.16), smoothstep(0.52, 0.86, m) * smoothstep(0.38, 0.72, n)); + col += vec3(1.0, 0.85, 0.7) * pow(smoothstep(0.55, 0.95, n * m * 1.7), 3.) * 1.1; + return col + stars(p, 40., ph, 0.) + stars(p, 90., ph * 2., 3.) * 0.6; +} +vec3 galaxy(vec2 p, float ph){ + p *= rot(0.4); p.y *= 2.1; float r = length(p), a = atan(p.y, p.x) + ph * 0.5; + float arm = pow(max(0.5 + 0.5 * cos(2. * (a - log(r + 0.02) * 2.6)), 0.), 3.); + float dust = fbm(p * 7.0 + 2.0); + float d = exp(-r * 4.2) * (0.35 + 1.4 * arm * smoothstep(0.02, 0.2, r)) * (0.6 + 0.6 * dust); + vec3 col = mix(vec3(0.35, 0.55, 1.0), vec3(1.0, 0.82, 0.6), exp(-r * 6.0)) * d * 2.2; + col += vec3(1.0, 0.9, 0.75) * exp(-r * r * 260.) * 2.5; + col *= 1. - 0.6 * smoothstep(0.5, 0.75, dust) * arm * smoothstep(0.05, 0.3, r); + return col + stars(p * vec2(1., 0.48), 50., ph, 1.); +} +vec3 blackhole(vec2 p, float ph){ + float r = length(p); vec2 q = p * vec2(1., 3.6); float e = length(q), a = atan(q.y, q.x); + float turb = fbm(vec2(cos(a + ph * 0.5), sin(a + ph * 0.5)) * 2.5 + e * 5.); + float disk = smoothstep(0.85, 0.42, e) * smoothstep(0.24, 0.34, e) * (0.5 + 0.9 * turb); + float dop = 1.0 - 0.75 * clamp(p.x / max(e, 0.05) * 1.2, -1., 1.); + vec3 dc = mix(vec3(1.0, 0.45, 0.12), vec3(1.0, 0.92, 0.8), smoothstep(0.6, 0.3, e)) * disk * dop * 1.6; + float arc = exp(-pow((r - 0.255) / 0.035, 2.)) * (0.55 + 0.6 * turb) * smoothstep(-0.12, 0.1, p.y); + float ring = exp(-pow((r - 0.205) / 0.008, 2.)) * 1.4; + float shadow = smoothstep(0.19, 0.205, r); + vec2 lp = p * (1. + 0.05 / max(r * r, 0.01)); + vec3 bg = stars(lp, 45., ph, 2.) * vec3(0.8, 0.9, 1.) + vec3(0.03, 0.04, 0.08) * fbm(lp * 3.); + vec3 col = bg * shadow + (vec3(1.0, 0.7, 0.4) * arc + vec3(1.0, 0.85, 0.65) * ring) * 1.4; + float front = step(p.y, 0.0); + col = mix(col * shadow, col, 0.) + dc * mix(shadow, 1.0, front); + return col; +} +vec3 planet(vec2 p, float ph){ + vec3 col = vec3(0.005, 0.008, 0.02) + stars(p, 60., ph, 4.); + vec2 c = vec2(0.22, -0.08); float R = 0.42; vec2 d = (p - c) / R; float rr = dot(d, d); + vec2 rp = (p - c) * rot(-0.35); float re = length(rp * vec2(1., 4.2)) / R; + float ringB = smoothstep(1.35, 1.4, re) * smoothstep(2.25, 2.15, re) * (0.55 + 0.45 * sin(re * 60.)) * (0.6 + 0.4 * vn(vec2(re * 40., 0.))); + vec3 ringC = vec3(0.85, 0.75, 0.6) * ringB * 0.9; + if (rp.y > 0.0) col += ringC; // far half of the ring, behind the planet + if (rr < 1.0) { + vec3 n = vec3(d, sqrt(1. - rr)); vec3 L = normalize(vec3(-0.8, 0.35, 0.45)); + float lat = n.y, lon = atan(n.x, n.z) + 0.15 * sin(ph); + float bands = fbm(vec2(lat * 9. + 0.3 * fbm(vec2(lon * 3., lat * 20.)), lon * 1.2)); + vec3 base = mix(vec3(0.55, 0.38, 0.24), vec3(0.92, 0.8, 0.62), bands); + float dif = max(dot(n, L), 0.); col = base * (0.02 + 1.2 * pow(dif, 0.8)); + col += vec3(0.4, 0.6, 1.0) * pow(1. - n.z, 3.) * dif * 0.8; + float sh = smoothstep(0.02, -0.02, abs(rp.y * 4.2 / R + 0.0) - 0.05) * 0.; + col *= 1. - sh; + } + col += vec3(0.4, 0.6, 1.0) * exp(-pow((sqrt(rr) - 1.0) / 0.05, 2.)) * 0.6 * smoothstep(0.2, -0.6, d.x); + if (rp.y <= 0.0 || rr >= 1.0) col += ringC * step(rp.y, 0.0); + return col; +} +float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0., 1.); return mix(b, a, h) - k * h * (1. - h); } +float blobs(vec3 q, float ph){ float d = 1e9; + for (int i = 0; i < 4; i++) { float fi = float(i); + vec3 c = vec3(cos(ph + fi * 1.7) * 0.55, sin(ph + fi * 2.3) * 0.32, sin(ph + fi * 1.1) * 0.25); + d = smin(d, length(q - c) - (0.3 + 0.06 * fi), 0.32); } return d; } +vec3 studio(vec3 d){ float a = smoothstep(0.6, 0.95, d.y) * 1.8 + smoothstep(0.3, 0.0, abs(d.x - 0.7)) * smoothstep(-0.2, 0.3, d.y) * 1.4; + return vec3(0.02, 0.025, 0.04) + vec3(1.0, 0.82, 0.62) * a + vec3(0.25, 0.45, 0.9) * smoothstep(0.2, -0.9, d.x) * 0.5; } +vec3 glass(vec2 p, float ph){ + vec3 ro = vec3(0., 0., -2.6), rd = normalize(vec3(p, 1.5)); float t = 0.; float hit = 0.; + for (int i = 0; i < 56; i++) { float d = blobs(ro + rd * t, ph); if (d < 0.002) { hit = 1.; break; } t += d; if (t > 6.) break; } + vec3 bg = mix(vec3(0.03, 0.035, 0.05), vec3(0.12, 0.09, 0.08), smoothstep(-0.6, 0.5, p.y)) + vec3(1.0, 0.75, 0.5) * exp(-pow((p.x - 0.55) / 0.08, 2.)) * 0.25; + if (hit < 0.5) return bg; + vec3 pos = ro + rd * t; vec2 e = vec2(0.002, 0.); + vec3 n = normalize(vec3(blobs(pos + e.xyy, ph) - blobs(pos - e.xyy, ph), blobs(pos + e.yxy, ph) - blobs(pos - e.yxy, ph), blobs(pos + e.yyx, ph) - blobs(pos - e.yyx, ph))); + float fr = pow(1. - max(dot(-rd, n), 0.), 3.); + vec3 rc = vec3(studio(refract(rd, n, 0.62)).r, studio(refract(rd, n, 0.66)).g, studio(refract(rd, n, 0.70)).b); + vec3 col = rc * vec3(0.75, 0.88, 1.0) + studio(reflect(rd, n)) * (0.08 + 0.9 * fr); + return col + pow(max(dot(reflect(rd, n), normalize(vec3(0.5, 0.8, -0.3))), 0.), 80.) * 3.; +} +vec3 prism(vec2 p, float ph){ + vec3 col = vec3(0.012, 0.012, 0.018); + float d = tri(p * rot(0.05 * sin(ph)), 0.2); + float beam = exp(-pow((p.y - 0.12 * (p.x + 0.9) * 0.15 - 0.0) / 0.012, 2.)) * step(p.x, -0.1) * 2.2; + float ang = atan(p.y + 0.02, p.x - 0.08); + float fan = step(0.08, p.x) * smoothstep(0.55, 0.0, abs(ang + 0.12) - 0.0) * smoothstep(-0.38, -0.26, ang) * smoothstep(0.12, 0.0, ang); + vec3 spec = hue(clamp((ang + 0.38) / 0.5, 0., 1.) * 0.8) * fan * (1.1 + 0.2 * sin(ph)) * smoothstep(1.2, 0.2, p.x); + col += vec3(1.) * beam + spec; + float edge = exp(-abs(d) * 200.) * 1.2, inside = smoothstep(0.003, -0.003, d); + col = mix(col, vec3(0.06, 0.07, 0.09) + spec * 0.4, inside * 0.7) + vec3(0.9, 0.95, 1.0) * edge; + return col; +} +vec3 aurora(vec2 p, vec2 uv, float ph){ + vec3 col = mix(vec3(0.01, 0.02, 0.05), vec3(0.0, 0.05, 0.09), uv.y) + stars(p, 70., ph, 5.) * 0.8; + vec2 q = uv; bool refl = q.y < 0.24; if (refl) q.y = 0.48 - q.y + 0.004 * sin(q.x * 80. + ph * 2.); + vec3 a = vec3(0.); + for (int i = 0; i < 3; i++) { float fi = float(i); + float y0 = 0.55 + 0.08 * fi + 0.06 * sin(q.x * 4. + ph + fi * 2.) + 0.05 * fbm(vec2(q.x * 3. + fi, fi)); + float dy = q.y - y0; float curtain = exp(-max(-dy, 0.) * 60.) * exp(-max(dy, 0.) * (5. - fi)) * (0.4 + 0.8 * fbm(vec2(q.x * 30. + fi * 7., q.y * 2. + sin(ph) * 0.2))); + a += mix(vec3(0.1, 1.0, 0.55), vec3(0.75, 0.25, 0.9), smoothstep(0.0, 0.2, dy)) * curtain * (0.9 - 0.2 * fi); } + col += a * (refl ? 0.45 : 1.0); + float ridge = 0.26 + 0.12 * fbm(vec2(uv.x * 2.6, 1.)) + 0.04 * fbm(vec2(uv.x * 12., 2.)); + if (!refl && uv.y < ridge) col = vec3(0.004, 0.006, 0.012) + a * 0.02; + if (refl) { float rr = 0.48 - ridge; if (uv.y > rr) col = vec3(0.004, 0.006, 0.012); col *= vec3(0.6, 0.75, 0.9); } + return col; +} +vec3 ocean(vec2 p, vec2 uv, float ph){ + float hz = 0.42; vec3 col; + vec2 sp = vec2(0.5, 0.47); + if (uv.y > hz) { + float y = (uv.y - hz) / (1. - hz); + col = mix(vec3(1.0, 0.45, 0.16), vec3(0.18, 0.12, 0.32), pow(y, 0.6)); + float cl = fbm(vec2(uv.x * 3. + 0.05 * sin(ph), y * 9.)); col = mix(col, vec3(0.98, 0.55, 0.35) * (0.5 + y), smoothstep(0.55, 0.75, cl) * 0.6); + float sd = length((uv - sp) * vec2(1.78, 1.)); col += vec3(1.0, 0.85, 0.55) * (smoothstep(0.075, 0.068, sd) * 2.5 + exp(-sd * 9.) * 0.6); + } else { + float z = 1. / (hz - uv.y + 0.02); vec2 w = vec2(uv.x * z * 0.6, z); + float wv = vn(w * vec2(3., 0.6) + vec2(0.3 * sin(ph), 0.3 * cos(ph))) + 0.5 * vn(w * vec2(9., 2.) - vec2(0.2 * cos(ph), 0.2 * sin(ph))); + col = mix(vec3(0.02, 0.04, 0.09), vec3(0.25, 0.12, 0.2), smoothstep(0.0, hz, uv.y)); + float glit = smoothstep(0.75, 1.0, wv * 0.7 + 0.3 * h21(floor(w * vec2(40., 8.)) + floor(uK / 2.))) * exp(-pow((uv.x - sp.x) / (0.05 + 0.25 * (hz - uv.y)), 2.)); + col += vec3(1.0, 0.7, 0.35) * glit * 2.2 + vec3(1.0, 0.5, 0.25) * exp(-pow((uv.x - 0.5) / 0.3, 2.)) * smoothstep(0.1, hz, uv.y) * 0.25; + } + return col; +} +vec3 dunes(vec2 uv, float ph){ + vec3 col = mix(vec3(1.0, 0.72, 0.45), vec3(0.42, 0.55, 0.78), pow(uv.y, 0.8)); + col += vec3(1.0, 0.9, 0.7) * exp(-length((uv - vec2(0.8, 0.62)) * vec2(1.78, 1.)) * 6.) * 0.8; + for (int i = 0; i < 5; i++) { float fi = float(i); float base = 0.62 - fi * 0.13; + float x = uv.x * (1.2 + fi * 0.6) + fi * 3.7 + 0.01 * sin(ph); + float h = base + 0.08 * sin(x * 2.2) + 0.05 * sin(x * 5.1 + fi) + 0.02 * fbm(vec2(x * 3., fi)); + if (uv.y < h) { float slope = cos(x * 2.2) * 0.17 + cos(x * 5.1 + fi) * 0.25; + vec3 lit = mix(vec3(0.95, 0.6, 0.32), vec3(0.75, 0.38, 0.22), fi / 5.), shd = mix(vec3(0.38, 0.25, 0.35), vec3(0.18, 0.12, 0.2), fi / 5.); + col = mix(shd, lit, smoothstep(-0.05, 0.05, -slope)) * (1. - 0.1 * fbm(vec2(uv.x * 60., uv.y * 300.))); col = mix(col, vec3(1.0, 0.75, 0.55), 0.18 * (4. - fi) / 4.); } + } + return col; +} +vec3 bokeh(vec2 p, float ph){ + vec3 col = vec3(0.012, 0.01, 0.018); + for (int i = 0; i < 26; i++) { float fi = float(i); vec2 c = vec2(h21(vec2(fi, 1.)) * 2. - 1., h21(vec2(fi, 2.)) - 0.5) * vec2(0.95, 0.6); + c += 0.04 * vec2(cos(ph + fi), sin(ph * 1. + fi * 1.3)); float r = 0.04 + 0.12 * pow(h21(vec2(fi, 3.)), 2.); + float d = length(p - c); float disk = smoothstep(r, r - 0.006, d) * (0.45 + 0.55 * smoothstep(r * 0.6, r, d)); + vec3 tint = mix(vec3(1.0, 0.62, 0.25), mix(vec3(1.0, 0.25, 0.2), vec3(0.4, 0.6, 1.0), step(0.5, h21(vec2(fi, 5.)))), step(0.6, h21(vec2(fi, 4.)))); + col += tint * disk * (0.35 + 0.5 * h21(vec2(fi, 6.))); } + return col; +} +vec3 fire(vec2 p, vec2 uv, float ph){ + vec3 q = vec3(p.x * 3.2, uv.y * 3.0 - 0.0, 0.) + vec3(0., 0.5 * cos(ph), 0.5 * sin(ph)); + float n = fbm3(q + vec3(0., -uv.y * 1.5, 0.)); + float f = clamp(n * 1.6 - uv.y * 1.25 + 0.15 - abs(p.x) * 0.7, 0., 1.); + vec3 col = vec3(1.5, 0.45, 0.06) * smoothstep(0.1, 0.5, f) + vec3(1.0, 0.9, 0.5) * smoothstep(0.45, 0.85, f); + for (int i = 0; i < 20; i++) { float fi = float(i); float y = fract(h21(vec2(fi, 9.)) + uK / 16. * (1. + floor(h21(vec2(fi, 8.)) * 2.))); + vec2 e = vec2((h21(vec2(fi, 7.)) - 0.5) * 1.2 + 0.05 * sin(y * 12. + fi), y * 1.1 - 0.5); + col += vec3(1.0, 0.55, 0.15) * smoothstep(0.012, 0.0, length(p - e)) * 1.5; } + return col + vec3(0.03, 0.01, 0.0); +} +vec3 ink(vec2 p, float ph){ + vec3 col = vec3(0.95, 0.93, 0.89); + vec2 q = p * 1.6; vec3 o = vec3(0.4 * cos(ph), 0.4 * sin(ph), 0.); + float w = fbm3(vec3(q, 0.) + o); float a = fbm3(vec3(q * 1.3 + w * 1.6, 2.) + o * 0.7), b = fbm3(vec3(q * 1.1 - w * 1.4, 5.) - o * 0.6); + col = mix(col, vec3(0.12, 0.16, 0.45), smoothstep(0.5, 0.72, a) * 0.95); + col = mix(col, vec3(0.8, 0.12, 0.18), smoothstep(0.52, 0.75, b) * 0.9); + col = mix(col, vec3(0.95, 0.65, 0.2), smoothstep(0.6, 0.8, a * b * 1.8) * 0.6); + return col; +} +vec3 geode(vec2 p, float ph){ + vec2 g = p * 7.; vec2 i = floor(g), f = fract(g); float md = 8., md2 = 8.; vec2 mc; + for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) { vec2 o = vec2(x, y); vec2 r = o + h22(i + o) - f; float d = dot(r, r); + if (d < md) { md2 = md; md = d; mc = i + o; } else if (d < md2) md2 = d; } + float edge = sqrt(md2) - sqrt(md); float r = length(p); + float k = h21(mc); vec3 c = mix(vec3(0.35, 0.12, 0.55), vec3(0.85, 0.55, 0.95), k) * smoothstep(0.65, 0.1, r); + c = mix(c, vec3(1.0, 0.75, 0.35), smoothstep(0.42, 0.55, r) * 0.9); + c *= 0.55 + 0.6 * smoothstep(0.0, 0.25, edge); + c += vec3(1.) * pow(max(sin(ph + k * TAU), 0.), 24.) * smoothstep(0.1, 0.0, md) * 1.5; + return c * smoothstep(0.75, 0.6, r) + vec3(0.04, 0.03, 0.03) * step(0.6, r); +} +vec3 sun(vec2 p, float ph){ + vec2 c = vec2(-0.1, -1.05); float R = 0.95; float r = length(p - c); + vec3 col = vec3(0.01, 0.005, 0.0); + if (r < R) { vec2 q = (p - c) * 10.; float g = fbm(q + vec2(0.3 * cos(ph), 0.3 * sin(ph))) ; float limb = pow(1. - (r / R) * (r / R), 0.35); + col = mix(vec3(1.0, 0.35, 0.03), vec3(1.0, 0.85, 0.4), g) * (0.6 + 1.1 * limb); } + col += vec3(1.0, 0.45, 0.1) * exp(-(r - R) * 9.) * step(R, r) * 0.9; + vec2 pc = vec2(0.15, -0.02); float a = atan(p.y - pc.y + 0.12, p.x - pc.x) ; float rr = length((p - pc) * vec2(1., 1.25)); + float loop = exp(-pow((rr - 0.22) / (0.025 + 0.02 * fbm(vec2(a * 4., ph * 0.0 + 1.))), 2.)) * step(-0.08, p.y - pc.y) ; + col += vec3(1.0, 0.4, 0.12) * loop * (0.7 + 0.5 * fbm(vec2(a * 9. + 0.3 * sin(ph), rr * 20.))) * 1.3; + return col; +} +vec3 warp(vec2 p, float ph){ + float a = atan(p.y, p.x), r = length(p); vec3 col = vec3(0.005, 0.008, 0.02); + for (int i = 0; i < 3; i++) { float fi = float(i); float n = 90. + 60. * fi; float ai = floor((a / TAU + 0.5) * n); + float s = h21(vec2(ai, fi)); float z = fract(s * 7.3 - uK / 16. * (1. + fi)); + float rr = 0.05 + pow(z, 2.2) * 1.3; float len = 0.02 + 0.3 * pow(z, 2.); + float da = abs(fract((a / TAU + 0.5) * n) - 0.5) / n * TAU * r; + col += mix(vec3(0.5, 0.7, 1.0), vec3(1.0), s) * smoothstep(0.004 + 0.003 * z, 0.0, da) * smoothstep(rr - len, rr, r) * step(r, rr) * (0.6 + 1.5 * z); } + return col + vec3(0.6, 0.75, 1.0) * exp(-r * 6.) * 0.6; +} +vec3 forest(vec2 p, vec2 uv, float ph){ + vec3 col = mix(vec3(0.65, 0.72, 0.5), vec3(0.95, 0.85, 0.55), uv.y); + vec2 L = vec2(-0.6, 0.55); vec2 d = normalize(p - L); + float ray = pow(fbm(vec2(atan(d.y, d.x) * 14., 1.)), 2.) * smoothstep(1.6, 0.2, length(p - L)); + for (int i = 0; i < 4; i++) { float fi = float(i); float depth = 1. - fi / 4.; + float x = uv.x * (5. + fi * 3.) + fi * 13.1; float id = floor(x); float fx = fract(x) - 0.5; + float w = 0.06 + 0.08 * h21(vec2(id, fi)) + 0.05 * depth; float trunk = smoothstep(w, w - 0.02, abs(fx + 0.15 * (h21(vec2(id, fi + 3.)) - 0.5))); + col = mix(col, mix(vec3(0.05, 0.08, 0.05), vec3(0.45, 0.5, 0.35), fi / 4.), trunk * (0.6 + 0.4 * depth)); } + col += vec3(1.0, 0.92, 0.65) * ray * 0.55; + for (int i = 0; i < 18; i++) { float fi = float(i); vec2 m = vec2(h21(vec2(fi, 1.)) * 2. - 1., h21(vec2(fi, 2.)) - 0.5) + 0.02 * vec2(sin(ph + fi), cos(ph + fi * 1.4)); + col += vec3(1.0, 0.95, 0.75) * smoothstep(0.006, 0.0, length(p - m)) * 0.8; } + return col; +} +vec3 chrome(vec2 p, float ph){ + vec2 q = p * 1.8; vec3 o = vec3(0.35 * cos(ph), 0.35 * sin(ph), 0.); + float h0 = fbm3(vec3(q, 0.) + o + fbm3(vec3(q * 1.5, 3.) - o) * 1.2); + vec2 e = vec2(0.01, 0.); + float hx = fbm3(vec3(q + e.xy, 0.) + o + fbm3(vec3((q + e.xy) * 1.5, 3.) - o) * 1.2); + float hy = fbm3(vec3(q + e.yx, 0.) + o + fbm3(vec3((q + e.yx) * 1.5, 3.) - o) * 1.2); + vec3 n = normalize(vec3(h0 - hx, h0 - hy, 0.015)); + vec3 r = reflect(vec3(0., 0., -1.), n); + vec3 env = mix(vec3(0.06, 0.07, 0.09), vec3(0.75, 0.82, 0.95), smoothstep(-0.2, 0.6, r.y)) + vec3(1.0, 0.6, 0.3) * smoothstep(0.7, 0.95, r.x) * 1.2; + return env * (0.5 + 0.7 * h0) + pow(max(r.y, 0.), 30.) * 1.5; +} +vec3 bloomf(vec2 p, float ph){ + vec3 col = mix(vec3(0.98, 0.86, 0.84), vec3(0.95, 0.75, 0.7), length(p)); + p *= rot(0.12 * sin(ph)); float a = atan(p.y, p.x), r = length(p); + for (int i = 0; i < 3; i++) { float fi = float(i); float k = 5. + fi * 3.; float open = 0.92 + 0.08 * sin(ph); + float pr = (0.42 - fi * 0.11) * open * (0.55 + 0.45 * pow(abs(cos(k * 0.5 * a + fi)) + 1e-4, 0.7)); + float inside = smoothstep(pr, pr - 0.01, r); + vec3 pc = mix(vec3(0.95, 0.42, 0.5), vec3(1.0, 0.8, 0.7), fi / 2.) * (0.6 + 0.4 * r / pr); + col = mix(col, pc, inside); } + col = mix(col, vec3(1.0, 0.85, 0.35), smoothstep(0.06, 0.03, r)); + return col; +} +vec3 underwater(vec2 p, vec2 uv, float ph){ + vec3 col = mix(vec3(0.0, 0.05, 0.12), vec3(0.05, 0.4, 0.6), pow(uv.y, 1.4)); + vec2 q = p * 6.; float c = 0.; + for (int i = 0; i < 3; i++) { float fi = float(i); c += abs(sin(q.x * (1. + fi * 0.4) + sin(q.y * 1.3 + ph + fi) * 1.5 + ph)) ; } + c = pow(1. - c / 3., 6.) * smoothstep(0.2, 1.0, uv.y); + col += vec3(0.6, 0.95, 1.0) * c * 1.2; + float ray = pow(fbm(vec2(p.x * 6. - p.y * 1.5, 0.5 * sin(ph))), 3.) * smoothstep(0.0, 1.0, uv.y); + col += vec3(0.5, 0.85, 1.0) * ray * 0.8; + for (int i = 0; i < 14; i++) { float fi = float(i); float y = fract(h21(vec2(fi, 3.)) + uK / 16.); vec2 b = vec2(h21(vec2(fi, 4.)) * 1.6 - 0.8 + 0.02 * sin(y * 20.), y * 1.0 - 0.5); + float d = length(p - b); col += vec3(0.8, 0.95, 1.0) * smoothstep(0.012, 0.009, d) * smoothstep(0.004, 0.009, d) * 1.2; } + return col; +} +vec3 lake(vec2 uv, float ph){ + float hz = 0.4; vec2 q = uv; bool refl = q.y < hz; if (refl) q.y = 2. * hz - q.y + 0.003 * sin(uv.x * 120. + ph * 2.) * (hz - uv.y) * 6.; + vec3 col = mix(vec3(1.0, 0.62, 0.45), vec3(0.35, 0.45, 0.75), pow((q.y - hz) / (1. - hz), 0.7)); + for (int i = 0; i < 4; i++) { float fi = float(i); float ridge = hz + 0.06 + 0.22 * (1. - fi / 4.) * (0.4 + 0.6 * abs(fbm(vec2(q.x * (2. + fi), fi * 3.)) * 1.6 - 0.6)); + vec3 mc = mix(vec3(0.16, 0.12, 0.22), vec3(0.85, 0.55, 0.55), fi / 4.); if (q.y < ridge) col = mix(mc, col, 0.15 * fi); } + if (refl) col = col * vec3(0.7, 0.75, 0.85) + 0.03; + col += vec3(1., 0.85, 0.75) * exp(-abs(uv.y - hz) * 60.) * 0.25; + return col; +} +vec3 jelly(vec2 p, float ph){ + vec3 col = mix(vec3(0.0, 0.01, 0.04), vec3(0.0, 0.04, 0.1), p.y + 0.5); + vec2 c = vec2(0.0, 0.12 + 0.03 * sin(ph)); vec2 q = p - c; float pulse = 1. + 0.08 * sin(ph); + float bell = length(q * vec2(1. / pulse, 1.5)) ; float top = step(-0.02, q.y); + float b = smoothstep(0.3, 0.28, bell) * top; float rim = exp(-pow((bell - 0.29) / 0.02, 2.)) * top; + col += vec3(0.95, 0.4, 0.75) * b * 0.35 + vec3(1.0, 0.7, 0.9) * rim * 1.4; + for (int i = 0; i < 9; i++) { float fi = float(i); float x0 = (fi / 8. - 0.5) * 0.48 * pulse; + float x = x0 + 0.04 * sin(q.y * 14. - ph * 1. + fi) * (-q.y); float tent = smoothstep(0.006, 0.0, abs(q.x - x)) * step(q.y, 0.0) * step(-0.55 + 0.1 * h21(vec2(fi, 1.)), q.y); + col += vec3(0.6, 0.8, 1.0) * tent * (0.6 + 0.6 * q.y + 0.3); } + return col + stars(p, 35., ph, 9.) * 0.3 * vec3(0.4, 0.8, 1.0); +} +vec3 storm(vec2 p, vec2 uv, float ph){ + float k = uK; float fl = (k == 3. || k == 4. ? 1. : 0.) * (k == 4. ? 0.45 : 1.) + (k == 11. ? 0.7 : 0.); + float cl = fbm(p * 2.5 + vec2(0.1 * cos(ph), 0.1 * sin(ph))); + vec3 col = mix(vec3(0.03, 0.03, 0.06), vec3(0.18, 0.18, 0.28), cl) * (1. + fl * 2.5 * cl); + float x = 0.1 + 0.05 * sin(uv.y * 30.) + 0.03 * sin(uv.y * 71. + 1.) + 0.02 * (vn(vec2(uv.y * 40., 0.)) - 0.5) * 4.; + float bolt = smoothstep(0.006, 0.0, abs(uv.x - 0.5 - x)) * step(0.15, uv.y) * step(uv.y, 0.85) * step(0.5, fl); + col += vec3(0.85, 0.85, 1.0) * bolt * 3.0 + vec3(0.5, 0.5, 0.9) * fl * exp(-abs(uv.x - 0.5 - x) * 12.) * 0.6; + return col * mix(1., 0.5, smoothstep(0.2, 0.0, uv.y)); +} +vec3 snow(vec2 p, vec2 uv, float ph){ + vec3 col = mix(vec3(0.42, 0.5, 0.75), vec3(0.08, 0.1, 0.22), uv.y); + for (int i = 0; i < 3; i++) { float fi = float(i); float x = uv.x * (4. + fi * 4.) + fi * 7.; float id = floor(x), fx = fract(x) - 0.5; + float h = 0.2 + 0.2 * h21(vec2(id, fi)) - fi * 0.06; float y = uv.y - (0.05 + 0.08 * fi * 0.0); + float tree = step(y, h) * step(abs(fx), (h - y) * 0.45 * (0.8 + 0.4 * fract((h - y) * 12.))); + col = mix(col, mix(vec3(0.02, 0.03, 0.06), vec3(0.2, 0.24, 0.38), fi / 3.), tree); } + col = mix(col, vec3(0.75, 0.8, 0.95), step(uv.y, 0.06)); + col += vec3(1.0, 0.7, 0.3) * smoothstep(0.012, 0.0, max(abs(p.x - 0.35), abs(p.y + 0.36))) * 1.5; + for (int i = 0; i < 40; i++) { float fi = float(i); float y = fract(h21(vec2(fi, 1.)) - uK / 16.); vec2 s = vec2(h21(vec2(fi, 2.)) * 1.9 - 0.95 + 0.02 * sin(ph + fi), y - 0.5); + col += vec3(1.) * smoothstep(0.006, 0.0, length(p - s)) * 0.9; } + return col; +} +vec3 trails(vec2 p, vec2 uv, float ph){ + vec3 col = vec3(0.01, 0.012, 0.025) + vec3(0.25, 0.12, 0.05) * smoothstep(0.6, 1.0, uv.y); + for (int i = 0; i < 8; i++) { float fi = float(i); float lane = (fi - 3.5) * 0.04; + float y = 0.3 + lane + 0.25 * sin(p.x * 1.6 + 0.6) * 0.5 + 0.12 * p.x; + float d = abs(uv.y - y); vec3 c = fi < 4. ? vec3(1.0, 0.18, 0.1) : vec3(1.0, 0.9, 0.75); + float dash = 0.75 + 0.25 * sin(p.x * 40. + (fi < 4. ? 1. : -1.) * ph * 3.); + col += c * smoothstep(0.004, 0.0, d) * dash * 1.4 + c * exp(-d * 120.) * 0.15; } + for (int i = 0; i < 40; i++) { float fi = float(i); vec2 w = vec2(h21(vec2(fi, 1.)) * 2. - 1., 0.12 + 0.35 * h21(vec2(fi, 2.))); + col += vec3(1.0, 0.8, 0.5) * smoothstep(0.006, 0.0, length(p - w)) * (0.4 + 0.6 * h21(vec2(fi, 3.))); } + return col; +} +vec3 mesh(vec2 p, float ph){ + vec3 col = vec3(0.98, 0.95, 0.92); float ws = 0.; + vec3 cs[4]; cs[0] = vec3(1.0, 0.45, 0.2); cs[1] = vec3(0.95, 0.3, 0.55); cs[2] = vec3(0.25, 0.45, 1.0); cs[3] = vec3(1.0, 0.8, 0.3); + vec3 acc = vec3(0.); + for (int i = 0; i < 4; i++) { float fi = float(i); vec2 c = vec2(cos(ph + fi * 1.6) * 0.45, sin(ph * 1. + fi * 2.2) * 0.25); + float w = exp(-dot(p - c, p - c) * 4.); acc += cs[i] * w; ws += w; } + col = mix(col, acc / max(ws, 1e-3), clamp(ws, 0., 1.)); + return col * (0.95 + 0.05 * h21(floor(p * 400.))); +} +vec3 rain(vec2 p, vec2 uv, float ph){ + vec3 bg = bokeh(p * 0.9, ph) * 1.2 + vec3(0.05, 0.04, 0.06); + vec2 g = p * vec2(9., 6.); vec2 i = floor(g), f = fract(g) - 0.5; vec2 o = (h22(i) - 0.5) * 0.6; + float r = 0.12 + 0.18 * h21(i + 3.); float d = length((f - o) * vec2(1., 0.8)); + float drop = smoothstep(r, r - 0.04, d); + vec3 refr = bokeh((p - (f - o) * 0.25) * 2.5, ph) * 1.5 + 0.06; + vec3 col = mix(bg * 0.45, refr, drop) + vec3(1.) * smoothstep(0.05, 0.0, length(f - o + vec2(0.04, -0.05))) * drop * 0.8; + float st = smoothstep(0.03, 0.0, abs(f.x - o.x)) * step(f.y, o.y) * smoothstep(-0.5, o.y, f.y) * step(0.7, h21(i + 9.)); + return col + vec3(0.6) * st * 0.4; +} +vec3 twinsuns(vec2 uv, float ph){ + vec3 col = mix(vec3(1.0, 0.55, 0.35), vec3(0.35, 0.25, 0.45), pow(uv.y, 0.8)); + vec2 s1 = vec2(0.36, 0.55), s2 = vec2(0.47, 0.62); + col += vec3(1.0, 0.85, 0.6) * (smoothstep(0.05, 0.045, length((uv - s1) * vec2(1.78, 1.))) * 2.0 + exp(-length((uv - s1) * vec2(1.78, 1.)) * 6.) * 0.5); + col += vec3(1.0, 0.7, 0.5) * (smoothstep(0.03, 0.026, length((uv - s2) * vec2(1.78, 1.))) * 2.0 + exp(-length((uv - s2) * vec2(1.78, 1.)) * 8.) * 0.4); + float ground = 0.3 + 0.03 * fbm(vec2(uv.x * 6., 1.)); + float arch = 0.; + { vec2 q = (uv - vec2(0.72, 0.3)) * vec2(1.78, 1.); float r = length(q); arch = step(0., q.y) * smoothstep(0.24, 0.23, r) * smoothstep(0.15, 0.16, r + 0.02 * fbm(q * 9.)); } + float mesa = step(uv.y, 0.3 + 0.2 * step(abs(uv.x - 0.12), 0.08) * (1. - smoothstep(0.08, 0.1, abs(uv.x - 0.12))) + 0.0); + if (uv.y < ground || arch > 0.5 || mesa > 0.5) col = mix(vec3(0.22, 0.1, 0.08), vec3(0.45, 0.2, 0.12), uv.y * 2.) * (0.8 + 0.2 * fbm(uv * 30.)); + return col + vec3(1.0, 0.7, 0.45) * 0.08 * fbm(uv * vec2(3., 10.) + vec2(0.05 * sin(ph), 0.)); +} +vec3 earth(vec2 p, vec2 uv, float ph){ + vec3 col = vec3(0.0, 0.0, 0.01) + stars(p, 70., ph, 11.) * 0.7; + vec2 c = vec2(0.0, -2.05); float R = 1.85; vec2 d = (p - c) / R; float rr = dot(d, d); + vec3 L = normalize(vec3(0.85, 0.28, 0.45)); + if (rr < 1.) { + vec3 n = vec3(d, sqrt(1. - rr)); + float a = 0.035 * sin(ph); vec3 m = vec3(n.x * cos(a) - n.z * sin(a), n.y, n.x * sin(a) + n.z * cos(a)); // a slow periodic turn + vec3 q = m * 2.4 + vec3(3.1, 1.7, 0.0); + float cont = fbm3(q + 0.5 * fbm3(q * 1.7)); + float land = smoothstep(0.50, 0.53, cont); + float desert = smoothstep(0.45, 0.65, fbm3(q * 2.3 + 7.0)) * land; + float cl = fbm3(m * 5.0 + vec3(0.2 * cos(ph), 0.0, 0.2 * sin(ph)) + fbm3(m * 9.0) * 0.6); + float clouds = smoothstep(0.50, 0.78, cl); + float dif = max(dot(n, L), 0.); + vec3 ocean = mix(vec3(0.004, 0.02, 0.07), vec3(0.01, 0.07, 0.16), smoothstep(0.35, 0.5, cont)); + vec3 ground = mix(vec3(0.06, 0.12, 0.04), vec3(0.42, 0.33, 0.2), desert); + vec3 surf = mix(ocean, ground, land); + vec3 V = vec3(0., 0., 1.); vec3 H = normalize(L + V); + float glint = pow(max(dot(n, H), 0.), 80.) * (1. - land) * (1. - clouds); + col = surf * (0.04 + 1.25 * dif) + vec3(1.0, 0.9, 0.75) * glint * 1.6; + col = mix(col, vec3(0.95, 0.97, 1.0) * (0.06 + 1.15 * dif), clouds * 0.9); + col *= 1.0 - 0.35 * clouds * smoothstep(0.5, 0.7, fbm3(m * 5.0 + vec3(0.03, -0.02, 0.0))); // cloud shadows + float night = smoothstep(0.12, -0.08, dot(n, L)); + float cities = land * (1. - desert * 0.7) * step(0.72, h31(floor(m * 140.))) * smoothstep(0.4, 0.7, fbm3(q * 6.)); + col += vec3(1.0, 0.68, 0.32) * cities * night * 1.4; + col += vec3(0.25, 0.5, 1.0) * pow(1. - n.z, 3.) * (0.25 + 0.9 * dif) * 0.9; // atmosphere toward the limb + } + float rl = sqrt(rr); + float rim = exp(-pow((rl - 1.0) / 0.01, 2.)) + exp(-max(rl - 1.0, 0.) * 35.) * 0.55 * step(1.0, rl); + col += vec3(0.3, 0.58, 1.0) * rim * 0.85 * smoothstep(-0.9, 0.6, d.x); + col += vec3(1.0, 0.9, 0.75) * exp(-length(p - vec2(0.62, -0.2)) * 9.) * 1.5; + return col; +} + +void main(){ + vec2 uv = vUv; vec2 p = (uv - 0.5) * vec2(16. / 9., 1.); + float ph = TAU * uK / 16.; vec2 c = vec2(cos(ph), sin(ph)); + int f = int(uFilm + 0.5); vec3 col; + if (f == 0) col = nebula(p, c, ph); + else if (f == 1) col = galaxy(p, ph); + else if (f == 2) col = blackhole(p, ph); + else if (f == 3) col = planet(p, ph); + else if (f == 4) col = glass(p, ph); + else if (f == 5) col = prism(p, ph); + else if (f == 6) col = aurora(p, uv, ph); + else if (f == 7) col = ocean(p, uv, ph); + else if (f == 8) col = dunes(uv, ph); + else if (f == 9) col = bokeh(p, ph); + else if (f == 10) col = fire(p, uv, ph); + else if (f == 11) col = ink(p, ph); + else if (f == 12) col = geode(p, ph); + else if (f == 13) col = sun(p, ph); + else if (f == 14) col = warp(p, ph); + else if (f == 15) col = forest(p, uv, ph); + else if (f == 16) col = chrome(p, ph); + else if (f == 17) col = bloomf(p, ph); + else if (f == 18) col = underwater(p, uv, ph); + else if (f == 19) col = lake(uv, ph); + else if (f == 20) col = jelly(p, ph); + else if (f == 21) col = storm(p, uv, ph); + else if (f == 22) col = snow(p, uv, ph); + else if (f == 23) col = trails(p, uv, ph); + else if (f == 24) col = mesh(p, ph); + else if (f == 25) col = rain(p, uv, ph); + else if (f == 26) col = twinsuns(uv, ph); + else col = earth(p, uv, ph); + col = col / (1. + col * 0.35); // soft shoulder: films are screens, not light sources + gl_FragColor = vec4(pow(clamp(col, 0., 1.), vec3(1. / 2.2)), 1.); // stored as sRGB, like the jpg atlas +}`; + +// one film at any size (look-hero/ exports the opening film at 1024x576 with it) +export function filmMaterial() { + return new THREE.ShaderMaterial({ uniforms: { uFilm: { value: 0 }, uK: { value: 0 } }, + vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }", fragmentShader: FRAG, depthTest: false, depthWrite: false }); +} + +// renders the procedural atlas once; returns a texture of NF x PROC.length tiles +export function renderProcAtlas(renderer) { + const W = TW * NF, H = TH * PROC.length; + const rt = new THREE.WebGLRenderTarget(W, H, { depthBuffer: false, generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter }); + // stored gamma-encoded in 8 bits with no colour-space tag; cards.js decodes it (pow 2.2), the jpg is decoded by the GPU + const mat = new THREE.ShaderMaterial({ uniforms: { uFilm: { value: 0 }, uK: { value: 0 } }, + vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }", fragmentShader: FRAG, depthTest: false, depthWrite: false }); + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat), scene = new THREE.Scene(); scene.add(quad); + const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const prevRT = renderer.getRenderTarget(), prevAuto = renderer.autoClear, vp = new THREE.Vector4(); + renderer.setRenderTarget(rt); renderer.autoClear = false; renderer.clear(); + for (let f = 0; f < PROC.length; f++) for (let k = 0; k < NF; k++) { + mat.uniforms.uFilm.value = f; mat.uniforms.uK.value = k; + // atlas row f from the top (v = 1 - (f+1)/rows), like the jpg atlas + rt.viewport.set(k * TW, H - (f + 1) * TH, TW, TH); renderer.setRenderTarget(rt); + renderer.render(scene, cam); + } + renderer.setRenderTarget(prevRT); renderer.autoClear = prevAuto; + rt.viewport.set(0, 0, W, H); + mat.dispose(); quad.geometry.dispose(); + return { tex: rt.texture, rt, rows: PROC.length }; +} + +export async function loadRealAtlas() { + const meta = await (await fetch("assets/films.json")).json(); + const tex = await new THREE.TextureLoader().loadAsync("assets/films.jpg"); + tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.flipY = true; + return { tex, rows: meta.films.length, names: meta.films }; +} diff --git a/showcase/04-intro-film/opening/hyperframes.json b/showcase/04-intro-film/opening/hyperframes.json new file mode 100644 index 0000000..dc9ec2e --- /dev/null +++ b/showcase/04-intro-film/opening/hyperframes.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + }, + "media": { + "autoProxy": true + } +} diff --git a/showcase/04-intro-film/opening/index.html b/showcase/04-intro-film/opening/index.html new file mode 100644 index 0000000..bdc0158 --- /dev/null +++ b/showcase/04-intro-film/opening/index.html @@ -0,0 +1,47 @@ + + + + + + opening film atlases + + + + +
+ +
+ + + + diff --git a/showcase/04-intro-film/review/chapters.png b/showcase/04-intro-film/review/chapters.png new file mode 100644 index 0000000..a5cf8d1 Binary files /dev/null and b/showcase/04-intro-film/review/chapters.png differ diff --git a/showcase/04-intro-film/review/motif-options.png b/showcase/04-intro-film/review/motif-options.png new file mode 100644 index 0000000..416af0c Binary files /dev/null and b/showcase/04-intro-film/review/motif-options.png differ diff --git a/showcase/04-intro-film/review/music-arc.json b/showcase/04-intro-film/review/music-arc.json new file mode 100644 index 0000000..8c423c0 --- /dev/null +++ b/showcase/04-intro-film/review/music-arc.json @@ -0,0 +1,195 @@ +{ + "I": -15.6, + "LRA": 10.9, + "true_peak": -1.0, + "sections": [ + { + "name": "chaos", + "start": 0.0, + "end": 8.0, + "measured": -18.4, + "planned": -17.3, + "delta": -1.2 + }, + { + "name": "nova", + "start": 8.0, + "end": 10.0, + "measured": -15.9, + "planned": -15.4, + "delta": -0.4 + }, + { + "name": "question", + "start": 10.0, + "end": 14.0, + "measured": -21.9, + "planned": -22.2, + "delta": 0.3 + }, + { + "name": "reveal", + "start": 14.0, + "end": 22.0, + "measured": -17.4, + "planned": -17.1, + "delta": -0.2 + }, + { + "name": "route", + "start": 22.0, + "end": 32.0, + "measured": -15.8, + "planned": -16.2, + "delta": 0.4 + }, + { + "name": "gates", + "start": 32.0, + "end": 48.0, + "measured": -18.0, + "planned": -17.2, + "delta": -0.8 + }, + { + "name": "sound", + "start": 48.0, + "end": 56.0, + "measured": -14.8, + "planned": -16.6, + "delta": 1.8 + }, + { + "name": "code", + "start": 56.0, + "end": 62.0, + "measured": -16.0, + "planned": -14.8, + "delta": -1.2 + }, + { + "name": "loop", + "start": 62.0, + "end": 70.0, + "measured": -15.4, + "planned": -15.2, + "delta": -0.2 + }, + { + "name": "hold3", + "start": 70.0, + "end": 72.0, + "measured": -16.4, + "planned": -16.1, + "delta": -0.4 + }, + { + "name": "climax", + "start": 72.0, + "end": 80.0, + "measured": -10.6, + "planned": -10.1, + "delta": -0.5 + }, + { + "name": "proof", + "start": 80.0, + "end": 86.0, + "measured": -14.2, + "planned": -11.5, + "delta": -2.7 + }, + { + "name": "stop", + "start": 86.0, + "end": 88.0, + "measured": -19.6, + "planned": -20.6, + "delta": 1.0 + }, + { + "name": "rebuild", + "start": 88.0, + "end": 94.0, + "measured": -17.5, + "planned": -17.6, + "delta": 0.1 + }, + { + "name": "coda", + "start": 94.0, + "end": 106.0, + "measured": -21.2, + "planned": -19.3, + "delta": -1.9 + } + ], + "chapters": [ + { + "name": "开场", + "start": 0, + "end": 22, + "measured": -18.5, + "planned": -18.0, + "delta": -0.5 + }, + { + "name": "路由", + "start": 22, + "end": 32, + "measured": -15.8, + "planned": -16.2, + "delta": 0.4 + }, + { + "name": "关卡", + "start": 32, + "end": 48, + "measured": -18.0, + "planned": -17.2, + "delta": -0.8 + }, + { + "name": "制作", + "start": 48, + "end": 72, + "measured": -15.5, + "planned": -15.6, + "delta": 0.2 + }, + { + "name": "成片", + "start": 72, + "end": 86, + "measured": -12.1, + "planned": -10.7, + "delta": -1.5 + }, + { + "name": "揭晓", + "start": 86, + "end": 94, + "measured": -18.0, + "planned": -18.3, + "delta": 0.4 + }, + { + "name": "片名", + "start": 94, + "end": 106, + "measured": -21.2, + "planned": -19.3, + "delta": -1.9 + } + ], + "peak": { + "section": "climax", + "measured": -10.6, + "margin_over_next": 3.7, + "position": [ + 0.68, + 0.75 + ] + }, + "section_contrast_LU": 11.4 +} \ No newline at end of file diff --git a/showcase/04-intro-film/review/music-arc.png b/showcase/04-intro-film/review/music-arc.png new file mode 100644 index 0000000..777d9e6 Binary files /dev/null and b/showcase/04-intro-film/review/music-arc.png differ diff --git a/showcase/04-intro-film/review/onscreen-opening.json b/showcase/04-intro-film/review/onscreen-opening.json new file mode 100644 index 0000000..e581143 --- /dev/null +++ b/showcase/04-intro-film/review/onscreen-opening.json @@ -0,0 +1,42 @@ +{ + "_about": "v5 opening, the prototype's real windows (start = fully shown, end = starts to leave).", + "items": [ + { + "id": "T1", + "en": "So many AI video tools.", + "zh": "AI 视频工具,多到数不清。", + "start": 0.6, + "end": 4.4 + }, + { + "id": "T2", + "en": "What’s missing?", + "zh": "差的是什么?", + "start": 10.4, + "end": 13.8 + }, + { + "id": "T3", + "en": "OpenVideoHarness Not more tools. Know-how.", + "zh": "OpenVideoHarness 不缺工具,缺门道。", + "start": 14.5, + "end": 18.8 + }, + { + "id": "REQ", + "en": "Make a 30-second vertical science short: why does a low-orbit satellite’s signal change pitch?", + "zh": "做一个 30 秒的竖屏科普:为什么低轨卫星的信号会“变调”。", + "start": 21.6, + "end": 28.6, + "_note": "typed 19.2–21.6; stays pinned through Ch1 (planned end 28.6)" + }, + { + "id": "RESP", + "en": "→ type 02 of 8 · standard", + "zh": "→ 8 类里的 02 类 · standard", + "start": 28.6, + "end": 32.0, + "_note": "appears at 22.15 under the request; read after it" + } + ] +} \ No newline at end of file diff --git a/showcase/04-intro-film/review/onscreen-plan.json b/showcase/04-intro-film/review/onscreen-plan.json new file mode 100644 index 0000000..21584b8 --- /dev/null +++ b/showcase/04-intro-film/review/onscreen-plan.json @@ -0,0 +1,191 @@ +{ + "_about": "v5 whole film, PLANNED windows with draft copy (gate ①). Proves the chapter lengths leave room for every read; the copy is re-checked at gate ②.", + "items": [ + { + "id": "T1", + "en": "So many AI video tools.", + "zh": "AI 视频工具,多到数不清。", + "start": 0.6, + "end": 4.4 + }, + { + "id": "T2", + "en": "What’s missing?", + "zh": "差的是什么?", + "start": 10.4, + "end": 13.8 + }, + { + "id": "T3", + "en": "OpenVideoHarness Not more tools. Know-how.", + "zh": "OpenVideoHarness 不缺工具,缺门道。", + "start": 14.5, + "end": 18.8 + }, + { + "id": "REQ", + "en": "Make a 30-second vertical science short: why does a low-orbit satellite’s signal change pitch?", + "zh": "做一个 30 秒的竖屏科普:为什么低轨卫星的信号会“变调”。", + "start": 21.6, + "end": 28.6, + "_note": "typed 19.2–21.6; stays pinned through Ch1 (planned end 28.6)" + }, + { + "id": "RESP", + "en": "→ type 02 of 8 · standard", + "zh": "→ 8 类里的 02 类 · standard", + "start": 28.6, + "end": 32.0, + "_note": "appears at 22.15 under the request; read after it" + }, + { + "id": "G1", + "en": "Gate ①: outline + 2–3 styles", + "zh": "关卡 ①:大纲 + 两三个风格", + "start": 32.3, + "end": 36.1 + }, + { + "id": "SW", + "en": "28 styles, not one taste.", + "zh": "28 种风格,不止一种口味。", + "start": 36.1, + "end": 40.0 + }, + { + "id": "G2", + "en": "Gate ②: storyboard", + "zh": "关卡 ②:分镜", + "start": 40.4, + "end": 47.8 + }, + { + "id": "SF", + "en": "Sound first, then picture.", + "zh": "声音先行,再写画面。", + "start": 48.4, + "end": 56.0 + }, + { + "id": "SP1", + "en": "Narration with feeling and rhythm", + "zh": "旁白有表情、有节奏", + "start": 50.0, + "end": 57.0 + }, + { + "id": "SP2", + "en": "Bilingual subtitles", + "zh": "双语字幕", + "start": 51.0, + "end": 57.0 + }, + { + "id": "SP3", + "en": "Music in code: 88 instruments", + "zh": "代码作曲:88 种乐器", + "start": 52.0, + "end": 57.0, + "_note": "count: render 前再核" + }, + { + "id": "SP4", + "en": "Effects, mix and QA", + "zh": "音效、混音与质检", + "start": 53.0, + "end": 57.0 + }, + { + "id": "PR", + "en": "It doesn’t paint pixels. It writes the program. frame = f(t)", + "zh": "它不画像素,它写程序。frame = f(t)", + "start": 57.2, + "end": 62.2 + }, + { + "id": "LOOP", + "en": "Self-review loop · 20-point checklist · 7 scores ≥ 8", + "zh": "自查闭环 · 20 条清单 · 7 项都到 8 分", + "start": 62.4, + "end": 67.0 + }, + { + "id": "FAIL", + "en": "✕ fail → ✓ pass", + "zh": "✕ 不合格 → ✓ 通过", + "start": 65.6, + "end": 70.2 + }, + { + "id": "G3", + "en": "Gate ③: first draft", + "zh": "关卡 ③:初版", + "start": 69.4, + "end": 72.6 + }, + { + "id": "FILM", + "en": "02 · vertical science short · HyperFrames", + "zh": "02 · 竖屏科普 · HyperFrames", + "start": 72.6, + "end": 76.8 + }, + { + "id": "MADE", + "en": "Made by an agent reading only this repo’s docs.", + "zh": "都是 agent 只看本仓库的文档做出来的。", + "start": 76.9, + "end": 82.0, + "_note": "README.md:74 / README.zh-CN.md:74 as of 4398f27 (v4 #14 wording is stale again)" + }, + { + "id": "S01", + "en": "01 · hand-drawn short · p5.brush", + "zh": "01 · 手绘短片 · p5.brush", + "start": 78.8, + "end": 82.8 + }, + { + "id": "S03", + "en": "03 · math explainer · Manim", + "zh": "03 · 数学讲解 · Manim", + "start": 80.6, + "end": 84.4 + }, + { + "id": "S00", + "en": "00 · launch short · HyperFrames", + "zh": "00 · 发布短片 · HyperFrames", + "start": 82.3, + "end": 86.0 + }, + { + "id": "TOO", + "en": "This film, too.", + "zh": "这支片子也是。", + "start": 86.3, + "end": 89.3 + }, + { + "id": "CODE", + "en": "Even the soundtrack is code.", + "zh": "连配乐都是代码。", + "start": 89.5, + "end": 93.6 + }, + { + "id": "TITLE", + "en": "OpenVideoHarness Video as code, for coding agents.", + "zh": "OpenVideoHarness 给 coding agent 的视频工作台", + "start": 95.0, + "end": 106.0 + }, + { + "id": "CTA", + "en": "$ bin/vh new · github.com/ZLHad/OpenVideoHarness", + "zh": "$ bin/vh new · github.com/ZLHad/OpenVideoHarness", + "start": 97.5, + "end": 106.0 + } + ] +} \ No newline at end of file diff --git a/showcase/04-intro-film/review/opening-animatic.gif b/showcase/04-intro-film/review/opening-animatic.gif new file mode 100644 index 0000000..cc6cdbc Binary files /dev/null and b/showcase/04-intro-film/review/opening-animatic.gif differ diff --git a/showcase/04-intro-film/review/opening-options.png b/showcase/04-intro-film/review/opening-options.png new file mode 100644 index 0000000..c36a4c1 Binary files /dev/null and b/showcase/04-intro-film/review/opening-options.png differ diff --git a/showcase/04-intro-film/tools/ai_atlas.py b/showcase/04-intro-film/tools/ai_atlas.py new file mode 100644 index 0000000..eadcd06 --- /dev/null +++ b/showcase/04-intro-film/tools/ai_atlas.py @@ -0,0 +1,34 @@ +"""Build assets/films-ai.jpg: the AI stills (assets/ai/NN.jpg) as 16-frame films, a slow push or pan on each +(they ping-pong on the cards, so the move goes in and back out), one film per row, 256x144 tiles like the other atlases. +Also writes the 1/4-size atlases the far cards use: films-proc-small.png, films-small.jpg, films-ai-small.jpg. + +usage (from the project root): uv run --no-project --with pillow python tools/ai_atlas.py +""" +import json +import math +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +A = ROOT / "assets" +TW, TH, NF = 256, 144, 16 +imgs = sorted((A / "ai").glob("*.jpg")) +atlas = Image.new("RGB", (TW * NF, TH * len(imgs)), (8, 9, 10)) +for r, f in enumerate(imgs): + im = Image.open(f).convert("RGB"); W, H = im.size + h = (r * 2654435761 % 1000) / 1000.0 # a fixed per-image choice of move + ang = h * 2 * math.pi; zoom1 = 1.06 + 0.06 * ((r * 40503 % 1000) / 1000.0) + for k in range(NF): + u = k / (NF - 1); z = 1.0 + (zoom1 - 1.0) * (u * u * (3 - 2 * u)) + cw, ch = W / z, W / z * 9 / 16 + if ch > H / z: ch = H / z; cw = ch * 16 / 9 + cx = W / 2 + math.cos(ang) * (W - cw) * 0.5 * u; cy = H / 2 + math.sin(ang) * (H - ch) * 0.5 * u + box = (cx - cw / 2, cy - ch / 2, cx + cw / 2, cy + ch / 2) + atlas.paste(im.resize((TW, TH), Image.LANCZOS, box=box), (k * TW, r * TH)) +atlas.save(A / "films-ai.jpg", quality=90, optimize=True) +(A / "films-ai.json").write_text(json.dumps({"tile": [TW, TH], "frames": NF, "fps": 8, "films": [f.stem for f in imgs]}, indent=1)) +for src, dst in (("films-proc.png", "films-proc-small.png"), ("films.jpg", "films-small.jpg"), ("films-ai.jpg", "films-ai-small.jpg")): + im = Image.open(A / src).convert("RGB") + im.resize((im.width // 4, im.height // 4), Image.LANCZOS).save(A / dst, **({"quality": 90} if dst.endswith(".jpg") else {"optimize": True})) +print(f"{len(imgs)} AI films → {A / 'films-ai.jpg'} ({atlas.size[0]}x{atlas.size[1]}); small atlases written") diff --git a/showcase/04-intro-film/tools/audio_qa.py b/showcase/04-intro-film/tools/audio_qa.py deleted file mode 100644 index 2e764f6..0000000 --- a/showcase/04-intro-film/tools/audio_qa.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Independent audio QA for the film mix (v3 brief): dropouts, pumping, clicks, and an RMS evidence plot. - -usage: uv run -q --with numpy --with scipy --with matplotlib python tools/audio_qa.py