diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173111..346e8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +**Two more styles: pastel-ui (浅色界面讲解) and y2k-chrome (Y2K 铬面), 31 in all** +- Why: two of the five community films in `cases/oneshot-five.md` work in registers the library lacked: a light, friendly UI explainer (科了个喵's "3 seconds") and late-90s liquid chrome pop (FunTech's showreel). +- `styles/pastel-ui/`: a pastel wallpaper, white rounded cards, one colour per concept carried through; the title is typed into a chat bar and sent, the three cards step forward one at a time on a progress rail, and the 初版 card opens to full screen as the end frame. Learning objects: the 科了个喵 film (2026) and Material You (Google I/O, 2021-05-18). Kalimba, Rhodes, plucked upright, string pad, finger snaps; UI foley. +- `styles/y2k-chrome/`: inflated liquid chrome reflecting an unseen studio (dark ground, silver horizon) on acid yellow; a chrome blob that splashes into the title, stays as the anchor, hops onto three level tiles on the beat, is swept off by a chrome tube and regrows as "Y2K". Learning objects: FunTech Showreel 2026, *Wipeout* (1995, The Designers Republic), Mac OS X Aqua (2000-01-05). Bubblegum dance at 150 BPM; pops, a rubbery hop squash, sticker slaps, a metallic sweep. +- Both: written in the non-rigid voice (what the swatch does and why, with 容易翻车的地方 instead of a 禁止项 list), text-size tiers by where it is watched, each built by a subagent and reviewed three times by a fresh reviewer (first round: a purple glow-orb hook and a 2 s freeze in pastel-ui; labels under the floor, a vanishing anchor and candy-looking chrome in y2k-chrome). Final review: pastel-ui 7/7/8/8/8/7/8/8, y2k-chrome 8/9/8/8/8/7/8/8, floors pass; the last small fixes (an overlapped label, an empty end frame, a crowded tile corner, one repeated sound) were checked on the contact sheets. `render.sh` (incl. audio qa) and `determinism.sh` pass for both (150/150 frames identical across 1 and 3 workers). +- `styles/README.md` (count, index rows, sound rows), both READMEs (31), `playbook/04-audio.md`'s transition table, `styles/gallery.jpg` and `gallery.mp4` regenerated (31 × 1.5 s). + **Style presets are references, not templates** - Why: the maintainer would rather the presets be examples the agent learns from and combines freely ("预设样式只是一些模版参考,让 Agents 自由学习组合并参考") than templates it executes. Before, `--style` copied the preset as `STYLE_PRESET.md`, pasted its prompt block into the BRIEF as instructions and opened the project's STYLE.md with "以风格预设为底…只写改动"; in the research note 06 experiment, the workflow agent that borrowed `ink-wash` logged three taste overrides in its project's DECISIONS.md just to depart from it (the author's local project, not in the repo). - `bin/vh new --style a,b` and `bin/vh style apply a,b ` attach one or several presets as references: copies in `style-refs//`, a `Style refs (repo presets)` line in the BRIEF, a note at the top of STYLE.md ("只是参考…和它们不一样不用解释"), and one `风格参考` line per preset in DECISIONS.md to fill in with what it lends. Nothing is pasted into the BRIEF; attaching a preset again adds nothing. A project made the old way keeps its preset: `STYLE_PRESET.md` and `style.tokens.json` move to `style-refs//` and it is listed first, while the pasted `## Style preset` block and the "为底" pointer go. Preset names must be lower-case slugs of a preset (no paths, no `_swatch`, no empty list); a missing DECISIONS.md is created from the template. CI covers a comma list in one call, side by side without repeats, the migration, and bad names anywhere in a list. diff --git a/README.md b/README.md index ad5a3ab..a4f5ff6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Coding agents like Claude Code and Codex can make videos by writing programs. This makes them do it reliably.** -Explainers, science shorts, product films, music videos, data stories, paper talks, hand-drawn shorts, meme edits and edits of your own footage: 9 video types (09 experimental), 29 styles, one workflow. +Explainers, science shorts, product films, music videos, data stories, paper talks, hand-drawn shorts, meme edits and edits of your own footage: 9 video types (09 experimental), 31 styles, one workflow. **English** · [中文](README.zh-CN.md) · [Wiki](https://github.com/ZLHad/OpenVideoHarness/wiki) @@ -12,7 +12,7 @@ Explainers, science shorts, product films, music videos, data stories, paper tal ![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) ![Voice](https://img.shields.io/badge/voice-Qwen3--TTS%20zh%20%7C%20en-purple) -![Styles](https://img.shields.io/badge/styles-29-green) +![Styles](https://img.shields.io/badge/styles-31-green) OpenVideoHarness intro film @@ -32,7 +32,7 @@ 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.** 29 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, offered to you before anything is built. - **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. @@ -95,8 +95,8 @@ The five films below were made by an agent **reading only this repo's docs**. Cl 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 -The 29 style samples, all showing the same content
▶ the reel, with sound (42 s) -29 styles, one reel (5-second samples)
The same content in 29 styles learned from famous work.
Borrow from one:
bin/vh new promo launch-film --style cutout-jazz
bin/vh style list shows all 29; details in styles/README.md
Sound: one score per sample (bin/vh music, mix … profile=swatch) +The 31 style samples, all showing the same content
▶ the reel, with sound (42 s) +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) @@ -121,11 +121,11 @@ The GIFs are compressed, silent previews. Films you make with it are welcome in -## 29 styles, not one taste +## 31 styles, not one taste AI-made videos drift toward one look: dark background, glow, glass cards, busy animated UI. Unless you say otherwise, that's where an agent goes. -So we studied famous work and wrote down 29 styles in [`styles/`](styles/), grouped into film titles, brand and launch, data and explainers, illustration and print, Chinese aesthetics, and retro tech. The sources include: +So we studied famous work and wrote down 31 styles in [`styles/`](styles/), grouped into film titles, brand and launch, data and explainers, illustration and print, Chinese aesthetics, and retro tech. The sources include: - **film and title design**: Saul Bass's titles, *Se7en*, *Blade Runner 2049*, Wes Anderson's symmetry, Wong Kar-wai's step-printing, Aardman's and Laika's stop-motion miniatures; - **design**: the Swiss grid, 3Blue1Brown, New York Times data graphics, Otto Neurath's Isotype pictograms; - **animation and print**: the halftone dots of *Spider-Verse*, 16-bit Super Nintendo pixel art; @@ -133,14 +133,14 @@ So we studied famous work and wrote down 29 styles in [`styles/`](styles/), grou Each style is written as instructions an agent can follow: colors and fonts, composition, how things move, how scenes change, what it sounds like, and which clichés to avoid. -**Every style comes with a real 5-second sample rendered by this repo**, each with its own code-written music. All 29 samples show exactly the same content, so the only difference is the style: +**Every style comes with a real 5-second sample rendered by this repo**, each with its own code-written music. All 31 samples show exactly the same content, so the only difference is the style: -The 29 style samples, all showing the same content +The 31 style samples, all showing the same content Watch them back to back, each with its own sound, in [`styles/gallery.mp4`](styles/gallery.mp4). To use one: ```bash -bin/vh style list # see all 29 +bin/vh style list # see all 31 bin/vh new promo launch-film --style cutout-jazz # attach a style as a reference (several: --style a,b) ``` @@ -342,7 +342,7 @@ OpenVideoHarness/ ├── video-types/ workflows for the 9 video types (09 experimental) ├── playbook/ shared know-how 00–12: pipeline, checks, motion, sound, effects, narrative, hooks and covers, composition, concept ├── templates/ files each new project fills in: brief, storyboard, style, review, decisions, notes, lessons, checklist; script, character and packaging when needed -├── styles/ 29 styles, each with a sample; _swatch/ renders the samples +├── styles/ 31 styles, each with a sample; _swatch/ renders the samples ├── recipes/ shot recipes: how a shot moves (frames, critical values, pitfalls) + pacing skeletons for whole films ├── cases/ 13 case studies + curated community work + a 3D long-form deep-dive ├── showcase/ films made with this repo (source + final + process notes) diff --git a/README.zh-CN.md b/README.zh-CN.md index 2dfcf1e..133e962 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ **让 Claude Code、Codex 这类写代码的 AI,用写程序的方式做视频,而且做得稳。** -科普、讲解、产品片、MV、数据、论文、手绘、梗图快剪、真人素材剪辑:9 类视频(09 实验中),29 种风格,一套流程。 +科普、讲解、产品片、MV、数据、论文、手绘、梗图快剪、真人素材剪辑:9 类视频(09 实验中),31 种风格,一套流程。 [English](README.md) · **中文** · [Wiki](https://github.com/ZLHad/OpenVideoHarness/wiki) @@ -12,7 +12,7 @@ ![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) ![Voice](https://img.shields.io/badge/voice-Qwen3--TTS%20zh%20%7C%20en-purple) -![Styles](https://img.shields.io/badge/styles-29-green) +![Styles](https://img.shields.io/badge/styles-31-green) OpenVideoHarness 介绍片 @@ -32,7 +32,7 @@ OpenVideoHarness 就是这套行规,写成了 AI 能照着执行的文档和 - **按片子类型给做法**:你说"做个科普"或"做个发布片",它就去读那一类的工作流:用哪个引擎、分几步、什么算好看、什么不许做。 - **三个节点停下来问你**:大纲、分镜、初版,每到一处都等你点头再往下做。方向在最便宜的时候定下来。只想快速试一版时,可以切到"快出"档,直接出片。 -- **不止一种口味**:29 个从名作里学来的风格,每个都附一段真渲的样片,开工前让你挑。 +- **不止一种口味**:31 个从名作里学来的风格,每个都附一段真渲的样片,开工前让你挑。 - **自己检查自己**:AI 看不了视频,也听不了声音。所以让它看渲染出来的帧、测混音的数据,照着清单改到合格;整片还要交给一个没参与制作的 reviewer 打分。 - **声音一起管**:中英配音(本地开源模型)、双语字幕、用代码写配乐和音效、混音和质检。 @@ -95,8 +95,8 @@ cd ~/OpenVideoHarness && claude 04 · 介绍片(一镜到底 3D)(HyperFrames + Three.js · 81 秒 · 1920×1080)
本仓库自己的产品片,一个连续的 3D 长镜头。
提示词(关卡 ① 时改的方向):“一镜到底 动画动效 音乐动态字等风格 叙事感 科幻感大片感”
建议工作流(精品档,--effort studio):
bin/vh new promo intro-film --style monumental-scifi
一镜到底 3D 见 playbook/08;bin/vh sfx place、sheet、check
声音:代码写的电影感配乐(D 小调,90 BPM)和 97 个音效;中英软字幕轨 -29 种风格的样片,内容完全相同
▶ 带声音的连播(42 秒) -29 种风格,连播(每段 5 秒)
同一段内容,换 29 种从名作里学来的风格。
挂一种当参考:
bin/vh new promo launch-film --style cutout-jazz
bin/vh style list 列出全部 29 种,详见 styles/README.md
声音:每段样片一首配乐(bin/vh music,mix … profile=swatch) +31 种风格的样片,内容完全相同
▶ 带声音的连播(42 秒) +31 种风格,连播(每段 5 秒)
同一段内容,换 31 种从名作里学来的风格。
挂一种当参考:
bin/vh new promo launch-film --style cutout-jazz
bin/vh style list 列出全部 31 种,详见 styles/README.md
声音:每段样片一首配乐(bin/vh music,mix … profile=swatch) @@ -121,11 +121,11 @@ GIF 是压缩过的无声预览。你做出来的片子也欢迎 PR 进 `showcas -## 29 种风格,不止一种口味 +## 31 种风格,不止一种口味 AI 做的视频很容易长成一个样子:暗底、发光、玻璃卡片、满屏动态 UI。你不说,它就往这个方向走。 -所以我们从名作里学了 29 种风格,放在 [`styles/`](styles/),分成六类:电影片头、品牌发布、数据讲解、插画印刷、中国美学、复古科技。学习对象包括: +所以我们从名作里学了 31 种风格,放在 [`styles/`](styles/),分成六类:电影片头、品牌发布、数据讲解、插画印刷、中国美学、复古科技。学习对象包括: - **电影和片头**:Saul Bass 的片头、《七宗罪》、《银翼杀手 2049》、韦斯·安德森的对称构图、王家卫的抽帧、Aardman 和 Laika 的定格微缩; - **设计**:瑞士网格、3Blue1Brown、《纽约时报》的数据图、纽拉特的图形统计(Isotype); - **动画和印刷**:《蜘蛛侠:平行宇宙》的网点、超级任天堂的 16 位像素; @@ -133,14 +133,14 @@ AI 做的视频很容易长成一个样子:暗底、发光、玻璃卡片、 每种风格都写成一份 AI 能照着做的说明:用什么颜色和字体、怎么构图、东西怎么动、怎么转场、配什么声音、哪些俗套不许碰。 -**每种风格都用本仓库真渲了一段 5 秒样片**,配乐也是各自用代码写的。29 段样片的内容一模一样,差别只在风格: +**每种风格都用本仓库真渲了一段 5 秒样片**,配乐也是各自用代码写的。31 段样片的内容一模一样,差别只在风格: -29 种风格的样片,内容完全相同 +31 种风格的样片,内容完全相同 连着看的版本在 [`styles/gallery.mp4`](styles/gallery.mp4),每段带着自己的声音。用法: ```bash -bin/vh style list # 看 29 种风格 +bin/vh style list # 看 31 种风格 bin/vh new promo launch-film --style cutout-jazz # 挂上一种风格当参考(几种:--style a,b) ``` @@ -390,7 +390,7 @@ OpenVideoHarness/ ├── video-types/ 9 类视频的工作流(09 实验中) ├── playbook/ 通用知识 00–12:流程、自查、运动设计、声音、特效、叙事、钩子与封面、作曲、立意等 ├── templates/ 每个新项目要填的文件:需求、分镜、风格、审阅、决定、笔记、经验、清单;按需再加旁白稿、角色、标题封面 -├── styles/ 29 种风格,各带样片;_swatch/ 是样片渲染器 +├── styles/ 31 种风格,各带样片;_swatch/ 是样片渲染器 ├── recipes/ 镜头配方:一镜怎么动(帧数、命门、坑)+ 整支片子的节奏骨架 ├── cases/ 13 个案例拆解 + 社区作品精选 + 一支 3D 长片深读 ├── showcase/ 本仓库自己做的片子(源码 + 成片 + 过程记录) diff --git a/playbook/04-audio.md b/playbook/04-audio.md index 4169f83..c8eda25 100644 --- a/playbook/04-audio.md +++ b/playbook/04-audio.md @@ -350,7 +350,7 @@ bin/vh beats <任意音乐文件> # 外来音乐的节 | 风格(`styles/` 里的例子) | 转场 | |---|---| - | 发布片、keynote、UI、机械(product-keynote、fui-hud、clockwork-map) | swoosh_tonal,短而亮的 whoosh | + | 发布片、keynote、UI、机械(product-keynote、fui-hud、clockwork-map、pastel-ui 的发送和甩出、y2k-chrome 的铬管) | swoosh_tonal,短而亮的 whoosh | | 科幻、宏大(monumental-scifi;dunhuang-mural 的飞天用柔和的一种) | swoosh_tonal(tone 高一点),长而低的 whoosh | | 卡通、扁平、漫画、快摇(bouncy-flat-2d、halftone-comic、pixel-16bit、neon-step-print、swiss-grid-type,symmetry-pastel 的甩镜) | whip,配 pop | | 数据、讲解、纪录、留白多的(editorial-data、bubble-chart-story、archival-pan-zoom、ink-wash;dark-math 干脆不加) | air,轻的 whoosh | diff --git a/styles/README.md b/styles/README.md index 91d13d3..68635c9 100644 --- a/styles/README.md +++ b/styles/README.md @@ -1,10 +1,10 @@ -# 风格库:从名作里学来的 29 种口味 +# 风格库:从名作里学来的 31 种口味 社区里用 Opus 5.5 做的视频,很大一部分长得差不多:暗底、发光、玻璃卡片、动态 UI。athemeroy 的主题 × 风格图谱里,"广告/发布片"这一行有 154 个文件落在"动态图形/界面"一格,是这一行最大的一格,是第二大的"三维渲染"(25)的 6 倍多(见 `references/repos/opus55-guide-athemeroy/docs/domain-style-atlas.zh-CN.md`)。agent 默认会走向最常见的那一种,所以这里给它一个明确的选择空间。 -这里的每个风格都从名作里提炼出一套语法:色板、字体、构图、运动、转场、声音。每个风格都**用本仓库的工具真渲了一段 5 秒样片**。29 段样片的内容完全一样,都是标题"每一帧,都是代码。"加上"大纲 → 分镜 → 初版"三元素,差别只来自风格。 +这里的每个风格都从名作里提炼出一套语法:色板、字体、构图、运动、转场、声音。每个风格都**用本仓库的工具真渲了一段 5 秒样片**。31 段样片的内容完全一样,都是标题"每一帧,都是代码。"加上"大纲 → 分镜 → 初版"三元素,差别只来自风格。 -![29 种风格的样片封面](gallery.jpg) +![31 种风格的样片封面](gallery.jpg) 连播版:[`gallery.mp4`](gallery.mp4)(每种风格约 1.5 秒,带各自的声音)。 @@ -14,14 +14,14 @@ 2. **建项目时挂上参考**: ```bash - bin/vh style list # 29 个风格、家族、有没有样片 + bin/vh style list # 31 个风格、家族、有没有样片 bin/vh new promo launch-film --style cutout-jazz # 副本放进项目的 style-refs/,BRIEF 的 Style refs 记上它;可以 --style a,b 挂几个 ``` 3. **预设是参考,不是模板**。项目的 `STYLE.md` 从立意写起;从预设里借了什么(色板、字体、转场、声音、质感),在 `DECISIONS.md` 记一行,和它不一样不用解释。预设里的数字是样片的值,换场景就按理由重新定。 4. **可以混搭**:从几个预设里各借一样,例如这个的色板、那个的转场。叠两种质感(半调加水墨、扫描线加纸纹)很容易糊,真要叠,先渲一小段看。 5. **自查**:按 `templates/TASTE_CHECKLIST.md`;借来的那部分,可以顺手看一眼原预设的"自查重点"。 -## 29 个风格 +## 31 个风格 每个目录里有: - `STYLE.md`:学习对象、视觉语法、运动语法、声音语法、适合与不适合、禁止项、prompt 块、引擎做法、自查重点; @@ -59,6 +59,7 @@ | [气泡图现场讲](bubble-chart-story/) `bubble-chart-story` | Hans Rosling《200 个国家,200 年,4 分钟》(2010) | 一张气泡图就是舞台,时间可以快进倒回 | 群体随时间的变化 | | [电影界面 HUD](fui-hud/) `fui-hud` | 《火星救援》《遗落战境》《银翼杀手 2049》的屏幕设计 | 点阵、发丝线、等宽读数,每个读数都是真数据 | 系统、监测、agent 的内部视角 | | [图形统计](isotype/) `isotype` | 维也纳图形统计法(Isotype):《社会与经济》图集(1930)、Gerd Arntz 的符号 | 一个符号代表固定数量,多就多排几个,在拍点上加一个、减一个 | 人口、资源、比例这类计数(类型 05) | +| [浅色界面讲解](pastel-ui/) `pastel-ui` | 《你问 AI 一句话之后的 3 秒钟》(2026)、Material You(2021) | 粉彩墙纸上的白色圆角卡片,一个概念一个颜色从头带到尾,东西被打出来、发出去、打上勾 | 过程讲解、App 和 SaaS 的上手引导、轻量的数据 | ### 插画与印刷 @@ -87,6 +88,7 @@ | [CRT 终端](crt-terminal/) `crt-terminal` | 《异形》飞船屏幕、《战争游戏》、VT100 | 单色荧光、等宽字符、打字和余辉 | 代码、黑客、系统日志 | | [合成器浪潮](synthwave-outrun/) `synthwave-outrun` | 《电子世界争霸战》(1982)、《Out Run》(1986) | 条纹落日、霓虹透视网格、铬字、录像带质感 | 复古科技、游戏、强节拍片头 | | [16 位像素](pixel-16bit/) `pixel-16bit` | 《Chrono Trigger》(1995)、《塞尔达传说 众神的三角力量》(1991)、超级任天堂的马赛克和调色板 | 320 × 180 整数倍放大、固定 24 色,对话窗逐字打出,马赛克转场 | 把事情讲成游戏系统:升级、背包、存档 | +| [Y2K 铬面](y2k-chrome/) `y2k-chrome` | *Wipeout*(1995,The Designers Republic)、Mac OS X 的 Aqua 界面(2000) | 充了气的液态铬字映着一间看不见的摄影棚,酸黄底、模切贴纸,一团铬踩着拍子跳过三块关卡砖,进场带弹性 | 品牌片和 showreel 里好玩的一段、科技梗快剪、流行 MV、游戏预告 | 本仓库自己的介绍片([`showcase/04-intro-film/`](../showcase/04-intro-film/))属于"纪念碑科幻"一族;showcase 01 和 03 分别接近"水彩田园"和"暗底数学"。 @@ -125,6 +127,8 @@ | `crt-terminal` | 终端小喇叭的冷极简 | 60 Hz 电源嗡声、失谐方波琶音、调制解调器哔声 | 干声,很多段只有嗡声 | | `synthwave-outrun` | 合成器浪潮 | supersaw 和弦、门限混响军鼓、四拍底鼓、锯齿贝斯 | 80 年代的门限混响,贝斯跟着底鼓一拍一泵 | | `pixel-16bit` | 超任式的 JRPG | 带乒乓回声的方波主旋律、拨弦、铜管垫、三角波低音 | 16 位采样加硬件回声,不是 8 位芯片乐 | +| `pastel-ui` | 界面提示音放大成的小曲 | 卡林巴、Rhodes、拨弦低音提琴、高处的弦乐 pad、沙锤 | 每个事件都是卡林巴的一个音,对勾一级比一级高,最响在 2–4 s | +| `y2k-chrome` | 千禧年泡泡糖电子舞曲 | 四拍底鼓、拍手、方波贝斯、808 低音、风琴和弦、钟琴 | 几乎每拍一个动作一个声音,没有屏息段,"停"是高光还在滑、分数还在跳 | **怎么知道它们听起来不一样**:agent 听不了,只能量。每段配乐取前 5 s,算 7 个特征:谱质心、起音率、低中高三段的能量占比、立体声宽度、打击乐占比;按旧配乐和引擎示范合在一起的均值和标准差换成 z 分数,再算两两之间的距离。旧配乐 28 份出自同一套减法合成器,全是单声道。重写以后,成对距离的中位数从 2.27 升到 3.71,最近的一对从 0.52(bubble-chart-story 和 risograph)拉开到 1.22(guochao-festive 和 scratched-type),378 对里没有一对小于 1.2;在现在这版样片的配乐上重量一遍,数字不变。这把尺子只有 7 个统计量,听不出旋律、和声,也分不出是不是同一种乐器;好不好听,还是要人听。 diff --git a/styles/_swatch/README.md b/styles/_swatch/README.md index 314eacd..2ef7071 100644 --- a/styles/_swatch/README.md +++ b/styles/_swatch/README.md @@ -238,7 +238,7 @@ def apply(t, env): … # 按 t(= k/30)直接设好每个会动的属 ## 画廊 -`gallery.py` 扫描 `styles/*/media/poster.jpg`(跳过 `_` 开头的文件夹),拼成 5 列的 `styles/gallery.jpg`(≤ 2 MB),每张下面写风格名。风格名取 STYLE.md 第一行 `# <风格名> · `,没有就取 tokens.json 的 `name`,再没有就用 slug。加 `--mp4` 时还会生成 `styles/gallery.mp4`:每个样片取 1.9–3.4 s,叠上名字,按顺序硬切,1280×720,≤ 12 MB。声音就是每个样片自己那一段,接缝处做 30 ms 的等功率交叉淡化(不爆音,也不凹下去),整条的开头和结尾各淡入淡出 30 ms;没有音轨的样片那一段垫房间底噪,不留数字静音,并打印一条警告。音频是 AAC 160k。实测 29 条(2026-10-02):jpg 1.06 MB,mp4 7.2 MB(43.5 s,CRF 23)。 +`gallery.py` 扫描 `styles/*/media/poster.jpg`(跳过 `_` 开头的文件夹),拼成 5 列的 `styles/gallery.jpg`(≤ 2 MB),每张下面写风格名。风格名取 STYLE.md 第一行 `# <风格名> · `,没有就取 tokens.json 的 `name`,再没有就用 slug。加 `--mp4` 时还会生成 `styles/gallery.mp4`:每个样片取 1.9–3.4 s,叠上名字,按顺序硬切,1280×720,≤ 12 MB。声音就是每个样片自己那一段,接缝处做 30 ms 的等功率交叉淡化(不爆音,也不凹下去),整条的开头和结尾各淡入淡出 30 ms;没有音轨的样片那一段垫房间底噪,不留数字静音,并打印一条警告。音频是 AAC 160k。实测 31 条(2026-10-02):jpg 1.13 MB,mp4 7.65 MB(46.5 s,CRF 23)。 ## 给预设作者的坑 diff --git a/styles/gallery.jpg b/styles/gallery.jpg index 408caca..7eb2d5a 100644 Binary files a/styles/gallery.jpg and b/styles/gallery.jpg differ diff --git a/styles/gallery.mp4 b/styles/gallery.mp4 index 606617d..9fb472e 100644 Binary files a/styles/gallery.mp4 and b/styles/gallery.mp4 differ diff --git a/styles/pastel-ui/STYLE.md b/styles/pastel-ui/STYLE.md new file mode 100644 index 0000000..25591b6 --- /dev/null +++ b/styles/pastel-ui/STYLE.md @@ -0,0 +1,114 @@ +# 浅色界面讲解 · pastel-ui + +一句话:把讲解装进一个干净、友好的界面里。粉彩渐变墙纸上放白色圆角卡片,界面只有一个蓝紫强调色,每个概念各有一个颜色、从头带到尾,一次只聚焦一张卡片,最后答案本身变成画面。动起来像一个做得好的操作系统:弹簧只过冲一两个百分点,字被打出来、发出去,卡片被选中、被点开。适合"X 是怎么运作的":知识短视频、App 和 SaaS 的功能讲解、轻量的数据故事。 + +样片:`media/swatch.mp4`(5 s,含配乐和拟音)· 封面 `media/poster.jpg`(t = 3.0 s) + +样片里:第 0 帧是粉彩墙纸,五团大色块(薰衣草两团,桃、薄荷、丁香各一团)慢慢漂。0.07 s 画面中部的白色输入框亮起,一个粗描边的胶囊从它身上长出来,铺到画面四分之一以上,圈身分三段、颜色饱满,正是后面三个概念的颜色(珊瑚、薄荷绿、向日葵);光圈散去,一圈同样三色的细环留在输入框外慢慢转。0.17–0.73 s 打出 "Every frame is code.",0.8 s 在小节线上发送:它变成蓝紫气泡,沿弧线飞到上方,1.2 s 前停稳。1.0 s 底部出现"正在输入",1.1 s 中间先浮出三张骨架卡片;1.24–1.53 s 回复逐词流进来,"代码"是强调色,这就是中文字幕。2.0–2.24 s 三张卡片盖掉骨架抬进来:大纲(要点逐行画出)、分镜(六格里一个球往前跳)、初版(正在渲染的预览,内容是这面墙纸的缩小版)。白点沿下面的轨道走,2.4 / 2.8 / 3.2 s 每到一个节点,节点填色、荡一圈波纹,焦点移过去:那张卡片放大到 1.2 倍,另外两张退到 60% 不透明,`STEP n/3` 滚一格。3.3 s 初版开始回放,球跳两下,卡片同时被"拿起来":原地往上抬、再放大一点;3.6 s 三个节点依次再荡一圈。4.0 s 初版卡片展开成整个屏幕(容器变换),它的预览变成画面本身:球跳完最后一下落地,底部三色进度条走到头,"Pastel UI / 浅色界面讲解"落在中间,下面依次弹出三个概念签(大纲、分镜、初版,各带自己的颜色)。实际字体:Avenir Next Demi Bold / Medium、PingFang SC Semibold、Menlo Regular。配乐 150 BPM、D 大调:卡林巴、Rhodes、拨弦低音提琴、弦乐 pad、响指。 + +## 学习对象 + +| 作品 | 年份 · 出处 | 从它身上学什么 | +|---|---|---| +| 《你问 AI 一句话之后的 3 秒钟》 | 2026-09-29 · 科了个喵,抖音(拆片见 `cases/oneshot-five.md` 第 1 节) | 浅色界面讲解可以很耐看:柔和的三色底、白卡片、一个蓝紫强调色;每个 token 一个颜色一路带到输出;常驻读数告诉观众走到哪了;结尾答案变成画面 | +| Material You(Material Design 3) | 2021 · Google,2021-05-18 在 Google I/O 上和 Android 12 预览版一起公布(当时 Android 12 还在 beta) | 动态取色:从墙纸里提取颜色,生成一套柔和的色调板,界面跟着墙纸换色 | + +核实(2026-10-02):科了个喵那支片子的日期、配色和读数以 `cases/oneshot-five.md` 的拆片为准;Material You 的公布场合、日期和动态取色按维护方核对过的事实写。 + +**不照搬**:开场在输入框里打字再发送,和原片前 5 秒是同一种镜头(原片打的是一个问题)。样片保留它,因为"打出来、发出去"是这个风格的签名;换掉的是节奏(0.8 s 内发出)和颜色的来历(三个概念色从输入框里长出来)。想彻底避开,可以让标题以一条通知或一张卡片进场。其余不用原片的镜头和它的"回车后 x 秒 / 慢放 ×N"读数,不用 Google 的组件和字体,聊天界面是通用的,不仿任何真实 App。 + +## 视觉语法 + +数字是样片在 1920×1080 里的值,换题材、换画幅时按理由调。 + +- **色板**: + + | 角色 | 颜色 | 含义 | + |---|---|---| + | 墙纸 | 底 `#F6F4FB`;薰衣草 `#DCD2FA`、桃 `#FFD9C7`、薄荷 `#C9EEDF`、丁香 `#EBD3F2` | 只是气氛 | + | 卡片、输入框 | `#FFFFFF` | 界面本身 | + | 主字 / 次要字 | `#1E2034` / `#4F5270` | 所有要读的字(次要字对白底 7.6:1) | + | 强调色 | `#5B5BEF` 蓝紫 | 界面在做事:发送键、我发出的气泡、关键词、轨道上的白点和播放头 | + | 概念色 | 珊瑚 `#FF8A66`、薄荷绿 `#2DBE94`、向日葵 `#F2B33D` | 一个概念一个颜色,开场的光圈、卡片、轨道、结尾的进度条都是同一个 | + | 阴影 | `#3A2E80`,11% 和 7% | 带一点紫,比灰黑的软 | + + 强调色只有一个;概念色可以有几个,每个只代表一样东西。概念色对白底只有 1.9–2.4:1,只做色块和填充,不写字;要读的字用主字、次要字,或白字配强调色(5.0:1)。概念多于五六个就按组上色。 +- **字体**:Avenir Next(标题 Demi Bold,标签 Medium),像系统界面;中文 PingFang SC Semibold;读数 Menlo。换圆体更童趣,但少一点"真界面"的感觉。 +- **字号**:按 BRIEF 的 `Watch on` 定下限(`playbook/03-motion-design.md` §4,1080p 合成里的 px): + + | 在哪看 | 主标题 | 辅助、标签、读数 | 字幕 | + |---|---|---|---| + | `phone` | ≥ 84 | ≥ 44 | ≥ 65 | + | `desktop` | ≥ 84 | ≥ 44 | ≥ 48 | + | `feed` | ≥ 150 | ≥ 80 | ≥ 115 | + + 样片按 `desktop` 做:过下限的是停稳后的标题,92 px(≥ 84)。打字的 0.6 s 里它是输入框中 72 px 的字,这是一个过渡状态,按标题算并不够 84,样片是靠"发出去就长大"让它过线的;正片里如果标题要在输入框里停留,就把输入框里的字也做到 84 以上。卡片中文 56 px、英文 44 px(聚焦时 67 / 53 px),读数 46 px,字幕 60 px,片名 124 / 64 px,结尾的概念签 56 px。这套版式过不了 `feed` 的下限,发信息流要重排。把 App 的字号(14–17 pt)照搬进视频是最常见的错。 +- **构图**:开场只有一个居中的输入框;之后分三条带:顶上标题,中间图解,底部字幕。一排卡片同时只有一张是主角,其余退后但仍读得出。留出至少三分之一的墙纸。竖屏时卡片竖着叠。 +- **形状和阴影**:卡片圆角 36 px,输入框、气泡、字幕是胶囊;阴影两层(大而淡加小而实),聚焦时加深。 +- **质感**:墙纸是几团很大的径向渐变,衰减接近高斯,各自以 ±50 px 慢慢漂;盖一层固定的细噪声(约 ±1.5 个色阶)防色带。不加颗粒。磨砂玻璃最多用在一个有功能的地方(固定的顶栏)。 + +## 运动语法 + +- **弹簧**:进场都用闭式弹簧(`lib.spring`),默认 `w 13, ζ 0.8`,过冲约 1.5%;停稳用 `ζ 0.9`。只有节点、小圆点这种"冒出来"的小东西用 `ζ 0.62`(约 8%)。 +- **先有因,再有果**:打字 → 发送 → 正在输入 → 回答流进来;白点到节点 → 焦点移过去 → 读数加一。跟着因果走,不用旁白也看得懂。 +- **焦点**:讲到哪一步,哪张卡片就放大,其余退后,每一拍都是看得出不同的画面。同一件事只用一两种方式表达(样片只留轨道和 `STEP n/3`)。 +- **路径**:东西从一处飞到另一处时,纵向比横向快一点,走弧线。 +- **占位**:内容还没到的地方放骨架卡片加一道加载扫光,真内容到了直接盖上去,等待也有东西看。 +- **文字动画**:签名是"打出来、发出去":标题先逐键打出,再变成气泡;回答逐词流进来,气泡先长、字后到;读数的数字往上滚。 +- **转场**:样片的招牌是容器变换:被聚焦的卡片展开成整个屏幕,里面的内容变成下一屏。同家族的还有信息流滚动(一甩,按惯性滚过一屏,越过停靠点一点再弹回)。不用交叉淡化,不用圆形遮罩。 +- **镜头**:没有摄影机。动的是界面:聚焦、点开、滚动。 +- **读数**:要真的在数东西(步骤、进度、计时),并且和画面上别的进度表示不重复。 + +## 声音语法 + +- **配乐**:轻、干净的器乐,像 App 的提示音放大成一首曲子。样片 150 BPM、D 大调,小节拍数 2/3/3/2/3,段落落在 0.8、2.0、3.2、4.0 s:卡林巴(`kalimba`)担当所有事件(光圈一个和弦,发送三个上行音,焦点每移一次高一级,展开一串琶音,落地一个和弦);Rhodes 每段一个和弦;拨弦低音提琴走根音;弦乐 pad 从头垫到尾,不会有数字静音;2.0–4.0 s 反拍上一层轻响指(`clap` 的 `o`)。邻近的 `bubble-chart-story` 用马林巴加沙锤,这里刻意避开。 +- **音效**:全是界面声音,小、亮、近:`air` 配光圈,`typing`,发送是 `click` 加短的 `swoosh_tonal`,`pop` 配东西出现和球落地,`tick` 配小读点,`toggle` 配焦点移动(固定提示音,三次一样是故意的),`whoosh` 往上配卡片展开,`shimmer`(降 7 个半音,落在 D 五声音阶上)配片名。声像按物体的 x 算:`pan = (2x/1920 − 1) × 0.75`。发送、落位、焦点移动都和卡林巴落在同一帧。 + +## 适合与不适合 + +- **适合**:02 知识短视频里的流程讲解(一次请求、一次支付);03 App、SaaS 的功能讲解;05 轻量的数据故事。 +- **不适合**:沉重的题材(粉彩显得轻佻);奢侈品和电影感品牌片;信息极密的长表格;需要手作笔触的作品。 +- **容易被误用成**:通用的"AI SaaS 模板片",或者某个真实 App 的仿冒界面。 + +## 容易翻车的地方 + +反着做也可以,但要知道换来了什么。 + +1. **粉彩上写浅色字**:底已经很浅,字再用灰或概念色,手机上第一个看不清。 +2. **颜色变成装饰**:同一个概念前后换色,"一个概念一个颜色"这条线就断了。 +3. **离"AI 默认片"只差一步**:加了紫色光晕、渐变字、满屏磨砂,就成了 `TASTE_CHECKLIST` 第 10 条那种片子。开场的光用概念色、做成清楚的形状,别做成一团发光的紫球。 +4. **同一件事说四遍**:打勾、亮灯、读数、进度条同时表示进度,画面挤成仪表盘,每一拍又都差不多。选一两种,力气留给焦点。 +5. **整片都在弹**:每个元素都过冲 10%,看起来像儿童 App。 +6. **读数不数东西**:和内容无关的跳动数字是装饰(`TASTE_CHECKLIST` 第 20 条)。 +7. **渐变出色带**:大面积柔和渐变在 8 bit 和 H.264 里会出一圈圈台阶。墙纸画进不透明图层、衰减取接近高斯、再加一层固定噪声,代价是码率多约 30%。 + +## Prompt 块 + +```text +Visual style: a light, friendly UI explainer. A soft pastel mesh wallpaper (lavender, peach, mint) of large gaussian-falloff blobs drifting slowly, finely dithered against banding. White rounded cards, pill input, bubbles and caption on it, each with a two-layer violet-tinted soft shadow. One blue-violet accent for what the interface does (send, my message, the key word, the walking dot); each concept owns one colour (coral, mint, sunflower…) carried everywhere it appears, opening to end; concept colours are chips and fills, never text. One card is the focus at a time: it scales to about 1.2× with a deeper shadow while the others step back to about 60 % opacity; progress is shown once (a stepper rail with a walking dot and a monospace step readout). One geometric-humanist sans in two weights, a semibold Chinese sans, a monospace for numbers, at the target screen's size floors. Motion like a good OS: springs with 1–2 % overshoot, a visible bounce only on small pops, curved paths, a title typed and sent as a bubble, an answer streamed token by token into a bubble that grows ahead of it, skeleton cards holding space. The interface can switch on with a crisp capsule of the concept colours growing out of the input (no glow). Signature transition: a container transform, the focused card opening into the full screen so its content becomes the next picture. No camera, no crossfades, no circle wipes. Sound: light instrumental around 150 BPM, major: kalimba on every event, electric piano one chord per section, plucked upright bass, a string pad underneath throughout, soft offbeat finger snaps in the busiest section; UI foley panned to where each thing is. +``` + +## 引擎做法 + +- **首选 HyperFrames**。正片里界面用 DOM 和 CSS 最自然(真文字、`border-radius`、`box-shadow`、`backdrop-filter`),但每个属性都由 t 驱动(GSAP 时间线 seek 或 t 的函数),不用 CSS transition 和 `@keyframes`。样片画在一张 canvas 上(`swatch.js`)。 +- **墙纸**:径向渐变画进不透明离屏图层再整层贴上,色标取近似高斯的五点(1、0.88、0.58、0.24、0),加固定噪声 `lib.grain(layer, 0, {amount: 0.03, mode: "source-over"})`(t 固定为 0,不闪)。 +- **三色胶囊**:`createConicGradient` 以输入框中心为圆心、在三分之一处硬切色标,描一个胶囊;0.55 s 从输入框尺寸长到 1860×620,`outExpo`,线宽 46 → 14,过半才开始淡。细而淡的描边在粉彩上几乎看不见。 +- **焦点**:`f = spring(t − t_k) − spring(t − t_{k+1})`,缩放 `1 + 0.2 f`,其余按 `1 − f` 退后,按 f 排序绘制。 +- **容器变换**:卡片矩形用 `cubic-bezier(0.2,0,0,1)` 插值到整屏,圆角归零,卡片自己的内容在前 30% 淡出;预览矩形同步插值到整屏,内容按归一化坐标画,球的跳跃在放大中是连续的。预览画的是同一帧的墙纸图层,所以展开后就是背景本身。 +- **打字和流式回答**:落键帧和回答的最终版式都在 setup 里算好;气泡宽度用一串弹簧追到每个词的右边界,比词早 0.09 s 起步,字裁在气泡里。 +- **概念色**:放在 `tokens.json` 的 `palette.entity`,所有地方按下标取。轨道的进度用分段渐变,只在节点附近短短过渡(珊瑚直接渐变到薄荷,中间会发灰发褐)。 +- **拟音**:`swatch.js` 末尾的 `FOLEY` 引用画面的时间常量,`node styles/_swatch/foley.mjs pastel-ui` 生成 `events.json`。 + +## 自查重点 + +- **对比度**:要读的字是不是都是深色或白字配强调色;退后的卡片在 60% 不透明下还读得出吗。 +- **颜色一致**:同一个概念在光圈、卡片、轨道、进度条上是不是同一个颜色。 +- **每一拍不同**:1 fps 联系表上,焦点移动的几拍是不是明显不同的画面。 +- **交接帧**:逐帧看发送、第一个流出来的词(不能跑出气泡)、容器展开的头两帧。 +- **色带和目标屏**:成片上 1:1 裁一块墙纸;按 `Watch on` 缩到对应宽度看一遍,退后卡片的英文标签最先看不清。 + +## 相关资源 + +- lemo-opuscar 的 `living-screencast`(LemoLab,MIT)是近亲:真实产品的界面重画成矢量、镜头在屏幕里推拉,追求"像录屏";这里是通用的讲解界面,靠概念色、焦点卡片和"答案变成画面"。见 `references/repos/lemo-opuscar/styles/living-screencast/STYLE.md`。 +- 本库:`fui-hud`(暗底电影界面)、`bubble-chart-story`(同为讲解,舞台是一张图)、`product-keynote`。 +- `cases/oneshot-five.md` 第 1 节;`video-types/02-knowledge-short.md`、`03-product-promo.md`、`05-data-story.md`;`playbook/03-motion-design.md` §4、`playbook/04-audio.md` 的"转场音效按风格选"。 diff --git a/styles/pastel-ui/events.json b/styles/pastel-ui/events.json new file mode 100644 index 0000000..a3ddf02 --- /dev/null +++ b/styles/pastel-ui/events.json @@ -0,0 +1,18 @@ +[ + {"t":0.1,"sfx":"air","gain_db":-8,"pan":0,"dur":0.5,"dir":"up","bright":0.35}, + {"t":0.15,"sfx":"typing","gain_db":0,"pan":-0.22,"dur":0.6}, + {"t":0.8,"sfx":"click","gain_db":-8,"pan":0.45}, + {"t":0.82,"sfx":"swoosh_tonal","gain_db":-7,"pan":0.3,"dur":0.38,"dir":"up","bright":0.3,"pan_from":0.45,"pan_to":0}, + {"t":1,"sfx":"pop","gain_db":-5,"pan":0,"pitch":3}, + {"t":1.46,"sfx":"tick","gain_db":-2,"pan":0.05}, + {"t":2.06,"sfx":"pop","gain_db":-4,"pan":-0.43}, + {"t":2.18,"sfx":"pop","gain_db":-4,"pan":0}, + {"t":2.3,"sfx":"pop","gain_db":-1,"pan":0.43}, + {"t":2.4,"sfx":"toggle","gain_db":-4,"pan":-0.43}, + {"t":2.8,"sfx":"toggle","gain_db":-6,"pan":0}, + {"t":3.2,"sfx":"toggle","gain_db":-6,"pan":0.43}, + {"t":3.3,"sfx":"tick","gain_db":-3,"pan":0.34}, + {"t":4,"sfx":"whoosh","gain_db":-9,"pan":0.2,"dur":0.5,"dir":"up","bright":0.4,"tone":0.2,"pan_from":0.45,"pan_to":0}, + {"t":4.5,"sfx":"pop","gain_db":-5,"pan":0.48,"pitch":-2}, + {"t":4.6,"sfx":"shimmer","gain_db":-10,"pan":0,"dur":0.8,"pitch":-7} +] diff --git a/styles/pastel-ui/media/poster.jpg b/styles/pastel-ui/media/poster.jpg new file mode 100644 index 0000000..1c95e91 Binary files /dev/null and b/styles/pastel-ui/media/poster.jpg differ diff --git a/styles/pastel-ui/media/swatch.mp4 b/styles/pastel-ui/media/swatch.mp4 new file mode 100644 index 0000000..9995014 Binary files /dev/null and b/styles/pastel-ui/media/swatch.mp4 differ diff --git a/styles/pastel-ui/score.json b/styles/pastel-ui/score.json new file mode 100644 index 0000000..88ce474 --- /dev/null +++ b/styles/pastel-ui/score.json @@ -0,0 +1,41 @@ +{ + "bpm": 150, "key": "D", "mode": "major", "seed": 23, + "meters": {"1": 2, "2": 3, "3": 3, "4": 2, "5": 3}, + "stereo": true, "space": {"type": "plate", "rt60": 1.3}, + "sections": [ + {"name": "wake", "bars": 1, "chords": ["Imaj7"]}, + {"name": "send", "bars": 1, "chords": ["vi7"]}, + {"name": "steps", "bars": 1, "chords": ["IVmaj7"]}, + {"name": "play", "bars": 1, "chords": ["Vsus4"]}, + {"name": "open", "bars": 1, "chords": ["Iadd9"]} + ], + "parts": [ + {"inst": "kalimba", "id": "tines", "loop": 5, "send": 0.32, "pan": -0.28, "gain_db": -2, + "pattern": [ + [0.25, 1.5, ["D5", "F#5", "A5"], 0.85], + [0.5, 0.375, "A5", 0.42], [0.875, 0.375, "F#5", 0.38], [1.25, 0.375, "E5", 0.42], [1.625, 0.375, "D5", 0.38], + [2.0, 0.25, "F#5", 0.75], [2.25, 0.25, "A5", 0.7], [2.5, 0.5, "B5", 0.72], + [3.1, 0.5, "D6", 0.5], [3.65, 0.75, "F#5", 0.6], + [5.15, 0.3, "B4", 0.62], [5.45, 0.3, "D5", 0.62], [5.75, 0.3, "F#5", 0.62], + [6.0, 1.0, "F#5", 0.8], [7.0, 1.0, "A5", 0.82], [8.0, 1.0, ["A5", "D6"], 0.85], + [8.25, 0.25, "E5", 0.5], [9.0, 0.5, "A5", 0.55], [9.7, 0.3, "D6", 0.5], + [10.0, 0.25, "D5", 0.72], [10.25, 0.25, "F#5", 0.66], [10.5, 0.25, "A5", 0.64], + [11.25, 1.5, ["F#5", "A5", "D6"], 0.75], [12.0, 1.0, "A5", 0.35] + ]}, + {"inst": "epiano", "id": "keys", "loop": 5, "gain_db": -11, "pan": 0.32, "send": 0.3, + "pattern": [ + [0.25, 1.75, ["F#3", "A3", "C#4", "E4"], 0.55], [2.0, 3.0, ["F#3", "A3", "B3", "D4"], 0.5], + [5.0, 3.0, ["G3", "B3", "D4", "F#4"], 0.55], [8.0, 2.0, ["A3", "D4", "E4", "G4"], 0.55], + [10.0, 3.0, ["F#3", "A3", "D4", "E4"], 0.58] + ]}, + {"inst": "strings", "id": "pad", "figure": "sustain", "octave": 4, "voicing": "close", + "params": {"attack": 0.3, "release": 1.0}, "hp": 350, "lp": 5000, "gain_db": -16, "send": 0.5, "pan": 0.0}, + {"inst": "upright_pm", "id": "bass", "loop": 5, "gain_db": -1, "lp": 1100, "pan": -0.08, "send": 0.08, + "pattern": [ + [0.25, 1.75, "D2", 0.8], [2.0, 3.0, "B1", 0.72], + [5.0, 1.0, "G2", 0.8], [6.0, 1.0, "B2", 0.62], [7.0, 1.0, "D3", 0.62], + [8.0, 1.5, "A2", 0.75], [9.5, 0.5, "E2", 0.5], [10.0, 3.0, "D2", 0.85] + ]}, + {"inst": "clap", "id": "snaps", "sections": ["steps", "play"], "pattern": "..o...o...o...o.", "gain_db": -12, "pan": 0.38, "send": 0.15} + ] +} diff --git a/styles/pastel-ui/swatch.js b/styles/pastel-ui/swatch.js new file mode 100644 index 0000000..e9cc045 --- /dev/null +++ b/styles/pastel-ui/swatch.js @@ -0,0 +1,454 @@ +// pastel-ui swatch — a light UI explainer: a pastel mesh wallpaper, white cards with soft shadows, one blue-violet +// accent for what the interface does, one colour per concept carried from start to end, a monospace step readout, +// a caption at the bottom with its key word in the accent. Motion is an OS's: springs with a percent or two of +// overshoot, things typed and sent, a focus that moves from card to card, a card that opens into the screen. +// +// 0.0–0.8 frame 0 is the pastel wallpaper; at 0.1 s an input bar switches on mid-frame and a capsule grows out of +// it in three segments, the three concept colours (coral, mint, sunflower); a thin ring in the same three +// colours keeps turning round the bar while "Every frame is code." is typed (0.15–0.73 s) +// 0.8–2.6 sent on the bar line at 0.8 s: the line becomes a blue-violet bubble that flies up as the title; the reply +// shows typing dots, then streams in token by token as the caption, 代码 in the accent; skeleton cards hold +// the middle while the answer arrives +// 2.0–4.0 three cards lift in over the skeletons (大纲 coral, 分镜 mint, 初版 sunflower); a dot walks a stepper rail +// under them and on each beat (2.4 / 2.8 / 3.2 s) the focus moves: that card grows to 1.2×, the others step +// back to 60 %; STEP n/3 counts. The draft renders as the dot walks to it, then plays back (a ball hops +// across its preview, which is a small copy of this very wallpaper); 3.6 s a pulse runs along the rail +// 4.0–5.0 container transform: the draft card opens to full screen, its preview becomes the picture, the ball +// finishes its last hop and lands, "Pastel UI / 浅色界面讲解" settles inside it over a three-colour scrubber +// Everything is a pure function of t. FOLEY (bottom) reads the same time constants as the picture. + +export const fonts = ["Avenir Next", "PingFang SC", "Menlo"]; + +// ── one clock for picture and sound +const T_WAKE = 0.1; // the input switches on; the colour capsule grows out of it +const T_TYPE = 0.15; // first key +const T_SEND = 0.8; // send, on the bar line +const T_DOTS = 1.0; // reply: typing dots +const ZH_TOKENS = ["每一帧", ",", "都是", "代码", "。"]; +const T_TOKEN = [1.24, 1.31, 1.38, 1.46, 1.53]; // the reply streams in +const T_SKEL = 1.1; // skeleton cards hold the middle while the answer arrives +const T_CARD = [2.0, 2.12, 2.24]; // cards lift in +const T_RAIL = 2.06; // the stepper rail draws +const T_CHECK = [2.4, 2.8, 3.2]; // the dot arrives, the focus moves (on the beat) +const HOPS = [[3.3, 3.6, 0.06, 0.32, 0.8], [3.6, 3.88, 0.32, 0.56, 0.8], [4.02, 4.5, 0.56, 0.8, 0.95]]; // the draft plays: [t0, t1, u0, u1, height] +const T_PLAY = HOPS[0][0], T_LAND = HOPS[2][1]; +const T_DONE = 3.6; // all three done: a pulse runs along the rail +const T_PUSH = 4.0, PUSH_DUR = 0.5; // the draft card opens to full screen +const T_NAME = 4.52; // the name settles inside the picture + +let L = null; // layout, computed once in setup (pure data) +let MESH = null; // this frame's wallpaper canvas (set at the start of each frame, read later in the same frame) + +export async function setup(ctx, tokens, lib) { + const { W } = lib; + lib.setFont(ctx, tokens, "display", 92); + const tLay = lib.layoutText(ctx, lib.TITLE_EN, { x: 0, y: 0, tracking: lib.tracking(tokens, "display", 92) }); + lib.setFont(ctx, tokens, "body", 72); + const iLay = lib.layoutText(ctx, lib.TITLE_EN, { x: 0, y: 0 }); + lib.setFont(ctx, tokens, "zh", 60); + const zLay = lib.layoutText(ctx, lib.TITLE_ZH, { x: 0, y: 0, align: "center", tracking: lib.tracking(tokens, "zh", 60) }); + // key frames: a little quicker than one key a frame, half a frame more after each space + const keyF = [], f0 = Math.ceil(T_TYPE * lib.FPS - 1e-6); let sp = 0; + lib.graphemes(lib.TITLE_EN).forEach((ch, i) => { keyF.push(f0 + Math.floor(i * 0.85 + sp * 0.5)); if (ch === " ") sp++; }); + const tokOf = []; ZH_TOKENS.forEach((s, k) => lib.graphemes(s).forEach(() => tokOf.push(k))); + const tokRight = ZH_TOKENS.map((_, k) => Math.max(...zLay.glyphs.filter((g) => tokOf[g.i] === k).map((g) => g.x + g.w))); + const cards = lib.slots(3, { area: { x0: 168, w: 1584 }, gap: 72 }).map((s) => ({ cx: s.x, x: s.x - s.w / 2, y: 388, w: s.w, h: 324 })); + L = { + inputW: iLay.width, zhX0: zLay.x0, tokRight, keyF, tokOf, cards, + bubble: { cx: W / 2, cy: 246, w: tLay.width + 120, h: 156 }, + pill: { cx: W / 2, cy: 590, w: 1320, h: 150, size: 72, pad: 84, btn: 54 }, + cap: { cx: W / 2, cy: 924, h: 108 }, + railY: 800, + }; +} + +// ───────── helpers +const P = (tokens, lib, k) => lib.color(tokens, k); +const entity = (tokens, lib, k) => P(tokens, lib, `entity.${k}`); +function rr(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.roundRect(x, y, w, h, Math.max(0, r)); } +const lerpRect = (a, b, u, lib) => ({ x: lib.lerp(a.x, b.x, u), y: lib.lerp(a.y, b.y, u), w: lib.lerp(a.w, b.w, u), h: lib.lerp(a.h, b.h, u) }); +/** A rounded box with the two-layer soft shadow (violet-tinted); lift deepens it. */ +function softBox(ctx, tokens, lib, x, y, w, h, r, fill, lift = 0) { + const sh = P(tokens, lib, "shadow"), { ambient: A, key: K } = tokens.shadow; + ctx.save(); ctx.fillStyle = fill; + ctx.shadowColor = lib.rgba(sh, A.alpha); ctx.shadowBlur = A.blur + lift * 2.2; ctx.shadowOffsetY = A.y + lift * 1.2; + rr(ctx, x, y, w, h, r); ctx.fill(); + ctx.shadowColor = lib.rgba(sh, K.alpha); ctx.shadowBlur = K.blur + lift * 0.5; ctx.shadowOffsetY = K.y + lift * 0.3; + ctx.fill(); + ctx.restore(); +} +/** The three concept colours around a centre, as hard-edged thirds (a conic gradient), rotated by rot. */ +function thirds(ctx, tokens, lib, cx, cy, rot) { + const g = ctx.createConicGradient(rot, cx, cy), E = [0, 1, 2].map((k) => entity(tokens, lib, k)); + [[0, 0], [0.32, 0], [0.345, 1], [0.655, 1], [0.68, 2], [0.985, 2], [1, 0]].forEach(([o, k]) => g.addColorStop(o, E[k])); + return g; +} + +// ───────── wallpaper: a pastel mesh, drifting; drawn opaque into a layer so the soft gradients don't band +function ground(ctx, t, tokens, lib) { + const { W, H } = lib, m = lib.layer("pastel-ui_mesh"); + m.fillStyle = P(tokens, lib, "bg"); m.fillRect(0, 0, W, H); + const blobs = [ + ["wash_lavender", 330, 210, 1120, 0.0], ["wash_peach", 1650, 280, 980, 1.7], + ["wash_mint", 470, 1010, 960, 3.1], ["wash_lilac", 1530, 1000, 820, 4.4], ["wash_lavender", 960, 600, 620, 2.2], + ]; + for (const [k, x0, y0, r, ph] of blobs) { + const x = x0 + 46 * Math.sin(0.85 * t + ph), y = y0 + 34 * Math.cos(0.7 * t + ph * 1.3); + const c = P(tokens, lib, k), g = m.createRadialGradient(x, y, 0, x, y, r); + tokens.texture.falloff_stops.forEach((al, i, a) => g.addColorStop(i / (a.length - 1), lib.rgba(c, al))); // ≈ gaussian: no knee + m.fillStyle = g; m.fillRect(0, 0, W, H); + } + lib.grain(m, 0, { amount: tokens.texture.dither, seed: 9, mode: "source-over" }); // static dither (±1.5 levels): no 8-bit bands + ctx.drawImage(m.canvas, 0, 0); + MESH = m.canvas; +} + +// ───────── the opening: the input switches on, the three colours grow out of it, then keep turning round it +const pillOn = (t, tokens, lib) => lib.spring(t - (T_WAKE - 1 / 30), tokens.ease.spring); +function capsule(ctx, t, tokens, lib) { + const p = L.pill, t0 = T_WAKE - 1 / 30; + for (const [d, wMax, hMax, lw0, lw1, aMul] of [[0, 1860, 620, 46, 14, 1], [0.07, 1620, 400, 22, 8, 0.9]]) { + const u = lib.seg(t, t0 + d, t0 + d + 0.55); + if (u <= 0 || u >= 1) continue; + const e = lib.ease.outExpo(u), w = lib.lerp(p.w, wMax, e), h = lib.lerp(p.h, hMax, e); + ctx.save(); ctx.globalAlpha *= aMul * (1 - lib.smoothstep(0.5, 1, u)); + if (d === 0) { ctx.fillStyle = "rgba(255,255,255,0.2)"; rr(ctx, p.cx - w / 2, p.cy - h / 2, w, h, h / 2); ctx.fill(); } // a lit area, not a glow + ctx.strokeStyle = thirds(ctx, tokens, lib, p.cx, p.cy, -Math.PI / 2 + 1.2 * u); ctx.lineWidth = lib.lerp(lw0, lw1, e); + rr(ctx, p.cx - w / 2, p.cy - h / 2, w, h, h / 2); ctx.stroke(); ctx.restore(); + } + const a = lib.tween(t, t0 + 0.2, t0 + 0.4, lib.ease.outCubic) * (1 - lib.tween(t, T_SEND, T_SEND + 0.12, lib.ease.outCubic)); + if (a <= 0) return; + const sc = lib.lerp(0.86, 1, pillOn(t, tokens, lib)), w = p.w * sc + 22, h = p.h * sc + 22; + ctx.save(); ctx.globalAlpha *= a; + ctx.strokeStyle = thirds(ctx, tokens, lib, p.cx, p.cy, -Math.PI / 2 + 2.4 * t); ctx.lineWidth = 5; + rr(ctx, p.cx - w / 2, p.cy - h / 2, w, h, h / 2); ctx.stroke(); ctx.restore(); +} +function inputPill(ctx, t, tokens, lib) { + const C = (k) => P(tokens, lib, k), p = L.pill; + const rise = pillOn(t, tokens, lib), out = lib.tween(t, T_SEND + 0.04, T_SEND + 0.26, lib.easeOf(tokens.ease.exit)); + if (out >= 1 || rise <= 0) return; + const typed = t < T_SEND ? L.keyF.filter((f) => f <= lib.frame(t)).length : 0; + const y = p.cy + (1 - rise) * 36 + out * 90, sc = lib.lerp(0.86, 1, rise), x0 = p.cx - p.w / 2, y0 = y - p.h / 2; + ctx.save(); ctx.globalAlpha *= Math.min(1, rise * 1.6) * (1 - out); + ctx.translate(p.cx, y); ctx.scale(sc, sc); ctx.translate(-p.cx, -y); + softBox(ctx, tokens, lib, x0, y0, p.w, p.h, p.h / 2, C("card")); + const tx = x0 + p.pad; lib.setFont(ctx, tokens, "body", p.size); ctx.fillStyle = C("ink"); + const lay = lib.layoutText(ctx, lib.graphemes(lib.TITLE_EN).slice(0, typed).join(""), { x: tx, y: y + p.size * 0.36 }); + lib.drawGlyphs(ctx, lay, (g, i) => ({ dy: i === typed - 1 ? -6 * (1 - lib.seg(lib.frame(t) - L.keyF[i], 0, 2)) : 0 })); + const idle = t < T_TYPE || typed === L.keyF.length; + if (t < T_SEND && (!idle || Math.floor(lib.frame(t) / 8) % 2 === 0)) { // caret + ctx.fillStyle = C("accent"); rr(ctx, tx + lay.width + 6, y - p.size * 0.58, p.size / 10, p.size * 1.16, p.size / 20); ctx.fill(); + } + // send: grey until there is text, then the accent; it dips when pressed + const bx = x0 + p.w - p.pad, on = lib.tween(t, T_TYPE, T_TYPE + 0.12, lib.ease.outCubic); + const press = t >= T_SEND ? 1 - 0.14 * Math.exp(-14 * (t - T_SEND)) * Math.cos(10 * (t - T_SEND)) : 1; + ctx.translate(bx, y); ctx.scale(press * p.btn / 42, press * p.btn / 42); + ctx.fillStyle = lib.mixColor(C("line"), C("accent"), on); ctx.beginPath(); ctx.arc(0, 0, 42, 0, lib.TAU); ctx.fill(); + ctx.strokeStyle = "#FFFFFF"; ctx.lineWidth = 6; ctx.lineCap = "round"; ctx.lineJoin = "round"; + ctx.beginPath(); ctx.moveTo(0, 17); ctx.lineTo(0, -16); ctx.moveTo(-14, -2); ctx.lineTo(0, -16); ctx.lineTo(14, -2); ctx.stroke(); + ctx.restore(); +} + +// ───────── title bubble and the streamed reply (the caption) +function bubble(ctx, t, tokens, lib) { + if (t < T_SEND) return; + const C = (k) => P(tokens, lib, k), b = L.bubble, p = L.pill, s0 = p.size / 92; + const u = lib.spring(t - T_SEND, tokens.ease.spring), ux = lib.spring(t - T_SEND - 0.04, tokens.ease.spring_settle); // y leads x: a curve + const fromX = p.cx - p.w / 2 + p.pad + L.inputW / 2; + const cx = lib.lerp(fromX, b.cx, ux), cy = lib.lerp(p.cy, b.cy, u), sc = lib.lerp(s0, 1, u); + const bg = lib.tween(t, T_SEND, T_SEND + 0.12, lib.ease.outCubic), a0 = ctx.globalAlpha; + ctx.save(); ctx.translate(cx, cy); ctx.scale(sc, sc); + const x0 = -b.w / 2, y0 = -b.h / 2, x1 = b.w / 2, y1 = b.h / 2; + ctx.save(); ctx.globalAlpha = a0 * bg; ctx.fillStyle = C("accent"); + ctx.shadowColor = lib.rgba(C("accent"), 0.28); ctx.shadowBlur = 48; ctx.shadowOffsetY = 18; + rr(ctx, x0, y0, b.w, b.h, 56); ctx.fill(); + ctx.beginPath(); ctx.moveTo(x1 - 70, y1 - 2); ctx.quadraticCurveTo(x1 - 8, y1 + 2, x1 + 16, y1 + 22); // the tail: a message from "me" + ctx.quadraticCurveTo(x1 - 2, y1 - 6, x1 - 8, y1 - 52); ctx.lineTo(x1 - 70, y1 - 52); ctx.closePath(); ctx.fill(); + ctx.restore(); + lib.setFont(ctx, tokens, "display", 92); + const lay = lib.layoutText(ctx, lib.TITLE_EN, { x: 0, y: 32, align: "center", tracking: lib.tracking(tokens, "display", 92) }); + ctx.fillStyle = lib.mixColor(C("ink"), "#FFFFFF", bg); lib.drawGlyphs(ctx, lay); + ctx.restore(); +} +function caption(ctx, t, tokens, lib) { + if (t < T_DOTS) return; + const C = (k) => P(tokens, lib, k), c = L.cap, h = c.h, pad = 56, left = L.zhX0 - pad; + const appear = lib.spring(t - T_DOTS, tokens.ease.spring); + let w = 172; // the pill grows to each token's right edge just before the token + ZH_TOKENS.forEach((_, k) => { w += (L.tokRight[k] + pad - left - (k ? L.tokRight[k - 1] + pad - left : 172)) * lib.spring(t - T_TOKEN[k] + 0.09, { w: 30, zeta: 0.9 }); }); + const sc = lib.lerp(0.72, 1, appear); + ctx.save(); ctx.translate(c.cx + left, c.cy); ctx.scale(sc, sc); ctx.translate(-left, 0); + ctx.globalAlpha *= Math.min(1, appear * 1.5); + softBox(ctx, tokens, lib, left, -h / 2, w, h, h / 2, lib.rgba("#FFFFFF", 0.94)); + const dotsA = 1 - lib.seg(t, T_TOKEN[0] - 0.06, T_TOKEN[0] + 0.04); + if (dotsA > 0) for (let i = 0; i < 3; i++) { // typing dots + const ph = lib.fract(t * 2.2 - i * 0.16), dy = -12 * Math.max(0, Math.sin(ph * lib.TAU)) * (ph < 0.5 ? 1 : 0); + ctx.fillStyle = lib.rgba(C("muted"), dotsA * (dy < -6 ? 0.8 : 0.5)); + ctx.beginPath(); ctx.arc(left + 54 + i * 32, dy, 10, 0, lib.TAU); ctx.fill(); + } + rr(ctx, left, -h / 2, w, h, h / 2); ctx.clip(); + lib.setFont(ctx, tokens, "zh", 60); + const lay = lib.layoutText(ctx, lib.TITLE_ZH, { x: 0, y: 21, align: "center", tracking: lib.tracking(tokens, "zh", 60) }); + lib.drawGlyphs(ctx, lay, (g, i) => { + const k = L.tokOf[i], u = lib.tween(t, T_TOKEN[k], T_TOKEN[k] + 0.14, lib.easeOf(tokens.ease.enter)); + return u <= 0 ? null : { alpha: u, dy: (1 - u) * 12, fill: ZH_TOKENS[k] === "代码" ? C("accent") : C("ink") }; + }); + ctx.restore(); +} + +// ───────── cards: skeletons first, then the three concepts; the focus moves with the dot +function skeletons(ctx, t, tokens, lib) { + L.cards.forEach((cd, k) => { + const a = lib.tween(t, T_SKEL + k * 0.05, T_SKEL + 0.2 + k * 0.05, lib.ease.outCubic) * (1 - lib.clamp((t - T_CARD[k]) / 0.16)); + if (a <= 0) return; + const { x, y, w, h } = cd; + ctx.save(); ctx.globalAlpha *= a; + ctx.fillStyle = "rgba(255,255,255,0.42)"; rr(ctx, x, y + 24, w, h, 36); ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.75)"; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.5)"; rr(ctx, x + 22, y + 46, w - 44, 190, 24); ctx.fill(); + rr(ctx, x + 32, y + 268, 120, 30, 15); ctx.fill(); rr(ctx, x + w - 172, y + 268, 140, 30, 15); ctx.fill(); + const sx = x - 200 + lib.fract((t - T_SKEL) * 1.4 + k * 0.18) * (w + 400); // loading shimmer + const g = ctx.createLinearGradient(sx - 120, 0, sx + 120, 0); + g.addColorStop(0, "rgba(255,255,255,0)"); g.addColorStop(0.5, "rgba(255,255,255,0.55)"); g.addColorStop(1, "rgba(255,255,255,0)"); + rr(ctx, x, y + 24, w, h, 36); ctx.clip(); ctx.fillStyle = g; ctx.fillRect(x, y, w, h + 48); + ctx.restore(); + }); +} +function focusOf(t, k, tokens, lib) { // 0…1 (with a little spring overshoot): this card is the current step + const sp = tokens.ease.spring, on = lib.spring(t - (T_CHECK[k] - 0.06), sp); + return on - (k < 2 ? lib.spring(t - (T_CHECK[k + 1] - 0.06), sp) : 0); +} +function cardRect(t, k, tokens, lib) { + const cd = L.cards[k], F = tokens.focus, p = lib.spring(t - T_CARD[k], tokens.ease.spring); + const f = focusOf(t, k, tokens, lib), back = Math.max(0, lib.spring(t - (T_CHECK[0] - 0.06), tokens.ease.spring) - Math.max(0, f)); + const pre = k === 2 ? lib.ease.inOutSine(lib.seg(t, T_PLAY + 0.05, T_PUSH)) : 0; // the draft is picked up (straight up) while it plays + const s = 1 + (F.scale - 1) * f - (1 - F.others_scale) * back + 0.1 * pre, cy = cd.y + cd.h / 2 + (1 - p) * 70 - 30 * pre, cx = cd.cx; + return { x: cx - cd.w * s / 2, y: cy - cd.h * s / 2, w: cd.w * s, h: cd.h * s, f: Math.max(0, f), + alpha: lib.clamp((t - T_CARD[k]) / 0.14) * (1 - (1 - F.others_opacity) * back) }; +} +function drawCard(ctx, t, k, R, tokens, lib, e = 0) { + const C = (n) => P(tokens, lib, n), col = entity(tokens, lib, k), s = R.w / 480; + ctx.save(); ctx.globalAlpha *= R.alpha; + softBox(ctx, tokens, lib, R.x, R.y, R.w, R.h, 36 * s * (1 - e), C("card"), 18 * R.f); + const inner = 1 - lib.seg(e, 0, 0.3); // the card's own content; it goes as the card opens + if (inner > 0) { + ctx.save(); ctx.globalAlpha *= inner; ctx.translate(R.x, R.y); ctx.scale(s, s); + ctx.fillStyle = lib.mixColor("#FFFFFF", col, 0.16); rr(ctx, 22, 22, 436, 190, 24); ctx.fill(); + if (k === 0) illusOutline(ctx, t, 22, 22, 436, col, lib); + else if (k === 1) illusBoard(ctx, t, 22, 22, 436, 190, col, lib); + else draftBar(ctx, t, col, tokens, lib); + const by = 274; // label row: colour chip · 中文 ……… English + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(44, by - 20, 12, 0, lib.TAU); ctx.fill(); + lib.setFont(ctx, tokens, "zh", 56); ctx.fillStyle = C("ink"); lib.drawText(ctx, lib.MOTIF[k].zh, 68, by, { tracking: lib.tracking(tokens, "zh", 56) }); + lib.setFont(ctx, tokens, "body", 44); ctx.fillStyle = C("muted"); lib.drawText(ctx, lib.MOTIF[k].en, 446, by, { align: "right" }); + ctx.restore(); + } + if (k === 2) { // the draft's preview: in screen space, so it can become the screen + const p0 = { x: R.x + 40 * s, y: R.y + 40 * s, w: 400 * s, h: 118 * s }; + preview(ctx, t, lerpRect(p0, R, e, lib), 16 * s * (1 - e), e, tokens, lib); + } + ctx.restore(); +} +function illusOutline(ctx, t, rx, ry, rw, col, lib) { // a bulleted outline, its lines drawn on + [1, 0.78, 0.56].forEach((wf, i) => { + const y = ry + 50 + i * 46, u = lib.tween(t, T_CARD[0] + 0.2 + i * 0.07, T_CARD[0] + 0.55 + i * 0.07, lib.ease.outCubic); + if (u <= 0) return; + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(rx + 40, y, 9 * Math.min(1, u * 2), 0, lib.TAU); ctx.fill(); + ctx.fillStyle = lib.mixColor("#FFFFFF", col, 0.5); rr(ctx, rx + 64, y - 8, Math.max(16, (rw - 98) * wf * u), 16, 8); ctx.fill(); + }); +} +function illusBoard(ctx, t, rx, ry, rw, rh, col, lib) { // six storyboard frames, a ball advancing through them + const pad = 18, gap = 12, tw = (rw - 2 * pad - 2 * gap) / 3, th = (rh - 2 * pad - gap) / 2; + for (let j = 0; j < 6; j++) { + const s = lib.spring(t - (T_CARD[1] + 0.2 + j * 0.05), { w: 18, zeta: 0.7 }); + if (s <= 0) continue; + const x = rx + pad + (j % 3) * (tw + gap), y = ry + pad + Math.floor(j / 3) * (th + gap), u = j / 5; + ctx.save(); ctx.translate(x + tw / 2, y + th / 2); ctx.scale(s, s); ctx.translate(-tw / 2, -th / 2); + ctx.fillStyle = lib.mixColor("#FFFFFF", col, 0.42); rr(ctx, 0, 0, tw, th, 12); ctx.fill(); + ctx.fillStyle = lib.mixColor("#FFFFFF", col, 0.62); ctx.fillRect(10, th - 14, tw - 20, 3); + ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.arc(24 + u * (tw - 48), th * 0.7 - Math.sin(u * Math.PI) * th * 0.42, 10, 0, lib.TAU); ctx.fill(); + ctx.restore(); + } +} +const renderProg = (t, lib) => lib.tween(t, T_CHECK[1] + 0.02, T_CHECK[2], lib.ease.inOutSine); // renders as the dot walks to it +const playProg = (t, lib) => lib.seg(t, T_PLAY, T_LAND); +function draftBar(ctx, t, col, tokens, lib) { // in the card: render progress, then a scrubber with a playhead + const y = 186, x0 = 40, w = 400, prog = renderProg(t, lib); + ctx.fillStyle = lib.mixColor("#FFFFFF", col, 0.35); rr(ctx, x0, y - 6, w, 12, 6); ctx.fill(); + ctx.fillStyle = col; rr(ctx, x0, y - 6, Math.max(12, w * prog), 12, 6); ctx.fill(); + if (t >= T_PLAY - 0.1) { + const hs = lib.spring(t - (T_PLAY - 0.1), { w: 18, zeta: 0.7 }), hx = x0 + 6 + (w - 12) * playProg(t, lib); + ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.arc(hx, y, 13 * hs, 0, lib.TAU); ctx.fill(); + ctx.strokeStyle = P(tokens, lib, "accent"); ctx.lineWidth = 4; ctx.stroke(); + } +} +function ballAt(t, lib) { // the draft's little film: three hops, the last one at full screen + let u = HOPS[0][2], land = -9; + for (const [a, b, u0, u1, hgt] of HOPS) { + if (t >= b) { u = u1; land = b; continue; } + if (t >= a) { const s = lib.seg(t, a, b); return { u: lib.lerp(u0, u1, s), h: Math.sin(Math.PI * s) * hgt, s, hop: [a, b, u0, u1, hgt] }; } + break; + } + return { u, h: 0, land }; +} +/** The draft's preview: a small copy of this very wallpaper (the film's own frame), a ground line, a ball; it renders + * behind a skeleton, plays back, and when the card opens it becomes the screen and carries the name. */ +function preview(ctx, t, R, rad, e, tokens, lib) { + const C = (n) => P(tokens, lib, n); + ctx.save(); rr(ctx, R.x, R.y, R.w, R.h, rad); ctx.clip(); + ctx.drawImage(MESH, R.x, R.y, R.w, R.h); + const gy = R.y + R.h * 0.8, r = R.h * lib.lerp(0.1, 0.045, e), ballAtY = (b) => gy - r - b.h * R.h * 0.5; + ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.fillRect(R.x + R.w * 0.04, gy, R.w * 0.92, Math.max(2, R.h * 0.008)); + const b = ballAt(t, lib); + if (b.hop) for (let j = 3; j >= 1; j--) { // a short trail while it hops + const s = b.s - j * 0.05; if (s <= 0) continue; + const [, , u0, u1, hgt] = b.hop, q = { u: lib.lerp(u0, u1, s), h: Math.sin(Math.PI * s) * hgt }; + ctx.fillStyle = lib.rgba("#FFFFFF", 0.2 * (4 - j) / 3); ctx.beginPath(); ctx.arc(R.x + q.u * R.w, ballAtY(q), r, 0, lib.TAU); ctx.fill(); + } + const k = lib.seg(t, b.land, b.land + 0.16), sq = b.land > 0 ? 0.22 * Math.sin(Math.PI * k) : 0; // squash on landing + ctx.save(); ctx.shadowColor = lib.rgba(C("shadow"), 0.22); ctx.shadowBlur = 6 + 30 * e; ctx.shadowOffsetY = 2 + 10 * e; + ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); + ctx.ellipse(R.x + b.u * R.w, gy - r * (1 - sq) - b.h * R.h * 0.5, r * (1 + sq * 0.6), r * (1 - sq), 0, 0, lib.TAU); ctx.fill(); ctx.restore(); + const shown = 0.06 + 0.94 * renderProg(t, lib), ex = R.x + R.w * shown; + if (shown < 1) { // the part not rendered yet: a skeleton with a loading shimmer + ctx.fillStyle = "rgba(255,255,255,0.72)"; ctx.fillRect(ex, R.y, R.x + R.w - ex, R.h); + const sx = R.x + lib.fract((t - T_CARD[2]) * 1.6) * (R.w + 240) - 120, g = ctx.createLinearGradient(sx - 110, 0, sx + 110, 0); + g.addColorStop(0, "rgba(255,255,255,0)"); g.addColorStop(0.5, "rgba(255,255,255,0.8)"); g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.save(); ctx.beginPath(); ctx.rect(ex, R.y, R.x + R.w - ex, R.h); ctx.clip(); ctx.fillStyle = g; ctx.fillRect(R.x, R.y, R.w, R.h); ctx.restore(); + } + const ks = R.w / lib.W; // the full-screen page: a three-colour scrubber, then the name + const sa = lib.tween(t, T_PUSH + 0.25, T_PUSH + 0.5, lib.ease.outCubic); + if (sa > 0) { + ctx.save(); ctx.globalAlpha *= sa; + const x0 = R.x + R.w * 0.1, x1 = R.x + R.w * 0.9, y = R.y + R.h * 0.92, sw = (x1 - x0 - 2 * 12 * ks) / 3, ph = x0 + (x1 - x0) * playProg(t, lib); + for (let j = 0; j < 3; j++) { + const a = x0 + j * (sw + 12 * ks); + ctx.fillStyle = "rgba(255,255,255,0.7)"; rr(ctx, a, y - 6 * ks, sw, 12 * ks, 6 * ks); ctx.fill(); + ctx.fillStyle = entity(tokens, lib, j); rr(ctx, a, y - 6 * ks, lib.clamp(ph - a, 0, sw), 12 * ks, 6 * ks); ctx.fill(); + } + ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.arc(ph, y, 16 * ks, 0, lib.TAU); ctx.fill(); + ctx.strokeStyle = C("accent"); ctx.lineWidth = 5 * ks; ctx.stroke(); + ctx.restore(); + } + const na = lib.tween(t, T_NAME, T_NAME + 0.28, lib.easeOf(tokens.ease.enter)); + if (na > 0) { + ctx.save(); ctx.globalAlpha *= na; const dy = (1 - na) * 18 * ks; + lib.setFont(ctx, tokens, "display", 124 * ks); ctx.fillStyle = C("ink"); + lib.drawText(ctx, "Pastel UI", R.x + R.w / 2, R.y + R.h * 0.43 + dy, { align: "center", tracking: lib.tracking(tokens, "display", 124 * ks) }); + lib.setFont(ctx, tokens, "zh", 64 * ks); ctx.fillStyle = C("muted"); + lib.drawText(ctx, "浅色界面讲解", R.x + R.w / 2, R.y + R.h * 0.43 + 96 * ks + dy, { align: "center", tracking: 64 * ks * 0.08 }); + ctx.restore(); + lib.setFont(ctx, tokens, "zh", 56 * ks); // the three concepts gather under the name, each in its own colour + const cw = 250 * ks, ch = 98 * ks, gap = 30 * ks, cy = R.y + R.h * 0.43 + 236 * ks; + for (let j = 0; j < 3; j++) { + const q = lib.spring(t - (T_NAME + 0.06 + j * 0.06), tokens.ease.pop); + if (q <= 0) continue; + const cx = R.x + R.w / 2 + (j - 1) * (cw + gap); + ctx.save(); ctx.translate(cx, cy); ctx.scale(q, q); + softBox(ctx, tokens, lib, -cw / 2, -ch / 2, cw, ch, ch / 2, C("card")); + ctx.fillStyle = entity(tokens, lib, j); ctx.beginPath(); ctx.arc(-cw / 2 + 52 * ks, 0, 16 * ks, 0, lib.TAU); ctx.fill(); + ctx.fillStyle = C("ink"); lib.drawText(ctx, lib.MOTIF[j].zh, -cw / 2 + 88 * ks, 20 * ks, { tracking: 56 * ks * 0.04 }); + ctx.restore(); + } + } + ctx.restore(); +} + +// ───────── the stepper rail +function dotX(t, lib) { + const xs = L.cards.map((c) => c.cx); + if (t < T_CHECK[1] - 0.36) return xs[0]; + if (t < T_CHECK[2] - 0.36) return lib.lerp(xs[0], xs[1], lib.ease.inOutCubic(lib.seg(t, T_CHECK[1] - 0.36, T_CHECK[1]))); + return lib.lerp(xs[1], xs[2], lib.ease.inOutCubic(lib.seg(t, T_CHECK[2] - 0.36, T_CHECK[2]))); +} +function rail(ctx, t, tokens, lib) { + if (t < T_RAIL) return; + const C = (k) => P(tokens, lib, k), y = L.railY, xs = L.cards.map((c) => c.cx); + ctx.save(); ctx.lineCap = "round"; + ctx.strokeStyle = lib.rgba("#FFFFFF", 0.9); ctx.lineWidth = 10; + lib.strokePartial(ctx, [[xs[0], y], [xs[2], y]], lib.tween(t, T_RAIL, T_RAIL + 0.36, lib.easeOf(tokens.ease.enter))); + const dx = t >= T_CHECK[0] - 0.12 ? dotX(t, lib) : xs[0]; + if (dx > xs[0] + 1) { // progress in the three colours, short blends at the nodes + const g = ctx.createLinearGradient(xs[0], 0, xs[2], 0); + [[0, 0], [0.4, 0], [0.5, 1], [0.9, 1], [1, 2]].forEach(([o, k]) => g.addColorStop(o, entity(tokens, lib, k))); + ctx.strokeStyle = g; ctx.lineWidth = 10; ctx.beginPath(); ctx.moveTo(xs[0], y); ctx.lineTo(dx, y); ctx.stroke(); + } + xs.forEach((x, k) => { // nodes: white until reached, then the stage's colour + a ring + const appear = lib.spring(t - (T_RAIL + 0.36 * (x - xs[0]) / (xs[2] - xs[0])), { w: 18, zeta: 0.7 }); + if (appear <= 0) return; + const reached = t >= T_CHECK[k]; + ctx.fillStyle = reached ? entity(tokens, lib, k) : "#FFFFFF"; + ctx.beginPath(); ctx.arc(x, y, 15 * appear, 0, lib.TAU); ctx.fill(); + if (!reached) { ctx.strokeStyle = C("line"); ctx.lineWidth = 4; ctx.stroke(); } + for (const t1 of [T_CHECK[k], T_DONE + k * 0.07]) { // a ring on arrival, and again in the "all done" pulse + const ring = lib.seg(t, t1, t1 + 0.45); + if (ring <= 0 || ring >= 1) continue; + ctx.strokeStyle = lib.rgba(entity(tokens, lib, k), 0.6 * (1 - ring)); ctx.lineWidth = 5; + ctx.beginPath(); ctx.arc(x, y, 15 + 34 * lib.ease.outCubic(ring), 0, lib.TAU); ctx.stroke(); + } + }); + const da = lib.spring(t - (T_CHECK[0] - 0.12), { w: 18, zeta: 0.7 }); + if (da > 0) { // the walking dot + ctx.save(); ctx.shadowColor = lib.rgba(C("accent"), 0.4); ctx.shadowBlur = 18; ctx.shadowOffsetY = 4; + ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.arc(dx, y, 19 * da, 0, lib.TAU); ctx.fill(); ctx.restore(); + ctx.strokeStyle = C("accent"); ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(dx, y, 16 * da, 0, lib.TAU); ctx.stroke(); + } + ctx.restore(); +} + +// ───────── the one readout: STEP n/3 (the digit rolls up) +function hud(ctx, t, e, tokens, lib) { + const C = (k) => P(tokens, lib, k), a = lib.tween(t, 0.15, 0.5, lib.ease.outCubic) * (1 - lib.seg(e, 0, 0.4)); + if (a <= 0) return; + const n = T_CHECK.filter((c) => t >= c).length, last = n ? T_CHECK[n - 1] : -9, r = lib.tween(t, last, last + 0.2, lib.easeOf(tokens.ease.enter)); + const x = 120, y = 108; + ctx.save(); ctx.globalAlpha *= a; + lib.setFont(ctx, tokens, "mono", 46); ctx.fillStyle = C("muted"); lib.drawText(ctx, "STEP", x, y); + const nx = x + ctx.measureText("STEP ").width, dw = ctx.measureText("0").width; + ctx.save(); ctx.beginPath(); ctx.rect(nx - 4, y - 48, dw + 8, 64); ctx.clip(); ctx.fillStyle = C("ink"); + const a0 = ctx.globalAlpha; + if (n && r < 1) { ctx.globalAlpha = a0 * (1 - r); lib.drawText(ctx, String(n - 1), nx, y - 40 * r); } + ctx.globalAlpha = a0 * (n ? r : 1); lib.drawText(ctx, String(n), nx, y + 40 * (1 - (n ? r : 1))); + ctx.restore(); + ctx.fillStyle = C("muted"); lib.drawText(ctx, "/3", nx + dw, y); + ctx.restore(); +} + +export function renderAt(t, ctx, tokens, lib) { + const e = t >= T_PUSH ? lib.easeOf(tokens.ease.emphasized)(lib.seg(t, T_PUSH, T_PUSH + PUSH_DUR)) : 0; + ground(ctx, t, tokens, lib); + const back = 1 - lib.smoothstep(0, 0.6, e); // everything but the opening card steps away + if (back > 0) { + ctx.save(); ctx.globalAlpha = back; + skeletons(ctx, t, tokens, lib); rail(ctx, t, tokens, lib); caption(ctx, t, tokens, lib); + const ks = [0, 1, 2].filter((k) => t >= T_CARD[k] && !(k === 2 && e > 0)).map((k) => ({ k, R: cardRect(t, k, tokens, lib) })); + ks.sort((a, b) => a.R.f - b.R.f).forEach(({ k, R }) => drawCard(ctx, t, k, R, tokens, lib)); // the focused card on top + ctx.restore(); + } + capsule(ctx, t, tokens, lib); + inputPill(ctx, t, tokens, lib); + if (back > 0) { ctx.save(); ctx.globalAlpha = back; bubble(ctx, t, tokens, lib); ctx.restore(); } + if (e > 0) { // container transform: the draft card opens into the screen + const R0 = cardRect(t, 2, tokens, lib), R = lerpRect(R0, { x: 0, y: 0, w: lib.W, h: lib.H }, e, lib); + drawCard(ctx, t, 2, { ...R, f: 1, alpha: 1 }, tokens, lib, e); + } + hud(ctx, t, e, tokens, lib); +} + +// foley: events.json is generated from this list (node styles/_swatch/foley.mjs pastel-ui); pan = the sound's x on screen +const panX = (x) => Math.round((2 * x / 1920 - 1) * 0.75 * 100) / 100; +const CARD_X = [408, 960, 1512]; +export const FOLEY = [ + { t: T_WAKE, sfx: "air", gain_db: -8, pan: 0, dur: 0.5, dir: "up", bright: 0.35 }, // the colours grow out + { t: T_TYPE, sfx: "typing", gain_db: 0, pan: panX(680), dur: 0.6 }, // the title typed + { t: T_SEND, sfx: "click", gain_db: -8, pan: panX(1536) }, // send + { t: T_SEND + 0.02, sfx: "swoosh_tonal", gain_db: -7, pan: 0.3, dur: 0.38, dir: "up", bright: 0.3, pan_from: 0.45, pan_to: 0 }, // it flies up + { t: T_DOTS, sfx: "pop", gain_db: -5, pan: 0, pitch: 3 }, // the reply starts typing + { t: T_TOKEN[3], sfx: "tick", gain_db: -2, pan: 0.05 }, // 代码 lands + ...T_CARD.map((tc, k) => ({ t: tc + 0.06, sfx: "pop", gain_db: k === 2 ? -1 : -4, pan: panX(CARD_X[k]) })), // cards land + ...T_CHECK.map((tc, k) => ({ t: tc, sfx: "toggle", gain_db: k ? -6 : -4, pan: panX(CARD_X[k]) })), // the focus moves + { t: T_PLAY, sfx: "tick", gain_db: -3, pan: panX(1400) }, // the draft plays + { t: T_PUSH, sfx: "whoosh", gain_db: -9, pan: 0.2, dur: 0.5, dir: "up", bright: 0.4, tone: 0.2, pan_from: 0.45, pan_to: 0 }, // the card opens + { t: T_LAND, sfx: "pop", gain_db: -5, pan: panX(1574), pitch: -2 }, // the ball lands + { t: T_NAME + 0.08, sfx: "shimmer", gain_db: -10, pan: 0, dur: 0.8, pitch: -7 }, // the name settles (in D) +]; diff --git a/styles/pastel-ui/tokens.json b/styles/pastel-ui/tokens.json new file mode 100644 index 0000000..47308db --- /dev/null +++ b/styles/pastel-ui/tokens.json @@ -0,0 +1,54 @@ +{ + "slug": "pastel-ui", + "name": { "zh": "浅色界面讲解", "en": "Pastel UI" }, + "family": "explainer", + "palette": { + "bg": "#F6F4FB", + "wash_lavender": "#DCD2FA", + "wash_peach": "#FFD9C7", + "wash_mint": "#C9EEDF", + "wash_lilac": "#EBD3F2", + "card": "#FFFFFF", + "ink": "#1E2034", + "muted": "#4F5270", + "line": "#E4E1EF", + "accent": "#5B5BEF", + "shadow": "#3A2E80", + "entity": ["#FF8A66", "#2DBE94", "#F2B33D"] + }, + "fonts": { + "display": { "family": ["Avenir Next", "Helvetica Neue"], "weight": 600, "tracking": -0.012 }, + "body": { "family": ["Avenir Next", "Helvetica Neue"], "weight": 500, "tracking": 0 }, + "zh": { "family": ["PingFang SC", "Hiragino Sans GB"], "weight": 600, "tracking": 0.02 }, + "mono": { "family": ["Menlo", "Monaco"], "weight": 400, "tracking": 0.02 } + }, + "ease": { + "enter": "cubic-bezier(0.22,1,0.36,1)", + "move": "cubic-bezier(0.65,0,0.35,1)", + "exit": "cubic-bezier(0.55,0,0.75,0.2)", + "emphasized": "cubic-bezier(0.2,0,0,1)", + "spring": { "w": 13, "zeta": 0.8 }, + "spring_settle": { "w": 16, "zeta": 0.9 }, + "pop": { "w": 20, "zeta": 0.62 } + }, + "focus": { "scale": 1.2, "others_scale": 0.95, "others_opacity": 0.6 }, + "timing": { "enter_frames": 12, "stagger_s": 0.12, "new_event_every_s": 0.4 }, + "shape": { "card_radius": 36, "pill_radius": "height/2" }, + "shadow": { "ambient": { "blur": 56, "y": 22, "alpha": 0.11 }, "key": { "blur": 10, "y": 4, "alpha": 0.07 }, "lift_px": 18 }, + "texture": { "type": "mesh-gradient", "falloff_stops": [1, 0.88, 0.58, 0.24, 0], "drift_px": 50, "dither": 0.03 }, + "transitions": ["container-transform", "push-in-on-card", "feed-scroll"], + "camera": "none: the interface moves (focus, open a card, scroll)", + "type_anim": "typed-and-sent; answers streamed token by token", + "sound": { + "bpm": 150, + "key": "D", + "mode": "major", + "palette": ["kalimba", "epiano", "upright_pm", "strings", "clap (finger snap)"], + "sfx": ["air", "typing", "click", "swoosh_tonal", "pop", "tick", "toggle", "whoosh", "shimmer"] + }, + "video_types": ["02", "03", "05"], + "study": [ + { "work": "你问 AI 一句话之后的 3 秒钟 (Douyin)", "year": 2026, "by": "科了个喵" }, + { "work": "Material You (Material Design 3), Google I/O, with the Android 12 preview", "year": 2021, "by": "Google" } + ] +} diff --git a/styles/y2k-chrome/STYLE.md b/styles/y2k-chrome/STYLE.md new file mode 100644 index 0000000..cc58df4 --- /dev/null +++ b/styles/y2k-chrome/STYLE.md @@ -0,0 +1,103 @@ +# Y2K 铬面 · y2k-chrome + +一句话:千禧年前后想象中的"未来科技",流行的那一路。一团会变形的液态铬是主角,它映着一间看不见的摄影棚(深蓝的天、银色的地平线、暗紫的地),在酸性黄的地上跳、吐字、变形;旁边是半透明的果冻塑料、白边模切贴纸和一两个游戏小标签。进场带弹跳和过冲,全踩在拍上。适合品牌片和 showreel、科技推特风的快剪、流行 MV、游戏预告。 + +样片:`media/swatch.mp4`(5 s,含 `score.json` 配乐和 `events.json` 拟音)· 封面 `media/poster.jpg`(t = 3.0 s) + +样片里:第 0 帧一团液态铬正砸在酸性黄的地上,压扁,上方几道下落线,一圈冲击环外扩,一声铁砧。它往下弹一跳,0.4 s 落地,把第一个词的水滴溅上天;左上 SCORE 胶囊弹下来。它鼓一下,把其余字母一个个"吐"出去:水滴飞到位置上膨胀成充气铬字,过冲约 25%,四个词落在 0.8 / 1.0 / 1.2 / 1.4 s 的八分音符上。吐完它只剩直径约 110 px 的一小团,1.8 s 跳到左边歇着;1.6 s 中文贴纸从右下转着飞进来,拍在标题下方。2.0 / 2.2 / 2.4 s 三块关卡砖从地上弹出来,铬团跟着每一下点一下头:1-1 大纲是青色果冻,1-2 分镜是品红果冻,1-3 初版是银色的铬,上面一台小显示器,屏幕里就是这团铬:越接近成品越亮。2.8 / 3.2 / 3.6 s 铬团在拍上依次跳到三块砖上,和砖一起压扁,"啵"一声橡皮似的闷响一次比一次高,小星炸开,砖角拍上 CLEAR 贴纸(1-3 的贴在左下角,它的上边已经有关卡号和铬团),SCORE 加 1000。从 2.0 s 起摄影棚每拍左右摆一下,铬面高光跟着滑;3.62 s 一个小跳从左到右跑过标题。4.0 s 强拍上一根斜铬管从右上角切进来,像刮水器一样往左刮,把铬团推着卷了进去,管面反光和管脊上的闪光跟着跑;管后是一张干净的酸性黄,铬团从管子背后掉出来落在中央,鼓起来,4.4 s 炸成充气铬字"Y2K",4.6 s 一张 CHROME 贴纸拍在右下,4.8 s 闪一下。实际字体:Arial Rounded MT Bold(描粗后"充气")、Yuanti SC Bold。配乐 150 BPM、A 大调的千禧泡泡糖舞曲。 + +## 学习对象 + +| 作品 | 年份 · 作者 / 工作室 | 从它身上学什么 | +|---|---|---| +| FunTech Showreel 2026([X 帖子](https://x.com/tkm_hmng8/status/2105255710531674358),拆解见 `cases/oneshot-five.md` 第 4 节) | 2026-09-30 · ハシモトタクマ(FunTech) | 一个铬面角色和一个充气铬字标贯穿十几个风格世界,贴纸和游戏 HUD 当点缀:画面怎么换,观众都知道看的是同一个主角 | +| *Wipeout* | 1995 · Psygnosis(PlayStation);封面、说明书、游戏内字体和各虚构车队的品牌由谢菲尔德的 The Designers Republic 设计 | 锐舞年代的未来主义品牌:logo 当图标用,游戏界面本身就是平面设计 | +| Mac OS X 的 Aqua 界面 | 2000-01-05 在旧金山 Macworld Expo 发布 · Apple | 水滴一样的组件:反光、半透明;Jobs 说过设计目标之一是让人看了想舔一口。样片的果冻砖就学这个 | + +核实:事实以协调方 2026-10-02 核过的为准;Jobs 那句话是转述。**不照搬**:不用 FunTech 的头盔吉祥物和字标、*Wipeout* 的车队 logo 和字体、Aqua 的窗口按钮和 Dock;FunTech 片尾前约 1 s 的真静音也不学(本仓库底线:片中不出现数字静音)。 + +## 视觉语法 + +下面是样片的做法和理由。数字是样片的值,换题材时按理由调。 + +- **一个铬角色贯穿全片**(从 FunTech 学的那一条):样片的铬团砸地、吐字、跳关、被卷走、再变成片尾的字。换成铬字标或吉祥物也行,要从头跟到尾。 +- **三种材质,一个比一个亮**:哑光的地、半透明果冻、全反射的铬。样片拿它讲进度:大纲、分镜是塑料,初版是铬。 +- **色板**(样片): + + | 角色 | 颜色 | 含义 | + |---|---|---| + | 地 · 酸性黄 | `#E4FF1A`,边缘 `#C9EA00`,中间提亮 | 舞台,从头到尾只有这一种地色 | + | 墨 | `#0E0D16` | 字、胶囊、铬的描边 | + | 热品红 | `#FF2BB0` | 分镜砖、贴纸上的字、铬最底下那一道细边 | + | 电光青 | `#00D2FF` | 大纲砖、小星 | + | 贴纸白 | `#FCFCF7` | 模切贴纸;不用纯白 | + | 铬的天 | `#1838FF` → `#59C8FF` → `#E6FAFF` → 银 `#C6CCD4` | 铬面朝上的部分,越近地平线越银 | + | 铬的地 | 一道黑线 `#0D0B16` → 暗紫(夜色和品红约七三开)→ 底边一道热品红 | 铬面朝下的部分;标题字里只占下面约三分之一 | + + 三个饱和色同时出现是这个语域本身(`TASTE_CHECKLIST` 第 9、10 条按覆盖处理)。约束放在别处:每种颜色只管一类东西,地只有一种颜色,铬本身大多是银、蓝和深色。白底、黑底也成立。 +- **铬是反射出来的**:朝上映天,朝下映地,正对镜头映地平线附近。所以铬的语法是"它反射的那间屋子":一条硬地平线(一道白线、一段银)、下面一道黑线、一两块柔光箱、一块黑旗。铬的地要和背景反差大、有层次(见"容易翻车"第 3 条)。 +- **字**:英文 Arial Rounded MT Bold,描一圈字号 7.5% 的圆角描边再"充气";铬字 180 px,两行,一行最多两个词。中文 Yuanti SC Bold(macOS 按需下载的字体,没有时回落 PingFang SC),黑字印在模切贴纸上。必读的长句放贴纸上,铬留给短而大的词。 +- **字号按"在哪看"定**(`playbook/03-motion-design.md` 第 4 节,1080p 合成里的 px,这条是底线):样片按 `desktop` 做,主标题 ≥ 84、辅助和标签 ≥ 44:铬字 180、中文贴纸 70、砖下标签 56、关卡号和 CLEAR 48、SCORE 44 / 数字 52、片尾 CHROME 64。推荐用法里的 X showreel、梗图快剪多半是横屏放进手机信息流(`feed`),那一档主标题 ≥ 150、贴纸和标签 ≥ 80:要么把标签砍到一两个、放大到 80 以上,要么另出竖版。 +- **贴纸**:文字描一圈字号 63% 的白色粗描边,加一块垫底的圆角矩形和一个偏移的软影,歪 3–8°。 +- **游戏标签少而真**:样片只留一个 SCORE(真的在数铬团落了几次)和砖上的关卡号、CLEAR。标签一多就成了"游戏系统",和 `pixel-16bit` 撞;身份在铬和那团液体。 +- **构图与质感**:居中的海报构图,上面两行铬字,中间一张斜贴纸,下面一排砖;满、挤、叠也成立,但铬字周围留出影子和一点空。不加颗粒:会让铬变脏、码率上涨。 + +## 运动语法 + +- **弹跳是这个语域的一部分**(`TASTE_CHECKLIST` 第 12 条按覆盖处理):字母 `w 26, ζ 0.38`(约 27% 过冲),砖 `w 22, ζ 0.4`,铬团落地是 0.11 s 衰减、4.2 Hz 的压扁振荡。想更"高级"(发布会、Aqua 那种),ζ 调到 0.6 以上。压扁守体积、锚在底边,看起来是真落在地上。 +- **一切踩拍,但错开**:大动作都在拍或八分音符上,同一时刻大幅过冲的最好只有一个;拍与拍之间水滴在飞、高光在滑、分数在跳。 +- **液态铬可以变形**:水滴和字、铬团和铬砖在同一张高度场里,挨近了像液体一样连起来:吐字、合并、被卷走、重新长出来,是这个风格最有辨识度的动作。 +- **高光要动,但别转圈**:摄影棚每拍左右摆 0.15 弧度、0.18 s 到位,所有铬面的高光一齐滑。一直往一个方向转,大块平面会整块转进黑旗或柔光箱,一拍黑一拍白。 +- **转场**:招牌是铬管刮屏:倾斜约 29° 的铬管 0.6 s 从右刮到左(`cubic-bezier(0.37,0,0.63,1)`),管子就是新旧画面的分界线,扫过时管面反光跟着跑。同语系的还有:一团铬压扁铺满再弹开、撕掉贴纸露出下一场、硬切。 + +## 声音语法 + +- **配乐**:千禧年泡泡糖舞曲(eurodance 一路)。样片 150 BPM、A 大调,I–V–vi–IV–I,小节 2 + 3 + 3 + 2 + 3 拍(`meters`),段落落在 0.8 / 2.0 / 3.2 / 4.0 s:`edm_kick` 四拍子、`clap`、`hihat` 反拍开镲、`sq_bass` 反拍八度贝斯、`organ` 浩室风琴切分和弦、`pulse` 在铬团落到砖上时弹"金币"(只给第一个音标 `hit`)、`glockenspiel` 撒高音、`strings` 垫底;`metal` 铁砧和 `sub808` 只在 0 s 和 4.0 s 各来一下,铁砧给砸地补上手机放得出的 1–4 kHz 起音。和 `synthwave-outrun` 分开:那边小调、门限军鼓、超锯齿;这边大调、风琴、方波、芯片音、金属。 +- **音效**:`impact` + `click`(砸地)、`pop`(小东西:弹跳、词、砖弹出、"Y2K",按动作升降调)、`swoosh_tonal` 塑成 0.12 s 往下滑的闷音(铬团落到砖上,中心 360 → 450 → 560 Hz 一次比一次高,峰值在接触后约 60 ms、压得最扁时)、`paper`(拍贴纸)、`swoosh_tonal`(溅水滴;铬管 0.5 s 从右扫到左,峰值在过中线的 4.3 s)、`shimmer`(片尾闪光),声像按物体的 x。几乎每拍都有一个看得见的动作和一个听得见的声音,音效比音乐低约 13 dB;真要停,留着弦乐和贝斯,不做成数字静音。 + +## 适合与不适合 + +- **适合**:03 品牌片、showreel;08 科技推特风、梗图快剪;04 流行、电子 MV;游戏预告、把功能做成关卡的 App 介绍。 +- **不适合**:严肃、悲伤、需要信任感的题材;大段文字的讲解;写实人物。 +- **容易被误用成**:暗底霓虹(`synthwave-outrun`);网点、拟声字、漫画格(`halftone-comic`,颜色和贴纸相近,区别是这里有真反射的铬和一个液体角色);给字加个金属渐变就叫铬。 + +## 容易翻车的地方 + +下面这些会让它变成"廉价的金属字"或"一锅乱炖"。故意反着做也可以,但要知道换来了什么。 + +1. 铬用线性渐变涂出来:没有地平线和高光,像灰色塑料;一动起来更假,高光不跟着形状走。 +2. 铬面上的高光完全静止:看两秒就当它是贴图。至少让摄影棚在拍上摆一下。 +3. 铬的地选错颜色:和背景同色,字的下半截消失;一整片饱和色,铬变成糖;只有暗色没有层次,像每个字下面叠了个错位的副本。要黑线、深色、底边一道亮色,再加墨色描边。 +4. 地平线跟着每一笔走:充气字每个转角都会挂下一条"舌头",暗色也会占掉半个字。在标题行上把仰角和屏幕空间的高度混一下(样片 0.82),地平线就横穿整行字、落在约三分之二高处。 +5. 摄影棚里放细长灯条:小字上是漂亮的高光,大铬团上就成了白杠和黑斜线。 +6. 角色只在开头出现:铬团吐完字就消失,后面换成一个通用的鼠标箭头,片子就散了。 +7. 同时弹、三色平均分配、游戏标签越加越多:成了果冻锅,也会和 `halftone-comic`、`pixel-16bit` 撞。 + +## Prompt 块 + +```text +Visual style: millennium pop-tech (1999–2001) carried by one character, a blob of liquid chrome that lands, spits droplets that swell into inflated chrome letters, hops through the scene on the beat, gets swept away and reforms as the end title. Chrome is a real reflection of a simple studio: deep blue above, silver and a white line at a hard horizon that runs straight through each line of letters, a black line, dark purple below, hot pink only as a thin bottom edge, one or two soft oval highlights, a thin ink outline; the studio swings a little on each beat so highlights slide. One flat saturated background (acid yellow, white or black); cyan and magenta only as accents. Glossy translucent candy-plastic shapes and white die-cut stickers at a few degrees (readable sentences on stickers, chrome for short big words); few game labels, all carrying real state. Rounded heavy type made puffy before it turns to chrome; text sized for the viewing tier (secondary text at least 44 px at 1080p on desktop, 80 px for horizontal video in a phone feed). Toy-like motion on the beat: 20–30% overshoot, volume-keeping squash anchored on the floor, staggered; a tilted chrome tube wiping across like a squeegee is the signature transition; no motion blur, no grain. Sound: bright major-key bubblegum dance near 150 BPM (four-on-the-floor kick, claps, off-beat open hats and octave bass, house-organ stabs, chip coin blips when something scores, a string bed so it never goes silent), a metallic anvil on the two biggest hits, a short rubbery squash rising in pitch on each landing, pops for small things, a paper slap for stickers, a tonal metallic swoosh for the wipe. +``` + +## 引擎做法 + +- **HyperFrames + Canvas2D,铬是 `lib.shader` 的一个片元着色器**。先把铬物体画进一张"场"画布:R 是覆盖(模糊 3 px,着色器按 0.5 取阈值,字角变圆,挨近的形状像液体一样连起来),B 是斜面高度(模糊 6 px,按 0.2 再切一次得到墨色描边),G 是大的鼓起(模糊 14 px,水滴另画球面渐变)。着色器用差分求梯度(G 取 ±5、±8 px,B 取 ±2、±3.5 px 两组平均,8-bit 台阶就平均掉了),得到法线,按反射方向查程序生成的摄影棚(天、银、白线、±0.012 的抗锯齿地平线、黑线、暗紫、底边品红、椭圆柔光箱、软边黑旗);标题行上仰角和屏幕空间的高度项按 0.82 混合,地平线横穿每一行字、落在约三分之二高处。输出带 alpha 合到画布上,之前画一层偏移的橄榄色软影。 +- **大形状解析地算**:铬团(带三瓣、五瓣起伏的椭圆球面)和铬管(圆柱截面)的高度在着色器里按公式算,否则 8-bit 场在 600 px 的面上会出等高线;uv 夹在离边 24 px 以内,碰到画面边缘的铬不会多出假高光。 +- **铬团**的每个状态都是 t 的纯函数:一串预先写好的跳跃(起跳前蹲、空中拉长、落地压扁),吐字时和字同一个 pass(水滴能连上),之后单独一个 pass、画在中文贴纸后面;被管子前沿推着缩没,在结束画面里从管子背后长出来再炸成字。 +- **转场**:铬管中心线 `x = X(t) + tan(0.5)·(y − 540)`,X 从 2320 走到 −482,管后的半平面画结束画面;三个 pass 共用一个 shader,按顺序渲、渲完立刻画上去。`T` 表同时给画面和 `FOLEY`(`foley.mjs` 生成 `events.json`)。 +- **成本**(M3 Max,SwiftShader):正式渲染整条流程约 40 s,`determinism.sh` 150/150 帧逐字节相同。 + +## 自查重点 + +- **像不像铬**:100% 裁切一个字母,要看到天、一条横着的硬地平线(白线和银)、暗带、底边一道细亮色、至少一块高光;缩到 640 px 宽时是"金属",不是"蓝粉糖"。 +- **角色在不在**:每一秒都能指出铬团在哪儿、在干什么。 +- **字号**:按观看档量 1080p 成片里的 px(`desktop` 辅助 ≥ 44,`feed` 辅助 ≥ 80)。 +- **遮挡**:跳跃的弧线、贴纸、角标和 CLEAR 有没有压住必读的字。 +- **高光动没动**:相邻两拍的铬面高光位置不一样,大块平面不跟着一拍黑一拍白。 +- **声音**:落点都在拍上(`bin/vh qa` 的 cue check);砸地和换场要有 1–4 kHz 的起音;片中没有数字静音。 + +## 相关资源 + +- `references/repos/lemo-opuscar/styles/` 里的 `game-show`(踩拍的玩具感和贴纸横幅,全是平涂矢量)和 `glass-product`(自发光条状面拼的摄影棚当反射环境),LemoLab,MIT。 +- 本库里容易混的:`synthwave-outrun`、`halftone-comic`、`pixel-16bit`。 +- `cases/oneshot-five.md` 第 4 节;`video-types/03-product-promo.md`、`08-brutalist-meme.md`、`04-lyric-music-video.md`;`playbook/03-motion-design.md` 第 4 节(字号分档);`playbook/04-audio.md`。 diff --git a/styles/y2k-chrome/events.json b/styles/y2k-chrome/events.json new file mode 100644 index 0000000..ed2f4d1 --- /dev/null +++ b/styles/y2k-chrome/events.json @@ -0,0 +1,22 @@ +[ + {"t":0,"sfx":"impact","gain_db":-7,"pan":0}, + {"t":0,"sfx":"click","gain_db":-6,"pan":0}, + {"t":0.4,"sfx":"pop","gain_db":-10,"pan":0,"pitch":-6}, + {"t":0.4,"sfx":"swoosh_tonal","gain_db":-10,"pan":0,"dur":0.3,"dir":"up","bright":0.6}, + {"t":0.8,"sfx":"pop","gain_db":-11,"pan":-0.2,"pitch":0}, + {"t":1,"sfx":"pop","gain_db":-11,"pan":0.27,"pitch":2}, + {"t":1.2,"sfx":"pop","gain_db":-11,"pan":-0.25,"pitch":4}, + {"t":1.4,"sfx":"pop","gain_db":-11,"pan":0.12,"pitch":6}, + {"t":1.6,"sfx":"paper","gain_db":-8,"pan":0.17,"dur":0.12,"bright":0.5}, + {"t":1.8,"sfx":"pop","gain_db":-9,"pan":-0.59,"pitch":-8}, + {"t":2,"sfx":"pop","gain_db":-10,"pan":-0.34,"pitch":-3}, + {"t":2.2,"sfx":"pop","gain_db":-10,"pan":0,"pitch":0}, + {"t":2.4,"sfx":"pop","gain_db":-10,"pan":0.34,"pitch":3}, + {"t":2.86,"sfx":"swoosh_tonal","gain_db":-7,"pan":-0.34,"dur":0.12,"center":360,"dir":"down","tone":0.95}, + {"t":3.26,"sfx":"swoosh_tonal","gain_db":-7,"pan":0,"dur":0.12,"center":450,"dir":"down","tone":0.95}, + {"t":3.66,"sfx":"swoosh_tonal","gain_db":-7,"pan":0.34,"dur":0.12,"center":560,"dir":"down","tone":0.95}, + {"t":4.3,"sfx":"swoosh_tonal","gain_db":-9,"pan":0,"dur":0.5,"bright":0.7,"pan_from":0.8,"pan_to":-0.8}, + {"t":4.4,"sfx":"pop","gain_db":-8,"pan":0,"pitch":-4}, + {"t":4.6,"sfx":"paper","gain_db":-8,"pan":0.13,"dur":0.1,"bright":0.6}, + {"t":4.8,"sfx":"shimmer","gain_db":-14,"pan":0.27} +] diff --git a/styles/y2k-chrome/media/poster.jpg b/styles/y2k-chrome/media/poster.jpg new file mode 100644 index 0000000..ca25c65 Binary files /dev/null and b/styles/y2k-chrome/media/poster.jpg differ diff --git a/styles/y2k-chrome/media/swatch.mp4 b/styles/y2k-chrome/media/swatch.mp4 new file mode 100644 index 0000000..4f7fd4f Binary files /dev/null and b/styles/y2k-chrome/media/swatch.mp4 differ diff --git a/styles/y2k-chrome/score.json b/styles/y2k-chrome/score.json new file mode 100644 index 0000000..294e67c --- /dev/null +++ b/styles/y2k-chrome/score.json @@ -0,0 +1,35 @@ +{ + "bpm": 150, "key": "A", "mode": "major", "seed": 2000, "meters": {"1": 2, "2": 3, "3": 3, "4": 2, "5": 3}, + "stereo": true, "space": "plate", + "sections": [ + {"name": "slam", "bars": 1, "chords": ["I"]}, + {"name": "title", "bars": 1, "chords": ["V"]}, + {"name": "levels", "bars": 1, "chords": ["vi"]}, + {"name": "select", "bars": 1, "chords": ["IV"], "riser": {"gain_db": -18}}, + {"name": "wipe", "bars": 1, "chords": ["I"]} + ], + "parts": [ + {"inst": "edm_kick", "id": "kick", "pattern": "x...", "gain_db": -1, "hit": true, + "by_section": {"slam": {"pattern": "X...x..."}, "select": {"pattern": "X...", "gain_db": 3}, "wipe": {"pattern": "X...x...x..."}}}, + {"inst": "clap", "id": "clap", "pattern": "....X.......", "gain_db": -5, "pan": 0.05, "send": 0.15, + "by_section": {"slam": {"pattern": "....X..."}, "levels": {"pattern": "X.......X..."}, "select": {"pattern": "....X.x."}}}, + {"inst": "hihat", "id": "hats", "sections": ["title", "levels", "select", "wipe"], "pattern": "g.o.", "gain_db": -10, "pan": -0.3}, + {"inst": "sub808", "id": "sub", "sections": ["slam", "wipe"], "pattern": [[0, 1.6, "c0", 1.0]], "octave": 1, "params": {"drive": 1.1, "decay": 0.9, "retrigger": true}, "gain_db": -6, + "by_section": {"wipe": {"pattern": [[0, 2.4, "c0", 0.9]]}}}, + {"inst": "sq_bass", "id": "bass", "sections": ["title", "levels", "select", "wipe"], "octave": 2, "gain_db": -3, "params": {"cutoff": 320, "env": 0.06}, + "pattern": [[0.5, 0.35, "c0"], [1.5, 0.35, "c0'"], [2.5, 0.35, "c0"]]}, + {"inst": "organ", "id": "organ", "figure": "stab", "pattern": "X..x..x...x.", "voicing": "spread", "octave": 4, "gain_db": -6, "send": 0.2, "pan": 0.2, "hit": "section", + "by_section": {"slam": {"pattern": "X~~....."}, "select": {"pattern": "X..x..x."}, "wipe": {"pattern": "X..x..x...X."}}}, + {"inst": "pulse", "id": "coin", "sections": ["levels", "select"], "params": {"duty": 0.25, "decay": 0.25, "sus": 0.3}, "gain_db": -9, "pan": -0.35, "hit": true, + "by_section": {"levels": {"pattern": [[2, 0.2, "B5", 0.9]]}, "select": {"pattern": [[0, 0.2, "C#6", 0.9], [1, 0.2, "E6", 0.9]]}}}, + {"inst": "pulse", "id": "coin2", "sections": ["levels", "select"], "params": {"duty": 0.25, "decay": 0.25, "sus": 0.3}, "gain_db": -9, "pan": -0.35, + "by_section": {"levels": {"pattern": [[2.2, 0.7, "E6", 0.9]]}, "select": {"pattern": [[0.2, 0.7, "F#6", 0.9], [1.2, 0.75, "A6", 0.9]]}}}, + {"inst": "metal", "id": "anvil", "sections": ["slam", "wipe"], "pattern": "X.......", "gain_db": -6, "pan": 0, "send": 0.2, "hit": true}, + {"inst": "glockenspiel", "id": "glock", "sections": ["slam", "wipe"], "gain_db": -10, "pan": 0.4, "send": 0.3, + "by_section": { + "slam": {"pattern": [[0, 1, "A6", 0.7], [1, 1, "E7", 0.6]]}, + "wipe": {"pattern": [[1, 0.25, "A6", 0.6], [1.25, 0.25, "C#7", 0.6], [1.5, 0.5, "E7", 0.7], [2, 1, "A7", 0.6]]} + }}, + {"inst": "strings", "id": "bed", "figure": "sustain", "octave": 3, "gain_db": -14, "send": 0.25} + ] +} diff --git a/styles/y2k-chrome/swatch.js b/styles/y2k-chrome/swatch.js new file mode 100644 index 0000000..8084eab --- /dev/null +++ b/styles/y2k-chrome/swatch.js @@ -0,0 +1,589 @@ +// y2k-chrome swatch · Y2K 铬面 (150 BPM, beat 0.4 s; bars of 2 + 3 + 3 + 2 + 3 beats start at 0 / 0.8 / 2.0 / 3.2 / 4.0) +// One character carries the film: a blob of liquid chrome. It lands, spits out the title, stays on as a small blob, +// hops onto the three level tiles on the beat, is swept up by a chrome tube and comes back as "Y2K". +// Chrome is a WebGL pass: chrome objects are painted into a field canvas (R = coverage, thresholded at 0.5 in the shader; +// G = dome height from a wide blur, or a painted sphere for droplets; B = bevel height from a narrow blur, also cut at +// 0.2 for an ink keyline). The blob and the tube get their height analytically in the shader instead (an 8-bit ramp +// across a 600 px dome draws contour rings). The shader turns height into a normal and reflects it into a procedural +// studio: deep-blue sky, silver near a hard (anti-aliased) horizon, a black line, a dark-purple ground with a thin +// hot-pink edge at the very bottom, two oval softboxes and a soft black flag. On the title rows the elevation is +// blended with a screen-space term, so the horizon runs straight through each line of letters (airbrushed chrome) +// instead of dripping along every stroke. From 2.0 s the studio swings ±0.15 rad on every beat, so the highlights slide. +// Everything else (gel tiles, stickers, the score pill, sparkles) is Canvas2D. +// 0.00 HOOK · the blob lands on acid yellow mid-squash: fall streaks, a white shock ring, an anvil hit +// 0.08–0.40 it hops down-screen and lands below the title area (squash); the score pill drops in +// 0.40–1.48 the landing splashes the first word's droplets high into the air; then it swells and spits the rest +// letter by letter: droplets arc up and swell into chrome letters with an overshoot; the four words land +// on the eighth notes 0.8 / 1.0 / 1.2 / 1.4; the blob is left small (r 56) +// 1.52–1.80 it hops to a resting spot at the left; 1.60 the Chinese line on a die-cut sticker slaps down +// 2.00–2.40 three level tiles pop out of the floor on the eighths: 1-1 大纲 cyan gel → 1-2 分镜 magenta gel → +// 1-3 初版 silver chrome with a little monitor (the more finished, the shinier); the blob bobs to each pop +// 2.80 / 3.20 / 3.60 the blob hops onto each tile and lands on the beat: tile and blob squash, sparkles, a CLEAR +// sticker on the tile's corner, the score ticks by 1000 +// 3.62–4.08 stage clear: a hop runs through the title letters, left to right +// 4.00–4.60 SIGNATURE TRANSITION: a tilted chrome tube sweeps in from the right like a squeegee (a softbox runs across +// it, a glint rides its crest), pushes the blob and swallows it; behind the tube, on a clean acid frame, +// the blob drops out at the centre, swells, and at 4.40 bursts into chrome "Y2K"; a CHROME sticker slaps +// on at 4.60; a glint at 4.80 + +const BEAT = 0.4; +const T = { + slam: 0.0, hop: 0.4, hud: 0.4, gather: [0.42, 0.58], + words: [0.8, 1.0, 1.2, 1.4], slap: 1.6, rest: 1.8, tiles: [2.0, 2.2, 2.4], + lands: [2.8, 3.2, 3.6], wave: 3.62, wipe: [4.0, 4.6], endPop: 4.4, endSticker: 4.6, glint: 4.8, +}; +const LAST = T.words[3] + 0.08; // the last droplet has left; the blob is down to its small size +const FLIGHT = 0.22; // a droplet's flight to its letter (s); the first word's leave on the hop landing +const HOP = 0.24; // the small blob's air time per hop +const FLOOR = 934; // the blob stands on this line (the tiles' bottom edge) +const TILE = { y: 840, w: 300, h: 188, r: 46, xs: [520, 960, 1400] }; +const TOP = TILE.y - TILE.h / 2; // 706: the tiles' top edge +const STK = { x: 1180, y: 566, rot: -0.05 }; // the die-cut sticker +const BLOB = { x: 960, y: 540, R: 330, Rlow: 195, Rsmall: 56, rest: [200, FLOOR - 56] }; +const END = { x: 960, y: 560 }; // where the blob drops out of the tube and turns into "Y2K" +const pan = (x) => Math.round(((2 * x) / 1920 - 1) * 75) / 100; // pan = (2x/W − 1) · 0.75 + +let L = null, FX = null; + +// ───────────────────────── chrome shader ───────────────────────── +const vec3 = (hex) => { const n = parseInt(hex.slice(1), 16); return `vec3(${[(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => (v / 255).toFixed(4)).join(",")})`; }; +const FRAG = (P, M) => ` +uniform float u_rot, u_kG, u_kB, u_rim, u_key; // u_key: keyline threshold on the bevel blur (0 = none) +uniform vec4 u_blob; // analytic blob: centre (canvas px), radius, lobe phase +uniform vec2 u_blobS; // its squash (sx, sy) +uniform vec4 u_tube; // analytic tube: centre-line x at y = 540, normal, half-width +uniform vec4 u_rows; // title rows: centre y of row 1 and 2, half height, weight +const vec3 SKY_TOP = ${vec3(P("skyTop"))}, SKY_MID = ${vec3(P("skyMid"))}, SKY_LOW = ${vec3(P("skyLow"))}; +const vec3 SILVER = ${vec3(P("silver"))}, GROUND = ${vec3(P("ground"))}, PURPLE = ${vec3(M)}, PINK = ${vec3(P("accent"))}; +const vec3 CYA = ${vec3(P("cyan"))}; +vec2 cpx(vec2 uv){ return vec2(uv.x, 1.0 - uv.y) * u_res; } // canvas pixels, y down +float ana(vec2 uv){ // analytic heights (px): float precision, no 8-bit steps + vec2 p = cpx(uv); float h = 0.0; + if (u_blob.z > 0.5) { + vec2 q = (p - u_blob.xy) / (u_blob.z * u_blobS); + float th = atan(q.y, q.x), rr = 1.0 + 0.05 * sin(3.0 * th + u_blob.w) + 0.035 * sin(5.0 * th - 1.7 * u_blob.w); + float rho = length(q) / rr; h += 0.62 * u_blob.z * sqrt(max(0.0, 1.0 - rho * rho)); + } + if (u_tube.w > 0.5) { float d = dot(p - vec2(u_tube.x, 540.0), u_tube.yz) / u_tube.w; h += 0.8 * u_tube.w * sqrt(max(0.0, 1.0 - d * d)); } + return h; +} +float box(vec2 p, vec2 c, vec2 h, float s){ vec2 d = abs(p - c) - h; return 1.0 - smoothstep(0.0, s, max(d.x, d.y)); } +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); } +vec3 env(vec3 r, float pk){ + float e = r.y; // elevation: + up + float a = atan(r.x, r.z) + u_rot; // azimuth: 0 = straight back at the camera + a = mod(a + 3.14159265, 6.2831853) - 3.14159265; + const float HZ = -0.12; + float s = clamp((e - HZ) / (1.0 - HZ), 0.0, 1.0), g = clamp((HZ - e) / (1.0 + HZ), 0.0, 1.0); + vec3 sky = mix(SILVER, SKY_LOW, smoothstep(0.06, 0.22, s)); // silver just above the horizon, then the sky + sky = mix(sky, SKY_MID, smoothstep(0.22, 0.45, s)); + sky = mix(sky, SKY_TOP, smoothstep(0.45, 0.95, s)); + sky = mix(sky, vec3(1.0), 1.0 - smoothstep(0.0, 0.05, s)); // a white line right on the horizon + vec3 gnd = mix(GROUND, PURPLE, smoothstep(0.05, 0.35, g)); // a black line, then dark purple + gnd = mix(gnd, PINK, smoothstep(pk, pk + 0.12, g)); // hot pink only on the lowest edge + vec3 col = mix(gnd, sky, smoothstep(HZ - 0.012, HZ + 0.012, e)); // a hard horizon, but anti-aliased + float flag = box(vec2(a, e), vec2(0.62, 0.05), vec2(0.13, 0.75), 0.12); // a soft black flag: a dark edge beside the light + col = mix(col, GROUND, flag * 0.55); + float sb = 1.0 - smoothstep(0.72, 1.0, length((vec2(a, e) - vec2(-0.85, 0.42)) / vec2(0.20, 0.24))); // two oval softboxes + sb = max(sb, 1.0 - smoothstep(0.65, 1.0, length((vec2(a, e) - vec2(1.05, 0.22)) / vec2(0.12, 0.12)))); + return mix(col, vec3(1.0), sb); +} +void main(){ + vec2 uv = clamp(v_uv, 24.0 / u_res, 1.0 - 24.0 / u_res); // full-bleed chrome: blurred masks fade out at the frame edge + vec4 c = texture(u_tex0, uv); + float aa = max(fwidth(c.r) * 0.7, 0.012); + float cov = smoothstep(0.5 - aa, 0.5 + aa, c.r); + float ka = max(fwidth(c.b) * 0.7, 0.01); + float key = u_key > 0.0 ? smoothstep(u_key - ka, u_key + ka, c.b) : 0.0; // the bevel blur, cut lower = a dilated outline + if (max(cov, key) <= 0.0) { outColor = vec4(0.0); return; } + vec2 px = 1.0 / u_res; + // dome (G) and bevel (B) gradients, each averaged over two stencils: 8-bit steps become noise that averages out + vec2 ax = vec2(px.x, 0.0), ay = vec2(0.0, px.y); + float gx = u_kG * 0.5 * ((texture(u_tex0, uv + 5.0 * ax).g - texture(u_tex0, uv - 5.0 * ax).g) / 10.0 + + (texture(u_tex0, uv + 8.0 * ax).g - texture(u_tex0, uv - 8.0 * ax).g) / 16.0) + + u_kB * 0.5 * ((texture(u_tex0, uv + 2.0 * ax).b - texture(u_tex0, uv - 2.0 * ax).b) / 4.0 + + (texture(u_tex0, uv + 3.5 * ax).b - texture(u_tex0, uv - 3.5 * ax).b) / 7.0) + + (ana(v_uv + ax) - ana(v_uv - ax)) / 2.0; + float gy = u_kG * 0.5 * ((texture(u_tex0, uv + 5.0 * ay).g - texture(u_tex0, uv - 5.0 * ay).g) / 10.0 + + (texture(u_tex0, uv + 8.0 * ay).g - texture(u_tex0, uv - 8.0 * ay).g) / 16.0) + + u_kB * 0.5 * ((texture(u_tex0, uv + 2.0 * ay).b - texture(u_tex0, uv - 2.0 * ay).b) / 4.0 + + (texture(u_tex0, uv + 3.5 * ay).b - texture(u_tex0, uv - 3.5 * ay).b) / 7.0) + + (ana(v_uv + ay) - ana(v_uv - ay)) / 2.0; + vec3 n = normalize(vec3(-gx, -gy, 1.0)); + vec3 r = reflect(vec3(0.0, 0.0, -1.0), n); + float onAna = step(0.5, ana(v_uv)); + if (u_rows.w > 0.0 && onAna < 0.5) { // on a title row: a straight horizon through the letters + float py = cpx(v_uv).y, d1 = py - u_rows.x, d2 = py - u_rows.y, d = abs(d1) < abs(d2) ? d1 : d2; + if (abs(d) < u_rows.z * 2.2) r.y = mix(r.y, clamp(0.1 - d / u_rows.z * 0.7, -1.0, 1.0), u_rows.w); // horizon ~2/3 down + } + vec3 col = env(r, mix(0.52, 0.9, onAna)); + float fr = clamp(pow(1.0 - n.z, 2.0), 0.0, 1.0); + vec3 tint = mix(PINK, CYA, 0.5 + 0.5 * sin(atan(n.y, n.x) + 0.6)); // a faint holographic rim + col = mix(col, col * 0.6 + tint * 0.5, fr * 0.25); + col *= mix(u_rim, 1.0, smoothstep(0.5, 0.74, c.r)); // a thin dark contour + col += (h21(gl_FragCoord.xy) - 0.5) / 255.0; + outColor = vec4(clamp(mix(GROUND, col, cov), 0.0, 1.0), max(cov, key)); +}`; + +// ───────────────────────── setup ───────────────────────── +export async function setup(ctx, tokens, lib) { + const P = (k) => lib.color(tokens, k); + FX = lib.shader(FRAG(P, lib.mixColor(P("endBg"), P("accent"), 0.32))); + // title: two lines, inflated chrome + const size = 180, lw = Math.round(size * 0.075); + lib.setFont(ctx, tokens, "display", size); + const lines = ["Every frame", "is code."], ys = [294, 480]; + const glyphs = []; + let word = 0; + lines.forEach((s, li) => { + const lay = lib.layoutText(ctx, s, { x: 960, y: ys[li], align: "center", tracking: size * 0.02 }); + lay.glyphs.forEach((g) => { if (g.ch === " ") { word++; return; } glyphs.push({ ...g, line: li, word }); }); + word++; + }); + const capH = size * 0.72; + const byWord = {}; // within a word, letters land a little apart around its eighth + glyphs.forEach((g) => (byWord[g.word] ||= []).push(g)); + Object.values(byWord).forEach((ws) => ws.forEach((g, j) => { + g.land = T.words[g.word] + (j - (ws.length - 1) / 2) * 0.026; + g.leave = g.word ? g.land - FLIGHT : T.hop + 0.015 * j; // the hop landing at 0.4 splashes out the whole first word + })); + glyphs.forEach((g, i) => { // each glyph pops from its own ink centre (a period from the baseline) + const m = ctx.measureText(g.ch); + g.k = i; g.cy = g.y - (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2; g.sx0 = BLOB.x + (lib.hash(3, i) - 0.5) * 120; + }); + const rows = [ys[0] - capH / 2, ys[1] - capH / 2, capH / 2 + lw / 2]; + lib.setFont(ctx, tokens, "zh", 70); + const zh = lib.layoutText(ctx, lib.TITLE_ZH, { x: 0, y: 25, align: "center", tracking: 70 * 0.06 }); + const sparkles = [ // [x, y, r, t0]: twinkles that land on beats + [250, 215, 44, 0.0], [1690, 640, 52, 0.0], [1700, 210, 40, 0.4], [300, 560, 34, 1.2], [1630, 455, 38, 2.0], + [150, 700, 30, 2.4], [1760, 900, 34, 2.8], [330, 440, 40, 3.2], [1690, 270, 46, 3.6], [1560, 140, 30, 3.6], + ]; + lib.setFont(ctx, tokens, "ui", 64); + const chromeWord = lib.layoutText(ctx, "CHROME", { x: 0, y: 23, align: "center", tracking: 64 * 0.08 }); + L = { P, size, lw, glyphs, capH, rows, zh, chromeWord, sparkles }; +} + +// ───────────────────────── shapes ───────────────────────── +function star4(ctx, x, y, r, rot = 0, pinch = 0.16) { // a four-point sparkle with concave sides + ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.beginPath(); + for (let i = 0; i < 4; i++) { + const a = (i * Math.PI) / 2, b = a + Math.PI / 4, c = a + Math.PI / 2; + if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r); + ctx.quadraticCurveTo(Math.cos(b) * r * pinch, Math.sin(b) * r * pinch, Math.cos(c) * r, Math.sin(c) * r); + } + ctx.closePath(); ctx.restore(); +} +function blobPath(ctx, b) { // a liquid blob: a circle with slow lobes (same formula as the shader) + ctx.beginPath(); + for (let i = 0; i <= 72; i++) { + const a = (i / 72) * Math.PI * 2, rr = b.R * (1 + 0.05 * Math.sin(3 * a + b.ph) + 0.035 * Math.sin(5 * a - 1.7 * b.ph)); + const px = b.x + Math.cos(a) * rr * b.sx, py = b.y + Math.sin(a) * rr * b.sy; + i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); + } + ctx.closePath(); +} +const SPHERE = [[0, 1], [0.3, 0.954], [0.5, 0.866], [0.7, 0.714], [0.82, 0.572], [0.9, 0.436], [0.96, 0.28], [1, 0]]; +function domeFill(ctx, x, y, R) { // sphere height profile as an alpha ramp (droplets) + const g = ctx.createRadialGradient(x, y, 0, x, y, R); + SPHERE.forEach(([s, h]) => g.addColorStop(s, `rgba(255,255,255,${h})`)); + return g; +} + +// ───────────────────────── chrome pass ───────────────────────── +// blurObjs(x): sharp white shapes whose height comes from blurring them (letters, the chrome tile). +// domeObjs(x, mode): "mask" fills the shapes white; "height" paints their sphere profile (droplets). +// blob / tube: height computed analytically in the shader; their coverage still comes through domeObjs(x, "mask"). +function chromePass(ctx, t, lib, { blurObjs = () => {}, domeObjs = () => {}, shadow = true, rim = 0.2, key = 0.2, rotExtra = 0, blob = null, tube = null, rows = null }) { + const P = L.P, W = lib.W, H = lib.H; + const mB = lib.layer("y2k_mB"); blurObjs(mB); + const mask = lib.layer("y2k_mask"); mask.drawImage(mB.canvas, 0, 0); domeObjs(mask, "mask"); + if (blob) { mask.fillStyle = "#fff"; blobPath(mask, blob); mask.fill(); } + const tint = (src, blur, col, name = "y2k_tmp") => { + const x = lib.layer(name); if (blur > 0) x.filter = `blur(${blur}px)`; x.drawImage(src, 0, 0); x.filter = "none"; + x.globalCompositeOperation = "source-in"; x.fillStyle = col; x.fillRect(0, 0, W, H); return x; + }; + if (shadow) { + const s = tint(mask.canvas, 11, P("shadow")); + ctx.save(); ctx.globalAlpha = 0.42; ctx.drawImage(s.canvas, 16, 24); ctx.restore(); + } + const hm = lib.layer("y2k_hm"); hm.fillStyle = "#000"; hm.fillRect(0, 0, W, H); hm.globalCompositeOperation = "lighter"; + hm.drawImage(tint(mask.canvas, 3, "#f00").canvas, 0, 0); + hm.drawImage(tint(mask.canvas, 6, "#00f").canvas, 0, 0); + const g = lib.layer("y2k_g"); g.filter = "blur(14px)"; g.drawImage(mB.canvas, 0, 0); g.filter = "none"; + domeObjs(g, "height"); + g.globalCompositeOperation = "source-in"; g.fillStyle = "#0f0"; g.fillRect(0, 0, W, H); + hm.drawImage(g.canvas, 0, 0); + const rot = 0.1 * Math.sin(t * 0.9) + beatSlide(t, lib) + rotExtra; + ctx.drawImage(FX.render(t, { u_rot: rot, u_kG: 70, u_kB: 18, u_rim: rim, u_key: key, + u_blob: blob ? [blob.x, blob.y, blob.R, blob.ph] : [0, 0, 0, 0], u_blobS: blob ? [blob.sx, blob.sy] : [1, 1], + u_tube: tube ? [tube.X, tube.n[0], tube.n[1], tube.hw] : [0, 1, 0, 0], u_rows: rows || [0, 0, 1, 0] }, { u_tex0: hm.canvas }), 0, 0); +} +function beatSlide(t, lib) { // from 2.0 s the studio swings ±0.15 rad, one way then back, on every beat + let r = 0; // (a swing, not a spin: flat faces never turn into the flag or a softbox) + for (let k = 0, b = 2.0; b < 5.0; k++, b += BEAT) r += (k === 0 ? 0.15 : k % 2 ? -0.3 : 0.3) * lib.tween(t, b, b + 0.18, lib.ease.outCubic); + return r; +} + +// ───────────────────────── the blob ───────────────────────── +const sqz = (t, t0, A, tau = 0.11, f = 4.2) => (t < t0 ? 0 : A * Math.exp(-(t - t0) / tau) * Math.cos(2 * Math.PI * f * (t - t0))); +// the small blob's hops after the title: [take-off, landing, from, to, arc height] +const HOPS = (() => { + const onTile = (k) => [TILE.xs[k], TOP - BLOB.Rsmall]; + const h = [[LAST + 0.04, T.rest, [BLOB.x, FLOOR - BLOB.Rsmall], BLOB.rest, 150]]; + T.lands.forEach((l, k) => h.push([l - HOP, l, k ? onTile(k - 1) : BLOB.rest, onTile(k), k ? 110 : 150])); + return h; +})(); +function blobMain(t, lib) { + let x = BLOB.x, y, R, s; + if (t < T.hop) { // the slam, then a hop down-screen, so the letters never touch it + const u = lib.seg(t, 0.08, T.hop), air = t > 0.08 ? 1 : 0; + R = lib.lerp(BLOB.R, BLOB.Rlow, u); + y = lib.lerp(BLOB.y, FLOOR - BLOB.Rlow, u) - 150 * 4 * u * (1 - u) * air; + s = sqz(t, T.slam, -0.3) + 0.1 * Math.sin(Math.PI * u) * air; + } else if (t < HOPS[0][0]) { // spitting: it swells, trembles and shrinks to its small size + const k = lib.seg(t, T.hop, LAST); + R = lib.lerp(BLOB.Rlow, BLOB.Rsmall, 1 - Math.pow(1 - k, 1.5)) * (1 + 0.08 * Math.sin(Math.PI * lib.seg(t, ...T.gather))); + y = FLOOR - R; + s = sqz(t, T.hop, -0.2) + 0.04 * Math.sin(2 * Math.PI * 9 * t) * (1 - k); + } else { // the small blob: hops to rest, bobs, hops onto the tiles + R = BLOB.Rsmall; + let pos = HOPS[HOPS.length - 1][3]; s = 0; + for (const [t0, t1, a, b, h] of HOPS) { + if (t >= t0 && t < t1) { const u = (t - t0) / (t1 - t0); pos = [lib.lerp(a[0], b[0], u), lib.lerp(a[1], b[1], u) - h * 4 * u * (1 - u)]; s += 0.16 * Math.sin(Math.PI * u); break; } + if (t < t0) { pos = a; break; } + } + [x, y] = pos; + for (const [t0, t1] of HOPS) s += -0.16 * Math.sin(Math.PI * lib.seg(t, t0 - 0.08, t0)) + sqz(t, t1, -0.32); // crouch, land + for (const b of T.tiles) if (t < HOPS[1][0]) s += sqz(t, b, -0.1); // bobs to each tile pop while it waits + } + const sy = 1 + s, sx = 1 / Math.sqrt(Math.max(0.4, sy)); + return { x, y: y + R * (1 - sy), R, sx, sy, ph: 2.1 * t }; // squash about the bottom: it stays on the floor +} +function glyphState(g, t, lib) { + const t0 = g.leave; + if (t < t0) return null; + const u = lib.clamp((t - t0) / (g.land - t0)); + const b = blobMain(t0, lib); + const sy0 = b.y - b.R * 0.6; + const dropR = 24 * (u < 1 ? 1 : 1 - lib.clamp((t - g.land) / 0.09)); + const dx = lib.lerp(g.sx0, g.cx, u), dy = lib.lerp(sy0, g.cy, u) - (g.word ? 160 : 240) * 4 * u * (1 - u); + const p = t >= g.land ? lib.spring(t - g.land, { w: 26, zeta: 0.38 }) : 0; + return { drop: dropR > 0.5 ? { x: dx, y: dy, r: dropR } : null, scale: t >= g.land ? 0.25 + 0.75 * p : 0 }; +} +function drawLetters(x, t, tokens, lib) { + lib.setFont(x, tokens, "display", L.size); + x.lineJoin = "round"; x.fillStyle = "#fff"; x.strokeStyle = "#fff"; x.lineWidth = L.lw; + x.textAlign = "center"; x.textBaseline = "alphabetic"; + for (const g of L.glyphs) { + const s = glyphState(g, t, lib); + if (!s || s.scale <= 0) continue; + const sc = s.scale, sxx = sc * (1 + 0.3 * (sc - 1)), syy = sc * (1 - 0.3 * (sc - 1)); + const wave = -24 * Math.sin(Math.PI * lib.clamp((t - T.wave - 0.016 * g.k) / 0.2)); // stage clear: a hop runs through the title + x.save(); x.translate(g.cx, g.cy + wave); x.scale(sxx, syy); x.translate(0, g.y - g.cy); + x.fillText(g.ch, 0, 0); x.strokeText(g.ch, 0, 0); x.restore(); + } +} +function drops(x, t, lib, mode) { + for (const g of L.glyphs) { + const s = glyphState(g, t, lib); + if (!s || !s.drop) continue; + x.fillStyle = mode === "mask" ? "#fff" : domeFill(x, s.drop.x, s.drop.y, s.drop.r); + x.beginPath(); x.arc(s.drop.x, s.drop.y, s.drop.r, 0, Math.PI * 2); x.fill(); + } +} + +// ───────────────────────── tiles ───────────────────────── +function tileState(k, t, lib) { + const tf = T.tiles[k] - 0.09; // pops up out of the floor: full size right on its eighth + if (t < tf) return null; + const p = lib.spring(t - tf, { w: 22, zeta: 0.4 }); // overshoots ~25 %, then a jelly settle + let sx = p * (1 - 0.25 * (p - 1)), sy = p * (1 + 0.4 * (p - 1)); + const c = T.lands[k]; + if (t >= c) { const q = t - c, d = 0.12 * Math.exp(-q / 0.1) * Math.cos(2 * Math.PI * 6 * q); sx *= 1 + d * 0.5; sy *= 1 - d; } // the blob lands on it + return { cx: TILE.xs[k], cy: TILE.y, sx, sy }; +} +function tilePath(x, s) { // anchored at the tile's bottom edge (squash from the floor) + const w = TILE.w * s.sx, h = TILE.h * s.sy, bot = s.cy + TILE.h / 2; + x.beginPath(); x.roundRect(s.cx - w / 2, bot - h, w, h, TILE.r * Math.min(s.sx, s.sy)); + return { x0: s.cx - w / 2, y0: bot - h, w, h }; +} +function gelTile(ctx, k, t, lib, base) { + const s = tileState(k, t, lib); if (!s) return; + const P = L.P; + ctx.save(); + ctx.shadowColor = lib.rgba(P("shadow"), 0.45); ctx.shadowBlur = 18; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 18; + const b = tilePath(ctx, s); + const g = ctx.createLinearGradient(0, b.y0, 0, b.y0 + b.h); + g.addColorStop(0, lib.mixColor(base, "#000000", 0.25)); g.addColorStop(0.55, base); g.addColorStop(1, lib.mixColor(base, "#ffffff", 0.45)); + ctx.globalAlpha = 0.9; ctx.fillStyle = g; ctx.fill(); + ctx.restore(); + ctx.save(); tilePath(ctx, s); ctx.clip(); + const rg = ctx.createRadialGradient(s.cx, b.y0 + b.h * 1.05, 10, s.cx, b.y0 + b.h * 1.05, b.w * 0.55); // caustic glow at the bottom + rg.addColorStop(0, "rgba(255,255,255,0.55)"); rg.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = rg; ctx.fillRect(b.x0, b.y0, b.w, b.h); + const gl = ctx.createLinearGradient(0, b.y0, 0, b.y0 + b.h * 0.48); // the gel gloss + gl.addColorStop(0, "rgba(255,255,255,0.92)"); gl.addColorStop(1, "rgba(255,255,255,0.12)"); + ctx.fillStyle = gl; ctx.beginPath(); ctx.roundRect(b.x0 + 14, b.y0 + 7, b.w - 28, b.h * 0.46, [TILE.r - 10, TILE.r - 10, 30, 30]); ctx.fill(); + ctx.restore(); + ctx.save(); tilePath(ctx, s); ctx.lineWidth = 3; ctx.strokeStyle = lib.mixColor(base, "#000000", 0.45); ctx.stroke(); ctx.restore(); + ctx.save(); ctx.translate(s.cx, b.y0 + b.h * 0.56); ctx.scale(s.sx, s.sy); // the icon, white, slightly sunk + ctx.fillStyle = "#fff"; ctx.strokeStyle = "#fff"; ctx.shadowColor = lib.rgba(lib.mixColor(base, "#000000", 0.5), 0.6); ctx.shadowBlur = 4; ctx.shadowOffsetY = 3; + if (k === 0) { // outline: a bullet list + ctx.lineWidth = 13; ctx.lineCap = "round"; + for (let r = 0; r < 3; r++) { const y = -42 + r * 36; ctx.beginPath(); ctx.arc(-76, y, 9, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.moveTo(-46, y); ctx.lineTo(r === 2 ? 30 : 82, y); ctx.stroke(); } + } else { // storyboard: three panels + for (let r = 0; r < 3; r++) { ctx.beginPath(); ctx.roundRect(-102 + r * 72, -44, 60, 86, 10); ctx.fill(); } + } + ctx.restore(); +} +function chromeTileMask(x, t, lib) { + const s = tileState(2, t, lib); if (!s) return; + x.fillStyle = "#fff"; tilePath(x, s); x.fill(); +} +function monitor(ctx, t, lib) { // 初版: a little monitor on the chrome tile, the finished frame on it + const s = tileState(2, t, lib); if (!s) return; + const P = L.P, cy = s.cy + TILE.h / 2 - TILE.h * s.sy * 0.52; + ctx.save(); ctx.translate(s.cx, cy); ctx.scale(s.sx, s.sy); + ctx.fillStyle = P("fg"); + ctx.beginPath(); ctx.roundRect(-14, 34, 28, 22, 4); ctx.roundRect(-46, 52, 92, 12, 6); ctx.fill(); // neck and foot + ctx.beginPath(); ctx.roundRect(-84, -58, 168, 98, 18); ctx.fill(); // bezel + ctx.fillStyle = P("bg"); ctx.beginPath(); ctx.roundRect(-72, -47, 144, 76, 10); ctx.fill(); // the acid frame + const g = ctx.createLinearGradient(0, -34, 0, 20); // a tiny chrome blob + g.addColorStop(0, P("skyTop")); g.addColorStop(0.45, P("skyLow")); g.addColorStop(0.5, "#ffffff"); g.addColorStop(0.53, P("ground")); + g.addColorStop(0.85, lib.mixColor(P("endBg"), P("accent"), 0.22)); g.addColorStop(1, P("accent")); + ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(-10, -7, 30, 26, 0, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = P("fg"); ctx.lineWidth = 3; ctx.stroke(); + ctx.fillStyle = "#fff"; ctx.lineWidth = 2.5; star4(ctx, 42, -24, 15, 0.2); ctx.fill(); ctx.stroke(); + ctx.restore(); +} +function tileLabels(ctx, t, tokens, lib) { + const P = L.P; + for (let k = 0; k < 3; k++) { + const s = tileState(k, t, lib); if (!s) continue; + ctx.save(); ctx.globalAlpha = lib.clamp((t - T.tiles[k]) / 0.06); + lib.setFont(ctx, tokens, "zh", 56); ctx.fillStyle = P("fg"); ctx.textAlign = "center"; + ctx.fillText(lib.MOTIF[k].zh, TILE.xs[k], TILE.y + TILE.h / 2 + 70); + ctx.restore(); + } +} +function badges(ctx, t, tokens, lib) { // level numbers, stuck on each tile's top-left corner + const P = L.P; + for (let k = 0; k < 3; k++) { + const s = tileState(k, t, lib); if (!s) continue; + ctx.save(); ctx.globalAlpha = lib.clamp((t - T.tiles[k]) / 0.06); + ctx.translate(TILE.xs[k] - TILE.w / 2 + 34, TOP + 2); ctx.rotate(-0.06); + ctx.fillStyle = P("fg"); ctx.beginPath(); ctx.roundRect(-56, -34, 124, 66, 33); ctx.fill(); + lib.setFont(ctx, tokens, "ui", 48); ctx.fillStyle = P("bg"); ctx.textAlign = "center"; ctx.fillText(`1-${k + 1}`, 6, 17); + ctx.restore(); + } +} +function clearStamp(ctx, k, t, tokens, lib) { // CLEAR, slapped on a corner of the tile the blob just cleared + const c = T.lands[k] + 0.05; if (t < c) return; + const P = L.P, q = t - c, sc = 1 + 0.5 * Math.exp(-q / 0.06) * Math.cos(2 * Math.PI * 5 * q); + const last = k === 2; // 1-3's top is busy (level number + the blob): lower-left corner + ctx.save(); ctx.translate(last ? TILE.xs[k] - 160 : TILE.xs[k] + TILE.w / 2 - 8, last ? TILE.y + TILE.h / 2 - 4 : TOP + 6); + ctx.rotate(last ? -0.12 : 0.14); ctx.scale(sc, sc); + lib.setFont(ctx, tokens, "ui", 48); ctx.textAlign = "center"; + ctx.shadowColor = lib.rgba(P("shadow"), 0.5); ctx.shadowBlur = 6; ctx.shadowOffsetX = 4; ctx.shadowOffsetY = 6; + ctx.fillStyle = P("sticker"); ctx.beginPath(); ctx.roundRect(-96, -38, 192, 72, 36); ctx.fill(); ctx.shadowColor = "transparent"; + ctx.fillStyle = P("accent"); ctx.fillText("CLEAR", 0, 16); + ctx.restore(); +} + +// ───────────────────────── stickers, score, sparkles ───────────────────────── +// a white die-cut sticker: flies in from the lower right, slaps down on t0, settles with a visible wobble +function dieCut(ctx, t, tokens, lib, o) { + const tf = o.t0 - 0.1; + if (t < tf) return; + const P = L.P; + let sc = 1, rot = o.rot, dx = 0, dy = 0, off = 10; + if (t < o.t0) { const u = (t - tf) / 0.1, e = u * u; sc = lib.lerp(1.35, 1, e); rot = lib.lerp(o.rot - 0.25, o.rot, e); dx = 320 * (1 - e); dy = 200 * (1 - e); off = lib.lerp(50, 10, u); } + else { + const q = t - o.t0; sc = 1 - 0.12 * Math.exp(-q / 0.1) * Math.cos(2 * Math.PI * 5 * q); + rot += 0.06 * Math.exp(-q / 0.12) * Math.sin(2 * Math.PI * 5 * q); + for (const b of o.wobbles || []) if (t >= b) rot += 0.025 * Math.exp(-(t - b) / 0.12) * Math.sin(2 * Math.PI * 6 * (t - b)); + } + const s = lib.layer("y2k_stk"); + s.translate(o.x + dx, o.y + dy); s.rotate(rot); s.scale(sc, sc); + lib.setFont(s, tokens, o.role, o.size); s.textAlign = "center"; s.lineJoin = "round"; + s.fillStyle = P("sticker"); s.beginPath(); s.roundRect(o.lay.x0 - 8, -o.size * 0.52, o.lay.width + 16, o.size * 1.14, o.size * 0.4); s.fill(); + lib.drawGlyphs(s, o.lay, () => ({ fill: "none", stroke: P("sticker"), lineWidth: o.size * 0.63 })); + lib.drawGlyphs(s, o.lay, () => ({ fill: o.ink })); + ctx.save(); ctx.shadowColor = lib.rgba(P("shadow"), 0.55); ctx.shadowBlur = 8 + off * 0.4; ctx.shadowOffsetX = off * 0.8; ctx.shadowOffsetY = off * 1.2; + ctx.globalAlpha = lib.clamp((t - tf) / 0.04); ctx.drawImage(s.canvas, 0, 0); ctx.restore(); +} +function scorePill(ctx, t, tokens, lib) { // the one game read-out: it counts the blob's landings + if (t < T.hud - 0.05) return; + const P = L.P, drop = 1 - lib.spring(t - T.hud + 0.05, { w: 24, zeta: 0.45 }); + const score = T.lands.reduce((s, c) => s + 1000 * lib.clamp((t - c) / 0.15), 0); + const bump = T.lands.reduce((b, c) => b + (t >= c ? 0.25 * Math.exp(-(t - c) / 0.08) : 0), 1); + const w = 400; + ctx.save(); ctx.translate(120, 96 - drop * 170); + ctx.fillStyle = P("fg"); ctx.beginPath(); ctx.roundRect(0, -38, w, 76, 38); ctx.fill(); + const gl = ctx.createLinearGradient(0, -38, 0, 0); gl.addColorStop(0, "rgba(255,255,255,0.35)"); gl.addColorStop(1, "rgba(255,255,255,0.04)"); + ctx.fillStyle = gl; ctx.beginPath(); ctx.roundRect(14, -35, w - 28, 33, 17); ctx.fill(); + lib.setFont(ctx, tokens, "ui", 44); ctx.fillStyle = P("bg"); lib.drawText(ctx, "SCORE", 32, 16, { tracking: 1 }); + ctx.save(); ctx.translate(w - 34, 19); ctx.scale(bump, bump); + lib.setFont(ctx, tokens, "ui", 52); ctx.fillStyle = "#fff"; ctx.textAlign = "right"; ctx.fillText(String(Math.floor(score / 10) * 10).padStart(4, "0"), 0, 0); ctx.restore(); + ctx.restore(); +} +function sparkles(ctx, t, lib, list) { + const P = L.P; + for (const [x, y, r, t0] of list) { + const q = t - t0 + (t0 === 0 ? 0.12 : 0); if (q < 0 || q > 0.42) continue; + const s = Math.sin(Math.PI * lib.clamp(q / 0.42)) * (1 + 0.25 * Math.exp(-q / 0.05)); + ctx.save(); ctx.fillStyle = "#fff"; ctx.strokeStyle = P("fg"); ctx.lineWidth = 3; ctx.lineJoin = "round"; + star4(ctx, x, y, r * s, 0.5 * q); ctx.fill(); ctx.stroke(); ctx.restore(); + } +} +function landBurst(ctx, t, lib) { // small sparkles fly out where the blob lands on a tile + for (let k = 0; k < 3; k++) { + const q = t - T.lands[k]; if (q < 0 || q > 0.45) continue; + const u = lib.ease.outCubic(q / 0.45); + for (let i = 0; i < 6; i++) { + const a = -Math.PI / 2 + (i - 2.5) * 0.55, d = 90 + 150 * u, cx = TILE.xs[k] + Math.cos(a) * d * 1.3, cy = TOP - 20 + Math.sin(a) * d; + ctx.save(); ctx.fillStyle = i % 2 ? L.P("cyan") : "#fff"; ctx.strokeStyle = L.P("fg"); ctx.lineWidth = 2.5; + star4(ctx, cx, cy, 26 * (1 - u) + 4, u * 2); ctx.fill(); ctx.stroke(); ctx.restore(); + } + } +} + +// ───────────────────────── the tube ───────────────────────── +// a squeegee sweeping right → left; behind it (on its right), the end frame +const TILT = 0.5; // radians from vertical +const SLANT = Math.tan(TILT), HALF = (hw) => hw / Math.cos(TILT); +function tubeGeom(t, lib) { + const u = lib.tween(t, ...T.wipe, lib.bezier(0.37, 0, 0.63, 1)); + const X = lib.lerp(2320, -482, u); // its upper corner breaks in on the downbeat; out at the end + return { X, n: [-Math.cos(TILT), Math.sin(TILT)], hw: 165 }; // n points ahead (to the left) +} +const tubeFront = (g, y) => g.X + SLANT * (y - 540) - HALF(g.hw); // its leading (left) edge at height y +const tubeBack = (g, y) => g.X + SLANT * (y - 540) + HALF(g.hw); +function halfPlane(x, X, n, back) { // the side behind (back) or ahead of the tube's centre line + const c = [X, 540], far = 4000, tx = -n[1], ty = n[0], s = back ? -1 : 1; + x.beginPath(); + x.moveTo(c[0] + tx * far, c[1] + ty * far); x.lineTo(c[0] - tx * far, c[1] - ty * far); + x.lineTo(c[0] - tx * far + s * n[0] * far, c[1] - ty * far + s * n[1] * far); x.lineTo(c[0] + tx * far + s * n[0] * far, c[1] + ty * far + s * n[1] * far); + x.closePath(); +} + +// ───────────────────────── scenes ───────────────────────── +function acidField(ctx, lib) { // the floor: acid yellow, lifted in the middle like a studio sweep + const P = L.P, g = ctx.createRadialGradient(960, 470, 80, 960, 520, 1180); + g.addColorStop(0, lib.mixColor(P("bg"), "#ffffff", 0.45)); g.addColorStop(0.5, P("bg")); g.addColorStop(1, P("bgEdge")); + ctx.fillStyle = g; ctx.fillRect(0, 0, lib.W, lib.H); +} +function mainScene(ctx, t, tokens, lib) { + const P = L.P; + acidField(ctx, lib); + const ru = lib.seg(t, T.slam, 0.36); // the shock ring of the slam + if (ru < 1) { ctx.save(); ctx.strokeStyle = "#fff"; ctx.globalAlpha = 1 - ru; ctx.lineWidth = 46 * (1 - ru) + 2; + ctx.beginPath(); ctx.ellipse(BLOB.x, BLOB.y + 150, 430 + 1100 * lib.ease.outCubic(ru), 150 + 420 * lib.ease.outCubic(ru), 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } + const su = lib.seg(t, T.slam, 0.2); // fall streaks above the landing + if (su < 1) { ctx.save(); ctx.strokeStyle = "#fff"; ctx.lineCap = "round"; ctx.globalAlpha = 1 - su; + [[-300, 26, 0.7], [-120, 36, 1], [80, 30, 0.85], [280, 22, 0.6]].forEach(([dx, w, l]) => { ctx.lineWidth = w * (1 - su); + ctx.beginPath(); ctx.moveTo(BLOB.x + dx, 330 - 330 * l * (1 - 0.6 * su)); ctx.lineTo(BLOB.x + dx, 330 + 40 * su); ctx.stroke(); }); + ctx.restore(); } + // the blob; from 4.0 the tube's leading edge pushes it and swallows it + let b = blobMain(t, lib); + if (t >= T.wipe[0]) { + const xf = tubeFront(tubeGeom(t, lib), b.y), a = lib.clamp((b.x + b.R - xf) / (1.6 * b.R)); + if (a >= 1) b = null; + else if (a > 0) { const R = b.R * Math.pow(1 - a, 0.7); b = { ...b, R, x: Math.min(b.x, xf - 0.55 * R), sx: b.sx * (1 - 0.3 * a), sy: b.sy * (1 + 0.2 * a), y: b.y + (b.R - R) }; } + } + const early = t < HOPS[0][0]; // while it spits, the blob shares the letters' pass (droplets merge) + gelTile(ctx, 0, t, lib, P("cyan")); + gelTile(ctx, 1, t, lib, P("accent")); + tileLabels(ctx, t, tokens, lib); + chromePass(ctx, t, lib, { + rows: [L.rows[0], L.rows[1], L.rows[2], 0.82], + blurObjs: (x) => { drawLetters(x, t, tokens, lib); chromeTileMask(x, t, lib); }, + blob: early ? b : null, + domeObjs: (x, mode) => drops(x, t, lib, mode), + }); + monitor(ctx, t, lib); + badges(ctx, t, tokens, lib); + if (!early && b && b.R > 1) chromePass(ctx, t, lib, { blob: b }); // afterwards it hops over the tiles in its own pass, behind the sticker + dieCut(ctx, t, tokens, lib, { t0: T.slap, x: STK.x, y: STK.y, rot: STK.rot, lay: L.zh, role: "zh", size: 70, ink: P("fg"), wobbles: T.lands }); + for (let k = 0; k < 3; k++) clearStamp(ctx, k, t, tokens, lib); + landBurst(ctx, t, lib); + sparkles(ctx, t, lib, L.sparkles); + scorePill(ctx, t, tokens, lib); +} +function endScene(ctx, t, tokens, lib) { + const P = L.P; + acidField(ctx, lib); + const q = t - T.endPop, gm = tubeGeom(t, lib); + // the blob drops out of the back of the tube at the centre, swells, then bursts into "Y2K" + const e = lib.clamp((END.x + 90 - tubeBack(gm, END.y)) / 160), shrink = lib.seg(t, T.endPop, T.endPop + 0.1); + let b = null; + if (e > 0 && shrink < 1) { + const R = BLOB.Rsmall * lib.ease.outCubic(e) * (1 + 0.25 * lib.seg(t, 4.3, T.endPop)) * (1 - shrink); + const sy = 1 + sqz(t, 4.31, -0.25) + 0.05 * Math.sin(2 * Math.PI * 11 * t) * lib.seg(t, 4.3, T.endPop); + b = { x: END.x + 50 * (1 - e), y: END.y + 40, R, sx: 1 / Math.sqrt(sy), sy, ph: 2.1 * t }; + } + chromePass(ctx, t, lib, { + rows: [END.y + 40, END.y + 40, 132, 0.82], blob: b && b.R > 1 ? b : null, + blurObjs: (x) => { + if (q < 0) return; + const sc = 0.15 + 0.85 * lib.spring(q, { w: 24, zeta: 0.36 }); + lib.setFont(x, tokens, "display", 330); x.textAlign = "center"; x.lineJoin = "round"; + x.fillStyle = "#fff"; x.strokeStyle = "#fff"; x.lineWidth = 26; + x.save(); x.translate(END.x, END.y + 40); x.scale(sc * (1 + 0.3 * (sc - 1)), sc * (1 - 0.3 * (sc - 1))); x.translate(0, 119); x.fillText("Y2K", 0, 0); x.strokeText("Y2K", 0, 0); x.restore(); + }, + }); + const field = [[260, 210, 0], [1640, 180, 1], [420, 860, 2], [1520, 900, 3], [180, 560, 4], [1760, 600, 5], [760, 140, 6], [1180, 960, 7]]; + for (const [x, y, i] of field) { // sparkles twinkling in turn, two per beat + const ph = lib.fract((t - 4.0) / 0.8 + i / 8), a = Math.sin(Math.PI * ph); + ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = ["#fff", P("cyan"), "#fff", P("accent")][i % 4]; ctx.strokeStyle = P("fg"); ctx.lineWidth = 2.5; + star4(ctx, x, y, 12 + 18 * a, 0.3 * i); ctx.fill(); ctx.stroke(); ctx.restore(); + } + dieCut(ctx, t, tokens, lib, { t0: T.endSticker, x: 1130, y: 830, rot: 0.07, lay: L.chromeWord, role: "ui", size: 64, ink: P("accent") }); + sparkles(ctx, t, lib, [[1310, 468, 70, T.glint], [560, 790, 40, T.endPop + 0.2]]); +} + +export function renderAt(t, ctx, tokens, lib) { + if (t < T.wipe[0]) return mainScene(ctx, t, tokens, lib); + const gm = tubeGeom(t, lib), { X, n, hw } = gm; + if (X < -480) return endScene(ctx, t, tokens, lib); + mainScene(ctx, t, tokens, lib); + const B = lib.layer("y2k_end"); endScene(B, t, tokens, lib); + ctx.save(); halfPlane(ctx, X, n, true); ctx.clip(); ctx.drawImage(B.canvas, 0, 0); + const bg = ctx.createLinearGradient(X - n[0] * hw, 540 - n[1] * hw, X - n[0] * (hw + 90), 540 - n[1] * (hw + 90)); // its shadow behind it + bg.addColorStop(0, "rgba(40,45,0,0.4)"); bg.addColorStop(1, "rgba(40,45,0,0)"); ctx.fillStyle = bg; ctx.fillRect(0, 0, lib.W, lib.H); ctx.restore(); + ctx.save(); const sg = ctx.createLinearGradient(X + n[0] * hw, 540 + n[1] * hw, X + n[0] * (hw + 90), 540 + n[1] * (hw + 90)); // and ahead of it + sg.addColorStop(0, "rgba(40,45,0,0.45)"); sg.addColorStop(1, "rgba(40,45,0,0)"); ctx.fillStyle = sg; halfPlane(ctx, X + n[0] * hw * 0.98, n, false); ctx.fill(); ctx.restore(); + chromePass(ctx, t, lib, { + shadow: false, rim: 0.3, key: 0, rotExtra: lib.lerp(0.7, -0.7, lib.seg(t, ...T.wipe)), tube: gm, // a softbox runs across it + domeObjs: (x, mode) => { + if (mode !== "mask") return; + const tx = -n[1], ty = n[0], far = 2000; + x.beginPath(); x.moveTo(X - n[0] * hw + tx * far, 540 - n[1] * hw + ty * far); x.lineTo(X - n[0] * hw - tx * far, 540 - n[1] * hw - ty * far); + x.lineTo(X + n[0] * hw - tx * far, 540 + n[1] * hw - ty * far); x.lineTo(X + n[0] * hw + tx * far, 540 + n[1] * hw + ty * far); x.closePath(); + x.fillStyle = "#fff"; x.fill(); + }, + }); + const ga = 0.5 + 0.5 * Math.sin(t * 40); // a glint riding the tube's crest (centre line: x = X + SLANT·(y − 540)) + ctx.save(); ctx.fillStyle = "#fff"; ctx.strokeStyle = L.P("fg"); ctx.lineWidth = 3; + star4(ctx, X + SLANT * (240 - 540) + 40, 240, 50 + 16 * ga, t * 3); ctx.fill(); ctx.stroke(); ctx.restore(); +} + +// ───────────────────────── foley (→ events.json via styles/_swatch/foley.mjs) ───────────────────────── +const WORD_X = [700, 1300, 640, 1110]; +export const FOLEY = [ + { t: T.slam, sfx: "impact", gain_db: -7, pan: 0 }, + { t: T.slam, sfx: "click", gain_db: -6, pan: 0 }, // a 1–4 kHz attack on the slam (the impact alone is all low end) + { t: T.hop, sfx: "pop", gain_db: -10, pan: 0, pitch: -6 }, + { t: T.hop, sfx: "swoosh_tonal", gain_db: -10, pan: 0, dur: 0.3, dir: "up", bright: 0.6 }, + ...T.words.map((w, i) => ({ t: w, sfx: "pop", gain_db: -11, pan: pan(WORD_X[i]), pitch: i * 2 })), + { t: T.slap, sfx: "paper", gain_db: -8, pan: pan(STK.x), dur: 0.12, bright: 0.5 }, + { t: T.rest, sfx: "pop", gain_db: -9, pan: pan(BLOB.rest[0]), pitch: -8 }, + ...T.tiles.map((w, k) => ({ t: w, sfx: "pop", gain_db: -10, pan: pan(TILE.xs[k]), pitch: -3 + k * 3 })), + // a rubbery squash, higher each hop: it swells from the contact and peaks as the blob is flattest (~60 ms after) + ...T.lands.map((c, k) => ({ t: c + 0.06, sfx: "swoosh_tonal", gain_db: -7, pan: pan(TILE.xs[k]), dur: 0.12, dir: "down", tone: 0.95, center: [360, 450, 560][k] })), + { t: (T.wipe[0] + T.wipe[1]) / 2, sfx: "swoosh_tonal", gain_db: -9, dur: 0.5, bright: 0.7, pan_from: 0.8, pan_to: -0.8 }, + { t: T.endPop, sfx: "pop", gain_db: -8, pan: 0, pitch: -4 }, + { t: T.endSticker, sfx: "paper", gain_db: -8, pan: pan(1130), dur: 0.1, bright: 0.6 }, + { t: T.glint, sfx: "shimmer", gain_db: -14, pan: pan(1310) }, +]; diff --git a/styles/y2k-chrome/tokens.json b/styles/y2k-chrome/tokens.json new file mode 100644 index 0000000..1146a23 --- /dev/null +++ b/styles/y2k-chrome/tokens.json @@ -0,0 +1,145 @@ +{ + "slug": "y2k-chrome", + "name": { + "zh": "Y2K 铬面", + "en": "Y2K Chrome" + }, + "family": "pop", + "palette": { + "bg": "#E4FF1A", + "bgEdge": "#C9EA00", + "fg": "#0E0D16", + "accent": "#FF2BB0", + "cyan": "#00D2FF", + "sticker": "#FCFCF7", + "shadow": "#5E6B00", + "skyTop": "#1838FF", + "skyMid": "#59C8FF", + "skyLow": "#E6FAFF", + "silver": "#C6CCD4", + "ground": "#0D0B16", + "endBg": "#0B0A12" + }, + "fonts": { + "display": { + "family": [ + "Arial Rounded MT Bold", + "Helvetica Neue" + ], + "weight": 400 + }, + "ui": { + "family": [ + "Arial Rounded MT Bold", + "Helvetica Neue" + ], + "weight": 400 + }, + "zh": { + "family": [ + "Yuanti SC", + "PingFang SC" + ], + "weight": 700 + } + }, + "chrome": { + "rule": "inflated liquid chrome shaded by reflecting a procedural studio: deep-blue sky, silver near a hard anti-aliased horizon with a white line on it, a black line under it, dark purple below, hot pink only on the lowest edge, two oval softboxes and a soft black flag; on title rows the horizon runs straight through each line of letters about two thirds down; an ink keyline; the studio swings ±0.15 rad on every beat", + "horizon_elev": -0.12, + "ground": [ + "ground", + "68% endBg + 32% accent", + "accent on the lowest edge only" + ], + "blur_px": { + "coverage": 3, + "bevel": 6, + "dome": 14 + }, + "height_px": { + "dome": 70, + "bevel": 18 + }, + "keyline_threshold": 0.2, + "title_row_weight": 0.82 + }, + "ease": { + "letter": { + "w": 26, + "zeta": 0.38 + }, + "tile": { + "w": 22, + "zeta": 0.4 + }, + "score_pill": { + "w": 24, + "zeta": 0.45 + }, + "blob_landing": "squash -0.32·e^(-q/0.11)·cos(2π·4.2q)", + "sticker_settle": "1 - 0.12·e^(-q/0.1)·cos(2π·5q)", + "wipe": "cubic-bezier(0.37,0,0.63,1)" + }, + "timing": { + "beat_s": 0.4, + "hold_min_s": 0.8, + "new_event_every_s": 0.4 + }, + "texture": { + "type": "none: chrome, gel plastic and vinyl stickers carry the surface; flat acid field with a soft studio falloff", + "amount": 0 + }, + "transitions": [ + "chrome-tube-wipe", + "liquid-burst-into-type", + "sticker-slap" + ], + "camera": "locked off; the impact comes from the objects' own squash and a shock ring", + "type_anim": "a liquid-chrome blob spits droplets that swell into inflated chrome letters with an overshoot; Chinese on a die-cut sticker slapped on at an angle", + "sound": { + "bpm": 150, + "key": "A", + "mode": "major", + "palette": [ + "edm_kick (four on the floor)", + "clap", + "hihat (open on the off-beats)", + "sq_bass (off-beat octave bass)", + "organ (house stab)", + "metal (anvil on the two big hits)", + "pulse (coin blips)", + "glockenspiel (sparkles)", + "strings (bed)" + ], + "sfx": [ + "impact + click (slam)", + "pop (small things)", + "swoosh_tonal shaped as a 0.12 s rubbery squash, rising per landing", + "paper (sticker slaps)", + "shimmer", + "swoosh_tonal (chrome wipe)" + ] + }, + "video_types": [ + "03", + "08", + "04" + ], + "study": [ + { + "work": "FunTech Showreel 2026", + "year": 2026, + "by": "ハシモトタクマ (FunTech)" + }, + { + "work": "Wipeout (cover, manual, in-game type, team identities)", + "year": 1995, + "by": "The Designers Republic for Psygnosis" + }, + { + "work": "Mac OS X Aqua interface", + "year": 2000, + "by": "Apple (unveiled at Macworld Expo San Francisco, 2000-01-05)" + } + ] +}