@amg555/frameforge-mcp 2.4.2 → 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,34 @@ 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
+
7
35
  ## [2.4.2] — 2026-11-09
8
36
 
9
37
  ### Added
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.2",
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.2', code.includes("version: '2.4.2'"));
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.2', cfWorker.includes("'2.4.2'"));
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.2',
1636
+ version: '2.4.3',
1637
1637
  };
1638
1638
 
1639
1639
  const CAPABILITIES = {
@@ -268,54 +268,54 @@ function detectComponentTheme({ visualDescription = '', caption = '', narration
268
268
  const text = `${visualDescription} ${caption} ${narration} ${topic}`.toLowerCase();
269
269
 
270
270
  // ── Doodled Character Actions (Human Stories, History, Evolution, Daily Life) ──
271
- // 1. Microscope / Lice Theory / Parasite / Genetics (170,000 Years Lice Proof)
271
+ // 1. Microscope / Lice Theory / Parasite / Genetics (Scene 7)
272
272
  if (/(?:^|[^\w])(lice|louse|microscope|fiber|weave|parasite|lice evolution|genetic biology proof)(?:[^\w]|$)/i.test(text)
273
273
  || /പേൻ|പേൻ തിയറി|തലയിലെ പേൻ|ശരീരത്തിലെ പേൻ|ലൈസ് തിയറി|മൈക്രോസ്കോപ്പ്/.test(text)) {
274
274
  return 'doodle_microscope_lice';
275
275
  }
276
276
 
277
- // 2. Dressing / Wardrobe / Wearing Clothes / Outfit / Morning Routine / Choosing clothes
278
- 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)
279
- || /വസ്ത്രധാരണം|ധരിച്ചിട്ടുണ്ടാ|ഷർട്ട്|തിരഞ്ഞെടുത്തത്|തുണി എടുത്തിടുകയല്ല|കല്യാണത്തിന്|ഇന്റർവ്യൂ|ആമുഖം: വസ്ത്രധാരണം|വസ്ത്രം തിരഞ്ഞെടുക്കുമ്പോൾ|വസ്ത്രം തിരഞ്ഞെടുക്കും/.test(text)) {
280
- return 'doodle_dressing';
281
- }
282
-
283
- // 3. Shivering / Cold / Protection Flaw / Winter / Freezing (e.g. myth busting cold defense)
277
+ // 2. Shivering / Cold / Protection Flaw / Winter / Freezing (Scene 2, 6)
284
278
  if (/(?:^|[^\w])(shiver|shivering|cold|freeze|freezing|ice|winter|teeth|chatter|protection flaw)(?:[^\w]|$)/i.test(text)
285
- || /തണുപ്പ്|വിറയ്ക്കുക|തണുപ്പിൽ|തണുപ്പിൽ നിന്ന്|പരിക്കു|തള്ളിക്കളയുന്നു|ശരീരത്തെ സംരക്ഷിക്കാൻ|ശരീര സംരക്ഷണം|സാധാരണ ധാരണകൾ/.test(text)) {
279
+ || /സാധാരണ ധാരണകൾ|തണുപ്പും സംരക്ഷണവും|തള്ളിക്കളയുന്നു|ശരീരത്തെ സംരക്ഷിക്കാൻ|ശരീര സംരക്ഷണം|വിറയ്ക്കുക|തണുപ്പിൽ നിന്ന് രക്ഷ നേടാൻ|തണുപ്പ് എന്ന കാരണം|തണുപ്പുള്ള സ്ഥലങ്ങളിലേക്ക്/.test(text)) {
286
280
  return 'doodle_shiver_cold';
287
281
  }
288
282
 
289
- // 4. Meeting Tribes / Social Language / Status / Community / Strangers
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
290
  if (/(?:^|[^\w])(tribe|tribal|clan|stranger|meeting strangers|social language|status in society|hierarchy|friend or enemy)(?:[^\w]|$)/i.test(text)
291
291
  || /കൂട്ടമായി|സമൂഹം|അപരിചിത|ഗോത്രം|ശത്രു|സുഹൃത്ത്|സാമൂഹിക ഭാഷ|സാമൂഹിക|സ്ഥാനം തിരിച്ചറിഞ്ഞിരുന്നത്/.test(text)) {
292
292
  return 'doodle_tribe_meeting';
293
293
  }
294
294
 
295
- // 5. Future Planning / Cognitive Revolution / Tomorrow / Foresight / Campfire thinking
295
+ // 5. Future Planning / Cognitive Revolution / Tomorrow / Foresight / Campfire thinking (Scene 8)
296
296
  if (/(?:^|[^\w])(planning for tomorrow|future planning|cognitive revolution|thinking future|contemplate|foresight|ian gilligan)(?:[^\w]|$)/i.test(text)
297
297
  || /നാളത്തേക്ക്|പ്ലാൻ|ഭാവി|ഭാവിയെ കുറിച്ച്|ബുദ്ധി ഉപയോഗിച്ച്|ചിന്തയിലെ മാറ്റം|ഇയാൻ ഗില്ലിഗൻ|ചിന്തിക്കാൻ തുടങ്ങി/.test(text)) {
298
298
  return 'doodle_thinking_future';
299
299
  }
300
300
 
301
- // 6. Hunter in Savannah / Wild / Blazing Sun / Spear / Mosquitoes / Naked in Jungle
302
- if (/(?:^|[^\w])(savannah|savanna|spear|hunter|hunting|africa savannah|foraging|wild jungle|naked|clothless|mosquito|mosquitoes|thorns)(?:[^\w]|$)/i.test(text)
303
- || /വേട്ട|വേട്ടയാടി|ആഫ്രിക്കൻ കാടുകളിൽ|കാട്ടിൽ നഗ്നരായി|നഗ്നരായിട്ടാണ്|കൊടുംചൂട്|കൊതുകുകടി|മുള്ളുകൾ|കുന്തം|കാട്ടിലെ ലക്ഷക്കണക്കിന്/.test(text)) {
304
- return 'doodle_hunter_savannah';
305
- }
306
-
307
- // 7. Bone Needle / Sewing / Stitching / Animal Pelt / Prehistoric Tools
301
+ // 6. Bone Needle / Sewing / Stitching / Animal Pelt / Prehistoric Tools (Scene 4)
308
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)
309
303
  || /സൂചി|സൂചിയും|തുന്ന|തുന്നാൻ|തോൽ തുന്നാൻ|തുന്നൽ|തോൽ തുന്നാനുള്ള/.test(text)) {
310
304
  return 'doodle_sewing_needle';
311
305
  }
312
306
 
313
- // 8. Shame / Mirror / Reflection / Water / Self Awareness / Theory of Mind
307
+ // 7. Shame / Mirror / Reflection / Water / Self Awareness / Theory of Mind (Scene 3, 9)
314
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)
315
- || /നാണം|നാണം മറയ്ക്കാൻ|കണ്ണാടി|പ്രതിഫലനം|മറ്റുള്ളവർ എന്നെ|തിയറി ഓഫ് മൈൻഡ്|സ്വന്തം ശരീരം മറച്ചു|വ്യക്തിത്വം|ശ്രദ്ധിക്കുന്നുണ്ട്|യഥാർത്ഥ രഹസ്യം|മനഃശാസ്ത്ര/.test(text)) {
309
+ || /കണ്ണാടി|പ്രതിഫലനം|മറ്റുള്ളവർ എന്നെ|തിയറി ഓഫ് മൈൻഡ്|സ്വന്തം ശരീരം മറച്ചു|വ്യക്തിത്വം|ശ്രദ്ധിക്കുന്നുണ്ട്|യഥാർത്ഥ രഹസ്യം|നാണം മറയ്ക്കാൻ/.test(text)) {
316
310
  return 'doodle_shame_reflection';
317
311
  }
318
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
319
  // 9. General Human Character
320
320
  if (/(?:^|[^\w])(human|person|man|woman|people|child|guy|doodle|character|actor)(?:[^\w]|$)/i.test(text)
321
321
  || /മനുഷ്യൻ|ആളുകൾ|വ്യക്തി/.test(text)) {
@@ -740,10 +740,13 @@ function drawBackground(t) {
740
740
  // ── Full-Canvas Cinematic Visual Directors ─────────────────────────────────────
741
741
  function drawMainScene(t) {
742
742
  ctx.save();
743
+ const p = Math.min(1, Math.max(0, t / window.DURATION));
743
744
  const enterSpring = spring(t, 0.05, 170, 13);
744
- const sceneScale = Math.min(1.04, Math.max(0.88, enterSpring));
745
- ctx.translate(W / 2, H * 0.50);
746
- 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);
747
750
  ctx.translate(-W / 2, -H * 0.50);
748
751
 
749
752
  const cx = W / 2;
@@ -1027,12 +1030,15 @@ function drawMainScene(t) {
1027
1030
  ctx.restore();
1028
1031
  }
1029
1032
 
1030
- // ── Doodled Character Narrative Themes ────────────────────────────────────
1033
+ // ── Doodled Character Narrative Themes (3-Beat Progressive Choreography) ─
1031
1034
  if (COMP_THEME === 'doodle_dressing') {
1032
- // Modern Dressing Room & Mirror Reflection
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
+
1033
1039
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.25)', 2.5);
1034
1040
 
1035
- // Clothes rack on left
1041
+ // Clothes rack on left (sways in beat 1)
1036
1042
  sLine(cx - 360, cy + 220, cx - 360, cy - 140, 11, '#94a3b8', 5);
1037
1043
  sLine(cx - 410, cy + 220, cx - 310, cy + 220, 12, '#94a3b8', 5);
1038
1044
  sLine(cx - 420, cy - 140, cx - 180, cy - 140, 13, '#cbd5e1', 5);
@@ -1053,96 +1059,132 @@ function drawMainScene(t) {
1053
1059
  ctx.fill();
1054
1060
  }
1055
1061
 
1056
- // Mirror on right
1062
+ // Mirror on right - emerges and glows in Beat 2 & 3
1057
1063
  const mirrorX = cx + 320;
1058
1064
  const mirrorY = cy + 20;
1059
- ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
1065
+ ctx.fillStyle = 'rgba(56, 189, 248, ' + (0.05 + beat2 * 0.08) + ')';
1060
1066
  ctx.beginPath();
1061
1067
  ctx.roundRect(mirrorX - 85, mirrorY - 210, 170, 390, 24);
1062
1068
  ctx.fill();
1063
- ctx.strokeStyle = '#d4af37';
1069
+ ctx.strokeStyle = beat3 > 0.5 ? '#f59e0b' : '#94a3b8';
1064
1070
  ctx.lineWidth = 4;
1065
1071
  ctx.stroke();
1066
1072
 
1067
- // Reflected character in mirror (faint glow)
1068
- ctx.save();
1069
- ctx.globalAlpha = 0.55;
1070
- drawDoodleCharacter({
1071
- x: mirrorX,
1072
- y: cy + 120,
1073
- scale: 1.05,
1074
- hairStyle: 'modern',
1075
- expression: 'smiling',
1076
- clothing: 'shirt',
1077
- armPose: 'dress_shirt',
1078
- seed: 88,
1079
- });
1080
- ctx.restore();
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
+ }
1081
1089
 
1082
- // Main character in center
1090
+ // Main character moves progressively from rack -> center -> mirror
1091
+ const charX = cx - 220 + beat2 * 170 + beat3 * 60;
1083
1092
  drawDoodleCharacter({
1084
- x: cx - 40,
1093
+ x: charX,
1085
1094
  y: cy + 140,
1086
1095
  scale: 1.35,
1087
1096
  hairStyle: 'modern',
1088
- expression: 'talking',
1097
+ expression: beat3 > 0.5 ? 'smiling' : 'talking',
1089
1098
  clothing: 'shirt',
1090
- armPose: 'dress_shirt',
1099
+ armPose: beat2 > 0.5 ? 'dress_shirt' : 'natural',
1091
1100
  seed: 101,
1092
1101
  });
1093
1102
 
1094
1103
  } else if (COMP_THEME === 'doodle_shiver_cold') {
1095
- // Freezing Wind & Shivering Early Human
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
+
1096
1108
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1097
1109
 
1098
1110
  // Freezing wind streaks rushing across
1099
- for (let w = 0; w < 12; w++) {
1100
- const windX = ((t * 700 + w * 220) % (W + 600)) - 300;
1101
- const windY = cy - 250 + (w * 45) + Math.sin(t * 3 + w) * 15;
1102
- sLine(windX, windY, windX + 160, windY + 5, 200 + w, 'rgba(255,255,255,0.3)', 2);
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);
1103
1115
  }
1104
1116
 
1105
1117
  // Drifting snow particles
1106
- for (let p = 0; p < 25; p++) {
1107
- const px = ((p * 85 + t * 450) % W);
1108
- const py = (p * 45 + t * 90) % H;
1109
- sCircle(px, py, 2.5, 300 + p, '#e0f2fe', 1, '#ffffff');
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');
1110
1122
  }
1111
1123
 
1112
- // Shivering breath vapor cloud
1113
- const breathPhase = (t * 2.2) % 1;
1114
- const bCloudX = cx + 25 + breathPhase * 60;
1115
- const bCloudY = cy - 90 - breathPhase * 25;
1116
- ctx.fillStyle = 'rgba(224, 242, 254, ' + ((1 - breathPhase) * 0.5) + ')';
1117
- ctx.beginPath();
1118
- ctx.arc(bCloudX, bCloudY, 15 + breathPhase * 25, 0, Math.PI * 2);
1119
- ctx.fill();
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
+ }
1120
1134
 
1121
- // Main shivering character
1122
- const vibX = Math.sin(t * 38) * 4.5;
1123
- const vibY = Math.cos(t * 32) * 2;
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;
1124
1139
  drawDoodleCharacter({
1125
- x: cx + vibX,
1140
+ x: cx - 120 + vibX,
1126
1141
  y: cy + 140 + vibY,
1127
1142
  scale: 1.35,
1128
1143
  hairStyle: 'wild',
1129
- expression: 'shivering',
1144
+ expression: isShivering ? 'shivering' : 'curious',
1130
1145
  clothing: 'none',
1131
- armPose: 'shiver',
1146
+ armPose: isShivering ? 'shiver' : 'thinker',
1132
1147
  seed: 102,
1133
1148
  });
1134
1149
 
1135
- // Big crossed out cold icon on right
1136
- sCircle(cx + 340, cy - 80, 55, 400, 'rgba(239,68,68,0.85)', 4);
1137
- sLine(cx + 300, cy - 120, cx + 380, cy - 40, 401, 'rgba(239,68,68,0.95)', 4.5);
1138
- ctx.font = 'bold 22px monospace';
1139
- ctx.fillStyle = '#ef4444';
1140
- ctx.textAlign = 'center';
1141
- ctx.fillText('NOT FOR COLD', cx + 340, cy - 5);
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
+ }
1142
1181
 
1143
1182
  } else if (COMP_THEME === 'doodle_hunter_savannah') {
1144
- // Blazing African Savannah Hunter with Spear
1145
- // Blazing midday sun
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
1146
1188
  const sunR = 75 + Math.sin(t * 2) * 6;
1147
1189
  ctx.fillStyle = '#f59e0b';
1148
1190
  ctx.beginPath();
@@ -1165,27 +1207,31 @@ function drawMainScene(t) {
1165
1207
  sLine(gx, cy + 220, gx + sway, cy + 140, 500 + g, '#d97706', 2.5);
1166
1208
  }
1167
1209
 
1168
- // Buzzing mosquitoes orbiting
1169
- for (let m = 0; m < 3; m++) {
1170
- const mx = cx + 40 + Math.sin(t * 8 + m * 2) * 65;
1171
- const my = cy - 80 + Math.cos(t * 7 + m * 2) * 45;
1172
- sCircle(mx, my, 2, 550 + m, '#0f172a', 1.5, '#ef4444');
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');
1173
1216
  }
1174
1217
 
1175
- // Walking early hunter
1218
+ // Walking early hunter with spear
1176
1219
  drawDoodleCharacter({
1177
- x: cx - 40,
1220
+ x: charX,
1178
1221
  y: cy + 140,
1179
1222
  scale: 1.35,
1180
1223
  hairStyle: 'wild',
1181
- expression: 'talking',
1224
+ expression: swatting ? 'shivering' : 'talking',
1182
1225
  clothing: 'none',
1183
1226
  armPose: 'hold_spear',
1184
1227
  seed: 103,
1185
1228
  });
1186
1229
 
1187
1230
  } else if (COMP_THEME === 'doodle_sewing_needle') {
1188
- // Prehistoric Campfire & Bone Needle Sewing
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
+
1189
1235
  // Campfire with animated flames and sparks
1190
1236
  const fireX = cx - 380;
1191
1237
  const fireY = cy + 180;
@@ -1197,16 +1243,15 @@ function drawMainScene(t) {
1197
1243
  ctx.quadraticCurveTo(fireX + (f - 2) * 8, fireY - flH, fireX - 10 + f * 12, fireY);
1198
1244
  ctx.fill();
1199
1245
  }
1200
- // Floating fire sparks
1201
1246
  for (let sp = 0; sp < 8; sp++) {
1202
1247
  const sx = fireX + Math.sin(t * 4 + sp) * 35;
1203
1248
  const sy = fireY - 30 - ((t * 80 + sp * 35) % 140);
1204
1249
  sCircle(sx, sy, 2, 600 + sp, '#fbbf24', 1, '#fbbf24');
1205
1250
  }
1206
1251
 
1207
- // Sitting craftsman with hide on lap
1252
+ // Seated human stitching hide with bone needle
1208
1253
  drawDoodleCharacter({
1209
- x: cx - 110,
1254
+ x: cx - 120,
1210
1255
  y: cy + 140,
1211
1256
  scale: 1.35,
1212
1257
  hairStyle: 'wild',
@@ -1216,39 +1261,57 @@ function drawMainScene(t) {
1216
1261
  seed: 104,
1217
1262
  });
1218
1263
 
1219
- // Zoomed-in bone needle microscope circle on right
1220
- const lensX = cx + 280;
1221
- const lensY = cy - 10;
1222
- const lensR = 145;
1223
- ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
1224
- ctx.beginPath();
1225
- ctx.arc(lensX, lensY, lensR, 0, Math.PI * 2);
1226
- ctx.fill();
1227
- ctx.strokeStyle = '#d4af37';
1228
- ctx.lineWidth = 5;
1229
- ctx.stroke();
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);
1230
1271
 
1231
- // Two thick leather pelt pieces being stitched
1232
- sLine(lensX - 90, lensY - 20, lensX + 90, lensY - 20, 650, '#92400e', 8);
1233
- sLine(lensX - 90, lensY + 20, lensX + 90, lensY + 20, 651, '#78350f', 8);
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();
1234
1280
 
1235
- // Drilled bone needle piercing through
1236
- const nWiggle = Math.sin(t * 4) * 20;
1237
- sLine(lensX - 45 + nWiggle, lensY - 65, lensX + 25 + nWiggle, lensY + 55, 652, '#fef08a', 6);
1238
- sCircle(lensX - 40 + nWiggle, lensY - 58, 4, 653, '#0f172a', 1.5, '#0f172a'); // Eyelet
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);
1239
1284
 
1240
- // Stitches
1241
- for (let st = 0; st < 6; st++) {
1242
- const stX = lensX - 70 + st * 28;
1243
- sLine(stX, lensY - 22, stX + 8, lensY + 22, 660 + st, '#ffffff', 3.5);
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();
1244
1308
  }
1245
- ctx.font = 'bold 18px monospace';
1246
- ctx.fillStyle = '#d4af37';
1247
- ctx.textAlign = 'center';
1248
- ctx.fillText('BONE NEEDLE • 120,000 YEARS', lensX, lensY + lensR + 32);
1249
1309
 
1250
1310
  } else if (COMP_THEME === 'doodle_shame_reflection') {
1251
- // Water Pool Reflection & Theory of Mind
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
+
1252
1315
  const poolX = cx;
1253
1316
  const poolY = cy + 180;
1254
1317
  ctx.fillStyle = 'rgba(14, 165, 233, 0.15)';
@@ -1269,22 +1332,24 @@ function drawMainScene(t) {
1269
1332
  ctx.stroke();
1270
1333
  }
1271
1334
 
1272
- // Inverted rippling reflection of character in water
1273
- ctx.save();
1274
- ctx.globalAlpha = 0.35;
1275
- ctx.translate(cx, poolY + 20);
1276
- ctx.scale(1.2, -0.75);
1277
- drawDoodleCharacter({
1278
- x: 0,
1279
- y: 0,
1280
- scale: 1.0,
1281
- hairStyle: 'wild',
1282
- expression: 'surprised',
1283
- clothing: 'none',
1284
- armPose: 'cover_body',
1285
- seed: 700,
1286
- });
1287
- ctx.restore();
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
+ }
1288
1353
 
1289
1354
  // Standing character looking down and covering body
1290
1355
  drawDoodleCharacter({
@@ -1292,77 +1357,98 @@ function drawMainScene(t) {
1292
1357
  y: cy + 120,
1293
1358
  scale: 1.35,
1294
1359
  hairStyle: 'wild',
1295
- expression: 'surprised',
1360
+ expression: refSpring > 0.4 ? 'surprised' : 'talking',
1296
1361
  clothing: 'none',
1297
- armPose: 'cover_body',
1362
+ armPose: refSpring > 0.4 ? 'cover_body' : 'natural',
1298
1363
  seed: 105,
1299
1364
  });
1300
1365
 
1301
- // Blushing cheeks
1302
- sCircle(cx - 18, cy - 10, 7, 750, 'rgba(244,63,94,0.6)', 1, 'rgba(244,63,94,0.6)');
1303
- sCircle(cx + 18, cy - 10, 7, 751, 'rgba(244,63,94,0.6)', 1, 'rgba(244,63,94,0.6)');
1304
-
1305
- // Overhead Theory of Mind Thought Halo
1306
- const haloX = cx + 180;
1307
- const haloY = cy - 120;
1308
- sCircle(haloX, haloY, 55, 760, '#38bdf8', 3);
1309
- // Eye icon inside halo
1310
- sCircle(haloX, haloY, 20, 761, '#ffffff', 2.5);
1311
- sCircle(haloX, haloY, 8, 762, '#38bdf8', 1, '#38bdf8');
1312
- ctx.font = 'bold 18px monospace';
1313
- ctx.fillStyle = '#38bdf8';
1314
- ctx.textAlign = 'center';
1315
- ctx.fillText('THEORY OF MIND', haloX, haloY + 78);
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
+ }
1316
1389
 
1317
1390
  } else if (COMP_THEME === 'doodle_tribe_meeting') {
1318
- // Two Clans Meeting & Communicating via Clothing Language
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
+
1319
1396
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1320
1397
 
1321
- // Left Clan Member (Mountain Clan with Pelt & Peace Gesture)
1398
+ // Left Clan Member walks toward center
1399
+ const leftX = cx - 380 + Math.min(1, approach) * 160;
1322
1400
  drawDoodleCharacter({
1323
- x: cx - 220,
1401
+ x: leftX,
1324
1402
  y: cy + 140,
1325
1403
  scale: 1.3,
1326
1404
  hairStyle: 'wild',
1327
1405
  expression: 'talking',
1328
1406
  clothing: 'pelt',
1329
- armPose: 'peace_gesture',
1407
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1330
1408
  seed: 106,
1331
1409
  });
1332
1410
 
1333
- // Right Clan Member (River Clan with Cloak & Feather)
1411
+ // Right Clan Member walks toward center
1412
+ const rightX = cx + 380 - Math.min(1, approach) * 160;
1334
1413
  drawDoodleCharacter({
1335
- x: cx + 220,
1414
+ x: rightX,
1336
1415
  y: cy + 140,
1337
1416
  scale: 1.3,
1338
1417
  hairStyle: 'feather',
1339
1418
  expression: 'curious',
1340
1419
  clothing: 'cloak',
1341
- armPose: 'natural',
1420
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1342
1421
  seed: 107,
1343
1422
  });
1344
1423
 
1345
- // Glowing connection arc and communication symbols between them
1346
- ctx.strokeStyle = ACCENT;
1347
- ctx.lineWidth = 3;
1348
- ctx.setLineDash([8, 8]);
1349
- ctx.beginPath();
1350
- ctx.moveTo(cx - 160, cy);
1351
- ctx.quadraticCurveTo(cx, cy - 100, cx + 160, cy);
1352
- ctx.stroke();
1353
- ctx.setLineDash([]);
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([]);
1354
1436
 
1355
- // Central status emblem
1356
- sCircle(cx, cy - 65, 36, 800, ACCENT, 3, 'rgba(15, 23, 42, 0.85)');
1357
- ctx.font = 'bold 24px monospace';
1358
- ctx.fillStyle = ACCENT;
1359
- ctx.textAlign = 'center';
1360
- ctx.fillText('🤝', cx, cy - 56);
1361
- ctx.font = 'bold 18px monospace';
1362
- ctx.fillText('CLOTHING AS SOCIAL LANGUAGE', cx, cy + 15);
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
+ }
1363
1446
 
1364
1447
  } else if (COMP_THEME === 'doodle_thinking_future') {
1365
- // Prehistoric Thinker & Planning for Tomorrow
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
+
1366
1452
  // Starlit night and crescent moon
1367
1453
  ctx.fillStyle = '#fef08a';
1368
1454
  ctx.beginPath();
@@ -1391,102 +1477,120 @@ function drawMainScene(t) {
1391
1477
  seed: 108,
1392
1478
  });
1393
1479
 
1394
- // Expanding thought cloud overhead
1395
- const cloudX = cx + 180;
1396
- const cloudY = cy - 80;
1397
- const cloudScale = spring(t, 0.15, 170, 14);
1398
-
1399
- ctx.save();
1400
- ctx.translate(cloudX, cloudY);
1401
- ctx.scale(cloudScale, cloudScale);
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);
1402
1487
 
1403
- // Thought bubbles leading up
1404
- sCircle(-180, 160, 12, 850, '#ffffff', 2, '#ffffff');
1405
- sCircle(-140, 120, 18, 851, '#ffffff', 2, '#ffffff');
1406
- sCircle(-90, 70, 24, 852, '#ffffff', 2, '#ffffff');
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');
1407
1491
 
1408
- // Main cloud body
1409
- ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
1410
- ctx.beginPath();
1411
- ctx.arc(-70, 0, 55, 0, Math.PI * 2);
1412
- ctx.arc(0, -25, 65, 0, Math.PI * 2);
1413
- ctx.arc(70, 0, 55, 0, Math.PI * 2);
1414
- ctx.arc(0, 30, 60, 0, Math.PI * 2);
1415
- ctx.fill();
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();
1416
1499
 
1417
- // Inside thought cloud: Tomorrow's sunrise & stretched hide on rack
1418
- ctx.fillStyle = '#f59e0b';
1419
- ctx.beginPath();
1420
- ctx.arc(0, 15, 30, Math.PI, Math.PI * 2);
1421
- ctx.fill();
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();
1422
1506
 
1423
- // Drying frame rack
1424
- sLine(-45, 15, -45, -35, 860, '#78350f', 4);
1425
- sLine(45, 15, 45, -35, 861, '#78350f', 4);
1426
- sLine(-50, -30, 50, -30, 862, '#78350f', 4);
1427
- sRect(-35, -28, 70, 40, 863, '#92400e', 3, '#b45309');
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);
1428
1510
 
1429
- ctx.restore();
1511
+ ctx.fillStyle = '#b45309';
1512
+ ctx.fillRect(-28, -20, 56, 32);
1430
1513
 
1431
- ctx.font = 'bold 20px monospace';
1432
- ctx.fillStyle = '#facc15';
1433
- ctx.textAlign = 'center';
1434
- ctx.fillText('💡 PLANNING FOR TOMORROW', cloudX, cloudY + 95);
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
+ }
1435
1521
 
1436
1522
  } else if (COMP_THEME === 'doodle_microscope_lice') {
1437
- // The Lice Theory & Genetic Divergence Proof
1438
- // Left scratching character
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
1439
1530
  drawDoodleCharacter({
1440
- x: cx - 290,
1531
+ x: cx - 240,
1441
1532
  y: cy + 140,
1442
1533
  scale: 1.35,
1443
1534
  hairStyle: 'wild',
1444
- expression: 'curious',
1535
+ expression: 'shivering',
1445
1536
  clothing: 'pelt',
1446
1537
  armPose: 'shiver',
1447
1538
  seed: 109,
1448
1539
  });
1449
1540
 
1450
- // Massive microscope lens on right
1451
- const lensX = cx + 140;
1452
- const lensY = cy + 10;
1453
- const lensR = 190;
1454
- ctx.fillStyle = 'rgba(15, 23, 42, 0.94)';
1455
- ctx.beginPath();
1456
- ctx.arc(lensX, lensY, lensR, 0, Math.PI * 2);
1457
- ctx.fill();
1458
- ctx.strokeStyle = '#d4af37';
1459
- ctx.lineWidth = 6;
1460
- ctx.stroke();
1461
-
1462
- // Fabric thread weave lines in lens background
1463
- for (let f = -4; f <= 4; f++) {
1464
- sLine(lensX - 140, lensY + f * 28, lensX + 140, lensY + f * 28, 900 + f, 'rgba(255,255,255,0.12)', 2);
1465
- sLine(lensX + f * 28, lensY - 140, lensX + f * 28, lensY + 140, 920 + f, 'rgba(255,255,255,0.12)', 2);
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);
1466
1545
  }
1467
1546
 
1468
- // Branching evolutionary divergence diagram inside lens
1469
- sLine(lensX - 120, lensY, lensX - 40, lensY, 940, '#ffffff', 4);
1470
- sCircle(lensX - 40, lensY, 8, 941, '#d4af37', 2, '#d4af37'); // Divergence point
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;
1471
1552
 
1472
- // Head lice branch
1473
- sLine(lensX - 40, lensY, lensX + 80, lensY - 60, 942, '#38bdf8', 4);
1474
- ctx.font = 'bold 16px monospace';
1475
- ctx.fillStyle = '#38bdf8';
1476
- ctx.textAlign = 'left';
1477
- ctx.fillText('HEAD LICE (HAIR)', lensX + 90, lensY - 56);
1553
+ ctx.save();
1554
+ ctx.translate(lensX, lensY);
1555
+ ctx.scale(scopeSpring, scopeSpring);
1478
1556
 
1479
- // Body lice branch (clothes only)
1480
- sLine(lensX - 40, lensY, lensX + 80, lensY + 60, 943, '#f43f5e', 4);
1481
- ctx.fillStyle = '#f43f5e';
1482
- ctx.fillText('BODY LICE (CLOTHES)', lensX + 90, lensY + 64);
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();
1483
1564
 
1484
- // Divergence date highlight badge
1485
- sRect(lensX - 110, lensY + 105, 220, 36, 950, '#d4af37', 2, 'rgba(212, 175, 55, 0.2)');
1486
- ctx.font = 'bold 16px monospace';
1487
- ctx.fillStyle = '#fef08a';
1488
- ctx.textAlign = 'center';
1489
- ctx.fillText('170,000 YEARS AGO', lensX, lensY + 129);
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
+ }
1490
1594
 
1491
1595
  } else if (COMP_THEME === 'doodle_general_human') {
1492
1596
  // General Expressive Doodled Human Narrator
@@ -1976,49 +2080,70 @@ function drawMainScene(t) {
1976
2080
  ctx.restore();
1977
2081
  }
1978
2082
 
1979
- // ── Kinetic Typography & Broadcast Subtitle Overlay ─────────────────────────
2083
+ // ── Sentence-Synchronized Broadcast Subtitles Overlay ───────────────────────
1980
2084
  function drawCinematicOverlay(t) {
1981
2085
  ctx.save();
1982
- const textSpring = spring(t, 0.12, 190, 15);
1983
- const headlineAlpha = Math.min(1, Math.max(0, textSpring));
1984
- ctx.globalAlpha = headlineAlpha;
1985
2086
 
1986
- // Kinetic Headline at top
1987
- 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);
1988
2091
 
1989
- // Tag Pill - minimal floating badge
1990
- if (TAG) {
1991
- ctx.font = 'bold 16px monospace';
1992
- ctx.fillStyle = ACCENT;
1993
- ctx.shadowColor = 'rgba(0, 0, 0, 0.85)';
1994
- ctx.shadowBlur = 10;
1995
- ctx.textAlign = 'left';
1996
- ctx.fillText('// ' + TAG, 80, capY - 22);
1997
- }
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;
1998
2102
 
1999
- // Primary Caption Headline - crisp, heroic typography without opaque cards
2000
- ctx.font = FONT_WEIGHT + ' 50px ' + FONT_DISPLAY;
2001
- ctx.fillStyle = '#ffffff';
2002
- ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
2003
- ctx.shadowBlur = 24;
2004
- ctx.shadowOffsetY = 4;
2005
- ctx.textAlign = 'left';
2006
- const cleanCap = CAPTION.length > 60 ? CAPTION.slice(0, 60) + '...' : CAPTION;
2007
- ctx.fillText(cleanCap, 80, capY + 30);
2008
-
2009
- // Subtitle / Spoken Narration - bottom center clean broadcast subtitles
2010
- if (NARRATION) {
2011
- const subSpring = spring(t, 0.22, 180, 16);
2012
- ctx.globalAlpha = Math.min(1, Math.max(0, subSpring));
2013
- ctx.font = '500 28px ' + FONT_BODY;
2014
- ctx.fillStyle = '#f8fafc';
2015
- ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
2016
- ctx.shadowBlur = 18;
2017
- ctx.shadowOffsetY = 3;
2103
+ ctx.save();
2104
+ ctx.globalAlpha = sentAlpha;
2105
+ ctx.font = '500 32px ' + FONT_BODY;
2018
2106
  ctx.textAlign = 'center';
2019
- const cleanNarr = NARRATION.length > 115 ? NARRATION.slice(0, 115) + '...' : NARRATION;
2020
- 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();
2021
2145
  }
2146
+
2022
2147
  ctx.restore();
2023
2148
 
2024
2149
  // Bottom Progress Indicator (minimal line)