@amg555/frameforge-mcp 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to FrameForge are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [2.4.3] — 2026-11-09
8
+
9
+ ### Enhanced
10
+ - **Sentence-Synchronized Subtitles (`src/video-renderer/scene-animator.js`)**:
11
+ - Replaced static slide titles, topic banners, and category tags (`// CHRONICLE`, `// HERITAGE`, top headlines) with sentence-synchronized broadcast subtitles centered at bottom with subtle frosted glass backing pills.
12
+ - Automatically slices narration scripts into sentence beats keyed to duration (`DURATION / sentenceCount`) with smooth spring entrance transitions (`spring(localT, 0.04, 210, 16)`).
13
+ - Word wrapping for long sentences to prevent clipping.
14
+
15
+ - **3-Beat Progressive Choreography & Dynamic Camera (`src/video-renderer/scene-animator.js`)**:
16
+ - Added multi-beat progression across all 9 doodle themes:
17
+ - `doodle_dressing`: Beat 1 browsing wardrobe $\to$ Beat 2 trying on shirt $\to$ Beat 3 smiling & waving in glowing amber mirror.
18
+ - `doodle_shiver_cold`: Beat 1 shivering alone $\to$ Beat 2 "NOT FOR COLD" stamp $\to$ Beat 3 "NOT FOR PROTECTION" stamp with character contemplating.
19
+ - `doodle_hunter_savannah`: Character walking with spear across savannah grass swatting mosquitoes under sun.
20
+ - `doodle_sewing_needle`: Campfire $\to$ loupe zoom into bone needle stitching animal hide $\to$ archaeological timeline badge.
21
+ - `doodle_shame_reflection`: Looking down $\to$ water reflection ripples clear $\to$ blush & Theory of Mind halo.
22
+ - `doodle_tribe_meeting`: Two clans approach $\to$ peace greeting $\to$ social language connection arc.
23
+ - `doodle_thinking_future`: Campfire thinker $\to$ thought cloud $\to$ planning for tomorrow tools & sunrise.
24
+ - `doodle_microscope_lice`: Character scratching itching pelt $\to$ microscope slide $\to$ head vs body lice divergence badge.
25
+ - Added subtle cinematic camera drift and breathing (`camZoom`, `camPanX`, `camPanY`) via progress $p = t / DURATION$.
26
+
27
+ ### Fixed
28
+ - **Regex Parsing Bug in Template Literal (`src/video-renderer/scene-animator.js`)**:
29
+ - Fixed syntax error caused by unescaped newline in regex literal inside template literal string.
30
+ - **Theme Detection Priority (`src/video-renderer/scene-animator.js`)**:
31
+ - Prioritized specific narrative themes (`doodle_microscope_lice`, `doodle_shiver_cold`, `doodle_hunter_savannah`, `doodle_tribe_meeting`, `doodle_thinking_future`, `doodle_sewing_needle`, `doodle_shame_reflection`) ahead of general wardrobe dressing to guarantee every scene gets its exact script-matched visual doodle.
32
+
33
+ ---
34
+
35
+ ## [2.4.2] — 2026-11-09
36
+
37
+ ### Added
38
+ - **Procedural Doodled Character Animation Engine (`src/video-renderer/scene-animator.js`)**:
39
+ - Pure-function-of-time character generator (`drawDoodleCharacter`) supporting expressive facial animations (talking, shivering, smiling, surprised), multiple hairstyles (wild, modern, feather), clothing types (none, pelt, shirt, cloak), and animated poses (hold_spear, shiver, sewing, dress_shirt, peace_gesture, thinker, natural).
40
+ - 12fps hand-drawn boiling wobble (`wb(seed, mag)`) and organic sketchy vector primitives (`sLine`, `sCircle`, `sRect`) conforming to the pure-$t$ rendering contract.
41
+ - 9 narrative-matched character scene themes: `doodle_dressing`, `doodle_shiver_cold`, `doodle_sewing_needle`, `doodle_hunter_savannah`, `doodle_shame_reflection`, `doodle_tribe_meeting`, `doodle_thinking_future`, `doodle_microscope_lice`, and `doodle_general_human`.
42
+ - Action-grounded theme detection in `detectComponentTheme`: Human narrative, evolution, psychology, and daily action scripts automatically map to physical character animations rather than abstract geometry.
43
+ - Enhanced LLM prompt in `generateSceneCodeWithLLM` directing AI models to animate expressive human figures actively performing narrated actions.
44
+
45
+ ---
46
+
7
47
  ## [2.4.1] — 2026-11-09
8
48
 
9
49
  ### Fixed
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amg555/frameforge-mcp",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "FrameForge — AI video production engine for agentic IDEs. npx @amg555/frameforge-mcp. FFmpeg + TTS run on your machine. 21 MCP tools.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -20,7 +20,7 @@ check('batch handling', code.includes('Array.isArray(body)'));
20
20
  check('ping handler', code.includes("method === 'ping'"));
21
21
  check('notifications/init', code.includes("notifications/initialized"));
22
22
  check('body size limit', code.includes('MAX_BODY_BYTES'));
23
- check('SERVER_INFO version 2.4.1', code.includes("version: '2.4.1'"));
23
+ check('SERVER_INFO version 2.4.3', code.includes("version: '2.4.3'"));
24
24
  check('18 tools defined (incl get_job_status)', code.includes('get_job_status'));
25
25
  check('export_to_editor tool defined', code.includes('export_to_editor'));
26
26
  check('edit_video tool defined', code.includes('edit_video'));
@@ -299,7 +299,7 @@ check('cf-worker has checkRateLimit', cfWorker.includes('checkRateLimit'));
299
299
  check('cf-worker hashes token (SHA-256)', cfWorker.includes('SHA-256'));
300
300
  check('cf-worker uses RATE_LIMITS KV', cfWorker.includes('env.RATE_LIMITS'));
301
301
  check('cf-worker returns 429 on limit', cfWorker.includes('429'));
302
- check('cf-worker version 2.4.1', cfWorker.includes("'2.4.1'"));
302
+ check('cf-worker version 2.4.3', cfWorker.includes("'2.4.3'"));
303
303
  check('wrangler.toml has kv_namespaces',fs.readFileSync('./cloudflare-mcp/wrangler.toml','utf8').includes('kv_namespaces'));
304
304
 
305
305
  // ── plugins/skills present ────────────────────────────────────────────────
@@ -1633,7 +1633,7 @@ Allows the agent in Claude Code, Cursor, or any IDE to inspect the visual output
1633
1633
 
1634
1634
  const SERVER_INFO = {
1635
1635
  name: 'video-pipeline',
1636
- version: '2.4.1',
1636
+ version: '2.4.3',
1637
1637
  };
1638
1638
 
1639
1639
  const CAPABILITIES = {
@@ -267,6 +267,61 @@ function detectComponentTheme({ visualDescription = '', caption = '', narration
267
267
  if (imagePath) return 'image';
268
268
  const text = `${visualDescription} ${caption} ${narration} ${topic}`.toLowerCase();
269
269
 
270
+ // ── Doodled Character Actions (Human Stories, History, Evolution, Daily Life) ──
271
+ // 1. Microscope / Lice Theory / Parasite / Genetics (Scene 7)
272
+ if (/(?:^|[^\w])(lice|louse|microscope|fiber|weave|parasite|lice evolution|genetic biology proof)(?:[^\w]|$)/i.test(text)
273
+ || /പേൻ|പേൻ തിയറി|തലയിലെ പേൻ|ശരീരത്തിലെ പേൻ|ലൈസ് തിയറി|മൈക്രോസ്കോപ്പ്/.test(text)) {
274
+ return 'doodle_microscope_lice';
275
+ }
276
+
277
+ // 2. Shivering / Cold / Protection Flaw / Winter / Freezing (Scene 2, 6)
278
+ if (/(?:^|[^\w])(shiver|shivering|cold|freeze|freezing|ice|winter|teeth|chatter|protection flaw)(?:[^\w]|$)/i.test(text)
279
+ || /സാധാരണ ധാരണകൾ|തണുപ്പും സംരക്ഷണവും|തള്ളിക്കളയുന്നു|ശരീരത്തെ സംരക്ഷിക്കാൻ|ശരീര സംരക്ഷണം|വിറയ്ക്കുക|തണുപ്പിൽ നിന്ന് രക്ഷ നേടാൻ|തണുപ്പ് എന്ന കാരണം|തണുപ്പുള്ള സ്ഥലങ്ങളിലേക്ക്/.test(text)) {
280
+ return 'doodle_shiver_cold';
281
+ }
282
+
283
+ // 3. Hunter in Savannah / Wild / Blazing Sun / Spear / Mosquitoes (Scene 5)
284
+ if (/(?:^|[^\w])(savannah|savanna|spear|hunter|hunting|africa savannah|foraging|wild jungle|mosquito|mosquitoes|thorns)(?:[^\w]|$)/i.test(text)
285
+ || /കാട്ടിൽ നഗ്നരായി|നഗ്നരായിട്ടാണ്|കൊടുംചൂട്|കൊതുകുകടി|മുള്ളുകൾ|കുന്തം|കാട്ടിലെ ലക്ഷക്കണക്കിന്/.test(text)) {
286
+ return 'doodle_hunter_savannah';
287
+ }
288
+
289
+ // 4. Meeting Tribes / Social Language / Status / Community / Strangers (Scene 10)
290
+ if (/(?:^|[^\w])(tribe|tribal|clan|stranger|meeting strangers|social language|status in society|hierarchy|friend or enemy)(?:[^\w]|$)/i.test(text)
291
+ || /കൂട്ടമായി|സമൂഹം|അപരിചിത|ഗോത്രം|ശത്രു|സുഹൃത്ത്|സാമൂഹിക ഭാഷ|സാമൂഹിക|സ്ഥാനം തിരിച്ചറിഞ്ഞിരുന്നത്/.test(text)) {
292
+ return 'doodle_tribe_meeting';
293
+ }
294
+
295
+ // 5. Future Planning / Cognitive Revolution / Tomorrow / Foresight / Campfire thinking (Scene 8)
296
+ if (/(?:^|[^\w])(planning for tomorrow|future planning|cognitive revolution|thinking future|contemplate|foresight|ian gilligan)(?:[^\w]|$)/i.test(text)
297
+ || /നാളത്തേക്ക്|പ്ലാൻ|ഭാവി|ഭാവിയെ കുറിച്ച്|ബുദ്ധി ഉപയോഗിച്ച്|ചിന്തയിലെ മാറ്റം|ഇയാൻ ഗില്ലിഗൻ|ചിന്തിക്കാൻ തുടങ്ങി/.test(text)) {
298
+ return 'doodle_thinking_future';
299
+ }
300
+
301
+ // 6. Bone Needle / Sewing / Stitching / Animal Pelt / Prehistoric Tools (Scene 4)
302
+ if (/(?:^|[^\w])(needle|bone needle|needles|sew|sewing|stitch|stitching|thread|animal hide|leather hide|hide sewing|fur pelt|tailor|sewing tools)(?:[^\w]|$)/i.test(text)
303
+ || /സൂചി|സൂചിയും|തുന്ന|തുന്നാൻ|തോൽ തുന്നാൻ|തുന്നൽ|തോൽ തുന്നാനുള്ള/.test(text)) {
304
+ return 'doodle_sewing_needle';
305
+ }
306
+
307
+ // 7. Shame / Mirror / Reflection / Water / Self Awareness / Theory of Mind (Scene 3, 9)
308
+ if (/(?:^|[^\w])(shame|blush|embarrass|mirror|reflection|water pool|theory of mind|how others see|self-aware|identity|looking at me|psychology|mind-shift)(?:[^\w]|$)/i.test(text)
309
+ || /കണ്ണാടി|പ്രതിഫലനം|മറ്റുള്ളവർ എന്നെ|തിയറി ഓഫ് മൈൻഡ്|സ്വന്തം ശരീരം മറച്ചു|വ്യക്തിത്വം|ശ്രദ്ധിക്കുന്നുണ്ട്|യഥാർത്ഥ രഹസ്യം|നാണം മറയ്ക്കാൻ/.test(text)) {
310
+ return 'doodle_shame_reflection';
311
+ }
312
+
313
+ // 8. Dressing / Wardrobe / Wearing Clothes / Outfit / Morning Routine (Scene 1, 11)
314
+ if (/(?:^|[^\w])(dress|dressing|wearing clothes|shirt|wardrobe|closet|hanger|outfit|choose clothes|wear|garment|wedding dress|interview|daily habit|why we wear clothes)(?:[^\w]|$)/i.test(text)
315
+ || /വസ്ത്രധാരണം|ധരിച്ചിട്ടുണ്ടാ|ഷർട്ട്|തിരഞ്ഞെടുത്തത്|തുണി എടുത്തിടുകയല്ല|കല്യാണത്തിന്|ഇന്റർവ്യൂ|ആമുഖം: വസ്ത്രധാരണം|വസ്ത്രം തിരഞ്ഞെടുക്കുമ്പോൾ|വസ്ത്രം തിരഞ്ഞെടുക്കും/.test(text)) {
316
+ return 'doodle_dressing';
317
+ }
318
+
319
+ // 9. General Human Character
320
+ if (/(?:^|[^\w])(human|person|man|woman|people|child|guy|doodle|character|actor)(?:[^\w]|$)/i.test(text)
321
+ || /മനുഷ്യൻ|ആളുകൾ|വ്യക്തി/.test(text)) {
322
+ return 'doodle_general_human';
323
+ }
324
+
270
325
  // Celestial / Orbiting satellites (e.g. GPS, orbital mechanics, planetary science)
271
326
  if (/(?:^|[^\w])(orbit|satellite|satellites|gps|planet|earth|mars|moon|galaxy|universe|solar|astronomy|gravity|space|telescope|interstellar)(?:[^\w]|$)/.test(text)
272
327
  || /ബഹിരാകാശ|ഗ്രഹം|ഉപഗ്രഹ|ഭൂമി|നക്ഷത്ര|പ്രപഞ്ച|अंतरिक्ष|ग्रह|तारा|விண்வெளி/.test(text)) {
@@ -685,16 +740,873 @@ function drawBackground(t) {
685
740
  // ── Full-Canvas Cinematic Visual Directors ─────────────────────────────────────
686
741
  function drawMainScene(t) {
687
742
  ctx.save();
743
+ const p = Math.min(1, Math.max(0, t / window.DURATION));
688
744
  const enterSpring = spring(t, 0.05, 170, 13);
689
- const sceneScale = Math.min(1.04, Math.max(0.88, enterSpring));
690
- ctx.translate(W / 2, H * 0.50);
691
- ctx.scale(sceneScale, sceneScale);
745
+ const camZoom = 1.0 + Math.sin(t * 0.45) * 0.02 + p * 0.04;
746
+ const camPanX = Math.sin(t * 0.35) * 12;
747
+ const camPanY = Math.cos(t * 0.28) * 6;
748
+ ctx.translate(W / 2 + camPanX, H * 0.50 + camPanY);
749
+ ctx.scale(Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom, Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom);
692
750
  ctx.translate(-W / 2, -H * 0.50);
693
751
 
694
752
  const cx = W / 2;
695
753
  const cy = H * 0.50;
696
754
 
697
- if (COMP_THEME === 'celestial_orbit') {
755
+ // ── Hand-Drawn Boil Wobble & Sketch Helpers (12fps Hand-Drawn Animation) ──
756
+ function wb(seed, mag = 2.4) {
757
+ const step = Math.floor(t * 12);
758
+ return Math.sin(seed * 19.37 + step * 2.41) * mag;
759
+ }
760
+
761
+ function sLine(x1, y1, x2, y2, seed, color = '#f8fafc', width = 3.5) {
762
+ ctx.save();
763
+ ctx.strokeStyle = color;
764
+ ctx.lineWidth = width;
765
+ ctx.lineCap = 'round';
766
+ ctx.lineJoin = 'round';
767
+ const mx = (x1 + x2) / 2 + wb(seed, 3.5);
768
+ const my = (y1 + y2) / 2 + wb(seed + 1, 3.5);
769
+ ctx.beginPath();
770
+ ctx.moveTo(x1 + wb(seed + 2, 1.5), y1 + wb(seed + 3, 1.5));
771
+ ctx.quadraticCurveTo(mx, my, x2 + wb(seed + 4, 1.5), y2 + wb(seed + 5, 1.5));
772
+ ctx.stroke();
773
+ ctx.restore();
774
+ }
775
+
776
+ function sCircle(x, y, r, seed, color = '#f8fafc', width = 3, fillColor = null) {
777
+ ctx.save();
778
+ if (fillColor) {
779
+ ctx.fillStyle = fillColor;
780
+ ctx.beginPath();
781
+ ctx.arc(x, y, r, 0, Math.PI * 2);
782
+ ctx.fill();
783
+ }
784
+ ctx.strokeStyle = color;
785
+ ctx.lineWidth = width;
786
+ ctx.beginPath();
787
+ const segs = 10;
788
+ for (let s = 0; s <= segs; s++) {
789
+ const a = (s / segs) * Math.PI * 2;
790
+ const curR = r + wb(seed + s, 2.5);
791
+ const px = x + Math.cos(a) * curR;
792
+ const py = y + Math.sin(a) * curR;
793
+ if (s === 0) ctx.moveTo(px, py);
794
+ else ctx.lineTo(px, py);
795
+ }
796
+ ctx.closePath();
797
+ ctx.stroke();
798
+ ctx.restore();
799
+ }
800
+
801
+ function sRect(x, y, w, h, seed, color = '#f8fafc', width = 3, fillColor = null) {
802
+ ctx.save();
803
+ if (fillColor) {
804
+ ctx.fillStyle = fillColor;
805
+ ctx.fillRect(x, y, w, h);
806
+ }
807
+ sLine(x, y, x + w, y, seed, color, width);
808
+ sLine(x + w, y, x + w, y + h, seed + 1, color, width);
809
+ sLine(x + w, y + h, x, y + h, seed + 2, color, width);
810
+ sLine(x, y + h, x, y, seed + 3, color, width);
811
+ ctx.restore();
812
+ }
813
+
814
+ function drawDoodleCharacter({
815
+ x = cx,
816
+ y = cy,
817
+ scale = 1.0,
818
+ headRadius = 38,
819
+ torsoHeight = 90,
820
+ legLength = 95,
821
+ hairStyle = 'wild',
822
+ expression = 'talking',
823
+ clothing = 'none',
824
+ armPose = 'natural',
825
+ seed = 100,
826
+ } = {}) {
827
+ ctx.save();
828
+ ctx.translate(x, y);
829
+ ctx.scale(scale, scale);
830
+
831
+ const blink = (t % 3.2 < 0.14) ? 0.15 : 1.0;
832
+ const mouthTalk = Math.abs(Math.sin(t * 14)) * 4;
833
+ const torsoTopY = -torsoHeight;
834
+ const torsoBottomY = 0;
835
+
836
+ // Torso Clothing Fills
837
+ if (clothing === 'pelt') {
838
+ ctx.fillStyle = '#b45309';
839
+ ctx.beginPath();
840
+ ctx.moveTo(-24 + wb(seed, 2), torsoTopY);
841
+ ctx.lineTo(24 + wb(seed + 1, 2), torsoTopY);
842
+ ctx.lineTo(32 + wb(seed + 2, 2), torsoBottomY + 28);
843
+ ctx.lineTo(-28 + wb(seed + 3, 2), torsoBottomY + 28);
844
+ ctx.closePath();
845
+ ctx.fill();
846
+ sLine(-28, torsoBottomY + 2, 32, torsoBottomY + 2, seed + 4, '#78350f', 4);
847
+ } else if (clothing === 'shirt') {
848
+ ctx.fillStyle = ACCENT;
849
+ ctx.beginPath();
850
+ ctx.moveTo(-26 + wb(seed, 2), torsoTopY);
851
+ ctx.lineTo(26 + wb(seed + 1, 2), torsoTopY);
852
+ ctx.lineTo(28 + wb(seed + 2, 2), torsoBottomY + 12);
853
+ ctx.lineTo(-28 + wb(seed + 3, 2), torsoBottomY + 12);
854
+ ctx.closePath();
855
+ ctx.fill();
856
+ sLine(-14, torsoTopY, 0, torsoTopY + 18, seed + 4, '#ffffff', 3);
857
+ sLine(14, torsoTopY, 0, torsoTopY + 18, seed + 5, '#ffffff', 3);
858
+ sLine(0, torsoTopY + 18, 0, torsoBottomY + 10, seed + 6, '#ffffff', 2.5);
859
+ } else if (clothing === 'cloak') {
860
+ ctx.fillStyle = '#0f766e';
861
+ ctx.beginPath();
862
+ ctx.moveTo(-32, torsoTopY - 6);
863
+ ctx.lineTo(32, torsoTopY - 6);
864
+ ctx.lineTo(44, torsoBottomY + 32);
865
+ ctx.lineTo(-44, torsoBottomY + 32);
866
+ ctx.closePath();
867
+ ctx.fill();
868
+ } else {
869
+ sLine(0, torsoTopY, 0, torsoBottomY, seed + 7, '#fcd34d', 4);
870
+ sLine(-15, torsoTopY + 30, -2, torsoTopY + 40, seed + 8, 'rgba(255,255,255,0.3)', 2);
871
+ sLine(15, torsoTopY + 30, 2, torsoTopY + 40, seed + 9, 'rgba(255,255,255,0.3)', 2);
872
+ }
873
+ sLine(0, torsoTopY, 0, torsoBottomY, seed + 10, '#f8fafc', 4);
874
+
875
+ // Head
876
+ const headCenterY = torsoTopY - headRadius - 6 + wb(seed + 11, 1.5);
877
+ sCircle(0, headCenterY, headRadius, seed + 12, '#f8fafc', 3.5, '#fef3c7');
878
+
879
+ // Hair
880
+ if (hairStyle === 'wild') {
881
+ for (let h = 0; h < 6; h++) {
882
+ const hairAng = -Math.PI * 0.85 + (h / 5) * Math.PI * 0.7;
883
+ const hx1 = Math.cos(hairAng) * headRadius;
884
+ const hy1 = headCenterY + Math.sin(hairAng) * headRadius;
885
+ const hx2 = Math.cos(hairAng) * (headRadius + 22 + wb(seed + 13 + h, 4));
886
+ const hy2 = headCenterY + Math.sin(hairAng) * (headRadius + 22 + wb(seed + 14 + h, 4));
887
+ sLine(hx1, hy1, hx2, hy2, seed + 15 + h, '#78350f', 3.5);
888
+ }
889
+ } else if (hairStyle === 'modern') {
890
+ ctx.fillStyle = '#1e293b';
891
+ ctx.beginPath();
892
+ ctx.arc(0, headCenterY - 12, headRadius * 0.96, Math.PI, Math.PI * 2);
893
+ ctx.closePath();
894
+ ctx.fill();
895
+ sLine(-headRadius * 0.9, headCenterY - 12, headRadius * 0.9, headCenterY - 12, seed + 20, '#1e293b', 4);
896
+ } else if (hairStyle === 'feather') {
897
+ sLine(8, headCenterY - headRadius, 28, headCenterY - headRadius - 35, seed + 21, '#38bdf8', 4);
898
+ sLine(28, headCenterY - headRadius - 35, 18, headCenterY - headRadius - 20, seed + 22, '#f43f5e', 3);
899
+ }
900
+
901
+ // Eyes
902
+ const eyeY = headCenterY + 4;
903
+ const eyeR = 4 * blink;
904
+ sCircle(-12, eyeY, Math.max(1, eyeR), seed + 23, '#0f172a', 2, '#0f172a');
905
+ sCircle(12, eyeY, Math.max(1, eyeR), seed + 24, '#0f172a', 2, '#0f172a');
906
+
907
+ // Eyebrows
908
+ if (expression === 'shivering') {
909
+ sLine(-18, eyeY - 12 + wb(seed, 2), -6, eyeY - 7 + wb(seed, 2), seed + 25, '#0f172a', 2.5);
910
+ sLine(6, eyeY - 7 + wb(seed, 2), 18, eyeY - 12 + wb(seed, 2), seed + 26, '#0f172a', 2.5);
911
+ } else if (expression === 'surprised' || expression === 'curious') {
912
+ sLine(-18, eyeY - 15, -6, eyeY - 13, seed + 27, '#0f172a', 2.5);
913
+ sLine(6, eyeY - 13, 18, eyeY - 15, seed + 28, '#0f172a', 2.5);
914
+ } else {
915
+ sLine(-17, eyeY - 9, -7, eyeY - 10, seed + 29, '#0f172a', 2.5);
916
+ sLine(7, eyeY - 10, 17, eyeY - 9, seed + 30, '#0f172a', 2.5);
917
+ }
918
+
919
+ // Mouth
920
+ const mouthY = headCenterY + 22;
921
+ if (expression === 'shivering') {
922
+ const mChatter = Math.sin(t * 32) * 2.5;
923
+ sLine(-10, mouthY + mChatter, -4, mouthY - 3 + mChatter, seed + 31, '#0f172a', 2.5);
924
+ sLine(-4, mouthY - 3 + mChatter, 3, mouthY + 3 + mChatter, seed + 32, '#0f172a', 2.5);
925
+ sLine(3, mouthY + 3 + mChatter, 10, mouthY + mChatter, seed + 33, '#0f172a', 2.5);
926
+ } else if (expression === 'talking') {
927
+ ctx.fillStyle = '#0f172a';
928
+ ctx.beginPath();
929
+ ctx.ellipse(0, mouthY, 7, Math.max(2, mouthTalk), 0, 0, Math.PI * 2);
930
+ ctx.fill();
931
+ } else if (expression === 'smiling') {
932
+ ctx.beginPath();
933
+ ctx.arc(0, mouthY - 4, 10, 0.2 * Math.PI, 0.8 * Math.PI);
934
+ ctx.strokeStyle = '#0f172a';
935
+ ctx.lineWidth = 2.5;
936
+ ctx.stroke();
937
+ } else {
938
+ sLine(-8, mouthY, 8, mouthY, seed + 34, '#0f172a', 2.5);
939
+ }
940
+
941
+ // Legs
942
+ const hipY = torsoBottomY;
943
+ if (armPose === 'sewing' || armPose === 'thinker') {
944
+ sLine(-10, hipY, -45, hipY + 45, seed + 35, '#f8fafc', 4);
945
+ sLine(-45, hipY + 45, 10, hipY + 50, seed + 36, '#f8fafc', 4);
946
+ sLine(10, hipY, 45, hipY + 45, seed + 37, '#f8fafc', 4);
947
+ sLine(45, hipY + 45, -10, hipY + 50, seed + 38, '#f8fafc', 4);
948
+ } else if (armPose === 'hold_spear') {
949
+ const legSwing = Math.sin(t * 5.5);
950
+ const l1KneeX = -15 + legSwing * 22;
951
+ const l1KneeY = hipY + legLength * 0.55;
952
+ const l1FootX = -15 + legSwing * 38;
953
+ const l1FootY = hipY + legLength;
954
+ sLine(-10, hipY, l1KneeX, l1KneeY, seed + 39, '#f8fafc', 4);
955
+ sLine(l1KneeX, l1KneeY, l1FootX, l1FootY, seed + 40, '#f8fafc', 4);
956
+
957
+ const l2KneeX = 15 - legSwing * 22;
958
+ const l2KneeY = hipY + legLength * 0.55;
959
+ const l2FootX = 15 - legSwing * 38;
960
+ const l2FootY = hipY + legLength;
961
+ sLine(10, hipY, l2KneeX, l2KneeY, seed + 41, '#f8fafc', 4);
962
+ sLine(l2KneeX, l2KneeY, l2FootX, l2FootY, seed + 42, '#f8fafc', 4);
963
+ } else {
964
+ sLine(-12, hipY, -14, hipY + legLength, seed + 43, '#f8fafc', 4);
965
+ sLine(12, hipY, 14, hipY + legLength, seed + 44, '#f8fafc', 4);
966
+ sLine(-14, hipY + legLength, -24, hipY + legLength, seed + 45, '#f8fafc', 3.5);
967
+ sLine(14, hipY + legLength, 24, hipY + legLength, seed + 46, '#f8fafc', 3.5);
968
+ }
969
+
970
+ // Arms & Hands
971
+ const shoulderY = torsoTopY + 12;
972
+ if (armPose === 'shiver') {
973
+ const shivA = Math.sin(t * 34) * 3;
974
+ sLine(-20, shoulderY, 16 + shivA, shoulderY + 35, seed + 47, '#f8fafc', 4);
975
+ sLine(20, shoulderY, -16 + shivA, shoulderY + 35, seed + 48, '#f8fafc', 4);
976
+ } else if (armPose === 'hold_spear') {
977
+ const spearX = -45;
978
+ sLine(-18, shoulderY, -35, shoulderY + 30, seed + 49, '#f8fafc', 4);
979
+ sLine(-35, shoulderY + 30, spearX, shoulderY + 5, seed + 50, '#f8fafc', 4);
980
+ sLine(spearX, -torsoHeight - 130, spearX, hipY + legLength + 10, seed + 51, '#92400e', 5);
981
+ ctx.fillStyle = '#64748b';
982
+ ctx.beginPath();
983
+ ctx.moveTo(spearX, -torsoHeight - 165);
984
+ ctx.lineTo(spearX - 10, -torsoHeight - 128);
985
+ ctx.lineTo(spearX + 10, -torsoHeight - 128);
986
+ ctx.closePath();
987
+ ctx.fill();
988
+ sLine(18, shoulderY, 35, shoulderY + 25 + Math.sin(t * 5.5) * 20, seed + 52, '#f8fafc', 4);
989
+ sLine(35, shoulderY + 25 + Math.sin(t * 5.5) * 20, 48, shoulderY + 45, seed + 53, '#f8fafc', 4);
990
+ } else if (armPose === 'sewing') {
991
+ sLine(-18, shoulderY, -35, shoulderY + 40, seed + 54, '#f8fafc', 4);
992
+ sLine(-35, shoulderY + 40, -18, shoulderY + 55, seed + 55, '#f8fafc', 4);
993
+ const sewCycle = Math.sin(t * 4);
994
+ const handX = 20 + sewCycle * 25;
995
+ const handY = shoulderY + 45 - Math.cos(t * 4) * 15;
996
+ sLine(18, shoulderY, 38, shoulderY + 25, seed + 56, '#f8fafc', 4);
997
+ sLine(38, shoulderY + 25, handX, handY, seed + 57, '#f8fafc', 4);
998
+ sLine(handX - 5, handY - 10, handX + 18, handY + 12, seed + 58, '#fef08a', 3.5);
999
+ ctx.strokeStyle = '#ffffff';
1000
+ ctx.lineWidth = 1.5;
1001
+ ctx.beginPath();
1002
+ ctx.moveTo(handX - 5, handY - 10);
1003
+ ctx.quadraticCurveTo(handX - 25, handY - 25, 0, hipY + 10);
1004
+ ctx.stroke();
1005
+ } else if (armPose === 'cover_body') {
1006
+ sLine(-18, shoulderY, -12, hipY - 10, seed + 59, '#f8fafc', 4);
1007
+ sLine(18, shoulderY, 12, hipY - 10, seed + 60, '#f8fafc', 4);
1008
+ } else if (armPose === 'dress_shirt') {
1009
+ sLine(-18, shoulderY, -38, shoulderY + 30, seed + 61, '#f8fafc', 4);
1010
+ sLine(-38, shoulderY + 30, -22, hipY - 5, seed + 62, '#f8fafc', 4);
1011
+ const adjustY = shoulderY + 8 + Math.sin(t * 3) * 6;
1012
+ sLine(18, shoulderY, 35, shoulderY + 15, seed + 63, '#f8fafc', 4);
1013
+ sLine(35, shoulderY + 15, 10, adjustY, seed + 64, '#f8fafc', 4);
1014
+ } else if (armPose === 'peace_gesture') {
1015
+ sLine(-18, shoulderY, -26, shoulderY + 40, seed + 65, '#f8fafc', 4);
1016
+ sLine(18, shoulderY, 40, shoulderY - 10 + Math.sin(t * 2) * 5, seed + 66, '#f8fafc', 4);
1017
+ sLine(40, shoulderY - 10 + Math.sin(t * 2) * 5, 45, shoulderY - 35, seed + 67, '#f8fafc', 4);
1018
+ } else if (armPose === 'thinker') {
1019
+ sLine(18, shoulderY, 36, hipY + 15, seed + 68, '#f8fafc', 4);
1020
+ sLine(36, hipY + 15, 8, headCenterY + 18, seed + 69, '#f8fafc', 4);
1021
+ sLine(-18, shoulderY, -30, hipY + 25, seed + 70, '#f8fafc', 4);
1022
+ } else {
1023
+ const gArm = Math.sin(t * 2.5) * 12;
1024
+ sLine(-18, shoulderY, -35, shoulderY + 35, seed + 71, '#f8fafc', 4);
1025
+ sLine(-35, shoulderY + 35, -42, shoulderY + 60, seed + 72, '#f8fafc', 4);
1026
+ sLine(18, shoulderY, 35, shoulderY + 20 - gArm, seed + 73, '#f8fafc', 4);
1027
+ sLine(35, shoulderY + 20 - gArm, 45, shoulderY + 5 - gArm, seed + 74, '#f8fafc', 4);
1028
+ }
1029
+
1030
+ ctx.restore();
1031
+ }
1032
+
1033
+ // ── Doodled Character Narrative Themes (3-Beat Progressive Choreography) ─
1034
+ if (COMP_THEME === 'doodle_dressing') {
1035
+ // Beat 1: Browsing wardrobe -> Beat 2: Trying shirt -> Beat 3: Looking in mirror reflection
1036
+ const beat2 = spring(t, window.DURATION * 0.35, 180, 16);
1037
+ const beat3 = spring(t, window.DURATION * 0.70, 190, 16);
1038
+
1039
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.25)', 2.5);
1040
+
1041
+ // Clothes rack on left (sways in beat 1)
1042
+ sLine(cx - 360, cy + 220, cx - 360, cy - 140, 11, '#94a3b8', 5);
1043
+ sLine(cx - 410, cy + 220, cx - 310, cy + 220, 12, '#94a3b8', 5);
1044
+ sLine(cx - 420, cy - 140, cx - 180, cy - 140, 13, '#cbd5e1', 5);
1045
+
1046
+ // 3 shirts on hangers
1047
+ const shirtColors = ['#38bdf8', '#f43f5e', '#fbbf24'];
1048
+ for (let c = 0; c < 3; c++) {
1049
+ const hx = cx - 380 + c * 80;
1050
+ const sway = Math.sin(t * 2.5 + c * 1.5) * 5;
1051
+ sLine(hx, cy - 140, hx + sway, cy - 110, 20 + c, '#e2e8f0', 2.5);
1052
+ ctx.fillStyle = shirtColors[c];
1053
+ ctx.beginPath();
1054
+ ctx.moveTo(hx + sway - 22, cy - 110);
1055
+ ctx.lineTo(hx + sway + 22, cy - 110);
1056
+ ctx.lineTo(hx + sway + 28, cy - 10);
1057
+ ctx.lineTo(hx + sway - 28, cy - 10);
1058
+ ctx.closePath();
1059
+ ctx.fill();
1060
+ }
1061
+
1062
+ // Mirror on right - emerges and glows in Beat 2 & 3
1063
+ const mirrorX = cx + 320;
1064
+ const mirrorY = cy + 20;
1065
+ ctx.fillStyle = 'rgba(56, 189, 248, ' + (0.05 + beat2 * 0.08) + ')';
1066
+ ctx.beginPath();
1067
+ ctx.roundRect(mirrorX - 85, mirrorY - 210, 170, 390, 24);
1068
+ ctx.fill();
1069
+ ctx.strokeStyle = beat3 > 0.5 ? '#f59e0b' : '#94a3b8';
1070
+ ctx.lineWidth = 4;
1071
+ ctx.stroke();
1072
+
1073
+ // Reflected character in mirror (smiling and thumbs up in beat 3)
1074
+ if (beat2 > 0.1) {
1075
+ ctx.save();
1076
+ ctx.globalAlpha = Math.min(0.65, beat2 * 0.65);
1077
+ drawDoodleCharacter({
1078
+ x: mirrorX,
1079
+ y: cy + 120,
1080
+ scale: 1.05,
1081
+ hairStyle: 'modern',
1082
+ expression: beat3 > 0.5 ? 'smiling' : 'curious',
1083
+ clothing: 'shirt',
1084
+ armPose: beat3 > 0.5 ? 'peace_gesture' : 'dress_shirt',
1085
+ seed: 88,
1086
+ });
1087
+ ctx.restore();
1088
+ }
1089
+
1090
+ // Main character moves progressively from rack -> center -> mirror
1091
+ const charX = cx - 220 + beat2 * 170 + beat3 * 60;
1092
+ drawDoodleCharacter({
1093
+ x: charX,
1094
+ y: cy + 140,
1095
+ scale: 1.35,
1096
+ hairStyle: 'modern',
1097
+ expression: beat3 > 0.5 ? 'smiling' : 'talking',
1098
+ clothing: 'shirt',
1099
+ armPose: beat2 > 0.5 ? 'dress_shirt' : 'natural',
1100
+ seed: 101,
1101
+ });
1102
+
1103
+ } else if (COMP_THEME === 'doodle_shiver_cold') {
1104
+ // Beat 1: Shivering in cold -> Beat 2: NOT FOR COLD stamp -> Beat 3: NOT FOR PROTECTION stamp
1105
+ const stamp1 = spring(t, window.DURATION * 0.38, 220, 15);
1106
+ const stamp2 = spring(t, window.DURATION * 0.72, 220, 15);
1107
+
1108
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1109
+
1110
+ // Freezing wind streaks rushing across
1111
+ for (let w = 0; w < 14; w++) {
1112
+ const windX = ((t * 750 + w * 210) % (W + 600)) - 300;
1113
+ const windY = cy - 250 + (w * 42) + Math.sin(t * 3.5 + w) * 15;
1114
+ sLine(windX, windY, windX + 170, windY + 5, 200 + w, 'rgba(255,255,255,0.3)', 2);
1115
+ }
1116
+
1117
+ // Drifting snow particles
1118
+ for (let sp = 0; sp < 25; sp++) {
1119
+ const px = ((sp * 85 + t * 450) % W);
1120
+ const py = (sp * 45 + t * 90) % H;
1121
+ sCircle(px, py, 2.5, 300 + sp, '#e0f2fe', 1, '#ffffff');
1122
+ }
1123
+
1124
+ // Shivering breath vapor cloud (calms in Beat 3)
1125
+ if (stamp2 < 0.8) {
1126
+ const breathPhase = (t * 2.2) % 1;
1127
+ const bCloudX = cx - 50 + breathPhase * 60;
1128
+ const bCloudY = cy - 90 - breathPhase * 25;
1129
+ ctx.fillStyle = 'rgba(224, 242, 254, ' + ((1 - breathPhase) * 0.5) + ')';
1130
+ ctx.beginPath();
1131
+ ctx.arc(bCloudX, bCloudY, 15 + breathPhase * 25, 0, Math.PI * 2);
1132
+ ctx.fill();
1133
+ }
1134
+
1135
+ // Main character: shivers in Beat 1 & 2, then realizes / curious in Beat 3
1136
+ const isShivering = stamp2 < 0.7;
1137
+ const vibX = isShivering ? Math.sin(t * 38) * 4.5 : 0;
1138
+ const vibY = isShivering ? Math.cos(t * 32) * 2 : 0;
1139
+ drawDoodleCharacter({
1140
+ x: cx - 120 + vibX,
1141
+ y: cy + 140 + vibY,
1142
+ scale: 1.35,
1143
+ hairStyle: 'wild',
1144
+ expression: isShivering ? 'shivering' : 'curious',
1145
+ clothing: 'none',
1146
+ armPose: isShivering ? 'shiver' : 'thinker',
1147
+ seed: 102,
1148
+ });
1149
+
1150
+ // Stamp 1: NOT FOR COLD (slams down in Beat 2)
1151
+ if (stamp1 > 0.05) {
1152
+ ctx.save();
1153
+ const s1X = cx + 240;
1154
+ const s1Y = cy - 100;
1155
+ ctx.translate(s1X, s1Y);
1156
+ ctx.scale(stamp1, stamp1);
1157
+ sCircle(0, 0, 50, 400, 'rgba(239,68,68,0.9)', 4);
1158
+ sLine(-36, -36, 36, 36, 401, 'rgba(239,68,68,0.95)', 4.5);
1159
+ ctx.font = 'bold 20px monospace';
1160
+ ctx.fillStyle = '#ef4444';
1161
+ ctx.textAlign = 'center';
1162
+ ctx.fillText('NOT FOR COLD', 0, 75);
1163
+ ctx.restore();
1164
+ }
1165
+
1166
+ // Stamp 2: NOT FOR PROTECTION (slams down in Beat 3)
1167
+ if (stamp2 > 0.05) {
1168
+ ctx.save();
1169
+ const s2X = cx + 240;
1170
+ const s2Y = cy + 90;
1171
+ ctx.translate(s2X, s2Y);
1172
+ ctx.scale(stamp2, stamp2);
1173
+ sCircle(0, 0, 50, 410, 'rgba(239,68,68,0.9)', 4);
1174
+ sLine(-36, -36, 36, 36, 411, 'rgba(239,68,68,0.95)', 4.5);
1175
+ ctx.font = 'bold 20px monospace';
1176
+ ctx.fillStyle = '#ef4444';
1177
+ ctx.textAlign = 'center';
1178
+ ctx.fillText('NOT FOR PROTECTION', 0, 75);
1179
+ ctx.restore();
1180
+ }
1181
+
1182
+ } else if (COMP_THEME === 'doodle_hunter_savannah') {
1183
+ // Beat 1: Walking with spear -> Beat 2: Swatting mosquitoes -> Beat 3: Pausing alert
1184
+ const walkProg = Math.min(1, Math.max(0, t / window.DURATION));
1185
+ const swatting = (t % 2.4 > 1.2);
1186
+
1187
+ // Blazing midday sun with undulating rays
1188
+ const sunR = 75 + Math.sin(t * 2) * 6;
1189
+ ctx.fillStyle = '#f59e0b';
1190
+ ctx.beginPath();
1191
+ ctx.arc(cx - 380, cy - 180, sunR, 0, Math.PI * 2);
1192
+ ctx.fill();
1193
+ for (let r = 0; r < 8; r++) {
1194
+ const rayA = (r / 8) * Math.PI * 2 + t * 0.4;
1195
+ const rx1 = cx - 380 + Math.cos(rayA) * (sunR + 10);
1196
+ const ry1 = cy - 180 + Math.sin(rayA) * (sunR + 10);
1197
+ const rx2 = cx - 380 + Math.cos(rayA) * (sunR + 32);
1198
+ const ry2 = cy - 180 + Math.sin(rayA) * (sunR + 32);
1199
+ sLine(rx1, ry1, rx2, ry2, 450 + r, '#fbbf24', 3);
1200
+ }
1201
+
1202
+ // Ground and tall swaying savannah grass
1203
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, '#78350f', 3);
1204
+ for (let g = 0; g < 32; g++) {
1205
+ const gx = cx - 550 + g * 36;
1206
+ const sway = Math.sin(t * 3.2 + g * 0.4) * 16;
1207
+ sLine(gx, cy + 220, gx + sway, cy + 140, 500 + g, '#d97706', 2.5);
1208
+ }
1209
+
1210
+ // Buzzing mosquitoes orbiting hunter
1211
+ const charX = cx - 180 + walkProg * 240;
1212
+ for (let m = 0; m < 4; m++) {
1213
+ const mx = charX + 50 + Math.sin(t * 8 + m * 2) * 65;
1214
+ const my = cy - 60 + Math.cos(t * 7 + m * 2) * 45;
1215
+ sCircle(mx, my, 2.5, 550 + m, '#0f172a', 1.5, '#ef4444');
1216
+ }
1217
+
1218
+ // Walking early hunter with spear
1219
+ drawDoodleCharacter({
1220
+ x: charX,
1221
+ y: cy + 140,
1222
+ scale: 1.35,
1223
+ hairStyle: 'wild',
1224
+ expression: swatting ? 'shivering' : 'talking',
1225
+ clothing: 'none',
1226
+ armPose: 'hold_spear',
1227
+ seed: 103,
1228
+ });
1229
+
1230
+ } else if (COMP_THEME === 'doodle_sewing_needle') {
1231
+ // Beat 1: Seated by campfire -> Beat 2: Zoom loupe with bone needle -> Beat 3: Timeline badges
1232
+ const loupeSpring = spring(t, window.DURATION * 0.32, 180, 15);
1233
+ const badgeSpring = spring(t, window.DURATION * 0.68, 200, 15);
1234
+
1235
+ // Campfire with animated flames and sparks
1236
+ const fireX = cx - 380;
1237
+ const fireY = cy + 180;
1238
+ for (let f = 0; f < 5; f++) {
1239
+ const flH = 50 + Math.sin(t * 12 + f) * 22;
1240
+ ctx.fillStyle = f % 2 === 0 ? '#ef4444' : '#f59e0b';
1241
+ ctx.beginPath();
1242
+ ctx.moveTo(fireX - 30 + f * 12, fireY);
1243
+ ctx.quadraticCurveTo(fireX + (f - 2) * 8, fireY - flH, fireX - 10 + f * 12, fireY);
1244
+ ctx.fill();
1245
+ }
1246
+ for (let sp = 0; sp < 8; sp++) {
1247
+ const sx = fireX + Math.sin(t * 4 + sp) * 35;
1248
+ const sy = fireY - 30 - ((t * 80 + sp * 35) % 140);
1249
+ sCircle(sx, sy, 2, 600 + sp, '#fbbf24', 1, '#fbbf24');
1250
+ }
1251
+
1252
+ // Seated human stitching hide with bone needle
1253
+ drawDoodleCharacter({
1254
+ x: cx - 120,
1255
+ y: cy + 140,
1256
+ scale: 1.35,
1257
+ hairStyle: 'wild',
1258
+ expression: 'talking',
1259
+ clothing: 'pelt',
1260
+ armPose: 'sewing',
1261
+ seed: 104,
1262
+ });
1263
+
1264
+ // Zoomed loupe expands open in Beat 2
1265
+ if (loupeSpring > 0.05) {
1266
+ ctx.save();
1267
+ const lensX = cx + 270;
1268
+ const lensY = cy - 20;
1269
+ ctx.translate(lensX, lensY);
1270
+ ctx.scale(loupeSpring, loupeSpring);
1271
+
1272
+ const lensR = 140;
1273
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.92)';
1274
+ ctx.beginPath();
1275
+ ctx.arc(0, 0, lensR, 0, Math.PI * 2);
1276
+ ctx.fill();
1277
+ ctx.strokeStyle = '#d4af37';
1278
+ ctx.lineWidth = 5;
1279
+ ctx.stroke();
1280
+
1281
+ // Two thick leather pelt pieces being stitched
1282
+ sLine(-85, -20, 85, -20, 650, '#92400e', 8);
1283
+ sLine(-85, 20, 85, 20, 651, '#78350f', 8);
1284
+
1285
+ // Drilled bone needle piercing through with animated wiggle
1286
+ const nWiggle = Math.sin(t * 4.5) * 18;
1287
+ sLine(-45 + nWiggle, -65, 25 + nWiggle, 55, 652, '#fef08a', 6);
1288
+ sCircle(-40 + nWiggle, -58, 4, 653, '#0f172a', 1.5, '#0f172a');
1289
+
1290
+ // Stitches across pelts
1291
+ for (let st = 0; st < 6; st++) {
1292
+ const stX = -70 + st * 28;
1293
+ sLine(stX, -22, stX + 8, 22, 660 + st, '#ffffff', 3.5);
1294
+ }
1295
+ ctx.restore();
1296
+ }
1297
+
1298
+ // Timeline badge in Beat 3
1299
+ if (badgeSpring > 0.05) {
1300
+ ctx.save();
1301
+ ctx.globalAlpha = Math.min(1, badgeSpring);
1302
+ sRect(cx + 120, cy + 140, 300, 42, 670, '#d4af37', 2.5, 'rgba(15, 23, 42, 0.85)');
1303
+ ctx.font = 'bold 16px monospace';
1304
+ ctx.fillStyle = '#fef08a';
1305
+ ctx.textAlign = 'center';
1306
+ ctx.fillText('BONE NEEDLE • 120,000 YEARS', cx + 270, cy + 167);
1307
+ ctx.restore();
1308
+ }
1309
+
1310
+ } else if (COMP_THEME === 'doodle_shame_reflection') {
1311
+ // Beat 1: Looking down -> Beat 2: Water reflection & blushing -> Beat 3: Theory of Mind halo
1312
+ const refSpring = spring(t, window.DURATION * 0.30, 180, 15);
1313
+ const haloSpring = spring(t, window.DURATION * 0.65, 190, 15);
1314
+
1315
+ const poolX = cx;
1316
+ const poolY = cy + 180;
1317
+ ctx.fillStyle = 'rgba(14, 165, 233, 0.15)';
1318
+ ctx.beginPath();
1319
+ ctx.ellipse(poolX, poolY, 260, 60, 0, 0, Math.PI * 2);
1320
+ ctx.fill();
1321
+ ctx.strokeStyle = 'rgba(56, 189, 248, 0.4)';
1322
+ ctx.lineWidth = 2.5;
1323
+ ctx.stroke();
1324
+
1325
+ // Ripples in pool
1326
+ for (let rip = 1; rip <= 3; rip++) {
1327
+ const ripR = (rip * 65 + t * 45) % 250;
1328
+ const ripAlpha = (1 - ripR / 250) * 0.4;
1329
+ ctx.strokeStyle = 'rgba(56, 189, 248, ' + ripAlpha + ')';
1330
+ ctx.beginPath();
1331
+ ctx.ellipse(poolX, poolY, ripR, ripR * 0.23, 0, 0, Math.PI * 2);
1332
+ ctx.stroke();
1333
+ }
1334
+
1335
+ // Inverted rippling reflection of character in water (clarifies in Beat 2)
1336
+ if (refSpring > 0.1) {
1337
+ ctx.save();
1338
+ ctx.globalAlpha = Math.min(0.45, refSpring * 0.45);
1339
+ ctx.translate(cx, poolY + 20);
1340
+ ctx.scale(1.2, -0.75);
1341
+ drawDoodleCharacter({
1342
+ x: 0,
1343
+ y: 0,
1344
+ scale: 1.0,
1345
+ hairStyle: 'wild',
1346
+ expression: 'surprised',
1347
+ clothing: 'none',
1348
+ armPose: 'cover_body',
1349
+ seed: 700,
1350
+ });
1351
+ ctx.restore();
1352
+ }
1353
+
1354
+ // Standing character looking down and covering body
1355
+ drawDoodleCharacter({
1356
+ x: cx,
1357
+ y: cy + 120,
1358
+ scale: 1.35,
1359
+ hairStyle: 'wild',
1360
+ expression: refSpring > 0.4 ? 'surprised' : 'talking',
1361
+ clothing: 'none',
1362
+ armPose: refSpring > 0.4 ? 'cover_body' : 'natural',
1363
+ seed: 105,
1364
+ });
1365
+
1366
+ // Blushing cheeks appear in Beat 2
1367
+ if (refSpring > 0.3) {
1368
+ const blushA = Math.min(0.7, refSpring * 0.7);
1369
+ sCircle(cx - 18, cy - 8, 7, 750, 'rgba(244,63,94,' + blushA + ')', 1, 'rgba(244,63,94,' + blushA + ')');
1370
+ sCircle(cx + 18, cy - 8, 7, 751, 'rgba(244,63,94,' + blushA + ')', 1, 'rgba(244,63,94,' + blushA + ')');
1371
+ }
1372
+
1373
+ // Theory of Mind Thought Halo in Beat 3
1374
+ if (haloSpring > 0.05) {
1375
+ ctx.save();
1376
+ const haloX = cx + 180;
1377
+ const haloY = cy - 120;
1378
+ ctx.translate(haloX, haloY);
1379
+ ctx.scale(haloSpring, haloSpring);
1380
+ sCircle(0, 0, 48, 760, '#38bdf8', 3);
1381
+ sCircle(0, 0, 18, 761, '#ffffff', 2.5);
1382
+ sCircle(0, 0, 8, 762, '#38bdf8', 1, '#38bdf8');
1383
+ ctx.font = 'bold 16px monospace';
1384
+ ctx.fillStyle = '#38bdf8';
1385
+ ctx.textAlign = 'center';
1386
+ ctx.fillText('THEORY OF MIND', 0, 72);
1387
+ ctx.restore();
1388
+ }
1389
+
1390
+ } else if (COMP_THEME === 'doodle_tribe_meeting') {
1391
+ // Beat 1: Walking toward center -> Beat 2: Greet & check patterns -> Beat 3: Social language arc
1392
+ const approach = spring(t, 0.05, 140, 16);
1393
+ const greet = spring(t, window.DURATION * 0.38, 190, 15);
1394
+ const arcSpring = spring(t, window.DURATION * 0.68, 200, 15);
1395
+
1396
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1397
+
1398
+ // Left Clan Member walks toward center
1399
+ const leftX = cx - 380 + Math.min(1, approach) * 160;
1400
+ drawDoodleCharacter({
1401
+ x: leftX,
1402
+ y: cy + 140,
1403
+ scale: 1.3,
1404
+ hairStyle: 'wild',
1405
+ expression: 'talking',
1406
+ clothing: 'pelt',
1407
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1408
+ seed: 106,
1409
+ });
1410
+
1411
+ // Right Clan Member walks toward center
1412
+ const rightX = cx + 380 - Math.min(1, approach) * 160;
1413
+ drawDoodleCharacter({
1414
+ x: rightX,
1415
+ y: cy + 140,
1416
+ scale: 1.3,
1417
+ hairStyle: 'feather',
1418
+ expression: 'curious',
1419
+ clothing: 'cloak',
1420
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1421
+ seed: 107,
1422
+ });
1423
+
1424
+ // Glowing connection arc and symbols in Beat 3
1425
+ if (arcSpring > 0.05) {
1426
+ ctx.save();
1427
+ ctx.globalAlpha = Math.min(1, arcSpring);
1428
+ ctx.strokeStyle = ACCENT;
1429
+ ctx.lineWidth = 3;
1430
+ ctx.setLineDash([8, 8]);
1431
+ ctx.beginPath();
1432
+ ctx.moveTo(leftX + 40, cy);
1433
+ ctx.quadraticCurveTo(cx, cy - 100, rightX - 40, cy);
1434
+ ctx.stroke();
1435
+ ctx.setLineDash([]);
1436
+
1437
+ sCircle(cx, cy - 65, 34, 800, ACCENT, 3, 'rgba(15, 23, 42, 0.85)');
1438
+ ctx.font = 'bold 22px monospace';
1439
+ ctx.fillStyle = ACCENT;
1440
+ ctx.textAlign = 'center';
1441
+ ctx.fillText('🤝', cx, cy - 56);
1442
+ ctx.font = 'bold 16px monospace';
1443
+ ctx.fillText('CLOTHING AS SOCIAL LANGUAGE', cx, cy + 15);
1444
+ ctx.restore();
1445
+ }
1446
+
1447
+ } else if (COMP_THEME === 'doodle_thinking_future') {
1448
+ // Beat 1: Seated thinker by fire -> Beat 2: Thought cloud floats up -> Beat 3: Tomorrow's rack & tools
1449
+ const cloudSpring = spring(t, window.DURATION * 0.32, 170, 14);
1450
+ const ideaSpring = spring(t, window.DURATION * 0.65, 190, 15);
1451
+
1452
+ // Starlit night and crescent moon
1453
+ ctx.fillStyle = '#fef08a';
1454
+ ctx.beginPath();
1455
+ ctx.arc(cx - 360, cy - 190, 50, 0, Math.PI * 2);
1456
+ ctx.fill();
1457
+ ctx.fillStyle = BG2;
1458
+ ctx.beginPath();
1459
+ ctx.arc(cx - 345, cy - 200, 48, 0, Math.PI * 2);
1460
+ ctx.fill();
1461
+
1462
+ // Large rock boulder for thinker
1463
+ ctx.fillStyle = '#334155';
1464
+ ctx.beginPath();
1465
+ ctx.ellipse(cx - 140, cy + 175, 85, 55, 0, 0, Math.PI * 2);
1466
+ ctx.fill();
1467
+
1468
+ // Thinker seated on rock
1469
+ drawDoodleCharacter({
1470
+ x: cx - 140,
1471
+ y: cy + 130,
1472
+ scale: 1.35,
1473
+ hairStyle: 'wild',
1474
+ expression: 'talking',
1475
+ clothing: 'pelt',
1476
+ armPose: 'thinker',
1477
+ seed: 108,
1478
+ });
1479
+
1480
+ // Expanding thought cloud overhead in Beat 2
1481
+ if (cloudSpring > 0.05) {
1482
+ const cloudX = cx + 180;
1483
+ const cloudY = cy - 80;
1484
+ ctx.save();
1485
+ ctx.translate(cloudX, cloudY);
1486
+ ctx.scale(cloudSpring, cloudSpring);
1487
+
1488
+ sCircle(-180, 160, 12, 850, '#ffffff', 2, '#ffffff');
1489
+ sCircle(-140, 120, 18, 851, '#ffffff', 2, '#ffffff');
1490
+ sCircle(-90, 70, 24, 852, '#ffffff', 2, '#ffffff');
1491
+
1492
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
1493
+ ctx.beginPath();
1494
+ ctx.arc(-70, 0, 55, 0, Math.PI * 2);
1495
+ ctx.arc(0, -25, 65, 0, Math.PI * 2);
1496
+ ctx.arc(70, 0, 55, 0, Math.PI * 2);
1497
+ ctx.arc(0, 30, 60, 0, Math.PI * 2);
1498
+ ctx.fill();
1499
+
1500
+ // Inside thought cloud: Tomorrow's sunrise & stretched hide on rack in Beat 3
1501
+ if (ideaSpring > 0.1) {
1502
+ ctx.fillStyle = '#f59e0b';
1503
+ ctx.beginPath();
1504
+ ctx.arc(0, 15, 30, Math.PI, Math.PI * 2);
1505
+ ctx.fill();
1506
+
1507
+ sLine(-35, 15, -35, -25, 860, '#78350f', 3);
1508
+ sLine(35, 15, 35, -25, 861, '#78350f', 3);
1509
+ sLine(-40, -25, 40, -25, 862, '#78350f', 3);
1510
+
1511
+ ctx.fillStyle = '#b45309';
1512
+ ctx.fillRect(-28, -20, 56, 32);
1513
+
1514
+ ctx.font = 'bold 15px monospace';
1515
+ ctx.fillStyle = '#d97706';
1516
+ ctx.textAlign = 'center';
1517
+ ctx.fillText('💡 PLANNING FOR TOMORROW', 0, 52);
1518
+ }
1519
+ ctx.restore();
1520
+ }
1521
+
1522
+ } else if (COMP_THEME === 'doodle_microscope_lice') {
1523
+ // Beat 1: Scratching pelt -> Beat 2: Giant microscope slides in -> Beat 3: Divergence tree
1524
+ const scopeSpring = spring(t, window.DURATION * 0.30, 180, 15);
1525
+ const treeSpring = spring(t, window.DURATION * 0.65, 200, 15);
1526
+
1527
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1528
+
1529
+ // Early human scratching pelt with itch lines
1530
+ drawDoodleCharacter({
1531
+ x: cx - 240,
1532
+ y: cy + 140,
1533
+ scale: 1.35,
1534
+ hairStyle: 'wild',
1535
+ expression: 'shivering',
1536
+ clothing: 'pelt',
1537
+ armPose: 'shiver',
1538
+ seed: 109,
1539
+ });
1540
+
1541
+ for (let it = 0; it < 3; it++) {
1542
+ const itX = cx - 290 - it * 12;
1543
+ const itY = cy + 50 + it * 20;
1544
+ sLine(itX, itY, itX + 18, itY - 14, 900 + it, '#f59e0b', 2.5);
1545
+ }
1546
+
1547
+ // Microscope slides in in Beat 2
1548
+ if (scopeSpring > 0.05) {
1549
+ const lensX = cx + 180;
1550
+ const lensY = cy;
1551
+ const lensR = 150;
1552
+
1553
+ ctx.save();
1554
+ ctx.translate(lensX, lensY);
1555
+ ctx.scale(scopeSpring, scopeSpring);
1556
+
1557
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.95)';
1558
+ ctx.beginPath();
1559
+ ctx.arc(0, 0, lensR, 0, Math.PI * 2);
1560
+ ctx.fill();
1561
+ ctx.strokeStyle = '#d4af37';
1562
+ ctx.lineWidth = 6;
1563
+ ctx.stroke();
1564
+
1565
+ // Fabric weave grid lines inside lens
1566
+ for (let g = -4; g <= 4; g++) {
1567
+ sLine(g * 24, -lensR * 0.8, g * 24, lensR * 0.8, 920 + g, 'rgba(56, 189, 248, 0.15)', 2);
1568
+ sLine(-lensR * 0.8, g * 24, lensR * 0.8, g * 24, 930 + g, 'rgba(56, 189, 248, 0.15)', 2);
1569
+ }
1570
+
1571
+ // Divergence tree in Beat 3
1572
+ if (treeSpring > 0.1) {
1573
+ sLine(-100, 0, -20, 0, 940, '#f8fafc', 4);
1574
+ sCircle(-20, 0, 7, 941, '#d4af37', 2, '#d4af37');
1575
+
1576
+ sLine(-20, 0, 60, -45, 942, '#38bdf8', 4);
1577
+ ctx.font = 'bold 14px monospace';
1578
+ ctx.fillStyle = '#38bdf8';
1579
+ ctx.textAlign = 'left';
1580
+ ctx.fillText('HEAD LICE (HAIR)', 70, -42);
1581
+
1582
+ sLine(-20, 0, 60, 45, 943, '#f43f5e', 4);
1583
+ ctx.fillStyle = '#f43f5e';
1584
+ ctx.fillText('BODY LICE (CLOTHES)', 70, 48);
1585
+
1586
+ sRect(-90, 80, 180, 32, 950, '#d4af37', 2, 'rgba(212, 175, 55, 0.25)');
1587
+ ctx.font = 'bold 14px monospace';
1588
+ ctx.fillStyle = '#fef08a';
1589
+ ctx.textAlign = 'center';
1590
+ ctx.fillText('170,000 YEARS AGO', 0, 101);
1591
+ }
1592
+ ctx.restore();
1593
+ }
1594
+
1595
+ } else if (COMP_THEME === 'doodle_general_human') {
1596
+ // General Expressive Doodled Human Narrator
1597
+ sLine(cx - W * 0.4, cy + 220, cx + W * 0.4, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1598
+ drawDoodleCharacter({
1599
+ x: cx,
1600
+ y: cy + 140,
1601
+ scale: 1.4,
1602
+ hairStyle: 'wild',
1603
+ expression: 'talking',
1604
+ clothing: 'pelt',
1605
+ armPose: 'natural',
1606
+ seed: 110,
1607
+ });
1608
+
1609
+ } else if (COMP_THEME === 'celestial_orbit') {
698
1610
  // 3D Celestial Body (Earth) with orbiting satellites & signal pulses
699
1611
  const earthR = Math.min(W, H) * 0.22;
700
1612
 
@@ -1168,49 +2080,70 @@ function drawMainScene(t) {
1168
2080
  ctx.restore();
1169
2081
  }
1170
2082
 
1171
- // ── Kinetic Typography & Broadcast Subtitle Overlay ─────────────────────────
2083
+ // ── Sentence-Synchronized Broadcast Subtitles Overlay ───────────────────────
1172
2084
  function drawCinematicOverlay(t) {
1173
2085
  ctx.save();
1174
- const textSpring = spring(t, 0.12, 190, 15);
1175
- const headlineAlpha = Math.min(1, Math.max(0, textSpring));
1176
- ctx.globalAlpha = headlineAlpha;
1177
2086
 
1178
- // Kinetic Headline at top
1179
- const capY = H * 0.12 + (1 - textSpring) * -20;
2087
+ // Subtitles / Spoken Narration - dynamic sentence progression across time
2088
+ if (NARRATION && NARRATION.trim().length > 0) {
2089
+ // Split narration into natural spoken sentence chunks
2090
+ const sentences = NARRATION.split(/[.!?;|]+/).map(s => s.trim()).filter(Boolean);
1180
2091
 
1181
- // Tag Pill - minimal floating badge
1182
- if (TAG) {
1183
- ctx.font = 'bold 16px monospace';
1184
- ctx.fillStyle = ACCENT;
1185
- ctx.shadowColor = 'rgba(0, 0, 0, 0.85)';
1186
- ctx.shadowBlur = 10;
1187
- ctx.textAlign = 'left';
1188
- ctx.fillText('// ' + TAG, 80, capY - 22);
1189
- }
2092
+ const count = Math.max(1, sentences.length);
2093
+ const sentDur = window.DURATION / count;
2094
+ const currIdx = Math.min(count - 1, Math.floor(t / sentDur));
2095
+ const activeText = sentences[currIdx] || NARRATION;
2096
+
2097
+ // Sentence local spring transition (smooth entrance per sentence)
2098
+ const localT = t - currIdx * sentDur;
2099
+ const sentSpring = spring(localT, 0.04, 210, 16);
2100
+ const sentAlpha = Math.min(1, Math.max(0, sentSpring));
2101
+ const sentSlideY = (1 - sentSpring) * 10;
1190
2102
 
1191
- // Primary Caption Headline - crisp, heroic typography without opaque cards
1192
- ctx.font = FONT_WEIGHT + ' 50px ' + FONT_DISPLAY;
1193
- ctx.fillStyle = '#ffffff';
1194
- ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
1195
- ctx.shadowBlur = 24;
1196
- ctx.shadowOffsetY = 4;
1197
- ctx.textAlign = 'left';
1198
- const cleanCap = CAPTION.length > 60 ? CAPTION.slice(0, 60) + '...' : CAPTION;
1199
- ctx.fillText(cleanCap, 80, capY + 30);
1200
-
1201
- // Subtitle / Spoken Narration - bottom center clean broadcast subtitles
1202
- if (NARRATION) {
1203
- const subSpring = spring(t, 0.22, 180, 16);
1204
- ctx.globalAlpha = Math.min(1, Math.max(0, subSpring));
1205
- ctx.font = '500 28px ' + FONT_BODY;
1206
- ctx.fillStyle = '#f8fafc';
1207
- ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
1208
- ctx.shadowBlur = 18;
1209
- ctx.shadowOffsetY = 3;
2103
+ ctx.save();
2104
+ ctx.globalAlpha = sentAlpha;
2105
+ ctx.font = '500 32px ' + FONT_BODY;
1210
2106
  ctx.textAlign = 'center';
1211
- const cleanNarr = NARRATION.length > 115 ? NARRATION.slice(0, 115) + '...' : NARRATION;
1212
- ctx.fillText(cleanNarr, W / 2, H * 0.92);
2107
+
2108
+ // Word wrap if sentence is long (> 58 chars)
2109
+ const maxChars = 56;
2110
+ const words = activeText.split(' ');
2111
+ const lines = [];
2112
+ let currentLine = '';
2113
+ for (let w = 0; w < words.length; w++) {
2114
+ const testLine = currentLine ? currentLine + ' ' + words[w] : words[w];
2115
+ if (testLine.length > maxChars && currentLine.length > 0) {
2116
+ lines.push(currentLine);
2117
+ currentLine = words[w];
2118
+ } else {
2119
+ currentLine = testLine;
2120
+ }
2121
+ }
2122
+ if (currentLine) lines.push(currentLine);
2123
+
2124
+ const subBaseY = H * 0.90 + sentSlideY;
2125
+ const lineSpacing = 44;
2126
+ const startY = subBaseY - ((lines.length - 1) * lineSpacing) / 2;
2127
+
2128
+ for (let l = 0; l < lines.length; l++) {
2129
+ const ly = startY + l * lineSpacing;
2130
+ // Soft translucent background backing for broadcast legibility
2131
+ const textMetrics = ctx.measureText(lines[l]);
2132
+ const pillW = textMetrics.width + 40;
2133
+ ctx.fillStyle = 'rgba(6, 9, 14, 0.72)';
2134
+ ctx.beginPath();
2135
+ ctx.roundRect((W - pillW) / 2, ly - 28, pillW, 38, 8);
2136
+ ctx.fill();
2137
+
2138
+ // Subtitle text with subtle glow
2139
+ ctx.fillStyle = '#ffffff';
2140
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
2141
+ ctx.shadowBlur = 12;
2142
+ ctx.fillText(lines[l], W / 2, ly);
2143
+ }
2144
+ ctx.restore();
1213
2145
  }
2146
+
1214
2147
  ctx.restore();
1215
2148
 
1216
2149
  // Bottom Progress Indicator (minimal line)
@@ -1265,10 +2198,11 @@ AVAILABLE HELPERS:
1265
2198
  - easeIn(t), easeOut(t), easeInOut(t), boilSeed(t, seed), hash(i)
1266
2199
  RULES:
1267
2200
  1. Every element must animate smoothly over time t [0..DURATION].
1268
- 2. Fill the canvas with rich visual geometry, vectors, animated nodes, characters, particles, or cinematic diagrams matching the prompt.
1269
- 3. NEVER use Math.random(), setTimeout, setInterval, Date.now(), performance.now().
1270
- 4. Output ONLY raw executable JavaScript code for the body of window.seek = async (t) => { ... }.
1271
- 5. NO markdown backticks, NO explanations. Start directly with JavaScript.`;
2201
+ 2. If narration, key phrase, or visual direction involves human actions, history, emotions, characters, or daily life (e.g. choosing clothes, shivering in cold, sewing hides, meeting another tribe, mirror reflection, thinking by fire), ALWAYS animate expressive hand-drawn doodled human figures actively performing those actions (limbs, head, gestures, facial expressions, setting props) rather than sterile abstract diagrams.
2202
+ 3. Fill the canvas with rich visual geometry, fluid strokes, animated characters, particles, or cinematic diagrams matching the prompt.
2203
+ 4. NEVER use Math.random(), setTimeout, setInterval, Date.now(), performance.now(). All motion must be a pure mathematical function of time t.
2204
+ 5. Output ONLY raw executable JavaScript code for the body of window.seek = async (t) => { ... }.
2205
+ 6. NO markdown backticks, NO explanations. Start directly with JavaScript.`;
1272
2206
 
1273
2207
  const user = `TOPIC: ${topic || 'Visual Explainer'}
1274
2208
  VISUAL DIRECTION: ${visualDescription || caption || 'Dynamic motion graphics'}