commit 66b61002f221ab19b9b100f01fd7ab38f2895cb7 Author: MohZhuang <1917703871@qq.com> Date: Mon Sep 21 12:34:21 2026 +0800 chore: prepare html-explainer v1.2.2 for public release First public release of the html-explainer skill: deterministic HTML-to-MP4 explainer video pipeline for AI coding agents. Additions since v1.2.1: - scripts/lint_frames.py: pre-render audit of the eight frame-contract rules - scripts/peek_frame.mjs: single-scene screenshot preview (seconds, not minutes) - scripts/check_integrity.py: version/catalog/count consistency + stray-CDN scan - Bilingual README (README.md Chinese default, README.en.md English) - LICENSE (MIT), THIRD_PARTY_NOTICES.md, licenses/Apache-2.0.txt - CHANGELOG.md, CONTRIBUTING.md, .gitignore, .gitattributes - GitHub CI workflow and issue/PR templates Fixes and docs: - document lint_frames.py and peek_frame.mjs, previously absent from the file table - add the lint stage to the pipeline order - remove references to private client projects so the skill can ship publicly - record the full upstream attribution chain for the style catalog - package_skill.py now excludes repo-hygiene files and keeps licence files diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..55e1a68 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Normalize line endings on commit; check out with LF everywhere. +# This matters because setup_env.sh is a bash script and the .mjs/.py +# toolchain is run from Git Bash / WSL / macOS as often as from PowerShell. +* text=auto eol=lf + +# Explicit for scripts people execute directly +*.sh text eol=lf +*.py text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.py text eol=lf +*.json text eol=lf +*.md text eol=lf +*.css text eol=lf +*.html text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Windows-only helpers keep CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary assets: never touch +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.zip binary +*.mp3 binary +*.mp4 binary +*.ttf binary +*.wasm binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c71dd6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,83 @@ +name: Bug report +description: Something rendered wrong, failed silently, or crashed. +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. This pipeline fails **silently** by design — a broken + frame usually still produces a plausible-looking video. The more of the detail + below you can fill in, the faster this gets diagnosed. + + - type: dropdown + id: stage + attributes: + label: Which stage failed? + options: + - "0 · new_project (scaffold)" + - "1 · tts_build (voiceover)" + - "2 · timeline_build (global timeline)" + - "3 · subs (subtitles / beats)" + - "4 · lint_frames (static audit)" + - "5 · render_video (rendering)" + - "6 · qc_check (audit)" + - "7 · cover_build (covers)" + - "setup_env / environment" + - "Not sure" + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Include the exact command you ran and the full output. + placeholder: | + Command: node scripts/render_video.mjs . --preview 30 + Expected: 30s preview with animated cards + Actual: preview renders, but the number counter never moves + validations: + required: true + + - type: textarea + id: symptom-class + attributes: + label: Did it fail loudly or silently? + description: > + Silent failures are the interesting ones here. If playback works but the output + is wrong (frozen element, desynced subtitle, missing text), say so explicitly. + validations: + required: false + + - type: textarea + id: environment + attributes: + label: Environment + description: "Run these and paste the result." + value: | + - OS: + - `node -v`: + - `python -V`: + - Shell (Git Bash / PowerShell / zsh): + - Browser used for rendering (Chrome / Edge / playwright chromium): + - ffmpeg source (PATH / imageio-ffmpeg): + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: The `version:` field in SKILL.md, or the release tag. + placeholder: "1.2.2" + validations: + required: true + + - type: textarea + id: qc-output + attributes: + label: QC output (if you got as far as rendering) + description: Paste `out/qc_report.md`, or attach the contact sheet. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ff9b01d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Read the frame contract first + url: https://github.com/OneMoh/html-explainer/blob/main/references/frame-contract.md + about: Most "my frame renders but looks wrong" reports are explained by the eight contract rules. + - name: Check the lessons ledger + url: https://github.com/OneMoh/html-explainer/blob/main/references/lessons.md + about: Known silent-failure modes and their diagnoses. Worth a search before filing. + - name: Setup and environment issues + url: https://github.com/OneMoh/html-explainer/blob/main/README.md#疑难排查 + about: Run `bash setup_env.sh` and compare against the troubleshooting table (English version: README.en.md#troubleshooting). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e40b784 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,51 @@ +name: Feature request +description: Propose a new capability, style, or workflow improvement. +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + This project deliberately keeps a small surface: HTML frames in, MP4 out, with + audio-video sync that actually holds. Proposals that add a build system, a plugin + API, or a cloud dependency are usually out of scope — but say so if you think + there's an important exception. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the situation you're in, not the API you want. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed approach + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Area + options: + - Visual styles / templates + - Narration / TTS + - Subtitles / beat sync + - Rendering + - QC / auditing + - Covers + - Docs + - Other + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Including "just do it manually" — it's a valid answer. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dc8bfdb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## What this changes + + + +## Why + + + +## How it was verified + + + +## Checklist + +- [ ] `python scripts/lint_frames.py --project ` reports no FAILs for frames I touched +- [ ] I actually rendered the change and looked at the output +- [ ] No external fonts, CDN links, or hardcoded colour literals introduced +- [ ] New lesson (if any) appended to `references/lessons.md` with the next number +- [ ] `python scripts/check_integrity.py` passes +- [ ] `README.md` and `README.en.md` updated together if user-facing behaviour changed +- [ ] `THIRD_PARTY_NOTICES.md` updated if third-party content was added or re-derived + +## Related issues + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a6ea80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +# Only the latest run per branch matters; cancel superseded ones. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: ${{ matrix.os }} / py${{ matrix.python }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Windows + Git Bash is the primary development platform, so it is + # tested alongside Linux rather than treated as an afterthought. + os: [ubuntu-latest, windows-latest] + python: ["3.9", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + # --- Repo self-consistency: version drift, catalog drift, stray CDN links --- + - name: Repository integrity check + run: python scripts/check_integrity.py + + # --- Python scripts must at least compile on the oldest supported version --- + - name: Compile Python scripts + run: python -m compileall -q scripts + + # --- Frame audit tool must be importable and its CLI must respond --- + - name: Lint tool smoke test + run: python scripts/lint_frames.py --help + + - name: Integrity checker smoke test + run: python scripts/check_integrity.py --root . + + # --- Shell entrypoint syntax --- + - name: Check setup_env.sh syntax + shell: bash + run: bash -n setup_env.sh + + # --- Renderer / cover builder / peek tool syntax --- + - name: Check Node module syntax + shell: bash + run: | + set -e + for f in scripts/*.mjs; do + echo "checking $f" + node --check "$f" + done + + - name: Check templates are self-contained + shell: bash + run: | + set -e + # Frames must never reach out to a CDN or web font: offline rendering + # degrades silently rather than failing. + # Scope to HTML only — markdown docs legitimately *mention* these URLs + # (e.g. the guide explaining that you must delete them), and GSAP's own + # bundled README documents its CDN option. + hits=$(grep -rInE --include='*.html' \ + 'https?://[^"'"'"' ]*(fonts\.googleapis|fonts\.gstatic|cdn\.)' . \ + || true) + if [ -n "$hits" ]; then + echo "$hits" + echo "::error::external font or CDN reference found in an HTML template" + exit 1 + fi + echo "no external font or CDN references in HTML templates" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff0f499 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# ---------- Python ---------- +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ +venv/ + +# ---------- Node ---------- +# playwright-core is a dependency, not vendored source. +# Install it with: bash setup_env.sh --install +node_modules/ + +# ---------- Distribution bundles ---------- +dist/ + +# ---------- Per-project runtime artifacts ---------- +# html-explainer generates these inside a *video project* directory. +# Ignored here so a demo project created inside the repo never gets committed. +out/ +audio/ +render/ +research/ +script/ +qc_frames/ +qc_report.md +qc_sheet.jpg +*.beats.js +*.mp4 +*.mp3 +*.wav +*.srt +*.vtt +*.log + +# ---------- OS / editor ---------- +.DS_Store +Thumbs.db +desktop.ini +.vscode/ +.idea/ +*.swp + +# ---------- Local-only scratch ---------- +.skilldev/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea56952 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,144 @@ +# Changelog + +All notable changes to `html-explainer` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Versions before `1.2.2` were private, internal releases. `1.2.2` is the first public +release on GitHub. + +--- + +## [1.2.2] — 2026-09-21 — first public release + +### Added + +- **`scripts/lint_frames.py`** — static pre-render audit of every scene frame. Checks the + eight frame-contract rules plus known pitfalls before spending time on a full render: + external fonts / CDN links, hardcoded colour literals, missing `window.__tl` + registration, CDN GSAP, wall-clock logic (`setInterval`, `requestAnimationFrame` + counters), CSS `transition` entrances, `B('…') || number` fallbacks, content intruding + into the subtitle safe zone, and missing CJK font families. +- **`scripts/peek_frame.mjs`** — single-frame viewer. Seeks one scene to a few time points + and screenshots them in seconds, so frame design can be judged without rendering the + whole video. `--at` takes percentages, not frame numbers. +- **Bilingual project README** (`README.md` in Chinese, `README.en.md` in English). +- **Open-source governance files**: `LICENSE` (MIT), `THIRD_PARTY_NOTICES.md`, + `CHANGELOG.md`, `CONTRIBUTING.md`, `licenses/Apache-2.0.txt`, `.gitignore`, + `.gitattributes`, GitHub issue/PR templates, and a CI workflow. + +### Fixed + +- **`speech_end_sec` disagreed between a cold run and a cached re-run** (lessons #31). + `_result()` measured the already-trimmed output file, so the head-trim amount was + subtracted twice: a cold run reported `7.541s`, a cached re-run reported `7.696s` — + a 0.155s discrepancy that broke the "two runs are frame-identical" guarantee and made + the final subtitle block disappear early. Now stored in post-trim coordinates. + +### Changed + +- Documentation: `scripts/lint_frames.py` and `scripts/peek_frame.mjs` added to the + command sequence and the file reference table (both previously undocumented). +- Documentation: the lint stage is now an explicit step in the workflow, between subtitle + generation and rendering. +- Documentation: removed references to private client projects so the skill can ship + publicly. +- Documented licensing: the style catalog is derived from Apache-2.0 content and now + carries full upstream attribution. + +### Notes + +- Backfilled from field work: preview-render frame deletion (`--keep-frames`), the + temporary-project technique for re-rendering a single scene, and the cold-open rule + that a video's first 0.5s must already show its subject (lessons #32–#34). + +--- + +## [1.2.1] — 2026-09-20 + +Four rendering / QC correctness fixes, all found by instrumenting the renderer rather +than by inspection. See `references/lessons.md` #27–#30. + +### Fixed + +- **`tl.pause(t)` silently suppressed seek callbacks.** GSAP's signature is + `pause(atTime, suppressEvents)` and the second parameter defaults to `true`, so any + `onUpdate` / `onStart` / `onComplete` fired by a seek was dropped. A counter animated + via `onUpdate` therefore sat frozen at its initial value in the output — no error, no + warning, correct thumbnail size, and undetectable by static checks. The renderer and + cover builder now call `tl.pause(t, false)`. The docs additionally steer number + animations toward a pure-transform "digit strip" that depends on no callback at all. +- **`deviceScaleFactor` passed to `chromium.launch()` was silently ignored.** Both + `viewport` and `deviceScaleFactor` are context-level options and only take effect on + `newPage()`. Covers were being written at 1× while the log claimed 2×. The build now + verifies output dimensions with PIL instead of trusting the log. +- **QC sampling only covered the first quarter of the video.** `qc_check.py` sampled + subtitle-block start times but sliced `pts[:samples]`, taking the first N blocks rather + than a spread. A 39-block video sampled 12 blocks covering only 0.25–23.86s, leaving six + late scenes completely unchecked while reporting "all passed". Sampling now steps evenly + across the full range. +- **Cover layout was being measured mid-animation.** Verification ran straight after page + load, so it read `fromTo` start values instead of the settled layout and reported + phantom offsets. Measurement now sets `__MG_RENDER__` and seeks to `tl.duration()` + first, matching the cover builder exactly. + +--- + +## [1.2.0] — 2026-09-20 + +### Added + +- **Dual-cover system.** Every finished video now produces two independently laid-out + covers instead of one: `cover_169.png` (1920×1080, 3840×2160 output) for the feed and + player, and `cover_34.png` (1440×1080, 2880×2160 output) for profile grids. + - `scripts/cover_build.mjs` — cover renderer reusing the scene renderer's deterministic + seek core, with three deliberate differences: no subtitle layer, a single seek to one + "cover moment" (`--at last` by default) rather than per-frame stepping, and 2× output + by default. + - `assets/cover-template.html` — cover template with the three required elements + documented in its header. + - `references/cover-guide.md` — re-layout comparison table, sizing rules, upload + strategy, and a self-check list. +- `scripts/new_project.py` now scaffolds both cover HTML files into a new project. + +### Notes + +- The two covers are **sibling layouts sharing one visual DNA, never a crop pair**. + Cropping 3:4 out of 16:9 discards 57.8% of the frame width (1920 → 810px), which + guarantees a headline hook gets cut. The re-layout changes the composition itself: + the paradox visual moves from side-by-side on the right to a vertical stack on top, + and the hook goes from two lines to three. + +--- + +## [1.1.0] — 2026-09-20 — first packaged release + +### Added + +- Core pipeline: `new_project.py` → `tts_build.py` → `timeline_build.py` → `subs.py` → + `render_video.mjs` → `qc_check.py`. +- **Deterministic seek renderer** (`render_video.mjs`). Instead of screen-recording a + live page, every frame is produced by seeking the GSAP timeline (`tl.pause(t, false)`) + and synchronising CSS animations (`document.getAnimations().currentTime = t*1000`) + before screenshotting. This eliminates the entire class of live-recording failures: + no playback-start race, no font-load timeout, no unstable lead-in. +- **`B('block text')` beat sync.** Frame animations are positioned by matching subtitle + block text rather than hardcoded frame numbers, so editing narration re-times the whole + video without touching a single frame file. +- `scripts/subs.py` — three subtitle outputs (runtime JSON, SRT/VTT sidecars, and the + in-frame layer injected by the renderer) plus generated `beats.js` per scene. +- `scripts/make_theme.py` — four presets plus topic-word colour derivation, writing a + single `theme.css` source of truth for all colours. +- `scripts/qc_check.py` — stream, duration, loudness, and sampled-frame auditing with a + contact sheet. +- `scripts/import_styles.py` — one-time porting tool that transcribes template design + specifications into `references/style-catalog.md` / `.json`. +- 23-style visual catalog across 8 categories, classified by adaptation cost. +- `setup_env.sh` for first-time environment checks and `--install` for fetching missing + dependencies; `package_skill.py` for portable zips. + +[1.2.2]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.2 +[1.2.1]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.1 +[1.2.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.0 +[1.1.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4898970 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,140 @@ +# Contributing to html-explainer + +Thanks for taking the time to contribute. This document covers the practical things: +how to get a working environment, what the project's hard rules are, and where different +kinds of contributions actually belong. + +## Ways to contribute + +| Kind of contribution | Where it goes | +|---|---| +| Bug report | [Open an issue](https://github.com/OneMoh/html-explainer/issues/new/choose) using the bug template | +| Rendering / sync bug you diagnosed | Add a numbered entry to `references/lessons.md` **and** fix the code | +| New visual style or a better adaptation recipe | `references/style-catalog.md` notes + a documented example | +| Documentation fix | Direct PR — typo fixes need no prior discussion | +| New feature | Open an issue first so the approach can be agreed before you write it | +| Platform-specific fix (macOS / Linux paths, shell variants) | Very welcome — the project is developed on Windows + Git Bash | + +## Development setup + +```bash +git clone https://github.com/OneMoh/html-explainer.git +cd html-explainer + +# Check the environment, then install anything missing +bash setup_env.sh # report only +bash setup_env.sh --install # fetch missing dependencies + +# Confirm the toolchain is healthy +node -v && python -V +``` + +Requirements: Python ≥ 3.9, Node ≥ 18, and either Chrome or Edge (both are almost always +already present). `ffmpeg` is used from `PATH` or falls back to the static binary bundled +with `imageio-ffmpeg`. + +### Running an end-to-end check + +```bash +python scripts/new_project.py demo --topic "人工智能" +cd demo +# fill narration.json, write frames/*.html, set project.json order +python ../scripts/tts_build.py --project . +python ../scripts/timeline_build.py --project . +python ../scripts/subs.py --project . +python ../scripts/lint_frames.py --project . +node ../scripts/render_video.mjs . --preview 30 +``` + +If all of that completes and `out/preview.mp4` plays with synchronised audio, your +environment is good. + +## The project's hard rules + +These are not style preferences. Breaking them produces silent failures — a video that +renders but is wrong. They are enforced by `scripts/lint_frames.py` where possible. + +1. **No external fonts or CDN resources in any frame.** Web fonts are unreachable from + many networks and time out silently. Use system font stacks only, and reference GSAP + from the bundled `../assets/gsap.min.js`. +2. **Colours come from `theme.css` CSS variables only.** No hex literals in frame code. + This is what makes a theme change a one-file operation. +3. **No wall-clock logic.** `setInterval` and `requestAnimationFrame` counters cannot + work under deterministic seeking. Animate with GSAP or CSS `@keyframes`. +4. **Animate with GSAP timelines, registered on `window.__tl`.** The renderer seeks this + timeline once per frame; without it, nothing animates. +5. **Position animations with `B('block text')`, never hardcoded times.** The fallback + form `B('x') || 3.2` is forbidden — a missing block should fail loudly at build time, + not silently reuse a stale number. +6. **Keep the subtitle safe zone clear.** Nothing may render in the bottom 80–170px, and + entrance paths must not cross it. +7. **Use ASCII paths for all project directories and output files.** Non-ASCII paths break + ffmpeg on Windows. Project and output names are ASCII; rename for presentation later. + +The full contract, with the reasoning behind each rule and the counter-examples that get +a frame rejected, is in [`references/frame-contract.md`](references/frame-contract.md). + +## Adding a new lesson + +`references/lessons.md` is the most valuable file in this repository. It exists because +this kind of pipeline fails *silently* — a bug produces a plausible-looking video rather +than an error. + +A good entry contains, in this order: + +1. **The symptom**, as you first observed it — including how it misled you. +2. **The diagnosis**, and the evidence that proved it (a probe, a diff, a measurement). +3. **The fix**, and what to check afterwards to confirm it. +4. **The general rule** this implies, if it implies one. + +Number entries sequentially and append. Do not renumber existing entries — they are +referenced from code comments. + +## Adding a new visual style + +1. Record the specification in `references/style-catalog.md` (canvas, type scale, timeline + structure, colour discipline) and `references/style-catalog.json`. +2. Classify it as `rich` (single file, CSS `@keyframes` timeline — drivable by the seek + renderer with no changes) or `gsap` (multi-composition — must be re-expressed, not + copied). +3. Add an adaptation recipe to `references/template-guide.md` if the style needs anything + beyond the standard three steps (swap the font stack, lift bottom elements out of the + subtitle zone, fill in real content). +4. Verify it renders: `node scripts/peek_frame.mjs --at 25,50,85`. + +Style identifiers are `kebab-case` and match the pattern already in use (`frame-*`, +`vfx-*`). + +## Pull request checklist + +Before opening a PR, please confirm: + +- [ ] `python scripts/lint_frames.py --project ` reports no FAILs for any frame + you touched +- [ ] You have actually rendered your change. Frame code that "looks right" frequently + is not — seek-based rendering has its own failure modes +- [ ] Any new lesson is added to `references/lessons.md` with a number +- [ ] No external fonts, CDN references, or hardcoded colours were introduced +- [ ] If you redistributed or re-derived third-party content, `THIRD_PARTY_NOTICES.md` + is updated +- [ ] `README.md` and `README.en.md` are updated together if user-facing behaviour + changed + +## Commit messages + +Conventional Commits are preferred (`fix:`, `feat:`, `docs:`, `chore:`). A body is +encouraged when the change fixes a silent failure — explain what was misbehaving and how +you verified the fix. + +## Scope note + +This project intentionally does **not** aim to be a framework. It is a pipeline with a +small, boring surface: HTML frames in, MP4 out, with audio-video sync that holds. Features +that add a build system, a plugin API, or a runtime dependency on a cloud service are +generally out of scope — say hello in an issue if you think there's an important exception. + +## License + +By contributing, you agree that your contributions are licensed under the MIT License +(see [LICENSE](LICENSE)), and you confirm you have the right to submit them. If you are +porting content from another project, add it to `THIRD_PARTY_NOTICES.md` in the same PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3439ca0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 Moh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This MIT license covers the original source code and documentation authored +for html-explainer. It does NOT cover third-party components or the derived +style specification text, which remain under their own terms. See +THIRD_PARTY_NOTICES.md for the complete list. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..0c784d0 --- /dev/null +++ b/README.en.md @@ -0,0 +1,552 @@ +
+ +# html-explainer + +**Turn a topic into a narrated, subtitled, fully-covered explainer video — locally, from HTML, with audio-video sync that actually holds.** + +A deterministic HTML → MP4 pipeline for AI coding agents. +No Remotion. No cloud render queue. No per-render fees. No API keys for the core path. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md) +[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](https://python.org) +[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#requirements) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +[简体中文](README.md) · [English](README.en.md) + +
+ +--- + +## What it is + +`html-explainer` produces explainer and knowledge videos — the kind with a voiceover, hard +subtitles, a progress bar, and motion-graphics scenes built in HTML/CSS/GSAP. + +You write a narration script. An agent (or you) picks a visual style from a library of 23, +writes one self-contained HTML file per beat, and this pipeline renders it to a real MP4 +with frame-accurate subtitle timing and two platform-ready covers. + +Everything runs on your machine. The only network calls are the TTS request and, optionally, +web research for the script. + +It is packaged as an **Agent Skill** (`SKILL.md` + `scripts/` + `references/`), so it drops +into Claude Code, WorkBuddy, Cursor, or any agent that can read a skill file — but the +scripts themselves are plain Python and Node, and run perfectly well by hand. + +--- + +## The core idea: deterministic seeking instead of screen recording + +Most "HTML to video" tools launch a browser, play the animation, and screen-record the +result. That approach is the source of an entire family of bugs that are maddening because +they are *intermittent*: the animation starts before recording does, a web font resolves late +so half the video uses the wrong typeface, the first few frames capture a pre-animation +state. + +`html-explainer` does not record. It **seeks**. + +For every single frame it: + +1. sets `window.__MG_RENDER__` so the frame knows not to autoplay, +2. seeks the GSAP timeline to that exact timestamp with `tl.pause(t, false)`, +3. synchronises every CSS animation with `document.getAnimations().currentTime = t * 1000`, +4. waits two animation frames, then screenshots. + +Frame 1,204 of a render is bit-identical to frame 1,204 of the next render. That property is +what makes the rest of the system possible — QC can audit specific frames, a single scene can +be re-rendered in isolation, and a whole class of timing bugs simply cannot occur. + +> The second parameter is not optional. `pause(atTime, suppressEvents)` defaults +> `suppressEvents` to `true`, which silently drops every callback fired by the seek — a +> counter animated via `onUpdate` will sit frozen at its initial value with no error. This +> cost real debugging time; see [`references/lessons.md`](references/lessons.md) #27. + +--- + +## Features + +| | | +|---|---| +| **Narration-locked beats** | Scenes position their animation by *matching subtitle text* (`B('block text')`), never by hardcoded frame number. Rewrite the script and the whole video re-times itself — **not one line of frame code changes.** | +| **Word-boundary subtitles** | Timing comes from edge-tts `WordBoundary` events, not character-count interpolation. In Chinese, two same-length phrases can differ 3× in duration, so interpolation drifts badly. | +| **Two-level clock** | Frame length uses the MP3 *container* duration (keeps audio and video aligned); the final subtitle block retires on the true *speech* end. Mixing the two produces subtitles that linger ~0.8s per scene. | +| **23 visual styles** | Eight categories — bold signal cards, luxury minimal, NYT-grade data charts, Swiss grid, glitch art, film light-leak, fluid hero, brand outro, and more. Each with canvas, type scale, timeline, and colour discipline documented. | +| **Dual covers** | Every finished video also ships a 16:9 feed cover and an independently laid-out 3:4 grid cover. Not a crop — a crop loses 57.8% of the frame width. | +| **Pre-render linting** | `lint_frames.py` audits all eight frame-contract rules before you spend 15 minutes rendering. | +| **Adaptive QC** | Loudness, duration drift, stream integrity, and a sampled contact sheet — with frame-size thresholds derived from the video's own median, so dark themes don't produce false positives. | +| **Offline by construction** | GSAP is vendored. Chrome or Edge is auto-detected. ffmpeg falls back to the static binary that ships with `imageio-ffmpeg`. Web fonts are banned by contract. | +| **Single source of truth for colour** | Every colour lives in `theme.css` as a CSS variable. Change the theme and the entire video re-skins with zero frame edits. | + +--- + +## How it compares + +| | `html-explainer` | Screen-recording HTML tools | Remotion | Cloud video APIs | +|---|---|---|---|---| +| **Render method** | Deterministic seek | Live capture | React → frames | Server-side render | +| **Reproducible frames** | Yes | No | Yes | N/A | +| **Per-render cost** | Zero | Zero | Zero | Metered | +| **Runtime dependency** | Node + Python + a browser | Same | Node + React toolchain | Network + account | +| **Authoring model** | HTML/CSS/GSAP per scene | HTML per scene | React components | JSON / template | +| **Re-timing after a script edit** | Automatic (`B()` beats) | Manual | Manual | Manual | +| **Works offline end-to-end** | Yes | Yes | Yes | No | +| **Built-in style library** | 23 documented styles | Varies | Community templates | Vendor templates | +| **Cover/thumbnail output** | Two verified layouts | No | No | Sometimes | + +The honest trade-off: because rendering is seek-based, **every animation must be seekable**. +Wall-clock animation (`setInterval`, `requestAnimationFrame` counters, CSS `transition` +entrances) cannot work and is rejected by the linter. That constraint is what buys +determinism, and it is enforced rather than merely advised. + +--- + +## Pipeline + +```mermaid +flowchart LR + A["Topic"] --> B["Research
facts with sources"] + B --> C["narration.json
text, | splits subtitle blocks"] + C --> D["tts_build.py
edge-tts → MP3 + word boundaries"] + D --> E["timeline_build.py
global axis + stitched track"] + E --> F["subs.py
subs.json · srt/vtt · beats.js"] + F --> G["frames/*.html
one scene per beat, style from library"] + G --> H{"lint_frames.py
8 contract rules"} + H -->|fail| G + H -->|pass| I["render_video.mjs
seek → PNG per frame"] + I --> J["ffmpeg
H.264 + AAC mux"] + J --> K[("out/slug.mp4")] + I --> L["qc_check.py
loudness · drift · contact sheet"] + K --> M["cover_build.mjs
cover_169.png · cover_34.png"] + + style K fill:#1f6feb,color:#fff + style H fill:#8957e5,color:#fff + style M fill:#238636,color:#fff +``` + +The order matters. `beats.js` is generated from the TTS output, which is why changing the +script re-times everything downstream without touching scene code. + +--- + +## Requirements + +| Requirement | Minimum | Notes | +|---|---|---| +| Python | 3.9+ | `edge-tts==7.2.8` (pinned deliberately — v7 changed the boundary API), `numpy`, `pillow`, `imageio-ffmpeg` | +| Node.js | 18+ | Runs the renderer and cover builder | +| Browser | Chrome or Edge | Auto-detected. Neither present → playwright chromium (~115MB, downloaded once) | +| ffmpeg | any | Found on `PATH`, otherwise the static binary bundled with `imageio-ffmpeg` is used | +| Disk | ~2 GB per finished video | Frame PNGs are large; they can be discarded after muxing | + +No administrator rights are needed anywhere. Everything installs to user scope. + +--- + +## Quick start + +```bash +git clone https://github.com/OneMoh/html-explainer.git +cd html-explainer + +# 1 · Check the environment (reports what's missing) +bash setup_env.sh + +# 2 · Install anything missing +bash setup_env.sh --install +``` + +Then run the pipeline inside a project directory: + +```bash +PY= # expand to an absolute path if you hand work to an agent +SKILL=$(pwd) # this repo + +# Stage 0 — scaffold a project +"$PY" "$SKILL/scripts/new_project.py" ~/videos/ai-intro ai-intro --topic "人工智能" +cd ~/videos/ai-intro + +# …write narration.json, fill project.json's order, +# and author one frames/.html per scene… + +# Stages 1–3 — voiceover, timeline, subtitles +"$PY" "$SKILL/scripts/tts_build.py" --project . +"$PY" "$SKILL/scripts/timeline_build.py" --project . +"$PY" "$SKILL/scripts/subs.py" --project . + +# Stage 4 — audit frames before spending render time +"$PY" "$SKILL/scripts/lint_frames.py" --project . + +# Stage 5 — render (--preview N gives a fast draft of the first N seconds) +node "$SKILL/scripts/render_video.mjs" . --preview 30 + +# Stage 6 — QC +"$PY" "$SKILL/scripts/qc_check.py" --project . + +# Stage 7 — full render + covers +node "$SKILL/scripts/render_video.mjs" . +node "$SKILL/scripts/cover_build.mjs" . +``` + +Output lands in `out/`: `slug.mp4`, `cover_169.png`, `cover_34.png`, `slug.srt`, `slug.vtt`, +plus `qc_report.md` and `qc_sheet.jpg`. + +> **Always pass `--keep-frames` if you intend to reuse the rendered PNGs.** Preview mode +> deletes the frame directory after muxing, by design. See +> [`references/lessons.md`](references/lessons.md) #32. + +--- + +## Command reference + +### Pipeline + +| Command | Does | +|---|---| +| `python scripts/new_project.py [--topic T] [--preset P] [--fps 30] [--width 1920] [--height 1080] [--lang zh]` | Scaffolds a project: config, theme, templates, and both cover HTML files | +| `python scripts/tts_build.py --project . [--voice V] [--rate +8%] [--no-trim] [--no-cache]` | Synthesises narration; caches, timeouts, retries, trims silence; writes both duration clocks | +| `python scripts/timeline_build.py --project . [--gap SEC]` | Builds the global timeline and stitches `narration-full.mp3` | +| `python scripts/subs.py --project . [--fps N]` | Emits `subs.json`, `srt`/`vtt` sidecars, and one `beats.js` per scene | +| `python scripts/lint_frames.py --project . [--only id,id] [--include-covers]` | Static audit of every frame against the eight contract rules | +| `node scripts/render_video.mjs [--out PATH] [--preview SEC] [--fps N] [--concurrency N] [--jpeg] [--scale N] [--keep-frames] [--browser PATH]` | Renders and muxes to MP4 | +| `python scripts/qc_check.py --project . [--samples N]` | Loudness / duration / stream audit plus a contact sheet | +| `node scripts/cover_build.mjs [--at last\|SEC] [--only 169\|34] [--scale N] [--jpg] [--keep-frames]` | Renders both covers at 2× by default | + +### Utilities + +| Command | Does | +|---|---| +| `node scripts/peek_frame.mjs [--at 40,80]` | Screenshots one scene at percentage points — seconds, not minutes. `--all` for every scene | +| `python scripts/make_theme.py --topic "医疗" --use` | Derives a palette from a topic word, or `--preset violet\|cyan\|amber\|mono` | +| `python scripts/check_integrity.py` | Version / catalog / count consistency and stray-CDN scan (also runs in CI) | +| `python setup_env.sh --install` → `bash setup_env.sh --install` | Environment check and dependency install | +| `python scripts/package_skill.py [--with-deps]` | Builds a portable zip (omit `--with-deps` for ~110KB) | + +--- + +## The `B()` beat system + +This is the part that changes how the work feels. + +A generated `beats.js` per scene exposes every subtitle block by its text: + +```js +B('右边卡片') // the second the phrase "右边卡片" starts being spoken +Be('证据') // the second that phrase finishes +``` + +So scene animation reads like this: + +```js +var tl = gsap.timeline({ paused: true }); +window.__tl = tl; + +tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片')); +tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2); +``` + +Reword the narration and you re-run three commands. The beats shift, every scene re-times +itself, and no frame file is edited. Compare that with frame-number hardcoding, where one +changed sentence means re-aligning the entire video by hand. + +**The fallback form is banned.** `B('x') || 3.2` hides a broken beat lookup behind a stale +number. If a block can't be found the build should fail loudly — a mis-timed video is worse +than a failed build. + +--- + +## Visual style library + +**Do not design scenes from a blank page.** 23 styles across 8 categories, each documented +with its canvas, type scale, timeline structure, and colour discipline +([`references/style-catalog.md`](references/style-catalog.md)). + +| Category | Styles | +|---|---| +| Presentation / title cards | Bold Poster, Bold Signal, Build Minimal, Creative Voltage, Electric Studio, Glitch Title, Kinetic Type, Swiss Grid, Warm Grain | +| Data visualisation | NYT Data Chart, Data Rollup, NYT Graph, Swiss Grid Data | +| Diagrams / process | Takram Organic, Decision Tree | +| Cinematic atmosphere | Film Light-Leak | +| Marketing / hero | Fluid Background Hero | +| Bumpers | Brand Logo Outro | +| Social portrait (9:16) | Play Mode, Vignelli | +| Product demo | Product Promo, Product Promo · 30s | +| Effects | VFX Text Cursor | + +Two classes, two very different adaptation costs: + +- **`rich` (12)** — a single file driven by pure CSS `@keyframes`. The seek renderer drives + these **with zero code changes**; adapting one is three steps: swap the web font for a + system stack, lift bottom elements out of the subtitle zone, fill in real content. +- **`gsap` (11)** — multi-composition, CDN-loaded. **Do not port the code.** A multi-scene + composition has no playback hook inside a single seekable frame, so importing it yields a + static first frame and no error whatsoever. Take the visual DNA and re-express it as CSS + keyframes. + +Rotate two to four styles across a video. Eight scenes sharing one template looks stale; +mixing an aggressive opener, a spare mid-section, and a data-driven conclusion does not. + +--- + +## Dual-cover system + +A finished video produces **two covers that are siblings, not a crop pair**. + +Cropping 3:4 out of 16:9 yields 810px from 1920px — discarding **57.8% of the frame width**. +Any full-width headline gets sliced. So both covers share one visual DNA (palette, +backdrop, key visual, hook copy) but are each laid out from scratch: + +| Element | 16:9 (`1920×1080`) | 3:4 (`1440×1080`) | +|---|---|---| +| Paradox visual | Right side, side-by-side | Top, vertically stacked | +| Hook | Bottom-left, 2 lines, ≥96px | Bottom, 3 lines, ≥120px | +| Corner label | Top-left | Top-centre or dropped | +| Max characters per line | 7 | 5 | + +Three elements are mandatory, and a missing one means rework: a large hook with genuine +tension, a central visual that *argues a contradiction* (two figures moving opposite ways, +a split line, light against dark — not a decorative shape), and margin to spare +(≥96px / ≥110px on all sides). + +Output is 2× by default: `3840×2160` and `2880×2160`, sized for platform compression. + +Full rules and the pre-upload checklist: [`references/cover-guide.md`](references/cover-guide.md). + +--- + +## The frame contract + +One scene is one self-contained HTML file. Eight rules — enforced by `lint_frames.py`, and +each one exists because breaking it fails *quietly*: + +| # | Rule | Breaks like this | +|---|---|---| +| 1 | Canvas `1920×1080` (or the size declared in `project.json`), system fonts only | Render viewport is the canvas | +| 2 | **No external fonts or CDNs** | Font fetch times out; type weight shifts mid-video | +| 3 | Colours only from `../theme.css` variables | A theme change becomes a partial re-skin | +| 4 | GSAP timeline registered on `window.__tl` | Renderer finds no timeline and errors | +| 5 | Position animation with `B('block text')` | Frame and voiceover drift apart | +| 6 | Keep the bottom 80–170px clear (subtitle zone) | Subtitles overprint content; both become unreadable | +| 7 | One focal point per frame; text ≥22px | QC rejection | +| 8 | Load GSAP from `../assets/gsap.min.js` | CDN unreachable → offline render fails | + +Numbers animated in a frame should use a **pure-transform digit strip** rather than +`onUpdate`. The renderer passes `suppressEvents = false` so callbacks do work, but a +transform-based animation depends on no callback at all and therefore survives every seek +strategy. + +Details, layout vocabulary, and the rejection examples: [`references/frame-contract.md`](references/frame-contract.md). + +--- + +## Project layout + +``` +html-explainer/ +├── SKILL.md # Agent-facing entry point: workflow, commands, contracts +├── README.md # Project documentation (Chinese, default) +├── README.en.md # This document +├── LICENSE · THIRD_PARTY_NOTICES.md · licenses/ +├── CHANGELOG.md · CONTRIBUTING.md +│ +├── scripts/ +│ ├── new_project.py # Scaffold +│ ├── tts_build.py # edge-tts → MP3 + word boundaries +│ ├── timeline_build.py # Global axis + stitched audio +│ ├── subs.py # Subtitles ×3 outputs + beats.js +│ ├── lint_frames.py # Pre-render contract audit +│ ├── render_video.mjs # Deterministic seek renderer +│ ├── qc_check.py # Post-render audit + contact sheet +│ ├── cover_build.mjs # Dual-cover renderer +│ ├── peek_frame.mjs # Single-scene screenshot preview +│ ├── make_theme.py # Palette derivation → theme.css +│ ├── check_integrity.py # Repo self-consistency (CI) +│ ├── import_styles.py # One-time style-catalog import tool +│ └── package_skill.py # Portable zip builder +│ +├── references/ +│ ├── style-catalog.md / .json # 23 styles, 8 categories +│ ├── template-guide.md # Adaptation recipes +│ ├── frame-contract.md # The eight rules, with reasoning +│ ├── cover-guide.md # Dual-cover specification +│ ├── workflow-guide.md # Stage-by-stage detail + agent prompt templates +│ └── lessons.md # 34 numbered silent-failure post-mortems +│ +├── assets/ +│ ├── frame-template.html # Scene template +│ ├── cover-template.html # Cover template +│ └── gsap.min.js # GSAP 3.13, bundled for offline rendering +│ +├── node/ # Vendored playwright-core +├── setup_env.sh # Environment check / install +└── .github/workflows/ci.yml # CI: integrity, syntax, CDN scan +``` + +A **video project** looks like this: + +``` +my-video/ +├── project.json # slug, fps, size, voice, rate, scene order, chapters +├── narration.json # [{ id, text }] — "|" splits subtitle blocks +├── theme.css # Every colour, as CSS variables +├── frames/ +│ ├── _template.html +│ ├── .html # One scene per file +│ ├── .beats.js # Generated — never edit by hand +│ ├── cover_169.html +│ └── cover_34.html +├── audio/ render/ research/ script/ +└── out/ # MP4, covers, SRT/VTT, QC report +``` + +--- + +## Configuration + +`project.json`: + +| Key | Meaning | +|---|---| +| `slug` | Output filename stem | +| `lang` | `zh` or `en` — selects the default voice | +| `fps` | Frame rate (default 30) | +| `width` / `height` | Canvas size (default `1920×1080`) | +| `gap` | Silence inserted between scenes, in seconds | +| `order` | Scene IDs in playback order — **fill this in after writing `narration.json`** | +| `chapters` | Optional `[{ title, startSegment }]` for progress-bar ticks | +| `theme` | Theme preset name | +| `voice` | edge-tts voice, e.g. `zh-CN-YunxiNeural` | +| `rate` | Speech rate, e.g. `+8%` | +| `progress` | Whether to render the global progress bar | + +Writing narration: + +```json +[ + { "id": "hook", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。" }, + { "id": "point1", "text": "一个场景一句解说。|字幕块之间用竖线分隔。" } +] +``` + +- `id` becomes the scene name and must be ASCII (`hook`, `point1`, `ending`). +- `|` splits subtitle blocks, **not** scenes. One narration entry = one scene. +- Keep Chinese subtitle blocks ≤16 characters, and split at semantic boundaries. +- Never add punctuation back to control line breaks — the renderer treats subtitles as + on-screen text and strips punctuation. +- Two adjacent numbers need a Chinese comma between them, or edge-tts reads them as one + fused number (`沪深300` + `4479.55` → "three million four hundred seventy-nine…"). + +Aim for roughly 5.4–6 Chinese characters per second. If the total lands more than 15% off +your target duration, rewrite sentences — do not distort the speech rate. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Every frame is the static first frame; nothing errors | A timeline was never registered on `window.__tl`, or `page.evaluate` was handed a *string* instead of a function literal (Playwright evaluates a string once and returns the function object — the body never runs) | Register the timeline; pass a real function literal. Lessons #17 | +| Numbers render but never move | `onUpdate` callbacks suppressed by a seek | Fixed in the renderer (`pause(t, false)`). Animate with a transform-based digit strip instead. Lessons #27 | +| Video is 1280×720 instead of 1920×1080 | `viewport` passed to `browser.launch()` — it is a *context* option | Pass it to `newPage()`. Lessons #18 | +| Covers come out at 1× though the log says 2× | Same trap with `deviceScaleFactor` | Pass to `newPage()` + `screenshot({ scale: 'device' })`; verify with PIL. Lessons #28 | +| `No such file or directory` on Windows | Non-ASCII path — ffmpeg on Windows reads UTF-8 as ANSI | Keep project, output, and frame paths ASCII. Lessons #11 | +| Node can't find `/tmp/...` | Git Bash's `/tmp` ≠ Node's `/tmp` on Windows | Pass `C:/...` forward-slash absolute paths. Lessons #12 | +| QC reports every frame as suspicious | Fixed frame-size threshold, invalid on dark themes | Now adaptive (35% of the video's own median). Lessons #19 | +| QC says everything passed but late scenes were never checked | Sampling that slices the first N blocks instead of stepping evenly | Fixed. Lessons #29 | +| Subtitles vanish slightly early on the last block | `speech_end_sec` computed in pre-trim coordinates | Fixed in 1.2.2. Lessons #31 | +| First 0.5s of the video is nearly black | Entrances fading in from `opacity: 0` on the first narration beat | Start earlier, use `power4.out`, and let a mask wipe provide the reveal instead of opacity. Lessons #34 | +| Rendered PNGs disappeared after a preview | Preview mode deletes the frame directory by design | Pass `--keep-frames`. Lessons #32 | +| Re-rendered the whole video to fix one scene | No per-scene switch exists | Use the temporary-project technique. Lessons #33 | + +**`references/lessons.md` is the most valuable file here.** 34 numbered entries, each +recording a bug that produced a plausible-looking video while being wrong — including how +it was misdiagnosed first. Read it before debugging from scratch. + +--- + +## Who this is for + +**Good fit** + +- You want to turn articles, documentation, or a topic into narrated videos at some volume. +- You already work with an AI coding agent and would rather express scenes as HTML than + learn a motion-graphics suite. +- You need reproducible output — re-render the same project and get the same frames. +- You need it to run offline, or you object to per-render pricing. +- You want the infrastructure work done: subtitles, audio sync, covers, QC. + +**Poor fit** + +- Real-footage editing, talking-head video, or recreating an existing video. +- React component animation as the authoring model (that is the job of the Remotion-based + `anything2explainer`, this project's sibling). +- A graphical drag-and-drop editor. Scenes are code, deliberately. + +--- + +## Roadmap + +- [ ] Per-scene render switch (`--only `) — today this needs the temporary-project workaround +- [ ] Cover layout measurement tool (line count / four-side margins / element overlap / hook size in one pass) — today you write a dozen lines yourself +- [ ] Additional cover aspect ratios via the spec table in `cover_build.mjs` +- [ ] English-first style notes in the catalog (currently Chinese descriptions throughout) +- [ ] Optional background-music bed with automatic ducking under narration +- [ ] Frame-contract lint rules for focal-point and glow discipline (currently eyeball-only) +- [ ] Pre-built project templates for common video shapes (60s short, 3-min explainer) + +--- + +## Contributing + +Issues and PRs are welcome. The most valuable contribution is a **diagnosed silent +failure** added to `references/lessons.md` — the symptom as it first misled you, the +evidence that proved the cause, the fix, and the resulting rule. + +Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) first. It covers the project's hard rules, +how to run an end-to-end check, and the PR checklist. + +CI checks the repository for version drift, style-catalog drift, and stray CDN references +on Linux and Windows across Python 3.9 and 3.13. + +--- + +## Acknowledgements + +The narration and audio-sync methodology in this project — word-boundary subtitles, the +two-level clock, speech-rate calibration, and the QC criteria — grew out of two earlier +projects by the same author, `anything2explainer` and `html-video-workbuddy-driver`. + +The 23-style visual catalog transcribes the template design specifications of +[nexu-io/html-video](https://github.com/nexu-io/html-video) (Apache-2.0). Seven of those +styles in turn trace back to MIT-licensed design work by +[Zara Zhang](https://github.com/zarazhangrui/frontend-slides), +[alchaincyf](https://github.com/alchaincyf/huashu-design), and Nate Herk. + +No upstream source code is bundled or required at runtime — the catalog is a written +specification, comparable to copying a recipe out of a restaurant. Full attribution, licence +texts, and the exact style-level mapping are in +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). + +GSAP is bundled under GreenSock's [standard "no charge" licence](https://gsap.com/standard-license). + +--- + +## Licence + +[MIT](LICENSE) © 2026 Moh + +The MIT licence covers the original code and documentation. Third-party components and the +derived style specification remain under their own terms — see +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). + +--- + +
+ +**Author: Moh** + +Built because re-aligning an entire video by hand after one changed sentence is a bad way to spend an afternoon. + +
diff --git a/README.md b/README.md new file mode 100644 index 0000000..cab35fd --- /dev/null +++ b/README.md @@ -0,0 +1,528 @@ +
+ +# html-explainer + +**把一个主题做成有配音、有硬字幕、有封面的讲解视频 —— 全本地、用 HTML 写画面,音画同步稳得住。** + +面向 AI 编程智能体的确定性 HTML → MP4 渲染流水线。 +不需要 Remotion,不需要云端渲染队列,没有按次计费,核心链路不需要任何 API key。 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md) +[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](https://python.org) +[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#环境要求) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +[简体中文](README.md) · [English](README.en.md) + +
+ +--- + +## 这是什么 + +`html-explainer` 用来生产科普 / 讲解 / 知识类视频 —— 就是那种带配音、带硬字幕、带进度条, +画面用 HTML/CSS/GSAP 做的动效视频。 + +你写一份解说词。智能体(或者你自己)从 23 种画面风格里挑一个,为每个节拍写一个自包含的 +HTML 文件,然后这条流水线把它渲染成真正的 MP4:字幕逐字对齐,外加两张可直接上传的封面。 + +所有计算都在你自己的机器上。唯一的联网请求是 TTS 合成,以及(可选的)为写稿做的资料调研。 + +它以 **Agent Skill** 的形式封装(`SKILL.md` + `scripts/` + `references/`),可以直接放进 +Claude Code、WorkBuddy、Cursor,或任何能读技能文件的智能体 —— 但脚本本身只是普通的 +Python 和 Node,手工跑完全没问题。 + +--- + +## 核心思路:确定性 seek,而不是屏幕录制 + +多数「HTML 转视频」工具的做法是:启动浏览器、播放动画、录屏。这条路线会生出一整类让人 +抓狂的 bug,因为它们都是**间歇性**的:动画在录制开始前就播了、网络字体加载晚了导致半段 +视频用了错误的字体、前几帧拍到的是动画前的状态。 + +`html-explainer` 不录制,它 **seek**。 + +每一帧都执行这四步: + +1. 置位 `window.__MG_RENDER__`,让画面知道自己不该自动起播; +2. 用 `tl.pause(t, false)` 把 GSAP 时间轴定位到那一瞬间; +3. 用 `document.getAnimations().currentTime = t * 1000` 同步所有 CSS 动画; +4. 等两个动画帧后截图。 + +渲染出来的第 1204 帧,和下一次渲染的第 1204 帧,逐像素一致。正是这个性质让后续的一切成为 +可能 —— QC 可以定点抽查某一帧、单个场景可以独立重渲,而一整类时序 bug 从根本上无法发生。 + +> 第二个参数不是可选项。`pause(atTime, suppressEvents)` 的 `suppressEvents` 默认为 `true`, +> 它会**静默丢弃**这次 seek 触发的所有回调 —— 用 `onUpdate` 写的数字滚动会永远停在初值, +> 且零报错。这个坑花掉了真实的调试时间,见 [`references/lessons.md`](references/lessons.md) #27。 + +--- + +## 特性 + +| | | +|---|---| +| **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间 —— **画面代码一行都不用改。** | +| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值。中文里两个同字数的短语时长可以差 3 倍,插值会明显错位。 | +| **两级时钟** | 帧长用 MP3 **容器时长**(保证音画同步);末块字幕按**语音真实结束**收尾。两者混用会让每场景字幕多停约 0.8 秒。 | +| **23 种画面风格** | 8 个类别 —— 大胆信号卡、奢华极简、NYT 编辑级数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、品牌收尾等,每种都记录了画布、字阶、时间轴与配色纪律。 | +| **封面双方案** | 每条成片都附带 16:9 信息流封面与**独立排版**的 3:4 主页封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 | +| **渲染前静态体检** | `lint_frames.py` 在你花 15 分钟渲染之前,先把八条画面契约的违规逐条报出来。 | +| **自适应 QC** | 响度、时长漂移、流完整性、抽帧速览图 —— 抽帧体积阈值取本片自身中位数推导,暗色主题不会产生假阳性。 | +| **结构性离线** | GSAP 内置;Chrome / Edge 自动探测;ffmpeg 缺失时回退到 `imageio-ffmpeg` 自带的静态二进制;网络字体被契约禁止。 | +| **配色单一来源** | 所有颜色都是 `theme.css` 里的 CSS 变量。换主题即整片换肤,画面代码零改动。 | + +--- + +## 横向对比 + +| | `html-explainer` | 录屏式 HTML 工具 | Remotion | 云端视频 API | +|---|---|---|---|---| +| **渲染方式** | 确定性 seek | 实时录制 | React → 逐帧 | 服务端渲染 | +| **帧可复现** | 是 | 否 | 是 | 不适用 | +| **单次成本** | 零 | 零 | 零 | 按量计费 | +| **运行时依赖** | Node + Python + 浏览器 | 同上 | Node + React 工具链 | 网络 + 账号 | +| **创作模型** | 每场景一个 HTML | 每场景一个 HTML | React 组件 | JSON / 模板 | +| **改稿后重新对时** | 自动(`B()` 节拍) | 手工 | 手工 | 手工 | +| **全程离线可跑** | 是 | 是 | 是 | 否 | +| **内置风格库** | 23 种(含规范文档) | 视情况 | 社区模板 | 厂商模板 | +| **封面 / 缩略图产出** | 两套已验证版式 | 无 | 无 | 有时有 | + +诚实的取舍:因为渲染是 seek 式的,**每一个动画都必须可 seek**。墙钟动画(`setInterval`、 +`requestAnimationFrame` 计数、CSS `transition` 入场)不可能工作,会被 linter 直接拒绝。 +这个约束换来了确定性 —— 而且它是被强制执行的,不只是写在文档里的建议。 + +--- + +## 流水线 + +```mermaid +flowchart LR + A["主题"] --> B["调研
每个数字带出处"] + B --> C["narration.json
竖线切字幕块"] + C --> D["tts_build.py
edge-tts → MP3 + 词边界"] + D --> E["timeline_build.py
全局轴 + 拼接音轨"] + E --> F["subs.py
subs.json · srt/vtt · beats.js"] + F --> G["frames/*.html
一句一场景,风格取自风格库"] + G --> H{"lint_frames.py
八条契约"} + H -->|不过| G + H -->|通过| I["render_video.mjs
逐帧 seek → PNG"] + I --> J["ffmpeg
H.264 + AAC 合成"] + J --> K[("out/slug.mp4")] + I --> L["qc_check.py
响度 · 漂移 · 抽帧速览"] + K --> M["cover_build.mjs
cover_169.png · cover_34.png"] + + style K fill:#1f6feb,color:#fff + style H fill:#8957e5,color:#fff + style M fill:#238636,color:#fff +``` + +顺序不能乱。`beats.js` 由 TTS 产出推导而来 —— 这就是为什么改了解说词之后,下游全部自动 +重排时序,而场景代码完全不用动。 + +--- + +## 环境要求 + +| 项 | 最低 | 说明 | +|---|---|---| +| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg` | +| Node.js | 18+ | 渲染器与封面器用 | +| 浏览器 | Chrome 或 Edge | 自动探测。都没有才下载 playwright chromium(约 115MB,只需一次) | +| ffmpeg | 任意版本 | 先在 `PATH` 找;没有则用 `imageio-ffmpeg` 自带的静态二进制 | +| 磁盘 | 每条成片约 2GB | 帧 PNG 体积大,合成后可删 | + +全程不需要管理员权限,依赖一律装用户级。 + +--- + +## 快速开始 + +```bash +git clone https://github.com/OneMoh/html-explainer.git +cd html-explainer + +# 1 · 环境自检(缺什么报什么) +bash setup_env.sh + +# 2 · 安装缺失依赖 +bash setup_env.sh --install +``` + +然后在项目目录里跑流水线: + +```bash +PY=<你的 venv python 路径> # 派给子智能体时要展开成绝对路径 +SKILL=$(pwd) # 本仓库 + +# 阶段 0 —— 建项目脚手架 +"$PY" "$SKILL/scripts/new_project.py" ~/videos/ai-intro ai-intro --topic "人工智能" +cd ~/videos/ai-intro + +# …写 narration.json、填 project.json 的 order、 +# 并为每个场景写一个 frames/.html… + +# 阶段 1–3 —— 配音、全局轴、字幕 +"$PY" "$SKILL/scripts/tts_build.py" --project . +"$PY" "$SKILL/scripts/timeline_build.py" --project . +"$PY" "$SKILL/scripts/subs.py" --project . + +# 阶段 4 —— 渲染前先体检画面(省下整片渲染的时间) +"$PY" "$SKILL/scripts/lint_frames.py" --project . + +# 阶段 5 —— 渲染(--preview 30 先出前 30 秒的快速样片) +node "$SKILL/scripts/render_video.mjs" . --preview 30 + +# 阶段 6 —— QC +"$PY" "$SKILL/scripts/qc_check.py" --project . + +# 阶段 7 —— 全片渲染 + 封面 +node "$SKILL/scripts/render_video.mjs" . +node "$SKILL/scripts/cover_build.mjs" . +``` + +产物在 `out/`:`slug.mp4`、`cover_169.png`、`cover_34.png`、`slug.srt`、`slug.vtt`, +外加 `qc_report.md` 与 `qc_sheet.jpg`。 + +> **只要打算复用渲出来的 PNG,就一定要加 `--keep-frames`。** 预览模式在合成后会按设计 +> 清空帧目录。见 [`references/lessons.md`](references/lessons.md) #32。 + +--- + +## 命令参考 + +### 主流水线 + +| 命令 | 作用 | +|---|---| +| `python scripts/new_project.py [--topic T] [--preset P] [--fps 30] [--width 1920] [--height 1080] [--lang zh]` | 建项目脚手架:配置、主题、模板,以及两份封面 HTML | +| `python scripts/tts_build.py --project . [--voice V] [--rate +8%] [--no-trim] [--no-cache]` | 合成配音;带缓存、超时、退避重试、裁静音;写两种时长口径 | +| `python scripts/timeline_build.py --project . [--gap SEC]` | 生成全局时间轴并拼接 `narration-full.mp3` | +| `python scripts/subs.py --project . [--fps N]` | 产出 `subs.json`、`srt`/`vtt` 外挂字幕,以及每场景一份 `beats.js` | +| `python scripts/lint_frames.py --project . [--only id,id] [--include-covers]` | 按八条契约静态体检所有画面帧 | +| `node scripts/render_video.mjs [--out PATH] [--preview SEC] [--fps N] [--concurrency N] [--jpeg] [--scale N] [--keep-frames] [--browser PATH]` | 渲染并合成 MP4 | +| `python scripts/qc_check.py --project . [--samples N]` | 响度 / 时长 / 流体检 + 抽帧速览图 | +| `node scripts/cover_build.mjs [--at last\|SEC] [--only 169\|34] [--scale N] [--jpg] [--keep-frames]` | 渲两张封面,默认 2 倍图 | + +### 辅助工具 + +| 命令 | 作用 | +|---|---| +| `node scripts/peek_frame.mjs [--at 40,80]` | 给单个场景在若干百分比时点截图 —— 是「秒级」而不是「分钟级」。`--all` 可覆盖全部场景 | +| `python scripts/make_theme.py --topic "医疗" --use` | 按主题词推导配色,或用 `--preset violet\|cyan\|amber\|mono` | +| `python scripts/check_integrity.py` | 版本号 / 风格目录 / 计数一致性 + 外链扫描(CI 也会跑) | +| `bash setup_env.sh --install` | 环境自检与依赖安装 | +| `python scripts/package_skill.py [--with-deps]` | 打成可移植 zip(不加 `--with-deps` 约 110KB) | + +--- + +## `B()` 节拍系统 + +这是让整个工作方式变掉的那一块。 + +`subs.py` 为每个场景生成一份 `beats.js`,把每块字幕按**原文**暴露出来: + +```js +B('右边卡片') // 「右边卡片」这几个字开始被念出的那一秒 +Be('证据') // 这句话念完的那一秒 +``` + +于是画面动画可以这样写: + +```js +var tl = gsap.timeline({ paused: true }); +window.__tl = tl; + +tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片')); +tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2); +``` + +改一次解说词,重跑三条命令。节拍随之平移,所有场景自行重排对时,没有一个画面文件被改动。 +对比帧号硬编码的写法:改一句话意味着整片手工重新对位。 + +**兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」这件事藏在一个过期的数字后面。 +查不到就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。 + +--- + +## 画面风格库 + +**不要对着空白页从零设计画面。** 23 种风格、8 个类别,每种都记录了画布、字阶、时间轴结构与 +配色纪律([`references/style-catalog.md`](references/style-catalog.md))。 + +| 类别 | 风格 | +|---|---| +| 演示 / 标题卡 | 大胆海报帧、大胆信号卡帧、奢华极简留白帧、创意电压分屏帧、电光工作室分屏帧、故障艺术标题帧、Kinetic Type、Swiss Grid、Warm Grain | +| 数据可视化 | NYT 风数据图表帧、数据滚动帧、NYT Graph、瑞士网格数据帧 | +| 图解 / 流程 | 东方柔和有机帧、Decision Tree | +| 氛围 / 空镜 | 胶片漏光电影帧 | +| 营销 / Hero | 流体背景 Hero 帧 | +| 片头片尾 | 品牌 Logo 收尾帧 | +| 社媒竖版(9:16) | Play Mode、Vignelli | +| 产品演示 | Product Promo、Product Promo · 30s | +| 特效 | VFX 文字光标 | + +分成两类,改编成本差别很大: + +- **`rich`(12 个)** —— 单文件 + 纯 CSS `@keyframes` 驱动。seek 渲染器**零改动即可驱动**; + 改编只要三步:把网络字体换成系统字体栈、把底部元素抬出字幕带、填进真实内容。 +- **`gsap`(11 个)** —— 多 composition + CDN 加载。**不要搬代码。** 多场景组合在单个可 + seek 的帧里没有起播钩子,整段搬进来只会得到静止首帧,而且零报错。只取它的视觉 DNA, + 用 CSS keyframes 重新表达。 + +一个项目建议轮换 2–4 种风格。8 个场景共用同一张脸会显得单调;用一个强冲击开场、一段留白 +论述、一个理性数据收尾,观感完全不同。 + +--- + +## 封面双方案 + +每条成片产出**两张互为姊妹、而非裁切关系**的封面。 + +从 16:9 居中裁 3:4,1920px 只剩 810px —— 丢掉 **57.8% 的画面宽度**。任何横跨全宽的标题都会 +被切掉一半。所以两张封面共享同一套视觉基因(配色、幕底、主视觉、钩子文案),但各自**重新 +排一次版**: + +| 元素 | 16:9(`1920×1080`) | 3:4(`1440×1080`) | +|---|---|---| +| 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 | +| 钩子 | 左下,两行,≥96px | 下方,三行,≥120px | +| 角标 | 左上 | 顶部居中或省略 | +| 每行字数上限 | 7 字 | 5 字 | + +三个要素缺一即返工:一句**真正有张力**的大字钩子、一个**在讲矛盾**的主视觉(两个数字反向 +运动、一道分裂线、一明一暗 —— 不是装饰图形)、以及四边都留有余量(≥96px / ≥110px)。 + +默认输出 2 倍图:`3840×2160` 与 `2880×2160`,用来应付平台压缩。 + +完整规则与上传前自检清单:[`references/cover-guide.md`](references/cover-guide.md)。 + +--- + +## 画面契约 + +一个场景 = 一个自包含 HTML 文件。八条规则 —— 由 `lint_frames.py` 强制执行,每一条的存在 +都是因为违反它会**静默失败**: + +| # | 规则 | 违反了会怎样 | +|---|---|---| +| 1 | 画布 `1920×1080`(或 `project.json` 声明的尺寸),只用系统字体 | 渲染视口即画布 | +| 2 | **绝不引外部字体或 CDN** | 字体请求超时,片中字重跳变 | +| 3 | 颜色只取 `../theme.css` 的变量 | 换主题变成半换皮 | +| 4 | GSAP 时间轴注册到 `window.__tl` | 渲染器找不到时间轴,直接报错 | +| 5 | 用 `B('块文本')` 定位动画 | 画面与配音变成两张皮 | +| 6 | 底部 80–170px 保持干净(字幕带) | 字幕压字,两边都读不清 | +| 7 | 每帧一个焦点,文字 ≥22px | QC 退回 | +| 8 | GSAP 从 `../assets/gsap.min.js` 加载 | CDN 不可达 → 离线渲染失败 | + +帧里的数字滚动应该用**纯 transform 的「数字卷轴」**,而不是 `onUpdate`。渲染器已经传 +`suppressEvents = false` 让回调能跑,但基于 transform 的动画不依赖任何回调,因此在任何 +seek 策略下都成立。 + +细则、版式基因与反例:[`references/frame-contract.md`](references/frame-contract.md)。 + +--- + +## 目录结构 + +``` +html-explainer/ +├── SKILL.md # 面向智能体的入口:流程、命令、契约 +├── README.md # 本文档(中文,默认) +├── README.en.md # 英文版 +├── LICENSE · THIRD_PARTY_NOTICES.md · licenses/ +├── CHANGELOG.md · CONTRIBUTING.md +│ +├── scripts/ +│ ├── new_project.py # 建项目 +│ ├── tts_build.py # edge-tts → MP3 + 词边界 +│ ├── timeline_build.py # 全局轴 + 拼接音轨 +│ ├── subs.py # 字幕三出口 + beats.js +│ ├── lint_frames.py # 渲染前契约体检 +│ ├── render_video.mjs # 确定性 seek 渲染器 +│ ├── qc_check.py # 成片体检 + 抽帧速览图 +│ ├── cover_build.mjs # 封面双方案渲染器 +│ ├── peek_frame.mjs # 单场景速览截图 +│ ├── make_theme.py # 推色 → theme.css +│ ├── check_integrity.py # 仓库自洽性(CI) +│ ├── import_styles.py # 一次性风格目录导入工具 +│ └── package_skill.py # 可移植 zip 打包 +│ +├── references/ +│ ├── style-catalog.md / .json # 23 种风格 / 8 个类别 +│ ├── template-guide.md # 改编三步法 / 重写法 +│ ├── frame-contract.md # 八条契约与理由 +│ ├── cover-guide.md # 封面双方案详规 +│ ├── workflow-guide.md # 阶段详解 + agent prompt 模板 +│ └── lessons.md # 34 条静默失败复盘 +│ +├── assets/ +│ ├── frame-template.html # 场景模板 +│ ├── cover-template.html # 封面模板 +│ └── gsap.min.js # GSAP 3.13,内置供离线渲染 +│ +├── node/ # 随包 playwright-core +├── setup_env.sh # 环境自检 / 安装 +└── .github/workflows/ci.yml # CI:自洽性、语法、外链扫描 +``` + +一个 **视频项目** 长这样: + +``` +my-video/ +├── project.json # slug、fps、尺寸、音色、语速、场景顺序、章节 +├── narration.json # [{ id, text }] —— 用 "|" 切字幕块 +├── theme.css # 所有颜色,以 CSS 变量形式 +├── frames/ +│ ├── _template.html +│ ├── .html # 一个场景一个文件 +│ ├── .beats.js # 自动生成 —— 绝不手改 +│ ├── cover_169.html +│ └── cover_34.html +├── audio/ render/ research/ script/ +└── out/ # MP4、封面、SRT/VTT、QC 报告 +``` + +--- + +## 配置说明 + +`project.json`: + +| 字段 | 含义 | +|---|---| +| `slug` | 输出文件名主干 | +| `lang` | `zh` 或 `en` —— 决定默认音色 | +| `fps` | 帧率(默认 30) | +| `width` / `height` | 画布尺寸(默认 `1920×1080`) | +| `gap` | 场景之间插入的静音秒数 | +| `order` | 场景 ID 的播放顺序 —— **写完 `narration.json` 后必须填** | +| `chapters` | 可选 `[{ title, startSegment }]`,给进度条加章节刻度 | +| `theme` | 主题预设名 | +| `voice` | edge-tts 音色,如 `zh-CN-YunxiNeural` | +| `rate` | 语速,如 `+8%` | +| `progress` | 是否渲染全局进度条 | + +写解说词: + +```json +[ + { "id": "hook", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。" }, + { "id": "point1", "text": "一个场景一句解说。|字幕块之间用竖线分隔。" } +] +``` + +- `id` 会成为场景名,必须是 ASCII(`hook`、`point1`、`ending`)。 +- `|` 切的是**字幕块**,不是场景。一条解说词 = 一个场景。 +- 中文字幕每块 ≤16 字,切点落在语义边界上。 +- 不要靠加回标点来控制断行 —— 渲染器把字幕当作屏幕文本,会去掉标点。 +- **相邻的两个数字之间要插中文逗号**,否则 edge-tts 会把它们熔成一个数 + (`沪深300` + `4479.55` → 「三百万四千四百七十九点五五点」)。 + +中文语速约 5.4–6 字/秒。如果总时长偏离目标超过 15%,改句子 —— 不要扭曲语速去硬凑。 + +--- + +## 疑难排查 + +| 症状 | 原因 | 处理 | +|---|---|---| +| 每一帧都是静止首帧,且不报错 | 时间轴没注册到 `window.__tl`;或 `page.evaluate` 传了**字符串**而不是函数字面量(Playwright 只把字符串求值一次,返回的是函数对象,函数体根本不执行) | 注册时间轴;传真正的函数字面量。lessons #17 | +| 数字能渲染,但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`)。帧里改用 transform 数字卷轴。lessons #27 | +| 视频变成 1280×720 而不是 1920×1080 | `viewport` 传给了 `browser.launch()` —— 它是 *context* 级选项 | 传给 `newPage()`。lessons #18 | +| 封面出成 1 倍图,但日志写着 2 倍 | 同上,`deviceScaleFactor` 的孪生坑 | 传给 `newPage()` + `screenshot({ scale: 'device' })`;用 PIL 核尺寸。lessons #28 | +| Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 项目、输出、抽帧路径全部保持 ASCII。lessons #11 | +| Node 找不到 `/tmp/...` | Git Bash 的 `/tmp` ≠ Node 的 `/tmp` | 传 `C:/...` 正斜杠绝对路径。lessons #12 | +| QC 把所有帧都报成可疑 | 抽帧体积阈值写死了,在暗色主题上失效 | 已改为自适应(本片中位数的 35%)。lessons #19 | +| QC 说全过,但后半段其实一帧没抽 | 抽样逻辑取了前 N 块,而不是按步长均匀挑 | 已修。lessons #29 | +| 末块字幕提前消失 | `speech_end_sec` 用了裁前坐标 | 1.2.2 已修。lessons #31 | +| 视频前 0.5 秒几乎全黑 | 第一句解说配了从 `opacity: 0` 开始的淡入 | 提前入场起点、换 `power4.out`,并让遮罩上推本身去提供遮挡而不是叠加透明度。lessons #34 | +| 预览渲完,帧 PNG 不见了 | 预览模式按设计会清空帧目录 | 加 `--keep-frames`。lessons #32 | +| 为了修一个场景重渲了整片 | 渲染器没有单场景开关 | 用临时项目法。lessons #33 | + +**`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个 +「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前, +先读它。 + +--- + +## 适合谁 + +**适合** + +- 想成规模地把文章、文档或某个主题做成带解说的视频。 +- 已经在用 AI 编程智能体,并且宁愿用 HTML 描述画面,也不想学一套动效软件。 +- 需要可复现的输出 —— 同一个项目重渲一次,帧应当一致。 +- 需要离线跑,或者不接受按次计费。 +- 想把字幕、音画同步、封面、QC 这些基础工程直接拿走用。 + +**不适合** + +- 实拍剪辑、真人口播,或者复刻一条已有的视频。 +- 想把「React 组件动画」作为创作模型(那是本项目的姊妹项目、基于 Remotion 的 + `anything2explainer` 的领域)。 +- 想要图形化拖拽编辑器。画面是代码,这是刻意选择。 + +--- + +## 路线图 + +- [ ] 单场景渲染开关(`--only `)—— 目前需要临时项目法绕过 +- [ ] 封面排版终态测量器(行数 / 四边边距 / 元素重叠 / 钩子字号一次判完)—— 目前需自行写十几行 +- [ ] 通过 `cover_build.mjs` 里的规格表支持更多封面比例 +- [ ] 风格目录补英文说明(目前描述以中文为主) +- [ ] 可选背景音乐轨,并对解说自动做闪避(ducking) +- [ ] 「焦点 / 发光纪律」的 lint 规则(目前只能靠肉眼) +- [ ] 常见视频形态的项目模板(60 秒短片、3 分钟讲解) + +--- + +## 参与贡献 + +欢迎提 issue 和 PR。最有价值的贡献,是一条**已被定位的静默失败**,加进 +`references/lessons.md` —— 写下它一开始是怎么误导你的、证明病因的证据、修法,以及由此 +得出的通用规则。 + +请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md),里面有本项目的硬性规则、端到端自检方法, +以及 PR 检查清单。 + +CI 会在 Linux 与 Windows 上、Python 3.9 与 3.13 上检查版本号漂移、风格目录漂移和外链引用。 + +--- + +## 致谢 + +本项目的解说与音画同步方法论 —— 词边界字幕、两级时钟、语速标定、QC 判据 —— 源自作者 +更早的两个项目 `anything2explainer` 与 `html-video-workbuddy-driver`。 + +23 种画面风格目录转写自 [nexu-io/html-video](https://github.com/nexu-io/html-video) +(Apache-2.0)的模板设计规范。其中 7 种风格又可追溯到 MIT 许可的设计作品,作者分别是 +[Zara Zhang](https://github.com/zarazhangrui/frontend-slides)、 +[alchaincyf](https://github.com/alchaincyf/huashu-design) 与 Nate Herk。 + +本仓库不打包、运行时不依赖任何上游源码 —— 风格目录是一份文字规范,类比把菜谱抄回家。 +完整的署名、许可证原文与逐风格对应关系见 +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。 + +GSAP 按 GreenSock 的 [standard "no charge" 许可](https://gsap.com/standard-license) 内置。 + +--- + +## 许可 + +[MIT](LICENSE) © 2026 Moh + +MIT 许可覆盖原创代码与文档。第三方组件与衍生出的风格规范仍适用其各自的条款 —— 见 +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。 + +--- + +
+ +**作者:Moh** + +做这个项目,是因为「改一句话然后把整片手工重新对位一遍」实在不是一种好的过下午的方式。 + +
diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..50bfa79 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,268 @@ +--- +name: html-explainer +version: 1.2.2 +description: 把任意主题做成「讲解/科普视频」并渲染成 MP4:调研→审查→解说词→字幕→edge-tts 配音→并行构建 HTML 场景→确定性逐帧渲染→成片后出双方案封面。**画面语言内置 23 个模板风格 / 8 个类别**(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、Logo 收尾、东方柔和有机、VFX 文字光标…共 23 种风格,含每种的画布/配色/字体/时间轴规范,见 references/style-catalog.md),流程规范与音画同步体系承自 anything2explainer(词边界字幕、两级时钟、语速标定、多 agent 分工与 QC 判据),渲染层为自研 seek 式渲染器。**封面双方案**:抖音主封面 1920×1080 + 兼容 3:4 的 1440×1080(独立重排,防主页栅格切字)。独立可移植:GSAP 内置、playwright-core 随包、ffmpeg 走 imageio-ffmpeg 回退、浏览器自动探测 Chrome/Edge;**不依赖 html-video / anything2explainer 任何代码或目录**。触发场景:要做科普/讲解/教学/知识/产品类视频、"讲一下 X 做成视频"、要用 html-video 那种模板化画面但更稳的音画同步、要挑某种视觉风格(极简/数据/赛博/电影感/品牌)出片、要出抖音封面/竖版封面、anything2explainer 换 HTML 渲染、或提到 html-explainer / HTML 讲解视频 / explainer video / MG 视频。 +agent_created: true +--- + +# html-explainer + +> **定位**:`anything2explainer` 的流程与音画同步 + `html-video` 的 **23 个模板风格库**, +> 渲染层独立实现。作者 **Moh**,MIT 许可(第三方声明见 `THIRD_PARTY_NOTICES.md`)。 +> +> **零外部依赖**:不需要 Remotion/npm 工程,不需要 html-video 仓库/Studio/pnpm/agent 后端。 +> 技能自带:GSAP(离线)、playwright-core、渲染器、TTS/字幕/时间轴/主题/QC/封面全套脚本。 +> 对两个来源项目的引用**只存在于注释署名里**,运行时不读它们的任何文件。 +> 风格库是**抄下来的设计规范文本**(`references/style-catalog.md`),来源 `html-video`(Apache-2.0), +> 类比:把菜谱抄回家,之后做菜不需要原餐厅营业。 + +把一个主题做成**原创**讲解视频:任意风格的 MG 画面(HTML/CSS/GSAP,1920×1080 或竖版)、 +配音(edge-tts)、词级对齐硬字幕、全局进度条。一句话一个场景,画面节拍直接锚在 +吐字时刻上(`B('块文本')` 节拍器)。 + +## ★ 画面风格库(23 个模板 / 8 个类别) + +**不要自己从零想画面 —— 先从风格库里挑。** 完整目录(含每种的画布/字体/时间轴/配色纪律) +见 **`references/style-catalog.md`**;怎么改编成合规帧见 **`references/template-guide.md`**。 + +| 类别 | 可用风格 | +|---|---| +| 演示 / 标题卡 | 大胆海报帧、大胆信号卡帧、奢华极简留白帧、创意电压分屏帧、电光工作室分屏帧、故障艺术标题帧、Kinetic Type、Swiss Grid、Warm Grain | +| 数据可视化 | NYT 风数据图表帧、数据滚动帧、NYT Graph、瑞士网格数据帧 | +| 图解 / 流程 | 东方柔和有机帧、Decision Tree | +| 氛围 / 空镜 | 胶片漏光电影帧 | +| 营销 / Hero | 流体背景 Hero 帧 | +| 片头片尾 | 品牌 Logo 收尾帧 | +| 社媒竖版 | Play Mode、Vignelli(9:16)| +| 产品演示 | Product Promo、Product Promo · 30s | +| 特效 | VFX 文字光标 | + +**两类模板、两种改编成本**(速查表「类型」列): + +| 类型 | 数量 | 处理 | +|---|---|---| +| **★ rich** | 12 | 单文件 + 纯 CSS `@keyframes`。**零改动可渲染** —— 只需①换系统字体栈(删 Google Fonts)②让出底部字幕带③填真实内容 | +| **gsap** | 11 | 多 composition + CDN GSAP(或 Remotion)。**不要搬代码**,只取视觉 DNA 用 CSS keyframes 重写(搬进来会得到静止首帧且零报错,见 `lessons.md` #9)| + +> 时长档要匹配内容:`frame-bold-signal` 是 3–6s 的短片花,拉长到 20s 会空。 +> 长段(>10s)优先选 3–30s 档的(Swiss Grid / Kinetic Type / NYT Graph / Warm Grain)。 + +## 何时用 / 不用 + +- 用:给主题/文章/文档做讲解视频;要挑某种视觉风格出片;要 html-video 那种模板化画面但要求音画稳;要 anything2explainer 流程但不想装 Remotion。 +- 不用:复刻现有视频、真人口播、实拍为主;要 Remotion/React 代码动画本体(那是 anything2explainer 的领域)。 + +## 环境(首台机器跑一次) + +```bash +bash /setup_env.sh # 自检;--install 联网补装 +``` + +依赖:Python≥3.9(edge-tts==7.2.8 钉死 / numpy / pillow / imageio-ffmpeg)、Node≥18、 +Chrome 或 Edge(几乎必有;都没有才下载 playwright chromium ~115MB)、ffmpeg(PATH 或 +imageio-ffmpeg 静态二进制自动回退)。Windows 注意:项目路径全 ASCII;给 Node/Python +传 `C:/...` 正斜杠路径;别用 heredoc 给 Python 传正则。 + +## 命令流水线(项目目录内,顺序不能乱) + +```bash +PY= # 派子 agent 时必须展开成绝对路径写进 prompt +"$PY" /scripts/new_project.py --topic "主题" # 阶段 0 建项目 +"$PY" /scripts/tts_build.py --project . # 配音:audio/*.mp3 + manifest +"$PY" /scripts/timeline_build.py --project . # 全局时间轴:layout.json + narration-full.mp3 +"$PY" /scripts/subs.py --project . # 字幕:subs.json + beats.js + srt/vtt +"$PY" /scripts/lint_frames.py --project . # 静态体检:八条契约违规(渲染前一秒出结果,比渲完再发现便宜得多) +node /scripts/render_video.mjs . [--preview 30] [--keep-frames] # 渲染:out/.mp4 +"$PY" /scripts/qc_check.py --project . # 体检 + 抽帧速览图 +node /scripts/cover_build.mjs . # 封面双方案:out/cover_169.png + cover_34.png +``` + +辅助工具(随时可用,不进主流水线): + +```bash +"$PY" /scripts/make_theme.py --topic "医疗" --use # 主题换色(只重写 theme.css,帧零改动) +node /scripts/peek_frame.mjs . <帧id> --at 40,80 # 单帧速览:秒级出图,先看设计对不对 +# ★ peek_frame 的 --at 是**百分比**不是帧号;查冷开场空屏要传 --at 1,3,6 这种小百分数 +``` + +顺序不能乱:tts → timeline → subs(beats 依赖前两者)→ lint → 渲染 → QC → 封面。改解说词 → 重跑前三条, +`frames/.beats.js` 自动刷新,**场景 HTML 一行不用改**(这是对 anything2explainer +「帧号硬编码、改一个字全片重对位」的结构性改进)。 + +> **`--preview` 会删掉渲出来的帧**(设计上是「预览模式顺手清草稿」)。凡是要拿 PNG 做 +> 拼接 / 复核 / 只重渲单场,一律加 `--keep-frames`,否则帧目录会在合成后被清空(lessons #32)。 +> 只改了一两个场景的画面时,**不要全片重渲**:用「临时项目法」按全局帧号补渲再贴回, +> 本片 3802 帧全重渲 924s,补渲 hook+outro 两段只花 220s(lessons #33)。 + +## ★ 封面双方案(成片后必做,不是可选项) + +封面决定点击率,成片决定完播率 —— 一张被切掉半个钩子的封面会让整片白做。 + +| 规格 | 画布 | 输出 | 用途 | +|---|---|---|---| +| **A. 抖音主封面** | 1920×1080 | `out/cover_169.png` | 信息流 / 播放页 | +| **B. 兼容 3:4** | 1440×1080 | `out/cover_34.png` | 主页栅格(防切字) | + +**核心纪律:两张是独立排版,不是裁切关系。** +从 16:9 居中裁 3:4 只剩 810px 宽,丢掉 **57.8%** 画面,大字钩子必被切。 +所以两份共享同一套视觉基因(配色/幕底/主视觉/钩子文案),**各自重排一次版**: + +| 元素 | 16:9 版 | 3:4 版 | +|---|---|---| +| 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 | +| 钩子 | 左下,两行 132px | 下方,三行 118px | +| 角标 | 左上 | 顶部居中 | +| 每行字数 | ≤7 字(防孤字断行) | ≤5 字 | + +**封面三要素**(缺一返工):① 大字钩子(≥96px/≥120px,含反差悬念) +② 核心悖论视觉(两个对数 / 一升一降 / 分裂线 / 一明一暗,不是装饰图形) +③ 信息余量(四边 ≥96px/≥110px,无贴边文字)。 + +做法:复制 `assets/cover-template.html` 为 `frames/cover_169.html` 与 `frames/cover_34.html`, +各自排版 → `node scripts/cover_build.mjs .`(默认 seek 到时间轴末尾取完整态,`--at 0.8` 可取入场中间态)。 +详规见 **`references/cover-guide.md`**。 + + +## 核心机制(为什么音画稳) + +| 机制 | 口径 | +|---|---| +| 帧时长 | MP3 **容器时长**(tts_build 裁首尾静音后回填)。词边界时长每段少 ~0.86s,用它必错位 | +| 末块字幕收尾 | 语音**真实结束**(speech_end_sec)—— 两级时钟,混用则「字幕过了语音还没过」 | +| 字幕节拍 | edge-tts **WordBoundary** 首字对帧号,绝不按字数插值(中文同字数时长差 3 倍) | +| 画面节拍 | 场景 HTML 里 `B('块文本')` 取该词起播秒排 GSAP —— 画面与吐字同源 | +| 渲染 | **确定性 seek**:`tl.pause(t, false)` + CSS 动画 `currentTime=t×1000` → 截图 → ffmpeg 合成。无实时录制,html-video 的引导期/起播/字体坑整类不存在。**第二参必须传 `false`**,少了它 `onUpdate` 类回调被静默抑制(数字滚动恒为初值,见 lessons #27) | +| 字幕层/进度条 | 渲染器注入并逐帧驱动(`#mg-subs` 44px 白字黑边 bottom 96px;`#mg-progress` accent 填充),帧作者零负担 | + +## 流程(四个确认点必须停下等用户回话) + +完整版见 `references/workflow-guide.md`(含研究员/构建/QC agent 的 prompt 模板与时长档位表)。 + +0. **建项目**(5 分钟):`new_project.py` + 定主题(`make_theme.py --topic/--preset … --use`,颜色全在 theme.css 的 CSS 变量里,画面代码禁止色值字面量) +1. **调研**(20 分钟,1 agent):research/调研.md,每个数字带 URL;**确认点 1**(时长/语言)并行问 +2. **解说词**(30 分钟):narration.json(`|` 切字幕块,中文 ≤16 字/块)→ 填 order → **确认点 2**(文案定稿)→ **确认点 3**(TTS 偏好)→ tts/timeline/subs 三连 → 核对时长区间(差 >15% 改句子,别改语速硬凑)→ **定稿后不改词** +3. **分镜(含选风格,20 分钟)**:script/storyboard.md,每场景一行 —— + **先从风格库挑风格**(`references/style-catalog.md`,按「挑风格的实用建议」表匹配内容类型, + 并核对时长档),再写画面/主角·尺寸/光/B() 锚点;末尾全局约束 + (贯穿示例、事实清单、每章 1–2 高光时刻、每章 ≥3 运镜)。 + 全片建议 2–4 种风格轮换,避免 8 个场景全用同一个模板。 +4. **场景构建(并行)**:每组 4–8 场景一个 agent(一波 ≤3–4 个)。 + - 挑中的 **rich** 模板:从它的 `source/index.html` 改写 —— ①删 Google Fonts 换系统栈 + ②底部元素抬到 ≥176px ③填真实内容(照 `example.md` 的字段) + - 挑中的 **gsap** 模板:**别搬代码**,只照风格规范用 CSS keyframes 重写 + - 全新画面:从 `frames/_template.html` 复制,契约见 `references/frame-contract.md` + - 改编细则见 `references/template-guide.md`;边做边写盘 + - 想「照镜子」不必渲全片:`node scripts/peek_frame.mjs <项目> --at 40,80` 秒级出图 +5. **静态体检**:`lint_frames.py --project .` 把八条契约违规钉在渲染之前(外链字体、色值字面量、 + 墙钟逻辑、`B()|| N` 兜底、字幕带压内容、缺中文字体族…);FAIL 清零再进渲染。 +6. **打样**:`render_video.mjs . --preview 30` → **确认点 4**(风格/字号/语速/节奏一次定稿)→ 全片渲染 + qc_check +7. **QC**:qc_report.md 的 FAIL 清零 + qc_sheet.jpg 肉眼过(字幕带 80–170px 无内容、一焦点、光跟主角)→ 按组修复 → 重渲 +8. **封面双方案**:做 `frames/cover_169.html` + `cover_34.html`(独立排版)→ `node scripts/cover_build.mjs .` + → 核对 `out/cover_report.md` + `references/cover-guide.md` 的七条自检清单 +9. **交付**:mp4 + 两张封面 + srt/vtt(上传平台=可检索文本)+ 发布说明(硬字幕→关平台自动字幕; + AI 配音→勾 AIGC;封面文字须与视频首帧钩子同义;受监管题材过合规) + +## 场景契约速查(完整版 references/frame-contract.md) + +八条:1920×1080 系统字体(禁外部字体)→ 颜色只取 theme.css 变量 → 动画只用 GSAP +(`window.__tl` 注册,禁 CSS transition 入场;@keyframes 循环装饰可用,渲染器会 seek)→ +节拍用 B() → 字幕带(80–170px)与进度条带(0–12px)不放内容 → 一场景一焦点 +(主角 ≥170px 或大字 ≥96px 带 accent 柔光,配角不发光,文字 ≥22px)→ +GSAP 用 `../assets/gsap.min.js`(本地内置)→ 主体动画压在 speech_end 前。 + +## 质量标尺 + +- 画面:每帧一个焦点,主角带光;accent 只给当前重点;背景只有幕底+网格,无碎屑 +- 节拍:元素出现落在对应字幕块起始 ±0.2s 内(B() 天然保证);每句至少一处可察觉变化 +- 字幕:中文 ≤16 字/块、无标点、单帧硬切、每块 ≥0.6s;末块跟着语音消失 +- 事实:画面数字/术语/年份逐个对调研 URL;示例数据标「示意」 +- 时长:落在确认点 1 区间内;成片与音轨差 <0.5s(qc_check 把关) + +## 关键文件 + +**流水线脚本**(按执行顺序) + +| 路径 | 作用 | +|---|---| +| `scripts/new_project.py` | 阶段 0 脚手架:建目录树 + `project.json` + `narration.json` 占位 + `theme.css` + 两份封面 HTML | +| `scripts/tts_build.py` | edge-tts:缓存/超时/重试/裁静音,manifest 写两级时长 | +| `scripts/timeline_build.py` | layout.json 全局轴 + narration-full.mp3(gap 显式插入) | +| `scripts/subs.py` | 字幕三出口(subs.json / srt+vtt / 画面内层由渲染器注入)+ beats.js 节拍器 | +| `scripts/lint_frames.py` | **渲染前静态体检**:八条契约违规逐条报(外链字体/色值字面量/墙钟/`B()\|\|N`/字幕带压内容/缺中文字体族…) | +| `scripts/render_video.mjs` | 渲染器:浏览器探测 → 逐场景 seek 截图 → ffmpeg 合成;`--preview N` 快样片,`--jpeg` 草稿模式 | +| `scripts/qc_check.py` | 流/时长/音量/抽帧体检 + contact sheet | +| `scripts/cover_build.mjs` | 封面双方案渲染器:`width=1920/1440` 两份独立排版 → 2 倍图;`--at` / `--only` / `--jpg` | + +**辅助工具** + +| 路径 | 作用 | +|---|---| +| `scripts/peek_frame.mjs` | 单帧速览:不渲全片,秒级截某场景的几个时点看图(`--at` 是百分比) | +| `scripts/check_integrity.py` | 仓库自洽性:版本号/风格目录/计数一致性 + 模板外链扫描(CI 与本地都跑) | +| `scripts/make_theme.py` | 4 预设 + 主题词推色 → theme.css(CSS 变量单源) | +| `scripts/import_styles.py` | (移植期一次性工具)把已装 html-video 的设计规范抄成纯文本风格目录;**跑视频永不需要它** | +| `setup_env.sh` | 环境自检 / `--install` 联网装缺项 | +| `package_skill.py` | 打成可移植 zip(`--with-deps` 含 node_modules) | + +**参考文档与资源** + +| 路径 | 作用 | +|---|---| +| `references/style-catalog.md` | **23 个画面风格目录**(画布/字体/时间轴/配色纪律 + rich/gsap 分类)——纯知识,非代码依赖 | +| `references/style-catalog.json` | 同上的机器可读版(`kf`/`multi`/`engine` 字段用于自动判类型) | +| `references/template-guide.md` | 模板改编指南(rich 三步法 / gsap 重写法 / 挑风格建议) | +| `references/frame-contract.md` | 契约细则 + 版式基因 + 反例 | +| `references/cover-guide.md` | **封面双方案详规**:重排对照表 / 三要素 / 尺寸倍率 / 上传策略 / 七条自检 | +| `references/workflow-guide.md` | 阶段详解 + agent prompt 模板 + 时长档位表 | +| `references/lessons.md` | 踩坑台账(继承 14 条 + 本技能记录,持续追加) | +| `assets/frame-template.html` | 场景模板(契约注释在文件头,B() 用法示例) | +| `assets/cover-template.html` | **封面模板**(封面三要素注释在文件头,可改尺寸复用为两份) | +| `assets/gsap.min.js` | GSAP 3.13 本地内置(离线渲染;License 见同目录 `gsap-README.md`) | +| `README.md` / `README.en.md` | 对外项目说明(中文为主,英文副) | +| `CHANGELOG.md` | 版本变更史 | +| `THIRD_PARTY_NOTICES.md` | 第三方组件与衍生内容的授权声明(**发布前必读**) | + +## 打包移植 + +技能目录自包含,不引用本机任何绝对路径(脚本按自身位置定位 `SKILL_ROOT`)。 + +**零依赖声明(重要)**:本技能**不需要** html-video 或 anything2explainer 存在。 +两个来源项目的名字只出现在:① 代码注释的出处署名 ② `scripts/import_styles.py` +这个**移植期一次性工具**的用法说明里。跑一条视频(tts → timeline → subs → render → +qc → cover)**完全不碰这两个项目**。风格库是抄成纯文本的设计规范 +(`references/style-catalog.md`),已随技能打包。 + +```bash +# ① 打包(默认不含 node_modules,只有 ~110KB) +python package_skill.py # → dist/html-explainer-v<版本>.zip +python package_skill.py --with-deps # 含 playwright-core,~14MB(目标机全程离线) + +# ② 新机器:解压到任意目录(推荐 ~/.workbuddy/skills/) +bash setup_env.sh --install # 自检 + 装缺项(先装后判定,装成功即算就绪) + +# ③ 跑一遍端到端(可选,验证链路) +python scripts/new_project.py demo --topic "人工智能" +# …填 narration.json / 写 frames/*.html / 填 project.json.order… +python scripts/tts_build.py --project . && python scripts/timeline_build.py --project . +python scripts/subs.py --project . && node scripts/render_video.mjs . +python scripts/qc_check.py --project . && node scripts/cover_build.mjs . +``` + +**依赖探测顺序**(都尽量用系统已有的,避免下载): +- Python:先找托管 venv,再 `python3` / `python` +- Node:`node -v` ≥18;没有则扫 `~/.workbuddy/binaries/node/versions/*` +- 浏览器:Chrome → Edge → playwright chromium → `BROWSER_PATH` 环境变量 +- ffmpeg:PATH → `imageio_ffmpeg.get_ffmpeg_exe()`(pip 装依赖时自带静态二进制) +- GSAP:包内 `assets/gsap.min.js`(离线,不外链) + +已验证:把 zip 解压到干净目录、只跑 `setup_env.sh --install`,**全链路输出与原目录逐帧一致** +(同 346 帧、同抽帧体积、同音画差 0.05s)。 + +## 许可与致谢 + +- 本技能**原创代码与文档**:MIT © 2026 **Moh**(见 `LICENSE`)。 +- **画面风格目录**(`references/style-catalog.md` / `.json`)由 [nexu-io/html-video](https://github.com/nexu-io/html-video) + (Apache-2.0)的模板设计规范转写而来,已保留署名;转写文本按 Apache-2.0 分发。 +- **打包内置**:GSAP 3.13(GreenSock 标准「no charge」许可,见 `assets/gsap-README.md`)、 + playwright-core(Apache-2.0)。 +- 完整清单与逐条授权见 **`THIRD_PARTY_NOTICES.md`**。发布/再分发前请连同该文件一起带上。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..28a7133 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,172 @@ +# Third-Party Notices + +`html-explainer` is MIT-licensed (see [LICENSE](LICENSE)). That license covers the +**original code and documentation** authored for this project. + +It does **not** cover the third-party components and derived content listed below. +Those remain under their own terms, reproduced here as required. + +> **If you redistribute this project (fork, zip, package, mirror, or ship it inside a +> product), you must carry this file along with it.** + +--- + +## 1. Bundled in this repository + +### GSAP 3.13.0 — `assets/gsap.min.js` + +- **Copyright**: © 2025 GreenSock. All rights reserved. Author: Jack Doyle. +- **License**: GreenSock standard "no charge" license — +- **Status**: Bundled verbatim for offline rendering. Not modified. +- **Note**: This is **not** an OSI license. GSAP is free to use, including commercially, + under its standard license terms. The copyright header inside `assets/gsap.min.js` + must not be removed. Upstream README is kept alongside it at + `assets/gsap-README.md`. + +--- + +## 2. Runtime dependencies (installed, not vendored) + +These are declared as dependencies and fetched by `bash setup_env.sh --install`. +They are not committed to this repository. + +| Component | Version (tested) | License | Upstream | +|---|---|---|---| +| `edge-tts` | 7.2.8 (pinned) | LGPL-3.0 | | +| `playwright-core` | 1.63.0 | Apache-2.0 | | +| `numpy` | 2.5.3 | BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 | | +| `pillow` | 12.3.0 | MIT-CMU | | +| `imageio-ffmpeg` | 0.6.0 | BSD-2-Clause | | +| `ffmpeg` (binary) | via `imageio-ffmpeg` | LGPL-2.1+ / GPL-2.0+ (varies by build) | | + +**A note on `edge-tts` (LGPL-3.0) and `ffmpeg`:** these are invoked as **separate +processes / imported as unmodified libraries**; this project does not statically link +them or ship modified copies. If you plan to redistribute a *bundled* binary build of +ffmpeg, review FFmpeg's own licensing page — build flags determine whether a given +binary is LGPL or GPL. + +`edge-tts` is pinned to `7.2.8` intentionally: the 7.x series has had breaking changes +in its speech-boundary API, and this project depends on `WordBoundary` events. + +--- + +## 3. Derived content — the style catalog + +`references/style-catalog.md` and `references/style-catalog.json` are a **transcription +of third-party template design specifications** — canvas size, typography, timeline +structure, colour discipline — for 23 named styles. No template source code, markup, or +asset from any upstream is included. Only factual design parameters and style names are +recorded, so that a compatible frame can be authored from scratch. + +This catalog was produced by `scripts/import_styles.py`, which reads an installed copy of +html-video and writes the specification out as plain text. **It is a one-time porting +tool.** Running an html-explainer video never reads html-video, and no html-video code is +executed or linked at any point. + +### 3.1 `nexu-io/html-video` — 23 templates + +- **Copyright**: the html-video authors (Open Design / nexu-io) +- **License**: Apache-2.0 +- **Source**: +- **What was derived**: design specifications and style identifiers for all 23 templates + (`frame-bold-poster` … `vfx-text-cursor`), transcribed into `references/style-catalog.*`. +- **Changes made**: specifications reformatted into a flat Markdown/JSON catalog; each + template classified as `rich` (single-file CSS `@keyframes`) or `gsap` (multi-composition); + annotated with adaptation cost and subtitle-safe-zone constraints. Original template + code, compositions, and assets were **not** copied. +- Apache-2.0 requires that recipients of a derivative work receive a copy of the license. + A verbatim copy is included at [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt). + The upstream repository ships no `NOTICE` file, so no `NOTICE` propagation applies + (Apache-2.0 §4(d)). + +### 3.2 Upstreams of the html-video templates + +`references/style-catalog.json` records a `via` field per style, preserving the +provenance that html-video attaches to each of its templates. Seven of the 23 styles +trace back to an earlier MIT-licensed project. Those notices are reproduced here because +this project's catalog describes the same styles. + +Exact mapping, as recorded in the catalog: + +#### `frontend-slides` — 4 styles + +- **Copyright**: Zara Zhang © 2025 +- **License**: MIT +- **Source**: +- **Styles**: `frame-bold-poster`, `frame-bold-signal`, `frame-creative-voltage`, + `frame-electric-studio` + +#### `huashu-design` — 3 styles + +- **Copyright**: alchaincyf (花叔 · 花生) © 2026 +- **License**: MIT +- **Source**: +- **Styles**: `frame-build-minimal`, `frame-pentagram-stat`, `frame-takram-organic` + +#### `hyperframes-student-kit` (Nate Herk) — 1 style + +- **Copyright**: Nate Herk +- **License**: MIT (as recorded in the catalog's `license` field for this entry) +- **Styles**: `frame-product-promo-30s` — recorded upstream as a fork of + `linear-promo-30s`, with brand-specific copy and assets replaced by generic + placeholders. + +html-video's own `ATTRIBUTIONS.md` documents the first two upstreams and states its rule +for all such templates: static slide designs were re-expressed as original CSS/SVG +keyframe timelines, with new sample data and no verbatim source copying, and the studio +names attached to some styles (Pentagram, Build, Takram) are recorded as **inspiration +only, never as affiliation or endorsement**. This project inherits that position. + +MIT License notice for all three upstreams above: + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### 3.3 Studio names — inspiration only, no affiliation + +Some style names reference real design studios or publications as a record of stylistic +inspiration (Pentagram, Build, Takram, NYT, Vignelli, Swiss Grid). These are **factual +statements of inspiration only**. This project — and html-video before it — is **not +affiliated with, endorsed by, or sponsored by** any of them. No third-party logo, +wordmark, typeface file, or branded asset is included; all frames are authored from +scratch with system fonts. + +--- + +## 4. Design lineage of the workflow + +The narration/audio-sync methodology (word-boundary subtitles, two-level clock, speech-rate +calibration, QC criteria) was developed in the author's earlier private projects, +`anything2explainer` and `html-video-workbuddy-driver`. Both are the author's own work. +No code from either is shipped or required at runtime; the names appear only in comments +and historical notes. + +--- + +## Summary for redistributors + +| Must ship with your copy | Why | +|---|---| +| `LICENSE` | MIT terms for the original code | +| `THIRD_PARTY_NOTICES.md` (this file) | Apache-2.0 §4 + MIT notice retention | +| `licenses/Apache-2.0.txt` | Apache-2.0 §4(a) requires a copy of the license | +| `assets/gsap-README.md` | GSAP license reference; keep the header in `gsap.min.js` intact | + +*Last reviewed: 2026-09-21.* diff --git a/assets/cover-template.html b/assets/cover-template.html new file mode 100644 index 0000000..f104ef9 --- /dev/null +++ b/assets/cover-template.html @@ -0,0 +1,158 @@ + + + + +封面模板 · 复制为 frames/cover_169.html 与 frames/cover_34.html + + + + + +
+
+
+
+ +
栏目名 · 期号
+ + +
+
+ 一边 + +38% + ↑ +
+
+ 另一边 + -12% + ↓ +
+
+
+ + +
+
开场 · 20XX
+
+ 同一件事
为什么两拨人的感受完全相反 +
+
+ + + + + + diff --git a/assets/frame-template.html b/assets/frame-template.html new file mode 100644 index 0000000..3dd4150 --- /dev/null +++ b/assets/frame-template.html @@ -0,0 +1,179 @@ + + + + +场景模板 · <id>(复制为 frames/<id>.html 后改内容) + + + + + +
+
+ +
01 · 开场
+ +
+
+
左边卡片 · 中性
+
概念A
+
配角不发光 · 灰系表达「未激活」
+
+
+
右边卡片 · 主题色
+
概念B
+
主角带柔光 · accent 只给当前重点
+
+
+ +
+
结论句 · 关键词用 主题色 强调
+
+ +
+
佐证
+
证据/数字条 —— 事实必须有调研出处(research/调研.md 带 URL)
+
+ + + + + + + + diff --git a/assets/gsap-README.md b/assets/gsap-README.md new file mode 100644 index 0000000..6578f24 --- /dev/null +++ b/assets/gsap-README.md @@ -0,0 +1,93 @@ +# GSAP (GreenSock Animation Platform) + +[![GSAP - Animate anything](https://gsap.com/GSAP-share-image.png)](http://gsap.com) + +GSAP is a **framework-agnostic** JavaScript animation library that turns developers into animation superheroes. Build high-performance animations that work in **every** major browser. Animate CSS, SVG, canvas, React, Vue, WebGL, colors, strings, motion paths, generic objects...anything JavaScript can touch! GSAP's ScrollTrigger plugin delivers jaw-dropping scroll-based animations with minimal code. gsap.matchMedia() makes building responsive, accessibility-friendly animations a breeze. + +No other library delivers such advanced sequencing, reliability, and tight control while solving real-world problems on over 12 million sites. GSAP works around countless browser inconsistencies; your animations ***just work***. At its core, GSAP is a high-speed property manipulator, updating values over time with extreme accuracy. It's up to 20x faster than jQuery! + +GSAP is completely flexible; sprinkle it wherever you want. **Zero dependencies.** + +There are many optional plugins and easing functions for achieving advanced effects easily like scrolling, morphing, [text splitting](https://gsap.com/docs/v3/Plugins/SplitText), animating along a motion path or FLIP animations. There's even a handy Observer for normalizing event detection across browsers/devices. + + +### Get Started + +[![Get Started with GSAP](http://gsap.com/_img/github/get-started.jpg)](http://gsap.com/get-started) + + +## Docs & Installation + +View the full documentation here, including an installation guide. + +### CDN + +```html + +``` + +See JSDelivr's dedicated GSAP page for quick CDN links to the core files/plugins. There are more installation instructions at gsap.com. + +**Every major ad network excludes GSAP from file size calculations** and most have it on their own CDNs, so contact them for the appropriate URL(s). + +### NPM +See the guide to using GSAP via NPM here. + +```javascript +npm install gsap +``` + +GSAP's core can animate almost anything including CSS and attributes, plus it includes all of the utility methods like interpolate(), mapRange(), most of the eases, and it can do snapping and modifiers. + +```javascript +// typical import +import gsap from "gsap"; + +// get other plugins: +import ScrollTrigger from "gsap/ScrollTrigger"; +import Flip from "gsap/Flip"; +import Draggable from "gsap/Draggable"; + +// or all tools are exported from the "all" file (excluding members-only plugins): +import { gsap, ScrollTrigger, Draggable, MotionPathPlugin } from "gsap/all"; + +// don't forget to register plugins +gsap.registerPlugin(ScrollTrigger, Draggable, Flip, MotionPathPlugin); +``` + +The NPM files are ES modules, but there's also a /dist/ directory with UMD files for extra compatibility. + +## GSAP is FREE! + +Thanks to [Webflow](https://webflow.com), GSAP is now **100% FREE** including ALL of the bonus plugins like [SplitText](https://gsap.com/docs/v3/Plugins/SplitText), [MorphSVG](https://gsap.com/docs/v3/Plugins/MorphSVGPlugin), and all the others that were exclusively available to Club GSAP members. That's right - the entire GSAP toolset is FREE, even for commercial use! 🤯 Read more [here](https://webflow.com/blog/gsap-becomes-free) + +### ScrollTrigger & ScrollSmoother + +If you're looking for scroll-driven animations, GSAP's ScrollTrigger plugin is the standard. There's a companion ScrollSmoother as well. + +[![ScrollTrigger](http://gsap.com/_img/github/scrolltrigger.jpg)](https://gsap.com/docs/v3/Plugins/ScrollTrigger) + +### Using React? + +There's a @gsap/react package that exposes a `useGSAP()` hook which is a drop-in replacement for `useEffect()`/`useLayoutEffect()`, automating cleanup tasks. Please read the React guide for details. + +### Resources + +* gsap.com +* Getting started guide +* Docs +* Demos & starter templates +* Community forums +* Ease Visualizer +* Showcase +* YouTube Channel +* Cheat sheet +* Webflow + +### Need help? +Ask in the friendly GSAP forums. Or share your knowledge and help someone else - it's a great way to sharpen your skills! Report any bugs there too (or file an issue here if you prefer). + +### License +GreenSock's standard "no charge" license can be viewed at https://gsap.com/standard-license. + +Copyright (c) 2008-2025, GreenSock. All rights reserved. \ No newline at end of file diff --git a/assets/gsap.min.js b/assets/gsap.min.js new file mode 100644 index 0000000..3bb5803 --- /dev/null +++ b/assets/gsap.min.js @@ -0,0 +1,11 @@ +/*! + * GSAP 3.13.0 + * https://gsap.com + * + * @license Copyright 2025, GreenSock. All rights reserved. + * Subject to the terms at https://gsap.com/standard-license. + * @author: Jack Doyle, jack@greensock.com + */ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function P(t){return(i=yt(t,ot))&&ze}function Q(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function R(t,e){return!e&&console.warn(t)}function S(t,e){return t&&(ot[t]=e)&&i&&(i[t]=e)||ot}function T(){return 0}function ea(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=gt.length;r--&&!gt[r].targetTest(i););e=gt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Zt(t[r],e)))||t.splice(r,1);return t}function fa(t){return t._gsap||ea(Ot(t))[0]._gsap}function ga(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ha(t,e){return(t=t.split(",")).forEach(e)||t}function ia(t){return Math.round(1e5*t)/1e5||0}function ja(t){return Math.round(1e7*t)/1e7||0}function ka(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function la(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++ia;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function za(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function Aa(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Ba(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Da(t,e,r,i){return t._startAt&&(L?t._startAt.revert(ht):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Fa(t){return t._repeat?Tt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ha(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ia(t){return t._end=ja(t._start+(t._tDur/Math.abs(t._ts||t._rts||U)||0))}function Ja(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=ja(r._time-(0U)&&e.render(r,!0)),Ba(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur(n=Math.abs(n))&&(a=i,o=n);return a}function ub(t){return Aa(t),t.scrollTrigger&&t.scrollTrigger.kill(!!L),t.progress()<1&&Pt(t,"onInterrupt"),t}function xb(t){if(t)if(t=!t.name&&t.default||t,x()||t.headless){var e=t.name,r=s(t),i=e&&!r&&t.init?function(){this._props=[]}:t,n={init:T,render:ue,add:Vt,kill:de,modifier:he,rawVars:0},a={targetTest:0,get:0,getSetter:ie,aliases:{},register:0};if(Ft(),t!==i){if(pt[e])return;ra(i,ra(va(t,n),a)),yt(i.prototype,yt(n,va(t,a))),pt[i.prop=e]=i,t.targetTest&&(gt.push(i),ft[e]=1),e=("css"===e?"CSS":e.charAt(0).toUpperCase()+e.substr(1))+"Plugin"}S(e,i),t.register&&t.register(ze,i,ge)}else Ct.push(t)}function Ab(t,e,r){return(6*(t+=t<0?1:1>16,e>>8&St,e&St]:0:Dt.black;if(!p){if(","===e.substr(-1)&&(e=e.substr(0,e.length-1)),Dt[e])p=Dt[e];else if("#"===e.charAt(0)){if(e.length<6&&(e="#"+(n=e.charAt(1))+n+(a=e.charAt(2))+a+(s=e.charAt(3))+s+(5===e.length?e.charAt(4)+e.charAt(4):"")),9===e.length)return[(p=parseInt(e.substr(1,6),16))>>16,p>>8&St,p&St,parseInt(e.substr(7),16)/255];p=[(e=parseInt(e.substr(1),16))>>16,e>>8&St,e&St]}else if("hsl"===e.substr(0,3))if(p=c=e.match(tt),r){if(~e.indexOf("="))return p=e.match(et),i&&p.length<4&&(p[3]=1),p}else o=+p[0]%360/360,u=p[1]/100,n=2*(h=p[2]/100)-(a=h<=.5?h*(u+1):h+u-h*u),3=N?u.endTime(!1):t._dur;return r(e)&&(isNaN(e)||e in o)?(a=e.charAt(0),s="%"===e.substr(-1),n=e.indexOf("="),"<"===a||">"===a?(0<=n&&(e=e.replace(/=/,"")),("<"===a?u._start:u.endTime(0<=u._repeat))+(parseFloat(e.substr(1))||0)*(s?(n<0?u:i).totalDuration()/100:1)):n<0?(e in o||(o[e]=h),o[e]):(a=parseFloat(e.charAt(n-1)+e.substr(n+1)),s&&i&&(a=a/100*($(i)?i[0]:i).totalDuration()),1=r&&te)return i;i=i._next}else for(i=t._last;i&&i._start>=r;){if("isPause"===i.data&&i._start=n._start)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0=this.totalDuration()||!v&&_)&&(f!==this._start&&Math.abs(l)===Math.abs(this._ts)||this._lock||(!t&&g||!(v===m&&0=i&&(a instanceof Jt?e&&n.push(a):(r&&n.push(a),t&&n.push.apply(n,a.getChildren(!0,e,r)))),a=a._next;return n},e.getById=function getById(t){for(var e=this.getChildren(1,1,1),r=e.length;r--;)if(e[r].vars.id===t)return e[r]},e.remove=function remove(t){return r(t)?this.removeLabel(t):s(t)?this.killTweensOf(t):(t.parent===this&&za(this,t),t===this._recent&&(this._recent=this._last),Ba(this))},e.totalTime=function totalTime(t,e){return arguments.length?(this._forcing=1,!this._dp&&this._ts&&(this._start=ja(Rt.time-(0r:!r||s.isActive())&&n.push(s):(i=s.getTweensOf(a,r)).length&&n.push.apply(n,i),s=s._next;return n},e.tweenTo=function tweenTo(t,e){e=e||{};var r,i=this,n=xt(i,t),a=e.startAt,s=e.onStart,o=e.onStartParams,u=e.immediateRender,h=Jt.to(i,ra({ease:e.ease||"none",lazy:!1,immediateRender:!1,time:n,overwrite:"auto",duration:e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale())||U,onStart:function onStart(){if(i.pause(),!r){var t=e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale());h._dur!==t&&Sa(h,t,0,1).render(h._time,!0,!0),r=1}s&&s.apply(h,o||[])}},e));return u?h.render(0):h},e.tweenFromTo=function tweenFromTo(t,e,r){return this.tweenTo(e,ra({startAt:{time:xt(this,t)}},r))},e.recent=function recent(){return this._recent},e.nextLabel=function nextLabel(t){return void 0===t&&(t=this._time),sb(this,xt(this,t))},e.previousLabel=function previousLabel(t){return void 0===t&&(t=this._time),sb(this,xt(this,t),1)},e.currentLabel=function currentLabel(t){return arguments.length?this.seek(t,!0):this.previousLabel(this._time+U)},e.shiftChildren=function shiftChildren(t,e,r){void 0===r&&(r=0);for(var i,n=this._first,a=this.labels;n;)n._start>=r&&(n._start+=t,n._end+=t),n=n._next;if(e)for(i in a)a[i]>=r&&(a[i]+=t);return Ba(this)},e.invalidate=function invalidate(t){var e=this._first;for(this._lock=0;e;)e.invalidate(t),e=e._next;return i.prototype.invalidate.call(this,t)},e.clear=function clear(t){void 0===t&&(t=!0);for(var e,r=this._first;r;)e=r._next,this.remove(r),r=e;return this._dp&&(this._time=this._tTime=this._pTime=0),t&&(this.labels={}),Ba(this)},e.totalDuration=function totalDuration(t){var e,r,i,n=0,a=this,s=a._last,o=N;if(arguments.length)return a.timeScale((a._repeat<0?a.duration():a.totalDuration())/(a.reversed()?-t:t));if(a._dirty){for(i=a.parent;s;)e=s._prev,s._dirty&&s.totalDuration(),o<(r=s._start)&&a._sort&&s._ts&&!a._lock?(a._lock=1,La(a,s,r-s._delay,1)._lock=0):o=r,r<0&&s._ts&&(n-=r,(!i&&!a._dp||i&&i.smoothChildTiming)&&(a._start+=r/a._ts,a._time-=r,a._tTime-=r),a.shiftChildren(-r,!1,-Infinity),o=0),s._end>n&&s._ts&&(n=s._end),s=e;Sa(a,a===I&&a._time>n?a._time:n,1,1),a._dirty=0}return a._tDur},Timeline.updateRoot=function updateRoot(t){if(I._ts&&(oa(I,Ha(t,I)),f=Rt.frame),Rt.frame>=mt){mt+=X.autoSleep||120;var e=I._first;if((!e||!e._ts)&&X.autoSleep&&Rt._listeners.length<2){for(;e&&!e._ts;)e=e._next;e||Rt.sleep()}}},Timeline}(Nt);ra(Qt.prototype,{_lock:0,_hasPause:0,_forcing:0});function bc(t,e,i,n,a,o){var u,h,l,f;if(pt[t]&&!1!==(u=new pt[t]).init(a,u.rawVars?e[t]:function _processVars(t,e,i,n,a){if(s(t)&&(t=Gt(t,a,e,i,n)),!v(t)||t.style&&t.nodeType||$(t)||J(t))return r(t)?Gt(t,a,e,i,n):t;var o,u={};for(o in t)u[o]=Gt(t[o],a,e,i,n);return u}(e[t],n,a,o,i),i,n,o)&&(i._pt=h=new ge(i._pt,a,t,0,1,u.render,u,0,u.priority),i!==d))for(l=i._ptLookup[i._targets.indexOf(a)],f=u._props.length;f--;)l[u._props[f]]=h;return u}function hc(t,r,e,i){var n,a,s=r.ease||i||"power1.inOut";if($(r))a=e[t]||(e[t]=[]),r.forEach(function(t,e){return a.push({t:e/(r.length-1)*100,v:t,e:s})});else for(n in r)a=e[n]||(e[n]=[]),"ease"===n||a.push({t:parseFloat(t),v:r[n],e:s})}var Ut,qt,Vt=function _addPropTween(t,e,i,n,a,o,u,h,l,f){s(n)&&(n=n(a||0,t,o));var d,c=t[e],p="get"!==i?i:s(c)?l?t[e.indexOf("set")||!s(t["get"+e.substr(3)])?e:"get"+e.substr(3)](l):t[e]():c,_=s(c)?l?re:te:$t;if(r(n)&&(~n.indexOf("random(")&&(n=pb(n)),"="===n.charAt(1)&&(!(d=ka(p,n)+(Za(p)||0))&&0!==d||(n=d))),!f||p!==n||qt)return isNaN(p*n)||""===n?(c||e in t||Q(e,n),function _addComplexStringPropTween(t,e,r,i,n,a,s){var o,u,h,l,f,d,c,p,_=new ge(this._pt,t,e,0,1,oe,null,n),m=0,g=0;for(_.b=r,_.e=i,r+="",(c=~(i+="").indexOf("random("))&&(i=pb(i)),a&&(a(p=[r,i],t,e),r=p[0],i=p[1]),u=r.match(it)||[];o=it.exec(i);)l=o[0],f=i.substring(m,o.index),h?h=(h+1)%5:"rgba("===f.substr(-5)&&(h=1),l!==u[g++]&&(d=parseFloat(u[g-1])||0,_._pt={_next:_._pt,p:f||1===g?f:",",s:d,c:"="===l.charAt(1)?ka(d,l)-d:parseFloat(l)-d,m:h&&h<4?Math.round:0},m=it.lastIndex);return _.c=m")}),s.duration();else{for(l in u={},x)"ease"===l||"easeEach"===l||hc(l,x[l],u,x.easeEach);for(l in u)for(C=u[l].sort(function(t,e){return t.t-e.t}),o=z=0;o=t._tDur||e<0)&&t.ratio===u&&(u&&Aa(t,1),r||L||(Pt(t,u?"onComplete":"onReverseComplete",!0),t._prom&&t._prom()))}else t._zTime||(t._zTime=e)}(this,t,e,r);return this},e.targets=function targets(){return this._targets},e.invalidate=function invalidate(t){return t&&this.vars.runBackwards||(this._startAt=0),this._pt=this._op=this._onUpdate=this._lazy=this.ratio=0,this._ptLookup=[],this.timeline&&this.timeline.invalidate(t),E.prototype.invalidate.call(this,t)},e.resetTo=function resetTo(t,e,r,i,n){c||Rt.wake(),this._ts||this.play();var a,s=Math.min(this._dur,(this._dp._time-this._start)*this._ts);return this._initted||Wt(this,s),a=this._ease(s/this._dur),function _updatePropTweens(t,e,r,i,n,a,s,o){var u,h,l,f,d=(t._pt&&t._ptCache||(t._ptCache={}))[e];if(!d)for(d=t._ptCache[e]=[],l=t._ptLookup,f=t._targets.length;f--;){if((u=l[f][e])&&u.d&&u.d._pt)for(u=u.d._pt;u&&u.p!==e&&u.fp!==e;)u=u._next;if(!u)return qt=1,t.vars[e]="+=0",Wt(t,s),qt=0,o?R(e+" not eligible for reset"):1;d.push(u)}for(f=d.length;f--;)(u=(h=d[f])._pt||h).s=!i&&0!==i||n?u.s+(i||0)+a*u.c:i,u.c=r-u.s,h.e&&(h.e=ia(r)+Za(h.e)),h.b&&(h.b=u.s+Za(h.b))}(this,t,e,r,i,a,s,n)?this.resetTo(t,e,r,i,1):(Ja(this,0),this.parent||ya(this._dp,this,"_first","_last",this._dp._sort?"_start":0),this.render(0))},e.kill=function kill(t,e){if(void 0===e&&(e="all"),!(t||e&&"all"!==e))return this._lazy=this._pt=0,this.parent?ub(this):this.scrollTrigger&&this.scrollTrigger.kill(!!L),this;if(this.timeline){var i=this.timeline.totalDuration();return this.timeline.killTweensOf(t,e,Ut&&!0!==Ut.vars.overwrite)._first||ub(this),this.parent&&i!==this.timeline.totalDuration()&&Sa(this,this._dur*this.timeline._tDur/i,0,1),this}var n,a,s,o,u,h,l,f=this._targets,d=t?Ot(t):f,c=this._ptLookup,p=this._pt;if((!e||"all"===e)&&function _arraysMatch(t,e){for(var r=t.length,i=r===e.length;i&&r--&&t[r]===e[r];);return r<0}(f,d))return"all"===e&&(this._pt=0),ub(this);for(n=this._op=this._op||[],"all"!==e&&(r(e)&&(u={},ha(e,function(t){return u[t]=1}),e=u),e=function _addAliasesToVars(t,e){var r,i,n,a,s=t[0]?fa(t[0]).harness:0,o=s&&s.aliases;if(!o)return e;for(i in r=yt({},e),o)if(i in r)for(n=(a=o[i].split(",")).length;n--;)r[a[n]]=r[i];return r}(f,e)),l=f.length;l--;)if(~d.indexOf(f[l]))for(u in a=c[l],"all"===e?(n[l]=e,o=a,s={}):(s=n[l]=n[l]||{},o=e),o)(h=a&&a[u])&&("kill"in h.d&&!0!==h.d.kill(u)||za(this,h,"_pt"),delete a[u]),"all"!==s&&(s[u]=1);return this._initted&&!this._pt&&p&&ub(this),this},Tween.to=function to(t,e,r){return new Tween(t,e,r)},Tween.from=function from(t,e){return Wa(1,arguments)},Tween.delayedCall=function delayedCall(t,e,r,i){return new Tween(e,0,{immediateRender:!1,lazy:!1,overwrite:!1,delay:t,onComplete:e,onReverseComplete:e,onCompleteParams:r,onReverseCompleteParams:r,callbackScope:i})},Tween.fromTo=function fromTo(t,e,r){return Wa(2,arguments)},Tween.set=function set(t,e){return e.duration=0,e.repeatDelay||(e.repeat=0),new Tween(t,e)},Tween.killTweensOf=function killTweensOf(t,e,r){return I.killTweensOf(t,e,r)},Tween}(Nt);ra(Jt.prototype,{_targets:[],_lazy:0,_startAt:0,_op:0,_onInit:0}),ha("staggerTo,staggerFrom,staggerFromTo",function(r){Jt[r]=function(){var t=new Qt,e=kt.call(arguments,0);return e.splice("staggerFromTo"===r?5:4,0,0),t[r].apply(t,e)}});function pc(t,e,r){return t.setAttribute(e,r)}function xc(t,e,r,i){i.mSet(t,e,i.m.call(i.tween,r,i.mt),i)}var $t=function _setterPlain(t,e,r){return t[e]=r},te=function _setterFunc(t,e,r){return t[e](r)},re=function _setterFuncWithParam(t,e,r,i){return t[e](i.fp,r)},ie=function _getSetter(t,e){return s(t[e])?te:u(t[e])&&t.setAttribute?pc:$t},ne=function _renderPlain(t,e){return e.set(e.t,e.p,Math.round(1e6*(e.s+e.c*t))/1e6,e)},se=function _renderBoolean(t,e){return e.set(e.t,e.p,!!(e.s+e.c*t),e)},oe=function _renderComplexString(t,e){var r=e._pt,i="";if(!t&&e.b)i=e.b;else if(1===t&&e.e)i=e.e;else{for(;r;)i=r.p+(r.m?r.m(r.s+r.c*t):Math.round(1e4*(r.s+r.c*t))/1e4)+i,r=r._next;i+=e.c}e.set(e.t,e.p,i,e)},ue=function _renderPropTweens(t,e){for(var r=e._pt;r;)r.r(t,r.d),r=r._next},he=function _addPluginModifier(t,e,r,i){for(var n,a=this._pt;a;)n=a._next,a.p===i&&a.modifier(t,e,r),a=n},de=function _killPropTweensOf(t){for(var e,r,i=this._pt;i;)r=i._next,i.p===t&&!i.op||i.op===t?za(this,i,"_pt"):i.dep||(e=1),i=r;return!e},_e=function _sortPropTweensByPriority(t){for(var e,r,i,n,a=t._pt;a;){for(e=a._next,r=i;r&&r.pr>a.pr;)r=r._next;(a._prev=r?r._prev:n)?a._prev._next=a:i=a,(a._next=r)?r._prev=a:n=a,a=e}t._pt=i},ge=(PropTween.prototype.modifier=function modifier(t,e,r){this.mSet=this.mSet||this.set,this.set=xc,this.m=t,this.mt=r,this.tween=e},PropTween);function PropTween(t,e,r,i,n,a,s,o,u){this.t=e,this.s=i,this.c=n,this.p=r,this.r=a||ne,this.d=s||this,this.set=o||$t,this.pr=u||0,(this._next=t)&&(t._prev=this)}ha(vt+"parent,duration,ease,delay,overwrite,runBackwards,startAt,yoyo,immediateRender,repeat,repeatDelay,data,paused,reversed,lazy,callbackScope,stringFilter,id,yoyoEase,stagger,inherit,repeatRefresh,keyframes,autoRevert,scrollTrigger",function(t){return ft[t]=1}),ot.TweenMax=ot.TweenLite=Jt,ot.TimelineLite=ot.TimelineMax=Qt,I=new Qt({sortChildren:!1,defaults:Z,autoRemoveChildren:!0,id:"root",smoothChildTiming:!0}),X.stringFilter=Gb;function Fc(t){return(be[t]||Me).map(function(t){return t()})}function Gc(){var t=Date.now(),o=[];2=20" + } + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..07e0bb1 --- /dev/null +++ b/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "html-explainer-runtime", + "version": "1.0.0", + "private": true, + "description": "vendored node deps for html-explainer renderer", + "dependencies": { + "playwright-core": "^1.63.0" + } +} diff --git a/package_skill.py b/package_skill.py new file mode 100644 index 0000000..f4d8d62 --- /dev/null +++ b/package_skill.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +package_skill.py —— 把 html-explainer 打包成可带到任意终端的 zip。 + +设计取舍: + · **排除 node/node_modules/**(14MB,且平台相关)—— setup_env.sh --install 会重装。 + 这样 zip 只有几十 KB,纯文本 + 一份 GSAP。 + · **保留 assets/gsap.min.js**(72KB,离线必需,体积可接受)。 + · **保留 README / LICENSE / THIRD_PARTY_NOTICES.md / licenses/** —— 再分发时必须随包 + 带上这些文件(Apache-2.0 §4 要求收到衍生作品的人同时收到许可证副本)。 + · **排除仓库卫生文件**(.github/、.gitignore、.gitattributes)—— 它们属于 GitHub + 仓库,不属于可移植的技能包。 + · 排除运行时产物(out/、audio/、*.beats.js、__pycache__、诊断脚本)。 + +用法: + python package_skill.py # 产出 dist/html-explainer-vX.Y.zip + python package_skill.py --with-deps # 连 node_modules 一起打(完全离线可跑,~14MB) +""" +from __future__ import annotations + +import argparse +import os +import re +import sys +import zipfile +from datetime import date + +SKILL_ROOT = os.path.dirname(os.path.abspath(__file__)) + +# 目录名匹配即整棵排除 +EXCLUDE_DIRS = { + "node_modules", "__pycache__", "dist", "out", "audio", ".git", + ".pytest_cache", "qc_frames", + ".github", # 仓库卫生:CI / issue 模板不属于可移植技能包 + "render", "research", "script", # 项目级产物(防在技能目录内建过 demo) +} +# 文件名 / 后缀排除 +EXCLUDE_SUFFIX = (".pyc", ".pyo", ".beats.js", ".mp4", ".mp3", ".wav", ".log") +EXCLUDE_NAMES = { + "package-lock.json", ".DS_Store", "Thumbs.db", "qc_sheet.jpg", "qc_report.md", + ".gitignore", ".gitattributes", +} + + +def version() -> str: + md = os.path.join(SKILL_ROOT, "SKILL.md") + try: + txt = open(md, encoding="utf-8").read() + m = re.search(r"^version:\s*(\S+)", txt, re.M) + if m: + return m.group(1).strip().strip('"\'') + except OSError: + pass + return "1.0.0" + + +def should_skip(rel: str, with_deps: bool) -> bool: + parts = rel.replace("\\", "/").split("/") + for p in parts[:-1]: + if p in EXCLUDE_DIRS: + if with_deps and p == "node_modules": + continue + return True + name = parts[-1] + if name in EXCLUDE_NAMES: + return True + if name.endswith(EXCLUDE_SUFFIX): + return True + # 临时诊断脚本(下划线开头 .js/.mjs/.py,非技能正式脚本) + if parts[0] == "scripts" and name.startswith("_"): + return True + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--with-deps", action="store_true", + help="把 node/node_modules 一起打包(离线可跑,体积 ~14MB)") + ap.add_argument("--out", default=None) + args = ap.parse_args() + + ver = version() + dist = os.path.join(SKILL_ROOT, "dist") + os.makedirs(dist, exist_ok=True) + tag = "-full" if args.with_deps else "" + out = args.out or os.path.join(dist, f"html-explainer-v{ver}{tag}.zip") + + count = 0 + total = 0 + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: + for root, dirs, files in os.walk(SKILL_ROOT): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS or + (args.with_deps and d == "node_modules")] + for f in files: + full = os.path.join(root, f) + rel = os.path.relpath(full, SKILL_ROOT) + if should_skip(rel, args.with_deps): + continue + arc = os.path.join("html-explainer", rel.replace("\\", "/")) + z.write(full, arc) + count += 1 + total += os.path.getsize(full) + + size = os.path.getsize(out) + print(f"✓ 打包完成:{out}") + print(f" 文件 {count} 个 · 源 {total / 1024:.0f}KB · zip {size / 1024:.0f}KB" + f"(压缩率 {100 - size * 100 / max(total, 1):.0f}%)") + if not args.with_deps: + print(" 注:未含 node/node_modules —— 目标机首次跑 `bash setup_env.sh --install` 装齐") + print() + print(" 带到新终端后:") + print(" 1. 解压到 ~/.workbuddy/skills/ (或任意位置,脚本按自身路径定位)") + print(" 2. bash setup_env.sh --install") + print(" 3. python scripts/new_project.py && 编辑 narration.json …") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/references/cover-guide.md b/references/cover-guide.md new file mode 100644 index 0000000..9f47c7e --- /dev/null +++ b/references/cover-guide.md @@ -0,0 +1,147 @@ +# 封面指南(cover-guide) + +成片出来后**必须同时出两张封面**。这不是可选步骤——封面决定点击率,而成片 +只决定完播率;一张被切掉半个钩子的封面会让整片白做。 + +## 一、为什么是「两张独立排版」而不是「一张裁两版」 + +成片画布 1920×1080。平台上主页栅格按 **3:4** 裁: + +| 从 16:9 裁 3:4 的算法 | 结果 | +|---|---| +| `h=1080` → `w=810` | 丢掉 **1110px = 57.8%** 的画面宽度 | +| 钩子如果横跨全宽(大字常常如此) | **必被切**,只留中间一截 | + +所以「兼容 3:4 的版本」定义是:**同一套视觉基因(配色/幕底/主视觉/钩子文案), +为 3:4 画幅重新排一次版**。两份是姊妹图,不是母子图。 + +``` +frames/cover_169.html 1920×1080 → out/cover_169.png 抖音主封面(信息流/播放页) +frames/cover_34.html 1440×1080 → out/cover_34.png 主页栅格兼容(防切字) +``` + +## 二、两张封面各自的排版纪律 + +### A. 1920×1080(抖音主封面) + +| 项 | 规格 | +|---|---| +| 钩子字号 | **≥96px**(建议 110–130px),全画面最大最亮 | +| 主体视觉 | 偏左或偏右,**不要居中**——信息流两侧会被 UI 压住 | +| 安全边距 | 四边 ≥96px;关键元素集中在**中间 80% 区域** | +| 悖论视觉 | 放右侧时数字可到 140–160px | +| 底部 | 可放角标/栏目名(16:9 版底部不会被裁成方图),但仍留 ≥96px | + +### B. 1440×1080(兼容 3:4) + +这是**真正的 3:4**(1440:1080 = 4:3,视觉上等价 3:4 的宽高比),不是裁出来的。 + +| 项 | 规格 | +|---|---| +| 钩子字号 | **120–150px** —— 画幅窄了,字必须更大才够冲击 | +| 布局 | **上中下三段式**:视觉在上 55%、钩子在下 45%,或反之 | +| 左右边距 | ≥110px(窄画幅比例上边距要更大) | +| 悖论视觉 | 改成**竖排堆叠**(16:9 是左右并置)——这是"重排"的核心动作 | +| 禁止 | 横跨全宽的细长元素(会被裁成断线);贴边文字 | + +### 重排对照(同一份内容,两种摆法) + +| 元素 | 16:9 版 | 3:4 版 | +|---|---|---| +| 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 | +| 钩子 | 左下,两行 | 下方,三行 | +| 角标 | 左上 | 顶部居中或省略 | +| 主视觉字号 | 数字 150px | 数字 120px(画幅窄,缩一档) | +| 钩子字号 | 118px | 138px(放大,抢占视觉) | + +## 三、封面三要素(缺一返工) + +### A. 大字钩子 + +一句话让人停下。**必须含反差/悬念**,验证方法:把这句单独发出去,会不会有人点? + +| 好钩子 | 坏钩子 | +|---|---| +| 同一件事,为什么两拨人的感受完全相反 | 关于 X 的几个事实 | +| 涨了 38%,他们却在退出 | XX 行业分析 | +| 这条规则,让所有人都在做同一件蠢事 | 今天聊聊 XX | + +字号 ≥96px(3:4 版 ≥120px),关键词用 `` + accent 色 + 柔光。 + +### B. 核心悖论视觉 + +画面主体本身要**在讲一个矛盾**——不是装饰图形。可选形态: + +- **双数对照**:一升一降的两个数字(见模板 `.paradox`),最直接 +- **分裂线**:一道斜切/裂缝把画面分成两半,两侧不同色 +- **撞色块**:accent 与 signal 两个色块相互挤压/重叠 +- **一明一暗**:主体一半在光里一半在暗处 + +**禁止**:中性几何装饰、无意义的曲线、跟内容无关的图标。 + +### C. 信息余量 + +平台四边会再裁一点(各平台 2–5% 不等)。 + +- 钩子距边 ≥96px(3:4 版 ≥110px) +- 不要有贴边文字、贴边细线 +- 16:9 版的关键信息集中在中间 80% + +## 四、尺寸与倍率 + +| 规格 | CSS 画布 | 实际输出像素 | +|---|---|---| +| `169` | 1920×1080 | **3840×2160**(`--scale 2` 默认,2 倍清晰度) | +| `34` | 1440×1080 | **2880×2160** | + +2 倍是为了应付平台压缩和高分屏;字节数约 1–3MB,上传无压力。 +要 1 倍原尺寸用 `--scale 1`。 + +## 五、用法 + +```bash +# ① 复制模板,做两份(改尺寸 + 重排版式 + 填真实钩子) +cp /assets/cover-template.html <项目>/frames/cover_169.html +cp /assets/cover-template.html <项目>/frames/cover_34.html + +# ② 渲染(默认 seek 到时间轴末尾取完整态) +node /scripts/cover_build.mjs <项目> + +# 常用变体 +node /scripts/cover_build.mjs . --at 0.8 # 取入场动画中间态(更有张力) +node /scripts/cover_build.mjs . --only 169 # 只出 16:9 +node /scripts/cover_build.mjs . --jpg # JPEG(体积小,平台都收) +node /scripts/cover_build.mjs . --scale 1 # 不要 2 倍 +``` + +产出:`out/cover_169.png`、`out/cover_34.png`、`out/cover_report.md` + +## 六、上传策略 + +| 场景 | 用哪张 | +|---|---| +| 抖音信息流 / 播放页主封面 | `cover_169.png`(选「横版封面」) | +| 抖音个人主页栅格 | `cover_34.png`(选「竖版封面」) | +| 平台只允许一张 | **用 `cover_34.png`** —— 多数主页栅格按 3:4 裁,16:9 会被切两侧 | +| 小红书 / 视频号 | 视平台;容器通常也接受 3:4,优先 `cover_34` | + +**封面文字必须与视频首帧钩子一致**(或至少同义)——观众点进来看到的是同一个承诺, +不一致会立刻跳出。 + +## 七、自检清单(上传前逐条过) + +> **先跑终态测量,再逐条肉眼过。** 封面排版验收**必须在时间轴终态下做** +> (`__MG_RENDER__=true` + `tl.pause(tl.duration(), false)`),否则量到的是 +> 入场动画起始值,边距/行数全错(lessons #30)。可照 `scripts/lint_frames.py` +> 的思路自写一个十余行的测量器:行数 / 四边边距 / 元素重叠 / 钩子字号一次判完。 + +- [ ] 两张封面都出了,都是独立排版(不是裁切关系) +- [ ] 钩子字号:16:9 版 ≥96px、3:4 版 ≥120px +- [ ] 钩子在**任何画幅下都不被切**(四边留 ≥96px / ≥110px) +- [ ] 钩子每行 ≤ 上限(16:9 ≤7 字、3:4 ≤5 字);用「行宽 ≈ 字数×字号×(1+ls) ≤ 容器宽」反算,防孤字断行 +- [ ] 悖论视觉是"真矛盾"(两个对数/一明一暗/分裂线),不是装饰 +- [ ] 一份封面只有一个视觉焦点(大字钩子是最大的那个) +- [ ] 配色全部来自 `theme.css`(与成片同一主题,不串色) +- [ ] 没有贴边文字、没有横跨全宽的细线 +- [ ] 封面文字 = 视频首帧钩子(同义) +- [ ] 3:4 版的数字/标签已按窄画幅缩一档、钩子放大一档 diff --git a/references/frame-contract.md b/references/frame-contract.md new file mode 100644 index 0000000..f9c95fb --- /dev/null +++ b/references/frame-contract.md @@ -0,0 +1,94 @@ +# 场景帧契约(frame-contract) + +一个场景 = 一个自包含 HTML 文件 `frames/.html`,1920×1080,GSAP 时间轴驱动。 +渲染器逐帧 `tl.pause(t, false)` 确定性截图 —— 与实时录制的根本区别:**任何「依赖墙钟」的东西都会失控**。 + +> **画面从哪来?** 先看 **`references/style-catalog.md`** —— html-video 的 23 个模板风格 +> (8 个类别,含每种画布/字体/时间轴/配色规范)。改编三步法与挑风格建议见 +> **`references/template-guide.md`**。从零设计画面是最后选项,不是第一选项。 + +模板:`assets/frame-template.html`(复制后改内容,契约注释在文件头)。 + +## 八条硬性契约 + +| # | 契约 | 违反后果 | +|---|------|----------| +| 1 | 画布 1920×1080(或 project.json 声明的尺寸),系统字体栈 | 渲染视口即画布 | +| 2 | 绝不引外部字体/Google Fonts(渲染器 5s 字体硬顶) | 整帧退化或首帧字体错 | +| 3 | 颜色全部取 `../theme.css` 的 CSS 变量,禁止字面量 | 换主题 = 半换皮 | +| 4 | 动画只用 GSAP:`var tl = gsap.timeline({paused:true}); window.__tl = tl;` | 渲染器找不到时间轴 → 报错 | +| 5 | 节拍用 `B('块文本')` 对齐吐字 | 画面与配音两张皮 | +| 6 | 底部 80–170px 是字幕带,内容收在 `bottom ≥ 170px` | 字幕压字,两边都读不清 | +| 7 | 每帧一个视觉焦点:主角 ≥170px 或大字 ≥96px 带 accent 柔光;配角不发光;文字 ≥22px | QC 退回 | +| 8 | GSAP `