@amg555/frameforge-mcp 2.4.3 → 2.4.5

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.
@@ -45,6 +45,19 @@ function getScriptFontStack(text = '', language = '') {
45
45
 
46
46
  // ── Aesthetic Definitions ──────────────────────────────────────────────────────
47
47
  const AESTHETICS = {
48
+ hand_drawn_doodle: {
49
+ name: 'Hand-Drawn Doodle',
50
+ palettes: [
51
+ { bg0: '#ffffff', bg1: '#faf7f2', bg2: '#f6f1e8', stroke: '#1c1917', strokeAlt: '#44403c', accent: '#c2410c', accent2: '#d97706', cardBg: 'rgba(255, 255, 255, 0.95)', cardBorder: 'rgba(28, 25, 23, 0.25)', tag: 'DOODLE', isLight: true },
52
+ { bg0: '#fdfbf7', bg1: '#f8f4eb', bg2: '#f3ece0', stroke: '#18181b', strokeAlt: '#3f3f46', accent: '#15803d', accent2: '#b45309', cardBg: 'rgba(255, 255, 255, 0.95)', cardBorder: 'rgba(24, 24, 27, 0.25)', tag: 'SKETCH', isLight: true },
53
+ { bg0: '#fcfaf5', bg1: '#f7f2e7', bg2: '#f0e8d8', stroke: '#0f172a', strokeAlt: '#334155', accent: '#0369a1', accent2: '#c2410c', cardBg: 'rgba(255, 255, 255, 0.95)', cardBorder: 'rgba(15, 23, 42, 0.25)', tag: 'INK', isLight: true },
54
+ ],
55
+ fontFamily: (script) => script ? `${script}, 'Caveat', 'Patrick Hand', 'Comic Sans MS', cursive, sans-serif` : `'Caveat', 'Patrick Hand', 'Segoe UI', cursive, sans-serif`,
56
+ fontDisplayWeight: '700',
57
+ backgroundType: 'paper_texture',
58
+ springTension: 160,
59
+ springFriction: 14,
60
+ },
48
61
  cyber_tech: {
49
62
  name: 'Cyber Tech',
50
63
  palettes: [
@@ -191,6 +204,7 @@ function detectAesthetic({
191
204
  // Explicit override wins
192
205
  const explicit = (theme || style || '').toLowerCase().trim();
193
206
  if (AESTHETICS[explicit]) return explicit;
207
+ if (/doodle|sketch|hand[-\s]?drawn|drawing|white background|cream background|cartoon|illustration/i.test(explicit)) return 'hand_drawn_doodle';
194
208
  if (/cyber|tech|code|ai|crypto|hacker|futur/i.test(explicit)) return 'cyber_tech';
195
209
  if (/doc|history|ancient|story|cinema|kerala|temple|culture/i.test(explicit)) return 'cinematic_documentary';
196
210
  if (/finan|market|stock|money|invest|crypto|business/i.test(explicit)) return 'finance_executive';
@@ -204,6 +218,12 @@ function detectAesthetic({
204
218
  // Keyword scan across all textual signals (English + Multilingual)
205
219
  const combined = `${topic} ${title} ${visualDescription} ${narration} ${caption} ${musicMood}`.toLowerCase();
206
220
 
221
+ // Hand-Drawn Doodle / Sketch / Cartoon
222
+ if (/(?:^|[^\w])(doodle|sketch|hand[-\s]?drawn|drawing|white background|cream background|paper|cartoon)(?:[^\w]|$)/.test(combined)
223
+ || /ഡൂഡിൽ|വര|വരച്ച|ചിത്രരചന|रेखाचित्र|कार्टून/.test(combined)) {
224
+ return 'hand_drawn_doodle';
225
+ }
226
+
207
227
  // Cosmic Space / Quantum / Orbital
208
228
  if (/(?:^|[^\w])(quantum|astronomy|galaxy|universe|space|mars|satellite|telescope|cosmos|interstellar|orbit|gps|satellites)(?:[^\w]|$)/.test(combined)
209
229
  || /പ്രപഞ്ച|ബഹിരാകാശ|നക്ഷത്ര|ഗ്രഹം|ഉപഗ്രഹ|ജ്യോതിശാസ്ത്ര|अंतरिक्ष|ब्रह्मांड|तारा|விண்வெளி|பிரபஞ்சம்/.test(combined)) {
@@ -263,9 +283,10 @@ function detectAesthetic({
263
283
  /**
264
284
  * Detect the graphic entity to render on the full canvas for a given scene.
265
285
  */
266
- function detectComponentTheme({ visualDescription = '', caption = '', narration = '', topic = '', imagePath = null, aesthetic = 'cyber_tech' } = {}) {
286
+ function detectComponentTheme({ visualDescription = '', visual_description = '', prompt = '', caption = '', narration = '', topic = '', imagePath = null, aesthetic = 'cyber_tech' } = {}) {
267
287
  if (imagePath) return 'image';
268
- const text = `${visualDescription} ${caption} ${narration} ${topic}`.toLowerCase();
288
+ const visDesc = visualDescription || visual_description || prompt || '';
289
+ const text = `${visDesc} ${caption} ${narration} ${topic}`.toLowerCase();
269
290
 
270
291
  // ── Doodled Character Actions (Human Stories, History, Evolution, Daily Life) ──
271
292
  // 1. Microscope / Lice Theory / Parasite / Genetics (Scene 7)
@@ -310,13 +331,19 @@ function detectComponentTheme({ visualDescription = '', caption = '', narration
310
331
  return 'doodle_shame_reflection';
311
332
  }
312
333
 
313
- // 8. Dressing / Wardrobe / Wearing Clothes / Outfit / Morning Routine (Scene 1, 11)
334
+ // 8. Modern vs Prehistoric Human Comparison (Scene 2)
335
+ if (/(?:^|[^\w])(modern vs ancient|modern human|fashionable|modern vs prehistoric|split-screen|split screen|transformation|then and now|evolution of human)(?:[^\w]|$)/i.test(text)
336
+ || /ഫാഷൻ|ആധുനിക മനുഷ്യൻ|പുരാതന മനുഷ്യൻ|കടകളോ|ആധുനികവും പുരാതനവും/.test(text)) {
337
+ return 'doodle_modern_vs_prehistoric';
338
+ }
339
+
340
+ // 9. Dressing / Wardrobe / Wearing Clothes / Outfit / Morning Routine (Scene 1, 11)
314
341
  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)) {
342
+ || /വസ്ത്രധാരണം|ധരിച്ചിട്ടുണ്ടാ|ധരിച്ച|വസ്ത്രം ധരിക്കാൻ|വസ്ത്രത്തിന്|വസ്ത്രം തിരഞ്ഞെടുക്കു|ഷർട്ട്|തിരഞ്ഞെടുത്തത്|തുണി എടുത്തിടുകയല്ല|കല്യാണത്തിന്|ഇന്റർവ്യൂ|ആമുഖം: വസ്ത്രധാരണം|വസ്ത്രം തിരഞ്ഞെടുക്കുമ്പോൾ|വസ്ത്രം തിരഞ്ഞെടുക്കും/.test(text)) {
316
343
  return 'doodle_dressing';
317
344
  }
318
345
 
319
- // 9. General Human Character
346
+ // 10. General Human Character
320
347
  if (/(?:^|[^\w])(human|person|man|woman|people|child|guy|doodle|character|actor)(?:[^\w]|$)/i.test(text)
321
348
  || /മനുഷ്യൻ|ആളുകൾ|വ്യക്തി/.test(text)) {
322
349
  return 'doodle_general_human';
@@ -497,6 +524,13 @@ window.seek = async (t) => {
497
524
  </html>`;
498
525
  }
499
526
 
527
+ // ── Captions Requested Detector ───────────────────────────────────────────────
528
+ /** Returns true ONLY if caller explicitly asks for on-screen captions or subtitles in prompt. */
529
+ function detectCaptionsRequested(text = '') {
530
+ if (!text || typeof text !== 'string') return false;
531
+ 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);
532
+ }
533
+
500
534
  // ── HTML Scene Code Generator ──────────────────────────────────────────────────
501
535
  /**
502
536
  * Generate a complete, self-contained HTML scene conforming to window.seek(t).
@@ -513,11 +547,19 @@ function generateSceneHtml({
513
547
  title = '',
514
548
  language = '',
515
549
  musicMood = 'neutral',
550
+ camera = 'auto',
551
+ cameraHint = 'auto',
516
552
  duration = 3,
517
553
  width = 1920,
518
554
  height = 1080,
519
555
  sceneIndex = 0,
556
+ showCaptions = false,
520
557
  } = {}) {
558
+ const captionsWanted = Boolean(
559
+ showCaptions ||
560
+ detectCaptionsRequested(`${caption} ${narration} ${topic} ${visualDescription} ${style} ${title}`) ||
561
+ process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
562
+ );
521
563
  const aestheticKey = detectAesthetic({
522
564
  topic,
523
565
  style,
@@ -585,6 +627,7 @@ function generateSceneHtml({
585
627
  <canvas id="stage" width="${width}" height="${height}"></canvas>
586
628
  <script>
587
629
  window.DURATION = ${clampedDur};
630
+ window.CAMERA_HINT = ${JSON.stringify(camera || cameraHint || 'auto')};
588
631
  const canvas = document.getElementById('stage');
589
632
  const ctx = canvas.getContext('2d');
590
633
  const W = ${width};
@@ -627,6 +670,7 @@ function spring(t, delay = 0, tension = ${aesthetic.springTension}, friction = $
627
670
  return 1 - Math.exp(-omega * dt);
628
671
  }
629
672
 
673
+ const SHOW_CAPTIONS = ${JSON.stringify(captionsWanted)};
630
674
  const AESTHETIC = ${JSON.stringify(aestheticKey)};
631
675
  const BG_TYPE = ${JSON.stringify(aesthetic.backgroundType)};
632
676
  const COMP_THEME = ${JSON.stringify(compTheme)};
@@ -644,6 +688,10 @@ const FONT_DISPLAY = ${JSON.stringify(fontDisplay)};
644
688
  const FONT_BODY = ${JSON.stringify(fontBody)};
645
689
  const FONT_WEIGHT = ${JSON.stringify(aesthetic.fontDisplayWeight)};
646
690
  const IMAGE_SRC = ${JSON.stringify(imageDataUri)};
691
+ const IS_LIGHT = ${Boolean(pal.isLight)};
692
+ const STROKE_COLOR = ${JSON.stringify(pal.isLight ? (pal.stroke || '#1c1917') : '#f8fafc')};
693
+ const STROKE_ALT = ${JSON.stringify(pal.isLight ? (pal.strokeAlt || '#44403c') : 'rgba(255, 255, 255, 0.7)')};
694
+ const AMBIENT_LINE = ${JSON.stringify(pal.isLight ? 'rgba(28, 25, 23, 0.22)' : 'rgba(255, 255, 255, 0.25)')};
647
695
 
648
696
  let cachedImage = null;
649
697
  if (IMAGE_SRC) {
@@ -663,7 +711,24 @@ function drawBackground(t) {
663
711
  ctx.fillRect(0, 0, W, H);
664
712
 
665
713
  ctx.save();
666
- if (BG_TYPE === 'grid_perspective') {
714
+ if (BG_TYPE === 'paper_texture' || IS_LIGHT) {
715
+ // Subtle paper grain / parchment texture
716
+ ctx.strokeStyle = 'rgba(120, 80, 40, 0.04)';
717
+ ctx.lineWidth = 1;
718
+ for (let y = 40; y < H; y += 40) {
719
+ const wobble = Math.sin(y * 0.1) * 3;
720
+ ctx.beginPath();
721
+ ctx.moveTo(0, y + wobble);
722
+ ctx.lineTo(W, y - wobble);
723
+ ctx.stroke();
724
+ }
725
+ // Subtle organic vignette
726
+ const vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.45, W / 2, H / 2, Math.max(W, H) * 0.75);
727
+ vig.addColorStop(0, 'rgba(0, 0, 0, 0)');
728
+ vig.addColorStop(1, 'rgba(120, 90, 60, 0.08)');
729
+ ctx.fillStyle = vig;
730
+ ctx.fillRect(0, 0, W, H);
731
+ } else if (BG_TYPE === 'grid_perspective') {
667
732
  // 3D Perspective Grid
668
733
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
669
734
  ctx.lineWidth = 1;
@@ -740,13 +805,61 @@ function drawBackground(t) {
740
805
  // ── Full-Canvas Cinematic Visual Directors ─────────────────────────────────────
741
806
  function drawMainScene(t) {
742
807
  ctx.save();
743
- const p = Math.min(1, Math.max(0, t / window.DURATION));
808
+ // Continuous audio-driven cinematic camera movement (no fixed interval timer cuts)
809
+ const dur = window.DURATION || 3;
810
+ const p = Math.min(1, Math.max(0, t / dur));
744
811
  const enterSpring = spring(t, 0.05, 170, 13);
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;
812
+ const camHint = (window.CAMERA_HINT || 'auto').toLowerCase();
813
+
814
+ let targetZoom = 1.0;
815
+ let targetPanX = 0;
816
+ let targetPanY = 0;
817
+
818
+ if (camHint === 'slow_zoom_in' || camHint === 'zoom_in') {
819
+ targetZoom = 1.0 + p * 0.08;
820
+ targetPanX = Math.sin(t * 0.4) * 6;
821
+ targetPanY = Math.cos(t * 0.3) * 4;
822
+ } else if (camHint === 'slow_zoom_out' || camHint === 'zoom_out') {
823
+ targetZoom = 1.08 - p * 0.08;
824
+ targetPanX = Math.sin(t * 0.4) * 6;
825
+ targetPanY = Math.cos(t * 0.3) * 4;
826
+ } else if (camHint === 'pan_left') {
827
+ targetZoom = 1.02;
828
+ targetPanX = 25 - p * 50;
829
+ targetPanY = Math.cos(t * 0.3) * 3;
830
+ } else if (camHint === 'pan_right') {
831
+ targetZoom = 1.02;
832
+ targetPanX = -25 + p * 50;
833
+ targetPanY = Math.cos(t * 0.3) * 3;
834
+ } else if (camHint === 'tilt_up') {
835
+ targetZoom = 1.03;
836
+ targetPanX = Math.sin(t * 0.3) * 4;
837
+ targetPanY = 20 - p * 40;
838
+ } else if (camHint === 'tilt_down') {
839
+ targetZoom = 1.03;
840
+ targetPanX = Math.sin(t * 0.3) * 4;
841
+ targetPanY = -20 + p * 40;
842
+ } else if (camHint === 'ken_burns') {
843
+ targetZoom = 1.0 + p * 0.06;
844
+ targetPanX = -18 + p * 36;
845
+ targetPanY = 12 - p * 24;
846
+ } else if (camHint === 'static') {
847
+ targetZoom = 1.0;
848
+ targetPanX = 0;
849
+ targetPanY = 0;
850
+ } else {
851
+ // 'auto' or narrative default: subtle organic breathing and smooth forward progression
852
+ targetZoom = 1.0 + Math.sin(p * Math.PI * 0.5) * 0.04;
853
+ targetPanX = Math.sin(t * 0.35) * 8;
854
+ targetPanY = Math.cos(t * 0.28) * 4;
855
+ }
856
+
857
+ const camZoom = targetZoom;
858
+ const camPanX = targetPanX;
859
+ const camPanY = targetPanY;
860
+
748
861
  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);
862
+ ctx.scale(Math.min(1.02, Math.max(0.92, enterSpring)) * camZoom, Math.min(1.02, Math.max(0.92, enterSpring)) * camZoom);
750
863
  ctx.translate(-W / 2, -H * 0.50);
751
864
 
752
865
  const cx = W / 2;
@@ -758,7 +871,7 @@ function drawMainScene(t) {
758
871
  return Math.sin(seed * 19.37 + step * 2.41) * mag;
759
872
  }
760
873
 
761
- function sLine(x1, y1, x2, y2, seed, color = '#f8fafc', width = 3.5) {
874
+ function sLine(x1, y1, x2, y2, seed, color = STROKE_COLOR, width = 3.5) {
762
875
  ctx.save();
763
876
  ctx.strokeStyle = color;
764
877
  ctx.lineWidth = width;
@@ -773,7 +886,7 @@ function drawMainScene(t) {
773
886
  ctx.restore();
774
887
  }
775
888
 
776
- function sCircle(x, y, r, seed, color = '#f8fafc', width = 3, fillColor = null) {
889
+ function sCircle(x, y, r, seed, color = STROKE_COLOR, width = 3, fillColor = null) {
777
890
  ctx.save();
778
891
  if (fillColor) {
779
892
  ctx.fillStyle = fillColor;
@@ -798,7 +911,7 @@ function drawMainScene(t) {
798
911
  ctx.restore();
799
912
  }
800
913
 
801
- function sRect(x, y, w, h, seed, color = '#f8fafc', width = 3, fillColor = null) {
914
+ function sRect(x, y, w, h, seed, color = STROKE_COLOR, width = 3, fillColor = null) {
802
915
  ctx.save();
803
916
  if (fillColor) {
804
917
  ctx.fillStyle = fillColor;
@@ -822,69 +935,107 @@ function drawMainScene(t) {
822
935
  expression = 'talking',
823
936
  clothing = 'none',
824
937
  armPose = 'natural',
938
+ showShadow = false,
939
+ showBlush = false,
940
+ showSpecular = false,
825
941
  seed = 100,
826
942
  } = {}) {
827
943
  ctx.save();
828
944
  ctx.translate(x, y);
829
945
  ctx.scale(scale, scale);
830
946
 
947
+ // Ground contact shadow: strictly conditional (default false)
948
+ if (showShadow) {
949
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
950
+ ctx.beginPath();
951
+ ctx.ellipse(0, legLength, 34, 7, 0, 0, Math.PI * 2);
952
+ ctx.fill();
953
+ }
954
+
831
955
  const blink = (t % 3.2 < 0.14) ? 0.15 : 1.0;
832
956
  const mouthTalk = Math.abs(Math.sin(t * 14)) * 4;
833
957
  const torsoTopY = -torsoHeight;
834
958
  const torsoBottomY = 0;
959
+ const shoulderW = 28;
835
960
 
836
- // Torso Clothing Fills
961
+ // High quality contoured torso silhouette (organic human form, never stick figure)
837
962
  if (clothing === 'pelt') {
963
+ // Prehistoric animal pelt tunic with textured ragged hem
838
964
  ctx.fillStyle = '#b45309';
839
965
  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);
966
+ ctx.moveTo(-shoulderW + wb(seed, 2), torsoTopY);
967
+ ctx.lineTo(shoulderW + wb(seed + 1, 2), torsoTopY);
968
+ ctx.lineTo(shoulderW + 6 + wb(seed + 2, 2), torsoBottomY + 22);
969
+ ctx.lineTo(-shoulderW - 6 + wb(seed + 3, 2), torsoBottomY + 22);
844
970
  ctx.closePath();
845
971
  ctx.fill();
846
- sLine(-28, torsoBottomY + 2, 32, torsoBottomY + 2, seed + 4, '#78350f', 4);
972
+
973
+ // Pelt border stitching & waist wrap
974
+ sLine(-shoulderW - 6, torsoBottomY + 22, shoulderW + 6, torsoBottomY + 22, seed + 4, '#78350f', 4);
975
+ sLine(-shoulderW, torsoBottomY - 8, shoulderW, torsoBottomY - 8, seed + 5, '#451a03', 3);
976
+ // Asymmetric fur shoulder wrap
977
+ sLine(-shoulderW, torsoTopY, shoulderW - 10, torsoTopY + 24, seed + 6, '#78350f', 3.5);
847
978
  } else if (clothing === 'shirt') {
979
+ // Modern tailored shirt/jacket with collar and button placket
848
980
  ctx.fillStyle = ACCENT;
849
981
  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);
982
+ ctx.moveTo(-shoulderW + wb(seed, 2), torsoTopY);
983
+ ctx.lineTo(shoulderW + wb(seed + 1, 2), torsoTopY);
984
+ ctx.lineTo(shoulderW + 2 + wb(seed + 2, 2), torsoBottomY + 12);
985
+ ctx.lineTo(-shoulderW - 2 + wb(seed + 3, 2), torsoBottomY + 12);
854
986
  ctx.closePath();
855
987
  ctx.fill();
988
+
989
+ // Collar lines & placket
856
990
  sLine(-14, torsoTopY, 0, torsoTopY + 18, seed + 4, '#ffffff', 3);
857
991
  sLine(14, torsoTopY, 0, torsoTopY + 18, seed + 5, '#ffffff', 3);
858
992
  sLine(0, torsoTopY + 18, 0, torsoBottomY + 10, seed + 6, '#ffffff', 2.5);
859
993
  } else if (clothing === 'cloak') {
994
+ // Flowing draped mantle
860
995
  ctx.fillStyle = '#0f766e';
861
996
  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);
997
+ ctx.moveTo(-shoulderW - 8, torsoTopY - 6);
998
+ ctx.lineTo(shoulderW + 8, torsoTopY - 6);
999
+ ctx.lineTo(shoulderW + 18, torsoBottomY + 32);
1000
+ ctx.lineTo(-shoulderW - 18, torsoBottomY + 32);
866
1001
  ctx.closePath();
867
1002
  ctx.fill();
1003
+ sLine(-shoulderW - 8, torsoTopY - 6, -shoulderW - 18, torsoBottomY + 32, seed + 7, '#134e4a', 3.5);
1004
+ sLine(shoulderW + 8, torsoTopY - 6, shoulderW + 18, torsoBottomY + 32, seed + 8, '#134e4a', 3.5);
868
1005
  } 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);
1006
+ // Natural contoured human torso (shoulders, ribcage, waist, hips)
1007
+ ctx.fillStyle = IS_LIGHT ? 'rgba(217, 119, 6, 0.12)' : 'rgba(254, 243, 199, 0.22)';
1008
+ ctx.beginPath();
1009
+ ctx.moveTo(-shoulderW + wb(seed, 1.5), torsoTopY + 5);
1010
+ ctx.lineTo(shoulderW + wb(seed + 1, 1.5), torsoTopY + 5);
1011
+ ctx.quadraticCurveTo(shoulderW - 6 + wb(seed + 2, 1.5), torsoTopY + torsoHeight * 0.5, shoulderW - 4, torsoBottomY);
1012
+ ctx.lineTo(-shoulderW + 4, torsoBottomY);
1013
+ ctx.quadraticCurveTo(-shoulderW + 6 + wb(seed + 3, 1.5), torsoTopY + torsoHeight * 0.5, -shoulderW + wb(seed, 1.5), torsoTopY + 5);
1014
+ ctx.closePath();
1015
+ ctx.fill();
1016
+
1017
+ // Contoured torso outline strokes
1018
+ sLine(-shoulderW, torsoTopY + 5, -shoulderW + 4, torsoBottomY, seed + 7, STROKE_COLOR, 3.5);
1019
+ sLine(shoulderW, torsoTopY + 5, shoulderW - 4, torsoBottomY, seed + 8, STROKE_COLOR, 3.5);
1020
+ sLine(-shoulderW + 4, torsoBottomY, shoulderW - 4, torsoBottomY, seed + 9, STROKE_COLOR, 3);
1021
+ // Subtle collarbone / rib accents
1022
+ sLine(-16, torsoTopY + 16, -2, torsoTopY + 22, seed + 10, STROKE_ALT, 2);
1023
+ sLine(16, torsoTopY + 16, 2, torsoTopY + 22, seed + 11, STROKE_ALT, 2);
872
1024
  }
873
- sLine(0, torsoTopY, 0, torsoBottomY, seed + 10, '#f8fafc', 4);
874
1025
 
875
- // Head
876
- const headCenterY = torsoTopY - headRadius - 6 + wb(seed + 11, 1.5);
877
- sCircle(0, headCenterY, headRadius, seed + 12, '#f8fafc', 3.5, '#fef3c7');
1026
+ // Head (expressive contoured skull)
1027
+ const headCenterY = torsoTopY - headRadius - 6 + wb(seed + 12, 1.5);
1028
+ sCircle(0, headCenterY, headRadius, seed + 13, STROKE_COLOR, 3.5, '#fef3c7');
878
1029
 
879
1030
  // Hair
880
1031
  if (hairStyle === 'wild') {
881
- for (let h = 0; h < 6; h++) {
882
- const hairAng = -Math.PI * 0.85 + (h / 5) * Math.PI * 0.7;
1032
+ for (let h = 0; h < 7; h++) {
1033
+ const hairAng = -Math.PI * 0.88 + (h / 6) * Math.PI * 0.76;
883
1034
  const hx1 = Math.cos(hairAng) * headRadius;
884
1035
  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);
1036
+ const hx2 = Math.cos(hairAng) * (headRadius + 24 + wb(seed + 14 + h, 4));
1037
+ const hy2 = headCenterY + Math.sin(hairAng) * (headRadius + 24 + wb(seed + 15 + h, 4));
1038
+ sLine(hx1, hy1, hx2, hy2, seed + 16 + h, '#78350f', 3.5);
888
1039
  }
889
1040
  } else if (hairStyle === 'modern') {
890
1041
  ctx.fillStyle = '#1e293b';
@@ -900,10 +1051,28 @@ function drawMainScene(t) {
900
1051
 
901
1052
  // Eyes
902
1053
  const eyeY = headCenterY + 4;
903
- const eyeR = 4 * blink;
1054
+ const eyeR = 4.2 * blink;
904
1055
  sCircle(-12, eyeY, Math.max(1, eyeR), seed + 23, '#0f172a', 2, '#0f172a');
905
1056
  sCircle(12, eyeY, Math.max(1, eyeR), seed + 24, '#0f172a', 2, '#0f172a');
906
1057
 
1058
+ // Specular eye highlights: only when explicitly enabled
1059
+ if (showSpecular && blink > 0.4) {
1060
+ ctx.fillStyle = '#ffffff';
1061
+ ctx.beginPath();
1062
+ ctx.arc(-14, eyeY - 1.5, 1.2, 0, Math.PI * 2);
1063
+ ctx.arc(10, eyeY - 1.5, 1.2, 0, Math.PI * 2);
1064
+ ctx.fill();
1065
+ }
1066
+
1067
+ // Rosy cheek blush: strictly conditional (default false)
1068
+ if (showBlush || expression === 'blushing' || expression === 'shame') {
1069
+ ctx.fillStyle = 'rgba(251, 113, 133, 0.28)';
1070
+ ctx.beginPath();
1071
+ ctx.ellipse(-14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
1072
+ ctx.ellipse(14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
1073
+ ctx.fill();
1074
+ }
1075
+
907
1076
  // Eyebrows
908
1077
  if (expression === 'shivering') {
909
1078
  sLine(-18, eyeY - 12 + wb(seed, 2), -6, eyeY - 7 + wb(seed, 2), seed + 25, '#0f172a', 2.5);
@@ -938,93 +1107,102 @@ function drawMainScene(t) {
938
1107
  sLine(-8, mouthY, 8, mouthY, seed + 34, '#0f172a', 2.5);
939
1108
  }
940
1109
 
941
- // Legs
1110
+ // Legs with articulated knees and planted feet
942
1111
  const hipY = torsoBottomY;
943
1112
  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);
1113
+ // Cross-legged seated pose
1114
+ sLine(-14, hipY, -48, hipY + 45, seed + 35, STROKE_COLOR, 4);
1115
+ sLine(-48, hipY + 45, 12, hipY + 50, seed + 36, STROKE_COLOR, 4);
1116
+ sLine(14, hipY, 48, hipY + 45, seed + 37, STROKE_COLOR, 4);
1117
+ sLine(48, hipY + 45, -12, hipY + 50, seed + 38, STROKE_COLOR, 4);
948
1118
  } else if (armPose === 'hold_spear') {
1119
+ // Dynamic walking gait
949
1120
  const legSwing = Math.sin(t * 5.5);
950
1121
  const l1KneeX = -15 + legSwing * 22;
951
1122
  const l1KneeY = hipY + legLength * 0.55;
952
1123
  const l1FootX = -15 + legSwing * 38;
953
1124
  const l1FootY = hipY + legLength;
954
- sLine(-10, hipY, l1KneeX, l1KneeY, seed + 39, '#f8fafc', 4);
955
- sLine(l1KneeX, l1KneeY, l1FootX, l1FootY, seed + 40, '#f8fafc', 4);
1125
+ sLine(-14, hipY, l1KneeX, l1KneeY, seed + 39, STROKE_COLOR, 4);
1126
+ sLine(l1KneeX, l1KneeY, l1FootX, l1FootY, seed + 40, STROKE_COLOR, 4);
1127
+ sLine(l1FootX, l1FootY, l1FootX + 16, l1FootY, seed + 41, STROKE_COLOR, 3.5);
956
1128
 
957
1129
  const l2KneeX = 15 - legSwing * 22;
958
1130
  const l2KneeY = hipY + legLength * 0.55;
959
1131
  const l2FootX = 15 - legSwing * 38;
960
1132
  const l2FootY = hipY + legLength;
961
- sLine(10, hipY, l2KneeX, l2KneeY, seed + 41, '#f8fafc', 4);
962
- sLine(l2KneeX, l2KneeY, l2FootX, l2FootY, seed + 42, '#f8fafc', 4);
1133
+ sLine(14, hipY, l2KneeX, l2KneeY, seed + 42, STROKE_COLOR, 4);
1134
+ sLine(l2KneeX, l2KneeY, l2FootX, l2FootY, seed + 43, STROKE_COLOR, 4);
1135
+ sLine(l2FootX, l2FootY, l2FootX + 16, l2FootY, seed + 44, STROKE_COLOR, 3.5);
963
1136
  } 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);
1137
+ // Standing legs with natural knee contour and planted feet
1138
+ const lKneeX = -15 + wb(seed + 39, 1);
1139
+ const rKneeX = 15 + wb(seed + 40, 1);
1140
+ const kneeY = hipY + legLength * 0.52;
1141
+ const footY = hipY + legLength;
1142
+ sLine(-16, hipY, lKneeX, kneeY, seed + 41, STROKE_COLOR, 4);
1143
+ sLine(lKneeX, kneeY, -16, footY, seed + 42, STROKE_COLOR, 4);
1144
+ sLine(-16, footY, -28, footY, seed + 43, STROKE_COLOR, 3.5);
1145
+
1146
+ sLine(16, hipY, rKneeX, kneeY, seed + 44, STROKE_COLOR, 4);
1147
+ sLine(rKneeX, kneeY, 16, footY, seed + 45, STROKE_COLOR, 4);
1148
+ sLine(16, footY, 28, footY, seed + 46, STROKE_COLOR, 3.5);
968
1149
  }
969
1150
 
970
- // Arms & Hands
1151
+ // Arms & Hands with natural articulated elbows and gestures
971
1152
  const shoulderY = torsoTopY + 12;
972
1153
  if (armPose === 'shiver') {
973
1154
  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);
1155
+ sLine(-shoulderW, shoulderY, 16 + shivA, shoulderY + 35, seed + 47, STROKE_COLOR, 4);
1156
+ sLine(shoulderW, shoulderY, -16 + shivA, shoulderY + 35, seed + 48, STROKE_COLOR, 4);
976
1157
  } 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';
1158
+ // Holding spear with grip
1159
+ const spearSway = Math.sin(t * 5.5) * 6;
1160
+ sLine(-shoulderW, shoulderY, -10, shoulderY + 28, seed + 49, STROKE_COLOR, 4);
1161
+ sLine(-10, shoulderY + 28, 12, shoulderY + 14, seed + 50, STROKE_COLOR, 4);
1162
+ // Spear pole passing through hands
1163
+ const spearX = 18 + spearSway;
1164
+ sLine(spearX - 25, cy - 140, spearX + 35, cy + 180, seed + 51, '#78350f', 5.5);
1165
+ // Flint spearhead bound with sinew
1166
+ ctx.fillStyle = '#94a3b8';
982
1167
  ctx.beginPath();
983
- ctx.moveTo(spearX, -torsoHeight - 165);
984
- ctx.lineTo(spearX - 10, -torsoHeight - 128);
985
- ctx.lineTo(spearX + 10, -torsoHeight - 128);
1168
+ ctx.moveTo(spearX - 25, cy - 140);
1169
+ ctx.lineTo(spearX - 32, cy - 180);
1170
+ ctx.lineTo(spearX - 18, cy - 170);
986
1171
  ctx.closePath();
987
1172
  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);
1173
+ sLine(spearX - 28, cy - 140, spearX - 20, cy - 150, seed + 52, '#f59e0b', 3);
990
1174
  } 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();
1175
+ // Left hand holds stretched pelt, right hand drives bone needle with sinew
1176
+ const sewPull = Math.sin(t * 4) * 14;
1177
+ sLine(-shoulderW, shoulderY, -40, shoulderY + 38, seed + 53, STROKE_COLOR, 4);
1178
+ sLine(-40, shoulderY + 38, -15, shoulderY + 45, seed + 54, STROKE_COLOR, 4);
1179
+ sLine(shoulderW, shoulderY, 35 + sewPull * 0.5, shoulderY + 28, seed + 55, STROKE_COLOR, 4);
1180
+ sLine(35 + sewPull * 0.5, shoulderY + 28, 10 + sewPull, shoulderY + 42, seed + 56, STROKE_COLOR, 4);
1005
1181
  } 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);
1182
+ // Protective covering gesture
1183
+ sLine(-shoulderW, shoulderY, 10, shoulderY + 28, seed + 57, STROKE_COLOR, 4);
1184
+ sLine(shoulderW, shoulderY, -10, shoulderY + 48, seed + 58, STROKE_COLOR, 4);
1185
+ } else if (armPose === 'thinker') {
1186
+ // Rodin-style contemplative pose: hand under chin, elbow on knee
1187
+ sLine(shoulderW, shoulderY, 15, shoulderY + 42, seed + 59, STROKE_COLOR, 4);
1188
+ sLine(15, shoulderY + 42, 6, headCenterY + 20, seed + 60, STROKE_COLOR, 4);
1189
+ sLine(-shoulderW, shoulderY, -30, shoulderY + 45, seed + 61, STROKE_COLOR, 4);
1008
1190
  } 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);
1191
+ // Examining clothes, touching button / adjusting collar
1192
+ sLine(-shoulderW, shoulderY, -14, shoulderY + 22, seed + 62, STROKE_COLOR, 4);
1193
+ sLine(shoulderW, shoulderY, 14, shoulderY + 22, seed + 63, STROKE_COLOR, 4);
1014
1194
  } 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);
1195
+ // Open welcoming hand
1196
+ sLine(-shoulderW, shoulderY, -35, shoulderY + 30, seed + 64, STROKE_COLOR, 4);
1197
+ sLine(shoulderW, shoulderY, 35, shoulderY + 15, seed + 65, STROKE_COLOR, 4);
1198
+ sLine(35, shoulderY + 15, 52, shoulderY + 2, seed + 66, STROKE_COLOR, 3.5);
1022
1199
  } 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);
1200
+ // Natural expressive speaking arms with subtle breathing gesture
1201
+ const gArm = Math.sin(t * 3.5) * 8;
1202
+ sLine(-shoulderW, shoulderY, -shoulderW - 8, shoulderY + 38, seed + 67, STROKE_COLOR, 4);
1203
+ sLine(-shoulderW - 8, shoulderY + 38, -shoulderW - 14, shoulderY + 68, seed + 68, STROKE_COLOR, 4);
1204
+ sLine(shoulderW, shoulderY, shoulderW + 12, shoulderY + 32 - gArm, seed + 69, STROKE_COLOR, 4);
1205
+ sLine(shoulderW + 12, shoulderY + 32 - gArm, shoulderW + 24, shoulderY + 18 - gArm, seed + 70, STROKE_COLOR, 4);
1028
1206
  }
1029
1207
 
1030
1208
  ctx.restore();
@@ -1036,7 +1214,7 @@ function drawMainScene(t) {
1036
1214
  const beat2 = spring(t, window.DURATION * 0.35, 180, 16);
1037
1215
  const beat3 = spring(t, window.DURATION * 0.70, 190, 16);
1038
1216
 
1039
- sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.25)', 2.5);
1217
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, AMBIENT_LINE, 2.5);
1040
1218
 
1041
1219
  // Clothes rack on left (sways in beat 1)
1042
1220
  sLine(cx - 360, cy + 220, cx - 360, cy - 140, 11, '#94a3b8', 5);
@@ -1100,44 +1278,120 @@ function drawMainScene(t) {
1100
1278
  seed: 101,
1101
1279
  });
1102
1280
 
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);
1281
+ } else if (COMP_THEME === 'doodle_modern_vs_prehistoric') {
1282
+ // Split-screen comparison: Modern stylish human on left vs Prehistoric human on right
1283
+ const dur = window.DURATION || 3;
1284
+ const bridgeSpring = spring(t, dur * 0.35, 170, 15);
1285
+
1286
+ // Natural ground line
1287
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, AMBIENT_LINE, 2.5);
1288
+
1289
+ // Subtle split-screen center divider
1290
+ ctx.save();
1291
+ ctx.setLineDash([10, 10]);
1292
+ sLine(cx, cy - 220, cx, cy + 200, 20, AMBIENT_LINE, 2);
1293
+ ctx.restore();
1294
+
1295
+ // Modern human on left
1296
+ drawDoodleCharacter({
1297
+ x: cx - 260,
1298
+ y: cy + 140,
1299
+ scale: 1.3,
1300
+ hairStyle: 'modern',
1301
+ expression: 'talking',
1302
+ clothing: 'shirt',
1303
+ armPose: 'dress_shirt',
1304
+ seed: 120,
1305
+ });
1306
+
1307
+ // Prehistoric human on right
1308
+ drawDoodleCharacter({
1309
+ x: cx + 260,
1310
+ y: cy + 140,
1311
+ scale: 1.3,
1312
+ hairStyle: 'wild',
1313
+ expression: 'curious',
1314
+ clothing: 'none',
1315
+ armPose: 'natural',
1316
+ seed: 121,
1317
+ });
1107
1318
 
1108
- sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1319
+ // Dynamic timeline bridge / evolution arc connecting them
1320
+ if (bridgeSpring > 0.05) {
1321
+ ctx.save();
1322
+ ctx.globalAlpha = Math.min(0.85, bridgeSpring * 0.85);
1323
+ ctx.strokeStyle = ACCENT;
1324
+ ctx.lineWidth = 3;
1325
+ ctx.setLineDash([8, 8]);
1326
+ ctx.beginPath();
1327
+ ctx.moveTo(cx - 200, cy - 30);
1328
+ ctx.quadraticCurveTo(cx, cy - 140, cx + 200, cy - 30);
1329
+ ctx.stroke();
1330
+ ctx.setLineDash([]);
1331
+
1332
+ // Center compass / timeline knot
1333
+ sCircle(cx, cy - 85, 24, 820, ACCENT, 3, IS_LIGHT ? '#ffffff' : 'rgba(15, 23, 42, 0.9)');
1334
+ sCircle(cx, cy - 85, 10, 821, STROKE_COLOR, 2, STROKE_COLOR);
1335
+ ctx.restore();
1336
+ }
1109
1337
 
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);
1338
+ } else if (COMP_THEME === 'doodle_shiver_cold') {
1339
+ // Narrative beat progression based strictly on audio duration (window.DURATION)
1340
+ const dur = window.DURATION || 3;
1341
+ const beat2 = spring(t, dur * 0.45, 160, 15);
1342
+
1343
+ // Natural ground line
1344
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, AMBIENT_LINE, 2);
1345
+
1346
+ // Freezing wind streaks (rush during cold beat, calm as sun breaks)
1347
+ const windAlpha = Math.max(0, 1 - beat2 * 0.85);
1348
+ if (windAlpha > 0.05) {
1349
+ for (let w = 0; w < 14; w++) {
1350
+ const windX = ((t * 750 + w * 210) % (W + 600)) - 300;
1351
+ const windY = cy - 250 + (w * 42) + Math.sin(t * 3.5 + w) * 15;
1352
+ const windColor = (IS_LIGHT ? 'rgba(100,116,139,' : 'rgba(255,255,255,') + (0.35 * windAlpha) + ')';
1353
+ sLine(windX, windY, windX + 170, windY + 5, 200 + w, windColor, 2);
1354
+ }
1115
1355
  }
1116
1356
 
1117
1357
  // 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');
1358
+ for (let sp = 0; sp < 22; sp++) {
1359
+ const px = ((sp * 85 + t * 400) % W);
1360
+ const py = (sp * 45 + t * 80) % H;
1361
+ sCircle(px, py, 2.2, 300 + sp, '#e0f2fe', 1, '#ffffff');
1122
1362
  }
1123
1363
 
1124
- // Shivering breath vapor cloud (calms in Beat 3)
1125
- if (stamp2 < 0.8) {
1364
+ // Shivering breath vapor cloud (calms in Beat 2)
1365
+ if (beat2 < 0.7) {
1126
1366
  const breathPhase = (t * 2.2) % 1;
1127
1367
  const bCloudX = cx - 50 + breathPhase * 60;
1128
1368
  const bCloudY = cy - 90 - breathPhase * 25;
1129
- ctx.fillStyle = 'rgba(224, 242, 254, ' + ((1 - breathPhase) * 0.5) + ')';
1369
+ ctx.fillStyle = 'rgba(224, 242, 254, ' + ((1 - breathPhase) * 0.45) + ')';
1130
1370
  ctx.beginPath();
1131
1371
  ctx.arc(bCloudX, bCloudY, 15 + breathPhase * 25, 0, Math.PI * 2);
1132
1372
  ctx.fill();
1133
1373
  }
1134
1374
 
1135
- // Main character: shivers in Beat 1 & 2, then realizes / curious in Beat 3
1136
- const isShivering = stamp2 < 0.7;
1375
+ // Warm golden sunlight breaks in (visual myth-busting revelation)
1376
+ if (beat2 > 0.05) {
1377
+ ctx.save();
1378
+ ctx.globalAlpha = Math.min(0.6, beat2 * 0.6);
1379
+ const sunBeamGrad = ctx.createRadialGradient(cx + 180, cy - 220, 20, cx + 180, cy - 100, 320);
1380
+ sunBeamGrad.addColorStop(0, 'rgba(251, 191, 36, 0.4)');
1381
+ sunBeamGrad.addColorStop(1, 'rgba(251, 191, 36, 0)');
1382
+ ctx.fillStyle = sunBeamGrad;
1383
+ ctx.beginPath();
1384
+ ctx.arc(cx + 180, cy - 180, 260, 0, Math.PI * 2);
1385
+ ctx.fill();
1386
+ ctx.restore();
1387
+ }
1388
+
1389
+ // Main character: shivers in cold beat, then pauses and looks at arms in wonder as sun illuminates
1390
+ const isShivering = beat2 < 0.6;
1137
1391
  const vibX = isShivering ? Math.sin(t * 38) * 4.5 : 0;
1138
1392
  const vibY = isShivering ? Math.cos(t * 32) * 2 : 0;
1139
1393
  drawDoodleCharacter({
1140
- x: cx - 120 + vibX,
1394
+ x: cx - 100 + beat2 * 80 + vibX,
1141
1395
  y: cy + 140 + vibY,
1142
1396
  scale: 1.35,
1143
1397
  hairStyle: 'wild',
@@ -1147,38 +1401,6 @@ function drawMainScene(t) {
1147
1401
  seed: 102,
1148
1402
  });
1149
1403
 
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
1404
  } else if (COMP_THEME === 'doodle_hunter_savannah') {
1183
1405
  // Beat 1: Walking with spear -> Beat 2: Swatting mosquitoes -> Beat 3: Pausing alert
1184
1406
  const walkProg = Math.min(1, Math.max(0, t / window.DURATION));
@@ -1295,15 +1517,12 @@ function drawMainScene(t) {
1295
1517
  ctx.restore();
1296
1518
  }
1297
1519
 
1298
- // Timeline badge in Beat 3
1520
+ // Ancient stone strata and bone needle engraving in Beat 3
1299
1521
  if (badgeSpring > 0.05) {
1300
1522
  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);
1523
+ ctx.globalAlpha = Math.min(0.65, badgeSpring * 0.65);
1524
+ sLine(cx + 140, cy + 150, cx + 420, cy + 150, 670, '#d4af37', 2);
1525
+ sLine(cx + 180, cy + 168, cx + 380, cy + 168, 671, '#d4af37', 1.5);
1307
1526
  ctx.restore();
1308
1527
  }
1309
1528
 
@@ -1378,12 +1597,8 @@ function drawMainScene(t) {
1378
1597
  ctx.translate(haloX, haloY);
1379
1598
  ctx.scale(haloSpring, haloSpring);
1380
1599
  sCircle(0, 0, 48, 760, '#38bdf8', 3);
1381
- sCircle(0, 0, 18, 761, '#ffffff', 2.5);
1600
+ sCircle(0, 0, 24, 761, STROKE_ALT, 2);
1382
1601
  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
1602
  ctx.restore();
1388
1603
  }
1389
1604
 
@@ -1393,7 +1608,7 @@ function drawMainScene(t) {
1393
1608
  const greet = spring(t, window.DURATION * 0.38, 190, 15);
1394
1609
  const arcSpring = spring(t, window.DURATION * 0.68, 200, 15);
1395
1610
 
1396
- sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1611
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, AMBIENT_LINE, 2);
1397
1612
 
1398
1613
  // Left Clan Member walks toward center
1399
1614
  const leftX = cx - 380 + Math.min(1, approach) * 160;
@@ -1421,10 +1636,10 @@ function drawMainScene(t) {
1421
1636
  seed: 107,
1422
1637
  });
1423
1638
 
1424
- // Glowing connection arc and symbols in Beat 3
1639
+ // Glowing connection arc in Beat 3
1425
1640
  if (arcSpring > 0.05) {
1426
1641
  ctx.save();
1427
- ctx.globalAlpha = Math.min(1, arcSpring);
1642
+ ctx.globalAlpha = Math.min(0.85, arcSpring * 0.85);
1428
1643
  ctx.strokeStyle = ACCENT;
1429
1644
  ctx.lineWidth = 3;
1430
1645
  ctx.setLineDash([8, 8]);
@@ -1434,13 +1649,8 @@ function drawMainScene(t) {
1434
1649
  ctx.stroke();
1435
1650
  ctx.setLineDash([]);
1436
1651
 
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);
1652
+ sCircle(cx, cy - 65, 28, 800, ACCENT, 3, IS_LIGHT ? '#ffffff' : 'rgba(15, 23, 42, 0.85)');
1653
+ sCircle(cx, cy - 65, 12, 801, STROKE_COLOR, 2, STROKE_COLOR);
1444
1654
  ctx.restore();
1445
1655
  }
1446
1656
 
@@ -1460,7 +1670,7 @@ function drawMainScene(t) {
1460
1670
  ctx.fill();
1461
1671
 
1462
1672
  // Large rock boulder for thinker
1463
- ctx.fillStyle = '#334155';
1673
+ ctx.fillStyle = IS_LIGHT ? '#78716c' : '#334155';
1464
1674
  ctx.beginPath();
1465
1675
  ctx.ellipse(cx - 140, cy + 175, 85, 55, 0, 0, Math.PI * 2);
1466
1676
  ctx.fill();
@@ -1485,9 +1695,9 @@ function drawMainScene(t) {
1485
1695
  ctx.translate(cloudX, cloudY);
1486
1696
  ctx.scale(cloudSpring, cloudSpring);
1487
1697
 
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');
1698
+ sCircle(-180, 160, 12, 850, STROKE_COLOR, 2, '#ffffff');
1699
+ sCircle(-140, 120, 18, 851, STROKE_COLOR, 2, '#ffffff');
1700
+ sCircle(-90, 70, 24, 852, STROKE_COLOR, 2, '#ffffff');
1491
1701
 
1492
1702
  ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
1493
1703
  ctx.beginPath();
@@ -1496,6 +1706,9 @@ function drawMainScene(t) {
1496
1706
  ctx.arc(70, 0, 55, 0, Math.PI * 2);
1497
1707
  ctx.arc(0, 30, 60, 0, Math.PI * 2);
1498
1708
  ctx.fill();
1709
+ ctx.strokeStyle = STROKE_COLOR;
1710
+ ctx.lineWidth = 2.5;
1711
+ ctx.stroke();
1499
1712
 
1500
1713
  // Inside thought cloud: Tomorrow's sunrise & stretched hide on rack in Beat 3
1501
1714
  if (ideaSpring > 0.1) {
@@ -1510,11 +1723,6 @@ function drawMainScene(t) {
1510
1723
 
1511
1724
  ctx.fillStyle = '#b45309';
1512
1725
  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
1726
  }
1519
1727
  ctx.restore();
1520
1728
  }
@@ -1524,7 +1732,7 @@ function drawMainScene(t) {
1524
1732
  const scopeSpring = spring(t, window.DURATION * 0.30, 180, 15);
1525
1733
  const treeSpring = spring(t, window.DURATION * 0.65, 200, 15);
1526
1734
 
1527
- sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, 'rgba(255,255,255,0.2)', 2);
1735
+ sLine(cx - W * 0.45, cy + 220, cx + W * 0.45, cy + 220, 10, AMBIENT_LINE, 2);
1528
1736
 
1529
1737
  // Early human scratching pelt with itch lines
1530
1738
  drawDoodleCharacter({
@@ -1573,28 +1781,18 @@ function drawMainScene(t) {
1573
1781
  sLine(-100, 0, -20, 0, 940, '#f8fafc', 4);
1574
1782
  sCircle(-20, 0, 7, 941, '#d4af37', 2, '#d4af37');
1575
1783
 
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);
1784
+ sLine(-20, 0, 75, -45, 942, '#38bdf8', 4);
1785
+ sCircle(82, -45, 8, 943, '#38bdf8', 2, '#38bdf8');
1786
+
1787
+ sLine(-20, 0, 75, 45, 944, '#f43f5e', 4);
1788
+ sCircle(82, 45, 8, 945, '#f43f5e', 2, '#f43f5e');
1591
1789
  }
1592
1790
  ctx.restore();
1593
1791
  }
1594
1792
 
1595
1793
  } else if (COMP_THEME === 'doodle_general_human') {
1596
1794
  // 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);
1795
+ sLine(cx - W * 0.4, cy + 220, cx + W * 0.4, cy + 220, 10, AMBIENT_LINE, 2);
1598
1796
  drawDoodleCharacter({
1599
1797
  x: cx,
1600
1798
  y: cy + 140,
@@ -2084,8 +2282,8 @@ function drawMainScene(t) {
2084
2282
  function drawCinematicOverlay(t) {
2085
2283
  ctx.save();
2086
2284
 
2087
- // Subtitles / Spoken Narration - dynamic sentence progression across time
2088
- if (NARRATION && NARRATION.trim().length > 0) {
2285
+ // Subtitles / Spoken Narration - ONLY rendered when explicitly asked in prompt or options
2286
+ if (SHOW_CAPTIONS && NARRATION && NARRATION.trim().length > 0) {
2089
2287
  // Split narration into natural spoken sentence chunks
2090
2288
  const sentences = NARRATION.split(/[.!?;|]+/).map(s => s.trim()).filter(Boolean);
2091
2289
 
@@ -2234,6 +2432,7 @@ module.exports = {
2234
2432
  generateSceneCodeWithLLM,
2235
2433
  detectAesthetic,
2236
2434
  detectComponentTheme,
2435
+ detectCaptionsRequested,
2237
2436
  getScriptFontStack,
2238
2437
  AESTHETICS,
2239
2438
  REGIONAL_FONTS,