diff --git a/.agents/skills/pocketjs_styling/SKILL.md b/.agents/skills/pocketjs_styling/SKILL.md new file mode 100644 index 00000000..527a4a9e --- /dev/null +++ b/.agents/skills/pocketjs_styling/SKILL.md @@ -0,0 +1,97 @@ +--- +name: pocketjs_styling +description: Guidelines and specifications for compiling Tailwind styles and writing Spec-compliant style objects in PocketJS. +--- + +# PocketJS Styling & Tailwind Compilation Guidelines + +This skill provides the compiler constraints and runtime style property specifications for building user interfaces in PocketJS. Follow these rules to avoid compile-time parser errors and runtime style crashes. + +--- + +## 1. Tailwind Compiler Restrictions (Build-time Class Validation) + +The build-time compiler (`compiler/tailwind.ts`) parses static class strings into compiled style records. If a class literal contains any unrecognized utility token, the compiler ignores the entire literal, which leads to a runtime crash (`unknown class ... not in the compiled style table`). + +### Text & Font Weights +* **Only `font-bold` is supported.** Do NOT use `font-semibold`, `font-extrabold`, or `font-black`. +* **Only `tracking-wide` is supported.** Do NOT use `tracking-tighter`, `tracking-tight`, etc. +* **Text sizes are restricted to baked font slots:** + * `text-xs` (12px) + * `text-sm` (14px) + * `text-base` (16px) + * `text-lg` (18px) + * `text-xl` (20px) + * `text-2xl` (24px) + * `text-4xl` (36px) + +### Borders +* **Directional borders are NOT supported in utility classes.** Do NOT use `border-t`, `border-b`, `border-l`, or `border-r`. +* **Only full-border wrappers are allowed:** `border`, `border-2`, `border-4`, `border-8`, or arbitrary `border-[N]`. +* **Implementing single-sided borders:** Render a separate thin `` element to act as a divider line: + ```tsx + {/* Horizontal top border separator */} + + ``` + +### Rounded Corners +* **`rounded-full` needs build-time known size.** You MUST specify both `w-N` and `h-N` in the *same* class literal: + ```tsx + {/* Correct */} + + + {/* Incorrect (w/h in dynamic style object instead of class) */} + + ``` + +--- + +## 2. Style Object Rules (Runtime Dynamic Styles) + +The dynamic `style` object is parsed at runtime. Keys are matched against the native spec enum (`spec/spec.ts`). + +### Positioning Offsets +* **Standard CSS directional keys are NOT supported.** Use their spec-compliant equivalents: + * `left` → **`insetL`** + * `right` → **`insetR`** + * `top` → **`insetT`** + * `bottom` → **`insetB`** + +### Visual & Layout Keys +* **Use spec-compliant camelCase style keys:** + * `background-color` → **`bgColor`** + * `border-radius` → **`radius`** + * `border-color` → **`borderColor`** + * `border-width` → **`borderWidth`** + * `z-index` → **`zIndex`** +* **Unsupported Keys:** Properties like `font-size`, `font-family`, and `box-shadow` are unrecognized. + +### Values and Units +* **All sizes and dimensions must be raw numbers (representing pixels).** Suffixes like `"px"` are not supported: + ```tsx + {/* Correct */} + style={{ width: 320, height: 220, insetL: 50 }} + + {/* Incorrect */} + style={{ width: "320px", height: "220px", insetL: "50px" }} + ``` +* **Colors must be `#rrggbbaa` hex strings.** Standard browser color syntaxes (`rgb()`, `rgba()`) are not supported: + ```tsx + {/* Correct */} + style={{ bgColor: "#0f172a66" }} + + {/* Helper for dynamic alpha blending */} + function toHexColor(r: number, g: number, b: number, a: number): string { + const toHex = (x: number) => { + const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`; + } + ``` + +### Dynamic Text Resizing +* Since `fontSize` is not a dynamic runtime property, scale text by setting a standard class size (like `text-2xl`) and modifying the **`scale`** key inside the dynamic `style` object: + ```tsx + + ``` diff --git a/README.md b/README.md index 65867c7b..3f4da32f 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,44 @@ fonts, vectors and core masks at Vita's native 960x544 density. Physical controls, left-analog input, and front-panel multi-touch snapshots are supported; PSP builds retain their controller-only fallback. +## Headless Video Rendering + +PocketJS can render high-performance, high-definition videos (e.g. 1080p) from Solid or Vue Vapor UI components without headless browser automation overhead. It runs inside a single Bun process, using the Rust core's software rasterizer to paint raw framebuffers and piping them directly to FFmpeg. + +To maximize performance: +- **Zero Heap Allocations**: Framebuffers are read directly from WASM linear memory and piped to FFmpeg, bypassing JavaScript V8 heap allocations entirely. +- **Multiprocess Concurrency**: Spawns parallel worker processes to render chunks of the video concurrently, stitching them together at the end with FFmpeg's ultra-fast demuxer (zero re-encoding overhead). +- **HD Asset Baking**: Integrates with the compilation pipeline to bake Inter font atlases and SVGs at matching target physical densities (e.g. 4x scale for 1080p). + +### Usage + +```sh +# Example 1: Full 1080p 60fps video with narration audio track and 4 parallel workers: +bun render -a ai-explainer-main -d 58.68 -f 60 --width 1920 --height 1080 -c 4 --audio apps/ai-explainer/assets/section4.wav + +# Example 2: 720p 30fps quick preview: +bun render -a ai-explainer-main -d 10.0 -f 30 --width 1280 --height 720 + +# Example 3: 1080p render using default 480x272 viewport at 4x scaling: +bun render -a music-main -d 60 -f 30 -s 4 + +# Example 4: Custom output path for 5-second 540p clip: +bun render -a motions-main -d 5.0 -f 30 -s 2 -o dist/render/motions-preview.mp4 + +# CLI Options: +# -a, --app Name of the app/demo target to render (required) +# -o, --output Output MP4 path (default: dist/render/.mp4) +# -d, --duration Duration of the video in seconds (default: 5.0) +# -f, --fps Frame rate of the video (default: 60) +# -s, --scale <1..10> Scaling factor (default: 4 for stock 480x272, 1 for custom width/height) +# -w, --width Logical width of layout viewport (default: 480) +# --height Logical height of layout viewport (default: 272) +# -c, --concurrency Number of parallel worker processes (default: CPU core count) +# --audio, -au Path to audio track to mix into output MP4 (WAV/MP3/AAC) +# --crf x264 quality factor (default: 18) +# --preset x264 speed preset (default: faster) +``` + ## The Pocket Launcher (on-device app switching) One PSP EBOOT or Vita VPK can embed every app admitted by that target plus a @@ -259,6 +297,7 @@ bun run launcher build --target vita -- --release # -> dist/vita/launcher-main.v bun run e2e:launcher # PPSSPPHeadless 3-swap journey bun run e2e:launcher:vita # Vita3K 7-swap/resource-reuse journey ``` +``` ## DevTools + time travel diff --git a/apps/ai-explainer/assets/section0.wav b/apps/ai-explainer/assets/section0.wav new file mode 100644 index 00000000..e61d05e8 Binary files /dev/null and b/apps/ai-explainer/assets/section0.wav differ diff --git a/apps/ai-explainer/assets/section1.wav b/apps/ai-explainer/assets/section1.wav new file mode 100644 index 00000000..7d8fd54f Binary files /dev/null and b/apps/ai-explainer/assets/section1.wav differ diff --git a/apps/ai-explainer/assets/section10.wav b/apps/ai-explainer/assets/section10.wav new file mode 100644 index 00000000..85622d07 Binary files /dev/null and b/apps/ai-explainer/assets/section10.wav differ diff --git a/apps/ai-explainer/assets/section11.wav b/apps/ai-explainer/assets/section11.wav new file mode 100644 index 00000000..7708ca69 Binary files /dev/null and b/apps/ai-explainer/assets/section11.wav differ diff --git a/apps/ai-explainer/assets/section12.wav b/apps/ai-explainer/assets/section12.wav new file mode 100644 index 00000000..fc24591c Binary files /dev/null and b/apps/ai-explainer/assets/section12.wav differ diff --git a/apps/ai-explainer/assets/section13.wav b/apps/ai-explainer/assets/section13.wav new file mode 100644 index 00000000..951972c8 Binary files /dev/null and b/apps/ai-explainer/assets/section13.wav differ diff --git a/apps/ai-explainer/assets/section14.wav b/apps/ai-explainer/assets/section14.wav new file mode 100644 index 00000000..a4c2db5c Binary files /dev/null and b/apps/ai-explainer/assets/section14.wav differ diff --git a/apps/ai-explainer/assets/section15.wav b/apps/ai-explainer/assets/section15.wav new file mode 100644 index 00000000..abf8b456 Binary files /dev/null and b/apps/ai-explainer/assets/section15.wav differ diff --git a/apps/ai-explainer/assets/section16.wav b/apps/ai-explainer/assets/section16.wav new file mode 100644 index 00000000..25931120 Binary files /dev/null and b/apps/ai-explainer/assets/section16.wav differ diff --git a/apps/ai-explainer/assets/section17.wav b/apps/ai-explainer/assets/section17.wav new file mode 100644 index 00000000..21dfc1c8 Binary files /dev/null and b/apps/ai-explainer/assets/section17.wav differ diff --git a/apps/ai-explainer/assets/section18.wav b/apps/ai-explainer/assets/section18.wav new file mode 100644 index 00000000..c5ae231c Binary files /dev/null and b/apps/ai-explainer/assets/section18.wav differ diff --git a/apps/ai-explainer/assets/section19.wav b/apps/ai-explainer/assets/section19.wav new file mode 100644 index 00000000..ead1fb75 Binary files /dev/null and b/apps/ai-explainer/assets/section19.wav differ diff --git a/apps/ai-explainer/assets/section2.wav b/apps/ai-explainer/assets/section2.wav new file mode 100644 index 00000000..62448b47 Binary files /dev/null and b/apps/ai-explainer/assets/section2.wav differ diff --git a/apps/ai-explainer/assets/section20.wav b/apps/ai-explainer/assets/section20.wav new file mode 100644 index 00000000..b8249d9a Binary files /dev/null and b/apps/ai-explainer/assets/section20.wav differ diff --git a/apps/ai-explainer/assets/section21.wav b/apps/ai-explainer/assets/section21.wav new file mode 100644 index 00000000..049cdf57 Binary files /dev/null and b/apps/ai-explainer/assets/section21.wav differ diff --git a/apps/ai-explainer/assets/section22.wav b/apps/ai-explainer/assets/section22.wav new file mode 100644 index 00000000..e127f366 Binary files /dev/null and b/apps/ai-explainer/assets/section22.wav differ diff --git a/apps/ai-explainer/assets/section3.wav b/apps/ai-explainer/assets/section3.wav new file mode 100644 index 00000000..d87a5e18 Binary files /dev/null and b/apps/ai-explainer/assets/section3.wav differ diff --git a/apps/ai-explainer/assets/section4.wav b/apps/ai-explainer/assets/section4.wav new file mode 100644 index 00000000..8a94d976 Binary files /dev/null and b/apps/ai-explainer/assets/section4.wav differ diff --git a/apps/ai-explainer/assets/section5.wav b/apps/ai-explainer/assets/section5.wav new file mode 100644 index 00000000..9c8b3181 Binary files /dev/null and b/apps/ai-explainer/assets/section5.wav differ diff --git a/apps/ai-explainer/assets/section6.wav b/apps/ai-explainer/assets/section6.wav new file mode 100644 index 00000000..a84c595c Binary files /dev/null and b/apps/ai-explainer/assets/section6.wav differ diff --git a/apps/ai-explainer/assets/section7.wav b/apps/ai-explainer/assets/section7.wav new file mode 100644 index 00000000..4a77be7d Binary files /dev/null and b/apps/ai-explainer/assets/section7.wav differ diff --git a/apps/ai-explainer/assets/section8.wav b/apps/ai-explainer/assets/section8.wav new file mode 100644 index 00000000..62489cb1 Binary files /dev/null and b/apps/ai-explainer/assets/section8.wav differ diff --git a/apps/ai-explainer/assets/section9.wav b/apps/ai-explainer/assets/section9.wav new file mode 100644 index 00000000..0ec46295 Binary files /dev/null and b/apps/ai-explainer/assets/section9.wav differ diff --git a/apps/ai-explainer/compositions/index.html b/apps/ai-explainer/compositions/index.html new file mode 100644 index 00000000..6fcb0056 --- /dev/null +++ b/apps/ai-explainer/compositions/index.html @@ -0,0 +1,121 @@ + + + + + + AI Engineering Explained for Developers + + + + + +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/apps/ai-explainer/compositions/section0.html b/apps/ai-explainer/compositions/section0.html new file mode 100644 index 00000000..1e5d9e2b --- /dev/null +++ b/apps/ai-explainer/compositions/section0.html @@ -0,0 +1,448 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section1.html b/apps/ai-explainer/compositions/section1.html new file mode 100644 index 00000000..413a6552 --- /dev/null +++ b/apps/ai-explainer/compositions/section1.html @@ -0,0 +1,573 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section10.html b/apps/ai-explainer/compositions/section10.html new file mode 100644 index 00000000..3f08087b --- /dev/null +++ b/apps/ai-explainer/compositions/section10.html @@ -0,0 +1,513 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section11.html b/apps/ai-explainer/compositions/section11.html new file mode 100644 index 00000000..95e4f1bc --- /dev/null +++ b/apps/ai-explainer/compositions/section11.html @@ -0,0 +1,508 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section12.html b/apps/ai-explainer/compositions/section12.html new file mode 100644 index 00000000..0795f605 --- /dev/null +++ b/apps/ai-explainer/compositions/section12.html @@ -0,0 +1,531 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section13.html b/apps/ai-explainer/compositions/section13.html new file mode 100644 index 00000000..0c9515c1 --- /dev/null +++ b/apps/ai-explainer/compositions/section13.html @@ -0,0 +1,575 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section14.html b/apps/ai-explainer/compositions/section14.html new file mode 100644 index 00000000..807e33bc --- /dev/null +++ b/apps/ai-explainer/compositions/section14.html @@ -0,0 +1,538 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section15.html b/apps/ai-explainer/compositions/section15.html new file mode 100644 index 00000000..7e5be952 --- /dev/null +++ b/apps/ai-explainer/compositions/section15.html @@ -0,0 +1,526 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section16.html b/apps/ai-explainer/compositions/section16.html new file mode 100644 index 00000000..64c7e127 --- /dev/null +++ b/apps/ai-explainer/compositions/section16.html @@ -0,0 +1,423 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section17.html b/apps/ai-explainer/compositions/section17.html new file mode 100644 index 00000000..272eec0b --- /dev/null +++ b/apps/ai-explainer/compositions/section17.html @@ -0,0 +1,506 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section18.html b/apps/ai-explainer/compositions/section18.html new file mode 100644 index 00000000..dbc377df --- /dev/null +++ b/apps/ai-explainer/compositions/section18.html @@ -0,0 +1,506 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section19.html b/apps/ai-explainer/compositions/section19.html new file mode 100644 index 00000000..85034fa7 --- /dev/null +++ b/apps/ai-explainer/compositions/section19.html @@ -0,0 +1,531 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section2.html b/apps/ai-explainer/compositions/section2.html new file mode 100644 index 00000000..629b4882 --- /dev/null +++ b/apps/ai-explainer/compositions/section2.html @@ -0,0 +1,454 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section20.html b/apps/ai-explainer/compositions/section20.html new file mode 100644 index 00000000..e00192df --- /dev/null +++ b/apps/ai-explainer/compositions/section20.html @@ -0,0 +1,526 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section21.html b/apps/ai-explainer/compositions/section21.html new file mode 100644 index 00000000..6816c818 --- /dev/null +++ b/apps/ai-explainer/compositions/section21.html @@ -0,0 +1,477 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section22.html b/apps/ai-explainer/compositions/section22.html new file mode 100644 index 00000000..c6f1420b --- /dev/null +++ b/apps/ai-explainer/compositions/section22.html @@ -0,0 +1,448 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section3.html b/apps/ai-explainer/compositions/section3.html new file mode 100644 index 00000000..ca2e33c4 --- /dev/null +++ b/apps/ai-explainer/compositions/section3.html @@ -0,0 +1,524 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section4.html b/apps/ai-explainer/compositions/section4.html new file mode 100644 index 00000000..2b53d71f --- /dev/null +++ b/apps/ai-explainer/compositions/section4.html @@ -0,0 +1,586 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section5.html b/apps/ai-explainer/compositions/section5.html new file mode 100644 index 00000000..77766b44 --- /dev/null +++ b/apps/ai-explainer/compositions/section5.html @@ -0,0 +1,393 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section6.html b/apps/ai-explainer/compositions/section6.html new file mode 100644 index 00000000..408db741 --- /dev/null +++ b/apps/ai-explainer/compositions/section6.html @@ -0,0 +1,545 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section7.html b/apps/ai-explainer/compositions/section7.html new file mode 100644 index 00000000..72b5b05e --- /dev/null +++ b/apps/ai-explainer/compositions/section7.html @@ -0,0 +1,514 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section8.html b/apps/ai-explainer/compositions/section8.html new file mode 100644 index 00000000..594b915e --- /dev/null +++ b/apps/ai-explainer/compositions/section8.html @@ -0,0 +1,471 @@ + + + + + + + + + diff --git a/apps/ai-explainer/compositions/section9.html b/apps/ai-explainer/compositions/section9.html new file mode 100644 index 00000000..9cc82fa8 --- /dev/null +++ b/apps/ai-explainer/compositions/section9.html @@ -0,0 +1,513 @@ + + + + + + + + + diff --git a/apps/ai-explainer/icon-bot.svg b/apps/ai-explainer/icon-bot.svg new file mode 100644 index 00000000..404c558f --- /dev/null +++ b/apps/ai-explainer/icon-bot.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/ai-explainer/icon-chat.svg b/apps/ai-explainer/icon-chat.svg new file mode 100644 index 00000000..e6d99686 --- /dev/null +++ b/apps/ai-explainer/icon-chat.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ai-explainer/icon-code.svg b/apps/ai-explainer/icon-code.svg new file mode 100644 index 00000000..e44a5766 --- /dev/null +++ b/apps/ai-explainer/icon-code.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ai-explainer/icon-doc.svg b/apps/ai-explainer/icon-doc.svg new file mode 100644 index 00000000..158d84c8 --- /dev/null +++ b/apps/ai-explainer/icon-doc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ai-explainer/main.tsx b/apps/ai-explainer/main.tsx new file mode 100644 index 00000000..3d541ea5 --- /dev/null +++ b/apps/ai-explainer/main.tsx @@ -0,0 +1,4 @@ +import AiExplainerSection4 from "./section4.tsx"; +import { mount } from "@pocketjs/framework"; + +mount(() => ); diff --git a/apps/ai-explainer/pocket.config.ts b/apps/ai-explainer/pocket.config.ts new file mode 100644 index 00000000..7c618a00 --- /dev/null +++ b/apps/ai-explainer/pocket.config.ts @@ -0,0 +1,5 @@ +import { definePocketConfig } from "../../framework/src/config.ts"; + +export default definePocketConfig({ + rasterDensity: 1, +}); diff --git a/apps/ai-explainer/section0.tsx b/apps/ai-explainer/section0.tsx new file mode 100644 index 00000000..79961532 --- /dev/null +++ b/apps/ai-explainer/section0.tsx @@ -0,0 +1,532 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { BTN } from "@pocketjs/framework/input"; + +// --------------------------------------------------------------------------- +// Timings and Animation Configurations (Intro Scene) +// --------------------------------------------------------------------------- + +const TRACK_FRAMES = 2798; // 46.64 seconds @ 60 Hz + +const CAPTIONS = [ + { start: 0, end: 240, text: "If you've ever opened Twitter, LinkedIn or YouTube recently, you've probably seen words like..." }, + { start: 240, end: 348, text: "Transformer..." }, + { start: 348, end: 450, text: "RAG..." }, + { start: 450, end: 552, text: "Agents..." }, + { start: 552, end: 660, text: "Embeddings..." }, + { start: 660, end: 768, text: "MCP..." }, + { start: 768, end: 888, text: "Reasoning models..." }, + { start: 888, end: 1080, text: "Context Engineering." }, + { start: 1080, end: 1320, text: "Everyone throws these words around as if everyone already understands them." }, + { start: 1320, end: 1590, text: "But most developers only know how to call an API. Very few actually understand what's happening underneath." }, + { start: 1590, end: 2010, text: "By the end of this video you'll understand how modern AI systems actually work, why they were built this way, and how every concept connects together." }, + { start: 2010, end: 2130, text: "This isn't just theory." }, + { start: 2130, end: 2430, text: "We're going to build the entire mental model of AI engineering from first principles." }, + { start: 2430, end: 2610, text: "Let's start with the most important question." }, + { start: 2610, end: 2798, text: "What exactly is a Large Language Model?" } +]; + +const highlightTimings = [ + { start: 240, end: 330 }, // Card 0: Transformer (4.0s to 5.5s) + { start: 348, end: 420 }, // Card 1: RAG (5.8s to 7.0s) + { start: 450, end: 528 }, // Card 2: Agents (7.5s to 8.8s) + { start: 552, end: 630 }, // Card 3: Embeddings (9.2s to 10.5s) + { start: 660, end: 732 }, // Card 4: MCP (11.0s to 12.2s) + { start: 768, end: 852 }, // Card 5: Reasoning (12.8s to 14.2s) + { start: 888, end: 990 } // Card 6: Context (14.8s to 16.5s) +]; + +const CARDS = [ + { id: "transformer", icon: "", title: "Transformer", sub: "Attention Engine" }, + { id: "rag", icon: "DB", title: "RAG", sub: "Knowledge Base" }, + { id: "agents", icon: "O──►", title: "Agents", sub: "Autonomy Loop" }, + { id: "embeddings", icon: "XYZ", title: "Embeddings", sub: "Vector Space" }, + { id: "mcp", icon: "🔌", title: "MCP", sub: "Tool Protocol" }, + { id: "reasoning", icon: "???", title: "Reasoning", sub: "Compute Search" }, + { id: "context", icon: "MEM", title: "Context Engineering", sub: "Active Memory Pipeline" } +]; + +function interpolate(frame: number, start: number, duration: number, from: number, to: number): number { + if (frame < start) return from; + if (frame > start + duration) return to; + const t = (frame - start) / duration; + const ease = t * t * (3 - 2 * t); // smoothstep + return from + (to - from) * ease; +} + +function toHexColor(r: number, g: number, b: number, a: number): string { + const toHex = (x: number) => { + const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`; +} + +export default function AiExplainerIntro() { + const [position, setPosition] = createSignal(0); + const [playing, setPlaying] = createSignal(true); + + onButtonPress(BTN.CIRCLE, () => setPlaying(!playing())); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setPosition((p) => Math.min(TRACK_FRAMES - 1, p + 300))); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setPosition((p) => Math.max(0, p - 300))); + + onFrame(() => { + if (!playing()) return; + setPosition((p) => (p + 1) % TRACK_FRAMES); + }); + + const currentCaption = () => { + const pos = position(); + const cap = CAPTIONS.find(c => pos >= c.start && pos < c.end); + return cap ? cap.text : ""; + }; + + const pos = () => position(); + + const getHighlightFactor = (i: number, p: number) => { + const timing = highlightTimings[i]; + if (p < timing.start) return 0; + if (p < timing.start + 30) return interpolate(p, timing.start, 30, 0, 1); + if (p < timing.end) return 1; + if (p < timing.end + 30) return interpolate(p, timing.end, 30, 1, 0); + return 0; + }; + + const getErrorFactor = (i: number, p: number) => { + const start = 1110 + i * 6; // starts at 18.5s (1110 frames) staggered by 6 frames + return interpolate(p, start, 60, 0, 1); + }; + + const getResolvedFactor = (i: number, p: number) => { + const start = 1530 + i * 5; // starts at 25.5s (1530 frames) staggered by 5 frames + return interpolate(p, start, 60, 0, 1); + }; + + const cardEntranceOpacity = (i: number) => { + return interpolate(pos(), 90 + i * 9, 48, 0, 1); + }; + + const cardEntranceScale = (i: number) => { + return interpolate(pos(), 90 + i * 9, 48, 0.8, 1); + }; + + const cardEntranceY = (i: number) => { + return interpolate(pos(), 90 + i * 9, 48, 50, 0); + }; + + const getCardBorderColor = (i: number) => { + const p = pos(); + const h = getHighlightFactor(i, p); + const e = getErrorFactor(i, p); + const r = getResolvedFactor(i, p); + + if (r > 0) { + // Blend from error (#ef4444 = 239, 68, 68) to resolved (#10b981 = 16, 185, 129) + const red = Math.round(239 * (1 - r) + 16 * r); + const green = Math.round(68 * (1 - r) + 185 * r); + const blue = Math.round(68 * (1 - r) + 129 * r); + const alpha = 0.15 + 0.85 * (e > r ? e : r); + return toHexColor(red, green, blue, alpha); + } + if (e > 0) { + // Blend from normal/highlight to error + const r_norm = 56; + const g_norm = 189; + const b_norm = 248; + const a_norm = 0.15 + 0.85 * h; + + const red = Math.round(r_norm * (1 - e) + 239 * e); + const green = Math.round(g_norm * (1 - e) + 68 * e); + const blue = Math.round(b_norm * (1 - e) + 68 * e); + const alpha = a_norm * (1 - e) + 1.0 * e; + return toHexColor(red, green, blue, alpha); + } + if (h > 0) { + return toHexColor(56, 189, 248, 0.15 + 0.85 * h); + } + return "#38bdf826"; + }; + + const getCardBgColor = (i: number) => { + const p = pos(); + const h = getHighlightFactor(i, p); + return toHexColor(15, 23, 42, 0.6 + 0.25 * h); + }; + + const getCardScale = (i: number) => { + const entranceScale = cardEntranceScale(i); + const h = getHighlightFactor(i, pos()); + return entranceScale * (1.0 + 0.05 * h); + }; + + const getCardTranslateY = (i: number) => { + return cardEntranceY(i); + }; + + const getCardOpacity = (i: number) => { + return cardEntranceOpacity(i); + }; + + const getTopLineColor = (i: number) => { + const p = pos(); + const r = getResolvedFactor(i, p); + const e = getErrorFactor(i, p); + + if (r > 0) { + return toHexColor(16, 185, 129, 0.3 + 0.7 * r); + } + if (e > 0) { + return toHexColor(239, 68, 68, 0.3 + 0.7 * e); + } + return "#38bdf84d"; + }; + + const getCardAccentColor = (i: number) => { + const p = pos(); + const r = getResolvedFactor(i, p); + const e = getErrorFactor(i, p); + + if (r > 0) { + return toHexColor(16, 185, 129, 0.8 + 0.2 * r); + } + if (e > 0) { + return toHexColor(239, 68, 68, 0.8 + 0.2 * e); + } + return "#38bdf8"; + }; + + // Connection lines visibility / pulse + const connectionsOpacity = () => { + const p = pos(); + if (p < 1500) return 0; + if (p < 1560) return interpolate(p, 1500, 60, 0, 0.4); + if (p < 1872) return 0.4; + return interpolate(p, 1872, 30, 0.4, 0); + }; + + // Grid container exit transition + const gridOpacity = () => { + return interpolate(pos(), 1860, 48, 1, 0); + }; + + const gridY = () => { + return interpolate(pos(), 1860, 48, 0, -100); + }; + + // Title Block entrance and exit transitions + const titleOpacity = () => { + const p = pos(); + if (p < 1950) return 0; + if (p < 2022) return interpolate(p, 1950, 72, 0, 1); + if (p < 2610) return 1; + return interpolate(p, 2610, 90, 1, 0); + }; + + const titleY = () => { + const p = pos(); + if (p < 1950) return 30; + if (p < 2022) return interpolate(p, 1950, 72, 30, 0); + return 0; + }; + + const titleScale = () => { + const p = pos(); + if (p < 1950) return 0.9; + + let scale = 1.0; + if (p < 2022) { + scale = interpolate(p, 1950, 72, 0.9, 1.0); + } + + if (p >= 1968 && p <= 2004) { + scale += 0.03 * Math.sin((p - 1968) * (Math.PI / 36)); + } + + if (p >= 2610) { + scale = interpolate(p, 2610, 90, scale, 1.15); + } + + return scale; + }; + + const titleTextShadow = () => { + const p = pos(); + if (p >= 1968 && p <= 2004) { + const factor = Math.sin((p - 1968) * (Math.PI / 36)); + return `0 0 ${Math.round(40 + 20 * factor)}px rgba(56, 189, 248, ${0.2 + 0.4 * factor})`; + } + return "0 0 40px rgba(56, 189, 248, 0.2)"; + }; + + const renderCard = (card: typeof CARDS[0], index: number) => { + const cardWidth = card.title === "Context Engineering" ? 670 : 320; + + return ( + + {/* Top border line indicator */} + + + {/* Icon */} + + {card.icon} + + + {/* Title */} + + {card.title} + + + {/* Subtitle */} + + {card.sub} + + + ); + }; + + return ( + + {/* Background grid lines */} + {Array.from({ length: 31 }).map((_, i) => ( + + ))} + {Array.from({ length: 17 }).map((_, i) => ( + + ))} + + {/* Ambient Glow Orbs */} + + + + + {/* Tech Header */} + + + + MODULE // AI_ENGINEERING_EXPLAINER + + PREVIEW_MODE: PROTO_V1 + {/* Bottom border separator */} + + + + {/* Content Area */} + + + {/* Keywords Grid Container */} + + {/* Connection Lines (rendered behind cards) */} + + {/* Row 1 Horizontal lines */} + + + + + {/* Row 2 Horizontal lines */} + + + + {/* Vertical lines */} + + + + + + + {/* Row 1 Cards */} + + {renderCard(CARDS[0], 0)} + {renderCard(CARDS[1], 1)} + {renderCard(CARDS[2], 2)} + {renderCard(CARDS[3], 3)} + + + {/* Row 2 Cards */} + + {renderCard(CARDS[4], 4)} + {renderCard(CARDS[5], 5)} + {renderCard(CARDS[6], 6)} + + + + {/* Central Title Block */} + + + AI Engineering + + + Explained + + + Everything You Need to Build Modern AI Applications + + + + + {/* Tech Footer */} + + {/* Top border separator */} + + {`FRAME_TIME: ${String(Math.floor(pos() / 3600)).padStart(2, "0")}:${String(Math.floor((pos() % 3600) / 60)).padStart(2, "0")}:${String(pos() % 60).padStart(2, "0")}`} + SYS_METRIC: DETERMINISTIC_TIMELINE + + + {/* Captions Overlay */} + + + {currentCaption()} + + + + ); +} diff --git a/apps/ai-explainer/section1.tsx b/apps/ai-explainer/section1.tsx new file mode 100644 index 00000000..9d3f2105 --- /dev/null +++ b/apps/ai-explainer/section1.tsx @@ -0,0 +1,370 @@ +import { createSignal } from "solid-js"; +import { Text, View, Image } from "@pocketjs/framework/components"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { BTN } from "@pocketjs/framework/input"; + +// --------------------------------------------------------------------------- +// Timings and Animation Configurations +// --------------------------------------------------------------------------- + +const TRACK_FRAMES = 2920; // 48.68 seconds @ 60 Hz + +const CAPTIONS = [ + { start: 0, end: 300, text: "Despite all the hype, a Large Language Model only does one job." }, + { start: 300, end: 480, text: "Predict the next token. That's it." }, + { start: 480, end: 630, text: "When you type \"All that glitters\"," }, + { start: 630, end: 780, text: "the model predicts that the next sequence is likely \"is not gold.\"" }, + { start: 780, end: 930, text: "When you ask \"The capital of France is\"," }, + { start: 930, end: 1080, text: "it predicts \"Paris.\"" }, + { start: 1080, end: 1410, text: "Everything else... writing code... summarizing PDFs... answering questions..." }, + { start: 1410, end: 1650, text: "starts from this surprisingly simple idea." }, + { start: 1650, end: 1890, text: "A language model isn't storing answers like a database." }, + { start: 1890, end: 2250, text: "Instead, it's learned statistical relationships between billions of text pieces." }, + { start: 2250, end: 2520, text: "The more data it sees... the better it becomes at predicting what comes next." }, + { start: 2520, end: 2670, text: "But this raises another question." }, + { start: 2670, end: 2920, text: "Humans read words. Computers don't. So how does a language model even read text?" } +]; + +function interpolate(frame: number, start: number, duration: number, from: number, to: number): number { + if (frame < start) return from; + if (frame > start + duration) return to; + const t = (frame - start) / duration; + const ease = t * t * (3 - 2 * t); // smoothstep + return from + (to - from) * ease; +} + +export default function AiExplainer() { + const [position, setPosition] = createSignal(0); + const [playing, setPlaying] = createSignal(true); + + onButtonPress(BTN.CIRCLE, () => setPlaying(!playing())); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setPosition((p) => Math.min(TRACK_FRAMES - 1, p + 300))); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setPosition((p) => Math.max(0, p - 300))); + + onFrame(() => { + if (!playing()) return; + setPosition((p) => (p + 1) % TRACK_FRAMES); + }); + + const currentCaption = () => { + const pos = position(); + const cap = CAPTIONS.find(c => pos >= c.start && pos < c.end); + return cap ? cap.text : ""; + }; + + // Interpolated animation values + const pos = () => position(); + + // Slide panels in (frame 60..120) and out (frame 2790..2850) + const leftPanelX = () => { + const p = pos(); + if (p > 2700) return interpolate(p, 2790, 60, 0, -60); + return interpolate(p, 60, 60, -60, 0); + }; + const leftPanelOpacity = () => { + const p = pos(); + if (p > 2700) return interpolate(p, 2790, 60, 1, 0); + return interpolate(p, 60, 60, 0, 1); + }; + + const rightPanelX = () => { + const p = pos(); + if (p > 2700) return interpolate(p, 2808, 60, 0, 60); + return interpolate(p, 72, 60, 60, 0); + }; + const rightPanelOpacity = () => { + const p = pos(); + if (p > 2700) return interpolate(p, 2808, 60, 1, 0); + return interpolate(p, 72, 60, 0, 1); + }; + + // Case 1 prompt opacity (fades in 210..240, dims 750..798) + const case1Opacity = () => { + const p = pos(); + return interpolate(p, 210, 30, 0, 1) * interpolate(p, 750, 48, 1, 0.4); + }; + const case1Y = () => interpolate(pos(), 750, 48, 0, -10); + const case1Scale = () => interpolate(pos(), 750, 48, 1, 0.95); + const predict1Opacity = () => interpolate(pos(), 480, 36, 0, 1); + const predict1Scale = () => interpolate(pos(), 480, 36, 0.9, 1); + + // Case 2 prompt opacity + const case2Opacity = () => interpolate(pos(), 780, 30, 0, 1); + const predict2Opacity = () => interpolate(pos(), 870, 36, 0, 1); + const predict2Scale = () => interpolate(pos(), 870, 36, 0.9, 1); + + // Floating apps vs Comparison panel switch + const floatAppsOpacity = () => { + const p = pos(); + return interpolate(p, 1080, 48, 0, 1) * interpolate(p, 1650, 36, 1, 0); + }; + const floatAppsY = () => interpolate(pos(), 1650, 36, 0, -20); + const floatAppCardOpacity = (delayOffset: number) => interpolate(pos(), 1080 + delayOffset, 36, 0, 1); + + const compPanelOpacity = () => interpolate(pos(), 1692, 48, 0, 1); + const dbCardOpacity = () => interpolate(pos(), 1710, 48, 0, 1); + const dbCardY = () => interpolate(pos(), 1710, 48, 15, 0); + const nnCardOpacity = () => interpolate(pos(), 1730, 48, 0, 1); + const nnCardY = () => interpolate(pos(), 1730, 48, 15, 0); + + // Connection line pulse opacity + const linePulseOpacity = () => { + const p = pos(); + if (p >= 2280 && p <= 2600) { + return 0.3 + 0.5 * Math.sin((p - 2280) * 0.1); + } + return 0.3; + }; + + return ( + + {/* Glow Orbs */} + + + + {/* Tech Header */} + + + + MODULE // LARGE_LANGUAGE_MODELS + + PREVIEW_MODE: PROTO_V1 + {/* Bottom border separator */} + + + + {/* Content Columns */} + + + {/* Left Panel: Prompt Console */} + + // PROMPT CONSOLE + + {/* Case 1 */} + + + All that glitters + {pos() >= 210 && pos() < 750 && ( + + )} + + + is not gold + P = 98.4% + + + + {/* Case 2 */} + + + The capital of France is + {pos() >= 780 && ( + + )} + + + Paris + P = 99.8% + + + + + {/* Right Panel: Technical Visuals */} + + {/* Visual 1: Floating Apps (frame 1080..1650) */} + + + + + + Writing Code + Token Streams + + + + + + Summarizing + Attention Maps + + + + + + + + Q&A Chat + Probability Net + + + + + + Agents + Thought Loops + + + + + + {/* Visual 2: DB vs NN Comparison (frame 1692..2790) */} + + {/* DB Card */} + + + DATABASE LOOKUP + + + SELECT capital FROM countries WHERE name = 'France' + + + ──► Result: 'Paris' (Exact Match) + + + + {/* NN Card */} + + + PROBABILITY NETWORK + + + {/* Mini Neural Network Display */} + + {/* Connections (horizontal simulated lines via thin absolute Views) */} + + + + + + {/* Nodes */} + + "France" + + + + + + + + + + "Paris" + P = 0.98 + + + + + + "London" + P = 0.01 + + + + + + + Learned Statistical Relationships (Trillions of parameters) + + + + + + + {/* Tech Footer */} + + {/* Top border separator */} + + {`FRAME_TIME: ${String(Math.floor(pos() / 60)).padStart(2, "0")}:${String(pos() % 60).padStart(2, "0")}:00`} + SYS_METRIC: PROBABILITY_SAMPLING + + + {/* Captions Overlay */} + + + {currentCaption()} + + + + ); +} diff --git a/apps/ai-explainer/section2.tsx b/apps/ai-explainer/section2.tsx new file mode 100644 index 00000000..3086f4bb --- /dev/null +++ b/apps/ai-explainer/section2.tsx @@ -0,0 +1,584 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { BTN } from "@pocketjs/framework/input"; + +// --------------------------------------------------------------------------- +// Timings and Animation Configurations (Section 2 - Tokenization) +// --------------------------------------------------------------------------- + +const TRACK_FRAMES = 2925; // 48.76 seconds @ 60 Hz + +const CAPTIONS = [ + { start: 0, end: 192, text: "Computers never actually read words." }, + { start: 192, end: 408, text: "Everything must eventually become numbers." }, + { start: 408, end: 600, text: "The first step is called tokenization." }, + { start: 600, end: 810, text: "Instead of treating an entire sentence as one giant object," }, + { start: 810, end: 1080, text: "the tokenizer breaks it into smaller pieces called tokens." }, + { start: 1080, end: 1320, text: "Sometimes they're whole words. Sometimes they're parts of words. Sometimes they're punctuation." }, + { start: 1320, end: 1530, text: "Even emojis become tokens." }, + { start: 1530, end: 1680, text: "Every token receives an ID." }, + { start: 1680, end: 1920, text: "The language model never sees the word itself. It only sees these numerical IDs." }, + { start: 1920, end: 2100, text: "But IDs alone don't carry meaning." }, + { start: 2100, end: 2400, text: "Token 523 isn't inherently similar to token 524." }, + { start: 2400, end: 2700, text: "So the next challenge is teaching the model which words are actually related." }, + { start: 2700, end: 2925, text: "That's where vectors come in." } +]; + +const chipStarts = [780, 870, 960, 1050, 1350]; // frames for All, that, glitt, ers, 🔥 + +const CHIPS_DATA = [ + { val: "All", id: "1023" }, + { val: "that", id: "194" }, + { val: "glitt", id: "8856" }, + { val: "ers", id: "230" }, + { val: "🔥", id: "9532" } +]; + +function interpolate(frame: number, start: number, duration: number, from: number, to: number): number { + if (frame < start) return from; + if (frame > start + duration) return to; + const t = (frame - start) / duration; + const ease = t * t * (3 - 2 * t); // smoothstep + return from + (to - from) * ease; +} + +function toHexColor(r: number, g: number, b: number, a: number): string { + const toHex = (x: number) => { + const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`; +} + +function interpolateColor(frame: number, start: number, duration: number, fromHex: string, toHex: string): string { + const f = interpolate(frame, start, duration, 0, 1); + + const parseHex = (hex: string) => { + const clean = hex.replace("#", ""); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + return { r, g, b }; + }; + + const cFrom = parseHex(fromHex); + const cTo = parseHex(toHex); + + const r = Math.round(cFrom.r * (1 - f) + cTo.r * f); + const g = Math.round(cFrom.g * (1 - f) + cTo.g * f); + const b = Math.round(cFrom.b * (1 - f) + cTo.b * f); + return toHexColor(r, g, b, 1); +} + +export default function AiExplainerSection2() { + const [position, setPosition] = createSignal(0); + const [playing, setPlaying] = createSignal(true); + + onButtonPress(BTN.CIRCLE, () => setPlaying(!playing())); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setPosition((p) => Math.min(TRACK_FRAMES - 1, p + 300))); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setPosition((p) => Math.max(0, p - 300))); + + onFrame(() => { + if (!playing()) return; + setPosition((p) => (p + 1) % TRACK_FRAMES); + }); + + const currentCaption = () => { + const pos = position(); + const cap = CAPTIONS.find(c => pos >= c.start && pos < c.end); + return cap ? cap.text : ""; + }; + + const pos = () => position(); + + // Left Panel Animation Functions + const leftPanelX = () => { + const p = pos(); + if (p < 60) return -80; + if (p < 120) return interpolate(p, 60, 60, -80, 0); + return 0; + }; + + const leftPanelY = () => { + const p = pos(); + if (p > 2790) return interpolate(p, 2790, 60, 0, 100); + return 0; + }; + + const leftPanelOpacity = () => { + const p = pos(); + if (p < 60) return 0; + if (p < 120) return interpolate(p, 60, 60, 0, 1); + if (p > 2790) return interpolate(p, 2790, 60, 1, 0); + return 1; + }; + + // Scanner Sweep Animation Functions + const scannerOpacity = () => { + const p = pos(); + if (p < 390) return 0; + if (p < 420) return interpolate(p, 390, 30, 0, 0.8); + if (p < 690) return 0.8; + return interpolate(p, 690, 18, 0.8, 0); + }; + + const scannerX = () => { + return interpolate(pos(), 420, 270, 0, 760); + }; + + // Text colors + const getTextAllColor = () => { + const p = pos(); + if (p < 780) return "#f8fafc"; + return interpolateColor(p, 780, 12, "#f8fafc", "#38bdf8"); + }; + + const getTextThatColor = () => { + const p = pos(); + if (p < 870) return "#f8fafc"; + return interpolateColor(p, 870, 12, "#f8fafc", "#38bdf8"); + }; + + const getTextGlittersColor = () => { + const p = pos(); + if (p < 960) return "#f8fafc"; + return interpolateColor(p, 960, 12, "#f8fafc", "#f43f5e"); + }; + + const getEmojiOpacity = () => { + return interpolate(pos(), 1290, 30, 0, 1); + }; + + // Chip dynamic state calculations + const getChipOpacity = (i: number) => { + const p = pos(); + const start = chipStarts[i]; + const base = interpolate(p, start, 30, 0, 1); + const shift = interpolate(p, 2460, 48, 1, 0.5); + return base * shift; + }; + + const getChipScale = (i: number) => { + const p = pos(); + const start = chipStarts[i]; + const base = interpolate(p, start, 30, 0.8, 1); + const shift = interpolate(p, 2460, 48, 1, 0.9); + return base * shift; + }; + + const getChipTranslateY = () => { + const p = pos(); + return interpolate(p, 2460, 48, 0, -20); + }; + + const getFlipFactor = () => { + return interpolate(pos(), 1590, 48, 0, 1); + }; + + const getChipBorderColor = () => { + const f = getFlipFactor(); + const red = Math.round(56 * (1 - f) + 96 * f); + const green = Math.round(189 * (1 - f) + 165 * f); + const blue = Math.round(248 * (1 - f) + 250 * f); + return toHexColor(red, green, blue, 1); + }; + + const getChipBgColor = () => { + const f = getFlipFactor(); + const red = Math.round(15 * (1 - f) + 30 * f); + const green = Math.round(23 * (1 - f) + 41 * f); + const blue = Math.round(42 * (1 - f) + 59 * f); + return toHexColor(red, green, blue, 0.8); + }; + + const getChipWordOpacity = () => { + return interpolate(pos(), 1590, 48, 1, 0.3); + }; + + // Right Panel Animation Functions + const vocabPanelX = () => { + const p = pos(); + if (p < 1890) return 40; + if (p < 1950) return interpolate(p, 1890, 60, 40, 0); + if (p > 2808) return interpolate(p, 2808, 60, 0, 100); + return 0; + }; + + const vocabPanelOpacity = () => { + const p = pos(); + if (p < 1890) return 0; + if (p < 1950) return interpolate(p, 1890, 60, 0, 1); + if (p < 2460) return 1; + if (p < 2508) return interpolate(p, 2460, 48, 1, 0.3); + if (p > 2808) return interpolate(p, 2808, 60, 0.3, 0); + return 0.3; + }; + + // Row compare (row 523, 524) + const getRowCompareColor = () => { + const p = pos(); + if (p < 1980) return "#94a3b8"; + return interpolateColor(p, 1980, 48, "#94a3b8", "#ef4444"); + }; + + const getRowCompareBg = () => { + const p = pos(); + const factor = interpolate(p, 1980, 48, 0, 1); + return toHexColor(239, 68, 68, 0.05 * factor); + }; + + const getRowCompareBorder = () => { + const p = pos(); + const factor = interpolate(p, 1980, 48, 0, 1); + return toHexColor(239, 68, 68, 0.2 * factor); + }; + + // Highlight rows (All, that, glitt, ers) + const getVocabRowHighlightFactor = (index: number) => { + const start = chipStarts[index]; + return interpolate(pos(), start, 30, 0, 1); + }; + + const getVocabRowBorder = (index: number) => { + const factor = getVocabRowHighlightFactor(index); + return toHexColor(56, 189, 248, 0.05 + 0.35 * factor); + }; + + const getVocabRowBg = (index: number) => { + const factor = getVocabRowHighlightFactor(index); + return toHexColor(56, 189, 248, 0.08 * factor); + }; + + const getVocabRowColor = (index: number) => { + const start = chipStarts[index]; + return interpolateColor(pos(), start, 30, "#94a3b8", "#38bdf8"); + }; + + const renderChip = (chip: typeof CHIPS_DATA[0], index: number) => { + return ( + + + {chip.val} + + + + ID: {chip.id} + + + ); + }; + + return ( + + {/* Background grid lines */} + {Array.from({ length: 31 }).map((_, i) => ( + + ))} + {Array.from({ length: 17 }).map((_, i) => ( + + ))} + + {/* Glowing Orbs */} + + + + {/* Tech Header */} + + + + MODULE // TOKENIZATION_ENGINE + + PREVIEW_MODE: PROTO_V1 + {/* Bottom border separator */} + + + + {/* Content Columns */} + + + {/* Left Side: Tokenizer Machine */} + + // TOKENIZATION_PIPELINE + + {/* Text Input Box */} + + + All{" "} + that{" "} + glitters + 🔥 + + + {/* Scanner line */} + + + + {/* Token output list */} + + {renderChip(CHIPS_DATA[0], 0)} + {renderChip(CHIPS_DATA[1], 1)} + {renderChip(CHIPS_DATA[2], 2)} + {renderChip(CHIPS_DATA[3], 3)} + {renderChip(CHIPS_DATA[4], 4)} + + + + {/* Right Side: Vocabulary Database Panel */} + + // VOCABULARY DATABASE + + + {/* Rows (unrelated rows that get highlighted red for comparison) */} + + Token: 523 + "apple" + + + Token: 524 + "orange" + + + {/* Matching rows that light up */} + + Token: 1023 + "All" + + + Token: 194 + "that" + + + Token: 8856 + "glitt" + + + Token: 230 + "ers" + + + + + + {/* Tech Footer */} + + {/* Top border separator */} + + {`FRAME_TIME: ${String(Math.floor(pos() / 3600)).padStart(2, "0")}:${String(Math.floor((pos() % 3600) / 60)).padStart(2, "0")}:${String(pos() % 60).padStart(2, "0")}`} + SYS_METRIC: VOCABULARY_INDEXING + + + {/* Captions Overlay */} + + + {currentCaption()} + + + + ); +} diff --git a/apps/ai-explainer/section3.tsx b/apps/ai-explainer/section3.tsx new file mode 100644 index 00000000..f1af4d99 --- /dev/null +++ b/apps/ai-explainer/section3.tsx @@ -0,0 +1,637 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { BTN } from "@pocketjs/framework/input"; + +// --------------------------------------------------------------------------- +// Timings and Animation Configurations (Section 3 - Vector Embeddings) +// --------------------------------------------------------------------------- + +const TRACK_FRAMES = 3552; // 59.20 seconds @ 60 Hz + +const CAPTIONS = [ + { start: 0, end: 270, text: 'If IDs are just arbitrary numbers, how does a model know that the word "King" is related to "Queen",' }, + { start: 270, end: 480, text: 'but completely different from "Apple"?' }, + { start: 480, end: 810, text: "This is solved by vectorization, turning tokens into high-dimensional vectors, or embeddings." }, + { start: 810, end: 1200, text: "Imagine a 3D coordinate space. In this space, words with similar meanings are grouped close together." }, + { start: 1200, end: 1530, text: 'Tokens like "King", "Queen", "Prince", and "Princess" cluster in one region representing royalty.' }, + { start: 1530, end: 1800, text: 'Meanwhile, "Apple", "Banana", and other fruits cluster in a separate area,' }, + { start: 1800, end: 1980, text: 'and tech terms like "Microsoft" float elsewhere.' }, + { start: 1980, end: 2400, text: "By converting text into coordinates, the model can calculate the semantic distance between words." }, + { start: 2400, end: 2550, text: "But this leads to a puzzle." }, + { start: 2550, end: 2970, text: 'If "Apple" has a single fixed position in this space, how does the model know if we mean the fruit or the tech company?' }, + { start: 2970, end: 3300, text: "To solve this, we need a mechanism that updates a word's meaning based on its context." }, + { start: 3300, end: 3552, text: "And that is where attention comes in." } +]; + +function interpolate(frame: number, start: number, duration: number, from: number, to: number): number { + if (frame < start) return from; + if (frame > start + duration) return to; + const t = (frame - start) / duration; + const ease = t * t * (3 - 2 * t); // smoothstep + return from + (to - from) * ease; +} + +function toHexColor(r: number, g: number, b: number, a: number): string { + const toHex = (x: number) => { + const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`; +} + +function interpolateColor(frame: number, start: number, duration: number, fromHex: string, toHex: string): string { + const f = interpolate(frame, start, duration, 0, 1); + + const parseHex = (hex: string) => { + const clean = hex.replace("#", ""); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + return { r, g, b }; + }; + + const cFrom = parseHex(fromHex); + const cTo = parseHex(toHex); + + const r = Math.round(cFrom.r * (1 - f) + cTo.r * f); + const g = Math.round(cFrom.g * (1 - f) + cTo.g * f); + const b = Math.round(cFrom.b * (1 - f) + cTo.b * f); + return toHexColor(r, g, b, 1); +} + +export default function AiExplainerSection3() { + const [position, setPosition] = createSignal(0); + const [playing, setPlaying] = createSignal(true); + + onButtonPress(BTN.CIRCLE, () => setPlaying(!playing())); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setPosition((p) => Math.min(TRACK_FRAMES - 1, p + 300))); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setPosition((p) => Math.max(0, p - 300))); + + onFrame(() => { + if (!playing()) return; + setPosition((p) => (p + 1) % TRACK_FRAMES); + }); + + const currentCaption = () => { + const pos = position(); + const cap = CAPTIONS.find((c) => pos >= c.start && pos < c.end); + return cap ? cap.text : ""; + }; + + const pos = () => position(); + + // Left Panel Animation + const convPanelX = () => { + const p = pos(); + if (p < 60) return -80; + if (p < 120) return interpolate(p, 60, 60, -80, 0); + return 0; + }; + + const convPanelY = () => { + const p = pos(); + if (p >= 3420) return interpolate(p, 3420, 60, 0, -100); + return 0; + }; + + const convPanelOpacity = () => { + const p = pos(); + if (p < 60) return 0; + if (p < 120) return interpolate(p, 60, 60, 0, 1); + if (p >= 3420) return interpolate(p, 3420, 60, 1, 0); + return 1; + }; + + // Right Panel Animation + const spacePanelX = () => { + const p = pos(); + if (p < 72) return 80; + if (p < 132) return interpolate(p, 72, 60, 80, 0); + return 0; + }; + + const spacePanelY = () => { + const p = pos(); + if (p >= 3432) return interpolate(p, 3432, 60, 0, 100); + return 0; + }; + + const spacePanelOpacity = () => { + const p = pos(); + if (p < 72) return 0; + if (p < 132) return interpolate(p, 72, 60, 0, 1); + if (p >= 3432) return interpolate(p, 3432, 60, 1, 0); + return 1; + }; + + // Left Panel Word Boxes + const boxKingOpacity = () => interpolate(pos(), 240, 36, 0, 1); + const boxQueenOpacity = () => interpolate(pos(), 330, 36, 0, 1); + const boxAppleOpacity = () => interpolate(pos(), 420, 36, 0, 1); + + // 3D Space Rotator Orbit + Zoom + const spaceRotateY = () => { + const p = pos(); + const cycle = (p % 360) / 360; + return Math.sin(cycle * Math.PI * 2) * 15; + }; + + const spaceRotateX = () => { + const p = pos(); + const cycle = (p % 360) / 360; + return Math.cos(cycle * Math.PI * 2) * 10; + }; + + const spaceZoomScale = () => { + const p = pos(); + if (p < 2400) return 1.0; + if (p < 2490) return interpolate(p, 2400, 90, 1.0, 1.4); + return 1.4; + }; + + const spaceZoomX = () => { + const p = pos(); + if (p < 2400) return 0; + if (p < 2490) return interpolate(p, 2400, 90, 0, 120); + return 120; + }; + + const spaceZoomY = () => { + const p = pos(); + if (p < 2400) return 0; + if (p < 2490) return interpolate(p, 2400, 90, 0, -120); + return -120; + }; + + const viewportOpacity = () => interpolate(pos(), 780, 60, 0, 1); + + // Node Staggers + const royaltyNodeOpacity = () => interpolate(pos(), 810, 48, 0, 1); + const royaltyNodeScale = () => interpolate(pos(), 810, 48, 0, 1); + + const fruitNodeOpacity = () => interpolate(pos(), 830, 48, 0, 1); + const fruitNodeScale = () => interpolate(pos(), 830, 48, 0, 1); + + const techNodeOpacity = () => interpolate(pos(), 850, 48, 0, 1); + const techNodeScale = () => interpolate(pos(), 850, 48, 0, 1); + + // Cluster Highlight Animations + const royaltyHighlightBg = () => { + const p = pos(); + if (p >= 1320 && p < 1350) return interpolateColor(p, 1320, 30, "#0f172acc", "#38bdf84d"); + if (p >= 1350 && p < 1410) return interpolateColor(p, 1350, 60, "#38bdf84d", "#0f172acc"); + return "#0f172acc"; + }; + + const royaltyHighlightScale = () => { + const p = pos(); + if (p >= 1320 && p < 1350) return interpolate(p, 1320, 30, 1.0, 1.1); + if (p >= 1350 && p < 1410) return interpolate(p, 1350, 60, 1.1, 1.0); + return 1.0; + }; + + const fruitHighlightBg = () => { + const p = pos(); + if (p >= 1470 && p < 1500) return interpolateColor(p, 1470, 30, "#0f172acc", "#34d3994d"); + if (p >= 1500 && p < 1560) return interpolateColor(p, 1500, 60, "#34d3994d", "#0f172acc"); + return "#0f172acc"; + }; + + const fruitHighlightScale = () => { + const p = pos(); + if (p >= 1470 && p < 1500) return interpolate(p, 1470, 30, 1.0, 1.1); + if (p >= 1500 && p < 1560) return interpolate(p, 1500, 60, 1.1, 1.0); + return 1.0; + }; + + const techHighlightBg = () => { + const p = pos(); + if (p >= 1620 && p < 1650) return interpolateColor(p, 1620, 30, "#0f172acc", "#fb923c4d"); + if (p >= 1650 && p < 1710) return interpolateColor(p, 1650, 60, "#fb923c4d", "#0f172acc"); + return "#0f172acc"; + }; + + const techHighlightScale = () => { + const p = pos(); + if (p >= 1620 && p < 1650) return interpolate(p, 1620, 30, 1.0, 1.1); + if (p >= 1650 && p < 1710) return interpolate(p, 1650, 60, 1.1, 1.0); + return 1.0; + }; + + // Distance Connector Lines + const distanceLinesFade = () => { + const p = pos(); + if (p >= 2340) return interpolate(p, 2340, 30, 1, 0); + return 1; + }; + + const matchLineOpacity = () => { + const p = pos(); + if (p < 1920) return 0; + if (p < 1956) return interpolate(p, 1920, 36, 0, 0.85) * distanceLinesFade(); + return 0.85 * distanceLinesFade(); + }; + + const matchLabelOpacity = () => { + const p = pos(); + if (p < 1950) return 0; + if (p < 1974) return interpolate(p, 1950, 24, 0, 1) * distanceLinesFade(); + return 1 * distanceLinesFade(); + }; + + const diffLineOpacity = () => { + const p = pos(); + if (p < 2040) return 0; + if (p < 2076) return interpolate(p, 2040, 36, 0, 0.85) * distanceLinesFade(); + return 0.85 * distanceLinesFade(); + }; + + const diffLabelOpacity = () => { + const p = pos(); + if (p < 2070) return 0; + if (p < 2094) return interpolate(p, 2070, 24, 0, 1) * distanceLinesFade(); + return 1 * distanceLinesFade(); + }; + + // Apple Node Split + const singleAppleOpacity = () => { + const p = pos(); + if (p < 2490) return fruitNodeOpacity(); + if (p < 2520) return interpolate(p, 2490, 30, 1, 0); + return 0; + }; + + const splitAppleFruitOpacity = () => { + const p = pos(); + if (p < 2520) return 0; + if (p < 2568) return interpolate(p, 2520, 48, 0, 1); + return 1; + }; + + const splitAppleTechOpacity = () => { + const p = pos(); + if (p < 2550) return 0; + if (p < 2598) return interpolate(p, 2550, 48, 0, 1); + return 1; + }; + + const appleContextBorderFruit = () => { + const p = pos(); + if (p >= 3000) return "#10b981"; + return "#34d399"; + }; + + const appleContextBorderTech = () => { + const p = pos(); + if (p >= 3030) return "#60a5fa"; + return "#fb923c"; + }; + + return ( + + {/* Background Grid Pattern */} + + + {/* Glowing Orbs */} + + + + {/* Technical Header */} + + {/* Bottom Border Line */} + + + + + MODULE // VECTOR_EMBEDDINGS + + PREVIEW_MODE: PROTO_V1 + + + {/* Main Content Area */} + + + {/* Left Column: Vector Embedding Generator */} + + // VECTOR_EMBEDDING_GENERATOR + + {/* Word Box: King */} + + "King" + ──► + [0.28, -0.45, 0.81, ...] + + + {/* Word Box: Queen */} + + "Queen" + ──► + [0.26, -0.42, 0.79, ...] + + + {/* Word Box: Apple */} + + "Apple" + ──► + [0.05, 0.89, -0.32, ...] + + + + {/* Right Column: 3D Scatter Space */} + + + // 3D_VECTOR_SPACE + + + {/* 3D Viewport Box */} + + {/* Grid Axes */} + + + + {/* Nodes: Royalty (Blue) */} + + King + + + + Queen + + + + Prince + + + + Princess + + + {/* Nodes: Fruit (Green) */} + + Banana + + + {/* Single Apple Node */} + + Apple + + + {/* Nodes: Tech (Orange) */} + + Microsoft + + + {/* Split Nodes for Context Zoom (Apple Fruit & Tech) */} + + Apple (Fruit) + + + + Apple (Tech) + + + {/* Distance Connector Lines */} + {/* Line: King -> Queen */} + + + Sim = 0.88 + + + {/* Line: King -> Apple */} + + + Sim = 0.05 + + + + + + {/* Tech Footer Details */} + + {/* Top Border Line */} + + + FRAME_TIME: 02:24:12 + SYS_METRIC: VECTOR_MAPPING + + + {/* Captions Overlay */} + + + {currentCaption()} + + + + ); +} diff --git a/apps/ai-explainer/section4.tsx b/apps/ai-explainer/section4.tsx new file mode 100644 index 00000000..b9af1aac --- /dev/null +++ b/apps/ai-explainer/section4.tsx @@ -0,0 +1,576 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { BTN } from "@pocketjs/framework/input"; + +// --------------------------------------------------------------------------- +// Timings and Animation Configurations (Section 4 - Attention Mechanism) +// --------------------------------------------------------------------------- + +const TRACK_FRAMES = 3520; // 58.68 seconds @ 60 Hz + +const CAPTIONS = [ + { start: 0, end: 408, text: 'In 2017, a team of Google researchers published a paper that changed everything: "Attention Is All You Need".' }, + { start: 408, end: 828, text: "Attention allows a model to look at a sentence and weigh the relationships between words." }, + { start: 828, end: 1470, text: 'Consider these two sentences: "Apple released new Macs" versus "Apple tastes delicious".' }, + { start: 1470, end: 1830, text: 'In the first sentence, the attention mechanism draws a strong connection between "Apple" and "Macs".' }, + { start: 1830, end: 2220, text: 'This context shifts the vector representation of "Apple" closer to "Microsoft" and technology.' }, + { start: 2220, end: 2490, text: 'In the second, it connects "Apple" with "tastes" and "delicious",' }, + { start: 2490, end: 2700, text: "shifting its meaning toward fruit." }, + { start: 2700, end: 2970, text: "Instead of reading words in isolation, the model dynamically computes context weights for every single token." }, + { start: 2970, end: 3240, text: "This was so revolutionary, researchers abandoned previous architectures like LSTMs" }, + { start: 3240, end: 3520, text: "and built a brand new system entirely around attention. They called it: The Transformer." } +]; + +function interpolate(frame: number, start: number, duration: number, from: number, to: number): number { + if (frame < start) return from; + if (frame > start + duration) return to; + const t = (frame - start) / duration; + const ease = t * t * (3 - 2 * t); // smoothstep + return from + (to - from) * ease; +} + +function toHexColor(r: number, g: number, b: number, a: number): string { + const toHex = (x: number) => { + const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`; +} + +function interpolateColor(frame: number, start: number, duration: number, fromHex: string, toHex: string): string { + const f = interpolate(frame, start, duration, 0, 1); + + const parseHex = (hex: string) => { + const clean = hex.replace("#", ""); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + return { r, g, b }; + }; + + const cFrom = parseHex(fromHex); + const cTo = parseHex(toHex); + + const r = Math.round(cFrom.r * (1 - f) + cTo.r * f); + const g = Math.round(cFrom.g * (1 - f) + cTo.g * f); + const b = Math.round(cFrom.b * (1 - f) + cTo.b * f); + return toHexColor(r, g, b, 1); +} + +export default function AiExplainerSection4() { + const [position, setPosition] = createSignal(0); + const [playing, setPlaying] = createSignal(true); + + onButtonPress(BTN.CIRCLE, () => setPlaying(!playing())); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setPosition((p) => Math.min(TRACK_FRAMES - 1, p + 300))); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setPosition((p) => Math.max(0, p - 300))); + + onFrame(() => { + if (!playing()) return; + setPosition((p) => (p + 1) % TRACK_FRAMES); + }); + + const currentCaption = () => { + const pos = position(); + const cap = CAPTIONS.find((c) => pos >= c.start && pos < c.end); + return cap ? cap.text : ""; + }; + + const pos = () => position(); + + // Stage 1: Google Paper Card (90f - 690f / 1.5s - 11.5s) + const paperOpacity = () => { + const p = pos(); + if (p < 90) return 0; + if (p < 150) return interpolate(p, 90, 60, 0, 1); + if (p < 690) return 1; + if (p < 750) return interpolate(p, 690, 60, 1, 0); + return 0; + }; + + const paperScale = () => { + const p = pos(); + if (p < 90) return 0.8; + if (p < 150) return interpolate(p, 90, 60, 0.8, 1.0); + if (p < 690) return 1.0; + if (p < 750) return interpolate(p, 690, 60, 1.0, 0.8); + return 0.8; + }; + + // Stage 2: Attention Comparison Frame (810f - 2880f / 13.5s - 48s) + const attFrameOpacity = () => { + const p = pos(); + if (p < 810) return 0; + if (p < 870) return interpolate(p, 810, 60, 0, 1); + if (p < 2880) return 1; + if (p < 2940) return interpolate(p, 2880, 60, 1, 0); + return 0; + }; + + const attFrameScale = () => { + const p = pos(); + if (p >= 2880) return interpolate(p, 2880, 60, 1.0, 0.95); + return 1.0; + }; + + // Token list entrance stagger + const tokenY = () => { + const p = pos(); + if (p < 850) return 30; + if (p < 890) return interpolate(p, 850, 40, 30, 0); + return 0; + }; + + const tokenOpacity = () => interpolate(pos(), 850, 40, 0, 1); + + // Case 1: Tech connection (1500f - 2250f / 25s - 37.5s) + const techLineOpacity = () => { + const p = pos(); + if (p < 1500) return 0; + if (p < 1560) return interpolate(p, 1500, 60, 0, 0.85); + return 0.85; + }; + + const techBadgeOpacity = () => interpolate(pos(), 1560, 30, 0, 1); + + const techIndicatorX = () => { + const p = pos(); + if (p < 1680) return 40; + if (p < 1728) return interpolate(p, 1680, 48, 40, 0); + return 0; + }; + + const techIndicatorOpacity = () => interpolate(pos(), 1680, 48, 0, 1); + + const techAppleBg = () => { + const p = pos(); + if (p >= 1710 && p < 1758) return interpolateColor(p, 1710, 48, "#040814b3", "#38bdf81f"); + if (p >= 1758) return "#38bdf81f"; + return "#040814b3"; + }; + + const techAppleX = () => { + const p = pos(); + if (p >= 1710 && p < 1758) return interpolate(p, 1710, 48, 0, 30); + if (p >= 1758) return 30; + return 0; + }; + + // Case 2: Food connection (2250f - 2880f / 37.5s - 48s) + const foodLineOpacity = () => { + const p = pos(); + if (p < 2250) return 0; + if (p < 2310) return interpolate(p, 2250, 60, 0, 0.85); + return 0.85; + }; + + const foodBadgeOpacity = () => interpolate(pos(), 2310, 30, 0, 1); + + const foodIndicatorX = () => { + const p = pos(); + if (p < 2430) return 40; + if (p < 2478) return interpolate(p, 2430, 48, 40, 0); + return 0; + }; + + const foodIndicatorOpacity = () => interpolate(pos(), 2430, 48, 0, 1); + + const foodAppleBg = () => { + const p = pos(); + if (p >= 2460 && p < 2508) return interpolateColor(p, 2460, 48, "#040814b3", "#34d3991f"); + if (p >= 2508) return "#34d3991f"; + return "#040814b3"; + }; + + const foodAppleX = () => { + const p = pos(); + if (p >= 2460 && p < 2508) return interpolate(p, 2460, 48, 0, 30); + if (p >= 2508) return 30; + return 0; + }; + + // Stage 3: LSTM vs Transformer Reveal (2970f - 3420f / 49.5s - 57s) + const transformerBoxOpacity = () => { + const p = pos(); + if (p < 2970) return 0; + if (p < 3030) return interpolate(p, 2970, 60, 0, 1); + if (p < 3420) return 1; + if (p < 3480) return interpolate(p, 3420, 60, 1, 0); + return 0; + }; + + const transformerBoxScale = () => { + const p = pos(); + if (p < 2970) return 0.8; + if (p < 3030) return interpolate(p, 2970, 60, 0.8, 1.0); + if (p < 3420) return 1.0; + if (p < 3480) return interpolate(p, 3420, 60, 1.0, 1.1); + return 0.8; + }; + + // LSTM Cross-out strike line + const lstmCrossScaleX = () => interpolate(pos(), 3120, 48, 0, 1); + + const lstmBoxColor = () => { + const p = pos(); + if (p >= 3150) return "#ef4444"; + return "#cbd5e1"; + }; + + const lstmBoxBorder = () => { + const p = pos(); + if (p >= 3150) return "#ef4444"; + return "#38bdf833"; + }; + + const lstmBoxOpacity = () => { + const p = pos(); + if (p >= 3150) return 0.4; + return 1.0; + }; + + const transCardScale = () => { + const p = pos(); + if (p >= 3210 && p < 3246) return interpolate(p, 3210, 36, 1.0, 1.1); + if (p >= 3246 && p < 3282) return interpolate(p, 3246, 36, 1.1, 1.0); + return 1.0; + }; + + return ( + + {/* Background Grid Pattern */} + + + {/* Glowing Orbs */} + + + + {/* Technical Header */} + + {/* Bottom Border Line */} + + + + + MODULE // ATTENTION_MECHANISM + + PREVIEW_MODE: PROTO_V1 + + + {/* Main Content Area */} + + + {/* Stage 1: Google Research Paper Card */} + + + RESEARCH // GOOGLE_2017 + + Attention Is All You Need + Vaswani, Shazeer, Parmar, Uszkoreit, Jones... + + + {/* Stage 2: Attention Comparison Frame */} + + {/* Row 1: Technology Context */} + + + CASE_01 // TECHNOLOGY_MAPPING + + + {/* Tokens List */} + + + Apple + + + + released + + + + new + + + + Macs + + + + {/* Connection Connector Line: Apple -> Macs */} + + + w = 0.85 + + + {/* Context Indicator (Right side) */} + + [TECH] + + TECHNOLOGY_CLUSTER + Vector Shift: → [Microsoft, Hardware] + + + + + {/* Row 2: Food Context */} + + + CASE_02 // BOTANICAL_MAPPING + + + {/* Tokens List */} + + + Apple + + + + tastes + + + + delicious + + + + {/* Connection Connector Line: Apple -> delicious */} + + + w = 0.92 + + + {/* Context Indicator (Right side) */} + + [FOOD] + + AGRICULTURE_CLUSTER + Vector Shift: → [Fruit, Organic] + + + + + + + {/* Stage 3: LSTM vs Transformer Architecture Reveal */} + + {/* Left Column: LSTM */} + + LEGACY ARCHITECTURE + + + LSTM / RNN + + {/* Red Strike Line */} + + + + + {/* Right Column: Transformer */} + + MODERN PARADIGM + + + + ATTENTION NATIVE + + THE TRANSFORMER + + + + + + + {/* Tech Footer Details */} + + {/* Top Border Line */} + + + FRAME_TIME: 02:43:28 + SYS_METRIC: CONTEXTUAL_WEIGHTING + + + {/* Captions Overlay */} + + + {currentCaption()} + + + + ); +} diff --git a/apps/motions/app.tsx b/apps/motions/app.tsx index a39ca24e..72d7c512 100644 --- a/apps/motions/app.tsx +++ b/apps/motions/app.tsx @@ -15,7 +15,7 @@ import { Show, createSignal } from "solid-js"; import type { JSX } from "solid-js"; import { Image, Text, View } from "@pocketjs/framework/components"; -import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { BTN } from "@pocketjs/framework/input"; // --------------------------------------------------------------------------- @@ -651,11 +651,26 @@ const PAGES: PageDef[] = [ }, ]; +const TRACK_FRAMES = 200; + export default function Motions() { const [index, setIndex] = createSignal(0); + const [position, setPosition] = createSignal(0); // frame tracker + const page = () => PAGES[index()]; onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setIndex((i) => (i + 1) % PAGES.length)); onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setIndex((i) => (i + PAGES.length - 1) % PAGES.length)); + + onFrame(() => { + const p = position() + 1; + if (p >= TRACK_FRAMES) { + setIndex((i) => (i + 1) % PAGES.length); + setPosition(0); + } else { + setPosition(p); + } + }); + return ( diff --git a/apps/music/app.tsx b/apps/music/app.tsx index 4dedd1c8..1c05a069 100644 --- a/apps/music/app.tsx +++ b/apps/music/app.tsx @@ -44,7 +44,7 @@ const TRACKS: Track[] = [ }, ]; -const TRACK_FRAMES = 300; // 5s per track at 60 Hz (demo-length, not the real song) +const TRACK_FRAMES = 100; // 5s per track at 60 Hz (demo-length, not the real song) const PROGRESS_TRACK_W = 160; // progress track px — matches the w-[160] track class // --------------------------------------------------------------------------- diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index 78e23564..1000a292 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -7,6 +7,7 @@ // spec.ts. Keep this generator free of anything non-deterministic (no dates, // no env, no object-key sorting surprises — insertion order only). +import { fileURLToPath } from "node:url"; import { ANALOG_CENTER, ANIMATABLE, @@ -397,7 +398,7 @@ export function generateRust(): string { } if (import.meta.main) { - const out = new URL("../../engine/core/src/spec.rs", import.meta.url).pathname; + const out = fileURLToPath(new URL("../../engine/core/src/spec.rs", import.meta.url)); await Bun.write(out, generateRust()); console.log(`wrote ${out}`); } diff --git a/engine/wasm/Cargo.toml b/engine/wasm/Cargo.toml index 91256969..a711373f 100644 --- a/engine/wasm/Cargo.toml +++ b/engine/wasm/Cargo.toml @@ -19,5 +19,7 @@ crate-type = ["cdylib"] pocketjs-core = { path = "../core" } [profile.release] -opt-level = "s" +opt-level = 3 lto = true +codegen-units = 1 +panic = "abort" diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index adaa2e58..f2d2cd5b 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -59,9 +59,13 @@ unsafe fn text<'a>(ptr: *const u8, len: usize) -> &'a str { /// bitmap resources such as rounded-corner masks; zero is the legacy default /// of one sample per logical pixel. Idempotent; call before anything else. #[no_mangle] -pub extern "C" fn ui_init(raster_density: u32) { +pub extern "C" fn ui_init(raster_density: u32, w: u32, h: u32) { unsafe { - UI = Some(Ui::new_with_raster_density(raster_density.max(1))); + let w_val = if w == 0 { 480 } else { w }; + let h_val = if h == 0 { 272 } else { h }; + let mut ui = Ui::new_with_raster_density(raster_density.max(1)); + ui.set_viewport(w_val as f32, h_val as f32); + UI = Some(ui); FRAMEBUFFER.clear(); DAMAGE_TRACKER = DamageTracker::new(); } diff --git a/framework/compiler/bake-svg.ts b/framework/compiler/bake-svg.ts index 6e30b504..9ff3f450 100644 --- a/framework/compiler/bake-svg.ts +++ b/framework/compiler/bake-svg.ts @@ -320,8 +320,15 @@ export function bakeSvg(svg: string, rasterDensity = 1): DecodedImage { if (logicalWidth <= 0 || logicalHeight <= 0) { throw new Error(`svg bake: bad dimensions ${logicalWidth}x${logicalHeight}`); } - const width = logicalWidth * rasterDensity; - const height = logicalHeight * rasterDensity; + const rawWidth = logicalWidth * rasterDensity; + const rawHeight = logicalHeight * rasterDensity; + const nextPowerOf2 = (n: number) => { + let p = 1; + while (p < n) p *= 2; + return p; + }; + const width = nextPowerOf2(rawWidth); + const height = nextPowerOf2(rawHeight); const viewBox = (root.viewBox ?? `0 0 ${logicalWidth} ${logicalHeight}`).trim().split(/\s+/).map(Number); if (viewBox.length !== 4 || viewBox.some((n) => !Number.isFinite(n))) { throw new Error(`svg bake: invalid viewBox="${root.viewBox}"`); diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index 3b8b0164..c9972c71 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -25,59 +25,45 @@ import vueJsxVaporPkg from "vue-jsx-vapor/package.json"; import babelCorePkg from "@babel/core/package.json"; import tsPresetPkg from "@babel/preset-typescript/package.json"; import type { PocketFramework } from "../src/config.ts"; +import { fileURLToPath } from "node:url"; +import { resolve, join } from "node:path"; export type { PocketFramework }; -export const RENDERER_PATH = new URL("../src/renderer.ts", import.meta.url).pathname; -export const RENDERER_SOLID_PATH = new URL("../src/renderer-solid.ts", import.meta.url).pathname; -export const RENDERER_VUE_VAPOR_PATH = new URL("../src/renderer-vue-vapor.ts", import.meta.url).pathname; +const resolveLocalPath = (rel: string) => resolve(fileURLToPath(new URL(rel, import.meta.url))); -const INDEX_PATH = new URL("../src/index.ts", import.meta.url).pathname; -const INDEX_VUE_VAPOR_PATH = new URL("../src/index-vue-vapor.ts", import.meta.url).pathname; -const ANIMATION_PATH = new URL("../src/animation.ts", import.meta.url).pathname; -const COMPONENTS_PATH = new URL("../src/components.ts", import.meta.url).pathname; -const COMPONENTS_VUE_VAPOR_PATH = new URL("../src/components-vue-vapor.ts", import.meta.url).pathname; -const CONFIG_PATH = new URL("../src/config.ts", import.meta.url).pathname; -const CLOCK_PATH = new URL("../src/clock.ts", import.meta.url).pathname; -const DEVTOOLS_PATH = new URL("../src/devtools.ts", import.meta.url).pathname; -const EFFECTS_PATH = new URL("../src/effects.ts", import.meta.url).pathname; -const HOST_PATH = new URL("../src/host.ts", import.meta.url).pathname; -const HOT_PATH = new URL("../src/hot.ts", import.meta.url).pathname; -const INPUT_API_PATH = new URL("../src/input-api.ts", import.meta.url).pathname; -const LAUNCHER_PATH = new URL("../src/launcher.ts", import.meta.url).pathname; -const LIFECYCLE_PATH = new URL("../src/lifecycle.ts", import.meta.url).pathname; -const LIFECYCLE_VUE_VAPOR_PATH = new URL("../src/lifecycle-vue-vapor.ts", import.meta.url).pathname; -const OSK_PATH = new URL("../src/osk.tsx", import.meta.url).pathname; -const MANIFEST_PATH = new URL("../src/manifest/index.ts", import.meta.url).pathname; -const PACKAGE_PATH = new URL( - "../../contracts/spec/pocket-package.ts", - import.meta.url, -).pathname; -const PLATFORM_PATH = new URL("../src/platform.ts", import.meta.url).pathname; -const PRELUDE_PATH = new URL("../src/prelude.ts", import.meta.url).pathname; -const GENERATED_STYLES_PATH = new URL( - "../src/styles.generated.ts", - import.meta.url, -).pathname; -const VITA_PACKAGE_PATH = new URL( - "../../tools/vita-package.ts", - import.meta.url, -).pathname; -const VUE_VAPOR_RUNTIME_PATH = new URL( - "../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js", - import.meta.url, -).pathname; -const SOLID_RUNTIME_PATH = new URL( - "../../node_modules/solid-js/dist/solid.js", - import.meta.url, -).pathname; -const SOLID_UNIVERSAL_RUNTIME_PATH = new URL( - "../../node_modules/solid-js/universal/dist/universal.js", - import.meta.url, -).pathname; +export const RENDERER_PATH = resolveLocalPath("../src/renderer.ts"); +export const RENDERER_SOLID_PATH = resolveLocalPath("../src/renderer-solid.ts"); +export const RENDERER_VUE_VAPOR_PATH = resolveLocalPath("../src/renderer-vue-vapor.ts"); + +const INDEX_PATH = resolveLocalPath("../src/index.ts"); +const INDEX_VUE_VAPOR_PATH = resolveLocalPath("../src/index-vue-vapor.ts"); +const ANIMATION_PATH = resolveLocalPath("../src/animation.ts"); +const COMPONENTS_PATH = resolveLocalPath("../src/components.ts"); +const COMPONENTS_VUE_VAPOR_PATH = resolveLocalPath("../src/components-vue-vapor.ts"); +const CONFIG_PATH = resolveLocalPath("../src/config.ts"); +const CLOCK_PATH = resolveLocalPath("../src/clock.ts"); +const DEVTOOLS_PATH = resolveLocalPath("../src/devtools.ts"); +const EFFECTS_PATH = resolveLocalPath("../src/effects.ts"); +const HOST_PATH = resolveLocalPath("../src/host.ts"); +const HOT_PATH = resolveLocalPath("../src/hot.ts"); +const INPUT_API_PATH = resolveLocalPath("../src/input-api.ts"); +const LAUNCHER_PATH = resolveLocalPath("../src/launcher.ts"); +const LIFECYCLE_PATH = resolveLocalPath("../src/lifecycle.ts"); +const LIFECYCLE_VUE_VAPOR_PATH = resolveLocalPath("../src/lifecycle-vue-vapor.ts"); +const OSK_PATH = resolveLocalPath("../src/osk.tsx"); +const MANIFEST_PATH = resolveLocalPath("../src/manifest/index.ts"); +const PACKAGE_PATH = resolveLocalPath("../../contracts/spec/pocket-package.ts"); +const PLATFORM_PATH = resolveLocalPath("../src/platform.ts"); +const PRELUDE_PATH = resolveLocalPath("../src/prelude.ts"); +const GENERATED_STYLES_PATH = resolveLocalPath("../src/styles.generated.ts"); +const VITA_PACKAGE_PATH = resolveLocalPath("../../tools/vita-package.ts"); +const VUE_VAPOR_RUNTIME_PATH = resolveLocalPath("../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js"); +const SOLID_RUNTIME_PATH = resolveLocalPath("../../node_modules/solid-js/dist/solid.js"); +const SOLID_UNIVERSAL_RUNTIME_PATH = resolveLocalPath("../../node_modules/solid-js/universal/dist/universal.js"); const PACKAGE_NAME = "@pocketjs/framework"; -const CACHE_DIR = new URL("../../.cache/transforms/", import.meta.url).pathname; +const CACHE_DIR = resolveLocalPath("../../.cache/transforms/"); const CACHE_VERSION = "2"; const JSX_PARSER_OPTS: ParserOptions = { plugins: ["jsx"] }; @@ -238,12 +224,17 @@ function makeCollector(out: Collected, framework: PocketFramework): PluginObj { for (const q of path.node.quasis) add(q.value.cooked ?? q.value.raw); }, JSXText(path) { - const raw = path.node.extra?.raw; - if (typeof raw === "string" && raw !== path.node.value) { - throw path.buildCodeFrameError( - "PocketJS: HTML entities in JSX text are not decoded by the JSX renderer - " + - 'write the literal character (é, ♥) or a string expression {"\\u00e9"} instead.', - ); + let raw = path.node.extra?.raw; + let val = path.node.value; + if (typeof raw === "string") { + raw = raw.replace(/\r/g, ""); + val = val.replace(/\r/g, ""); + if (raw !== val) { + throw path.buildCodeFrameError( + "PocketJS: HTML entities in JSX text are not decoded by the JSX renderer - " + + 'write the literal character (é, ♥) or a string expression {"\\u00e9"} instead.', + ); + } } add(path.node.value); }, @@ -414,7 +405,7 @@ export async function transformFile( ); } const key = await hashKey(path, src, framework, options.features); - const cacheFile = CACHE_DIR + key + ".json"; + const cacheFile = join(CACHE_DIR, key + ".json"); const cached = (await Bun.file(cacheFile).json().catch(() => null)) as CacheEntry | null; if (cached && typeof cached.code === "string") { return { diff --git a/framework/src/config.ts b/framework/src/config.ts index e95f39d2..3aabeb8d 100644 --- a/framework/src/config.ts +++ b/framework/src/config.ts @@ -17,6 +17,11 @@ export interface PocketConfig { * prefers over the repo root one. */ theme?: AnimationTheme; + /** + * Default raster density / scale factor (1 to 10) to use when baking + * fonts, SVGs, and other assets into the application's .pak bundle. + */ + rasterDensity?: number; } export function definePocketConfig(config: PocketConfig): PocketConfig { diff --git a/framework/src/host.ts b/framework/src/host.ts index 099a0fd2..e675a164 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -32,6 +32,8 @@ export interface BuildHostContract { * node id 0 means "none" (anchor 0 = append, setFocus 0 = clear). Texture * handles have operation-specific 0-based or generation-tagged contracts. */ export interface HostOps { + /** Optional layout viewport dimensions ({ w, h }) set by host. */ + __viewport?: { w: number; h: number }; /** type: spec NODE_TYPE (0 view, 1 text, 2 image) → new node id. */ createNode(type: number): number; /** Destroys the whole subtree; frees anim tracks; clears focus if inside. */ diff --git a/hosts/web/engine.js b/hosts/web/engine.js index 4c0559bb..68c0b8fd 100644 --- a/hosts/web/engine.js +++ b/hosts/web/engine.js @@ -24,7 +24,7 @@ function positiveIntParam(name, fallback, max = 32000) { } const LOGICAL_W = positiveIntParam("width", DEFAULT_FB_W); const LOGICAL_H = positiveIntParam("height", DEFAULT_FB_H); -const RENDER_SCALE = positiveIntParam("scale", 1, 4); +const RENDER_SCALE = positiveIntParam("scale", 1, 10); const RASTER_DENSITY = positiveIntParam("density", RENDER_SCALE, 255); const FB_W = LOGICAL_W * RENDER_SCALE; const FB_H = LOGICAL_H * RENDER_SCALE; @@ -108,6 +108,9 @@ let frameCb = null; // runs the 2 FPS world a headless agent sees — on a real screen. const VALID_HZ = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60]; let simHz = 60; +let renderScale = 1; +let logicalW = 480; +let logicalH = 272; let logSink = () => {}; let fpsSink = () => {}; let statsFrames = 0; @@ -223,7 +226,7 @@ function safeFrame() { function blit() { if (!wasm || !ctx) return; - const pixels = wasm.renderScaledIncremental(RENDER_SCALE); + const pixels = wasm.renderScaledIncremental ? wasm.renderScaledIncremental(RENDER_SCALE) : wasm.renderScaled(RENDER_SCALE); imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); drawHud(ctx, FB_W, FB_H, hudFps, hudMem); // built-in on-canvas overlay @@ -342,10 +345,11 @@ export async function load(name, opts = {}) { if (!wasm) throw new Error("mount() first"); stop(); frameCb = null; - wasm.init(RASTER_DENSITY); // fresh Ui: tree/styles/atlases/textures all reset + wasm.init(RASTER_DENSITY, LOGICAL_W, LOGICAL_H); // fresh Ui: tree/styles/atlases/textures all reset // Host contract (see apps/hero/main.tsx): both globals BEFORE eval, reset // EVERY load so nothing stale leaks across reloads. globalThis.ui = wasm.ops; + globalThis.ui.__viewport = { w: LOGICAL_W, h: LOGICAL_H }; globalThis.frame = undefined; globalThis.__simHz = simHz; // clock policy — before eval, like __pak // DevTools: identity + transport BEFORE eval; render() picks them up. diff --git a/hosts/web/wasm-ops.d.ts b/hosts/web/wasm-ops.d.ts index 9bc17604..088eaa42 100644 --- a/hosts/web/wasm-ops.d.ts +++ b/hosts/web/wasm-ops.d.ts @@ -9,8 +9,8 @@ export declare const FB_H: number; export interface WasmUi { ops: HostOps; exports: WebAssembly.Exports & { memory: WebAssembly.Memory }; - /** Reset the core and set raster samples per logical pixel (default 1). */ - init(rasterDensity?: number): void; + /** Reset the core and set raster samples per logical pixel (default 1) and viewport dimensions. */ + init(rasterDensity?: number, width?: number, height?: number): void; /** Advance exactly one fixed-dt (1/60 s) frame. */ tick(): void; /** Hash the current DrawList without rasterizing it; null for an older wasm. */ diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index da3bf20a..8fef76c7 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -36,14 +36,18 @@ export async function createWasmUi(wasm, options = {}) { return value; } - const viewportWidth = integerInRange(options.width ?? FB_W, "viewport width", 1, 32000); - const viewportHeight = integerInRange(options.height ?? FB_H, "viewport height", 1, 32000); + let viewportWidth = integerInRange(options.width ?? FB_W, "viewport width", 1, 32000); + let viewportHeight = integerInRange(options.height ?? FB_H, "viewport height", 1, 32000); const initialDensity = integerInRange(options.rasterDensity ?? 1, "rasterDensity", 1, 255); - const init = (rasterDensity = initialDensity) => { - ex.ui_init(integerInRange(rasterDensity, "rasterDensity", 1, 255)); - // Older wasm binaries predate ui_set_viewport (same convention as - // drawHash): tolerate them at the stock size, fail loud otherwise. + const init = (rasterDensity = initialDensity, w = viewportWidth, h = viewportHeight) => { + viewportWidth = integerInRange(w, "viewport width", 1, 32000); + viewportHeight = integerInRange(h, "viewport height", 1, 32000); + ex.ui_init( + integerInRange(rasterDensity, "rasterDensity", 1, 255), + viewportWidth, + viewportHeight, + ); if (ex.ui_set_viewport) ex.ui_set_viewport(viewportWidth, viewportHeight); else if (viewportWidth !== FB_W || viewportHeight !== FB_H) { throw new Error("this pocketjs.wasm predates ui_set_viewport — rebuild it: bun tools/wasm.ts"); @@ -146,7 +150,7 @@ export async function createWasmUi(wasm, options = {}) { }, /** Rasterize the logical DrawList directly at an integer physical scale. */ renderScaled(scale) { - scale = integerInRange(scale, "render scale", 1, 4); + scale = integerInRange(scale, "render scale", 1, 10); return framebufferView(ex.ui_render_scaled(scale), scale); }, /** Repaint only changed regions, falling back for older wasm builds. */ diff --git a/package.json b/package.json index 45b60db0..081b0d62 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "pocket:pack": "bun tools/pocket-pack.ts", "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", "tape": "bun tools/tape.ts", + "render": "bun tools/render.ts", "tape:check": "bun tools/tape.ts replay hero-main tests/tapes/hero-main.tape.json --assert tests/tapes/hero-main.hashes.json", "devtools": "bun tools/devtools.ts", "devtools:psp": "bun tools/devtools-psp.ts", diff --git a/plans/video-rendering-pipeline.md b/plans/video-rendering-pipeline.md new file mode 100644 index 00000000..8b8130e0 --- /dev/null +++ b/plans/video-rendering-pipeline.md @@ -0,0 +1,201 @@ +# High-Performance Video Rendering Pipeline with PocketJS + +This proposal explores the feasibility, architecture, and performance benefits of using **PocketJS** as the core frame-generation engine in a video rendering pipeline (similar to Remotion or Hyperframes). + +--- + +## 1. Architectural Overview: Remotion vs. PocketJS + +Traditional JS-based video renderers (like Remotion) rely on headless browser automation (Puppeteer or Playwright running Chromium) to load pages, step animations, capture frame screenshots, and encode them. + +PocketJS allows for an in-process, headless architecture that runs entirely inside a single [Bun](https://bun.sh) process without browser overhead. + +### Render Pipeline Flow + +```mermaid +graph TD + subgraph Remotion / Browser-Based + R_App[React JSX App] --> R_DOM[DOM Tree Update] + R_DOM --> R_Browser[Headless Chromium] + R_Browser --> R_GPU[GPU Paint & Readback] + R_GPU -->|Browser IPC: JPEG/PNG| R_Pipe[Node.js Parent Process] + R_Pipe -->|Disk I/O / PNG Compress| R_Disk[Disk / FFmpeg Pipe] + end + + subgraph PocketJS / In-Process + P_App[Solid/Vue JSX App] -->|Reactive Graph| P_Wasm[WASM exports: ui_set_prop, etc.] + P_Wasm -->|In-Process Layout| P_Core[Rust no_std Core] + P_Core -->|Scanline Software Raster| P_RAM[WASM Buffer: Raw RGBA8] + P_RAM -->|Direct RAM Pipe| P_FFmpeg[FFmpeg Subprocess] + end + + style Remotion / Browser-Based fill:#f9d7d7,stroke:#c0392b,stroke-width:2px + style PocketJS / In-Process fill:#d5f5e3,stroke:#27ae60,stroke-width:2px +``` + +### Comparison Matrix + +| Criteria | Browser-Based (Remotion / Hyperframes) | PocketJS-Based Pipeline | +| :--- | :--- | :--- | +| **Runtime Environment** | Node.js + Headless Browser (Chromium) | Bun / Node.js + WASM (Rust Core) | +| **Memory Footprint** | ~200MB - 1GB+ per worker thread | **< 15 MB** per worker | +| **Startup / Boot Time** | ~1 to 3 seconds (browser launch & navigation) | **< 50 milliseconds** (wasm load & JS eval) | +| **Frame Generation Speed** | ~5 to 20 frames/sec (FPS) | **100+ to 500+ frames/sec** (pure CPU software scanline) | +| **Disk/GPU I/O Overhead** | High (GPU readback + PNG compression) | **Zero** (direct RAM piping of raw RGBA) | +| **Concurrency Scaling** | Heavy (constrained by Chrome CPU/GPU bounds) | **Extremely high** (constrained only by CPU cores) | + +--- + +## 2. Why PocketJS is Uniquely Suited + +### ⚡ Direct RAM-to-Process Piping +Rather than compressing frames into CPU-heavy PNG/JPEG formats and writing them to disk (which introduces massive Disk I/O bottlenecks), the raw 32-bit framebuffer bytes can be pulled directly from WASM linear memory and piped into FFmpeg via a standard OS pipe (`stdin`). + +### ⏱️ Bulletproof Frame-Counter Determinism +As detailed in [DETERMINISM.md](file:///home/rayan/pocketjs/DETERMINISM.md), PocketJS uses a virtual clock (`@pocketjs/framework/clock`) where time is measured in frame ticks rather than the system wall clock. When rendering headlessly: +- Animations, spring physics, and transitions are evaluated frame-by-frame (`1/60s` increments). +- There is zero risk of frame drift, visual desync, or missed frames due to CPU lags. + +### 📐 Integer-Scaled High Definition (1080p) +PocketJS's default canvas coordinates are fixed at `480 x 272` (logical PSP size). However, the Rust core exposes `ui_render_scaled(scale)` in [lib.rs](file:///home/rayan/pocketjs/wasm/src/lib.rs#L295-L298). +- **Scale Factor 4** generates a physical framebuffer of exactly **`1920 x 1088`** pixels. +- This is a standard H.264 macroblock-aligned width/height. +- To produce true 1080p (`1920 x 1080`), you can either discard/crop the 8 extra pixels in memory or instruct FFmpeg to crop them on the fly (`-vf "crop=1920:1080"`). + +--- + +## 3. Reference Architecture: How to Pipe Frames + +Here is a conceptual flow for the rendering script. It uses [Bun.spawn](https://bun.sh/docs/api/subprocess) to launch FFmpeg and pipes raw RGBA frames directly: + +```mermaid +sequenceDiagram + participant Main as Bun CLI Script + participant UI as Solid/Vue App + participant Core as WASM/Rust Core + participant FF as FFmpeg Subprocess + + Main->>Core: Boot WASM & Initialize (rasterDensity=4) + Main->>UI: Evaluate JS bundle & Mount App + + loop For each frame (0..TotalFrames) + Main->>Core: Set clock/frame ticks + Main->>UI: Update reactive state (e.g. video progress signal) + Main->>Core: ui_tick() (evaluates layout & animations) + Main->>Core: ui_render_scaled(4) + Core-->>Main: Return pointer to raw 1920x1088 RGBA8 buffer + Main->>FF: Write raw bytes directly to stdin + end + + Main->>FF: Close stdin (EOF) + FF->>Main: Output video file compiled! +``` + +--- + +## 4. Proof of Concept Code (Bun/TypeScript) + +Below is an implementation of a headless rendering script based on [tape.ts](file:///home/rayan/pocketjs/scripts/tape.ts) and [wasm-ops.js](file:///home/rayan/pocketjs/host-web/wasm-ops.js): + +```typescript +import { existsSync, mkdirSync } from "node:fs"; +import { createWasmUi } from "./host-web/wasm-ops.js"; + +const WASM_PATH = "./host-web/pocketjs.wasm"; +const APP_JS_PATH = "./dist/tape-runtime/hero.js"; +const APP_PAK_PATH = "./dist/tape-runtime/hero.pak"; + +async function renderVideo(app: string, durationInSec: number, fps: number, outputPath: string) { + // 1. Load WASM and boot the PocketJS app + const wasmBuffer = await Bun.file(WASM_PATH).arrayBuffer(); + const wasm = await createWasmUi(wasmBuffer); + + // Set raster density to 4x (1920x1088 output) + wasm.init(4); + + const g = globalThis as Record; + g.ui = wasm.ops; + g.__pak = existsSync(APP_PAK_PATH) ? await Bun.file(APP_PAK_PATH).arrayBuffer() : undefined; + g.__pocketApp = app; + + // Load application bundle + const src = await Bun.file(APP_JS_PATH).text(); + (0, eval)(src); + + const frameFn = g.frame as (buttons: number) => void; + if (!frameFn) throw new Error("Entry bundle did not expose frame function"); + + // 2. Spawn FFmpeg subprocess + const width = 1920; + const height = 1088; + + const ffmpeg = Bun.spawn([ + "ffmpeg", + "-y", + "-f", "rawvideo", + "-pix_fmt", "rgba", + "-s", `${width}x${height}`, + "-r", String(fps), + "-i", "-", // read raw frames from stdin + "-vf", "crop=1920:1080:0:4", // Crop 8 vertical pixels (4 from top, 4 from bottom) to make it exactly 1920x1080 + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "faster", + "-crf", "18", + outputPath + ], { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit" + }); + + const totalFrames = durationInSec * fps; + console.log(`Starting video render: ${totalFrames} frames @ ${fps}fps to ${outputPath}`); + + const start = Performance.now(); + + // 3. Frame rendering loop + for (let i = 0; i < totalFrames; i++) { + // Tick the clock (or pass simulated button masks if replaying a user session) + frameFn(0); + wasm.tick(); + + // Pull the raw 1920x1088 RGBA8 buffer + const frameBuffer = wasm.renderScaled(4); + + // Write frame directly to FFmpeg stdin (no intermediate files!) + ffmpeg.stdin.write(frameBuffer); + } + + // 4. Close pipe and await completion + ffmpeg.stdin.end(); + await ffmpeg.exited; + + const timeTaken = (Performance.now() - start) / 1000; + console.log(`Render complete! Created ${outputPath} in ${timeTaken.toFixed(2)}s (${(totalFrames / timeTaken).toFixed(1)} FPS)`); +} + +// Example usage: +// await renderVideo("hero", 10, 60, "./output.mp4"); +``` + +--- + +## 5. Potential Challenges and Solutions + +> [!IMPORTANT] +> Keep the following limitations in mind before committing to a full implementation: + +### 1. Font Atlas Density +PocketJS bakes font atlases to support exactly the characters used at build time (see [README.md](file:///home/rayan/pocketjs/README.md#L60-L63)). +- **Challenge**: Fonts baked at 1x density (for PSP) will look blurry when stretched 4x in the software rasterizer. +- **Solution**: Set `viewport.rasterDensity = 4` in your build environment or pass `--density=4` to the asset pipeline so the font baker rasterizes glyph atlases at high-resolution coordinates. + +### 2. Missing Browser APIs +Because PocketJS runs on QuickJS / Bun + a custom Rust layout engine, typical browser dependencies (DOM APIs, Web Audio, SVG CSS filters) do not exist. +- **Challenge**: If your pipeline requires complex rendering elements, like dynamic WebGL shaders or third-party web charts, they will not run. +- **Solution**: Author components using PocketJS primitive elements (``, ``, ``). If canvas-like drawing is needed, use PocketJS texture uploads to stream raw textures into node handles. + +### 3. Software Rasterization Constraints +- **Challenge**: CPU-based scanline rendering might become a bottleneck if the scene contains thousands of complex translucent shapes and high-resolution textures. +- **Solution**: The wgpu backend in `pocket3d/` can be used to run hardware-accelerated offscreen headless renders on servers equipped with a GPU. However, for most UI/composition tasks, the CPU scanline renderer in WASM remains faster than browser overhead. \ No newline at end of file diff --git a/tests/contract.ts b/tests/contract.ts index 0d53ae1c..625b052d 100644 --- a/tests/contract.ts +++ b/tests/contract.ts @@ -6,6 +6,7 @@ // (b) Round-trips the styles.bin encoder/decoder over a table exercising // every feature (variants, transition, all three value kinds). +import { fileURLToPath } from "node:url"; import { generateRust } from "../contracts/spec/gen-rust.ts"; import { abgr, @@ -34,7 +35,7 @@ function check(ok: boolean, label: string, detail = "") { // ---- (a) generated spec.rs is in sync --------------------------------------- -const specRsPath = new URL("../engine/core/src/spec.rs", import.meta.url).pathname; +const specRsPath = fileURLToPath(new URL("../engine/core/src/spec.rs", import.meta.url)); const committed = await Bun.file(specRsPath).text().catch(() => null); const expected = generateRust(); check( diff --git a/tools/build.ts b/tools/build.ts index b06eaa64..670de222 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -88,6 +88,7 @@ let useConfig = true; let planPath: string | undefined; let densityFlag: number | undefined; let projectRoot = process.cwd(); +let densityArg: number | undefined; for (const a of args) { if (a.startsWith("--extra-chars=")) extraChars = a.slice("--extra-chars=".length); else if (a.startsWith("--font-regular=")) regularFontPath = resolvePath(a.slice("--font-regular=".length)); @@ -99,6 +100,7 @@ for (const a of args) { else if (a.startsWith("--project-root=")) projectRoot = resolvePath(a.slice("--project-root=".length)); else if (a.startsWith("--outdir=")) DIST = resolvePath(a.slice("--outdir=".length)) + "/"; else if (a.startsWith("--density=")) densityFlag = Number(a.slice("--density=".length)); + else if (a.startsWith("--raster-density=")) densityFlag = Number(a.slice("--raster-density=".length)); else if (!a.startsWith("-")) appArg = a; } @@ -147,12 +149,20 @@ function resolveEntry(arg: string): string { resolvePath(ROOT, arg), wantsMain ? resolvePath(ROOT, "apps", demoName, "main.tsx") : "", wantsMain ? resolvePath(ROOT, "apps", demoName, "main.ts") : "", + wantsMain ? resolvePath(ROOT, "demos", demoName, "main.tsx") : "", + wantsMain ? resolvePath(ROOT, "demos", demoName, "main.ts") : "", !wantsMain ? resolvePath(ROOT, "apps", demoName, "app.tsx") : "", + !wantsMain ? resolvePath(ROOT, "demos", demoName, "app.tsx") : "", isBareName && !wantsMain ? resolvePath(ROOT, "apps", demoName, "main.tsx") : "", isBareName && !wantsMain ? resolvePath(ROOT, "apps", demoName, "main.ts") : "", + isBareName && !wantsMain ? resolvePath(ROOT, "demos", demoName, "main.tsx") : "", + isBareName && !wantsMain ? resolvePath(ROOT, "demos", demoName, "main.ts") : "", resolvePath(ROOT, "apps", arg), resolvePath(ROOT, "apps", arg + ".tsx"), resolvePath(ROOT, "apps", arg + ".ts"), + resolvePath(ROOT, "demos", arg), + resolvePath(ROOT, "demos", arg + ".tsx"), + resolvePath(ROOT, "demos", arg + ".ts"), ].filter(Boolean); for (const t of tries) { if (/\.tsx?$/.test(t) && existsSync(t) && statSync(t).isFile()) return t; @@ -186,7 +196,7 @@ function outputName(file: string): string { const normalizedRoot = ROOT.replace(/\\/g, "/"); const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : normalizedRoot + "/"; const rel = normalizedFile.startsWith(prefix) ? normalizedFile.slice(prefix.length) : normalizedFile; - const demo = rel.match(/^apps\/([^/]+)\/(app|main)\.tsx?$/); + const demo = rel.match(/^(?:apps|demos)\/([^/]+)\/(app|main)\.tsx?$/); if (demo) return demo[2] === "main" ? `${demo[1]}-main` : demo[1]; return normalizedFile.split("/").pop()!.replace(/\.tsx?$/, ""); } @@ -195,9 +205,6 @@ const appName = buildPlan?.app.output ?? outputName(requestedEntry); // A resolved plan names the exact artifact. Low-level demo builds retain the // framework suffix so multiple framework variants can coexist in dist/. const outName = buildPlan ? appName : `${appName}${frameworkConfig.outputSuffix}`; -// Raster density: a resolved plan owns it; --density=N serves hosts whose -// viewport is not a fixed platform contract (the desktop widget shell runs -// arbitrary window sizes on 2x displays, which no target profile names). if (buildPlan && densityFlag !== undefined) { throw new Error("PocketJS build: --density cannot override a ResolvedBuildPlan"); } @@ -207,7 +214,7 @@ if (densityFlag !== undefined && (!Number.isInteger(densityFlag) || densityFlag const rasterDensity = buildPlan?.viewport.rasterDensity ?? densityFlag ?? 1; console.log( `PocketJS build: ${appName} (${entry}, framework=${framework}` + - `${buildPlan ? `, target=${buildPlan.target.id}, raster=${rasterDensity}x, plan=${buildPlan.planHash.slice(0, 20)}…` : ""})`, + `${buildPlan || densityFlag !== undefined ? `, raster=${rasterDensity}x` : ""}${buildPlan ? `, target=${buildPlan.target.id}, plan=${buildPlan.planHash.slice(0, 20)}…` : ""})`, ); // --------------------------------------------------------------------------- diff --git a/tools/render.ts b/tools/render.ts new file mode 100644 index 00000000..38aecf85 --- /dev/null +++ b/tools/render.ts @@ -0,0 +1,458 @@ +#!/usr/bin/env bun +// scripts/render.ts — high-performance video rendering pipeline using PocketJS. +// Pipes raw framebuffers from WASM memory directly to an FFmpeg subprocess. + +import { existsSync, mkdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { cpus } from "node:os"; +import { createWasmUi } from "../hosts/web/wasm-ops.js"; + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const RUNTIME_DIST = join(ROOT, "dist/render-runtime/"); +const WASM_PATH = join(ROOT, "hosts/web/pocketjs.wasm"); + +interface GlobalPocketEngine { + ui?: unknown; + __pak?: ArrayBuffer; + __pocketApp?: string; + frame?: (buttons: number) => void; +} + +function ensureBuilt(path: string, cmd: string[]): void { + if (existsSync(path)) return; + console.log(`render: ${path.slice(ROOT.length)} missing — running: ${cmd.join(" ")}`); + const p = Bun.spawnSync(cmd, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }); + if (p.exitCode !== 0 || !existsSync(path)) { + console.error(`render: failed to produce ${path}`); + process.exit(1); + } +} + +function buildApp(app: string, scale: number): void { + rmSync(RUNTIME_DIST, { recursive: true, force: true }); + mkdirSync(RUNTIME_DIST, { recursive: true }); + const output = RUNTIME_DIST + app + ".js"; + const cmd = [ + process.execPath, + "tools/build.ts", + app, + `--outdir=${RUNTIME_DIST}`, + `--density=${scale}` + ]; + console.log(`render: rebuilding ${app} with density=${scale}`); + const p = Bun.spawnSync(cmd, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }); + if (p.exitCode !== 0 || !existsSync(output)) { + console.error(`render: failed to build app ${app}`); + process.exit(1); + } +} + +function usage(msg?: string): never { + if (msg) console.error(`Error: ${msg}\n`); + console.error( + [ + "Usage:", + " bun scripts/render.ts -a [options]", + "", + "Options:", + " -a, --app Name of the app/demo to render (required)", + " -o, --output Output MP4 file path (default: dist/render/.mp4)", + " -d, --duration Duration of video in seconds (default: 5.0)", + " -f, --fps Frame rate of output video (default: 60)", + " -s, --scale <1..10> Integer scaling factor of logical size (default: 4)", + " -w, --width Logical width of layout viewport (default: 480)", + " --height Logical height of layout viewport (default: 272)", + " -c, --concurrency Number of parallel processes (default: CPU count)", + " --audio, -au Path to audio file to mix into output MP4 (WAV/MP3/AAC)", + " --crf x264 quality factor (default: 18)", + " --preset x264 encoder preset (default: faster)", + " -h, --help Show this help message", + ].join("\n") + ); + process.exit(1); +} + +async function main() { + const args = Bun.argv.slice(2); + let app: string | undefined; + let output: string | undefined; + let duration = 5.0; + let fps = 60; + let scale = 4; + let scaleExplicit = false; + let customSizeExplicit = false; + let widthParam = 480; + let heightParam = 272; + let crf = 18; + let preset = "faster"; + let concurrency = Math.max(1, cpus().length); + let audioParam: string | undefined; + let chunkStart: number | undefined; + let chunkEnd: number | undefined; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "-a" || a === "--app") { + app = args[++i]; + } else if (a === "-o" || a === "--output") { + output = args[++i]; + } else if (a === "-d" || a === "--duration") { + duration = Number(args[++i]); + } else if (a === "-f" || a === "--fps") { + fps = Number(args[++i]); + } else if (a === "-s" || a === "--scale") { + scale = Number(args[++i]); + scaleExplicit = true; + } else if (a === "-w" || a === "--width" || a === "-width") { + widthParam = Number(args[++i]); + customSizeExplicit = true; + } else if (a === "--height" || a === "-height") { + heightParam = Number(args[++i]); + customSizeExplicit = true; + } else if (a === "-c" || a === "--concurrency") { + concurrency = Number(args[++i]); + } else if (a === "--audio" || a === "-au") { + audioParam = args[++i]; + } else if (a === "--crf") { + crf = Number(args[++i]); + } else if (a === "--preset") { + preset = args[++i]; + } else if (a === "--chunk-start") { + chunkStart = Number(args[++i]); + } else if (a === "--chunk-end") { + chunkEnd = Number(args[++i]); + } else if (a === "-h" || a === "--help") { + usage(); + } else { + usage(`Unknown argument: ${a}`); + } + } + + if (customSizeExplicit && !scaleExplicit) { + scale = 1; + } + + if (!app) { + usage("App name is required (-a or --app)"); + } + + let resolvedAudioPath: string | undefined; + if (audioParam) { + const candidate = isAbsolute(audioParam) ? audioParam : resolve(ROOT, audioParam); + if (!existsSync(candidate)) { + usage(`Audio file not found: ${audioParam}`); + } + resolvedAudioPath = candidate; + } + + if (!Number.isInteger(scale) || scale < 1 || scale > 10) { + usage("Scale must be an integer between 1 and 10"); + } + + if (isNaN(widthParam) || widthParam <= 0 || widthParam > 32000) { + usage("Width must be a positive integer <= 32000"); + } + + if (isNaN(heightParam) || heightParam <= 0 || heightParam > 32000) { + usage("Height must be a positive integer <= 32000"); + } + + if (isNaN(duration) || duration <= 0) { + usage("Duration must be a positive number"); + } + + if (!Number.isInteger(fps) || fps <= 0) { + usage("FPS must be a positive integer"); + } + + if (isNaN(crf) || crf < 0 || crf > 51) { + usage("CRF must be a number between 0 and 51"); + } + + const isWorker = chunkStart !== undefined && chunkEnd !== undefined; + const baseApp = app.replace(/\\/g, "/").split("/").pop()!.replace(/\.tsx?$/, ""); + if (!output) { + const renderDir = join(ROOT, "dist/render/"); + mkdirSync(renderDir, { recursive: true }); + output = join(renderDir, `${baseApp}.mp4`); + } + + const totalFrames = Math.round(duration * fps); + + // 1. Parent Process orchestrates parallel chunks if concurrency > 1 + if (!isWorker && concurrency > 1 && totalFrames >= concurrency) { + console.log(`render: orchestrating parallel render with concurrency ${concurrency} across ${totalFrames} frames...`); + const start = performance.now(); + + // Build the app bundle once in parent so workers can share it + ensureBuilt(WASM_PATH, [process.execPath, "tools/wasm.ts"]); + buildApp(app, scale); + + const chunkTasks: { start: number; end: number; output: string }[] = []; + const framesPerChunk = Math.ceil(totalFrames / concurrency); + for (let i = 0; i < concurrency; i++) { + const cStart = i * framesPerChunk; + const cEnd = Math.min(totalFrames, (i + 1) * framesPerChunk); + if (cStart >= totalFrames) break; + chunkTasks.push({ + start: cStart, + end: cEnd, + output: join(dirname(output), `${baseApp}-chunk-${i}.mp4`) + }); + } + + const workerProgress = new Array(chunkTasks.length).fill(0); + + const printProgress = () => { + const totalRendered = workerProgress.reduce((a, b) => a + b, 0); + const pct = (totalRendered / totalFrames) * 100; + const barLen = 20; + const filled = Math.round((pct / 100) * barLen); + const empty = barLen - filled; + const elapsed = (performance.now() - start) / 1000; + const fpsStr = elapsed > 0 ? `, ${(totalRendered / elapsed).toFixed(1)} fps` : ""; + process.stdout.write( + `\rrender: [${"█".repeat(filled)}${"░".repeat(empty)}] ${pct.toFixed(1)}% (${totalRendered}/${totalFrames} frames${fpsStr})\x1b[K` + ); + }; + + async function readStdout(index: number, stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("progress:")) { + const frames = Number(line.slice(9).trim()); + workerProgress[index] = frames; + printProgress(); + } + } + } + } + + // Spawn workers + const workers = chunkTasks.map((task, index) => { + const child = Bun.spawn([ + process.execPath, + "tools/render.ts", + "-a", app, + "-o", task.output, + "-d", String(duration), + "-f", String(fps), + "-s", String(scale), + "-w", String(widthParam), + "--height", String(heightParam), + "--crf", String(crf), + "--preset", preset, + "--chunk-start", String(task.start), + "--chunk-end", String(task.end) + ], { + stdout: "pipe", + stderr: "ignore" + }); + + readStdout(index, child.stdout); + return child; + }); + + const exitCodes = await Promise.all(workers.map(w => w.exited)); + process.stdout.write("\n"); + for (let i = 0; i < exitCodes.length; i++) { + if (exitCodes[i] !== 0) { + throw new Error(`Worker rendering chunk ${i} (${chunkTasks[i].start}-${chunkTasks[i].end}) failed with exit code ${exitCodes[i]}`); + } + } + + // Stitch chunks using FFmpeg concat demuxer + console.log(`render: stitching ${chunkTasks.length} chunks into ${output}...`); + if (resolvedAudioPath) { + console.log(`render: mixing audio track ${resolvedAudioPath}...`); + } + const concatListPath = join(dirname(output), `${baseApp}-concat.txt`); + const concatLines = chunkTasks.map(t => `file '${t.output.replace(/\\/g, "/")}'`).join("\n") + "\n"; + writeFileSync(concatListPath, concatLines, "utf8"); + + const stitchCmd = [ + "ffmpeg", + "-y", + "-safe", "0", + "-f", "concat", + "-i", concatListPath, + ]; + if (resolvedAudioPath) { + stitchCmd.push("-i", resolvedAudioPath, "-c:v", "copy", "-c:a", "aac", "-shortest"); + } else { + stitchCmd.push("-c", "copy"); + } + stitchCmd.push(output); + + const stitch = Bun.spawn(stitchCmd, { + stdout: "ignore", + stderr: "inherit" + }); + + const stitchCode = await stitch.exited; + // Clean up temporary chunks and concat list + unlinkSync(concatListPath); + for (const t of chunkTasks) { + if (existsSync(t.output)) { + unlinkSync(t.output); + } + } + + if (stitchCode !== 0) { + throw new Error(`FFmpeg concat stitching failed with exit code ${stitchCode}`); + } + + const timeTaken = (performance.now() - start) / 1000; + console.log(`render: complete! Created ${output} in ${timeTaken.toFixed(2)}s (${(totalFrames / timeTaken).toFixed(1)} FPS)`); + return; + } + + // 2. Build the app bundle at the correct density (skipped in worker processes since parent built it) + if (!isWorker) { + ensureBuilt(WASM_PATH, [process.execPath, "tools/wasm.ts"]); + buildApp(app, scale); + } + + // 3. Load WASM and boot the app + if (!isWorker) { + console.log(`render: loading WASM and booting ${app}...`); + } + const wasm = await createWasmUi(await Bun.file(WASM_PATH).arrayBuffer()); + wasm.init(scale, widthParam, heightParam); + + const g = globalThis as unknown as GlobalPocketEngine & Record; + g.ui = wasm.ops; + wasm.ops.__viewport = { w: widthParam, h: heightParam }; + const pakPath = join(RUNTIME_DIST, `${baseApp}.pak`); + g.__pak = existsSync(pakPath) ? await Bun.file(pakPath).arrayBuffer() : undefined; + g.__pocketApp = baseApp; + + const jsPath = join(RUNTIME_DIST, `${baseApp}.js`); + const src = await Bun.file(jsPath).text(); + (0, eval)(src); + + const frameFn = g.frame; + if (typeof frameFn !== "function") { + throw new Error("Entry bundle did not expose globalThis.frame function"); + } + + // 4. Compute dimensions and crop arguments + const width = widthParam * scale; + const height = heightParam * scale; + const isDefault480 = widthParam === 480 && heightParam === 272; + const targetW = isDefault480 ? 480 * scale : width; + const targetH = isDefault480 ? 270 * scale : height; + const cropY = scale; + const cropFilter = isDefault480 + ? `crop=${targetW}:${targetH}:0:${cropY}` + : `scale=trunc(iw/2)*2:trunc(ih/2)*2`; + + if (!isWorker) { + console.log(`render: spawning FFmpeg to output ${output}`); + if (resolvedAudioPath) { + console.log(`render: mixing audio track ${resolvedAudioPath}...`); + } + console.log(`render: input size ${width}x${height} -> output size ${targetW}x${targetH} (cropped)`); + } + + const ffmpegCmd = [ + "ffmpeg", + "-y", + "-threads", isWorker ? "1" : "0", + "-f", "rawvideo", + "-pix_fmt", "rgba", + "-s", `${width}x${height}`, + "-r", String(fps), + "-i", "-", // read from stdin + ]; + if (!isWorker && resolvedAudioPath) { + ffmpegCmd.push("-i", resolvedAudioPath); + } + ffmpegCmd.push( + "-vf", cropFilter, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", preset, + "-crf", String(crf) + ); + if (!isWorker && resolvedAudioPath) { + ffmpegCmd.push("-c:a", "aac", "-shortest"); + } + ffmpegCmd.push(output); + + const ffmpeg = Bun.spawn(ffmpegCmd, { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore" + }); + + const startFrame = chunkStart ?? 0; + const endFrame = chunkEnd ?? totalFrames; + + if (!isWorker) { + console.log(`render: generating ${totalFrames} frames @ ${fps} FPS...`); + } + + const start = performance.now(); + for (let f = 0; f < endFrame; f++) { + frameFn(0); + wasm.tick(); + if (f >= startFrame) { + const frameBuffer = wasm.renderScaled(scale); + ffmpeg.stdin.write(frameBuffer); + if ((f - startFrame) % 4 === 0) { + await ffmpeg.stdin.flush(); + } + + // Update progress + if (isWorker) { + if ((f - startFrame) % 10 === 0 || f === endFrame - 1) { + console.log(`progress:${f - startFrame + 1}`); + } + } else { + if (f % 10 === 0 || f === endFrame - 1) { + const pct = ((f + 1) / totalFrames) * 100; + const barLen = 20; + const filled = Math.round((pct / 100) * barLen); + const empty = barLen - filled; + const elapsed = (performance.now() - start) / 1000; + const fpsStr = elapsed > 0 ? `, ${((f + 1) / elapsed).toFixed(1)} fps` : ""; + process.stdout.write( + `\rrender: [${"█".repeat(filled)}${"░".repeat(empty)}] ${pct.toFixed(1)}% (${f + 1}/${totalFrames} frames${fpsStr})\x1b[K` + ); + } + } + } + } + await ffmpeg.stdin.flush(); + + ffmpeg.stdin.end(); + const exitCode = await ffmpeg.exited; + if (exitCode !== 0) { + throw new Error(`FFmpeg exited with code ${exitCode}`); + } + + if (!isWorker) { + process.stdout.write("\n"); + const timeTaken = (performance.now() - start) / 1000; + console.log(`render: complete! Created ${output} in ${timeTaken.toFixed(2)}s (${(totalFrames / timeTaken).toFixed(1)} FPS)`); + } +} + +if (import.meta.main) { + try { + await main(); + } catch (err) { + console.error(`render error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} diff --git a/tools/wasm.ts b/tools/wasm.ts index b4b858ed..fb06cdac 100644 --- a/tools/wasm.ts +++ b/tools/wasm.ts @@ -21,6 +21,7 @@ const BUILT = join(WASM_DIR, "target/wasm32-unknown-unknown/release/pocketjs_was const env = { ...process.env, PATH: `${join(homedir(), ".cargo/bin")}${delimiter}${process.env.PATH ?? ""}`, + RUSTFLAGS: "-C target-feature=+simd128", }; const proc = Bun.spawnSync( diff --git a/tsconfig.json b/tsconfig.json index a58deab5..4e0adf53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,5 +39,5 @@ "@pocketjs/framework/vue-vapor/renderer": ["./framework/src/renderer-vue-vapor.ts"] } }, - "include": ["framework", "apps", "tests", "contracts", "hosts/web"] + "include": ["framework", "apps", "tests", "contracts", "hosts/web", "tools"] }