@amg555/frameforge-mcp 2.4.2 → 2.4.4

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.
@@ -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)) {
@@ -497,6 +497,13 @@ window.seek = async (t) => {
497
497
  </html>`;
498
498
  }
499
499
 
500
+ // ── Captions Requested Detector ───────────────────────────────────────────────
501
+ /** Returns true ONLY if caller explicitly asks for on-screen captions or subtitles in prompt. */
502
+ function detectCaptionsRequested(text = '') {
503
+ if (!text || typeof text !== 'string') return false;
504
+ return /(?:with|show|display|include|enable|add)\s+(?:captions?|subtitles?|sub-titles?|subs?)|(?:captions?|subtitles?)\s*[:=]\s*(?:true|yes|1|on|always)/i.test(text);
505
+ }
506
+
500
507
  // ── HTML Scene Code Generator ──────────────────────────────────────────────────
501
508
  /**
502
509
  * Generate a complete, self-contained HTML scene conforming to window.seek(t).
@@ -517,7 +524,13 @@ function generateSceneHtml({
517
524
  width = 1920,
518
525
  height = 1080,
519
526
  sceneIndex = 0,
527
+ showCaptions = false,
520
528
  } = {}) {
529
+ const captionsWanted = Boolean(
530
+ showCaptions ||
531
+ detectCaptionsRequested(`${caption} ${narration} ${topic} ${visualDescription} ${style} ${title}`) ||
532
+ process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
533
+ );
521
534
  const aestheticKey = detectAesthetic({
522
535
  topic,
523
536
  style,
@@ -627,6 +640,7 @@ function spring(t, delay = 0, tension = ${aesthetic.springTension}, friction = $
627
640
  return 1 - Math.exp(-omega * dt);
628
641
  }
629
642
 
643
+ const SHOW_CAPTIONS = ${JSON.stringify(captionsWanted)};
630
644
  const AESTHETIC = ${JSON.stringify(aestheticKey)};
631
645
  const BG_TYPE = ${JSON.stringify(aesthetic.backgroundType)};
632
646
  const COMP_THEME = ${JSON.stringify(compTheme)};
@@ -740,10 +754,51 @@ function drawBackground(t) {
740
754
  // ── Full-Canvas Cinematic Visual Directors ─────────────────────────────────────
741
755
  function drawMainScene(t) {
742
756
  ctx.save();
757
+ const p = Math.min(1, Math.max(0, t / window.DURATION));
743
758
  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);
759
+
760
+ // Multi-Shot Dynamic Director: cut camera framing every 5-7s to keep pacing energetic
761
+ const shotDur = 5.5;
762
+ const shotIdx = Math.floor(t / shotDur);
763
+ const shotLocalT = t - shotIdx * shotDur;
764
+ const shotCutSpring = spring(shotLocalT, 0.02, 220, 18);
765
+ const shotProgress = (t % shotDur) / shotDur;
766
+
767
+ let targetZoom = 1.0;
768
+ let targetPanX = 0;
769
+ let targetPanY = 0;
770
+
771
+ if (window.DURATION > 6.5) {
772
+ const angle = shotIdx % 4;
773
+ if (angle === 1) {
774
+ targetZoom = 1.14 + shotProgress * 0.03;
775
+ targetPanX = -40 + Math.sin(t * 0.4) * 8;
776
+ targetPanY = -12;
777
+ } else if (angle === 2) {
778
+ targetZoom = 1.20 + shotProgress * 0.03;
779
+ targetPanX = 40 + Math.cos(t * 0.4) * 8;
780
+ targetPanY = -18;
781
+ } else if (angle === 3) {
782
+ targetZoom = 1.08 + shotProgress * 0.02;
783
+ targetPanX = Math.sin(t * 0.3) * 12;
784
+ targetPanY = 12;
785
+ } else {
786
+ targetZoom = 1.02 + shotProgress * 0.03;
787
+ targetPanX = Math.sin(t * 0.3) * 12;
788
+ targetPanY = Math.cos(t * 0.25) * 6;
789
+ }
790
+ } else {
791
+ targetZoom = 1.0 + Math.sin(t * 0.45) * 0.02 + p * 0.04;
792
+ targetPanX = Math.sin(t * 0.35) * 12;
793
+ targetPanY = Math.cos(t * 0.28) * 6;
794
+ }
795
+
796
+ const camZoom = 1.0 + (targetZoom - 1.0) * Math.min(1, Math.max(0, shotCutSpring));
797
+ const camPanX = targetPanX * Math.min(1, Math.max(0, shotCutSpring));
798
+ const camPanY = targetPanY * Math.min(1, Math.max(0, shotCutSpring));
799
+
800
+ ctx.translate(W / 2 + camPanX, H * 0.50 + camPanY);
801
+ ctx.scale(Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom, Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom);
747
802
  ctx.translate(-W / 2, -H * 0.50);
748
803
 
749
804
  const cx = W / 2;
@@ -825,6 +880,12 @@ function drawMainScene(t) {
825
880
  ctx.translate(x, y);
826
881
  ctx.scale(scale, scale);
827
882
 
883
+ // Soft ground contact shadow
884
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
885
+ ctx.beginPath();
886
+ ctx.ellipse(0, legLength, 34, 7, 0, 0, Math.PI * 2);
887
+ ctx.fill();
888
+
828
889
  const blink = (t % 3.2 < 0.14) ? 0.15 : 1.0;
829
890
  const mouthTalk = Math.abs(Math.sin(t * 14)) * 4;
830
891
  const torsoTopY = -torsoHeight;
@@ -901,6 +962,22 @@ function drawMainScene(t) {
901
962
  sCircle(-12, eyeY, Math.max(1, eyeR), seed + 23, '#0f172a', 2, '#0f172a');
902
963
  sCircle(12, eyeY, Math.max(1, eyeR), seed + 24, '#0f172a', 2, '#0f172a');
903
964
 
965
+ // Specular eye highlights
966
+ if (blink > 0.4) {
967
+ ctx.fillStyle = '#ffffff';
968
+ ctx.beginPath();
969
+ ctx.arc(-14, eyeY - 1.5, 1.2, 0, Math.PI * 2);
970
+ ctx.arc(10, eyeY - 1.5, 1.2, 0, Math.PI * 2);
971
+ ctx.fill();
972
+ }
973
+
974
+ // Warm cheek blush
975
+ ctx.fillStyle = 'rgba(251, 113, 133, 0.28)';
976
+ ctx.beginPath();
977
+ ctx.ellipse(-14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
978
+ ctx.ellipse(14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
979
+ ctx.fill();
980
+
904
981
  // Eyebrows
905
982
  if (expression === 'shivering') {
906
983
  sLine(-18, eyeY - 12 + wb(seed, 2), -6, eyeY - 7 + wb(seed, 2), seed + 25, '#0f172a', 2.5);
@@ -1027,12 +1104,15 @@ function drawMainScene(t) {
1027
1104
  ctx.restore();
1028
1105
  }
1029
1106
 
1030
- // ── Doodled Character Narrative Themes ────────────────────────────────────
1107
+ // ── Doodled Character Narrative Themes (3-Beat Progressive Choreography) ─
1031
1108
  if (COMP_THEME === 'doodle_dressing') {
1032
- // Modern Dressing Room & Mirror Reflection
1109
+ // Beat 1: Browsing wardrobe -> Beat 2: Trying shirt -> Beat 3: Looking in mirror reflection
1110
+ const beat2 = spring(t, window.DURATION * 0.35, 180, 16);
1111
+ const beat3 = spring(t, window.DURATION * 0.70, 190, 16);
1112
+
1033
1113
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.25)', 2.5);
1034
1114
 
1035
- // Clothes rack on left
1115
+ // Clothes rack on left (sways in beat 1)
1036
1116
  sLine(cx - 360, cy + 220, cx - 360, cy - 140, 11, '#94a3b8', 5);
1037
1117
  sLine(cx - 410, cy + 220, cx - 310, cy + 220, 12, '#94a3b8', 5);
1038
1118
  sLine(cx - 420, cy - 140, cx - 180, cy - 140, 13, '#cbd5e1', 5);
@@ -1053,96 +1133,132 @@ function drawMainScene(t) {
1053
1133
  ctx.fill();
1054
1134
  }
1055
1135
 
1056
- // Mirror on right
1136
+ // Mirror on right - emerges and glows in Beat 2 & 3
1057
1137
  const mirrorX = cx + 320;
1058
1138
  const mirrorY = cy + 20;
1059
- ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
1139
+ ctx.fillStyle = 'rgba(56, 189, 248, ' + (0.05 + beat2 * 0.08) + ')';
1060
1140
  ctx.beginPath();
1061
1141
  ctx.roundRect(mirrorX - 85, mirrorY - 210, 170, 390, 24);
1062
1142
  ctx.fill();
1063
- ctx.strokeStyle = '#d4af37';
1143
+ ctx.strokeStyle = beat3 > 0.5 ? '#f59e0b' : '#94a3b8';
1064
1144
  ctx.lineWidth = 4;
1065
1145
  ctx.stroke();
1066
1146
 
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();
1147
+ // Reflected character in mirror (smiling and thumbs up in beat 3)
1148
+ if (beat2 > 0.1) {
1149
+ ctx.save();
1150
+ ctx.globalAlpha = Math.min(0.65, beat2 * 0.65);
1151
+ drawDoodleCharacter({
1152
+ x: mirrorX,
1153
+ y: cy + 120,
1154
+ scale: 1.05,
1155
+ hairStyle: 'modern',
1156
+ expression: beat3 > 0.5 ? 'smiling' : 'curious',
1157
+ clothing: 'shirt',
1158
+ armPose: beat3 > 0.5 ? 'peace_gesture' : 'dress_shirt',
1159
+ seed: 88,
1160
+ });
1161
+ ctx.restore();
1162
+ }
1081
1163
 
1082
- // Main character in center
1164
+ // Main character moves progressively from rack -> center -> mirror
1165
+ const charX = cx - 220 + beat2 * 170 + beat3 * 60;
1083
1166
  drawDoodleCharacter({
1084
- x: cx - 40,
1167
+ x: charX,
1085
1168
  y: cy + 140,
1086
1169
  scale: 1.35,
1087
1170
  hairStyle: 'modern',
1088
- expression: 'talking',
1171
+ expression: beat3 > 0.5 ? 'smiling' : 'talking',
1089
1172
  clothing: 'shirt',
1090
- armPose: 'dress_shirt',
1173
+ armPose: beat2 > 0.5 ? 'dress_shirt' : 'natural',
1091
1174
  seed: 101,
1092
1175
  });
1093
1176
 
1094
1177
  } else if (COMP_THEME === 'doodle_shiver_cold') {
1095
- // Freezing Wind & Shivering Early Human
1178
+ // Beat 1: Shivering in cold -> Beat 2: NOT FOR COLD stamp -> Beat 3: NOT FOR PROTECTION stamp
1179
+ const stamp1 = spring(t, window.DURATION * 0.38, 220, 15);
1180
+ const stamp2 = spring(t, window.DURATION * 0.72, 220, 15);
1181
+
1096
1182
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1097
1183
 
1098
1184
  // 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);
1185
+ for (let w = 0; w < 14; w++) {
1186
+ const windX = ((t * 750 + w * 210) % (W + 600)) - 300;
1187
+ const windY = cy - 250 + (w * 42) + Math.sin(t * 3.5 + w) * 15;
1188
+ sLine(windX, windY, windX + 170, windY + 5, 200 + w, 'rgba(255,255,255,0.3)', 2);
1103
1189
  }
1104
1190
 
1105
1191
  // 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');
1192
+ for (let sp = 0; sp < 25; sp++) {
1193
+ const px = ((sp * 85 + t * 450) % W);
1194
+ const py = (sp * 45 + t * 90) % H;
1195
+ sCircle(px, py, 2.5, 300 + sp, '#e0f2fe', 1, '#ffffff');
1110
1196
  }
1111
1197
 
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();
1198
+ // Shivering breath vapor cloud (calms in Beat 3)
1199
+ if (stamp2 < 0.8) {
1200
+ const breathPhase = (t * 2.2) % 1;
1201
+ const bCloudX = cx - 50 + breathPhase * 60;
1202
+ const bCloudY = cy - 90 - breathPhase * 25;
1203
+ ctx.fillStyle = 'rgba(224, 242, 254, ' + ((1 - breathPhase) * 0.5) + ')';
1204
+ ctx.beginPath();
1205
+ ctx.arc(bCloudX, bCloudY, 15 + breathPhase * 25, 0, Math.PI * 2);
1206
+ ctx.fill();
1207
+ }
1120
1208
 
1121
- // Main shivering character
1122
- const vibX = Math.sin(t * 38) * 4.5;
1123
- const vibY = Math.cos(t * 32) * 2;
1209
+ // Main character: shivers in Beat 1 & 2, then realizes / curious in Beat 3
1210
+ const isShivering = stamp2 < 0.7;
1211
+ const vibX = isShivering ? Math.sin(t * 38) * 4.5 : 0;
1212
+ const vibY = isShivering ? Math.cos(t * 32) * 2 : 0;
1124
1213
  drawDoodleCharacter({
1125
- x: cx + vibX,
1214
+ x: cx - 120 + vibX,
1126
1215
  y: cy + 140 + vibY,
1127
1216
  scale: 1.35,
1128
1217
  hairStyle: 'wild',
1129
- expression: 'shivering',
1218
+ expression: isShivering ? 'shivering' : 'curious',
1130
1219
  clothing: 'none',
1131
- armPose: 'shiver',
1220
+ armPose: isShivering ? 'shiver' : 'thinker',
1132
1221
  seed: 102,
1133
1222
  });
1134
1223
 
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);
1224
+ // Stamp 1: NOT FOR COLD (slams down in Beat 2)
1225
+ if (stamp1 > 0.05) {
1226
+ ctx.save();
1227
+ const s1X = cx + 240;
1228
+ const s1Y = cy - 100;
1229
+ ctx.translate(s1X, s1Y);
1230
+ ctx.scale(stamp1, stamp1);
1231
+ sCircle(0, 0, 50, 400, 'rgba(239,68,68,0.9)', 4);
1232
+ sLine(-36, -36, 36, 36, 401, 'rgba(239,68,68,0.95)', 4.5);
1233
+ ctx.font = 'bold 20px monospace';
1234
+ ctx.fillStyle = '#ef4444';
1235
+ ctx.textAlign = 'center';
1236
+ ctx.fillText('NOT FOR COLD', 0, 75);
1237
+ ctx.restore();
1238
+ }
1239
+
1240
+ // Stamp 2: NOT FOR PROTECTION (slams down in Beat 3)
1241
+ if (stamp2 > 0.05) {
1242
+ ctx.save();
1243
+ const s2X = cx + 240;
1244
+ const s2Y = cy + 90;
1245
+ ctx.translate(s2X, s2Y);
1246
+ ctx.scale(stamp2, stamp2);
1247
+ sCircle(0, 0, 50, 410, 'rgba(239,68,68,0.9)', 4);
1248
+ sLine(-36, -36, 36, 36, 411, 'rgba(239,68,68,0.95)', 4.5);
1249
+ ctx.font = 'bold 20px monospace';
1250
+ ctx.fillStyle = '#ef4444';
1251
+ ctx.textAlign = 'center';
1252
+ ctx.fillText('NOT FOR PROTECTION', 0, 75);
1253
+ ctx.restore();
1254
+ }
1142
1255
 
1143
1256
  } else if (COMP_THEME === 'doodle_hunter_savannah') {
1144
- // Blazing African Savannah Hunter with Spear
1145
- // Blazing midday sun
1257
+ // Beat 1: Walking with spear -> Beat 2: Swatting mosquitoes -> Beat 3: Pausing alert
1258
+ const walkProg = Math.min(1, Math.max(0, t / window.DURATION));
1259
+ const swatting = (t % 2.4 > 1.2);
1260
+
1261
+ // Blazing midday sun with undulating rays
1146
1262
  const sunR = 75 + Math.sin(t * 2) * 6;
1147
1263
  ctx.fillStyle = '#f59e0b';
1148
1264
  ctx.beginPath();
@@ -1165,27 +1281,31 @@ function drawMainScene(t) {
1165
1281
  sLine(gx, cy + 220, gx + sway, cy + 140, 500 + g, '#d97706', 2.5);
1166
1282
  }
1167
1283
 
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');
1284
+ // Buzzing mosquitoes orbiting hunter
1285
+ const charX = cx - 180 + walkProg * 240;
1286
+ for (let m = 0; m < 4; m++) {
1287
+ const mx = charX + 50 + Math.sin(t * 8 + m * 2) * 65;
1288
+ const my = cy - 60 + Math.cos(t * 7 + m * 2) * 45;
1289
+ sCircle(mx, my, 2.5, 550 + m, '#0f172a', 1.5, '#ef4444');
1173
1290
  }
1174
1291
 
1175
- // Walking early hunter
1292
+ // Walking early hunter with spear
1176
1293
  drawDoodleCharacter({
1177
- x: cx - 40,
1294
+ x: charX,
1178
1295
  y: cy + 140,
1179
1296
  scale: 1.35,
1180
1297
  hairStyle: 'wild',
1181
- expression: 'talking',
1298
+ expression: swatting ? 'shivering' : 'talking',
1182
1299
  clothing: 'none',
1183
1300
  armPose: 'hold_spear',
1184
1301
  seed: 103,
1185
1302
  });
1186
1303
 
1187
1304
  } else if (COMP_THEME === 'doodle_sewing_needle') {
1188
- // Prehistoric Campfire & Bone Needle Sewing
1305
+ // Beat 1: Seated by campfire -> Beat 2: Zoom loupe with bone needle -> Beat 3: Timeline badges
1306
+ const loupeSpring = spring(t, window.DURATION * 0.32, 180, 15);
1307
+ const badgeSpring = spring(t, window.DURATION * 0.68, 200, 15);
1308
+
1189
1309
  // Campfire with animated flames and sparks
1190
1310
  const fireX = cx - 380;
1191
1311
  const fireY = cy + 180;
@@ -1197,16 +1317,15 @@ function drawMainScene(t) {
1197
1317
  ctx.quadraticCurveTo(fireX + (f - 2) * 8, fireY - flH, fireX - 10 + f * 12, fireY);
1198
1318
  ctx.fill();
1199
1319
  }
1200
- // Floating fire sparks
1201
1320
  for (let sp = 0; sp < 8; sp++) {
1202
1321
  const sx = fireX + Math.sin(t * 4 + sp) * 35;
1203
1322
  const sy = fireY - 30 - ((t * 80 + sp * 35) % 140);
1204
1323
  sCircle(sx, sy, 2, 600 + sp, '#fbbf24', 1, '#fbbf24');
1205
1324
  }
1206
1325
 
1207
- // Sitting craftsman with hide on lap
1326
+ // Seated human stitching hide with bone needle
1208
1327
  drawDoodleCharacter({
1209
- x: cx - 110,
1328
+ x: cx - 120,
1210
1329
  y: cy + 140,
1211
1330
  scale: 1.35,
1212
1331
  hairStyle: 'wild',
@@ -1216,39 +1335,57 @@ function drawMainScene(t) {
1216
1335
  seed: 104,
1217
1336
  });
1218
1337
 
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();
1338
+ // Zoomed loupe expands open in Beat 2
1339
+ if (loupeSpring > 0.05) {
1340
+ ctx.save();
1341
+ const lensX = cx + 270;
1342
+ const lensY = cy - 20;
1343
+ ctx.translate(lensX, lensY);
1344
+ ctx.scale(loupeSpring, loupeSpring);
1230
1345
 
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);
1346
+ const lensR = 140;
1347
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.92)';
1348
+ ctx.beginPath();
1349
+ ctx.arc(0, 0, lensR, 0, Math.PI * 2);
1350
+ ctx.fill();
1351
+ ctx.strokeStyle = '#d4af37';
1352
+ ctx.lineWidth = 5;
1353
+ ctx.stroke();
1354
+
1355
+ // Two thick leather pelt pieces being stitched
1356
+ sLine(-85, -20, 85, -20, 650, '#92400e', 8);
1357
+ sLine(-85, 20, 85, 20, 651, '#78350f', 8);
1234
1358
 
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
1359
+ // Drilled bone needle piercing through with animated wiggle
1360
+ const nWiggle = Math.sin(t * 4.5) * 18;
1361
+ sLine(-45 + nWiggle, -65, 25 + nWiggle, 55, 652, '#fef08a', 6);
1362
+ sCircle(-40 + nWiggle, -58, 4, 653, '#0f172a', 1.5, '#0f172a');
1239
1363
 
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);
1364
+ // Stitches across pelts
1365
+ for (let st = 0; st < 6; st++) {
1366
+ const stX = -70 + st * 28;
1367
+ sLine(stX, -22, stX + 8, 22, 660 + st, '#ffffff', 3.5);
1368
+ }
1369
+ ctx.restore();
1370
+ }
1371
+
1372
+ // Timeline badge in Beat 3
1373
+ if (badgeSpring > 0.05) {
1374
+ ctx.save();
1375
+ ctx.globalAlpha = Math.min(1, badgeSpring);
1376
+ sRect(cx + 120, cy + 140, 300, 42, 670, '#d4af37', 2.5, 'rgba(15, 23, 42, 0.85)');
1377
+ ctx.font = 'bold 16px monospace';
1378
+ ctx.fillStyle = '#fef08a';
1379
+ ctx.textAlign = 'center';
1380
+ ctx.fillText('BONE NEEDLE • 120,000 YEARS', cx + 270, cy + 167);
1381
+ ctx.restore();
1244
1382
  }
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
1383
 
1250
1384
  } else if (COMP_THEME === 'doodle_shame_reflection') {
1251
- // Water Pool Reflection & Theory of Mind
1385
+ // Beat 1: Looking down -> Beat 2: Water reflection & blushing -> Beat 3: Theory of Mind halo
1386
+ const refSpring = spring(t, window.DURATION * 0.30, 180, 15);
1387
+ const haloSpring = spring(t, window.DURATION * 0.65, 190, 15);
1388
+
1252
1389
  const poolX = cx;
1253
1390
  const poolY = cy + 180;
1254
1391
  ctx.fillStyle = 'rgba(14, 165, 233, 0.15)';
@@ -1269,22 +1406,24 @@ function drawMainScene(t) {
1269
1406
  ctx.stroke();
1270
1407
  }
1271
1408
 
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();
1409
+ // Inverted rippling reflection of character in water (clarifies in Beat 2)
1410
+ if (refSpring > 0.1) {
1411
+ ctx.save();
1412
+ ctx.globalAlpha = Math.min(0.45, refSpring * 0.45);
1413
+ ctx.translate(cx, poolY + 20);
1414
+ ctx.scale(1.2, -0.75);
1415
+ drawDoodleCharacter({
1416
+ x: 0,
1417
+ y: 0,
1418
+ scale: 1.0,
1419
+ hairStyle: 'wild',
1420
+ expression: 'surprised',
1421
+ clothing: 'none',
1422
+ armPose: 'cover_body',
1423
+ seed: 700,
1424
+ });
1425
+ ctx.restore();
1426
+ }
1288
1427
 
1289
1428
  // Standing character looking down and covering body
1290
1429
  drawDoodleCharacter({
@@ -1292,77 +1431,98 @@ function drawMainScene(t) {
1292
1431
  y: cy + 120,
1293
1432
  scale: 1.35,
1294
1433
  hairStyle: 'wild',
1295
- expression: 'surprised',
1434
+ expression: refSpring > 0.4 ? 'surprised' : 'talking',
1296
1435
  clothing: 'none',
1297
- armPose: 'cover_body',
1436
+ armPose: refSpring > 0.4 ? 'cover_body' : 'natural',
1298
1437
  seed: 105,
1299
1438
  });
1300
1439
 
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);
1440
+ // Blushing cheeks appear in Beat 2
1441
+ if (refSpring > 0.3) {
1442
+ const blushA = Math.min(0.7, refSpring * 0.7);
1443
+ sCircle(cx - 18, cy - 8, 7, 750, 'rgba(244,63,94,' + blushA + ')', 1, 'rgba(244,63,94,' + blushA + ')');
1444
+ sCircle(cx + 18, cy - 8, 7, 751, 'rgba(244,63,94,' + blushA + ')', 1, 'rgba(244,63,94,' + blushA + ')');
1445
+ }
1446
+
1447
+ // Theory of Mind Thought Halo in Beat 3
1448
+ if (haloSpring > 0.05) {
1449
+ ctx.save();
1450
+ const haloX = cx + 180;
1451
+ const haloY = cy - 120;
1452
+ ctx.translate(haloX, haloY);
1453
+ ctx.scale(haloSpring, haloSpring);
1454
+ sCircle(0, 0, 48, 760, '#38bdf8', 3);
1455
+ sCircle(0, 0, 18, 761, '#ffffff', 2.5);
1456
+ sCircle(0, 0, 8, 762, '#38bdf8', 1, '#38bdf8');
1457
+ ctx.font = 'bold 16px monospace';
1458
+ ctx.fillStyle = '#38bdf8';
1459
+ ctx.textAlign = 'center';
1460
+ ctx.fillText('THEORY OF MIND', 0, 72);
1461
+ ctx.restore();
1462
+ }
1316
1463
 
1317
1464
  } else if (COMP_THEME === 'doodle_tribe_meeting') {
1318
- // Two Clans Meeting & Communicating via Clothing Language
1465
+ // Beat 1: Walking toward center -> Beat 2: Greet & check patterns -> Beat 3: Social language arc
1466
+ const approach = spring(t, 0.05, 140, 16);
1467
+ const greet = spring(t, window.DURATION * 0.38, 190, 15);
1468
+ const arcSpring = spring(t, window.DURATION * 0.68, 200, 15);
1469
+
1319
1470
  sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1320
1471
 
1321
- // Left Clan Member (Mountain Clan with Pelt & Peace Gesture)
1472
+ // Left Clan Member walks toward center
1473
+ const leftX = cx - 380 + Math.min(1, approach) * 160;
1322
1474
  drawDoodleCharacter({
1323
- x: cx - 220,
1475
+ x: leftX,
1324
1476
  y: cy + 140,
1325
1477
  scale: 1.3,
1326
1478
  hairStyle: 'wild',
1327
1479
  expression: 'talking',
1328
1480
  clothing: 'pelt',
1329
- armPose: 'peace_gesture',
1481
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1330
1482
  seed: 106,
1331
1483
  });
1332
1484
 
1333
- // Right Clan Member (River Clan with Cloak & Feather)
1485
+ // Right Clan Member walks toward center
1486
+ const rightX = cx + 380 - Math.min(1, approach) * 160;
1334
1487
  drawDoodleCharacter({
1335
- x: cx + 220,
1488
+ x: rightX,
1336
1489
  y: cy + 140,
1337
1490
  scale: 1.3,
1338
1491
  hairStyle: 'feather',
1339
1492
  expression: 'curious',
1340
1493
  clothing: 'cloak',
1341
- armPose: 'natural',
1494
+ armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
1342
1495
  seed: 107,
1343
1496
  });
1344
1497
 
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([]);
1498
+ // Glowing connection arc and symbols in Beat 3
1499
+ if (arcSpring > 0.05) {
1500
+ ctx.save();
1501
+ ctx.globalAlpha = Math.min(1, arcSpring);
1502
+ ctx.strokeStyle = ACCENT;
1503
+ ctx.lineWidth = 3;
1504
+ ctx.setLineDash([8, 8]);
1505
+ ctx.beginPath();
1506
+ ctx.moveTo(leftX + 40, cy);
1507
+ ctx.quadraticCurveTo(cx, cy - 100, rightX - 40, cy);
1508
+ ctx.stroke();
1509
+ ctx.setLineDash([]);
1354
1510
 
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);
1511
+ sCircle(cx, cy - 65, 34, 800, ACCENT, 3, 'rgba(15, 23, 42, 0.85)');
1512
+ ctx.font = 'bold 22px monospace';
1513
+ ctx.fillStyle = ACCENT;
1514
+ ctx.textAlign = 'center';
1515
+ ctx.fillText('🤝', cx, cy - 56);
1516
+ ctx.font = 'bold 16px monospace';
1517
+ ctx.fillText('CLOTHING AS SOCIAL LANGUAGE', cx, cy + 15);
1518
+ ctx.restore();
1519
+ }
1363
1520
 
1364
1521
  } else if (COMP_THEME === 'doodle_thinking_future') {
1365
- // Prehistoric Thinker & Planning for Tomorrow
1522
+ // Beat 1: Seated thinker by fire -> Beat 2: Thought cloud floats up -> Beat 3: Tomorrow's rack & tools
1523
+ const cloudSpring = spring(t, window.DURATION * 0.32, 170, 14);
1524
+ const ideaSpring = spring(t, window.DURATION * 0.65, 190, 15);
1525
+
1366
1526
  // Starlit night and crescent moon
1367
1527
  ctx.fillStyle = '#fef08a';
1368
1528
  ctx.beginPath();
@@ -1391,102 +1551,120 @@ function drawMainScene(t) {
1391
1551
  seed: 108,
1392
1552
  });
1393
1553
 
1394
- // Expanding thought cloud overhead
1395
- const cloudX = cx + 180;
1396
- const cloudY = cy - 80;
1397
- const cloudScale = spring(t, 0.15, 170, 14);
1554
+ // Expanding thought cloud overhead in Beat 2
1555
+ if (cloudSpring > 0.05) {
1556
+ const cloudX = cx + 180;
1557
+ const cloudY = cy - 80;
1558
+ ctx.save();
1559
+ ctx.translate(cloudX, cloudY);
1560
+ ctx.scale(cloudSpring, cloudSpring);
1398
1561
 
1399
- ctx.save();
1400
- ctx.translate(cloudX, cloudY);
1401
- ctx.scale(cloudScale, cloudScale);
1562
+ sCircle(-180, 160, 12, 850, '#ffffff', 2, '#ffffff');
1563
+ sCircle(-140, 120, 18, 851, '#ffffff', 2, '#ffffff');
1564
+ sCircle(-90, 70, 24, 852, '#ffffff', 2, '#ffffff');
1402
1565
 
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');
1566
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
1567
+ ctx.beginPath();
1568
+ ctx.arc(-70, 0, 55, 0, Math.PI * 2);
1569
+ ctx.arc(0, -25, 65, 0, Math.PI * 2);
1570
+ ctx.arc(70, 0, 55, 0, Math.PI * 2);
1571
+ ctx.arc(0, 30, 60, 0, Math.PI * 2);
1572
+ ctx.fill();
1407
1573
 
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();
1574
+ // Inside thought cloud: Tomorrow's sunrise & stretched hide on rack in Beat 3
1575
+ if (ideaSpring > 0.1) {
1576
+ ctx.fillStyle = '#f59e0b';
1577
+ ctx.beginPath();
1578
+ ctx.arc(0, 15, 30, Math.PI, Math.PI * 2);
1579
+ ctx.fill();
1416
1580
 
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();
1422
-
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');
1581
+ sLine(-35, 15, -35, -25, 860, '#78350f', 3);
1582
+ sLine(35, 15, 35, -25, 861, '#78350f', 3);
1583
+ sLine(-40, -25, 40, -25, 862, '#78350f', 3);
1428
1584
 
1429
- ctx.restore();
1585
+ ctx.fillStyle = '#b45309';
1586
+ ctx.fillRect(-28, -20, 56, 32);
1430
1587
 
1431
- ctx.font = 'bold 20px monospace';
1432
- ctx.fillStyle = '#facc15';
1433
- ctx.textAlign = 'center';
1434
- ctx.fillText('💡 PLANNING FOR TOMORROW', cloudX, cloudY + 95);
1588
+ ctx.font = 'bold 15px monospace';
1589
+ ctx.fillStyle = '#d97706';
1590
+ ctx.textAlign = 'center';
1591
+ ctx.fillText('💡 PLANNING FOR TOMORROW', 0, 52);
1592
+ }
1593
+ ctx.restore();
1594
+ }
1435
1595
 
1436
1596
  } else if (COMP_THEME === 'doodle_microscope_lice') {
1437
- // The Lice Theory & Genetic Divergence Proof
1438
- // Left scratching character
1597
+ // Beat 1: Scratching pelt -> Beat 2: Giant microscope slides in -> Beat 3: Divergence tree
1598
+ const scopeSpring = spring(t, window.DURATION * 0.30, 180, 15);
1599
+ const treeSpring = spring(t, window.DURATION * 0.65, 200, 15);
1600
+
1601
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1602
+
1603
+ // Early human scratching pelt with itch lines
1439
1604
  drawDoodleCharacter({
1440
- x: cx - 290,
1605
+ x: cx - 240,
1441
1606
  y: cy + 140,
1442
1607
  scale: 1.35,
1443
1608
  hairStyle: 'wild',
1444
- expression: 'curious',
1609
+ expression: 'shivering',
1445
1610
  clothing: 'pelt',
1446
1611
  armPose: 'shiver',
1447
1612
  seed: 109,
1448
1613
  });
1449
1614
 
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);
1615
+ for (let it = 0; it < 3; it++) {
1616
+ const itX = cx - 290 - it * 12;
1617
+ const itY = cy + 50 + it * 20;
1618
+ sLine(itX, itY, itX + 18, itY - 14, 900 + it, '#f59e0b', 2.5);
1466
1619
  }
1467
1620
 
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
1621
+ // Microscope slides in in Beat 2
1622
+ if (scopeSpring > 0.05) {
1623
+ const lensX = cx + 180;
1624
+ const lensY = cy;
1625
+ const lensR = 150;
1626
+
1627
+ ctx.save();
1628
+ ctx.translate(lensX, lensY);
1629
+ ctx.scale(scopeSpring, scopeSpring);
1471
1630
 
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);
1631
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.95)';
1632
+ ctx.beginPath();
1633
+ ctx.arc(0, 0, lensR, 0, Math.PI * 2);
1634
+ ctx.fill();
1635
+ ctx.strokeStyle = '#d4af37';
1636
+ ctx.lineWidth = 6;
1637
+ ctx.stroke();
1478
1638
 
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);
1639
+ // Fabric weave grid lines inside lens
1640
+ for (let g = -4; g <= 4; g++) {
1641
+ sLine(g * 24, -lensR * 0.8, g * 24, lensR * 0.8, 920 + g, 'rgba(56, 189, 248, 0.15)', 2);
1642
+ sLine(-lensR * 0.8, g * 24, lensR * 0.8, g * 24, 930 + g, 'rgba(56, 189, 248, 0.15)', 2);
1643
+ }
1483
1644
 
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);
1645
+ // Divergence tree in Beat 3
1646
+ if (treeSpring > 0.1) {
1647
+ sLine(-100, 0, -20, 0, 940, '#f8fafc', 4);
1648
+ sCircle(-20, 0, 7, 941, '#d4af37', 2, '#d4af37');
1649
+
1650
+ sLine(-20, 0, 60, -45, 942, '#38bdf8', 4);
1651
+ ctx.font = 'bold 14px monospace';
1652
+ ctx.fillStyle = '#38bdf8';
1653
+ ctx.textAlign = 'left';
1654
+ ctx.fillText('HEAD LICE (HAIR)', 70, -42);
1655
+
1656
+ sLine(-20, 0, 60, 45, 943, '#f43f5e', 4);
1657
+ ctx.fillStyle = '#f43f5e';
1658
+ ctx.fillText('BODY LICE (CLOTHES)', 70, 48);
1659
+
1660
+ sRect(-90, 80, 180, 32, 950, '#d4af37', 2, 'rgba(212, 175, 55, 0.25)');
1661
+ ctx.font = 'bold 14px monospace';
1662
+ ctx.fillStyle = '#fef08a';
1663
+ ctx.textAlign = 'center';
1664
+ ctx.fillText('170,000 YEARS AGO', 0, 101);
1665
+ }
1666
+ ctx.restore();
1667
+ }
1490
1668
 
1491
1669
  } else if (COMP_THEME === 'doodle_general_human') {
1492
1670
  // General Expressive Doodled Human Narrator
@@ -1976,49 +2154,70 @@ function drawMainScene(t) {
1976
2154
  ctx.restore();
1977
2155
  }
1978
2156
 
1979
- // ── Kinetic Typography & Broadcast Subtitle Overlay ─────────────────────────
2157
+ // ── Sentence-Synchronized Broadcast Subtitles Overlay ───────────────────────
1980
2158
  function drawCinematicOverlay(t) {
1981
2159
  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
2160
 
1986
- // Kinetic Headline at top
1987
- const capY = H * 0.12 + (1 - textSpring) * -20;
2161
+ // Subtitles / Spoken Narration - ONLY rendered when explicitly asked in prompt or options
2162
+ if (SHOW_CAPTIONS && NARRATION && NARRATION.trim().length > 0) {
2163
+ // Split narration into natural spoken sentence chunks
2164
+ const sentences = NARRATION.split(/[.!?;|]+/).map(s => s.trim()).filter(Boolean);
1988
2165
 
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
- }
2166
+ const count = Math.max(1, sentences.length);
2167
+ const sentDur = window.DURATION / count;
2168
+ const currIdx = Math.min(count - 1, Math.floor(t / sentDur));
2169
+ const activeText = sentences[currIdx] || NARRATION;
1998
2170
 
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;
2171
+ // Sentence local spring transition (smooth entrance per sentence)
2172
+ const localT = t - currIdx * sentDur;
2173
+ const sentSpring = spring(localT, 0.04, 210, 16);
2174
+ const sentAlpha = Math.min(1, Math.max(0, sentSpring));
2175
+ const sentSlideY = (1 - sentSpring) * 10;
2176
+
2177
+ ctx.save();
2178
+ ctx.globalAlpha = sentAlpha;
2179
+ ctx.font = '500 32px ' + FONT_BODY;
2018
2180
  ctx.textAlign = 'center';
2019
- const cleanNarr = NARRATION.length > 115 ? NARRATION.slice(0, 115) + '...' : NARRATION;
2020
- ctx.fillText(cleanNarr, W / 2, H * 0.92);
2181
+
2182
+ // Word wrap if sentence is long (> 58 chars)
2183
+ const maxChars = 56;
2184
+ const words = activeText.split(' ');
2185
+ const lines = [];
2186
+ let currentLine = '';
2187
+ for (let w = 0; w < words.length; w++) {
2188
+ const testLine = currentLine ? currentLine + ' ' + words[w] : words[w];
2189
+ if (testLine.length > maxChars && currentLine.length > 0) {
2190
+ lines.push(currentLine);
2191
+ currentLine = words[w];
2192
+ } else {
2193
+ currentLine = testLine;
2194
+ }
2195
+ }
2196
+ if (currentLine) lines.push(currentLine);
2197
+
2198
+ const subBaseY = H * 0.90 + sentSlideY;
2199
+ const lineSpacing = 44;
2200
+ const startY = subBaseY - ((lines.length - 1) * lineSpacing) / 2;
2201
+
2202
+ for (let l = 0; l < lines.length; l++) {
2203
+ const ly = startY + l * lineSpacing;
2204
+ // Soft translucent background backing for broadcast legibility
2205
+ const textMetrics = ctx.measureText(lines[l]);
2206
+ const pillW = textMetrics.width + 40;
2207
+ ctx.fillStyle = 'rgba(6, 9, 14, 0.72)';
2208
+ ctx.beginPath();
2209
+ ctx.roundRect((W - pillW) / 2, ly - 28, pillW, 38, 8);
2210
+ ctx.fill();
2211
+
2212
+ // Subtitle text with subtle glow
2213
+ ctx.fillStyle = '#ffffff';
2214
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
2215
+ ctx.shadowBlur = 12;
2216
+ ctx.fillText(lines[l], W / 2, ly);
2217
+ }
2218
+ ctx.restore();
2021
2219
  }
2220
+
2022
2221
  ctx.restore();
2023
2222
 
2024
2223
  // Bottom Progress Indicator (minimal line)
@@ -2109,6 +2308,7 @@ module.exports = {
2109
2308
  generateSceneCodeWithLLM,
2110
2309
  detectAesthetic,
2111
2310
  detectComponentTheme,
2311
+ detectCaptionsRequested,
2112
2312
  getScriptFontStack,
2113
2313
  AESTHETICS,
2114
2314
  REGIONAL_FONTS,