[teamai] Push 87 resource(s) from XingfenD
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Presentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* extract-narrations.ts — collect every chapter's narration array and emit
|
||||
* a flat segment list that the TTS pipeline can consume.
|
||||
*
|
||||
* Run via:
|
||||
* npm run extract-narrations # writes audio-segments.json
|
||||
* npm run extract-narrations -- --print # also prints to stdout
|
||||
*
|
||||
* Reads chapter order from src/registry/chapters.ts via a simple regex
|
||||
* (no React/CSS evaluation needed). For each chapter it dynamically
|
||||
* imports `src/chapters/<NN>-<id>/narrations.ts` (which is React-free)
|
||||
* and flattens to:
|
||||
*
|
||||
* [
|
||||
* { chapter, step, text, audio: "<chapter>/<step>.mp3" },
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* Step indices in the JSON are 1-indexed, matching the audio file naming
|
||||
* convention (`public/audio/<chapter>/<N>.mp3`).
|
||||
*
|
||||
* Empty narration strings are skipped (silent steps don't need a TTS file).
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const REGISTRY_PATH = resolve(ROOT, "src/registry/chapters.ts");
|
||||
const CHAPTERS_DIR = resolve(ROOT, "src/chapters");
|
||||
const OUT_PATH = resolve(ROOT, "audio-segments.json");
|
||||
|
||||
interface Segment {
|
||||
chapter: string;
|
||||
step: number;
|
||||
text: string;
|
||||
audio: string;
|
||||
}
|
||||
|
||||
/** Parse `src/registry/chapters.ts` to learn chapter id order. */
|
||||
async function readChapterOrder(): Promise<{ id: string; folder: string }[]> {
|
||||
const src = await readFile(REGISTRY_PATH, "utf8");
|
||||
// Match: id: "..." AND from "../chapters/<folder>/narrations"
|
||||
const ids: string[] = [];
|
||||
const folders: Record<string, string> = {};
|
||||
|
||||
for (const m of src.matchAll(/id:\s*["']([^"']+)["']/g)) ids.push(m[1]!);
|
||||
for (const m of src.matchAll(
|
||||
/from\s+["']\.\.\/chapters\/([^"'\/]+)\/narrations["']/g,
|
||||
)) {
|
||||
// We map by import order; pair 1:1 with `ids`. Both orders are the
|
||||
// chapter declaration order in CHAPTERS so they line up.
|
||||
const folder = m[1]!;
|
||||
folders[folder] = folder;
|
||||
}
|
||||
|
||||
// Map id → folder by reading each chapter's narrations.ts existence.
|
||||
// Folders are typically `<NN>-<id>`; fall back to plain `<id>` if not.
|
||||
const result: { id: string; folder: string }[] = [];
|
||||
for (const id of ids) {
|
||||
const candidates = Object.keys(folders).filter((f) => f.endsWith(`-${id}`));
|
||||
const folder = candidates[0] ?? Object.keys(folders).find((f) => f === id);
|
||||
if (!folder) {
|
||||
throw new Error(
|
||||
`chapter id "${id}" registered but no matching folder found ` +
|
||||
`under src/chapters/. Expected something like NN-${id}/narrations.ts`,
|
||||
);
|
||||
}
|
||||
result.push({ id, folder });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadNarrations(folder: string): Promise<unknown[]> {
|
||||
const file = join(CHAPTERS_DIR, folder, "narrations.ts");
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`missing narrations.ts: ${file}`);
|
||||
}
|
||||
const url = pathToFileURL(file).href;
|
||||
const mod = await import(url);
|
||||
if (!Array.isArray(mod.narrations)) {
|
||||
throw new Error(
|
||||
`narrations.ts in ${folder} must export an array named "narrations"`,
|
||||
);
|
||||
}
|
||||
return mod.narrations as unknown[];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const print = process.argv.includes("--print");
|
||||
const order = await readChapterOrder();
|
||||
|
||||
const segments: Segment[] = [];
|
||||
let silentSteps = 0;
|
||||
for (const { id, folder } of order) {
|
||||
const arr = await loadNarrations(folder);
|
||||
arr.forEach((entry, i) => {
|
||||
const step = i + 1;
|
||||
if (typeof entry !== "string") {
|
||||
throw new Error(
|
||||
`chapter "${id}" step ${step}: narration must be a string ` +
|
||||
`(got ${typeof entry}). The {text, minHoldMs} form was removed; ` +
|
||||
`if your animation is longer than the narration, write longer ` +
|
||||
`narration, split the step, or speed the animation up.`,
|
||||
);
|
||||
}
|
||||
if (entry.trim() === "") {
|
||||
// Silent step — no TTS needed; runtime falls back to estimate.
|
||||
silentSteps++;
|
||||
return;
|
||||
}
|
||||
segments.push({
|
||||
chapter: id,
|
||||
step,
|
||||
text: entry,
|
||||
audio: `${id}/${step}.mp3`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(OUT_PATH, JSON.stringify(segments, null, 2) + "\n", "utf8");
|
||||
|
||||
console.error(
|
||||
`✓ extracted ${segments.length} segments from ${order.length} chapters` +
|
||||
(silentSteps > 0 ? ` (skipped ${silentSteps} silent steps)` : ""),
|
||||
);
|
||||
console.error(` → ${OUT_PATH}`);
|
||||
if (print) console.log(JSON.stringify(segments, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`✗ ${err.message ?? err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# synthesize-audio.sh — provider-agnostic TTS runner.
|
||||
#
|
||||
# Reads audio-segments.json (produced by extract-narrations.ts) and
|
||||
# writes one mp3 per segment under public/audio/<chapter>/<N>.mp3.
|
||||
#
|
||||
# This file itself does NOT know how to call any TTS engine. It loads
|
||||
# a provider adapter from tts-providers/<name>.sh which must expose:
|
||||
#
|
||||
# tts_synthesize <text> <out_path> [<voice>] (required)
|
||||
# tts_check (optional)
|
||||
# tts_install_help (optional)
|
||||
#
|
||||
# See tts-providers/README.md for the full contract and copy-pasteable
|
||||
# recipes for adding more providers (OpenAI / ElevenLabs / edge-tts /
|
||||
# Azure / etc.).
|
||||
#
|
||||
# Choosing a provider:
|
||||
# PRESENTATION_TTS=<name> env var (default: minimax)
|
||||
# --provider=<name> CLI flag (overrides env)
|
||||
#
|
||||
# Choosing a voice (provider decides what's valid):
|
||||
# PRESENTATION_TTS_VOICE=<id> env var
|
||||
# --voice=<id> CLI flag (overrides env)
|
||||
#
|
||||
# Other flags:
|
||||
# --force re-synthesize even if mp3 exists
|
||||
#
|
||||
# Behavior:
|
||||
# • Serial calls (TTS APIs commonly rate-limit parallel requests).
|
||||
# • Skips segments whose mp3 already exists — rerun safely after a
|
||||
# partial failure. Pass --force to re-synthesize all.
|
||||
# • Prints progress per segment with elapsed time.
|
||||
#
|
||||
# Examples:
|
||||
# npm run synthesize-audio
|
||||
# npm run synthesize-audio -- --force
|
||||
# PRESENTATION_TTS=openai npm run synthesize-audio
|
||||
# npm run synthesize-audio -- --provider=elevenlabs --voice=Rachel
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SEGMENTS="$ROOT/audio-segments.json"
|
||||
OUT_DIR="$ROOT/public/audio"
|
||||
PROVIDERS_DIR="$SCRIPT_DIR/tts-providers"
|
||||
|
||||
PROVIDER="${PRESENTATION_TTS:-minimax}"
|
||||
VOICE="${PRESENTATION_TTS_VOICE:-}"
|
||||
FORCE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=true ;;
|
||||
--voice=*) VOICE="${arg#--voice=}" ;;
|
||||
--provider=*) PROVIDER="${arg#--provider=}" ;;
|
||||
-h|--help)
|
||||
sed -n '2,46p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "✗ unknown arg: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROVIDER_FILE="$PROVIDERS_DIR/$PROVIDER.sh"
|
||||
|
||||
# ── Pre-flight ────────────────────────────────────────────────────────
|
||||
if [[ ! -f "$SEGMENTS" ]]; then
|
||||
echo "✗ $SEGMENTS not found. Run: npm run extract-narrations" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v jq >/dev/null; then
|
||||
echo "✗ jq is required to read audio-segments.json" >&2
|
||||
echo " Install: brew install jq (or apt-get install jq, etc.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$PROVIDER_FILE" ]]; then
|
||||
echo "✗ TTS provider '$PROVIDER' not found at $PROVIDER_FILE" >&2
|
||||
echo >&2
|
||||
echo " Available providers:" >&2
|
||||
for f in "$PROVIDERS_DIR"/*.sh; do
|
||||
[[ -f "$f" ]] || continue
|
||||
echo " • $(basename "$f" .sh)" >&2
|
||||
done
|
||||
echo >&2
|
||||
echo " To add your own, see $PROVIDERS_DIR/README.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$PROVIDER_FILE"
|
||||
|
||||
if ! declare -F tts_synthesize >/dev/null; then
|
||||
echo "✗ provider '$PROVIDER' does not define tts_synthesize" >&2
|
||||
echo " See $PROVIDERS_DIR/README.md for the contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if declare -F tts_check >/dev/null; then
|
||||
if ! tts_check; then
|
||||
echo >&2
|
||||
if declare -F tts_install_help >/dev/null; then
|
||||
tts_install_help
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Main loop ─────────────────────────────────────────────────────────
|
||||
total=$(jq 'length' "$SEGMENTS")
|
||||
i=0
|
||||
synthesized=0
|
||||
skipped=0
|
||||
failed=0
|
||||
|
||||
while IFS= read -r row; do
|
||||
i=$((i + 1))
|
||||
chapter=$(echo "$row" | jq -r '.chapter')
|
||||
step=$(echo "$row" | jq -r '.step')
|
||||
text=$(echo "$row" | jq -r '.text')
|
||||
out="$OUT_DIR/$chapter/$step.mp3"
|
||||
|
||||
if [[ -f "$out" && "$FORCE" != true ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
printf "[%3d/%d] %-20s skip (exists)\n" "$i" "$total" "$chapter/$step.mp3"
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$out")"
|
||||
start=$(date +%s)
|
||||
if tts_synthesize "$text" "$out" "$VOICE"; then
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
synthesized=$((synthesized + 1))
|
||||
printf "[%3d/%d] %-20s ✓ %ss\n" "$i" "$total" "$chapter/$step.mp3" "$elapsed"
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
printf "[%3d/%d] %-20s ✗ FAILED\n" "$i" "$total" "$chapter/$step.mp3" >&2
|
||||
fi
|
||||
done < <(jq -c '.[]' "$SEGMENTS")
|
||||
|
||||
echo
|
||||
echo "✓ done (provider=$PROVIDER) — synthesized $synthesized, skipped $skipped, failed $failed"
|
||||
[[ $failed -eq 0 ]] || exit 2
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
# TTS Providers
|
||||
|
||||
`synthesize-audio.sh` 是 provider-agnostic 的 runner —— 它自己不知道
|
||||
怎么调任何 TTS,只知道循环 `audio-segments.json`、跳过已存在文件、
|
||||
打印进度。
|
||||
|
||||
**每个 provider 是这个目录下的一个 `.sh` 文件**,定义一个
|
||||
`tts_synthesize` 函数(必需),以及可选的 `tts_check` 和
|
||||
`tts_install_help`。runner 根据 `PRESENTATION_TTS` 环境变量加载对应文件。
|
||||
|
||||
---
|
||||
|
||||
## 怎么用
|
||||
|
||||
```bash
|
||||
# 默认(minimax)
|
||||
npm run synthesize-audio
|
||||
|
||||
# 换 provider
|
||||
PRESENTATION_TTS=openai npm run synthesize-audio
|
||||
npm run synthesize-audio -- --provider=elevenlabs
|
||||
|
||||
# 指定音色(每个 provider 自己解析)
|
||||
PRESENTATION_TTS_VOICE=alloy npm run synthesize-audio
|
||||
npm run synthesize-audio -- --voice=zh-CN-YunxiNeural
|
||||
|
||||
# 强制全部重合成
|
||||
npm run synthesize-audio -- --force
|
||||
```
|
||||
|
||||
`--provider` 和 `--voice` 的命令行参数会覆盖 env var。
|
||||
|
||||
---
|
||||
|
||||
## 内置 provider
|
||||
|
||||
| 文件 | 后端 | 鉴权 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `minimax.sh` | MiniMax `mmx` CLI | `mmx auth login --api-key` | **默认**;中文口播质量稳 |
|
||||
| `openai.sh` | OpenAI Audio Speech API | `OPENAI_API_KEY` env var | curl-based;多数 agent 已有 key |
|
||||
|
||||
只内置这两个 —— 我们不替你做更多技术选型。其它后端的代码片段在下面,
|
||||
复制到 `tts-providers/<name>.sh` 即可启用。
|
||||
|
||||
---
|
||||
|
||||
## 怎么加你自己的 TTS
|
||||
|
||||
1. 在这个目录建 `<name>.sh`(小写、kebab-case)
|
||||
2. 实现 `tts_synthesize text out_path [voice]`(必需)
|
||||
3. 可选实现 `tts_check`(启动前校验环境)和 `tts_install_help`(失败时打印怎么修)
|
||||
4. `PRESENTATION_TTS=<name> npm run synthesize-audio`
|
||||
|
||||
---
|
||||
|
||||
## 三函数契约
|
||||
|
||||
### `tts_synthesize <text> <out_path> [<voice>]` (required)
|
||||
|
||||
把一段文字写成 mp3 / 任意 web 可播的音频文件到 `<out_path>`。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `$1` | 要合成的文本(已是 UTF-8 字符串,可能包含中英文混排和标点) |
|
||||
| `$2` | 目标文件绝对路径(runner 已 `mkdir -p` 它的父目录),扩展名 `.mp3` |
|
||||
| `$3` | 音色 id(可能为空字符串,provider 自行决定默认) |
|
||||
|
||||
成功 → exit 0 并把音频写到 `$2`。失败 → 非零退出(runner 会标 FAILED 继续下一段,不会终止全局合成)。
|
||||
|
||||
> 如果 backend 只能出 wav / ogg,自己在函数末尾用 `ffmpeg` 转一下:
|
||||
> `ffmpeg -y -i tmp.wav -codec:a libmp3lame -qscale:a 2 "$out" >/dev/null 2>&1`
|
||||
|
||||
### `tts_check` (optional)
|
||||
|
||||
启动时被 runner 调一次(不是每段)。检查 CLI 是否装、API key 是否设、auth 是否通。
|
||||
未就绪 return 非零,runner 会立刻终止并打印 `tts_install_help`。
|
||||
|
||||
### `tts_install_help` (optional)
|
||||
|
||||
`tts_check` 失败时被 runner 调,往 stderr 打印怎么装 / 怎么登录 / 在哪拿 key。
|
||||
|
||||
---
|
||||
|
||||
## 常见 TTS 后端的现成片段
|
||||
|
||||
下面**不是**内置 provider —— 是你自己写 `tts-providers/<name>.sh` 时
|
||||
可以**直接抄过去**的代码片段。复制 → 保存为 `<name>.sh` → 调通了
|
||||
就 `PRESENTATION_TTS=<name>` 用。
|
||||
|
||||
> 大多数云 TTS 的 API key 通过环境变量传入(例如 `OPENAI_API_KEY`、
|
||||
> `ELEVENLABS_API_KEY`)。把 `export` 加到你的 shell rc,或在
|
||||
> 同目录放一个 git-ignored 的 `.env` 文件并 `set -a; source .env; set +a`。
|
||||
|
||||
### OpenAI TTS
|
||||
|
||||
**已内置** —— 直接看 [`openai.sh`](./openai.sh)。
|
||||
该文件也是写 HTTP-based provider 的**官方参考实现**:jq 构造 JSON
|
||||
payload、curl `-fsS` 提交、可选 base URL(接 Azure-OpenAI / 代理)、
|
||||
可选 model env var、空音色 fallback 到默认值。新接 REST API 的
|
||||
provider 直接抄它起手最快。
|
||||
|
||||
启用:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
PRESENTATION_TTS=openai npm run synthesize-audio
|
||||
# 用 HD 模型 + 别的音色
|
||||
OPENAI_TTS_MODEL=tts-1-hd npm run synthesize-audio -- --provider=openai --voice=nova
|
||||
```
|
||||
|
||||
### ElevenLabs — `tts-providers/elevenlabs.sh`
|
||||
|
||||
```bash
|
||||
# Docs: https://elevenlabs.io/docs/api-reference/text-to-speech
|
||||
# Env: ELEVENLABS_API_KEY=...
|
||||
# Voice: pass voice ID; "Rachel" default is 21m00Tcm4TlvDq8ikWAM
|
||||
# Model: eleven_multilingual_v2 supports Chinese; eleven_turbo_v2_5 cheaper
|
||||
|
||||
tts_check() {
|
||||
command -v curl >/dev/null || { echo "✗ curl not found" >&2; return 1; }
|
||||
command -v jq >/dev/null || { echo "✗ jq not found" >&2; return 1; }
|
||||
[[ -n "${ELEVENLABS_API_KEY:-}" ]] || { echo "✗ ELEVENLABS_API_KEY not set" >&2; return 1; }
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
Set your ElevenLabs key first:
|
||||
export ELEVENLABS_API_KEY=... # get one at https://elevenlabs.io
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1" out="$2" voice="${3:-21m00Tcm4TlvDq8ikWAM}"
|
||||
local payload
|
||||
payload=$(jq -n --arg t "$text" \
|
||||
'{text:$t, model_id:"eleven_multilingual_v2"}')
|
||||
|
||||
curl -fsS -o "$out" -X POST \
|
||||
"https://api.elevenlabs.io/v1/text-to-speech/$voice" \
|
||||
-H "xi-api-key: $ELEVENLABS_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload"
|
||||
}
|
||||
```
|
||||
|
||||
### edge-tts — `tts-providers/edge-tts.sh`(免费 / 无 API key)
|
||||
|
||||
```bash
|
||||
# Docs: https://github.com/rany2/edge-tts
|
||||
# Install: pip install edge-tts
|
||||
# Voices: edge-tts --list-voices
|
||||
# zh-CN-YunxiNeural (男声)
|
||||
# zh-CN-XiaoxiaoNeural (女声)
|
||||
# en-US-AriaNeural (英文女声)
|
||||
# en-US-GuyNeural (英文男声)
|
||||
|
||||
tts_check() {
|
||||
command -v edge-tts >/dev/null || { echo "✗ edge-tts not found" >&2; return 1; }
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
Install edge-tts (free, uses Microsoft Edge's TTS backend, no API key):
|
||||
pip install edge-tts
|
||||
List available voices:
|
||||
edge-tts --list-voices | less
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1" out="$2" voice="${3:-zh-CN-YunxiNeural}"
|
||||
edge-tts --text "$text" --voice "$voice" --write-media "$out" >/dev/null 2>&1
|
||||
}
|
||||
```
|
||||
|
||||
### macOS `say` — `tts-providers/say.sh`(离线 / 兜底)
|
||||
|
||||
```bash
|
||||
# 系统自带,零依赖,适合 CI 跑通流程 / 离线预览。
|
||||
# 中文音色:Tingting / Sinji / Meijia(看 `say -v ?` 全列表)
|
||||
# 输出是 aiff,要 ffmpeg 转 mp3(Auto 模式 audio 标签默认认 mp3)。
|
||||
|
||||
tts_check() {
|
||||
command -v say >/dev/null || { echo "✗ 'say' not available (macOS only)" >&2; return 1; }
|
||||
command -v ffmpeg >/dev/null || { echo "✗ ffmpeg not found (brew install ffmpeg)" >&2; return 1; }
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
macOS-only provider. Needs ffmpeg for aiff→mp3:
|
||||
brew install ffmpeg
|
||||
List voices: say -v ?
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1" out="$2" voice="${3:-Tingting}"
|
||||
local tmp
|
||||
tmp=$(mktemp -t tts).aiff
|
||||
say -v "$voice" -o "$tmp" "$text" \
|
||||
&& ffmpeg -y -i "$tmp" -codec:a libmp3lame -qscale:a 2 "$out" >/dev/null 2>&1
|
||||
local code=$?
|
||||
rm -f "$tmp"
|
||||
return $code
|
||||
}
|
||||
```
|
||||
|
||||
### Azure Speech — `tts-providers/azure.sh`
|
||||
|
||||
```bash
|
||||
# Docs: https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech
|
||||
# Env: AZURE_SPEECH_KEY=... AZURE_SPEECH_REGION=eastus
|
||||
# SSML payload — Azure requires SSML, not plain JSON
|
||||
|
||||
tts_check() {
|
||||
command -v curl >/dev/null || { echo "✗ curl not found" >&2; return 1; }
|
||||
[[ -n "${AZURE_SPEECH_KEY:-}" ]] || { echo "✗ AZURE_SPEECH_KEY not set" >&2; return 1; }
|
||||
[[ -n "${AZURE_SPEECH_REGION:-}" ]] || { echo "✗ AZURE_SPEECH_REGION not set" >&2; return 1; }
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
Set Azure Speech credentials:
|
||||
export AZURE_SPEECH_KEY=...
|
||||
export AZURE_SPEECH_REGION=eastus # or your resource's region
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1" out="$2" voice="${3:-zh-CN-YunxiNeural}"
|
||||
local lang="${voice%%-*}-${voice#*-}"; lang="${lang%%-*}-${lang#*-}" # "zh-CN"
|
||||
local ssml="<speak version='1.0' xml:lang='$lang'><voice xml:lang='$lang' name='$voice'>$(printf '%s' "$text" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')</voice></speak>"
|
||||
|
||||
curl -fsS -o "$out" -X POST \
|
||||
"https://${AZURE_SPEECH_REGION}.tts.speech.microsoft.com/cognitiveservices/v1" \
|
||||
-H "Ocp-Apim-Subscription-Key: $AZURE_SPEECH_KEY" \
|
||||
-H "Content-Type: application/ssml+xml" \
|
||||
-H "X-Microsoft-OutputFormat: audio-24khz-48kbitrate-mono-mp3" \
|
||||
-H "User-Agent: web-video-presentation" \
|
||||
--data-binary "$ssml"
|
||||
}
|
||||
```
|
||||
|
||||
### Google Cloud TTS — `tts-providers/gcloud.sh`
|
||||
|
||||
```bash
|
||||
# Docs: https://cloud.google.com/text-to-speech/docs/reference/rest
|
||||
# Auth: easiest is `gcloud auth application-default login`
|
||||
# (or set GOOGLE_APPLICATION_CREDENTIALS to a service-account json)
|
||||
# Voices: zh-CN-Wavenet-A / zh-CN-Neural2-A / en-US-Neural2-J etc.
|
||||
|
||||
tts_check() {
|
||||
command -v curl >/dev/null || { echo "✗ curl not found" >&2; return 1; }
|
||||
command -v jq >/dev/null || { echo "✗ jq not found" >&2; return 1; }
|
||||
command -v base64 >/dev/null || { echo "✗ base64 not found" >&2; return 1; }
|
||||
command -v gcloud >/dev/null || { echo "✗ gcloud not found" >&2; return 1; }
|
||||
gcloud auth application-default print-access-token >/dev/null 2>&1 || {
|
||||
echo "✗ gcloud is not authenticated (run: gcloud auth application-default login)" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
Install gcloud SDK and authenticate:
|
||||
https://cloud.google.com/sdk/docs/install
|
||||
gcloud auth application-default login
|
||||
gcloud services enable texttospeech.googleapis.com
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1" out="$2" voice="${3:-zh-CN-Wavenet-A}"
|
||||
local lang="${voice%-*}"; lang="${lang%-*}" # "zh-CN"
|
||||
local token
|
||||
token=$(gcloud auth application-default print-access-token)
|
||||
|
||||
local payload
|
||||
payload=$(jq -n --arg t "$text" --arg v "$voice" --arg l "$lang" \
|
||||
'{input:{text:$t}, voice:{languageCode:$l, name:$v}, audioConfig:{audioEncoding:"MP3"}}')
|
||||
|
||||
curl -fsS -X POST https://texttospeech.googleapis.com/v1/text:synthesize \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
| jq -r '.audioContent' | base64 -d > "$out"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设计要点(自己写 provider 时记住)
|
||||
|
||||
1. **`set -e` 友好**:runner 用 `set -euo pipefail`,所以你的函数里要么明确处理失败,要么让命令自然非零退出。不要吞错误。
|
||||
|
||||
2. **静默成功,喧闹失败**:成功时不打印任何东西到 stdout(runner 自己打进度条);失败时往 stderr 打详细原因。把 CLI 工具的 stdout 重定向到 `/dev/null`,stderr 留着看。
|
||||
|
||||
3. **mp3 输出**:浏览器里 `<audio>` 标签最稳吃 mp3。能直接出 mp3 就出 mp3;非 mp3 后端在函数末尾加一步 ffmpeg。
|
||||
|
||||
4. **音色 fallback**:`$3` 可能是空字符串。给一个合理的默认值(你最常用的中文音色 / 英文音色),不要因为没传音色就报错。
|
||||
|
||||
5. **不要做并发**:runner 是串行的(避免 rate limit)。provider 函数也别在内部 fork 多线程。
|
||||
|
||||
6. **不要修改全局状态**:provider 文件被 `source` 进 runner 的 shell。别 `cd`、别改 `IFS`、别 `set -e/+e` 切换,否则会污染 runner。把局部变量都 `local`。
|
||||
|
||||
⚠️ 一个坑:runner 用 `set -u`,**macOS 默认 bash 3.2 在 `"${arr[@]}"` 展开空数组时会炸 `unbound variable`**。如果你的 provider 需要"可选 --voice 参数",**不要**用 `local args=(); [[ -n $voice ]] && args=(--voice $v); cmd "${args[@]}"` —— 直接写两个 if 分支调命令(看 `minimax.sh` 的写法)。
|
||||
|
||||
7. **API 长度上限**:单段大多数 API 都有上限(OpenAI ~4096 chars / MiniMax ~5000 / ElevenLabs ~5000)。Skill 的 narrations 单段一般 < 200 字符,正常不会撞到。如果你的 narration 撞到了,**先回去拆 step**——一个 step 的口播本来就不该这么长。
|
||||
|
||||
---
|
||||
|
||||
## 调试
|
||||
|
||||
```bash
|
||||
# 看 runner 怎么调你的 provider
|
||||
bash -x scripts/synthesize-audio.sh
|
||||
|
||||
# 跑单段试试,不动 audio-segments.json
|
||||
source scripts/tts-providers/<name>.sh
|
||||
tts_check && tts_synthesize "测试一下" /tmp/test.mp3 ""
|
||||
afplay /tmp/test.mp3 # macOS 播一下听听
|
||||
```
|
||||
|
||||
跑通了再 `npm run synthesize-audio`。
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# MiniMax provider — uses the official mmx-cli.
|
||||
#
|
||||
# Docs: https://platform.minimaxi.com/docs/token-plan/minimax-cli
|
||||
# Repo: https://github.com/MiniMax-AI/cli
|
||||
#
|
||||
# Strengths: Chinese narration quality is consistently good; lots of
|
||||
# voice options; one-line CLI call.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
tts_check() {
|
||||
if ! command -v mmx >/dev/null; then
|
||||
echo "✗ mmx CLI not found in PATH." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! mmx auth status >/dev/null 2>&1; then
|
||||
echo "✗ mmx is not authenticated." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
To use the MiniMax provider:
|
||||
|
||||
Install: npm install -g mmx-cli
|
||||
Login: mmx auth login --api-key sk-xxxxx
|
||||
(get a key at https://platform.minimaxi.com)
|
||||
|
||||
Or pick another provider: PRESENTATION_TTS=<name> npm run synthesize-audio
|
||||
See tts-providers/README.md for the list and how to add your own.
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1"
|
||||
local out="$2"
|
||||
local voice="${3:-}"
|
||||
|
||||
# Branch instead of using an empty array — runner uses `set -u`, and
|
||||
# macOS-default bash 3.2 fires "unbound variable" on "${arr[@]}" when
|
||||
# arr is empty. The two-branch form is portable to old bash.
|
||||
if [[ -n "$voice" ]]; then
|
||||
mmx speech synthesize --voice "$voice" --text "$text" --out "$out" \
|
||||
>/dev/null 2>&1
|
||||
else
|
||||
mmx speech synthesize --text "$text" --out "$out" \
|
||||
>/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# OpenAI TTS provider — uses the Audio Speech REST API via curl.
|
||||
#
|
||||
# Docs: https://platform.openai.com/docs/api-reference/audio/createSpeech
|
||||
# Env: OPENAI_API_KEY=sk-... required
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1 optional (for proxies / Azure-OpenAI)
|
||||
# OPENAI_TTS_MODEL=tts-1 optional (tts-1 = fast, tts-1-hd = higher quality)
|
||||
# Voices: alloy / echo / fable / onyx / nova / shimmer
|
||||
# (default: alloy)
|
||||
#
|
||||
# Strengths: many agents already have OPENAI_API_KEY set; predictable
|
||||
# pricing; very fast.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
tts_check() {
|
||||
if ! command -v curl >/dev/null; then
|
||||
echo "✗ curl not found in PATH." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! command -v jq >/dev/null; then
|
||||
echo "✗ jq is required to build the request payload safely." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "✗ OPENAI_API_KEY is not set." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tts_install_help() {
|
||||
cat <<'EOF' >&2
|
||||
To use the OpenAI provider:
|
||||
|
||||
Set your key: export OPENAI_API_KEY=sk-...
|
||||
(get one at https://platform.openai.com/api-keys)
|
||||
Optional: export OPENAI_BASE_URL=https://your-proxy/v1
|
||||
export OPENAI_TTS_MODEL=tts-1-hd # higher quality, ~2× cost
|
||||
|
||||
Install deps (only if missing):
|
||||
curl — brew install curl / apt-get install curl
|
||||
jq — brew install jq / apt-get install jq
|
||||
|
||||
Or pick another provider: PRESENTATION_TTS=<name> npm run synthesize-audio
|
||||
EOF
|
||||
}
|
||||
|
||||
tts_synthesize() {
|
||||
local text="$1"
|
||||
local out="$2"
|
||||
local voice="${3:-}"
|
||||
[[ -z "$voice" ]] && voice="alloy"
|
||||
|
||||
local base="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
|
||||
local model="${OPENAI_TTS_MODEL:-tts-1}"
|
||||
|
||||
local payload
|
||||
payload=$(jq -n \
|
||||
--arg t "$text" \
|
||||
--arg v "$voice" \
|
||||
--arg m "$model" \
|
||||
'{model:$m, input:$t, voice:$v, response_format:"mp3"}')
|
||||
|
||||
# On HTTP error curl with -f exits non-zero, runner marks FAILED.
|
||||
# We capture stderr so a single segment's API error doesn't spam,
|
||||
# but if every segment fails the user should run with `bash -x`.
|
||||
curl -fsS -o "$out" -X POST "$base/audio/speech" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" 2>/dev/null
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "./styles/fonts.css"; // Google Fonts for built-in themes
|
||||
import "./styles/tokens.css"; // active theme — generated by scaffold (see THEMES.md)
|
||||
import "./styles/base.css";
|
||||
import "./styles/animations.css";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { AutoStartGate } from "./components/AutoStartGate";
|
||||
import { AutoToggle } from "./components/AutoToggle";
|
||||
import { ProgressBar } from "./components/ProgressBar";
|
||||
import { Stage } from "./components/Stage";
|
||||
import { useAudioPlayer } from "./hooks/useAudioPlayer";
|
||||
import { useAutoMode } from "./hooks/useAutoMode";
|
||||
import { useStepper } from "./hooks/useStepper";
|
||||
import { CHAPTERS } from "./registry/chapters";
|
||||
|
||||
/**
|
||||
* Estimate spoken duration of a Chinese narration string. Native pace
|
||||
* ≈ 4 char/s → 250ms per char. Used as Auto-mode fallback ONLY when the
|
||||
* audio file is missing / fails / the narration is empty. When audio plays
|
||||
* normally, this value is unused — auto-advance fires on `audio.ended`.
|
||||
*/
|
||||
function estimateMs(text: string): number {
|
||||
if (!text) return 1500;
|
||||
return Math.max(1500, text.length * 250);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const stepper = useStepper(CHAPTERS);
|
||||
const ch = CHAPTERS[stepper.cursor.chapter]!;
|
||||
const Cmp = ch.Component;
|
||||
const stepText = ch.narrations[stepper.cursor.step] ?? "";
|
||||
|
||||
const { mode, cycleMode, autoStarted, setAutoStarted } = useAutoMode();
|
||||
|
||||
// Audio path follows the convention: /audio/<chapter-id>/<step+1>.mp3
|
||||
// (1-indexed file names match what `extract-narrations.ts` outputs.)
|
||||
// Empty narration → no audio src, Auto mode falls back to estimate.
|
||||
const audioSrc =
|
||||
mode === "manual" || stepText === ""
|
||||
? null
|
||||
: `${import.meta.env.BASE_URL}audio/${ch.id}/${stepper.cursor.step + 1}.mp3`;
|
||||
|
||||
const onAutoAdvance = useCallback(() => stepper.next(), [stepper]);
|
||||
|
||||
useAudioPlayer({
|
||||
src: audioSrc,
|
||||
mode,
|
||||
trailMs: 200,
|
||||
estimateFallbackMs: estimateMs(stepText),
|
||||
onAutoAdvance,
|
||||
autoStarted,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stage onAdvance={stepper.next}>
|
||||
<div key={ch.id} className="scene">
|
||||
<Cmp step={stepper.cursor.step} />
|
||||
</div>
|
||||
</Stage>
|
||||
<ProgressBar
|
||||
chapters={CHAPTERS}
|
||||
cursor={stepper.cursor}
|
||||
onJumpChapter={stepper.jumpToChapter}
|
||||
/>
|
||||
<AutoToggle mode={mode} onCycle={cycleMode} />
|
||||
<AutoStartGate
|
||||
visible={mode === "auto" && !autoStarted}
|
||||
onStart={() => setAutoStarted(true)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* Example chapter — uses ONLY semantic tokens from the active theme.
|
||||
* Replace this file freely; it's just demo content.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ex-scene { color: var(--text); }
|
||||
|
||||
.ex-em { color: var(--accent); }
|
||||
|
||||
/* ─── step 0: cover ─── */
|
||||
.ex-cover-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-7);
|
||||
}
|
||||
.ex-cover-h {
|
||||
font-size: var(--t-display-2);
|
||||
line-height: 1.05;
|
||||
margin: 0;
|
||||
max-width: 24ch;
|
||||
}
|
||||
.ex-cover-foot {
|
||||
margin-top: var(--space-7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ─── step 1: split ─── */
|
||||
.ex-split {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) 1fr;
|
||||
gap: var(--space-9);
|
||||
padding-top: var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.ex-split-num {
|
||||
font-size: var(--t-display-1);
|
||||
}
|
||||
.ex-split-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.ex-split-h {
|
||||
font-size: var(--t-h1);
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.ex-split-p {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--t-body);
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
max-width: 56ch;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── step 2: close pull-quote ─── */
|
||||
.ex-close { padding: 0; }
|
||||
.ex-close-inner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--stage-pad-y) var(--stage-pad-x);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.ex-quote {
|
||||
font-size: var(--t-display-2);
|
||||
line-height: 1.15;
|
||||
max-width: 22ch;
|
||||
}
|
||||
.ex-close-foot {
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { MaskReveal } from "../../components/MaskReveal";
|
||||
import type { ChapterStepProps } from "../../registry/types";
|
||||
import "./Example.css";
|
||||
|
||||
/**
|
||||
* Reference chapter — replace with your own.
|
||||
*
|
||||
* Demonstrates the per-step takeover pattern AND the design-token system:
|
||||
* each step uses SEMANTIC tokens (--text, --accent, --t-*, --space-*) so
|
||||
* you can swap themes without editing the chapter at all.
|
||||
*
|
||||
* See SKILL.md "non-negotiable #3" for why each step is its own layout.
|
||||
*/
|
||||
export default function ExampleChapter({ step }: ChapterStepProps) {
|
||||
/* Step 0 — magazine cover, headline + kicker + click cue */
|
||||
if (step === 0) {
|
||||
return (
|
||||
<div className="ex-scene scene-pad">
|
||||
<header className="masthead">
|
||||
<span className="brand">Your Presentation</span>
|
||||
<span className="issue">Issue · 01 — Replace this</span>
|
||||
</header>
|
||||
<hr className="rule" style={{ marginTop: "var(--space-5)" }} />
|
||||
|
||||
<div className="ex-cover-body">
|
||||
<div className="kicker">Chapter 01 — Example</div>
|
||||
<h1 className="ex-cover-h">
|
||||
<MaskReveal show duration={900}>
|
||||
<span className="serif-cn">这是 </span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={300} duration={900}>
|
||||
<span className="serif-it ex-em">first step</span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={650} duration={900}>
|
||||
<span className="serif-cn">.</span>
|
||||
</MaskReveal>
|
||||
</h1>
|
||||
<div className="ex-cover-foot label-mono">
|
||||
<span className="dot-accent" /> Tap anywhere to advance
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Step 1 — split layout: hero number + body */
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="ex-scene scene-pad">
|
||||
<header className="masthead">
|
||||
<span className="brand">Your Presentation</span>
|
||||
<span className="issue">Issue · 01</span>
|
||||
</header>
|
||||
<hr className="rule" style={{ marginTop: "var(--space-5)" }} />
|
||||
|
||||
<div className="ex-split">
|
||||
<div className="ex-split-num hero-num">02</div>
|
||||
<div className="ex-split-body">
|
||||
<div className="kicker">每一步</div>
|
||||
<h2 className="ex-split-h">
|
||||
<MaskReveal show duration={900}>
|
||||
<span className="serif-cn">独占 </span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={300} duration={900}>
|
||||
<span className="serif-it ex-em">整个屏幕</span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={650} duration={900}>
|
||||
<span className="serif-cn">.</span>
|
||||
</MaskReveal>
|
||||
</h2>
|
||||
<p className="ex-split-p">
|
||||
The current theme controls every visual detail — palette,
|
||||
fonts, hero-number style, rule weight, decoration, motion.
|
||||
The chapter code is theme-agnostic.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Step 2 — pull-quote close */
|
||||
return (
|
||||
<div className="ex-scene scene-pad ex-close">
|
||||
<div className="ex-close-inner">
|
||||
<div className="kicker">Now</div>
|
||||
<div className="pull-quote ex-quote">
|
||||
<MaskReveal show duration={1100}>
|
||||
<span className="serif-cn">Replace this with </span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={400} duration={1100}>
|
||||
<span className="serif-it ex-em">your own </span>
|
||||
</MaskReveal>
|
||||
<MaskReveal show delay={760} duration={1100}>
|
||||
<span className="serif-cn">chapters.</span>
|
||||
</MaskReveal>
|
||||
</div>
|
||||
<div className="ex-close-foot label-mono">
|
||||
See SKILL.md / CHAPTER-CRAFT.md / THEMES.md
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Narration } from "../../registry/types";
|
||||
|
||||
/**
|
||||
* Per-step narration for this chapter.
|
||||
*
|
||||
* Length === number of steps the chapter component renders.
|
||||
* Index i === the spoken text for `step === i` in `Example.tsx`.
|
||||
*
|
||||
* Audio synthesis uses this file directly (see scripts/extract-narrations.ts).
|
||||
* Auto-play mode plays `public/audio/<chapter-id>/<i+1>.mp3` at each step
|
||||
* and advances when the audio ends (+ a tiny trail pad).
|
||||
*
|
||||
* Empty string ("") = no audio for this step (silent transition);
|
||||
* Auto mode falls back to a short estimate so the presentation still
|
||||
* progresses.
|
||||
*
|
||||
* Rule of thumb: visual animation duration MUST be ≤ narration duration.
|
||||
* If your animation needs more time, write longer narration, split the
|
||||
* step, or speed the animation up — there is no "minimum hold" knob.
|
||||
*/
|
||||
export const narrations: Narration[] = [
|
||||
// step 0 — magazine cover
|
||||
"这是示例章节的第一步。把这一行换成你这一步的口播文案。",
|
||||
// step 1 — split layout
|
||||
"第二步。每个数组元素对应章节里 step === N 的那一屏。长度必须严格相等。",
|
||||
// step 2 — pull-quote close
|
||||
"第三步。这个数组就是音频合成 + 自动播放的唯一真相源——再也不会和章节代码漂移。",
|
||||
];
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
.auto-gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
cursor: pointer;
|
||||
animation: auto-gate-fade-in 320ms ease-out;
|
||||
}
|
||||
@keyframes auto-gate-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.auto-gate-card {
|
||||
text-align: center;
|
||||
color: #f4f4f5;
|
||||
padding: 48px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.auto-gate-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(244, 244, 245, 0.55);
|
||||
}
|
||||
.auto-gate-title {
|
||||
font-family: var(--font-display-en, system-ui);
|
||||
font-size: 56px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.auto-gate-sub {
|
||||
font-family: var(--font-body, var(--font-display-en));
|
||||
font-size: 16px;
|
||||
color: rgba(244, 244, 245, 0.65);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.auto-gate-sub kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import "./AutoStartGate.css";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onStart(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen overlay shown ONCE when `?auto=1` is loaded. Browsers block
|
||||
* audio playback until the page receives a user gesture, so we show this
|
||||
* gate and let the user press Space (or click) to release auto playback.
|
||||
*
|
||||
* After the user starts, the gate is hidden for the rest of the session.
|
||||
*/
|
||||
export function AutoStartGate({ visible, onStart }: Props) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div
|
||||
className="auto-gate"
|
||||
data-no-advance
|
||||
onClick={onStart}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="auto-gate-card">
|
||||
<div className="auto-gate-kicker">AUTO PLAYBACK</div>
|
||||
<div className="auto-gate-title">Press SPACE to start</div>
|
||||
<div className="auto-gate-sub">
|
||||
Audio plays per step and advances automatically.
|
||||
<br />
|
||||
Press <kbd>M</kbd> any time to switch modes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.at-hover {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 160px;
|
||||
height: 60px;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.at-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: #f4f4f5;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
padding: 8px 14px;
|
||||
border-radius: 0 0 0 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
transition: opacity 280ms ease, transform 280ms ease;
|
||||
}
|
||||
.at-hover:hover .at-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.at-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #888;
|
||||
display: inline-block;
|
||||
}
|
||||
.at-manual .at-dot {
|
||||
background: #888;
|
||||
}
|
||||
.at-audio .at-dot {
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 8px rgba(250, 204, 21, 0.6);
|
||||
}
|
||||
.at-auto .at-dot {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 8px rgba(74, 222, 128, 0.7);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PlaybackMode } from "../hooks/useAudioPlayer";
|
||||
import "./AutoToggle.css";
|
||||
|
||||
interface Props {
|
||||
mode: PlaybackMode;
|
||||
onCycle(): void;
|
||||
}
|
||||
|
||||
const LABEL: Record<PlaybackMode, string> = {
|
||||
manual: "MANUAL",
|
||||
audio: "AUDIO",
|
||||
auto: "AUTO",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hidden-on-hover playback mode toggle, fixed top-right.
|
||||
* Default opacity 0; hover the corner reveals it. Click cycles the mode.
|
||||
* `data-no-advance` so clicking the button doesn't advance the stage.
|
||||
*/
|
||||
export function AutoToggle({ mode, onCycle }: Props) {
|
||||
return (
|
||||
<div className="at-hover" data-no-advance>
|
||||
<button
|
||||
className={`at-btn at-${mode}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCycle();
|
||||
}}
|
||||
title="切换播放模式(M)"
|
||||
>
|
||||
<span className="at-dot" />
|
||||
<span className="at-label">{LABEL[mode]}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* clip-path text wipe. Pair with `.mask-reveal` and `.mask-reveal.in` from
|
||||
* animations.css. Use for any text that should appear (not fade).
|
||||
*/
|
||||
export function MaskReveal({
|
||||
show,
|
||||
delay = 0,
|
||||
duration,
|
||||
className,
|
||||
children,
|
||||
}: Props) {
|
||||
const cls = ["mask-reveal", show ? "in" : "", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const style: CSSProperties = {
|
||||
display: "inline-block",
|
||||
transitionDelay: show ? `${delay}ms` : "0ms",
|
||||
...(duration ? { transitionDuration: `${duration}ms` } : null),
|
||||
};
|
||||
return (
|
||||
<span className={cls} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
.pb-hover {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.pb {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
/* Adapt to content; if it would exceed the viewport, scroll horizontally
|
||||
instead of squeezing chapter pills / step pips. */
|
||||
max-width: calc(100vw - 32px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--text-faint) transparent;
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition: opacity var(--dur-base) var(--ease-quart),
|
||||
transform var(--dur-base) var(--ease-quart);
|
||||
}
|
||||
.pb::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.pb::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.pb::-webkit-scrollbar-thumb {
|
||||
background: var(--text-faint);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.pb-hover:hover .pb {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.pb-chapter {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-mute);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-quick), border-color var(--dur-quick);
|
||||
}
|
||||
.pb-chapter:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.pb-active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.pb-num {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pb-pips {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.pb-pip {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-quick);
|
||||
}
|
||||
.pb-pip-on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.pb-github {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition: opacity var(--dur-base) var(--ease-quart),
|
||||
transform var(--dur-base) var(--ease-quart),
|
||||
color var(--dur-quick);
|
||||
}
|
||||
.pb-github:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.pb-hover:hover .pb-github {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.pb-github svg {
|
||||
display: block;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ChapterDef } from "../registry/types";
|
||||
import "./ProgressBar.css";
|
||||
|
||||
interface Props {
|
||||
chapters: ChapterDef[];
|
||||
cursor: { chapter: number; step: number };
|
||||
onJumpChapter(idx: number, step?: number): void;
|
||||
/**
|
||||
* Optional GitHub link rendered next to the bar; reveals/hides together
|
||||
* with the chapter list on hover. Pass `null` to hide.
|
||||
*/
|
||||
githubUrl?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_GITHUB_URL =
|
||||
"https://github.com/ConardLi/garden-skills";
|
||||
|
||||
/**
|
||||
* Hidden-on-hover progress bar, fixed to the bottom of the viewport.
|
||||
* Click chapter pill or pip to jump.
|
||||
*
|
||||
* Width is content-adaptive and capped at `100vw - 32px`; if total chapters
|
||||
* (or an active chapter's step pips) overflow, the bar scrolls horizontally
|
||||
* instead of squeezing items. The active chapter is auto-scrolled into view
|
||||
* on chapter change so it's visible the moment hover reveals the bar.
|
||||
*
|
||||
* A GitHub link sits to the right of the viewport, sharing the same hover
|
||||
* trigger so it appears/disappears in sync with the bar.
|
||||
*/
|
||||
export function ProgressBar({
|
||||
chapters,
|
||||
cursor,
|
||||
onJumpChapter,
|
||||
githubUrl = DEFAULT_GITHUB_URL,
|
||||
}: Props) {
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}, [cursor.chapter]);
|
||||
|
||||
return (
|
||||
<div className="pb-hover" data-no-advance>
|
||||
<div className="pb">
|
||||
{chapters.map((c, i) => {
|
||||
const isActive = i === cursor.chapter;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
className={`pb-chapter ${isActive ? "pb-active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onJumpChapter(i, 0);
|
||||
}}
|
||||
>
|
||||
<span className="pb-num">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="pb-title">{c.title}</span>
|
||||
{isActive && (
|
||||
<div className="pb-pips">
|
||||
{Array.from({ length: c.narrations.length }, (_, s) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`pb-pip ${
|
||||
s <= cursor.step ? "pb-pip-on" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onJumpChapter(i, s);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{githubUrl && (
|
||||
<a
|
||||
className="pb-github"
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View source on GitHub"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 .5C5.65.5.5 5.65.5 12.02c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1-.02-1.96-3.2.7-3.87-1.54-3.87-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.04-.71.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.79 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.12 3.04.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.13 0 1.54-.01 2.78-.01 3.16 0 .31.21.68.8.56 4.56-1.52 7.85-5.83 7.85-10.91C23.5 5.65 18.35.5 12 .5Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useStageScale } from "../hooks/useStageScale";
|
||||
|
||||
interface Props {
|
||||
onAdvance(): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 16:9 stage. Click anywhere except interactive children = advance.
|
||||
*
|
||||
* Layout structure (3 nested elements):
|
||||
* .app-shell ← full viewport, flex-centers the fitter
|
||||
* .stage-fitter ← sized to ACTUAL VISIBLE px (1920*scale × 1080*scale)
|
||||
* so the layout system honestly sees what's on screen
|
||||
* and centers it bulletproof on every viewport / DPR.
|
||||
* .stage-frame ← raw 1920×1080 box, scaled from top-left into the fitter.
|
||||
*
|
||||
* Surface colors come from the active theme's CSS custom properties
|
||||
* (var(--shell), var(--surface)) — see themes/<id>/tokens.css.
|
||||
*/
|
||||
export function Stage({ onAdvance, children }: Props) {
|
||||
const scale = useStageScale();
|
||||
const fitterStyle: CSSProperties = {
|
||||
width: 1920 * scale,
|
||||
height: 1080 * scale,
|
||||
};
|
||||
const frameStyle: CSSProperties = {
|
||||
transform: `scale(${scale})`,
|
||||
};
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="stage-fitter" style={fitterStyle}>
|
||||
<div
|
||||
className="stage-frame"
|
||||
style={frameStyle}
|
||||
onClick={(e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest("button, a, input, [data-no-advance]")) return;
|
||||
onAdvance();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type PlaybackMode = "manual" | "audio" | "auto";
|
||||
|
||||
interface Options {
|
||||
/** Audio file path. `null` = no audio for this step (silent). */
|
||||
src: string | null;
|
||||
/** `manual` = no playback. `audio` = play but don't auto-advance.
|
||||
* `auto` = play and auto-advance when finished. */
|
||||
mode: PlaybackMode;
|
||||
/** Small breathing pad (ms) after audio finishes before advancing,
|
||||
* in `auto` mode. Default 200ms. Set to 0 if mp3 already has trailing
|
||||
* silence. */
|
||||
trailMs?: number;
|
||||
/** Fallback duration (ms) for `auto` mode when the audio file is missing
|
||||
* or fails to play. Typically computed from text length. */
|
||||
estimateFallbackMs?: number;
|
||||
/** Called when `auto` mode determines the step is finished. */
|
||||
onAutoAdvance: () => void;
|
||||
/** Has the user started auto playback? (Browsers block autoplay until
|
||||
* the page receives a user gesture; the AutoStartGate flips this.) */
|
||||
autoStarted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step audio playback for the presentation.
|
||||
*
|
||||
* Manages a single hidden `<audio>` element. Switches `src` whenever the
|
||||
* current step changes.
|
||||
*
|
||||
* In `auto` mode:
|
||||
* • Audio file present → advance `trailMs` after the audio's `ended` event.
|
||||
* • Audio file missing / blocked / src = null → advance after
|
||||
* `estimateFallbackMs` (so previews and silent steps still work).
|
||||
*
|
||||
* Audio playback is the sole driver of step duration — there is intentionally
|
||||
* no "minimum hold" knob. If a chapter's visual animation needs more time,
|
||||
* the chapter should write longer narration, split the step, or speed the
|
||||
* animation up. This keeps Auto-mode behavior trivially predictable.
|
||||
*/
|
||||
export function useAudioPlayer({
|
||||
src,
|
||||
mode,
|
||||
trailMs = 200,
|
||||
estimateFallbackMs = 1500,
|
||||
onAutoAdvance,
|
||||
autoStarted,
|
||||
}: Options) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// Latest callback ref so timers don't capture stale closures.
|
||||
const onAdvanceRef = useRef(onAutoAdvance);
|
||||
onAdvanceRef.current = onAutoAdvance;
|
||||
|
||||
useEffect(() => {
|
||||
const prev = audioRef.current;
|
||||
if (prev) {
|
||||
prev.pause();
|
||||
prev.removeAttribute("src");
|
||||
prev.load();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
if (mode === "manual") return;
|
||||
if (mode === "auto" && !autoStarted) return;
|
||||
|
||||
let advanced = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const advanceAfter = (ms: number) => {
|
||||
if (mode !== "auto" || advanced) return;
|
||||
timer = window.setTimeout(() => {
|
||||
if (advanced) return;
|
||||
advanced = true;
|
||||
onAdvanceRef.current();
|
||||
}, Math.max(0, ms));
|
||||
};
|
||||
|
||||
if (src) {
|
||||
const audio = new Audio(src);
|
||||
audioRef.current = audio;
|
||||
audio.preload = "auto";
|
||||
|
||||
audio.addEventListener("ended", () => advanceAfter(trailMs));
|
||||
audio.addEventListener("error", () => {
|
||||
// Audio file missing or undecodable — fall back to estimate.
|
||||
if (mode === "auto") advanceAfter(estimateFallbackMs);
|
||||
});
|
||||
|
||||
audio.play().catch((err) => {
|
||||
// Autoplay blocked (rare, AutoStartGate should prevent this) or
|
||||
// file missing — fall back to estimate in auto mode.
|
||||
console.warn("audio play failed:", err);
|
||||
if (mode === "auto") advanceAfter(estimateFallbackMs);
|
||||
});
|
||||
} else if (mode === "auto") {
|
||||
// No audio for this step (silent / empty narration) — use estimate.
|
||||
advanceAfter(estimateFallbackMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
advanced = true;
|
||||
if (timer != null) clearTimeout(timer);
|
||||
const a = audioRef.current;
|
||||
if (a) {
|
||||
a.pause();
|
||||
a.removeAttribute("src");
|
||||
a.load();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [src, mode, trailMs, estimateFallbackMs, autoStarted]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { PlaybackMode } from "./useAudioPlayer";
|
||||
|
||||
const ORDER: PlaybackMode[] = ["manual", "audio", "auto"];
|
||||
|
||||
function readModeFromURL(): PlaybackMode {
|
||||
if (typeof window === "undefined") return "manual";
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
if (q.get("auto") === "1") return "auto";
|
||||
if (q.get("audio") === "1") return "audio";
|
||||
return "manual";
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback mode state machine + URL sync + keyboard toggle.
|
||||
*
|
||||
* Modes:
|
||||
* • `manual` — silent, you click / arrow-key to advance
|
||||
* • `audio` — audio plays per step, but you still click to advance
|
||||
* • `auto` — audio plays AND advances automatically (full recording mode)
|
||||
*
|
||||
* Initial mode is read from URL: `?auto=1` or `?audio=1`. Press `M` to
|
||||
* cycle: manual → audio → auto → manual. URL stays in sync so reload
|
||||
* preserves the mode.
|
||||
*
|
||||
* `autoStarted` exists separately because browsers require a user gesture
|
||||
* before audio can autoplay — `AutoStartGate` flips it on space-press.
|
||||
*/
|
||||
export function useAutoMode() {
|
||||
const [mode, setModeState] = useState<PlaybackMode>(() => readModeFromURL());
|
||||
const [autoStarted, setAutoStarted] = useState(false);
|
||||
|
||||
const setMode = useCallback((m: PlaybackMode) => {
|
||||
setModeState(m);
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("audio");
|
||||
url.searchParams.delete("auto");
|
||||
if (m === "audio") url.searchParams.set("audio", "1");
|
||||
if (m === "auto") url.searchParams.set("auto", "1");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
if (m !== "auto") setAutoStarted(false);
|
||||
}, []);
|
||||
|
||||
const cycleMode = useCallback(() => {
|
||||
setMode(ORDER[(ORDER.indexOf(mode) + 1) % ORDER.length]!);
|
||||
}, [mode, setMode]);
|
||||
|
||||
// Keyboard: `M` cycles mode. `Space` starts auto if gated.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.key === "m" || e.key === "M") {
|
||||
e.preventDefault();
|
||||
cycleMode();
|
||||
} else if (e.key === " " && mode === "auto" && !autoStarted) {
|
||||
e.preventDefault();
|
||||
setAutoStarted(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [mode, autoStarted, cycleMode]);
|
||||
|
||||
return { mode, setMode, cycleMode, autoStarted, setAutoStarted };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Compute the scale needed to fit a 1920x1080 stage inside the current
|
||||
* viewport, leaving `marginX` / `marginY` of breathing room around it
|
||||
* (so absolutely-positioned UI like the progress bar isn't cropped).
|
||||
*/
|
||||
export function useStageScale(
|
||||
baseW = 1920,
|
||||
baseH = 1080,
|
||||
marginX = 80,
|
||||
marginY = 100,
|
||||
) {
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
const usefulW = Math.max(320, window.innerWidth - marginX * 2);
|
||||
const usefulH = Math.max(180, window.innerHeight - marginY * 2);
|
||||
setScale(Math.min(usefulW / baseW, usefulH / baseH));
|
||||
}
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, [baseW, baseH, marginX, marginY]);
|
||||
|
||||
return scale;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ChapterDef } from "../registry/types";
|
||||
|
||||
/**
|
||||
* Bump this when chapter step counts / structure change so old persisted
|
||||
* cursors don't land mid-removed-step.
|
||||
*/
|
||||
const STORAGE_KEY = "presentation-cursor-v4";
|
||||
|
||||
export type Cursor = { chapter: number; step: number };
|
||||
|
||||
export interface StepperState {
|
||||
cursor: Cursor;
|
||||
totalChapters: number;
|
||||
chapterTotalSteps: number;
|
||||
globalIndex: number;
|
||||
totalGlobal: number;
|
||||
next(): void;
|
||||
prev(): void;
|
||||
jumpToChapter(idx: number, step?: number): void;
|
||||
jumpToGlobal(globalIdx: number): void;
|
||||
}
|
||||
|
||||
const clamp = (n: number, lo: number, hi: number) =>
|
||||
Math.max(lo, Math.min(hi, n));
|
||||
|
||||
/**
|
||||
* Clamp a (possibly stale) cursor to the current chapter list. Persisted
|
||||
* cursors can outlive structural changes — fewer chapters, fewer steps,
|
||||
* a different scaffolded project sharing the same dev-server origin — so
|
||||
* we always re-validate before handing one to React.
|
||||
*/
|
||||
function sanitize(cursor: Cursor, chapters: ChapterDef[]): Cursor {
|
||||
if (chapters.length === 0) return { chapter: 0, step: 0 };
|
||||
const chapter = clamp(cursor.chapter | 0, 0, chapters.length - 1);
|
||||
const stepCount = chapters[chapter]!.narrations.length;
|
||||
const step = clamp(cursor.step | 0, 0, Math.max(0, stepCount - 1));
|
||||
return { chapter, step };
|
||||
}
|
||||
|
||||
export function useStepper(chapters: ChapterDef[]): StepperState {
|
||||
const [cursor, setCursor] = useState<Cursor>(() => {
|
||||
const fallback = { chapter: 0, step: 0 };
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return sanitize(JSON.parse(raw), chapters);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return fallback;
|
||||
});
|
||||
|
||||
// Re-sanitize if the chapter list shape changes after mount (e.g. HMR
|
||||
// updates `chapters.ts`) — keeps a stale persisted cursor from leaking
|
||||
// into a render where it's now out of range.
|
||||
useEffect(() => {
|
||||
setCursor((cur) => {
|
||||
const next = sanitize(cur, chapters);
|
||||
return next.chapter === cur.chapter && next.step === cur.step
|
||||
? cur
|
||||
: next;
|
||||
});
|
||||
}, [chapters]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cursor));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
const offsets = useMemo(() => {
|
||||
const arr: number[] = [];
|
||||
let acc = 0;
|
||||
for (const c of chapters) {
|
||||
arr.push(acc);
|
||||
acc += c.narrations.length;
|
||||
}
|
||||
return arr;
|
||||
}, [chapters]);
|
||||
const totalGlobal = useMemo(
|
||||
() => chapters.reduce((s, c) => s + c.narrations.length, 0),
|
||||
[chapters],
|
||||
);
|
||||
const globalIndex = (offsets[cursor.chapter] ?? 0) + cursor.step;
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCursor((cur) => {
|
||||
const c = chapters[cur.chapter]!;
|
||||
if (cur.step < c.narrations.length - 1)
|
||||
return { ...cur, step: cur.step + 1 };
|
||||
if (cur.chapter < chapters.length - 1)
|
||||
return { chapter: cur.chapter + 1, step: 0 };
|
||||
return cur;
|
||||
});
|
||||
}, [chapters]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCursor((cur) => {
|
||||
if (cur.step > 0) return { ...cur, step: cur.step - 1 };
|
||||
if (cur.chapter > 0) {
|
||||
const p = chapters[cur.chapter - 1]!;
|
||||
return { chapter: cur.chapter - 1, step: p.narrations.length - 1 };
|
||||
}
|
||||
return cur;
|
||||
});
|
||||
}, [chapters]);
|
||||
|
||||
const jumpToChapter = useCallback(
|
||||
(idx: number, step = 0) => {
|
||||
const ch = clamp(idx, 0, chapters.length - 1);
|
||||
const c = chapters[ch]!;
|
||||
setCursor({
|
||||
chapter: ch,
|
||||
step: clamp(step, 0, c.narrations.length - 1),
|
||||
});
|
||||
},
|
||||
[chapters],
|
||||
);
|
||||
|
||||
const jumpToGlobal = useCallback(
|
||||
(g: number) => {
|
||||
const target = clamp(g, 0, totalGlobal - 1);
|
||||
let acc = 0;
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const t = chapters[i]!.narrations.length;
|
||||
if (target < acc + t) {
|
||||
setCursor({ chapter: i, step: target - acc });
|
||||
return;
|
||||
}
|
||||
acc += t;
|
||||
}
|
||||
},
|
||||
[chapters, totalGlobal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.key === "ArrowRight" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
next();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
prev();
|
||||
} else if (e.key === "Home") {
|
||||
jumpToChapter(0, 0);
|
||||
} else if (e.key === "End") {
|
||||
const last = chapters.length - 1;
|
||||
jumpToChapter(last, chapters[last]!.narrations.length - 1);
|
||||
} else if (e.key >= "1" && e.key <= "9") {
|
||||
const n = Number(e.key) - 1;
|
||||
if (n < chapters.length) jumpToChapter(n, 0);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [next, prev, jumpToChapter, chapters]);
|
||||
|
||||
const ch = chapters[cursor.chapter]!;
|
||||
return {
|
||||
cursor,
|
||||
totalChapters: chapters.length,
|
||||
chapterTotalSteps: ch.narrations.length,
|
||||
globalIndex,
|
||||
totalGlobal,
|
||||
next,
|
||||
prev,
|
||||
jumpToChapter,
|
||||
jumpToGlobal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ChapterDef } from "./types";
|
||||
import ExampleChapter from "../chapters/01-example/Example";
|
||||
import { narrations as exampleNarrations } from "../chapters/01-example/narrations";
|
||||
|
||||
/**
|
||||
* Order = order of presentation.
|
||||
*
|
||||
* Each chapter MUST provide a `narrations: Narration[]` array. Its length
|
||||
* is the chapter's step count — there is no `totalSteps` to maintain
|
||||
* separately. This guarantees the audio synthesis pipeline, the runtime
|
||||
* stepper, and the chapter `.tsx` switch on `step` cannot drift apart.
|
||||
*
|
||||
* Visual styling (color, fonts) comes entirely from the active theme —
|
||||
* chapters never hard-code palette / font names. See THEMES.md.
|
||||
*/
|
||||
export const CHAPTERS: ChapterDef[] = [
|
||||
{
|
||||
id: "example",
|
||||
title: "示例章节",
|
||||
narrations: exampleNarrations,
|
||||
Component: ExampleChapter,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface ChapterStepProps {
|
||||
step: number; // 0..(narrations.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* One narration entry — the spoken text for that step.
|
||||
*
|
||||
* Empty string ("") means "no audio for this step" (e.g. silent transition
|
||||
* shot). Auto mode falls back to a short estimate when audio is missing or
|
||||
* the text is empty.
|
||||
*/
|
||||
export type Narration = string;
|
||||
|
||||
export interface ChapterDef {
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* Per-step narration text. **Length === total steps in this chapter.**
|
||||
* This is the single source of truth for step count and audio synthesis.
|
||||
*/
|
||||
narrations: Narration[];
|
||||
Component: ComponentType<ChapterStepProps>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* animations.css — generic, theme-agnostic motion vocabulary.
|
||||
*
|
||||
* All durations / easings come from the active theme's tokens.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* MaskReveal — clip-path text wipe (left → right) */
|
||||
.mask-reveal {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 700ms var(--ease-quart);
|
||||
}
|
||||
.mask-reveal.in { clip-path: inset(0 0 0 0); }
|
||||
|
||||
/* Hairline rule grow — scaleX from 0 → 1, transform-origin: left */
|
||||
.rule-grow {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform var(--dur-cinematic) var(--ease-expo);
|
||||
}
|
||||
.rule-grow.in { transform: scaleX(1); }
|
||||
|
||||
/* Generic vertical rise + opacity (small UI labels, captions) */
|
||||
@keyframes rise-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scale-in with overshoot (use for stamp / badge / accent moment) */
|
||||
@keyframes scale-in {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
60% { opacity: 1; transform: scale(1.04); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Pop with overshoot ease (more spring) */
|
||||
@keyframes pop-in {
|
||||
0% { opacity: 0; transform: scale(0.6) rotate(-2deg); }
|
||||
100% { opacity: 1; transform: scale(1) rotate(0); }
|
||||
}
|
||||
|
||||
/* Caret blink (bounded — for use INSIDE a sized animation) */
|
||||
@keyframes caret-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Slow-pulse halo (rings, notifications) */
|
||||
@keyframes pulse-halo {
|
||||
0% { opacity: 0.6; transform: scale(0.9); }
|
||||
70% { opacity: 0; transform: scale(1.6); }
|
||||
100% { opacity: 0; transform: scale(1.6); }
|
||||
}
|
||||
|
||||
/* Letter stagger (use with .letter-stagger > .letter, set --i: 0..n) */
|
||||
.letter-stagger > .letter {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transform: translateY(40px) scale(0.85);
|
||||
animation: letter-rise var(--dur-slow) var(--ease-expo) forwards;
|
||||
animation-delay: calc(var(--i, 0) * 60ms);
|
||||
}
|
||||
@keyframes letter-rise {
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* base.css — design system for the web-video-presentation skill.
|
||||
*
|
||||
* Ownership split:
|
||||
* • Themes (themes/<id>/tokens.css) own COLOR, FONTS, and the
|
||||
* "personality knobs" that make themes recognizable
|
||||
* (--r-card / --rule-* / --hero-num-* / --card-shadow / decorative
|
||||
* pattern + vignette).
|
||||
* • Chapters use COLOR + FONT tokens (mandatory) and the personality
|
||||
* primitive classes (.hero-num / .rule / .card / .stage-frame).
|
||||
* Everything else — font sizes, spacing, motion timings, easings,
|
||||
* border widths, generic radii — chapters write freely (token OR
|
||||
* hard-coded value, whatever the content needs).
|
||||
*
|
||||
* The variables defined below are kept ONLY for the primitive classes
|
||||
* in this file (.label-mono / .kicker / .corner-mark / .click-cue /
|
||||
* .scene-pad / .badge-mono / .pull-quote / .card / .masthead). They are
|
||||
* NOT a contract chapters must consume.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ─── 0. Reset ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; padding: 0; height: 100%; overflow: hidden; }
|
||||
img, svg, video { display: block; max-width: 100%; }
|
||||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; }
|
||||
|
||||
/* ─── 1. Body defaults (theme palette + features applied here) ─── */
|
||||
body {
|
||||
font-family: var(--font-display-cn);
|
||||
color: var(--text);
|
||||
background: var(--shell);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
text-wrap: pretty;
|
||||
/* OpenType feature settings (themes can override the value) */
|
||||
font-feature-settings: var(--font-features, "tnum", "ss01");
|
||||
letter-spacing: var(--track-snug, -0.005em);
|
||||
cursor: default;
|
||||
}
|
||||
::selection { background: var(--accent); color: var(--surface); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 2. Design tokens — defaults for things themes RARELY need to change.
|
||||
* A theme can override any of these in its tokens.css.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* ── Type scale (used by primitive classes below; chapters can use
|
||||
these OR write hard-coded sizes — whatever the content needs) ── */
|
||||
--t-display-1: clamp(140px, 11vw, 200px);
|
||||
--t-display-2: clamp(80px, 7vw, 128px);
|
||||
--t-h1: clamp(56px, 5vw, 88px);
|
||||
--t-h2: clamp(40px, 3.6vw, 60px);
|
||||
--t-body: 20px;
|
||||
--t-micro: 12px;
|
||||
|
||||
/* ── Tracking presets (used by primitive classes) ── */
|
||||
--track-tight: -0.025em; /* large serif display */
|
||||
--track-snug: -0.005em; /* body */
|
||||
--track-caps: 0.22em; /* mono uppercase labels */
|
||||
|
||||
/* ── Spacing scale (used by primitive classes) ── */
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-7: 48px;
|
||||
--space-9: 96px;
|
||||
|
||||
/* ── Stage padding (theme can dial density) ── */
|
||||
--stage-pad-x: 96px;
|
||||
--stage-pad-y: 80px;
|
||||
|
||||
/* ── Radius scale (themes reference these via --r-card; chapters write
|
||||
their own radii freely) ── */
|
||||
--r-flat: 0;
|
||||
--r-xs: 4px;
|
||||
--r-sm: 8px;
|
||||
--r-md: 16px;
|
||||
--r-xl: 32px;
|
||||
--r-pill: 999px;
|
||||
/* "default card radius" — theme sets identity (sharp / soft / keynote) */
|
||||
--r-card: var(--r-md);
|
||||
|
||||
/* ── Border weights (used by primitive classes) ── */
|
||||
--bw-1: 1px;
|
||||
--bw-2: 2px;
|
||||
--bw-4: 4px;
|
||||
/* "default rule" — theme sets identity (hairline / thick / dashed) */
|
||||
--rule-w: 1px;
|
||||
--rule-style: solid;
|
||||
|
||||
/* ── Motion (used by primitive class transitions; chapters time their
|
||||
own motion freely, ideally guided by theme.json `mood`) ── */
|
||||
--ease-quart: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
--ease-expo: cubic-bezier(0.86, 0, 0.07, 1);
|
||||
--ease-soft: cubic-bezier(0.4, 0, 0.1, 1);
|
||||
--ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--dur-quick: 280ms;
|
||||
--dur-base: 600ms;
|
||||
--dur-slow: 900ms;
|
||||
--dur-cinematic: 1400ms;
|
||||
|
||||
/* ── Hero number style (themes redefine for terminal / chunky / chalk) ── */
|
||||
--hero-num-font: var(--font-display-en);
|
||||
--hero-num-style: italic;
|
||||
--hero-num-weight: 400;
|
||||
--hero-num-track: var(--track-tight);
|
||||
|
||||
/* ── Stage shadow (themes override for paper / glow / flat) ── */
|
||||
--shadow-stage:
|
||||
0 60px 160px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 3. Stage chrome — letterbox + 16:9 fitter + raw 1920×1080 frame.
|
||||
* The fitter is sized to ACTUAL visible px so flex-centering is exact.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
.app-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--shell);
|
||||
}
|
||||
.stage-fitter {
|
||||
position: relative;
|
||||
/* width / height are set inline by Stage.tsx (1920*scale × 1080*scale) */
|
||||
isolation: isolate;
|
||||
}
|
||||
.stage-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
transform-origin: top left;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-stage);
|
||||
border: var(--stage-border, none);
|
||||
/* radius on the LIVE stage gives editorial / keynote softness;
|
||||
theme can disable with --r-stage: 0 */
|
||||
border-radius: var(--r-stage, 0);
|
||||
}
|
||||
|
||||
/* OPTIONAL pattern layer drawn ON the stage so screen recordings catch it */
|
||||
.stage-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: var(--surface-pattern, none);
|
||||
background-size: var(--surface-pattern-size, auto);
|
||||
mix-blend-mode: var(--surface-pattern-blend, normal);
|
||||
opacity: var(--surface-pattern-opacity, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
/* OPTIONAL radial vignette overlay (chalkboard / cinematic edges) */
|
||||
.stage-frame::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: var(--surface-vignette, none);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* every chapter root */
|
||||
.scene { position: absolute; inset: 0; z-index: 2; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 4. Type primitives — chapters compose with these instead of hard sizes.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
.serif-cn {
|
||||
font-family: var(--font-display-cn);
|
||||
font-weight: 700;
|
||||
font-style: normal; /* prevent inherited italic on CJK */
|
||||
letter-spacing: var(--track-tight);
|
||||
text-shadow: var(--text-shadow, none);
|
||||
}
|
||||
.serif-it {
|
||||
font-family: var(--font-display-en);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
letter-spacing: var(--track-tight);
|
||||
text-shadow: var(--text-shadow, none);
|
||||
}
|
||||
.display-en {
|
||||
font-family: var(--font-display-en);
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
letter-spacing: var(--track-tight);
|
||||
text-shadow: var(--text-shadow, none);
|
||||
}
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* small uppercase mono label — the workhorse caption / kicker */
|
||||
.label-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* hero figure / hero number — theme controls font, style, weight */
|
||||
.hero-num {
|
||||
font-family: var(--hero-num-font);
|
||||
font-style: var(--hero-num-style);
|
||||
font-weight: var(--hero-num-weight);
|
||||
letter-spacing: var(--hero-num-track);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 0.9;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 5. Layout primitives.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
.scene-pad {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: var(--stage-pad-y) var(--stage-pad-x);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stack { display: flex; flex-direction: column; }
|
||||
.row { display: flex; flex-direction: row; }
|
||||
.fill { flex: 1; }
|
||||
.center { display: grid; place-items: center; }
|
||||
|
||||
/* hairline divider — theme controls weight + style (solid / dashed) */
|
||||
.rule {
|
||||
height: 0;
|
||||
border: 0;
|
||||
border-top: var(--rule-w) var(--rule-style) var(--rule);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.rule-accent {
|
||||
height: 0;
|
||||
border: 0;
|
||||
border-top: var(--bw-2) solid var(--accent);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 6. Component primitives (reusable visual building blocks).
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* status dot — theme can swap symbol via --accent-mark-content */
|
||||
.dot-accent {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent-glow);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* corner registration mark (top-left) — film leader / magazine folio */
|
||||
.corner-mark {
|
||||
position: absolute;
|
||||
top: var(--space-7);
|
||||
left: var(--space-9);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
pointer-events: none;
|
||||
}
|
||||
.corner-mark .ord {
|
||||
font-family: var(--font-display-en);
|
||||
font-style: italic;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.corner-mark .slash { opacity: 0.3; font-size: 16px; }
|
||||
|
||||
/* bottom-right next-step hint — fades on first step */
|
||||
.click-cue {
|
||||
position: absolute;
|
||||
right: var(--space-9);
|
||||
bottom: var(--space-7);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-slow) var(--ease-quart);
|
||||
pointer-events: none;
|
||||
}
|
||||
.click-cue.visible { opacity: 0.6; }
|
||||
.click-cue .arrow {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display-en);
|
||||
font-style: italic;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
animation: nudge 2.4s var(--ease-soft) infinite;
|
||||
}
|
||||
@keyframes nudge {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(6px); }
|
||||
}
|
||||
|
||||
/* mono badge / pill */
|
||||
.badge-mono {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 4px 10px;
|
||||
border: var(--bw-1) solid var(--rule);
|
||||
border-radius: var(--r-pill);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.badge-mono.is-accent { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* pull-quote — left-bordered editorial block */
|
||||
.pull-quote {
|
||||
border-left: var(--bw-4) solid var(--accent);
|
||||
padding: var(--space-3) 0 var(--space-3) var(--space-5);
|
||||
font-family: var(--font-display-cn);
|
||||
font-weight: 700;
|
||||
font-size: var(--t-h2);
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* card surfaces — theme decides if they're paper-lifted, glass, or flat */
|
||||
.card {
|
||||
background: var(--surface-2);
|
||||
border: var(--bw-1) solid var(--rule);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
.card-glass {
|
||||
background: var(--card-glass-bg, rgba(255, 255, 255, 0.06));
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: var(--bw-1) solid var(--card-glass-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--r-card);
|
||||
}
|
||||
|
||||
/* table-row hairline */
|
||||
.tr-rule {
|
||||
border-top: var(--rule-w) var(--rule-style) var(--rule);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 7. Magazine-masthead helpers (used heavily by editorial themes).
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
}
|
||||
.masthead .brand {
|
||||
font-family: var(--font-display-en);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.masthead .issue {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* 8. State helpers.
|
||||
*
|
||||
* `.no-advance` / `[data-no-advance]` — interactive elements inside a
|
||||
* chapter mark themselves with this so a click on them does NOT advance
|
||||
* the global step. The marker is consumed by Stage.tsx; no styles needed.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
.fade-in { animation: fade-in var(--dur-base) var(--ease-quart) both; }
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────
|
||||
* fonts.css — Google Fonts referenced by the built-in themes.
|
||||
*
|
||||
* Loaded via @import (not <link>) for portability across Vite versions.
|
||||
* If you author a new theme that uses a different family, append it
|
||||
* here and to the THEMES.md "fonts loaded by default" list.
|
||||
* ───────────────────────────────────────────────────────────────────── */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,600&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,wght@0,400;0,500;0,700;1,400;1,500&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;700;900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap");
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
fs: { allow: [".."] },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user