DevForML commited on
Commit
015a372
·
verified ·
1 Parent(s): a6d04c8

Upload 70 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. assests/.DS_Store +0 -0
  3. assests/certification/coursera.png +0 -0
  4. assests/certification/deeplearning.png +0 -0
  5. assests/certification/nptel-logo.png +0 -0
  6. assests/cw2_company.jpg +0 -0
  7. assests/embedding_links/COT_Chain_Of_thought_example.png +0 -0
  8. assests/embedding_links/arena.ai_leaderboard (2).png +3 -0
  9. assests/embedding_links/links.txt +3 -0
  10. assests/embedding_links/mteb-leaderboard.hf.space_.png +3 -0
  11. assests/html_sample/brain_motion_v4.zip +3 -0
  12. assests/html_sample/index1.html +553 -0
  13. assests/html_sample/index2.html +1464 -0
  14. assests/html_sample/index3.html +1587 -0
  15. assests/html_sample/index4.html +1666 -0
  16. assests/html_sample/ping_pong.html +0 -0
  17. assests/illustration/COT_Chain_Of_thought_example.png +0 -0
  18. assests/illustration/agent_working_story.jpg +3 -0
  19. assests/illustration/anthropic-light.svg +1 -0
  20. assests/illustration/docling.svg +305 -0
  21. assests/illustration/huggingface-color.svg +1 -0
  22. assests/illustration/langchain (1).webp +0 -0
  23. assests/illustration/langchain-color.svg +1 -0
  24. assests/illustration/llamaindex-color.svg +1 -0
  25. assests/illustration/rag_indexing.avif +0 -0
  26. assests/illustration/rag_retrieval_generation.avif +0 -0
  27. assests/illustration/smolagent.webp +0 -0
  28. assests/illustration/unstructured-color.svg +1 -0
  29. assests/learning reference/2 blue 3 brown.jpg +0 -0
  30. assests/learning reference/5 minutes engineering.jpg +0 -0
  31. assests/learning reference/analytics_india_magazine_logo.jpg +0 -0
  32. assests/learning reference/arenaai_logo.jpg +0 -0
  33. assests/learning reference/campusx.jpg +0 -0
  34. assests/learning reference/codewithharry.jpg +0 -0
  35. assests/learning reference/evolving_ai_logo.jpg +0 -0
  36. assests/learning reference/graduate_ml.jpg +0 -0
  37. assests/learning reference/krish_naik.jpg +0 -0
  38. assests/learning reference/nptel.jpg +0 -0
  39. assests/learning reference/varun mayya.jpg +0 -0
  40. assests/linkedin bg (4).png +3 -0
  41. assests/linkedin-profile-card.html +166 -0
  42. assests/logos set whites/anthropic (1).svg +1 -0
  43. assests/logos set whites/antigravity-color.svg +1 -0
  44. assests/logos set whites/claude-color.svg +1 -0
  45. assests/logos set whites/codex-color (1).svg +1 -0
  46. assests/logos set whites/deepseek-color.svg +1 -0
  47. assests/logos set whites/gemini-color (1).svg +1 -0
  48. assests/logos set whites/grok.svg +1 -0
  49. assests/logos set whites/huggingface-color.svg +1 -0
  50. assests/logos set whites/kimi-color.svg +1 -0
.gitattributes CHANGED
@@ -38,3 +38,16 @@ assets/user/embedding/mteb-leaderboard.png filter=lfs diff=lfs merge=lfs -text
38
  assets/user/illustration/agent-workflow.jpg filter=lfs diff=lfs merge=lfs -text
39
  assets/user/linkedin/banner.png filter=lfs diff=lfs merge=lfs -text
40
  assets/user/linkedin/profile.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  assets/user/illustration/agent-workflow.jpg filter=lfs diff=lfs merge=lfs -text
39
  assets/user/linkedin/banner.png filter=lfs diff=lfs merge=lfs -text
40
  assets/user/linkedin/profile.png filter=lfs diff=lfs merge=lfs -text
41
+ assests/embedding_links/arena.ai_leaderboard[[:space:]](2).png filter=lfs diff=lfs merge=lfs -text
42
+ assests/embedding_links/mteb-leaderboard.hf.space_.png filter=lfs diff=lfs merge=lfs -text
43
+ assests/illustration/agent_working_story.jpg filter=lfs diff=lfs merge=lfs -text
44
+ assests/linkedin[[:space:]]bg[[:space:]](4).png filter=lfs diff=lfs merge=lfs -text
45
+ assests/profile-pic.png filter=lfs diff=lfs merge=lfs -text
46
+ assests/slide_wise_new/body_animationv1.svg filter=lfs diff=lfs merge=lfs -text
47
+ assests/slide_wise_new/bodyanimationv2.webm filter=lfs diff=lfs merge=lfs -text
48
+ assests/slide_wise_new/brain-1791631278192.svg filter=lfs diff=lfs merge=lfs -text
49
+ assests/slide_wise_new/brain-1791631695827.svg filter=lfs diff=lfs merge=lfs -text
50
+ assests/slide_wise_new/embedding_model.gif filter=lfs diff=lfs merge=lfs -text
51
+ assests/slide_wise_new/mcp_architecture.png filter=lfs diff=lfs merge=lfs -text
52
+ assests/slide_wise_new/mcp_componenet_illustration.png filter=lfs diff=lfs merge=lfs -text
53
+ assests/slide_wise_new/React_chain_of_thought.png filter=lfs diff=lfs merge=lfs -text
assests/.DS_Store ADDED
Binary file (6.15 kB). View file
 
assests/certification/coursera.png ADDED
assests/certification/deeplearning.png ADDED
assests/certification/nptel-logo.png ADDED
assests/cw2_company.jpg ADDED
assests/embedding_links/COT_Chain_Of_thought_example.png ADDED
assests/embedding_links/arena.ai_leaderboard (2).png ADDED

Git LFS Details

  • SHA256: 242dbba55ac27ed8d9ff0815c851fb34a67791aff88c21465fceb0161a0c9296
  • Pointer size: 131 Bytes
  • Size of remote file: 218 kB
assests/embedding_links/links.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ mteb leaderboard - https://mteb-leaderboard.hf.space/
2
+ lmarena leaderboard - https://lmarena-ai-arena-leaderboard.static.hf.space
3
+
assests/embedding_links/mteb-leaderboard.hf.space_.png ADDED

Git LFS Details

  • SHA256: d33d5d8a65277b47d96940986111a2f97cd93a9e401ff7e9e128eaf90c447369
  • Pointer size: 131 Bytes
  • Size of remote file: 176 kB
assests/html_sample/brain_motion_v4.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:48a69c13e697f21cc078a52a27598d65a71aa6d5fa7903ae6c4c3fce5682ad49
3
+ size 48385
assests/html_sample/index1.html ADDED
@@ -0,0 +1,553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Brain Motion — SVG Video Studio + Color Grade</title>
7
+ <style>
8
+ :root{
9
+ color-scheme: dark;
10
+ --bg:#090b11;
11
+ --panel:#131826f2;
12
+ --panel-2:#1b2231;
13
+ --line:#2a3446;
14
+ --text:#eef2fb;
15
+ --muted:#96a3b8;
16
+ --accent:#8d7eff;
17
+ --accent-2:#6358d6;
18
+ --radius:18px;
19
+ }
20
+ *{box-sizing:border-box}
21
+ body{
22
+ margin:0; min-height:100vh; background:var(--bg); color:var(--text);
23
+ font:14px/1.5 Inter,system-ui,Segoe UI,Roboto,sans-serif;
24
+ overflow-x:hidden;
25
+ }
26
+ body:before,body:after{
27
+ content:""; position:fixed; width:70vw; height:70vw; border-radius:50%; filter:blur(90px); opacity:.18; pointer-events:none; z-index:-1;
28
+ }
29
+ body:before{ left:-20vw; top:-20vw; background:radial-gradient(circle at 30% 30%, #8d7eff, transparent 60%)}
30
+ body:after{ right:-15vw; bottom:-10vw; background:radial-gradient(circle at 30% 30%, #3de4c8, #5b6cff 60%)}
31
+ .app{ width:min(1480px,100%); margin:auto; padding:22px 22px 28px}
32
+ .topbar{ display:flex; gap:16px; align-items:center; margin-bottom:18px}
33
+ .logo{
34
+ width:48px; height:48px; border-radius:14px; display:grid; place-items:center;
35
+ background:conic-gradient(from 180deg, #a99cff, #6e64ff, #48e6c2, #a99cff);
36
+ box-shadow:0 8px 30px #7a6dff55; font-size:22px; color:white; flex:0 0 auto
37
+ }
38
+ h1{ margin:0; font-size:clamp(20px,3vw,30px); letter-spacing:-.04em; line-height:1.1}
39
+ .subtitle{ margin:4px 0 0; color:var(--muted)}
40
+ .workspace{ display:grid; grid-template-columns:360px minmax(0,1fr); gap:18px; align-items:start}
41
+ .panel,.stage{ border:1px solid var(--line); border-radius:var(--radius); background:var(--panel); backdrop-filter: blur(18px)}
42
+ .panel{ padding:16px 16px 10px; max-height:calc(100vh - 96px); overflow:auto}
43
+ .stage{ padding:14px}
44
+ .section{ padding:14px 0 16px; border-top:1px solid #222c3b}
45
+ .section:first-child{ border-top:0; padding-top:4px}
46
+ h2{ margin:0 0 10px; font-size:13px; letter-spacing:.14em; text-transform:uppercase; color:#c8d0de}
47
+ .btn{
48
+ min-height:38px; padding:8px 12px; border:1px solid var(--line); border-radius:11px;
49
+ background:var(--panel-2); color:var(--text); cursor:pointer; transition:.15s; font-weight:600
50
+ }
51
+ .btn:hover:not(:disabled){ border-color:#7b72e0; background:#252f42}
52
+ .btn.primary{ background:var(--accent-2); border-color:var(--accent-2); color:white}
53
+ .btn.primary:hover{ background:#756ae8}
54
+ .btn:disabled{ opacity:.45; cursor:not-allowed}
55
+ .button-row{ display:flex; flex-wrap:wrap; gap:8px}
56
+ .small-note,.status,.icon-status{ color:var(--muted); font-size:12px; line-height:1.4}
57
+ .icon-status{ min-height:18px; margin:8px 0 0; overflow-wrap:anywhere}
58
+ .control{ margin:12px 0}
59
+ .control-label{ display:flex; justify-content:space-between; gap:10px; margin-bottom:5px; color:#d6deea; font-size:13px}
60
+ .control-label output{ color:#b8aaff; font-variant-numeric:tabular-nums}
61
+ input[type=range]{ width:100%; accent-color:var(--accent)}
62
+ select,input[type=number]{ width:100%; min-height:38px; padding:7px 10px; border:1px solid var(--line); border-radius:10px; background:#101522; color:var(--text)}
63
+ input[type=color]{ width:46px; height:34px; padding:2px; border:1px solid var(--line); border-radius:8px; background:#101522}
64
+ .toggle-row{ display:flex; flex-wrap:wrap; gap:12px; margin:10px 0}
65
+ .toggle{ display:flex; gap:8px; align-items:center; color:#d6deea}
66
+ .toggle input{ accent-color:var(--accent)}
67
+ .color-row{ display:flex; justify-content:space-between; align-items:center; gap:10px}
68
+ .export-grid{ display:grid; grid-template-columns:1fr 1fr; gap:8px}
69
+ .export-grid .btn{ min-height:48px}
70
+ .preview{
71
+ position:relative; display:grid; place-items:center; min-height:360px; overflow:hidden;
72
+ border:1px solid #2d384a; border-radius:14px;
73
+ background-color:#10141c;
74
+ background-image: linear-gradient(45deg,#1a2130 25%,transparent 25%), linear-gradient(-45deg,#1a2130 25%,transparent 25%), linear-gradient(45deg,transparent 75%,#1a2130 75%), linear-gradient(-45deg,transparent 75%,#1a2130 75%);
75
+ background-size:22px 22px; background-position:0 0,0 11px,11px -11px,-11px 0
76
+ }
77
+ canvas{ display:block; width:100%; height:auto; max-height:72vh; object-fit:contain}
78
+ .placeholder{ position:absolute; padding:20px; text-align:center; color:var(--muted); pointer-events:none}
79
+ .timeline{ display:grid; grid-template-columns:auto 1fr auto; gap:10px; align-items:center; margin-top:12px}
80
+ .time-label{ min-width:78px; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums}
81
+ .hidden-input,#sourceVideo{ display:none}
82
+ .chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; background:#1e2635; border:1px solid #2a3446; font-size:11px; color:#b9c4d6}
83
+ @media(max-width:980px){ .workspace{ grid-template-columns:1fr} .panel{ max-height:none} .preview{ min-height:240px} }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div class="app">
88
+ <header class="topbar">
89
+ <div class="logo">✦</div>
90
+ <div>
91
+ <h1>Brain Motion — SVG Video Studio</h1>
92
+ <p class="subtitle">Video → SVG cells with transparent export + full color grading. Works as a static site.</p>
93
+ </div>
94
+ <span class="chip">Fable Edition • Color Grade</span>
95
+ </header>
96
+
97
+ <div class="workspace">
98
+ <aside class="panel">
99
+ <section class="section">
100
+ <h2>1. Media</h2>
101
+ <div class="button-row">
102
+ <button class="btn primary" id="uploadVideoBtn" data-lock>Choose video / image</button>
103
+ <button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
104
+ <button class="btn" id="clearSvgBtn" data-lock>Clear</button>
105
+ </div>
106
+ <input class="hidden-input" id="videoInput" type="file" accept="video/*,image/*">
107
+ <input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple>
108
+ <video id="sourceVideo" playsinline muted></video>
109
+ <p class="icon-status" id="iconStatus">No SVGs — fallback shapes will be used.</p>
110
+ <p class="small-note">Drop any number of SVG files. Each cell cycles through them. You can also drop an image — it will be treated as a still.</p>
111
+ </section>
112
+
113
+ <section class="section">
114
+ <h2>2. Cells</h2>
115
+ <div class="control"><label class="control-label" for="cellSize"><span>Spacing</span><output id="cellValue">14 px</output></label><input id="cellSize" data-lock type="range" min="6" max="40" value="14"></div>
116
+ <div class="control"><label class="control-label" for="scale"><span>Size</span><output id="scaleValue">0.95</output></label><input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95"></div>
117
+ <div class="control"><label class="control-label" for="gap"><span>Gap</span><output id="gapValue">1 px</output></label><input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1"></div>
118
+ <div class="control"><label class="control-label" for="sizeMode">Size follows</label><select id="sizeMode" data-lock><option value="bright">Bright = larger</option><option value="dark">Dark = larger</option></select></div>
119
+ <div class="control"><label class="control-label" for="fallbackShape">Fallback shape</label><select id="fallbackShape" data-lock><option value="circle">Circle</option><option value="square">Square</option><option value="triangle">Triangle</option><option value="star">Star</option><option value="diamond">Diamond</option><option value="cross">Cross</option></select></div>
120
+ <div class="control"><label class="control-label" for="colorMode">SVG tint</label><select id="colorMode" data-lock><option value="sample">Tint to video color</option><option value="custom">Custom Color / Palette</option><option value="shadesBlack">Shades of Black</option><option value="white">White</option><option value="native">Keep SVG colors</option></select></div>
121
+ <div class="color-row" id="customColorRow" style="margin:8px 0; display:none"><label for="customColor">Tint color</label><input id="customColor" data-lock type="color" value="#050505"></div>
122
+ <div class="toggle-row"><label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by luma</label></div>
123
+ </section>
124
+
125
+ <section class="section">
126
+ <h2>3. Color Grading</h2>
127
+ <div class="control"><label class="control-label" for="gradePreset">Preset</label><select id="gradePreset" data-lock>
128
+ <option value="neutral">Neutral</option>
129
+ <option value="warmFilm">Warm Film</option>
130
+ <option value="coolTech">Cool Tech</option>
131
+ <option value="cyberpunk">Cyberpunk</option>
132
+ <option value="fable">Fable Pastel</option>
133
+ <option value="sunset">Sunset</option>
134
+ <option value="bwHigh">B&W High Contrast</option>
135
+ </select></div>
136
+ <div class="toggle-row">
137
+ <label class="toggle"><input id="enableGrade" data-lock type="checkbox" checked> Enable grading</label>
138
+ <button class="btn" id="resetGrade" data-lock>Reset</button>
139
+ </div>
140
+
141
+ <div class="control"><label class="control-label" for="exposure"><span>Exposure</span><output id="exposureValue">0</output></label><input id="exposure" data-lock type="range" min="-100" max="100" value="0"></div>
142
+ <div class="control"><label class="control-label" for="brightness"><span>Brightness</span><output id="brightnessValue">0</output></label><input id="brightness" data-lock type="range" min="-100" max="100" value="0"></div>
143
+ <div class="control"><label class="control-label" for="contrast"><span>Contrast</span><output id="contrastValue">0</output></label><input id="contrast" data-lock type="range" min="-100" max="100" value="0"></div>
144
+ <div class="control"><label class="control-label" for="saturation"><span>Saturation</span><output id="saturationValue">100%</output></label><input id="saturation" data-lock type="range" min="0" max="200" value="100"></div>
145
+ <div class="control"><label class="control-label" for="hue"><span>Hue shift</span><output id="hueValue">0°</output></label><input id="hue" data-lock type="range" min="-180" max="180" value="0"></div>
146
+ <div class="control"><label class="control-label" for="temperature"><span>Temperature</span><output id="temperatureValue">0</output></label><input id="temperature" data-lock type="range" min="-100" max="100" value="0"></div>
147
+ <div class="control"><label class="control-label" for="tint"><span>Tint (G/M)</span><output id="tintValue">0</output></label><input id="tint" data-lock type="range" min="-100" max="100" value="0"></div>
148
+ <div class="control"><label class="control-label" for="vibrance"><span>Vibrance</span><output id="vibranceValue">0</output></label><input id="vibrance" data-lock type="range" min="-100" max="100" value="0"></div>
149
+ <div class="control"><label class="control-label" for="highlights"><span>Highlights</span><output id="highlightsValue">0</output></label><input id="highlights" data-lock type="range" min="-100" max="100" value="0"></div>
150
+ <div class="control"><label class="control-label" for="shadows"><span>Shadows</span><output id="shadowsValue">0</output></label><input id="shadows" data-lock type="range" min="-100" max="100" value="0"></div>
151
+ <p class="small-note">Grading is applied before cell sampling, so it affects tint + size logic.</p>
152
+ </section>
153
+
154
+ <section class="section">
155
+ <h2>4. Background & Export</h2>
156
+ <div class="toggle-row"><label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent bg</label></div>
157
+ <div class="color-row"><label for="bgColor">Opaque bg color</label><input id="bgColor" data-lock type="color" value="#10141b"></div>
158
+ <div class="control"><label class="control-label" for="backgroundMode">Cut out</label><select id="backgroundMode" data-lock><option value="dark">Dark pixels</option><option value="light">Light pixels</option><option value="none">None</option></select></div>
159
+ <div class="control"><label class="control-label" for="cutoff"><span>Cut threshold</span><output id="cutoffValue">5%</output></label><input id="cutoff" data-lock type="range" min="0" max="95" value="5"></div>
160
+ <div class="control"><label class="control-label" for="outputWidth">Output width</label><input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="640"></div>
161
+ <div class="control"><label class="control-label" for="duration">Export seconds</label><input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4"></div>
162
+ <div class="control"><label class="control-label" for="fps">Export FPS</label><input id="fps" data-lock type="number" min="4" max="20" step="1" value="12"></div>
163
+ <div class="button-row" style="margin:12px 0 8px"><button class="btn" id="playPause" disabled data-lock>Play</button><button class="btn" id="saveFrame" data-lock>Save PNG</button></div>
164
+ <div class="export-grid">
165
+ <button class="btn primary" id="exportSvg" disabled data-lock>Animated SVG</button>
166
+ <button class="btn" id="exportGif" disabled data-lock>GIF</button>
167
+ <button class="btn" id="exportWebm" disabled data-lock>WebM</button>
168
+ <button class="btn" id="exportMp4" disabled data-lock>MP4</button>
169
+ </div>
170
+ <p class="small-note">GIF = 1-bit transparency. MP4 = always opaque. WebM alpha = browser dependent.</p>
171
+ </section>
172
+ </aside>
173
+
174
+ <section class="stage">
175
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
176
+ <h2 style="margin:0">Preview</h2><span class="small-note">Checker = transparent</span>
177
+ </div>
178
+ <div class="preview" id="previewBox">
179
+ <canvas id="canvas" width="640" height="360"></canvas>
180
+ <div class="placeholder" id="placeholder">Choose a video or image.<br>Add SVG icons to use them as cells.</div>
181
+ </div>
182
+ <div class="timeline">
183
+ <button class="btn" id="timelinePlay" disabled data-lock>Play</button>
184
+ <input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock>
185
+ <span class="time-label" id="timeLabel">0:00 / 0:00</span>
186
+ </div>
187
+ <p class="status" id="status">Ready.</p>
188
+ </section>
189
+ </div>
190
+ </div>
191
+
192
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
193
+ <script>
194
+ (() => {
195
+ 'use strict';
196
+ const $ = id => document.getElementById(id);
197
+ const video = $('sourceVideo');
198
+ const canvas = $('canvas');
199
+ const ctx = canvas.getContext('2d',{alpha:true});
200
+ const sampleCanvas = document.createElement('canvas');
201
+ const sampleCtx = sampleCanvas.getContext('2d',{willReadFrequently:true});
202
+ const tintCanvas = document.createElement('canvas'); tintCanvas.width=32; tintCanvas.height=32;
203
+ const tintCtx = tintCanvas.getContext('2d');
204
+ let videoUrl=null, svgAssets=[], busy=false, recordingOpaque=false, lastRenderedTime=-1;
205
+ let sourceImage=null, isImageMode=false;
206
+
207
+ const GRADE_PRESETS={
208
+ neutral:{exposure:0,brightness:0,contrast:0,saturation:100,hue:0,temperature:0,tint:0,vibrance:0,highlights:0,shadows:0},
209
+ warmFilm:{exposure:5,brightness:4,contrast:8,saturation:110,hue:0,temperature:32,tint:-6,vibrance:18,highlights:-8,shadows:14},
210
+ coolTech:{exposure:0,brightness:0,contrast:14,saturation:92,hue:4,temperature:-38,tint:8,vibrance:6,highlights:0,shadows:8},
211
+ cyberpunk:{exposure:8,brightness:2,contrast:22,saturation:145,hue:-18,temperature:8,tint:-14,vibrance:32,highlights:-12,shadows:10},
212
+ fable:{exposure:6,brightness:8,contrast:-8,saturation:86,hue:2,temperature:14,tint:4,vibrance:-12,highlights:-14,shadows:12},
213
+ sunset:{exposure:10,brightness:6,contrast:12,saturation:128,hue:-8,temperature:58,tint:-8,vibrance:22,highlights:-10,shadows:16},
214
+ bwHigh:{exposure:4,brightness:2,contrast:38,saturation:0,hue:0,temperature:0,tint:0,vibrance:0,highlights:-6,shadows:10}
215
+ };
216
+
217
+ function setStatus(m){ $('status').textContent=m }
218
+ function clamp(v,a,b){ return Math.max(a,Math.min(b,v)) }
219
+ function clamp01(v){ return Math.max(0,Math.min(1,v)) }
220
+
221
+ function rgb01ToHsl(r,g,b){
222
+ const max=Math.max(r,g,b), min=Math.min(r,g,b);
223
+ let h=0,s=0,l=(max+min)/2;
224
+ if(max!==min){
225
+ const d=max-min;
226
+ s=l>0.5? d/(2-max-min): d/(max+min);
227
+ switch(max){
228
+ case r: h=(g-b)/d + (g<b?6:0); break;
229
+ case g: h=(b-r)/d +2; break;
230
+ case b: h=(r-g)/d +4; break;
231
+ }
232
+ h/=6;
233
+ }
234
+ return [h,s,l];
235
+ }
236
+ function hslToRgb01(h,s,l){
237
+ let r,g,b;
238
+ if(s===0){ r=g=b=l; }
239
+ else{
240
+ const hue2rgb=(p,q,t)=>{ if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<1/2)return q; if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; };
241
+ const q=l<0.5? l*(1+s): l+s-l*s; const p=2*l-q;
242
+ r=hue2rgb(p,q,h+1/3); g=hue2rgb(p,q,h); b=hue2rgb(p,q,h-1/3);
243
+ }
244
+ return [r,g,b];
245
+ }
246
+ function applyGrading(r,g,b,o){
247
+ let R=r/255,G=g/255,B=b/255;
248
+ const expFactor=Math.pow(2,o.exposure/100);
249
+ R*=expFactor; G*=expFactor; B*=expFactor;
250
+ const bright=o.brightness/100*0.35; R+=bright; G+=bright; B+=bright;
251
+ const temp=o.temperature/100;
252
+ if(temp>0){ R+=temp*0.18; G+=temp*0.04; B-=temp*0.14; } else { R+=temp*0.10; B-=temp*0.18; }
253
+ const tint=o.tint/100; G+=tint*0.14; R-=tint*0.05; B-=tint*0.05;
254
+ const cf=(259*(o.contrast+255))/(255*(259-o.contrast));
255
+ R=cf*(R-0.5)+0.5; G=cf*(G-0.5)+0.5; B=cf*(B-0.5)+0.5;
256
+ const luma=0.299*R+0.587*G+0.114*B;
257
+ const sh=o.shadows/100, hi=o.highlights/100;
258
+ if(luma<0.5){ const f=(0.5-luma)*1.6; R+=sh*f*0.22; G+=sh*f*0.22; B+=sh*f*0.22; }
259
+ else { const f=(luma-0.5)*1.6; R+=hi*f*0.22; G+=hi*f*0.22; B+=hi*f*0.22; }
260
+ let hsl=rgb01ToHsl(clamp01(R),clamp01(G),clamp01(B));
261
+ let h=hsl[0], s=hsl[1], l=hsl[2];
262
+ h=(h+o.hue/360)%1; if(h<0)h+=1;
263
+ s*=(o.saturation/100);
264
+ const vib=o.vibrance/100;
265
+ if(vib!==0){ s+= (1-s)*vib*0.7; }
266
+ s=clamp01(s); l=clamp01(l);
267
+ const rgb=hslToRgb01(h,s,l);
268
+ return [clamp01(rgb[0])*255, clamp01(rgb[1])*255, clamp01(rgb[2])*255];
269
+ }
270
+ function getGradeOpts(){
271
+ return {
272
+ enabled:$('enableGrade').checked,
273
+ exposure:+$('exposure').value, brightness:+$('brightness').value, contrast:+$('contrast').value,
274
+ saturation:+$('saturation').value, hue:+$('hue').value, temperature:+$('temperature').value,
275
+ tint:+$('tint').value, vibrance:+$('vibrance').value, highlights:+$('highlights').value, shadows:+$('shadows').value
276
+ };
277
+ }
278
+ function applyPreset(name){
279
+ const p=GRADE_PRESETS[name]; if(!p)return;
280
+ for(const k in p){ const el=$(k); if(el) el.value=p[k]; }
281
+ updateLabels(); renderSafely();
282
+ }
283
+ function updateLabels(){
284
+ $('cellValue').textContent=`${$('cellSize').value} px`;
285
+ $('scaleValue').textContent=(+$('scale').value).toFixed(2);
286
+ $('gapValue').textContent=`${$('gap').value} px`;
287
+ $('cutoffValue').textContent=`${$('cutoff').value}%`;
288
+ $('exposureValue').textContent=`${$('exposure').value>0?'+':''}${$('exposure').value}`;
289
+ $('brightnessValue').textContent=`${$('brightness').value>0?'+':''}${$('brightness').value}`;
290
+ $('contrastValue').textContent=`${$('contrast').value>0?'+':''}${$('contrast').value}`;
291
+ $('saturationValue').textContent=`${$('saturation').value}%`;
292
+ $('hueValue').textContent=`${$('hue').value}°`;
293
+ $('temperatureValue').textContent=`${$('temperature').value>0?'+':''}${$('temperature').value}`;
294
+ $('tintValue').textContent=`${$('tint').value>0?'+':''}${$('tint').value}`;
295
+ $('vibranceValue').textContent=`${$('vibrance').value>0?'+':''}${$('vibrance').value}`;
296
+ $('highlightsValue').textContent=`${$('highlights').value>0?'+':''}${$('highlights').value}`;
297
+ $('shadowsValue').textContent=`${$('shadows').value>0?'+':''}${$('shadows').value}`;
298
+ }
299
+ function resizeCanvas(){
300
+ const w = isImageMode ? (sourceImage?.naturalWidth||640) : (video.videoWidth||640);
301
+ const h = isImageMode ? (sourceImage?.naturalHeight||360) : (video.videoHeight||360);
302
+ if(!w||!h)return;
303
+ const wantW=clamp(+$('outputWidth').value||640,240,1280);
304
+ const ratio=Math.min(wantW/w,1280/h);
305
+ const nw=Math.max(1,Math.round(w*ratio)), nh=Math.max(1,Math.round(h*ratio));
306
+ if(canvas.width!==nw||canvas.height!==nh){ canvas.width=nw; canvas.height=nh; sampleCanvas.width=nw; sampleCanvas.height=nh; }
307
+ }
308
+ function drawAssetCentered(tCtx, asset, box){
309
+ const iw=asset.width||asset.image.naturalWidth||100, ih=asset.height||asset.image.naturalHeight||100;
310
+ const s=Math.min(box/iw,box/ih); tCtx.drawImage(asset.image,-iw*s/2,-ih*s/2,iw*s,ih*s);
311
+ }
312
+ function drawBuiltin(tCtx,type,size,color){
313
+ const r=size/2; tCtx.fillStyle=color; tCtx.beginPath();
314
+ switch(type){
315
+ case 'square': tCtx.rect(-r,-r,size,size); break;
316
+ case 'triangle': tCtx.moveTo(0,-r); tCtx.lineTo(r,r); tCtx.lineTo(-r,r); tCtx.closePath(); break;
317
+ case 'star': for(let i=0;i<10;i++){ const a=-Math.PI/2+i*Math.PI/5, rad=i%2===0?r:r*0.43, x=Math.cos(a)*rad, y=Math.sin(a)*rad; i? tCtx.lineTo(x,y): tCtx.moveTo(x,y);} tCtx.closePath(); break;
318
+ case 'diamond': tCtx.moveTo(0,-r); tCtx.lineTo(r,0); tCtx.lineTo(0,r); tCtx.lineTo(-r,0); tCtx.closePath(); break;
319
+ case 'cross': tCtx.rect(-r*0.28,-r,r*0.56,size); tCtx.rect(-r,-r*0.28,size,r*0.56); break;
320
+ default: tCtx.arc(0,0,r,0,Math.PI*2);
321
+ } tCtx.fill();
322
+ }
323
+ function drawTintedAsset(asset,color,size){
324
+ tintCtx.clearRect(0,0,32,32); tintCtx.save(); tintCtx.translate(16,16); drawAssetCentered(tintCtx,asset,30); tintCtx.restore();
325
+ tintCtx.globalCompositeOperation='source-in'; tintCtx.fillStyle=color; tintCtx.fillRect(0,0,32,32); tintCtx.globalCompositeOperation='source-over';
326
+ ctx.drawImage(tintCanvas,-size/2,-size/2,size,size);
327
+ }
328
+ function syncButtons(){
329
+ const has=isImageMode||!!video.videoWidth;
330
+ $('playPause').disabled=busy||!has||isImageMode;
331
+ $('timelinePlay').disabled=busy||!has||isImageMode;
332
+ $('seek').disabled=busy||!has||isImageMode;
333
+ ['exportSvg','exportGif','exportWebm','exportMp4'].forEach(id=>$(id).disabled=busy||!has);
334
+ }
335
+ function setBusy(v){ busy=v; document.querySelectorAll('[data-lock]').forEach(el=>el.disabled=v); syncButtons(); }
336
+
337
+ function renderFrame(forceOpaque=false){
338
+ if(isImageMode){
339
+ if(!sourceImage) return;
340
+ resizeCanvas(); const W=canvas.width,H=canvas.height;
341
+ sampleCtx.drawImage(sourceImage,0,0,W,H);
342
+ } else {
343
+ if(!video.videoWidth||video.readyState<2) return;
344
+ resizeCanvas();
345
+ sampleCtx.drawImage(video,0,0,canvas.width,canvas.height);
346
+ }
347
+ const W=canvas.width,H=canvas.height;
348
+ const pixels=sampleCtx.getImageData(0,0,W,H).data;
349
+ ctx.clearRect(0,0,W,H);
350
+ const isTrans=$('transparent').checked && !forceOpaque;
351
+ if(!isTrans){ ctx.fillStyle=$('bgColor').value; ctx.fillRect(0,0,W,H); }
352
+ const cell=+$('cellSize').value, scaleSet=+$('scale').value, gap=+$('gap').value;
353
+ const cutoff=+$('cutoff').value/100, cols=Math.ceil(W/cell), rows=Math.ceil(H/cell);
354
+ const sizeMode=$('sizeMode').value, bgMode=$('backgroundMode').value, colorMode=$('colorMode').value, rot=$('rotate').checked, fallback=$('fallbackShape').value;
355
+ const grade=getGradeOpts();
356
+ const contrastFactor=(259*(grade.contrast+255))/(255*(259-grade.contrast));
357
+
358
+ for(let r=0;r<rows;r++){
359
+ const y=r*cell;
360
+ for(let c=0;c<cols;c++){
361
+ const x=c*cell, sx=Math.min(W-1,Math.floor(x+cell/2)), sy=Math.min(H-1,Math.floor(y+cell/2)), idx=(sy*W+sx)*4;
362
+ const a=pixels[idx+3]/255; if(a<0.04) continue;
363
+ let R=pixels[idx], G=pixels[idx+1], B=pixels[idx+2];
364
+ if(grade.enabled){ [R,G,B]=applyGrading(R,G,B,grade); }
365
+ else { R=clamp(contrastFactor*(R-128)+128,0,255); G=clamp(contrastFactor*(G-128)+128,0,255); B=clamp(contrastFactor*(B-128)+128,0,255); }
366
+ const luma=(0.299*R+0.587*G+0.114*B)/255;
367
+ if(bgMode==='dark'&&luma<cutoff) continue;
368
+ if(bgMode==='light'&&luma>1-cutoff) continue;
369
+ const bright=sizeMode==='dark'?1-luma:luma;
370
+ const size=Math.min(cell*1.8, Math.max(0, cell*scaleSet*(0.12+0.88*bright)-gap));
371
+ const customTint = $('customColor').value || '#050505';
372
+ let col;
373
+ if(colorMode==='white') col = '#ffffff';
374
+ else if(colorMode==='custom') col = customTint;
375
+ else if(colorMode==='shadesBlack') {
376
+ const shade = Math.round(luma * 45);
377
+ col = `rgb(${shade},${shade},${shade})`;
378
+ } else col = `rgb(${Math.round(R)},${Math.round(G)},${Math.round(B)})`;
379
+
380
+ const asset=svgAssets.length? svgAssets[(r*cols+c)%svgAssets.length]: null;
381
+ ctx.save(); ctx.translate(x+cell/2,y+cell/2); ctx.globalAlpha=a;
382
+ if(rot) ctx.rotate((luma-0.5)*Math.PI);
383
+ if(asset){ if(colorMode==='native') drawAssetCentered(ctx,asset,size); else drawTintedAsset(asset,col,size); }
384
+ else drawBuiltin(ctx,fallback,size,col);
385
+ ctx.restore();
386
+ }
387
+ }
388
+ if(!isImageMode) lastRenderedTime=video.currentTime;
389
+ $('placeholder').style.display='none';
390
+ }
391
+ function renderSafely(){ try{ renderFrame(recordingOpaque); }catch(e){ console.error(e); setStatus('Preview error: '+e.message); } }
392
+
393
+ function sanitizeSvg(src){
394
+ const doc=new DOMParser().parseFromString(src,'image/svg+xml');
395
+ if(doc.querySelector('parsererror')) throw new Error('Invalid SVG');
396
+ const root=doc.documentElement; if(!root||root.localName.toLowerCase()!=='svg') throw new Error('No <svg>');
397
+ const blocked=new Set(['script','foreignobject','iframe','object','embed']);
398
+ for(const el of [...root.querySelectorAll('*')]){
399
+ if(blocked.has(el.localName.toLowerCase())){ el.remove(); continue; }
400
+ for(const at of [...el.attributes]){
401
+ const n=at.name.toLowerCase(), v=at.value.trim();
402
+ if(n.startsWith('on')) el.removeAttributeNode(at);
403
+ else if((n==='href'||n==='xlink:href'||n==='src')&&v&&!v.startsWith('#')&&!/^data:image\//i.test(v)) el.removeAttributeNode(at);
404
+ else if(/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(v)) el.removeAttributeNode(at);
405
+ }
406
+ }
407
+ for(const st of [...root.querySelectorAll('style')]) if(/@import|url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(st.textContent)) st.remove();
408
+ if(!root.hasAttribute('xmlns')) root.setAttribute('xmlns','http://www.w3.org/2000/svg');
409
+ let vb=root.getAttribute('viewBox')||''; let vals=vb.trim().split(/[\s,]+/).map(Number);
410
+ let vw=100,vh=100;
411
+ if(vals.length===4&&vals.every(Number.isFinite)&&vals[2]>0&&vals[3]>0){ vw=vals[2]; vh=vals[3]; }
412
+ else { vw=parseFloat(root.getAttribute('width'))||100; vh=parseFloat(root.getAttribute('height'))||100; root.setAttribute('viewBox',`0 0 ${vw} ${vh}`); }
413
+ root.setAttribute('width',String(vw)); root.setAttribute('height',String(vh));
414
+ return {text:new XMLSerializer().serializeToString(root), width:vw, height:vh};
415
+ }
416
+ function loadSvgImage(file,parsed){
417
+ const blob=new Blob([parsed.text],{type:'image/svg+xml;charset=utf-8'}); const url=URL.createObjectURL(blob);
418
+ return new Promise((res,rej)=>{ const img=new Image(); img.onload=()=>res({name:file.name,image:img,url,width:parsed.width,height:parsed.height}); img.onerror=()=>{ URL.revokeObjectURL(url); rej(new Error('Cannot load '+file.name)); }; img.src=url; });
419
+ }
420
+ function updateIconStatus(){
421
+ if(!svgAssets.length){ $('iconStatus').textContent='No SVGs — fallback shapes.'; return; }
422
+ $('iconStatus').textContent=`${svgAssets.length} SVG(s): ${svgAssets.map(a=>a.name).join(', ')}`;
423
+ }
424
+ function clearSvg(){ for(const a of svgAssets) URL.revokeObjectURL(a.url); svgAssets=[]; updateIconStatus(); renderSafely(); }
425
+
426
+ function formatTime(s){ if(!Number.isFinite(s)||s<0)s=0; const m=Math.floor(s/60), sec=Math.floor(s%60); return `${m}:${String(sec).padStart(2,'0')}`; }
427
+ function updateTimeline(){ if(!video.videoWidth||!Number.isFinite(video.duration)||video.duration<=0){ $('timeLabel').textContent='0:00 / 0:00'; return;} $('seek').value=String(Math.round(video.currentTime/video.duration*1000)); $('timeLabel').textContent=`${formatTime(video.currentTime)} / ${formatTime(video.duration)}`; }
428
+ function seekTo(sec){ return new Promise((res,rej)=>{ const d=video.duration, max=Number.isFinite(d)&&d>0? Math.max(0,d-0.01): Math.max(0,sec); const t=clamp(sec,0,max); if(!video.seeking&&Math.abs(video.currentTime-t)<0.001) return res(); let timer; const fin=()=>{ clearTimeout(timer); video.removeEventListener('seeked',fin); res(); }; video.addEventListener('seeked',fin); timer=setTimeout(fin,8000); try{ video.currentTime=t; }catch(e){ clearTimeout(timer); video.removeEventListener('seeked',fin); rej(e);} }); }
429
+ function exportSettings(){ const fps=clamp(Math.round(+$('fps').value||12),4,20), secs=clamp(+$('duration').value||4,0.5,15), fc=Math.max(1,Math.ceil(secs*fps)); return {fps,frameCount:fc,duration:fc/fps}; }
430
+ async function captureFrames(onFrame,label){
431
+ if(!video.videoWidth && !isImageMode) throw new Error('Choose media first');
432
+ const st=exportSettings(); const wasPlaying=!isImageMode&&!video.paused; const prev=video.currentTime; const md=Number.isFinite(video.duration)&&video.duration>0? video.duration:null;
433
+ if(!isImageMode) video.pause();
434
+ try{
435
+ for(let i=0;i<st.frameCount;i++){
436
+ if(!isImageMode){
437
+ const time=md? (i/st.fps)%md : i/st.fps; await seekTo(time); await new Promise(r=>requestAnimationFrame(r));
438
+ }
439
+ renderFrame(false); await onFrame(i,{...st,width:canvas.width,height:canvas.height}); setStatus(`${label}: frame ${i+1}/${st.frameCount}`);
440
+ }
441
+ return {...st,width:canvas.width,height:canvas.height};
442
+ } finally {
443
+ if(!isImageMode){ try{ await seekTo(prev);}catch(_){} if(wasPlaying) await video.play().catch(()=>{}); renderSafely(); updateTimeline(); }
444
+ }
445
+ }
446
+ function downloadBlob(blob,name){ const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),60000); }
447
+ function numberText(v){ return Number(v.toFixed(6)).toString(); }
448
+ function makeAnimatedSvg(frames,w,h,fps){
449
+ const c=frames.length, dur=c/fps, durT=numberText(dur);
450
+ const els=frames.map((d,i)=>{
451
+ let anim=''; if(c>1){ let vals,kt; if(i===0){ vals='visible;hidden;hidden'; kt=`0;${numberText(1/c)};1`; } else if(i===c-1){ vals='hidden;visible;hidden'; kt=`0;${numberText(i/c)};1`; } else { vals='hidden;visible;hidden;hidden'; kt=`0;${numberText(i/c)};${numberText((i+1)/c)};1`; } anim=`<animate attributeName="visibility" calcMode="discrete" values="${vals}" keyTimes="${kt}" dur="${durT}s" repeatCount="indefinite"/>`; }
452
+ const vis=i===0?'visible':'hidden'; return `<image x="0" y="0" width="${w}" height="${h}" visibility="${vis}" href="${d}" xlink:href="${d}">${anim}</image>`;
453
+ }).join('\n');
454
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n${els}\n</svg>`;
455
+ }
456
+
457
+ $('uploadVideoBtn').addEventListener('click',()=>$('videoInput').click());
458
+ $('addSvgBtn').addEventListener('click',()=>$('svgInput').click());
459
+ $('videoInput').addEventListener('change',e=>{
460
+ const file=e.target.files&&e.target.files[0]; if(!file)return;
461
+ if(file.type.startsWith('image/')){
462
+ isImageMode=true; const url=URL.createObjectURL(file); const img=new Image();
463
+ img.onload=()=>{ sourceImage=img; resizeCanvas(); $('placeholder').style.display='none'; renderSafely(); syncButtons(); setStatus(`Image loaded: ${img.naturalWidth}×${img.naturalHeight}`); URL.revokeObjectURL(url); };
464
+ img.onerror=()=>setStatus('Cannot read image'); img.src=url;
465
+ } else {
466
+ isImageMode=false; sourceImage=null; video.pause(); if(videoUrl) URL.revokeObjectURL(videoUrl); videoUrl=URL.createObjectURL(file);
467
+ video.src=videoUrl; video.loop=true; video.muted=true; video.load(); $('placeholder').style.display='block'; setStatus(`Loading ${file.name}…`);
468
+ }
469
+ e.target.value='';
470
+ });
471
+ video.addEventListener('loadedmetadata',()=>{ resizeCanvas(); $('placeholder').style.display='none'; updateTimeline(); renderSafely(); syncButtons(); setStatus(`Video ${video.videoWidth}×${video.videoHeight}`); video.play().catch(()=>setStatus('Video loaded. Press Play.')); });
472
+ video.addEventListener('loadeddata',renderSafely); video.addEventListener('seeked',()=>{ renderSafely(); updateTimeline(); }); video.addEventListener('timeupdate',updateTimeline);
473
+ video.addEventListener('play',()=>{ $('playPause').textContent='Pause'; $('timelinePlay').textContent='Pause'; });
474
+ video.addEventListener('pause',()=>{ $('playPause').textContent='Play'; $('timelinePlay').textContent='Play'; renderSafely(); });
475
+ $('svgInput').addEventListener('change',async e=>{
476
+ const files=[...(e.target.files||[])]; e.target.value=''; let added=0;
477
+ for(const f of files){ if(!f.name.toLowerCase().endsWith('.svg')&&f.type!=='image/svg+xml') continue; try{ const parsed=sanitizeSvg(await f.text()); const asset=await loadSvgImage(f,parsed); svgAssets.push(asset); added++; }catch(err){ setStatus(err.message); } }
478
+ updateIconStatus(); renderSafely(); if(added) setStatus(`Added ${added} SVG(s)`);
479
+ });
480
+ $('clearSvgBtn').addEventListener('click',clearSvg);
481
+ function togglePlay(){ if(isImageMode||!video.videoWidth) return; if(video.paused) video.play().catch(()=>setStatus('Cannot play')); else video.pause(); }
482
+ $('playPause').addEventListener('click',togglePlay); $('timelinePlay').addEventListener('click',togglePlay);
483
+ $('seek').addEventListener('input',e=>{ if(isImageMode||!video.videoWidth||!Number.isFinite(video.duration))return; video.pause(); video.currentTime=(+e.target.value/1000)*video.duration; });
484
+
485
+ const allControls=['cellSize','scale','gap','sizeMode','fallbackShape','colorMode','customColor','rotate','transparent','bgColor','backgroundMode','cutoff','outputWidth','exposure','brightness','contrast','saturation','hue','temperature','tint','vibrance','highlights','shadows','enableGrade'];
486
+ for(const id of allControls){ const el=$(id); if(!el)continue; el.addEventListener('input',()=>{ updateLabels(); renderSafely(); }); el.addEventListener('change',()=>{ updateLabels(); renderSafely(); }); }
487
+ $('colorMode').addEventListener('change', e => {
488
+ $('customColorRow').style.display = e.target.value === 'custom' ? 'flex' : 'none';
489
+ });
490
+ $('gradePreset').addEventListener('change',e=>applyPreset(e.target.value));
491
+ $('resetGrade').addEventListener('click',()=>applyPreset('neutral'));
492
+
493
+ $('saveFrame').addEventListener('click',async()=>{
494
+ if(!canvas.width){ setStatus('No frame'); return; }
495
+ const blob=await new Promise(r=>canvas.toBlob(r,'image/png')); if(!blob){ setStatus('PNG fail'); return; }
496
+ downloadBlob(blob,`brain-${Date.now()}.png`); setStatus('Saved PNG with transparency');
497
+ });
498
+ $('exportSvg').addEventListener('click',async()=>{
499
+ setBusy(true); setStatus('SVG…');
500
+ try{
501
+ const frames=[]; const meta=await captureFrames(async()=>{ frames.push(canvas.toDataURL('image/png')); },'SVG');
502
+ const svg=makeAnimatedSvg(frames,meta.width,meta.height,meta.fps);
503
+ downloadBlob(new Blob([svg],{type:'image/svg+xml;charset=utf-8'}),`brain-${Date.now()}.svg`);
504
+ setStatus('Animated SVG saved (PNG frames inside SVG)');
505
+ }catch(e){ setStatus('SVG fail: '+e.message); } finally{ setBusy(false); }
506
+ });
507
+ async function fetchGifWorker(){ const url='https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js'; const r=await fetch(url); if(!r.ok) throw new Error('GIF worker download failed'); const t=await r.text(); return URL.createObjectURL(new Blob([t],{type:'application/javascript'})); }
508
+ $('exportGif').addEventListener('click',async()=>{
509
+ if(!window.GIF){ setStatus('GIF lib not loaded'); return; }
510
+ setBusy(true); let wUrl=null;
511
+ try{
512
+ setStatus('GIF worker…'); wUrl=await fetchGifWorker();
513
+ const useTrans=$('transparent').checked; const tKey=0xff00ff;
514
+ const opts={workers:2,quality:10,repeat:0,width:canvas.width,height:canvas.height,workerScript:wUrl}; if(useTrans) opts.transparent=tKey;
515
+ const gif=new window.GIF(opts); const gc=document.createElement('canvas'); gc.width=canvas.width; gc.height=canvas.height; const gcCtx=gc.getContext('2d',{willReadFrequently:true});
516
+ const done=new Promise((res,rej)=>{ gif.on('finished',res); gif.on('error',rej); gif.on('progress',p=>setStatus(`GIF ${Math.round(p*100)}%`)); });
517
+ await captureFrames(async(i,meta)=>{
518
+ const fr=ctx.getImageData(0,0,canvas.width,canvas.height); const d=fr.data;
519
+ for(let j=0;j<d.length;j+=4){ if(useTrans&&d[j+3]<128){ d[j]=255; d[j+1]=0; d[j+2]=255; d[j+3]=255; } else { d[j+3]=255; if(useTrans&&d[j]===255&&d[j+1]===0&&d[j+2]===255) d[j+1]=1; } }
520
+ gcCtx.putImageData(fr,0,0); gif.addFrame(gc,{copy:true,delay:Math.max(20,Math.round(1000/meta.fps))}); setStatus(`GIF prep ${i+1}/${meta.frameCount}`);
521
+ },'GIF');
522
+ setStatus('Encoding GIF…'); gif.render(); const blob=await done; downloadBlob(blob,`brain-${Date.now()}.gif`); setStatus('GIF saved');
523
+ }catch(e){ console.error(e); setStatus('GIF fail: '+e.message); } finally{ if(wUrl) URL.revokeObjectURL(wUrl); setBusy(false); }
524
+ });
525
+ async function exportVideo(kind){
526
+ if(!video.videoWidth && !isImageMode){ setStatus('Choose media'); return; }
527
+ if(!window.MediaRecorder||!canvas.captureStream){ setStatus('Browser no MediaRecorder'); return; }
528
+ const cands=kind==='mp4'? ['video/mp4;codecs=avc1.42E01E','video/mp4;codecs=avc1','video/mp4']: ['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm'];
529
+ const mime=cands.find(t=>MediaRecorder.isTypeSupported(t)); if(!mime){ setStatus(kind+' not supported, try WebM'); return; }
530
+ setBusy(true); const st=exportSettings(); let stream=null, rec=null;
531
+ try{
532
+ if(!isImageMode){ video.pause(); await seekTo(0); }
533
+ recordingOpaque=kind==='mp4'; renderFrame(recordingOpaque);
534
+ stream=canvas.captureStream(st.fps); rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:5000000});
535
+ const chunks=[]; const fin=new Promise((res,rej)=>{ rec.addEventListener('dataavailable',e=>{ if(e.data&&e.data.size) chunks.push(e.data); }); rec.addEventListener('stop',()=>res(new Blob(chunks,{type:rec.mimeType||mime}))); rec.addEventListener('error',e=>rej(e.error||new Error('record fail'))); });
536
+ rec.start(200); if(!isImageMode) await video.play();
537
+ setStatus(`Recording ${kind.toUpperCase()} ${st.duration.toFixed(1)}s…`);
538
+ await new Promise(r=>setTimeout(r,st.duration*1000));
539
+ if(!isImageMode) video.pause(); if(rec.state!=='inactive') rec.stop();
540
+ const blob=await fin; downloadBlob(blob,`brain-${Date.now()}.${kind==='mp4'?'mp4':'webm'}`); setStatus(kind==='mp4'?'MP4 saved (opaque)':'WebM saved');
541
+ }catch(e){ setStatus('Video fail: '+e.message); } finally{
542
+ recordingOpaque=false; if(rec&&rec.state!=='inactive') try{rec.stop();}catch(_){}
543
+ if(stream) stream.getTracks().forEach(t=>t.stop()); setBusy(false); renderSafely();
544
+ }
545
+ }
546
+ $('exportWebm').addEventListener('click',()=>exportVideo('webm')); $('exportMp4').addEventListener('click',()=>exportVideo('mp4'));
547
+
548
+ function loop(){ if(!isImageMode&&video.videoWidth&&!video.paused&&video.readyState>=2&&video.currentTime!==lastRenderedTime) renderSafely(); requestAnimationFrame(loop); }
549
+ updateLabels(); updateIconStatus(); syncButtons(); requestAnimationFrame(loop);
550
+ })();
551
+ </script>
552
+ </body>
553
+ </html>
assests/html_sample/index2.html ADDED
@@ -0,0 +1,1464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Brain Motion — SVG Video Studio</title>
7
+
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0b0d12;
12
+ --panel: #131720;
13
+ --panel-2: #191f2b;
14
+ --line: #2b3443;
15
+ --text: #edf2fa;
16
+ --muted: #9aa6b7;
17
+ --accent: #8a7dff;
18
+ --accent-2: #5f54cf;
19
+ }
20
+
21
+ * { box-sizing: border-box; }
22
+
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
29
+ }
30
+
31
+ button, input, select { font: inherit; }
32
+
33
+ .app {
34
+ width: min(1440px, 100%);
35
+ min-height: 100vh;
36
+ margin: auto;
37
+ padding: 24px;
38
+ }
39
+
40
+ .topbar {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 14px;
44
+ margin-bottom: 22px;
45
+ }
46
+
47
+ .logo {
48
+ display: grid;
49
+ place-items: center;
50
+ width: 44px;
51
+ height: 44px;
52
+ flex: 0 0 auto;
53
+ border-radius: 14px;
54
+ background: linear-gradient(140deg, #a69cff, #6258d6);
55
+ color: white;
56
+ font-size: 22px;
57
+ }
58
+
59
+ h1 {
60
+ margin: 0;
61
+ font-size: clamp(21px, 3vw, 29px);
62
+ letter-spacing: -.035em;
63
+ }
64
+
65
+ .subtitle {
66
+ margin: 3px 0 0;
67
+ color: var(--muted);
68
+ }
69
+
70
+ .workspace {
71
+ display: grid;
72
+ grid-template-columns: 330px minmax(0, 1fr);
73
+ gap: 18px;
74
+ align-items: start;
75
+ }
76
+
77
+ .panel, .stage {
78
+ border: 1px solid var(--line);
79
+ border-radius: 16px;
80
+ background: var(--panel);
81
+ }
82
+
83
+ .panel {
84
+ padding: 18px;
85
+ max-height: calc(100vh - 110px);
86
+ overflow: auto;
87
+ }
88
+
89
+ .section + .section {
90
+ margin-top: 20px;
91
+ padding-top: 18px;
92
+ border-top: 1px solid var(--line);
93
+ }
94
+
95
+ h2 {
96
+ margin: 0 0 12px;
97
+ font-size: 14px;
98
+ letter-spacing: .02em;
99
+ }
100
+
101
+ .small-note, .status, .icon-status {
102
+ color: var(--muted);
103
+ font-size: 12px;
104
+ }
105
+
106
+ .small-note { margin: 9px 0 0; }
107
+
108
+ .button-row {
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ gap: 8px;
112
+ }
113
+
114
+ .btn {
115
+ min-height: 39px;
116
+ padding: 9px 12px;
117
+ border: 1px solid var(--line);
118
+ border-radius: 10px;
119
+ background: var(--panel-2);
120
+ color: var(--text);
121
+ cursor: pointer;
122
+ transition: background .15s, border-color .15s, transform .15s;
123
+ }
124
+
125
+ .btn:hover:not(:disabled) {
126
+ border-color: #7770d8;
127
+ background: #24283a;
128
+ }
129
+
130
+ .btn:active:not(:disabled) { transform: translateY(1px); }
131
+
132
+ .btn.primary {
133
+ border-color: var(--accent-2);
134
+ background: var(--accent-2);
135
+ color: white;
136
+ }
137
+
138
+ .btn.primary:hover:not(:disabled) { background: #7167e2; }
139
+
140
+ .btn:disabled {
141
+ opacity: .48;
142
+ cursor: not-allowed;
143
+ }
144
+
145
+ .full { width: 100%; }
146
+
147
+ .icon-status {
148
+ min-height: 18px;
149
+ margin: 9px 0 0;
150
+ overflow-wrap: anywhere;
151
+ }
152
+
153
+ .control { margin: 14px 0; }
154
+
155
+ .control-label {
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: space-between;
159
+ gap: 10px;
160
+ margin-bottom: 6px;
161
+ color: #dce3ed;
162
+ }
163
+
164
+ .control-label output {
165
+ color: #b4aaff;
166
+ font-variant-numeric: tabular-nums;
167
+ }
168
+
169
+ input[type="range"] {
170
+ width: 100%;
171
+ accent-color: var(--accent);
172
+ }
173
+
174
+ select, input[type="number"] {
175
+ width: 100%;
176
+ min-height: 38px;
177
+ padding: 7px 10px;
178
+ border: 1px solid var(--line);
179
+ border-radius: 9px;
180
+ background: #10141c;
181
+ color: var(--text);
182
+ }
183
+
184
+ input[type="color"] {
185
+ width: 46px;
186
+ height: 34px;
187
+ padding: 2px;
188
+ border: 1px solid var(--line);
189
+ border-radius: 8px;
190
+ background: #10141c;
191
+ cursor: pointer;
192
+ }
193
+
194
+ .toggle-row {
195
+ display: flex;
196
+ flex-wrap: wrap;
197
+ gap: 14px;
198
+ margin: 12px 0;
199
+ }
200
+
201
+ .toggle {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 8px;
205
+ color: #dce3ed;
206
+ }
207
+
208
+ .toggle input { accent-color: var(--accent); }
209
+
210
+ .color-row {
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: space-between;
214
+ gap: 10px;
215
+ }
216
+
217
+ .export-grid {
218
+ display: grid;
219
+ grid-template-columns: 1fr 1fr;
220
+ gap: 8px;
221
+ }
222
+
223
+ .export-grid .btn { min-height: 48px; }
224
+
225
+ .stage {
226
+ min-width: 0;
227
+ padding: 16px;
228
+ }
229
+
230
+ .stage-head {
231
+ display: flex;
232
+ justify-content: space-between;
233
+ align-items: center;
234
+ gap: 12px;
235
+ margin-bottom: 12px;
236
+ }
237
+
238
+ .stage-title {
239
+ margin: 0;
240
+ font-size: 14px;
241
+ }
242
+
243
+ .stage-hint {
244
+ color: var(--muted);
245
+ font-size: 12px;
246
+ }
247
+
248
+ .preview {
249
+ position: relative;
250
+ display: grid;
251
+ place-items: center;
252
+ min-height: 320px;
253
+ overflow: hidden;
254
+ border: 1px solid #303846;
255
+ border-radius: 12px;
256
+ background-color: #10141b;
257
+ background-image:
258
+ linear-gradient(45deg, #1a202a 25%, transparent 25%),
259
+ linear-gradient(-45deg, #1a202a 25%, transparent 25%),
260
+ linear-gradient(45deg, transparent 75%, #1a202a 75%),
261
+ linear-gradient(-45deg, transparent 75%, #1a202a 75%);
262
+ background-size: 22px 22px;
263
+ background-position: 0 0, 0 11px, 11px -11px, -11px 0;
264
+ }
265
+
266
+ canvas {
267
+ display: block;
268
+ width: 100%;
269
+ height: auto;
270
+ max-height: 72vh;
271
+ object-fit: contain;
272
+ }
273
+
274
+ .placeholder {
275
+ position: absolute;
276
+ padding: 20px;
277
+ color: var(--muted);
278
+ text-align: center;
279
+ pointer-events: none;
280
+ }
281
+
282
+ .timeline {
283
+ display: grid;
284
+ grid-template-columns: auto minmax(100px, 1fr) auto;
285
+ gap: 10px;
286
+ align-items: center;
287
+ margin-top: 13px;
288
+ }
289
+
290
+ .time-label {
291
+ min-width: 76px;
292
+ color: var(--muted);
293
+ font-size: 12px;
294
+ font-variant-numeric: tabular-nums;
295
+ }
296
+
297
+ .timeline input { margin: 0; }
298
+
299
+ .status {
300
+ min-height: 20px;
301
+ margin: 13px 0 0;
302
+ }
303
+
304
+ .hidden-input, #sourceVideo { display: none; }
305
+
306
+ @media (max-width: 900px) {
307
+ .app { padding: 14px; }
308
+ .workspace { grid-template-columns: 1fr; }
309
+ .panel { max-height: none; }
310
+ .preview { min-height: 220px; }
311
+ }
312
+ </style>
313
+ </head>
314
+
315
+ <body>
316
+ <div class="app">
317
+ <header class="topbar">
318
+ <div class="logo" aria-hidden="true">✦</div>
319
+ <div>
320
+ <h1>Brain Motion — SVG Video Studio</h1>
321
+ <p class="subtitle">Turn video into animated SVG-cell artwork with a transparent background.</p>
322
+ </div>
323
+ </header>
324
+
325
+ <div class="workspace">
326
+ <aside class="panel">
327
+ <section class="section">
328
+ <h2>1. Video and SVGs</h2>
329
+ <div class="button-row">
330
+ <button class="btn primary" id="uploadVideoBtn" data-lock>Choose video</button>
331
+ <button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
332
+ <button class="btn" id="clearSvgBtn" data-lock>Clear SVGs</button>
333
+ </div>
334
+
335
+ <input class="hidden-input" id="videoInput" type="file" accept="video/*">
336
+ <input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple>
337
+ <video id="sourceVideo" playsinline muted></video>
338
+
339
+ <p class="icon-status" id="iconStatus">No SVGs loaded — circles will be used.</p>
340
+ <p class="small-note">Add multiple SVG files. Loaded SVGs cycle through the video cells.</p>
341
+ </section>
342
+
343
+ <section class="section">
344
+ <h2>2. Cell appearance</h2>
345
+
346
+ <div class="control">
347
+ <label class="control-label" for="cellSize">
348
+ <span>Cell spacing</span><output id="cellValue">14 px</output>
349
+ </label>
350
+ <input id="cellSize" data-lock type="range" min="6" max="40" value="14">
351
+ </div>
352
+
353
+ <div class="control">
354
+ <label class="control-label" for="scale">
355
+ <span>Cell size</span><output id="scaleValue">0.95</output>
356
+ </label>
357
+ <input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95">
358
+ </div>
359
+
360
+ <div class="control">
361
+ <label class="control-label" for="gap">
362
+ <span>Gap</span><output id="gapValue">1 px</output>
363
+ </label>
364
+ <input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1">
365
+ </div>
366
+
367
+ <div class="control">
368
+ <label class="control-label" for="contrast">
369
+ <span>Contrast</span><output id="contrastValue">0</output>
370
+ </label>
371
+ <input id="contrast" data-lock type="range" min="-100" max="100" value="0">
372
+ </div>
373
+
374
+ <div class="control">
375
+ <label class="control-label" for="sizeMode">Size follows</label>
376
+ <select id="sizeMode" data-lock>
377
+ <option value="bright">Bright pixels = larger</option>
378
+ <option value="dark">Dark pixels = larger</option>
379
+ </select>
380
+ </div>
381
+
382
+ <div class="control">
383
+ <label class="control-label" for="fallbackShape">Fallback shape</label>
384
+ <select id="fallbackShape" data-lock>
385
+ <option value="circle">Circle</option>
386
+ <option value="square">Square</option>
387
+ <option value="triangle">Triangle</option>
388
+ <option value="star">Star</option>
389
+ <option value="diamond">Diamond</option>
390
+ <option value="cross">Cross</option>
391
+ </select>
392
+ </div>
393
+
394
+ <div class="control">
395
+ <label class="control-label" for="colorMode">SVG color</label>
396
+ <select id="colorMode" data-lock>
397
+ <option value="sample">Tint to video color</option>
398
+ <option value="native">Keep original SVG colors</option>
399
+ <option value="white">Tint white</option>
400
+ </select>
401
+ </div>
402
+
403
+ <div class="toggle-row">
404
+ <label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by brightness</label>
405
+ </div>
406
+ </section>
407
+
408
+ <section class="section">
409
+ <h2>3. Background and export</h2>
410
+
411
+ <div class="toggle-row">
412
+ <label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent background</label>
413
+ </div>
414
+
415
+ <div class="color-row">
416
+ <label for="bgColor">Background color for opaque exports</label>
417
+ <input id="bgColor" data-lock type="color" value="#10141b">
418
+ </div>
419
+
420
+ <div class="control">
421
+ <label class="control-label" for="backgroundMode">Cut out pixels</label>
422
+ <select id="backgroundMode" data-lock>
423
+ <option value="dark">Dark pixels</option>
424
+ <option value="light">Light pixels</option>
425
+ <option value="none">Do not cut out</option>
426
+ </select>
427
+ </div>
428
+
429
+ <div class="control">
430
+ <label class="control-label" for="cutoff">
431
+ <span>Cutout threshold</span><output id="cutoffValue">5%</output>
432
+ </label>
433
+ <input id="cutoff" data-lock type="range" min="0" max="95" value="5">
434
+ </div>
435
+
436
+ <div class="control">
437
+ <label class="control-label" for="outputWidth">Output width</label>
438
+ <input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="480">
439
+ </div>
440
+
441
+ <div class="control">
442
+ <label class="control-label" for="duration">Export duration (seconds)</label>
443
+ <input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4">
444
+ </div>
445
+
446
+ <div class="control">
447
+ <label class="control-label" for="fps">Export FPS</label>
448
+ <input id="fps" data-lock type="number" min="4" max="20" step="1" value="12">
449
+ </div>
450
+
451
+ <div class="button-row" style="margin: 14px 0 9px">
452
+ <button class="btn" id="playPause" disabled data-lock>Play</button>
453
+ <button class="btn" id="saveFrame" data-lock>Save current PNG</button>
454
+ </div>
455
+
456
+ <div class="export-grid">
457
+ <button class="btn primary" id="exportSvg" disabled data-lock>Animated SVG</button>
458
+ <button class="btn" id="exportGif" disabled data-lock>GIF</button>
459
+ <button class="btn" id="exportWebm" disabled data-lock>WebM</button>
460
+ <button class="btn" id="exportMp4" disabled data-lock>MP4 (opaque)</button>
461
+ </div>
462
+
463
+ <p class="small-note">
464
+ GIF transparency is limited. MP4 is always opaque. WebM alpha depends on browser support.
465
+ Video exports contain no audio.
466
+ </p>
467
+ </section>
468
+ </aside>
469
+
470
+ <section class="stage">
471
+ <div class="stage-head">
472
+ <h2 class="stage-title">Preview</h2>
473
+ <span class="stage-hint">The checkerboard indicates transparent pixels.</span>
474
+ </div>
475
+
476
+ <div class="preview">
477
+ <canvas id="canvas" width="480" height="270"></canvas>
478
+ <div class="placeholder" id="placeholder">Choose a video to start.<br>Add one or more SVG files to use them as animated cells.</div>
479
+ </div>
480
+
481
+ <div class="timeline">
482
+ <button class="btn" id="timelinePlay" disabled data-lock>Play</button>
483
+ <input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock>
484
+ <span class="time-label" id="timeLabel">0:00 / 0:00</span>
485
+ </div>
486
+
487
+ <p class="status" id="status" aria-live="polite">Ready. Choose a video to begin.</p>
488
+ </section>
489
+ </div>
490
+ </div>
491
+
492
+ <!-- GIF encoding dependency. Other exports do not require this library. -->
493
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
494
+
495
+ <script>
496
+ (() => {
497
+ 'use strict';
498
+
499
+ const $ = id => document.getElementById(id);
500
+
501
+ const video = $('sourceVideo');
502
+ const canvas = $('canvas');
503
+ const ctx = canvas.getContext('2d', { alpha: true });
504
+
505
+ const sampleCanvas = document.createElement('canvas');
506
+ const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true });
507
+
508
+ const tintCanvas = document.createElement('canvas');
509
+ tintCanvas.width = 32;
510
+ tintCanvas.height = 32;
511
+ const tintCtx = tintCanvas.getContext('2d');
512
+
513
+ let videoUrl = null;
514
+ let svgAssets = [];
515
+ let busy = false;
516
+ let recordingOpaque = false;
517
+ let lastRenderedTime = -1;
518
+
519
+ const exportButtons = [
520
+ $('exportSvg'), $('exportGif'), $('exportWebm'), $('exportMp4')
521
+ ];
522
+
523
+ function setStatus(message) {
524
+ $('status').textContent = message;
525
+ }
526
+
527
+ function syncActionButtons() {
528
+ const hasVideo = !!video.videoWidth;
529
+ $('playPause').disabled = busy || !hasVideo;
530
+ $('timelinePlay').disabled = busy || !hasVideo;
531
+ $('seek').disabled = busy || !hasVideo;
532
+ for (const button of exportButtons) {
533
+ button.disabled = busy || !hasVideo;
534
+ }
535
+ }
536
+
537
+ function setBusy(value) {
538
+ busy = value;
539
+ document.querySelectorAll('[data-lock]').forEach(el => {
540
+ el.disabled = value;
541
+ });
542
+ syncActionButtons();
543
+ }
544
+
545
+ function clamp(value, min, max) {
546
+ return Math.max(min, Math.min(max, value));
547
+ }
548
+
549
+ function updateLabels() {
550
+ $('cellValue').textContent = `${$('cellSize').value} px`;
551
+ $('scaleValue').textContent = Number($('scale').value).toFixed(2);
552
+ $('gapValue').textContent = `${$('gap').value} px`;
553
+
554
+ const contrast = Number($('contrast').value);
555
+ $('contrastValue').textContent = `${contrast > 0 ? '+' : ''}${contrast}`;
556
+
557
+ $('cutoffValue').textContent = `${$('cutoff').value}%`;
558
+ }
559
+
560
+ function resizeCanvasForVideo() {
561
+ if (!video.videoWidth || !video.videoHeight) return;
562
+
563
+ const wantedWidth = clamp(Number($('outputWidth').value) || 480, 240, 1280);
564
+ const maxHeight = 1280;
565
+ const ratio = Math.min(
566
+ wantedWidth / video.videoWidth,
567
+ maxHeight / video.videoHeight
568
+ );
569
+
570
+ const width = Math.max(1, Math.round(video.videoWidth * ratio));
571
+ const height = Math.max(1, Math.round(video.videoHeight * ratio));
572
+
573
+ if (canvas.width !== width || canvas.height !== height) {
574
+ canvas.width = width;
575
+ canvas.height = height;
576
+ sampleCanvas.width = width;
577
+ sampleCanvas.height = height;
578
+ }
579
+ }
580
+
581
+ function adjustChannel(value, factor) {
582
+ return clamp(factor * (value - 128) + 128, 0, 255);
583
+ }
584
+
585
+ function drawAssetCentered(targetCtx, asset, boxSize) {
586
+ const iw = asset.width || asset.image.naturalWidth || 100;
587
+ const ih = asset.height || asset.image.naturalHeight || 100;
588
+ const scale = Math.min(boxSize / iw, boxSize / ih);
589
+
590
+ targetCtx.drawImage(
591
+ asset.image,
592
+ -iw * scale / 2,
593
+ -ih * scale / 2,
594
+ iw * scale,
595
+ ih * scale
596
+ );
597
+ }
598
+
599
+ function drawBuiltin(targetCtx, type, size, color) {
600
+ const r = size / 2;
601
+ targetCtx.fillStyle = color;
602
+ targetCtx.beginPath();
603
+
604
+ switch (type) {
605
+ case 'square':
606
+ targetCtx.rect(-r, -r, size, size);
607
+ break;
608
+
609
+ case 'triangle':
610
+ targetCtx.moveTo(0, -r);
611
+ targetCtx.lineTo(r, r);
612
+ targetCtx.lineTo(-r, r);
613
+ targetCtx.closePath();
614
+ break;
615
+
616
+ case 'star':
617
+ for (let i = 0; i < 10; i++) {
618
+ const angle = -Math.PI / 2 + i * Math.PI / 5;
619
+ const radius = i % 2 === 0 ? r : r * 0.43;
620
+ const x = Math.cos(angle) * radius;
621
+ const y = Math.sin(angle) * radius;
622
+ if (i === 0) targetCtx.moveTo(x, y);
623
+ else targetCtx.lineTo(x, y);
624
+ }
625
+ targetCtx.closePath();
626
+ break;
627
+
628
+ case 'diamond':
629
+ targetCtx.moveTo(0, -r);
630
+ targetCtx.lineTo(r, 0);
631
+ targetCtx.lineTo(0, r);
632
+ targetCtx.lineTo(-r, 0);
633
+ targetCtx.closePath();
634
+ break;
635
+
636
+ case 'cross':
637
+ targetCtx.rect(-r * 0.28, -r, r * 0.56, size);
638
+ targetCtx.rect(-r, -r * 0.28, size, r * 0.56);
639
+ break;
640
+
641
+ default:
642
+ targetCtx.arc(0, 0, r, 0, Math.PI * 2);
643
+ break;
644
+ }
645
+
646
+ targetCtx.fill();
647
+ }
648
+
649
+ function drawTintedAsset(asset, color, size) {
650
+ tintCtx.clearRect(0, 0, tintCanvas.width, tintCanvas.height);
651
+ tintCtx.save();
652
+ tintCtx.translate(tintCanvas.width / 2, tintCanvas.height / 2);
653
+ drawAssetCentered(tintCtx, asset, tintCanvas.width - 2);
654
+ tintCtx.restore();
655
+
656
+ tintCtx.globalCompositeOperation = 'source-in';
657
+ tintCtx.fillStyle = color;
658
+ tintCtx.fillRect(0, 0, tintCanvas.width, tintCanvas.height);
659
+ tintCtx.globalCompositeOperation = 'source-over';
660
+
661
+ ctx.drawImage(
662
+ tintCanvas,
663
+ -size / 2,
664
+ -size / 2,
665
+ size,
666
+ size
667
+ );
668
+ }
669
+
670
+ function renderFrame(forceOpaque = false) {
671
+ if (!video.videoWidth || video.readyState < 2) return;
672
+
673
+ resizeCanvasForVideo();
674
+
675
+ const width = canvas.width;
676
+ const height = canvas.height;
677
+
678
+ sampleCtx.drawImage(video, 0, 0, width, height);
679
+ const pixels = sampleCtx.getImageData(0, 0, width, height).data;
680
+
681
+ ctx.clearRect(0, 0, width, height);
682
+
683
+ const isTransparent = $('transparent').checked && !forceOpaque;
684
+ if (!isTransparent) {
685
+ ctx.fillStyle = $('bgColor').value;
686
+ ctx.fillRect(0, 0, width, height);
687
+ }
688
+
689
+ const cell = Number($('cellSize').value);
690
+ const scaleSetting = Number($('scale').value);
691
+ const gap = Number($('gap').value);
692
+ const cutoff = Number($('cutoff').value) / 100;
693
+ const contrast = Number($('contrast').value);
694
+ const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
695
+ const columns = Math.ceil(width / cell);
696
+ const rows = Math.ceil(height / cell);
697
+ const sizeMode = $('sizeMode').value;
698
+ const backgroundMode = $('backgroundMode').value;
699
+ const colorMode = $('colorMode').value;
700
+ const rotate = $('rotate').checked;
701
+ const fallbackShape = $('fallbackShape').value;
702
+
703
+ for (let row = 0; row < rows; row++) {
704
+ const y = row * cell;
705
+
706
+ for (let column = 0; column < columns; column++) {
707
+ const x = column * cell;
708
+ const sampleX = Math.min(width - 1, Math.floor(x + cell / 2));
709
+ const sampleY = Math.min(height - 1, Math.floor(y + cell / 2));
710
+ const index = (sampleY * width + sampleX) * 4;
711
+
712
+ const alpha = pixels[index + 3] / 255;
713
+ if (alpha < 0.04) continue;
714
+
715
+ const red = adjustChannel(pixels[index], contrastFactor);
716
+ const green = adjustChannel(pixels[index + 1], contrastFactor);
717
+ const blue = adjustChannel(pixels[index + 2], contrastFactor);
718
+ const luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
719
+
720
+ if (backgroundMode === 'dark' && luma < cutoff) continue;
721
+ if (backgroundMode === 'light' && luma > 1 - cutoff) continue;
722
+
723
+ const brightness = sizeMode === 'dark' ? 1 - luma : luma;
724
+ const size = Math.min(
725
+ cell * 1.8,
726
+ Math.max(0, cell * scaleSetting * (0.12 + 0.88 * brightness) - gap)
727
+ );
728
+
729
+ if (size < 1) continue;
730
+
731
+ const color = colorMode === 'white'
732
+ ? '#ffffff'
733
+ : `rgb(${Math.round(red)},${Math.round(green)},${Math.round(blue)})`;
734
+
735
+ const asset = svgAssets.length
736
+ ? svgAssets[(row * columns + column) % svgAssets.length]
737
+ : null;
738
+
739
+ ctx.save();
740
+ ctx.translate(x + cell / 2, y + cell / 2);
741
+ ctx.globalAlpha = alpha;
742
+
743
+ if (rotate) {
744
+ ctx.rotate((luma - 0.5) * Math.PI);
745
+ }
746
+
747
+ if (asset) {
748
+ if (colorMode === 'native') {
749
+ drawAssetCentered(ctx, asset, size);
750
+ } else {
751
+ drawTintedAsset(asset, color, size);
752
+ }
753
+ } else {
754
+ drawBuiltin(ctx, fallbackShape, size, color);
755
+ }
756
+
757
+ ctx.restore();
758
+ }
759
+ }
760
+
761
+ lastRenderedTime = video.currentTime;
762
+ $('placeholder').style.display = 'none';
763
+ }
764
+
765
+ function renderSafely() {
766
+ try {
767
+ renderFrame(recordingOpaque);
768
+ } catch (error) {
769
+ console.error(error);
770
+ setStatus(`Preview error: ${error.message}`);
771
+ }
772
+ }
773
+
774
+ function sanitizeSvg(source) {
775
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
776
+
777
+ if (doc.querySelector('parsererror')) {
778
+ throw new Error('The selected file is not valid SVG.');
779
+ }
780
+
781
+ const root = doc.documentElement;
782
+ if (!root || root.localName.toLowerCase() !== 'svg') {
783
+ throw new Error('No <svg> element was found in that file.');
784
+ }
785
+
786
+ const blocked = new Set(['script', 'foreignobject', 'iframe', 'object', 'embed']);
787
+
788
+ for (const element of [...root.querySelectorAll('*')]) {
789
+ if (blocked.has(element.localName.toLowerCase())) {
790
+ element.remove();
791
+ continue;
792
+ }
793
+
794
+ for (const attribute of [...element.attributes]) {
795
+ const name = attribute.name.toLowerCase();
796
+ const value = attribute.value.trim();
797
+
798
+ if (name.startsWith('on')) {
799
+ element.removeAttributeNode(attribute);
800
+ continue;
801
+ }
802
+
803
+ if ((name === 'href' || name === 'xlink:href' || name === 'src') &&
804
+ value &&
805
+ !value.startsWith('#') &&
806
+ !/^data:image\//i.test(value)) {
807
+ element.removeAttributeNode(attribute);
808
+ continue;
809
+ }
810
+
811
+ if (/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(value)) {
812
+ element.removeAttributeNode(attribute);
813
+ }
814
+ }
815
+ }
816
+
817
+ for (const style of [...root.querySelectorAll('style')]) {
818
+ if (/@import|url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(style.textContent)) {
819
+ style.remove();
820
+ }
821
+ }
822
+
823
+ if (!root.hasAttribute('xmlns')) {
824
+ root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
825
+ }
826
+
827
+ const viewBoxText = root.getAttribute('viewBox') || '';
828
+ const values = viewBoxText.trim().split(/[\s,]+/).map(Number);
829
+
830
+ let viewX = 0;
831
+ let viewY = 0;
832
+ let viewWidth = 100;
833
+ let viewHeight = 100;
834
+
835
+ if (values.length === 4 && values.every(Number.isFinite) && values[2] > 0 && values[3] > 0) {
836
+ [viewX, viewY, viewWidth, viewHeight] = values;
837
+ } else {
838
+ viewWidth = parseFloat(root.getAttribute('width')) || 100;
839
+ viewHeight = parseFloat(root.getAttribute('height')) || 100;
840
+ root.setAttribute('viewBox', `0 0 ${viewWidth} ${viewHeight}`);
841
+ }
842
+
843
+ root.setAttribute('width', String(viewWidth));
844
+ root.setAttribute('height', String(viewHeight));
845
+
846
+ return {
847
+ text: new XMLSerializer().serializeToString(root),
848
+ width: viewWidth,
849
+ height: viewHeight
850
+ };
851
+ }
852
+
853
+ function loadSvgImage(file, parsed) {
854
+ const blob = new Blob([parsed.text], { type: 'image/svg+xml;charset=utf-8' });
855
+ const url = URL.createObjectURL(blob);
856
+
857
+ return new Promise((resolve, reject) => {
858
+ const image = new Image();
859
+
860
+ image.onload = () => resolve({
861
+ name: file.name,
862
+ image,
863
+ url,
864
+ width: parsed.width,
865
+ height: parsed.height
866
+ });
867
+
868
+ image.onerror = () => {
869
+ URL.revokeObjectURL(url);
870
+ reject(new Error(`Could not load ${file.name}.`));
871
+ };
872
+
873
+ image.src = url;
874
+ });
875
+ }
876
+
877
+ function updateIconStatus() {
878
+ if (!svgAssets.length) {
879
+ $('iconStatus').textContent = 'No SVGs loaded — circles will be used.';
880
+ return;
881
+ }
882
+
883
+ $('iconStatus').textContent =
884
+ `${svgAssets.length} SVG${svgAssets.length === 1 ? '' : 's'} loaded: ` +
885
+ svgAssets.map(asset => asset.name).join(', ');
886
+ }
887
+
888
+ function clearSvgAssets() {
889
+ for (const asset of svgAssets) URL.revokeObjectURL(asset.url);
890
+ svgAssets = [];
891
+ updateIconStatus();
892
+ renderSafely();
893
+ }
894
+
895
+ function formatTime(seconds) {
896
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
897
+ const minutes = Math.floor(seconds / 60);
898
+ const wholeSeconds = Math.floor(seconds % 60);
899
+ return `${minutes}:${String(wholeSeconds).padStart(2, '0')}`;
900
+ }
901
+
902
+ function updateTimeline() {
903
+ if (!video.videoWidth || !Number.isFinite(video.duration) || video.duration <= 0) {
904
+ $('timeLabel').textContent = '0:00 / 0:00';
905
+ return;
906
+ }
907
+
908
+ $('seek').value = String(Math.round(video.currentTime / video.duration * 1000));
909
+ $('timeLabel').textContent =
910
+ `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
911
+ }
912
+
913
+ function seekTo(seconds) {
914
+ return new Promise((resolve, reject) => {
915
+ const duration = video.duration;
916
+ const maximum = Number.isFinite(duration) && duration > 0
917
+ ? Math.max(0, duration - 0.01)
918
+ : Math.max(0, seconds);
919
+
920
+ const target = clamp(seconds, 0, maximum);
921
+
922
+ if (!video.seeking && Math.abs(video.currentTime - target) < 0.001) {
923
+ resolve();
924
+ return;
925
+ }
926
+
927
+ let timer;
928
+
929
+ const finish = () => {
930
+ clearTimeout(timer);
931
+ video.removeEventListener('seeked', finish);
932
+ resolve();
933
+ };
934
+
935
+ video.addEventListener('seeked', finish);
936
+ timer = setTimeout(finish, 8000);
937
+
938
+ try {
939
+ video.currentTime = target;
940
+ } catch (error) {
941
+ clearTimeout(timer);
942
+ video.removeEventListener('seeked', finish);
943
+ reject(error);
944
+ }
945
+ });
946
+ }
947
+
948
+ function exportSettings() {
949
+ const fps = clamp(Math.round(Number($('fps').value) || 12), 4, 20);
950
+ const seconds = clamp(Number($('duration').value) || 4, 0.5, 15);
951
+ const frameCount = Math.max(1, Math.ceil(seconds * fps));
952
+
953
+ return {
954
+ fps,
955
+ frameCount,
956
+ duration: frameCount / fps
957
+ };
958
+ }
959
+
960
+ async function captureFrames(onFrame, label) {
961
+ if (!video.videoWidth) throw new Error('Choose a video first.');
962
+
963
+ const settings = exportSettings();
964
+ const wasPlaying = !video.paused;
965
+ const previousTime = video.currentTime;
966
+ const mediaDuration = Number.isFinite(video.duration) && video.duration > 0
967
+ ? video.duration
968
+ : null;
969
+
970
+ video.pause();
971
+
972
+ try {
973
+ for (let i = 0; i < settings.frameCount; i++) {
974
+ const time = mediaDuration
975
+ ? (i / settings.fps) % mediaDuration
976
+ : i / settings.fps;
977
+
978
+ await seekTo(time);
979
+ await new Promise(resolve => requestAnimationFrame(resolve));
980
+ renderFrame(false);
981
+
982
+ await onFrame(i, {
983
+ ...settings,
984
+ width: canvas.width,
985
+ height: canvas.height
986
+ });
987
+
988
+ setStatus(`${label}: rendering frame ${i + 1} of ${settings.frameCount}…`);
989
+ }
990
+
991
+ return {
992
+ ...settings,
993
+ width: canvas.width,
994
+ height: canvas.height
995
+ };
996
+ } finally {
997
+ try {
998
+ await seekTo(previousTime);
999
+ } catch (_) {}
1000
+
1001
+ if (wasPlaying) {
1002
+ await video.play().catch(() => {});
1003
+ }
1004
+
1005
+ renderSafely();
1006
+ updateTimeline();
1007
+ }
1008
+ }
1009
+
1010
+ function downloadBlob(blob, filename) {
1011
+ const url = URL.createObjectURL(blob);
1012
+ const link = document.createElement('a');
1013
+
1014
+ link.href = url;
1015
+ link.download = filename;
1016
+ document.body.appendChild(link);
1017
+ link.click();
1018
+ link.remove();
1019
+
1020
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
1021
+ }
1022
+
1023
+ function numberText(value) {
1024
+ return Number(value.toFixed(6)).toString();
1025
+ }
1026
+
1027
+ function makeAnimatedSvg(frames, width, height, fps) {
1028
+ const count = frames.length;
1029
+ const duration = count / fps;
1030
+ const durationText = numberText(duration);
1031
+
1032
+ const imageElements = frames.map((data, index) => {
1033
+ let animation = '';
1034
+
1035
+ if (count > 1) {
1036
+ let values;
1037
+ let keyTimes;
1038
+
1039
+ if (index === 0) {
1040
+ values = 'visible;hidden;hidden';
1041
+ keyTimes = `0;${numberText(1 / count)};1`;
1042
+ } else if (index === count - 1) {
1043
+ values = `hidden;visible;hidden`;
1044
+ keyTimes = `0;${numberText(index / count)};1`;
1045
+ } else {
1046
+ values = 'hidden;visible;hidden;hidden';
1047
+ keyTimes =
1048
+ `0;${numberText(index / count)};${numberText((index + 1) / count)};1`;
1049
+ }
1050
+
1051
+ animation =
1052
+ `<animate attributeName="visibility" calcMode="discrete" ` +
1053
+ `values="${values}" keyTimes="${keyTimes}" ` +
1054
+ `dur="${durationText}s" repeatCount="indefinite"/>`;
1055
+ }
1056
+
1057
+ const initialVisibility = index === 0 ? 'visible' : 'hidden';
1058
+
1059
+ return `<image x="0" y="0" width="${width}" height="${height}" ` +
1060
+ `visibility="${initialVisibility}" href="${data}" xlink:href="${data}">` +
1061
+ `${animation}</image>`;
1062
+ }).join('\n');
1063
+
1064
+ return `<?xml version="1.0" encoding="UTF-8"?>
1065
+ <svg xmlns="http://www.w3.org/2000/svg"
1066
+ xmlns:xlink="http://www.w3.org/1999/xlink"
1067
+ width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
1068
+ ${imageElements}
1069
+ </svg>`;
1070
+ }
1071
+
1072
+ $('uploadVideoBtn').addEventListener('click', () => $('videoInput').click());
1073
+ $('addSvgBtn').addEventListener('click', () => $('svgInput').click());
1074
+
1075
+ $('videoInput').addEventListener('change', event => {
1076
+ const file = event.target.files && event.target.files[0];
1077
+ if (!file) return;
1078
+
1079
+ video.pause();
1080
+
1081
+ if (videoUrl) URL.revokeObjectURL(videoUrl);
1082
+ videoUrl = URL.createObjectURL(file);
1083
+
1084
+ video.src = videoUrl;
1085
+ video.loop = true;
1086
+ video.muted = true;
1087
+ video.load();
1088
+
1089
+ $('placeholder').style.display = 'block';
1090
+ setStatus(`Loading ${file.name}…`);
1091
+ event.target.value = '';
1092
+ });
1093
+
1094
+ video.addEventListener('loadedmetadata', () => {
1095
+ resizeCanvasForVideo();
1096
+ $('seek').disabled = false;
1097
+ $('timelinePlay').disabled = false;
1098
+ $('playPause').disabled = false;
1099
+ $('placeholder').style.display = 'none';
1100
+ updateTimeline();
1101
+ renderSafely();
1102
+ syncActionButtons();
1103
+
1104
+ setStatus(`Video loaded: ${video.videoWidth} × ${video.videoHeight}.`);
1105
+ video.play().catch(() => {
1106
+ setStatus('Video loaded. Press Play to start the preview.');
1107
+ });
1108
+ });
1109
+
1110
+ video.addEventListener('loadeddata', () => renderSafely());
1111
+ video.addEventListener('seeked', () => {
1112
+ renderSafely();
1113
+ updateTimeline();
1114
+ });
1115
+ video.addEventListener('timeupdate', updateTimeline);
1116
+ video.addEventListener('play', () => {
1117
+ $('playPause').textContent = 'Pause';
1118
+ $('timelinePlay').textContent = 'Pause';
1119
+ });
1120
+ video.addEventListener('pause', () => {
1121
+ $('playPause').textContent = 'Play';
1122
+ $('timelinePlay').textContent = 'Play';
1123
+ renderSafely();
1124
+ });
1125
+ video.addEventListener('error', () => {
1126
+ setStatus('Could not read that video. Try an MP4 or WebM file supported by your browser.');
1127
+ });
1128
+
1129
+ $('svgInput').addEventListener('change', async event => {
1130
+ const files = [...(event.target.files || [])];
1131
+ event.target.value = '';
1132
+
1133
+ if (!files.length) return;
1134
+
1135
+ let added = 0;
1136
+
1137
+ for (const file of files) {
1138
+ if (!file.name.toLowerCase().endsWith('.svg') && file.type !== 'image/svg+xml') {
1139
+ continue;
1140
+ }
1141
+
1142
+ try {
1143
+ const parsed = sanitizeSvg(await file.text());
1144
+ const asset = await loadSvgImage(file, parsed);
1145
+ svgAssets.push(asset);
1146
+ added++;
1147
+ } catch (error) {
1148
+ console.warn(error);
1149
+ setStatus(error.message);
1150
+ }
1151
+ }
1152
+
1153
+ updateIconStatus();
1154
+ renderSafely();
1155
+
1156
+ if (added) {
1157
+ setStatus(`Added ${added} SVG${added === 1 ? '' : 's'}. All loaded SVGs cycle across the cells.`);
1158
+ }
1159
+ });
1160
+
1161
+ $('clearSvgBtn').addEventListener('click', clearSvgAssets);
1162
+
1163
+ function togglePlayback() {
1164
+ if (!video.videoWidth) return;
1165
+
1166
+ if (video.paused) {
1167
+ video.play().catch(() => setStatus('The video could not be played.'));
1168
+ } else {
1169
+ video.pause();
1170
+ }
1171
+ }
1172
+
1173
+ $('playPause').addEventListener('click', togglePlayback);
1174
+ $('timelinePlay').addEventListener('click', togglePlayback);
1175
+
1176
+ $('seek').addEventListener('input', event => {
1177
+ if (!video.videoWidth || !Number.isFinite(video.duration)) return;
1178
+
1179
+ video.pause();
1180
+ const fraction = Number(event.target.value) / 1000;
1181
+ video.currentTime = fraction * video.duration;
1182
+ });
1183
+
1184
+ const renderControlIds = [
1185
+ 'cellSize', 'scale', 'gap', 'contrast', 'sizeMode',
1186
+ 'backgroundMode', 'cutoff', 'fallbackShape', 'colorMode',
1187
+ 'rotate', 'transparent', 'bgColor', 'outputWidth'
1188
+ ];
1189
+
1190
+ for (const id of renderControlIds) {
1191
+ $(id).addEventListener('input', () => {
1192
+ updateLabels();
1193
+ renderSafely();
1194
+ });
1195
+ $(id).addEventListener('change', () => {
1196
+ updateLabels();
1197
+ renderSafely();
1198
+ });
1199
+ }
1200
+
1201
+ $('saveFrame').addEventListener('click', async () => {
1202
+ if (!video.videoWidth) {
1203
+ setStatus('Choose a video first.');
1204
+ return;
1205
+ }
1206
+
1207
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
1208
+ if (!blob) {
1209
+ setStatus('Could not create the PNG.');
1210
+ return;
1211
+ }
1212
+
1213
+ downloadBlob(blob, `brain-motion-${Date.now()}.png`);
1214
+ setStatus('Saved the current frame as a transparent PNG.');
1215
+ });
1216
+
1217
+ $('exportSvg').addEventListener('click', async () => {
1218
+ setBusy(true);
1219
+ setStatus('Preparing animated SVG frames…');
1220
+
1221
+ try {
1222
+ const frames = [];
1223
+ const meta = await captureFrames(async () => {
1224
+ frames.push(canvas.toDataURL('image/png'));
1225
+ }, 'SVG');
1226
+
1227
+ const svg = makeAnimatedSvg(frames, meta.width, meta.height, meta.fps);
1228
+ downloadBlob(
1229
+ new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
1230
+ `brain-motion-${Date.now()}.svg`
1231
+ );
1232
+
1233
+ setStatus('Saved animated SVG. It embeds transparent PNG frames inside an SVG animation.');
1234
+ } catch (error) {
1235
+ setStatus(`SVG export failed: ${error.message}`);
1236
+ } finally {
1237
+ setBusy(false);
1238
+ }
1239
+ });
1240
+
1241
+ async function fetchGifWorker() {
1242
+ const url = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
1243
+ const response = await fetch(url);
1244
+
1245
+ if (!response.ok) {
1246
+ throw new Error('Could not download the GIF encoder worker. Run this page from localhost and check your internet connection.');
1247
+ }
1248
+
1249
+ const workerSource = await response.text();
1250
+ return URL.createObjectURL(
1251
+ new Blob([workerSource], { type: 'application/javascript' })
1252
+ );
1253
+ }
1254
+
1255
+ $('exportGif').addEventListener('click', async () => {
1256
+ if (!window.GIF) {
1257
+ setStatus('GIF encoder did not load. Check your internet connection and reload the page.');
1258
+ return;
1259
+ }
1260
+
1261
+ setBusy(true);
1262
+ let workerUrl = null;
1263
+
1264
+ try {
1265
+ setStatus('Loading GIF encoder…');
1266
+ workerUrl = await fetchGifWorker();
1267
+
1268
+ const options = {
1269
+ workers: 2,
1270
+ quality: 10,
1271
+ repeat: 0,
1272
+ width: canvas.width,
1273
+ height: canvas.height,
1274
+ workerScript: workerUrl
1275
+ };
1276
+
1277
+ const usesTransparency = $('transparent').checked;
1278
+ const transparentKey = 0xff00ff;
1279
+
1280
+ if (usesTransparency) options.transparent = transparentKey;
1281
+
1282
+ const gif = new window.GIF(options);
1283
+ const gifCanvas = document.createElement('canvas');
1284
+ gifCanvas.width = canvas.width;
1285
+ gifCanvas.height = canvas.height;
1286
+
1287
+ const gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
1288
+
1289
+ const finished = new Promise((resolve, reject) => {
1290
+ gif.on('finished', resolve);
1291
+ gif.on('error', reject);
1292
+ gif.on('progress', progress => {
1293
+ setStatus(`Encoding GIF: ${Math.round(progress * 100)}%…`);
1294
+ });
1295
+ });
1296
+
1297
+ await captureFrames(async (index, meta) => {
1298
+ const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
1299
+ const data = frame.data;
1300
+
1301
+ for (let i = 0; i < data.length; i += 4) {
1302
+ if (usesTransparency && data[i + 3] < 128) {
1303
+ data[i] = 255;
1304
+ data[i + 1] = 0;
1305
+ data[i + 2] = 255;
1306
+ data[i + 3] = 255;
1307
+ } else {
1308
+ data[i + 3] = 255;
1309
+
1310
+ // Keep actual magenta artwork from matching the transparent key.
1311
+ if (usesTransparency && data[i] === 255 && data[i + 1] === 0 && data[i + 2] === 255) {
1312
+ data[i + 1] = 1;
1313
+ }
1314
+ }
1315
+ }
1316
+
1317
+ gifCtx.putImageData(frame, 0, 0);
1318
+ gif.addFrame(gifCanvas, {
1319
+ copy: true,
1320
+ delay: Math.max(20, Math.round(1000 / meta.fps))
1321
+ });
1322
+
1323
+ setStatus(`GIF: preparing frame ${index + 1} of ${meta.frameCount}…`);
1324
+ }, 'GIF');
1325
+
1326
+ setStatus('Encoding GIF…');
1327
+ gif.render();
1328
+
1329
+ const blob = await finished;
1330
+ downloadBlob(blob, `brain-motion-${Date.now()}.gif`);
1331
+ setStatus('Saved GIF. GIF transparency is limited to transparent/opaque pixels.');
1332
+ } catch (error) {
1333
+ console.error(error);
1334
+ setStatus(`GIF export failed: ${error.message || 'Check that the page is served from localhost.'}`);
1335
+ } finally {
1336
+ if (workerUrl) URL.revokeObjectURL(workerUrl);
1337
+ setBusy(false);
1338
+ }
1339
+ });
1340
+
1341
+ async function exportRecordedVideo(kind) {
1342
+ if (!video.videoWidth) {
1343
+ setStatus('Choose a video first.');
1344
+ return;
1345
+ }
1346
+
1347
+ if (!window.MediaRecorder || !canvas.captureStream) {
1348
+ setStatus('This browser does not support canvas video recording. Try a current version of Chrome or Edge.');
1349
+ return;
1350
+ }
1351
+
1352
+ const mimeCandidates = kind === 'mp4'
1353
+ ? ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']
1354
+ : ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
1355
+
1356
+ const mimeType = mimeCandidates.find(type => MediaRecorder.isTypeSupported(type));
1357
+
1358
+ if (!mimeType) {
1359
+ setStatus(kind === 'mp4'
1360
+ ? 'MP4 recording is not supported by this browser. MP4 support is browser-dependent; use WebM instead.'
1361
+ : 'WebM recording is not supported by this browser.');
1362
+ return;
1363
+ }
1364
+
1365
+ setBusy(true);
1366
+
1367
+ const settings = exportSettings();
1368
+ const wasPlaying = !video.paused;
1369
+ const previousTime = video.currentTime;
1370
+ let stream = null;
1371
+ let recorder = null;
1372
+
1373
+ try {
1374
+ video.pause();
1375
+ await seekTo(0);
1376
+
1377
+ recordingOpaque = kind === 'mp4';
1378
+ renderFrame(recordingOpaque);
1379
+
1380
+ stream = canvas.captureStream(settings.fps);
1381
+
1382
+ recorder = new MediaRecorder(stream, {
1383
+ mimeType,
1384
+ videoBitsPerSecond: 5_000_000
1385
+ });
1386
+
1387
+ const chunks = [];
1388
+
1389
+ const recordingFinished = new Promise((resolve, reject) => {
1390
+ recorder.addEventListener('dataavailable', event => {
1391
+ if (event.data && event.data.size) chunks.push(event.data);
1392
+ });
1393
+
1394
+ recorder.addEventListener('stop', () => {
1395
+ resolve(new Blob(chunks, { type: recorder.mimeType || mimeType }));
1396
+ });
1397
+
1398
+ recorder.addEventListener('error', event => {
1399
+ reject(event.error || new Error('Video recording failed.'));
1400
+ });
1401
+ });
1402
+
1403
+ recorder.start(200);
1404
+ await video.play();
1405
+
1406
+ setStatus(`Recording ${kind.toUpperCase()} for ${settings.duration.toFixed(1)} seconds…`);
1407
+ await new Promise(resolve => setTimeout(resolve, settings.duration * 1000));
1408
+
1409
+ video.pause();
1410
+ if (recorder.state !== 'inactive') recorder.stop();
1411
+
1412
+ const blob = await recordingFinished;
1413
+ const extension = kind === 'mp4' ? 'mp4' : 'webm';
1414
+
1415
+ downloadBlob(blob, `brain-motion-${Date.now()}.${extension}`);
1416
+
1417
+ setStatus(kind === 'mp4'
1418
+ ? 'Saved MP4 with an opaque background. MP4 does not support transparency.'
1419
+ : 'Saved WebM. Transparent alpha depends on browser and codec support.');
1420
+ } catch (error) {
1421
+ console.error(error);
1422
+ setStatus(`Video export failed: ${error.message}`);
1423
+ } finally {
1424
+ recordingOpaque = false;
1425
+
1426
+ if (recorder && recorder.state !== 'inactive') {
1427
+ try { recorder.stop(); } catch (_) {}
1428
+ }
1429
+
1430
+ if (stream) stream.getTracks().forEach(track => track.stop());
1431
+
1432
+ try {
1433
+ await seekTo(previousTime);
1434
+ } catch (_) {}
1435
+
1436
+ if (wasPlaying) await video.play().catch(() => {});
1437
+
1438
+ renderSafely();
1439
+ setBusy(false);
1440
+ }
1441
+ }
1442
+
1443
+ $('exportWebm').addEventListener('click', () => exportRecordedVideo('webm'));
1444
+ $('exportMp4').addEventListener('click', () => exportRecordedVideo('mp4'));
1445
+
1446
+ function previewLoop() {
1447
+ if (video.videoWidth &&
1448
+ !video.paused &&
1449
+ video.readyState >= 2 &&
1450
+ video.currentTime !== lastRenderedTime) {
1451
+ renderSafely();
1452
+ }
1453
+
1454
+ requestAnimationFrame(previewLoop);
1455
+ }
1456
+
1457
+ updateLabels();
1458
+ updateIconStatus();
1459
+ syncActionButtons();
1460
+ requestAnimationFrame(previewLoop);
1461
+ })();
1462
+ </script>
1463
+ </body>
1464
+ </html>
assests/html_sample/index3.html ADDED
@@ -0,0 +1,1587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Brain Motion — SVG Video Studio</title>
7
+
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0b0d12;
12
+ --panel: #14171f;
13
+ --panel-2: #1a1e28;
14
+ --line: #272c3a;
15
+ --text: #edf2fa;
16
+ --muted: #8d97a8;
17
+ --accent: #8a7dff;
18
+ --accent-2: #6258cf;
19
+ --radius: 14px;
20
+ }
21
+
22
+ * { box-sizing: border-box; }
23
+
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ background: radial-gradient(circle at top left, #151929, var(--bg) 60%);
28
+ color: var(--text);
29
+ font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
30
+ }
31
+
32
+ button, input, select { font: inherit; }
33
+
34
+ .app {
35
+ width: min(1480px, 100%);
36
+ min-height: 100vh;
37
+ margin: auto;
38
+ padding: 20px;
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 20px;
42
+ }
43
+
44
+ .topbar {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 16px;
48
+ }
49
+
50
+ .logo {
51
+ display: grid;
52
+ place-items: center;
53
+ width: 46px;
54
+ height: 46px;
55
+ flex: 0 0 auto;
56
+ border-radius: 14px;
57
+ background: linear-gradient(140deg, #a69cff, #5f54cf);
58
+ color: white;
59
+ font-size: 24px;
60
+ box-shadow: 0 4px 12px rgba(98, 88, 207, 0.3);
61
+ }
62
+
63
+ h1 {
64
+ margin: 0;
65
+ font-size: clamp(20px, 3vw, 26px);
66
+ font-weight: 600;
67
+ letter-spacing: -.03em;
68
+ }
69
+
70
+ .subtitle {
71
+ margin: 2px 0 0;
72
+ color: var(--muted);
73
+ font-size: 13px;
74
+ }
75
+
76
+ .workspace {
77
+ display: grid;
78
+ grid-template-columns: 320px minmax(0, 1fr);
79
+ gap: 20px;
80
+ align-items: start;
81
+ flex: 1;
82
+ }
83
+
84
+ .panel, .stage {
85
+ border: 1px solid var(--line);
86
+ border-radius: var(--radius);
87
+ background: var(--panel);
88
+ }
89
+
90
+ .panel {
91
+ padding: 16px;
92
+ max-height: calc(100vh - 100px);
93
+ overflow-y: auto;
94
+ scrollbar-width: thin;
95
+ scrollbar-color: var(--line) transparent;
96
+ }
97
+
98
+ .panel::-webkit-scrollbar { width: 6px; }
99
+ .panel::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
100
+
101
+ .section + .section {
102
+ margin-top: 16px;
103
+ padding-top: 16px;
104
+ border-top: 1px solid var(--line);
105
+ }
106
+
107
+ h2 {
108
+ margin: 0 0 12px;
109
+ font-size: 13px;
110
+ font-weight: 600;
111
+ letter-spacing: .03em;
112
+ text-transform: uppercase;
113
+ color: #bcc5d6;
114
+ }
115
+
116
+ .small-note, .status, .icon-status {
117
+ color: var(--muted);
118
+ font-size: 11px;
119
+ }
120
+
121
+ .small-note { margin: 8px 0 0; line-height: 1.4; }
122
+
123
+ .button-row {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ gap: 8px;
127
+ }
128
+
129
+ .btn {
130
+ min-height: 36px;
131
+ padding: 8px 14px;
132
+ border: 1px solid var(--line);
133
+ border-radius: 9px;
134
+ background: var(--panel-2);
135
+ color: var(--text);
136
+ cursor: pointer;
137
+ transition: all .15s ease;
138
+ font-weight: 500;
139
+ }
140
+
141
+ .btn:hover:not(:disabled) {
142
+ border-color: #7770d8;
143
+ background: #24283a;
144
+ }
145
+
146
+ .btn:active:not(:disabled) { transform: scale(0.97); }
147
+
148
+ .btn.primary {
149
+ border-color: var(--accent-2);
150
+ background: var(--accent-2);
151
+ color: white;
152
+ }
153
+
154
+ .btn.primary:hover:not(:disabled) { background: #7167e2; }
155
+
156
+ .btn:disabled {
157
+ opacity: .4;
158
+ cursor: not-allowed;
159
+ }
160
+
161
+ .full { width: 100%; }
162
+
163
+ .icon-status {
164
+ min-height: 16px;
165
+ margin: 8px 0 0;
166
+ overflow-wrap: anywhere;
167
+ }
168
+
169
+ .control { margin: 12px 0; }
170
+
171
+ .control-label {
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: space-between;
175
+ gap: 10px;
176
+ margin-bottom: 5px;
177
+ color: #dce3ed;
178
+ font-size: 12px;
179
+ }
180
+
181
+ .control-label output {
182
+ color: #b4aaff;
183
+ font-variant-numeric: tabular-nums;
184
+ font-size: 11px;
185
+ }
186
+
187
+ input[type="range"] {
188
+ width: 100%;
189
+ accent-color: var(--accent);
190
+ height: 6px;
191
+ }
192
+
193
+ select, input[type="number"] {
194
+ width: 100%;
195
+ min-height: 34px;
196
+ padding: 6px 10px;
197
+ border: 1px solid var(--line);
198
+ border-radius: 8px;
199
+ background: #10141c;
200
+ color: var(--text);
201
+ }
202
+
203
+ input[type="color"] {
204
+ width: 40px;
205
+ height: 30px;
206
+ padding: 2px;
207
+ border: 1px solid var(--line);
208
+ border-radius: 6px;
209
+ background: #10141c;
210
+ cursor: pointer;
211
+ }
212
+
213
+ .toggle-row {
214
+ display: flex;
215
+ flex-wrap: wrap;
216
+ gap: 14px;
217
+ margin: 10px 0;
218
+ }
219
+
220
+ .toggle {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 7px;
224
+ color: #dce3ed;
225
+ font-size: 12px;
226
+ }
227
+
228
+ .toggle input { accent-color: var(--accent); }
229
+
230
+ .color-row {
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: space-between;
234
+ gap: 10px;
235
+ font-size: 12px;
236
+ }
237
+
238
+ .export-grid {
239
+ display: grid;
240
+ grid-template-columns: 1fr 1fr;
241
+ gap: 8px;
242
+ }
243
+
244
+ .export-grid .btn { min-height: 42px; font-size: 12px; }
245
+
246
+ .stage {
247
+ min-width: 0;
248
+ padding: 16px;
249
+ display: flex;
250
+ flex-direction: column;
251
+ }
252
+
253
+ .stage-head {
254
+ display: flex;
255
+ justify-content: space-between;
256
+ align-items: center;
257
+ gap: 12px;
258
+ margin-bottom: 12px;
259
+ }
260
+
261
+ .stage-title {
262
+ margin: 0;
263
+ font-size: 14px;
264
+ font-weight: 600;
265
+ }
266
+
267
+ .stage-hint {
268
+ color: var(--muted);
269
+ font-size: 11px;
270
+ }
271
+
272
+ .preview {
273
+ position: relative;
274
+ display: grid;
275
+ place-items: center;
276
+ min-height: 400px;
277
+ overflow: hidden;
278
+ border: 1px solid #303846;
279
+ border-radius: 12px;
280
+ background-color: #10141b;
281
+ background-image:
282
+ linear-gradient(45deg, #1a202a 25%, transparent 25%),
283
+ linear-gradient(-45deg, #1a202a 25%, transparent 25%),
284
+ linear-gradient(45deg, transparent 75%, #1a202a 75%),
285
+ linear-gradient(-45deg, transparent 75%, #1a202a 75%);
286
+ background-size: 20px 20px;
287
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
288
+ flex: 1;
289
+ }
290
+
291
+ canvas {
292
+ display: block;
293
+ width: 100%;
294
+ height: auto;
295
+ max-height: 70vh;
296
+ object-fit: contain;
297
+ }
298
+
299
+ .placeholder {
300
+ position: absolute;
301
+ padding: 30px;
302
+ color: var(--muted);
303
+ text-align: center;
304
+ pointer-events: none;
305
+ display: flex;
306
+ flex-direction: column;
307
+ align-items: center;
308
+ gap: 12px;
309
+ }
310
+
311
+ .placeholder-icon {
312
+ font-size: 42px;
313
+ opacity: 0.4;
314
+ color: var(--accent);
315
+ }
316
+
317
+ .timeline {
318
+ display: grid;
319
+ grid-template-columns: auto minmax(100px, 1fr) auto;
320
+ gap: 12px;
321
+ align-items: center;
322
+ margin-top: 14px;
323
+ }
324
+
325
+ .time-label {
326
+ min-width: 80px;
327
+ color: var(--muted);
328
+ font-size: 11px;
329
+ font-variant-numeric: tabular-nums;
330
+ text-align: right;
331
+ }
332
+
333
+ .timeline input { margin: 0; }
334
+
335
+ .status {
336
+ min-height: 18px;
337
+ margin: 12px 0 0;
338
+ font-size: 11px;
339
+ }
340
+
341
+ .hidden-input, #sourceVideo { display: none; }
342
+
343
+ @media (max-width: 900px) {
344
+ .app { padding: 12px; }
345
+ .workspace { grid-template-columns: 1fr; }
346
+ .panel { max-height: none; }
347
+ .preview { min-height: 280px; }
348
+ }
349
+ </style>
350
+ </head>
351
+
352
+ <body>
353
+ <div class="app">
354
+ <header class="topbar">
355
+ <div class="logo" aria-hidden="true">✦</div>
356
+ <div>
357
+ <h1>Brain Motion Studio</h1>
358
+ <p class="subtitle">Transform video into dynamic SVG-cell artwork with transparency & color grading.</p>
359
+ </div>
360
+ </header>
361
+
362
+ <div class="workspace">
363
+ <aside class="panel">
364
+ <section class="section">
365
+ <h2>1. Source Media</h2>
366
+ <div class="button-row">
367
+ <button class="btn primary" id="uploadVideoBtn" data-lock>Video</button>
368
+ <button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
369
+ <button class="btn" id="clearSvgBtn" data-lock>Clear SVGs</button>
370
+ </div>
371
+
372
+ <input class="hidden-input" id="videoInput" type="file" accept="video/*">
373
+ <input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple>
374
+ <video id="sourceVideo" playsinline muted></video>
375
+
376
+ <p class="icon-status" id="iconStatus">No SVGs loaded — circles will be used.</p>
377
+ <p class="small-note">Upload a video, then add any number of SVG files. They will automatically cycle through the cells.</p>
378
+ </section>
379
+
380
+ <section class="section">
381
+ <h2>2. Color Grading</h2>
382
+
383
+ <div class="control">
384
+ <label class="control-label" for="brightness">
385
+ <span>Brightness</span><output id="brightnessValue">0</output>
386
+ </label>
387
+ <input id="brightness" data-lock type="range" min="-100" max="100" value="0">
388
+ </div>
389
+
390
+ <div class="control">
391
+ <label class="control-label" for="saturation">
392
+ <span>Saturation</span><output id="saturationValue">0</output>
393
+ </label>
394
+ <input id="saturation" data-lock type="range" min="-100" max="100" value="0">
395
+ </div>
396
+
397
+ <div class="color-row" style="margin: 12px 0">
398
+ <label for="tintColor">Tint / Overlay</label>
399
+ <input id="tintColor" data-lock type="color" value="#8a7dff">
400
+ </div>
401
+
402
+ <div class="control">
403
+ <label class="control-label" for="tintOpacity">
404
+ <span>Tint Intensity</span><output id="tintOpacityValue">0%</output>
405
+ </label>
406
+ <input id="tintOpacity" data-lock type="range" min="0" max="100" value="0">
407
+ </div>
408
+ </section>
409
+
410
+ <section class="section">
411
+ <h2>3. Cell Appearance</h2>
412
+
413
+ <div class="control">
414
+ <label class="control-label" for="cellSize">
415
+ <span>Spacing</span><output id="cellValue">14 px</output>
416
+ </label>
417
+ <input id="cellSize" data-lock type="range" min="6" max="40" value="14">
418
+ </div>
419
+
420
+ <div class="control">
421
+ <label class="control-label" for="scale">
422
+ <span>Scale</span><output id="scaleValue">0.95</output>
423
+ </label>
424
+ <input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95">
425
+ </div>
426
+
427
+ <div class="control">
428
+ <label class="control-label" for="gap">
429
+ <span>Gap</span><output id="gapValue">1 px</output>
430
+ </label>
431
+ <input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1">
432
+ </div>
433
+
434
+ <div class="control">
435
+ <label class="control-label" for="contrast">
436
+ <span>Contrast</span><output id="contrastValue">0</output>
437
+ </label>
438
+ <input id="contrast" data-lock type="range" min="-100" max="100" value="0">
439
+ </div>
440
+
441
+ <div class="control">
442
+ <label class="control-label" for="sizeMode">Size Logic</label>
443
+ <select id="sizeMode" data-lock>
444
+ <option value="bright">Bright = Larger</option>
445
+ <option value="dark">Dark = Larger</option>
446
+ </select>
447
+ </div>
448
+
449
+ <div class="control">
450
+ <label class="control-label" for="fallbackShape">Fallback Shape</label>
451
+ <select id="fallbackShape" data-lock>
452
+ <option value="circle">Circle</option>
453
+ <option value="square">Square</option>
454
+ <option value="triangle">Triangle</option>
455
+ <option value="star">Star</option>
456
+ <option value="diamond">Diamond</option>
457
+ <option value="cross">Cross</option>
458
+ </select>
459
+ </div>
460
+
461
+ <div class="control">
462
+ <label class="control-label" for="colorMode">SVG Color</label>
463
+ <select id="colorMode" data-lock>
464
+ <option value="sample">Tint to Video Color</option>
465
+ <option value="native">Keep Original SVG</option>
466
+ <option value="white">Tint White</option>
467
+ </select>
468
+ </div>
469
+
470
+ <div class="toggle-row">
471
+ <label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by Brightness</label>
472
+ </div>
473
+ </section>
474
+
475
+ <section class="section">
476
+ <h2>4. Background & Export</h2>
477
+
478
+ <div class="toggle-row">
479
+ <label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent Background</label>
480
+ </div>
481
+
482
+ <div class="color-row" style="margin: 10px 0">
483
+ <label for="bgColor">Opaque Bg Color</label>
484
+ <input id="bgColor" data-lock type="color" value="#10141b">
485
+ </div>
486
+
487
+ <div class="control">
488
+ <label class="control-label" for="backgroundMode">Cutout Logic</label>
489
+ <select id="backgroundMode" data-lock>
490
+ <option value="dark">Remove Dark Pixels</option>
491
+ <option value="light">Remove Light Pixels</option>
492
+ <option value="none">Keep All</option>
493
+ </select>
494
+ </div>
495
+
496
+ <div class="control">
497
+ <label class="control-label" for="cutoff">
498
+ <span>Cutout Threshold</span><output id="cutoffValue">5%</output>
499
+ </label>
500
+ <input id="cutoff" data-lock type="range" min="0" max="95" value="5">
501
+ </div>
502
+
503
+ <div class="control">
504
+ <label class="control-label" for="outputWidth">Output Width</label>
505
+ <input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="480">
506
+ </div>
507
+
508
+ <div class="control">
509
+ <label class="control-label" for="duration">Duration (sec)</label>
510
+ <input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4">
511
+ </div>
512
+
513
+ <div class="control">
514
+ <label class="control-label" for="fps">Export FPS</label>
515
+ <input id="fps" data-lock type="number" min="4" max="20" step="1" value="12">
516
+ </div>
517
+
518
+ <div class="button-row" style="margin: 14px 0 9px">
519
+ <button class="btn" id="playPause" disabled data-lock>Play</button>
520
+ <button class="btn" id="saveFrame" data-lock>Save PNG</button>
521
+ </div>
522
+
523
+ <div class="export-grid">
524
+ <button class="btn primary" id="exportSvg" disabled data-lock>SVG</button>
525
+ <button class="btn" id="exportGif" disabled data-lock>GIF</button>
526
+ <button class="btn" id="exportWebm" disabled data-lock>WebM</button>
527
+ <button class="btn" id="exportMp4" disabled data-lock>MP4</button>
528
+ </div>
529
+
530
+ <p class="small-note">
531
+ WebM/MP4 export contains no audio. MP4 is always opaque. GIF transparency is 1-bit (on/off).
532
+ </p>
533
+ </section>
534
+ </aside>
535
+
536
+ <section class="stage">
537
+ <div class="stage-head">
538
+ <h2 class="stage-title">Preview</h2>
539
+ <span class="stage-hint">Checkerboard = Transparency</span>
540
+ </div>
541
+
542
+ <div class="preview">
543
+ <canvas id="canvas" width="480" height="270"></canvas>
544
+ <div class="placeholder" id="placeholder">
545
+ <div class="placeholder-icon">✦</div>
546
+ <div>Choose a video to start.<br>Add SVGs to use them as animated cells.</div>
547
+ </div>
548
+ </div>
549
+
550
+ <div class="timeline">
551
+ <button class="btn" id="timelinePlay" disabled data-lock style="min-height: 32px; padding: 6px 12px;">▶</button>
552
+ <input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock>
553
+ <span class="time-label" id="timeLabel">0:00 / 0:00</span>
554
+ </div>
555
+
556
+ <p class="status" id="status" aria-live="polite">Ready. Load a video to begin.</p>
557
+ </section>
558
+ </div>
559
+ </div>
560
+
561
+ <!-- GIF encoding dependency. Other exports do not require this library. -->
562
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
563
+
564
+ <script>
565
+ (() => {
566
+ 'use strict';
567
+
568
+ const $ = id => document.getElementById(id);
569
+
570
+ const video = $('sourceVideo');
571
+ const canvas = $('canvas');
572
+ const ctx = canvas.getContext('2d', { alpha: true });
573
+
574
+ const sampleCanvas = document.createElement('canvas');
575
+ const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true });
576
+
577
+ const tintCanvas = document.createElement('canvas');
578
+ tintCanvas.width = 32;
579
+ tintCanvas.height = 32;
580
+ const tintCtx = tintCanvas.getContext('2d');
581
+
582
+ let videoUrl = null;
583
+ let svgAssets = [];
584
+ let busy = false;
585
+ let recordingOpaque = false;
586
+ let lastRenderedTime = -1;
587
+
588
+ const exportButtons = [
589
+ $('exportSvg'), $('exportGif'), $('exportWebm'), $('exportMp4')
590
+ ];
591
+
592
+ function setStatus(message) {
593
+ $('status').textContent = message;
594
+ }
595
+
596
+ function syncActionButtons() {
597
+ const hasVideo = !!video.videoWidth;
598
+ $('playPause').disabled = busy || !hasVideo;
599
+ $('timelinePlay').disabled = busy || !hasVideo;
600
+ $('seek').disabled = busy || !hasVideo;
601
+ for (const button of exportButtons) {
602
+ button.disabled = busy || !hasVideo;
603
+ }
604
+ }
605
+
606
+ function setBusy(value) {
607
+ busy = value;
608
+ document.querySelectorAll('[data-lock]').forEach(el => {
609
+ el.disabled = value;
610
+ });
611
+ syncActionButtons();
612
+ }
613
+
614
+ function clamp(value, min, max) {
615
+ return Math.max(min, Math.min(max, value));
616
+ }
617
+
618
+ function updateLabels() {
619
+ $('cellValue').textContent = `${$('cellSize').value} px`;
620
+ $('scaleValue').textContent = Number($('scale').value).toFixed(2);
621
+ $('gapValue').textContent = `${$('gap').value} px`;
622
+
623
+ const contrast = Number($('contrast').value);
624
+ $('contrastValue').textContent = `${contrast > 0 ? '+' : ''}${contrast}`;
625
+
626
+ $('cutoffValue').textContent = `${$('cutoff').value}%`;
627
+
628
+ const brightness = Number($('brightness').value);
629
+ $('brightnessValue').textContent = `${brightness > 0 ? '+' : ''}${brightness}`;
630
+
631
+ const saturation = Number($('saturation').value);
632
+ $('saturationValue').textContent = `${saturation > 0 ? '+' : ''}${saturation}`;
633
+
634
+ $('tintOpacityValue').textContent = `${$('tintOpacity').value}%`;
635
+ }
636
+
637
+ function resizeCanvasForVideo() {
638
+ if (!video.videoWidth || !video.videoHeight) return;
639
+
640
+ const wantedWidth = clamp(Number($('outputWidth').value) || 480, 240, 1280);
641
+ const maxHeight = 1280;
642
+ const ratio = Math.min(
643
+ wantedWidth / video.videoWidth,
644
+ maxHeight / video.videoHeight
645
+ );
646
+
647
+ const width = Math.max(1, Math.round(video.videoWidth * ratio));
648
+ const height = Math.max(1, Math.round(video.videoHeight * ratio));
649
+
650
+ if (canvas.width !== width || canvas.height !== height) {
651
+ canvas.width = width;
652
+ canvas.height = height;
653
+ sampleCanvas.width = width;
654
+ sampleCanvas.height = height;
655
+ }
656
+ }
657
+
658
+ function adjustChannel(value, factor) {
659
+ return clamp(factor * (value - 128) + 128, 0, 255);
660
+ }
661
+
662
+ // Color Grading Logic
663
+ function gradeColor(r, g, b) {
664
+ // Brightness adjustment
665
+ const brightness = Number($('brightness').value);
666
+ if (brightness !== 0) {
667
+ const adj = brightness * 2.55;
668
+ r += adj;
669
+ g += adj;
670
+ b += adj;
671
+ }
672
+
673
+ // Saturation adjustment
674
+ const saturation = Number($('saturation').value);
675
+ if (saturation !== 0) {
676
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
677
+ const factor = 1 + (saturation / 100);
678
+ r = luma + (r - luma) * factor;
679
+ g = luma + (g - luma) * factor;
680
+ b = luma + (b - luma) * factor;
681
+ }
682
+
683
+ // Tint overlay
684
+ const tintOpacity = Number($('tintOpacity').value) / 100;
685
+ if (tintOpacity > 0) {
686
+ const hex = $('tintColor').value.replace(/^#/, '');
687
+ const tR = parseInt(hex.substring(0, 2), 16);
688
+ const tG = parseInt(hex.substring(2, 4), 16);
689
+ const tB = parseInt(hex.substring(4, 6), 16);
690
+
691
+ r = r * (1 - tintOpacity) + tR * tintOpacity;
692
+ g = g * (1 - tintOpacity) + tG * tintOpacity;
693
+ b = b * (1 - tintOpacity) + tB * tintOpacity;
694
+ }
695
+
696
+ return [
697
+ clamp(Math.round(r), 0, 255),
698
+ clamp(Math.round(g), 0, 255),
699
+ clamp(Math.round(b), 0, 255)
700
+ ];
701
+ }
702
+
703
+ function drawAssetCentered(targetCtx, asset, boxSize) {
704
+ const iw = asset.width || asset.image.naturalWidth || 100;
705
+ const ih = asset.height || asset.image.naturalHeight || 100;
706
+ const scale = Math.min(boxSize / iw, boxSize / ih);
707
+
708
+ targetCtx.drawImage(
709
+ asset.image,
710
+ -iw * scale / 2,
711
+ -ih * scale / 2,
712
+ iw * scale,
713
+ ih * scale
714
+ );
715
+ }
716
+
717
+ function drawBuiltin(targetCtx, type, size, color) {
718
+ const r = size / 2;
719
+ targetCtx.fillStyle = color;
720
+ targetCtx.beginPath();
721
+
722
+ switch (type) {
723
+ case 'square':
724
+ targetCtx.rect(-r, -r, size, size);
725
+ break;
726
+
727
+ case 'triangle':
728
+ targetCtx.moveTo(0, -r);
729
+ targetCtx.lineTo(r, r);
730
+ targetCtx.lineTo(-r, r);
731
+ targetCtx.closePath();
732
+ break;
733
+
734
+ case 'star':
735
+ for (let i = 0; i < 10; i++) {
736
+ const angle = -Math.PI / 2 + i * Math.PI / 5;
737
+ const radius = i % 2 === 0 ? r : r * 0.43;
738
+ const x = Math.cos(angle) * radius;
739
+ const y = Math.sin(angle) * radius;
740
+ if (i === 0) targetCtx.moveTo(x, y);
741
+ else targetCtx.lineTo(x, y);
742
+ }
743
+ targetCtx.closePath();
744
+ break;
745
+
746
+ case 'diamond':
747
+ targetCtx.moveTo(0, -r);
748
+ targetCtx.lineTo(r, 0);
749
+ targetCtx.lineTo(0, r);
750
+ targetCtx.lineTo(-r, 0);
751
+ targetCtx.closePath();
752
+ break;
753
+
754
+ case 'cross':
755
+ targetCtx.rect(-r * 0.28, -r, r * 0.56, size);
756
+ targetCtx.rect(-r, -r * 0.28, size, r * 0.56);
757
+ break;
758
+
759
+ default:
760
+ targetCtx.arc(0, 0, r, 0, Math.PI * 2);
761
+ break;
762
+ }
763
+
764
+ targetCtx.fill();
765
+ }
766
+
767
+ function drawTintedAsset(asset, color, size) {
768
+ tintCtx.clearRect(0, 0, tintCanvas.width, tintCanvas.height);
769
+ tintCtx.save();
770
+ tintCtx.translate(tintCanvas.width / 2, tintCanvas.height / 2);
771
+ drawAssetCentered(tintCtx, asset, tintCanvas.width - 2);
772
+ tintCtx.restore();
773
+
774
+ tintCtx.globalCompositeOperation = 'source-in';
775
+ tintCtx.fillStyle = color;
776
+ tintCtx.fillRect(0, 0, tintCanvas.width, tintCanvas.height);
777
+ tintCtx.globalCompositeOperation = 'source-over';
778
+
779
+ ctx.drawImage(
780
+ tintCanvas,
781
+ -size / 2,
782
+ -size / 2,
783
+ size,
784
+ size
785
+ );
786
+ }
787
+
788
+ function renderFrame(forceOpaque = false) {
789
+ if (!video.videoWidth || video.readyState < 2) return;
790
+
791
+ resizeCanvasForVideo();
792
+
793
+ const width = canvas.width;
794
+ const height = canvas.height;
795
+
796
+ sampleCtx.drawImage(video, 0, 0, width, height);
797
+ const pixels = sampleCtx.getImageData(0, 0, width, height).data;
798
+
799
+ ctx.clearRect(0, 0, width, height);
800
+
801
+ const isTransparent = $('transparent').checked && !forceOpaque;
802
+ if (!isTransparent) {
803
+ ctx.fillStyle = $('bgColor').value;
804
+ ctx.fillRect(0, 0, width, height);
805
+ }
806
+
807
+ const cell = Number($('cellSize').value);
808
+ const scaleSetting = Number($('scale').value);
809
+ const gap = Number($('gap').value);
810
+ const cutoff = Number($('cutoff').value) / 100;
811
+ const contrast = Number($('contrast').value);
812
+ const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
813
+ const columns = Math.ceil(width / cell);
814
+ const rows = Math.ceil(height / cell);
815
+ const sizeMode = $('sizeMode').value;
816
+ const backgroundMode = $('backgroundMode').value;
817
+ const colorMode = $('colorMode').value;
818
+ const rotate = $('rotate').checked;
819
+ const fallbackShape = $('fallbackShape').value;
820
+
821
+ for (let row = 0; row < rows; row++) {
822
+ const y = row * cell;
823
+
824
+ for (let column = 0; column < columns; column++) {
825
+ const x = column * cell;
826
+ const sampleX = Math.min(width - 1, Math.floor(x + cell / 2));
827
+ const sampleY = Math.min(height - 1, Math.floor(y + cell / 2));
828
+ const index = (sampleY * width + sampleX) * 4;
829
+
830
+ const alpha = pixels[index + 3] / 255;
831
+ if (alpha < 0.04) continue;
832
+
833
+ let red = adjustChannel(pixels[index], contrastFactor);
834
+ let green = adjustChannel(pixels[index + 1], contrastFactor);
835
+ let blue = adjustChannel(pixels[index + 2], contrastFactor);
836
+
837
+ // Apply Color Grading
838
+ [red, green, blue] = gradeColor(red, green, blue);
839
+
840
+ const luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
841
+
842
+ if (backgroundMode === 'dark' && luma < cutoff) continue;
843
+ if (backgroundMode === 'light' && luma > 1 - cutoff) continue;
844
+
845
+ const brightness = sizeMode === 'dark' ? 1 - luma : luma;
846
+ const size = Math.min(
847
+ cell * 1.8,
848
+ Math.max(0, cell * scaleSetting * (0.12 + 0.88 * brightness) - gap)
849
+ );
850
+
851
+ if (size < 1) continue;
852
+
853
+ const color = colorMode === 'white'
854
+ ? '#ffffff'
855
+ : `rgb(${Math.round(red)},${Math.round(green)},${Math.round(blue)})`;
856
+
857
+ const asset = svgAssets.length
858
+ ? svgAssets[(row * columns + column) % svgAssets.length]
859
+ : null;
860
+
861
+ ctx.save();
862
+ ctx.translate(x + cell / 2, y + cell / 2);
863
+ ctx.globalAlpha = alpha;
864
+
865
+ if (rotate) {
866
+ ctx.rotate((luma - 0.5) * Math.PI);
867
+ }
868
+
869
+ if (asset) {
870
+ if (colorMode === 'native') {
871
+ drawAssetCentered(ctx, asset, size);
872
+ } else {
873
+ drawTintedAsset(asset, color, size);
874
+ }
875
+ } else {
876
+ drawBuiltin(ctx, fallbackShape, size, color);
877
+ }
878
+
879
+ ctx.restore();
880
+ }
881
+ }
882
+
883
+ lastRenderedTime = video.currentTime;
884
+ $('placeholder').style.display = 'none';
885
+ }
886
+
887
+ function renderSafely() {
888
+ try {
889
+ renderFrame(recordingOpaque);
890
+ } catch (error) {
891
+ console.error(error);
892
+ setStatus(`Preview error: ${error.message}`);
893
+ }
894
+ }
895
+
896
+ function sanitizeSvg(source) {
897
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
898
+
899
+ if (doc.querySelector('parsererror')) {
900
+ throw new Error('The selected file is not valid SVG.');
901
+ }
902
+
903
+ const root = doc.documentElement;
904
+ if (!root || root.localName.toLowerCase() !== 'svg') {
905
+ throw new Error('No <svg> element was found in that file.');
906
+ }
907
+
908
+ const blocked = new Set(['script', 'foreignobject', 'iframe', 'object', 'embed']);
909
+
910
+ for (const element of [...root.querySelectorAll('*')]) {
911
+ if (blocked.has(element.localName.toLowerCase())) {
912
+ element.remove();
913
+ continue;
914
+ }
915
+
916
+ for (const attribute of [...element.attributes]) {
917
+ const name = attribute.name.toLowerCase();
918
+ const value = attribute.value.trim();
919
+
920
+ if (name.startsWith('on')) {
921
+ element.removeAttributeNode(attribute);
922
+ continue;
923
+ }
924
+
925
+ if ((name === 'href' || name === 'xlink:href' || name === 'src') &&
926
+ value &&
927
+ !value.startsWith('#') &&
928
+ !/^data:image\//i.test(value)) {
929
+ element.removeAttributeNode(attribute);
930
+ continue;
931
+ }
932
+
933
+ if (/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(value)) {
934
+ element.removeAttributeNode(attribute);
935
+ }
936
+ }
937
+ }
938
+
939
+ for (const style of [...root.querySelectorAll('style')]) {
940
+ if (/@import|url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(style.textContent)) {
941
+ style.remove();
942
+ }
943
+ }
944
+
945
+ if (!root.hasAttribute('xmlns')) {
946
+ root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
947
+ }
948
+
949
+ const viewBoxText = root.getAttribute('viewBox') || '';
950
+ const values = viewBoxText.trim().split(/[\s,]+/).map(Number);
951
+
952
+ let viewX = 0;
953
+ let viewY = 0;
954
+ let viewWidth = 100;
955
+ let viewHeight = 100;
956
+
957
+ if (values.length === 4 && values.every(Number.isFinite) && values[2] > 0 && values[3] > 0) {
958
+ [viewX, viewY, viewWidth, viewHeight] = values;
959
+ } else {
960
+ viewWidth = parseFloat(root.getAttribute('width')) || 100;
961
+ viewHeight = parseFloat(root.getAttribute('height')) || 100;
962
+ root.setAttribute('viewBox', `0 0 ${viewWidth} ${viewHeight}`);
963
+ }
964
+
965
+ root.setAttribute('width', String(viewWidth));
966
+ root.setAttribute('height', String(viewHeight));
967
+
968
+ return {
969
+ text: new XMLSerializer().serializeToString(root),
970
+ width: viewWidth,
971
+ height: viewHeight
972
+ };
973
+ }
974
+
975
+ function loadSvgImage(file, parsed) {
976
+ const blob = new Blob([parsed.text], { type: 'image/svg+xml;charset=utf-8' });
977
+ const url = URL.createObjectURL(blob);
978
+
979
+ return new Promise((resolve, reject) => {
980
+ const image = new Image();
981
+
982
+ image.onload = () => resolve({
983
+ name: file.name,
984
+ image,
985
+ url,
986
+ width: parsed.width,
987
+ height: parsed.height
988
+ });
989
+
990
+ image.onerror = () => {
991
+ URL.revokeObjectURL(url);
992
+ reject(new Error(`Could not load ${file.name}.`));
993
+ };
994
+
995
+ image.src = url;
996
+ });
997
+ }
998
+
999
+ function updateIconStatus() {
1000
+ if (!svgAssets.length) {
1001
+ $('iconStatus').textContent = 'No SVGs loaded — circles will be used.';
1002
+ return;
1003
+ }
1004
+
1005
+ $('iconStatus').textContent =
1006
+ `${svgAssets.length} SVG${svgAssets.length === 1 ? '' : 's'} loaded: ` +
1007
+ svgAssets.map(asset => asset.name).join(', ');
1008
+ }
1009
+
1010
+ function clearSvgAssets() {
1011
+ for (const asset of svgAssets) URL.revokeObjectURL(asset.url);
1012
+ svgAssets = [];
1013
+ updateIconStatus();
1014
+ renderSafely();
1015
+ }
1016
+
1017
+ function formatTime(seconds) {
1018
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
1019
+ const minutes = Math.floor(seconds / 60);
1020
+ const wholeSeconds = Math.floor(seconds % 60);
1021
+ return `${minutes}:${String(wholeSeconds).padStart(2, '0')}`;
1022
+ }
1023
+
1024
+ function updateTimeline() {
1025
+ if (!video.videoWidth || !Number.isFinite(video.duration) || video.duration <= 0) {
1026
+ $('timeLabel').textContent = '0:00 / 0:00';
1027
+ return;
1028
+ }
1029
+
1030
+ $('seek').value = String(Math.round(video.currentTime / video.duration * 1000));
1031
+ $('timeLabel').textContent =
1032
+ `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
1033
+ }
1034
+
1035
+ function seekTo(seconds) {
1036
+ return new Promise((resolve, reject) => {
1037
+ const duration = video.duration;
1038
+ const maximum = Number.isFinite(duration) && duration > 0
1039
+ ? Math.max(0, duration - 0.01)
1040
+ : Math.max(0, seconds);
1041
+
1042
+ const target = clamp(seconds, 0, maximum);
1043
+
1044
+ if (!video.seeking && Math.abs(video.currentTime - target) < 0.001) {
1045
+ resolve();
1046
+ return;
1047
+ }
1048
+
1049
+ let timer;
1050
+
1051
+ const finish = () => {
1052
+ clearTimeout(timer);
1053
+ video.removeEventListener('seeked', finish);
1054
+ resolve();
1055
+ };
1056
+
1057
+ video.addEventListener('seeked', finish);
1058
+ timer = setTimeout(finish, 8000);
1059
+
1060
+ try {
1061
+ video.currentTime = target;
1062
+ } catch (error) {
1063
+ clearTimeout(timer);
1064
+ video.removeEventListener('seeked', finish);
1065
+ reject(error);
1066
+ }
1067
+ });
1068
+ }
1069
+
1070
+ function exportSettings() {
1071
+ const fps = clamp(Math.round(Number($('fps').value) || 12), 4, 20);
1072
+ const seconds = clamp(Number($('duration').value) || 4, 0.5, 15);
1073
+ const frameCount = Math.max(1, Math.ceil(seconds * fps));
1074
+
1075
+ return {
1076
+ fps,
1077
+ frameCount,
1078
+ duration: frameCount / fps
1079
+ };
1080
+ }
1081
+
1082
+ async function captureFrames(onFrame, label) {
1083
+ if (!video.videoWidth) throw new Error('Choose a video first.');
1084
+
1085
+ const settings = exportSettings();
1086
+ const wasPlaying = !video.paused;
1087
+ const previousTime = video.currentTime;
1088
+ const mediaDuration = Number.isFinite(video.duration) && video.duration > 0
1089
+ ? video.duration
1090
+ : null;
1091
+
1092
+ video.pause();
1093
+
1094
+ try {
1095
+ for (let i = 0; i < settings.frameCount; i++) {
1096
+ const time = mediaDuration
1097
+ ? (i / settings.fps) % mediaDuration
1098
+ : i / settings.fps;
1099
+
1100
+ await seekTo(time);
1101
+ await new Promise(resolve => requestAnimationFrame(resolve));
1102
+ renderFrame(false);
1103
+
1104
+ await onFrame(i, {
1105
+ ...settings,
1106
+ width: canvas.width,
1107
+ height: canvas.height
1108
+ });
1109
+
1110
+ setStatus(`${label}: rendering frame ${i + 1} of ${settings.frameCount}…`);
1111
+ }
1112
+
1113
+ return {
1114
+ ...settings,
1115
+ width: canvas.width,
1116
+ height: canvas.height
1117
+ };
1118
+ } finally {
1119
+ try {
1120
+ await seekTo(previousTime);
1121
+ } catch (_) {}
1122
+
1123
+ if (wasPlaying) {
1124
+ await video.play().catch(() => {});
1125
+ }
1126
+
1127
+ renderSafely();
1128
+ updateTimeline();
1129
+ }
1130
+ }
1131
+
1132
+ function downloadBlob(blob, filename) {
1133
+ const url = URL.createObjectURL(blob);
1134
+ const link = document.createElement('a');
1135
+
1136
+ link.href = url;
1137
+ link.download = filename;
1138
+ document.body.appendChild(link);
1139
+ link.click();
1140
+ link.remove();
1141
+
1142
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
1143
+ }
1144
+
1145
+ function numberText(value) {
1146
+ return Number(value.toFixed(6)).toString();
1147
+ }
1148
+
1149
+ function makeAnimatedSvg(frames, width, height, fps) {
1150
+ const count = frames.length;
1151
+ const duration = count / fps;
1152
+ const durationText = numberText(duration);
1153
+
1154
+ const imageElements = frames.map((data, index) => {
1155
+ let animation = '';
1156
+
1157
+ if (count > 1) {
1158
+ let values;
1159
+ let keyTimes;
1160
+
1161
+ if (index === 0) {
1162
+ values = 'visible;hidden;hidden';
1163
+ keyTimes = `0;${numberText(1 / count)};1`;
1164
+ } else if (index === count - 1) {
1165
+ values = `hidden;visible;hidden`;
1166
+ keyTimes = `0;${numberText(index / count)};1`;
1167
+ } else {
1168
+ values = 'hidden;visible;hidden;hidden';
1169
+ keyTimes =
1170
+ `0;${numberText(index / count)};${numberText((index + 1) / count)};1`;
1171
+ }
1172
+
1173
+ animation =
1174
+ `<animate attributeName="visibility" calcMode="discrete" ` +
1175
+ `values="${values}" keyTimes="${keyTimes}" ` +
1176
+ `dur="${durationText}s" repeatCount="indefinite"/>`;
1177
+ }
1178
+
1179
+ const initialVisibility = index === 0 ? 'visible' : 'hidden';
1180
+
1181
+ return `<image x="0" y="0" width="${width}" height="${height}" ` +
1182
+ `visibility="${initialVisibility}" href="${data}" xlink:href="${data}">` +
1183
+ `${animation}</image>`;
1184
+ }).join('\n');
1185
+
1186
+ return `<?xml version="1.0" encoding="UTF-8"?>
1187
+ <svg xmlns="http://www.w3.org/2000/svg"
1188
+ xmlns:xlink="http://www.w3.org/1999/xlink"
1189
+ width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
1190
+ ${imageElements}
1191
+ </svg>`;
1192
+ }
1193
+
1194
+ $('uploadVideoBtn').addEventListener('click', () => $('videoInput').click());
1195
+ $('addSvgBtn').addEventListener('click', () => $('svgInput').click());
1196
+
1197
+ $('videoInput').addEventListener('change', event => {
1198
+ const file = event.target.files && event.target.files[0];
1199
+ if (!file) return;
1200
+
1201
+ video.pause();
1202
+
1203
+ if (videoUrl) URL.revokeObjectURL(videoUrl);
1204
+ videoUrl = URL.createObjectURL(file);
1205
+
1206
+ video.src = videoUrl;
1207
+ video.loop = true;
1208
+ video.muted = true;
1209
+ video.load();
1210
+
1211
+ $('placeholder').style.display = 'block';
1212
+ setStatus(`Loading ${file.name}…`);
1213
+ event.target.value = '';
1214
+ });
1215
+
1216
+ video.addEventListener('loadedmetadata', () => {
1217
+ resizeCanvasForVideo();
1218
+ $('seek').disabled = false;
1219
+ $('timelinePlay').disabled = false;
1220
+ $('playPause').disabled = false;
1221
+ $('placeholder').style.display = 'none';
1222
+ updateTimeline();
1223
+ renderSafely();
1224
+ syncActionButtons();
1225
+
1226
+ setStatus(`Video loaded: ${video.videoWidth} × ${video.videoHeight}.`);
1227
+ video.play().catch(() => {
1228
+ setStatus('Video loaded. Press Play to start the preview.');
1229
+ });
1230
+ });
1231
+
1232
+ video.addEventListener('loadeddata', () => renderSafely());
1233
+ video.addEventListener('seeked', () => {
1234
+ renderSafely();
1235
+ updateTimeline();
1236
+ });
1237
+ video.addEventListener('timeupdate', updateTimeline);
1238
+ video.addEventListener('play', () => {
1239
+ $('playPause').textContent = 'Pause';
1240
+ $('timelinePlay').textContent = '⏸';
1241
+ });
1242
+ video.addEventListener('pause', () => {
1243
+ $('playPause').textContent = 'Play';
1244
+ $('timelinePlay').textContent = '▶';
1245
+ renderSafely();
1246
+ });
1247
+ video.addEventListener('error', () => {
1248
+ setStatus('Could not read that video. Try an MP4 or WebM file supported by your browser.');
1249
+ });
1250
+
1251
+ $('svgInput').addEventListener('change', async event => {
1252
+ const files = [...(event.target.files || [])];
1253
+ event.target.value = '';
1254
+
1255
+ if (!files.length) return;
1256
+
1257
+ let added = 0;
1258
+
1259
+ for (const file of files) {
1260
+ if (!file.name.toLowerCase().endsWith('.svg') && file.type !== 'image/svg+xml') {
1261
+ continue;
1262
+ }
1263
+
1264
+ try {
1265
+ const parsed = sanitizeSvg(await file.text());
1266
+ const asset = await loadSvgImage(file, parsed);
1267
+ svgAssets.push(asset);
1268
+ added++;
1269
+ } catch (error) {
1270
+ console.warn(error);
1271
+ setStatus(error.message);
1272
+ }
1273
+ }
1274
+
1275
+ updateIconStatus();
1276
+ renderSafely();
1277
+
1278
+ if (added) {
1279
+ setStatus(`Added ${added} SVG${added === 1 ? '' : 's'}. All loaded SVGs cycle across the cells.`);
1280
+ }
1281
+ });
1282
+
1283
+ $('clearSvgBtn').addEventListener('click', clearSvgAssets);
1284
+
1285
+ function togglePlayback() {
1286
+ if (!video.videoWidth) return;
1287
+
1288
+ if (video.paused) {
1289
+ video.play().catch(() => setStatus('The video could not be played.'));
1290
+ } else {
1291
+ video.pause();
1292
+ }
1293
+ }
1294
+
1295
+ $('playPause').addEventListener('click', togglePlayback);
1296
+ $('timelinePlay').addEventListener('click', togglePlayback);
1297
+
1298
+ $('seek').addEventListener('input', event => {
1299
+ if (!video.videoWidth || !Number.isFinite(video.duration)) return;
1300
+
1301
+ video.pause();
1302
+ const fraction = Number(event.target.value) / 1000;
1303
+ video.currentTime = fraction * video.duration;
1304
+ });
1305
+
1306
+ const renderControlIds = [
1307
+ 'cellSize', 'scale', 'gap', 'contrast', 'sizeMode',
1308
+ 'backgroundMode', 'cutoff', 'fallbackShape', 'colorMode',
1309
+ 'rotate', 'transparent', 'bgColor', 'outputWidth',
1310
+ 'brightness', 'saturation', 'tintColor', 'tintOpacity'
1311
+ ];
1312
+
1313
+ for (const id of renderControlIds) {
1314
+ $(id).addEventListener('input', () => {
1315
+ updateLabels();
1316
+ renderSafely();
1317
+ });
1318
+ $(id).addEventListener('change', () => {
1319
+ updateLabels();
1320
+ renderSafely();
1321
+ });
1322
+ }
1323
+
1324
+ $('saveFrame').addEventListener('click', async () => {
1325
+ if (!video.videoWidth) {
1326
+ setStatus('Choose a video first.');
1327
+ return;
1328
+ }
1329
+
1330
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
1331
+ if (!blob) {
1332
+ setStatus('Could not create the PNG.');
1333
+ return;
1334
+ }
1335
+
1336
+ downloadBlob(blob, `brain-motion-${Date.now()}.png`);
1337
+ setStatus('Saved the current frame as a transparent PNG.');
1338
+ });
1339
+
1340
+ $('exportSvg').addEventListener('click', async () => {
1341
+ setBusy(true);
1342
+ setStatus('Preparing animated SVG frames…');
1343
+
1344
+ try {
1345
+ const frames = [];
1346
+ const meta = await captureFrames(async () => {
1347
+ frames.push(canvas.toDataURL('image/png'));
1348
+ }, 'SVG');
1349
+
1350
+ const svg = makeAnimatedSvg(frames, meta.width, meta.height, meta.fps);
1351
+ downloadBlob(
1352
+ new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
1353
+ `brain-motion-${Date.now()}.svg`
1354
+ );
1355
+
1356
+ setStatus('Saved animated SVG. It embeds transparent PNG frames inside an SVG animation.');
1357
+ } catch (error) {
1358
+ setStatus(`SVG export failed: ${error.message}`);
1359
+ } finally {
1360
+ setBusy(false);
1361
+ }
1362
+ });
1363
+
1364
+ async function fetchGifWorker() {
1365
+ const url = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
1366
+ const response = await fetch(url);
1367
+
1368
+ if (!response.ok) {
1369
+ throw new Error('Could not download the GIF encoder worker. Run this page from localhost and check your internet connection.');
1370
+ }
1371
+
1372
+ const workerSource = await response.text();
1373
+ return URL.createObjectURL(
1374
+ new Blob([workerSource], { type: 'application/javascript' })
1375
+ );
1376
+ }
1377
+
1378
+ $('exportGif').addEventListener('click', async () => {
1379
+ if (!window.GIF) {
1380
+ setStatus('GIF encoder did not load. Check your internet connection and reload the page.');
1381
+ return;
1382
+ }
1383
+
1384
+ setBusy(true);
1385
+ let workerUrl = null;
1386
+
1387
+ try {
1388
+ setStatus('Loading GIF encoder…');
1389
+ workerUrl = await fetchGifWorker();
1390
+
1391
+ const options = {
1392
+ workers: 2,
1393
+ quality: 10,
1394
+ repeat: 0,
1395
+ width: canvas.width,
1396
+ height: canvas.height,
1397
+ workerScript: workerUrl
1398
+ };
1399
+
1400
+ const usesTransparency = $('transparent').checked;
1401
+ const transparentKey = 0xff00ff;
1402
+
1403
+ if (usesTransparency) options.transparent = transparentKey;
1404
+
1405
+ const gif = new window.GIF(options);
1406
+ const gifCanvas = document.createElement('canvas');
1407
+ gifCanvas.width = canvas.width;
1408
+ gifCanvas.height = canvas.height;
1409
+
1410
+ const gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
1411
+
1412
+ const finished = new Promise((resolve, reject) => {
1413
+ gif.on('finished', resolve);
1414
+ gif.on('error', reject);
1415
+ gif.on('progress', progress => {
1416
+ setStatus(`Encoding GIF: ${Math.round(progress * 100)}%…`);
1417
+ });
1418
+ });
1419
+
1420
+ await captureFrames(async (index, meta) => {
1421
+ const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
1422
+ const data = frame.data;
1423
+
1424
+ for (let i = 0; i < data.length; i += 4) {
1425
+ if (usesTransparency && data[i + 3] < 128) {
1426
+ data[i] = 255;
1427
+ data[i + 1] = 0;
1428
+ data[i + 2] = 255;
1429
+ data[i + 3] = 255;
1430
+ } else {
1431
+ data[i + 3] = 255;
1432
+
1433
+ // Keep actual magenta artwork from matching the transparent key.
1434
+ if (usesTransparency && data[i] === 255 && data[i + 1] === 0 && data[i + 2] === 255) {
1435
+ data[i + 1] = 1;
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ gifCtx.putImageData(frame, 0, 0);
1441
+ gif.addFrame(gifCanvas, {
1442
+ copy: true,
1443
+ delay: Math.max(20, Math.round(1000 / meta.fps))
1444
+ });
1445
+
1446
+ setStatus(`GIF: preparing frame ${index + 1} of ${meta.frameCount}…`);
1447
+ }, 'GIF');
1448
+
1449
+ setStatus('Encoding GIF…');
1450
+ gif.render();
1451
+
1452
+ const blob = await finished;
1453
+ downloadBlob(blob, `brain-motion-${Date.now()}.gif`);
1454
+ setStatus('Saved GIF. GIF transparency is limited to transparent/opaque pixels.');
1455
+ } catch (error) {
1456
+ console.error(error);
1457
+ setStatus(`GIF export failed: ${error.message || 'Check that the page is served from localhost.'}`);
1458
+ } finally {
1459
+ if (workerUrl) URL.revokeObjectURL(workerUrl);
1460
+ setBusy(false);
1461
+ }
1462
+ });
1463
+
1464
+ async function exportRecordedVideo(kind) {
1465
+ if (!video.videoWidth) {
1466
+ setStatus('Choose a video first.');
1467
+ return;
1468
+ }
1469
+
1470
+ if (!window.MediaRecorder || !canvas.captureStream) {
1471
+ setStatus('This browser does not support canvas video recording. Try a current version of Chrome or Edge.');
1472
+ return;
1473
+ }
1474
+
1475
+ const mimeCandidates = kind === 'mp4'
1476
+ ? ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']
1477
+ : ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
1478
+
1479
+ const mimeType = mimeCandidates.find(type => MediaRecorder.isTypeSupported(type));
1480
+
1481
+ if (!mimeType) {
1482
+ setStatus(kind === 'mp4'
1483
+ ? 'MP4 recording is not supported by this browser. MP4 support is browser-dependent; use WebM instead.'
1484
+ : 'WebM recording is not supported by this browser.');
1485
+ return;
1486
+ }
1487
+
1488
+ setBusy(true);
1489
+
1490
+ const settings = exportSettings();
1491
+ const wasPlaying = !video.paused;
1492
+ const previousTime = video.currentTime;
1493
+ let stream = null;
1494
+ let recorder = null;
1495
+
1496
+ try {
1497
+ video.pause();
1498
+ await seekTo(0);
1499
+
1500
+ recordingOpaque = kind === 'mp4';
1501
+ renderFrame(recordingOpaque);
1502
+
1503
+ stream = canvas.captureStream(settings.fps);
1504
+
1505
+ recorder = new MediaRecorder(stream, {
1506
+ mimeType,
1507
+ videoBitsPerSecond: 5_000_000
1508
+ });
1509
+
1510
+ const chunks = [];
1511
+
1512
+ const recordingFinished = new Promise((resolve, reject) => {
1513
+ recorder.addEventListener('dataavailable', event => {
1514
+ if (event.data && event.data.size) chunks.push(event.data);
1515
+ });
1516
+
1517
+ recorder.addEventListener('stop', () => {
1518
+ resolve(new Blob(chunks, { type: recorder.mimeType || mimeType }));
1519
+ });
1520
+
1521
+ recorder.addEventListener('error', event => {
1522
+ reject(event.error || new Error('Video recording failed.'));
1523
+ });
1524
+ });
1525
+
1526
+ recorder.start(200);
1527
+ await video.play();
1528
+
1529
+ setStatus(`Recording ${kind.toUpperCase()} for ${settings.duration.toFixed(1)} seconds…`);
1530
+ await new Promise(resolve => setTimeout(resolve, settings.duration * 1000));
1531
+
1532
+ video.pause();
1533
+ if (recorder.state !== 'inactive') recorder.stop();
1534
+
1535
+ const blob = await recordingFinished;
1536
+ const extension = kind === 'mp4' ? 'mp4' : 'webm';
1537
+
1538
+ downloadBlob(blob, `brain-motion-${Date.now()}.${extension}`);
1539
+
1540
+ setStatus(kind === 'mp4'
1541
+ ? 'Saved MP4 with an opaque background. MP4 does not support transparency.'
1542
+ : 'Saved WebM. Transparent alpha depends on browser and codec support.');
1543
+ } catch (error) {
1544
+ console.error(error);
1545
+ setStatus(`Video export failed: ${error.message}`);
1546
+ } finally {
1547
+ recordingOpaque = false;
1548
+
1549
+ if (recorder && recorder.state !== 'inactive') {
1550
+ try { recorder.stop(); } catch (_) {}
1551
+ }
1552
+
1553
+ if (stream) stream.getTracks().forEach(track => track.stop());
1554
+
1555
+ try {
1556
+ await seekTo(previousTime);
1557
+ } catch (_) {}
1558
+
1559
+ if (wasPlaying) await video.play().catch(() => {});
1560
+
1561
+ renderSafely();
1562
+ setBusy(false);
1563
+ }
1564
+ }
1565
+
1566
+ $('exportWebm').addEventListener('click', () => exportRecordedVideo('webm'));
1567
+ $('exportMp4').addEventListener('click', () => exportRecordedVideo('mp4'));
1568
+
1569
+ function previewLoop() {
1570
+ if (video.videoWidth &&
1571
+ !video.paused &&
1572
+ video.readyState >= 2 &&
1573
+ video.currentTime !== lastRenderedTime) {
1574
+ renderSafely();
1575
+ }
1576
+
1577
+ requestAnimationFrame(previewLoop);
1578
+ }
1579
+
1580
+ updateLabels();
1581
+ updateIconStatus();
1582
+ syncActionButtons();
1583
+ requestAnimationFrame(previewLoop);
1584
+ })();
1585
+ </script>
1586
+ </body>
1587
+ </html>
assests/html_sample/index4.html ADDED
@@ -0,0 +1,1666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Brain Motion Studio v4 — Custom Palette & Shades</title>
7
+
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0b0d12;
12
+ --panel: #14171f;
13
+ --panel-2: #1a1e28;
14
+ --line: #272c3a;
15
+ --text: #edf2fa;
16
+ --muted: #8d97a8;
17
+ --accent: #8a7dff;
18
+ --accent-2: #6258cf;
19
+ --radius: 14px;
20
+ }
21
+
22
+ * { box-sizing: border-box; }
23
+
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ background: radial-gradient(circle at top left, #151929, var(--bg) 60%);
28
+ color: var(--text);
29
+ font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
30
+ }
31
+
32
+ button, input, select { font: inherit; }
33
+
34
+ .app {
35
+ width: min(1480px, 100%);
36
+ min-height: 100vh;
37
+ margin: auto;
38
+ padding: 20px;
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 20px;
42
+ }
43
+
44
+ .topbar {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 16px;
48
+ }
49
+
50
+ .logo {
51
+ display: grid;
52
+ place-items: center;
53
+ width: 46px;
54
+ height: 46px;
55
+ flex: 0 0 auto;
56
+ border-radius: 14px;
57
+ background: linear-gradient(140deg, #a69cff, #5f54cf);
58
+ color: white;
59
+ font-size: 24px;
60
+ box-shadow: 0 4px 12px rgba(98, 88, 207, 0.3);
61
+ }
62
+
63
+ h1 {
64
+ margin: 0;
65
+ font-size: clamp(20px, 3vw, 26px);
66
+ font-weight: 600;
67
+ letter-spacing: -.03em;
68
+ }
69
+
70
+ .subtitle {
71
+ margin: 2px 0 0;
72
+ color: var(--muted);
73
+ font-size: 13px;
74
+ }
75
+
76
+ .badge {
77
+ margin-left: 8px;
78
+ padding: 2px 8px;
79
+ border-radius: 999px;
80
+ background: #252b3d;
81
+ border: 1px solid var(--accent);
82
+ color: #c4beff;
83
+ font-size: 11px;
84
+ font-weight: 600;
85
+ vertical-align: middle;
86
+ }
87
+
88
+ .workspace {
89
+ display: grid;
90
+ grid-template-columns: 340px minmax(0, 1fr);
91
+ gap: 20px;
92
+ align-items: start;
93
+ flex: 1;
94
+ }
95
+
96
+ .panel, .stage {
97
+ border: 1px solid var(--line);
98
+ border-radius: var(--radius);
99
+ background: var(--panel);
100
+ }
101
+
102
+ .panel {
103
+ padding: 16px;
104
+ max-height: calc(100vh - 100px);
105
+ overflow-y: auto;
106
+ scrollbar-width: thin;
107
+ scrollbar-color: var(--line) transparent;
108
+ }
109
+
110
+ .panel::-webkit-scrollbar { width: 6px; }
111
+ .panel::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
112
+
113
+ .section + .section {
114
+ margin-top: 16px;
115
+ padding-top: 16px;
116
+ border-top: 1px solid var(--line);
117
+ }
118
+
119
+ h2 {
120
+ margin: 0 0 12px;
121
+ font-size: 13px;
122
+ font-weight: 600;
123
+ letter-spacing: .03em;
124
+ text-transform: uppercase;
125
+ color: #bcc5d6;
126
+ }
127
+
128
+ .small-note, .status, .icon-status {
129
+ color: var(--muted);
130
+ font-size: 11px;
131
+ }
132
+
133
+ .small-note { margin: 8px 0 0; line-height: 1.4; }
134
+
135
+ .button-row {
136
+ display: flex;
137
+ flex-wrap: wrap;
138
+ gap: 8px;
139
+ }
140
+
141
+ .btn {
142
+ min-height: 36px;
143
+ padding: 8px 14px;
144
+ border: 1px solid var(--line);
145
+ border-radius: 9px;
146
+ background: var(--panel-2);
147
+ color: var(--text);
148
+ cursor: pointer;
149
+ transition: all .15s ease;
150
+ font-weight: 500;
151
+ }
152
+
153
+ .btn:hover:not(:disabled) {
154
+ border-color: #7770d8;
155
+ background: #24283a;
156
+ }
157
+
158
+ .btn:active:not(:disabled) { transform: scale(0.97); }
159
+
160
+ .btn.primary {
161
+ border-color: var(--accent-2);
162
+ background: var(--accent-2);
163
+ color: white;
164
+ }
165
+
166
+ .btn.primary:hover:not(:disabled) { background: #7167e2; }
167
+
168
+ .btn:disabled {
169
+ opacity: .4;
170
+ cursor: not-allowed;
171
+ }
172
+
173
+ .full { width: 100%; }
174
+
175
+ .icon-status {
176
+ min-height: 16px;
177
+ margin: 8px 0 0;
178
+ overflow-wrap: anywhere;
179
+ }
180
+
181
+ .control { margin: 12px 0; }
182
+
183
+ .control-label {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ gap: 10px;
188
+ margin-bottom: 5px;
189
+ color: #dce3ed;
190
+ font-size: 12px;
191
+ }
192
+
193
+ .control-label output {
194
+ color: #b4aaff;
195
+ font-variant-numeric: tabular-nums;
196
+ font-size: 11px;
197
+ }
198
+
199
+ input[type="range"] {
200
+ width: 100%;
201
+ accent-color: var(--accent);
202
+ height: 6px;
203
+ }
204
+
205
+ select, input[type="number"] {
206
+ width: 100%;
207
+ min-height: 34px;
208
+ padding: 6px 10px;
209
+ border: 1px solid var(--line);
210
+ border-radius: 8px;
211
+ background: #10141c;
212
+ color: var(--text);
213
+ }
214
+
215
+ input[type="color"] {
216
+ width: 40px;
217
+ height: 30px;
218
+ padding: 2px;
219
+ border: 1px solid var(--line);
220
+ border-radius: 6px;
221
+ background: #10141c;
222
+ cursor: pointer;
223
+ }
224
+
225
+ .toggle-row {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 14px;
229
+ margin: 10px 0;
230
+ }
231
+
232
+ .toggle {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 7px;
236
+ color: #dce3ed;
237
+ font-size: 12px;
238
+ }
239
+
240
+ .toggle input { accent-color: var(--accent); }
241
+
242
+ .color-row {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: space-between;
246
+ gap: 10px;
247
+ font-size: 12px;
248
+ }
249
+
250
+ .palette-swatches {
251
+ display: flex;
252
+ gap: 6px;
253
+ margin-top: 8px;
254
+ align-items: center;
255
+ }
256
+
257
+ .swatch-btn {
258
+ width: 22px;
259
+ height: 22px;
260
+ border-radius: 50%;
261
+ border: 2px solid #2a3344;
262
+ cursor: pointer;
263
+ transition: transform .15s, border-color .15s;
264
+ padding: 0;
265
+ }
266
+
267
+ .swatch-btn:hover {
268
+ transform: scale(1.15);
269
+ border-color: #a49aff;
270
+ }
271
+
272
+ .export-grid {
273
+ display: grid;
274
+ grid-template-columns: 1fr 1fr;
275
+ gap: 8px;
276
+ }
277
+
278
+ .export-grid .btn { min-height: 42px; font-size: 12px; }
279
+
280
+ .stage {
281
+ min-width: 0;
282
+ padding: 16px;
283
+ display: flex;
284
+ flex-direction: column;
285
+ }
286
+
287
+ .stage-head {
288
+ display: flex;
289
+ justify-content: space-between;
290
+ align-items: center;
291
+ gap: 12px;
292
+ margin-bottom: 12px;
293
+ }
294
+
295
+ .stage-title {
296
+ margin: 0;
297
+ font-size: 14px;
298
+ font-weight: 600;
299
+ }
300
+
301
+ .stage-hint {
302
+ color: var(--muted);
303
+ font-size: 11px;
304
+ }
305
+
306
+ .preview {
307
+ position: relative;
308
+ display: grid;
309
+ place-items: center;
310
+ min-height: 400px;
311
+ overflow: hidden;
312
+ border: 1px solid #303846;
313
+ border-radius: 12px;
314
+ background-color: #10141b;
315
+ background-image:
316
+ linear-gradient(45deg, #1a202a 25%, transparent 25%),
317
+ linear-gradient(-45deg, #1a202a 25%, transparent 25%),
318
+ linear-gradient(45deg, transparent 75%, #1a202a 75%),
319
+ linear-gradient(-45deg, transparent 75%, #1a202a 75%);
320
+ background-size: 20px 20px;
321
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
322
+ flex: 1;
323
+ }
324
+
325
+ canvas {
326
+ display: block;
327
+ width: 100%;
328
+ height: auto;
329
+ max-height: 70vh;
330
+ object-fit: contain;
331
+ }
332
+
333
+ .placeholder {
334
+ position: absolute;
335
+ padding: 30px;
336
+ color: var(--muted);
337
+ text-align: center;
338
+ pointer-events: none;
339
+ display: flex;
340
+ flex-direction: column;
341
+ align-items: center;
342
+ gap: 12px;
343
+ }
344
+
345
+ .placeholder-icon {
346
+ font-size: 42px;
347
+ opacity: 0.4;
348
+ color: var(--accent);
349
+ }
350
+
351
+ .timeline {
352
+ display: grid;
353
+ grid-template-columns: auto minmax(100px, 1fr) auto;
354
+ gap: 12px;
355
+ align-items: center;
356
+ margin-top: 14px;
357
+ }
358
+
359
+ .time-label {
360
+ min-width: 80px;
361
+ color: var(--muted);
362
+ font-size: 11px;
363
+ font-variant-numeric: tabular-nums;
364
+ text-align: right;
365
+ }
366
+
367
+ .timeline input { margin: 0; }
368
+
369
+ .status {
370
+ min-height: 18px;
371
+ margin: 12px 0 0;
372
+ font-size: 11px;
373
+ }
374
+
375
+ .hidden-input, #sourceVideo { display: none; }
376
+
377
+ @media (max-width: 900px) {
378
+ .app { padding: 12px; }
379
+ .workspace { grid-template-columns: 1fr; }
380
+ .panel { max-height: none; }
381
+ .preview { min-height: 280px; }
382
+ }
383
+ </style>
384
+ </head>
385
+
386
+ <body>
387
+ <div class="app">
388
+ <header class="topbar">
389
+ <div class="logo" aria-hidden="true">✦</div>
390
+ <div>
391
+ <h1>Brain Motion Studio <span class="badge">v4</span></h1>
392
+ <p class="subtitle">Transform video into dynamic SVG-cell artwork with custom color palette & shades of black.</p>
393
+ </div>
394
+ </header>
395
+
396
+ <div class="workspace">
397
+ <aside class="panel">
398
+ <section class="section">
399
+ <h2>1. Source Media</h2>
400
+ <div class="button-row">
401
+ <button class="btn primary" id="uploadVideoBtn" data-lock>Video</button>
402
+ <button class="btn" id="addSvgBtn" data-lock>Add SVGs</button>
403
+ <button class="btn" id="clearSvgBtn" data-lock>Clear SVGs</button>
404
+ </div>
405
+
406
+ <input class="hidden-input" id="videoInput" type="file" accept="video/*">
407
+ <input class="hidden-input" id="svgInput" type="file" accept=".svg,image/svg+xml" multiple>
408
+ <video id="sourceVideo" playsinline muted></video>
409
+
410
+ <p class="icon-status" id="iconStatus">No SVGs loaded — circles will be used.</p>
411
+ <p class="small-note">Upload a video, then add any number of SVG files. They will automatically cycle through the cells.</p>
412
+ </section>
413
+
414
+ <section class="section">
415
+ <h2>2. Color Grading</h2>
416
+
417
+ <div class="control">
418
+ <label class="control-label" for="brightness">
419
+ <span>Brightness</span><output id="brightnessValue">0</output>
420
+ </label>
421
+ <input id="brightness" data-lock type="range" min="-100" max="100" value="0">
422
+ </div>
423
+
424
+ <div class="control">
425
+ <label class="control-label" for="saturation">
426
+ <span>Saturation</span><output id="saturationValue">0</output>
427
+ </label>
428
+ <input id="saturation" data-lock type="range" min="-100" max="100" value="0">
429
+ </div>
430
+
431
+ <div class="color-row" style="margin: 12px 0">
432
+ <label for="tintColor">Tint / Overlay</label>
433
+ <input id="tintColor" data-lock type="color" value="#8a7dff">
434
+ </div>
435
+
436
+ <div class="control">
437
+ <label class="control-label" for="tintOpacity">
438
+ <span>Tint Intensity</span><output id="tintOpacityValue">0%</output>
439
+ </label>
440
+ <input id="tintOpacity" data-lock type="range" min="0" max="100" value="0">
441
+ </div>
442
+ </section>
443
+
444
+ <section class="section">
445
+ <h2>3. Cell Appearance</h2>
446
+
447
+ <div class="control">
448
+ <label class="control-label" for="cellSize">
449
+ <span>Spacing</span><output id="cellValue">14 px</output>
450
+ </label>
451
+ <input id="cellSize" data-lock type="range" min="6" max="40" value="14">
452
+ </div>
453
+
454
+ <div class="control">
455
+ <label class="control-label" for="scale">
456
+ <span>Scale</span><output id="scaleValue">0.95</output>
457
+ </label>
458
+ <input id="scale" data-lock type="range" min="0.25" max="1.8" step="0.05" value="0.95">
459
+ </div>
460
+
461
+ <div class="control">
462
+ <label class="control-label" for="gap">
463
+ <span>Gap</span><output id="gapValue">1 px</output>
464
+ </label>
465
+ <input id="gap" data-lock type="range" min="0" max="12" step="0.5" value="1">
466
+ </div>
467
+
468
+ <div class="control">
469
+ <label class="control-label" for="contrast">
470
+ <span>Contrast</span><output id="contrastValue">0</output>
471
+ </label>
472
+ <input id="contrast" data-lock type="range" min="-100" max="100" value="0">
473
+ </div>
474
+
475
+ <div class="control">
476
+ <label class="control-label" for="sizeMode">Size Logic</label>
477
+ <select id="sizeMode" data-lock>
478
+ <option value="bright">Bright = Larger</option>
479
+ <option value="dark">Dark = Larger</option>
480
+ </select>
481
+ </div>
482
+
483
+ <div class="control">
484
+ <label class="control-label" for="fallbackShape">Fallback Shape</label>
485
+ <select id="fallbackShape" data-lock>
486
+ <option value="circle">Circle</option>
487
+ <option value="square">Square</option>
488
+ <option value="triangle">Triangle</option>
489
+ <option value="star">Star</option>
490
+ <option value="diamond">Diamond</option>
491
+ <option value="cross">Cross</option>
492
+ </select>
493
+ </div>
494
+
495
+ <div class="control">
496
+ <label class="control-label" for="colorMode">SVG Color Mode</label>
497
+ <select id="colorMode" data-lock>
498
+ <option value="sample">Tint to Video Color</option>
499
+ <option value="shadesBlack">Shades of Black (Charcoal)</option>
500
+ <option value="custom">Custom Color / Palette</option>
501
+ <option value="white">Tint White</option>
502
+ <option value="native">Keep Original SVG</option>
503
+ </select>
504
+ </div>
505
+
506
+ <!-- Custom Color Palette Controls -->
507
+ <div id="customColorContainer" style="margin: 10px 0; display: none;">
508
+ <div class="color-row">
509
+ <label for="customColor">Color Palette</label>
510
+ <input id="customColor" data-lock type="color" value="#0a0a0a">
511
+ </div>
512
+ <div class="palette-swatches">
513
+ <span style="font-size:10px; color:var(--muted)">Presets:</span>
514
+ <button class="swatch-btn" style="background:#000000" title="Pitch Black (#000000)" onclick="setPaletteColor('#000000')"></button>
515
+ <button class="swatch-btn" style="background:#1a1e24" title="Dark Charcoal (#1a1e24)" onclick="setPaletteColor('#1a1e24')"></button>
516
+ <button class="swatch-btn" style="background:#2d3748" title="Slate Gray (#2d3748)" onclick="setPaletteColor('#2d3748')"></button>
517
+ <button class="swatch-btn" style="background:#102a43" title="Midnight Navy (#102a43)" onclick="setPaletteColor('#102a43')"></button>
518
+ <button class="swatch-btn" style="background:#8a7dff" title="Accent Violet (#8a7dff)" onclick="setPaletteColor('#8a7dff')"></button>
519
+ </div>
520
+ </div>
521
+
522
+ <div class="toggle-row">
523
+ <label class="toggle"><input id="rotate" data-lock type="checkbox"> Rotate by Brightness</label>
524
+ </div>
525
+ </section>
526
+
527
+ <section class="section">
528
+ <h2>4. Background & Export</h2>
529
+
530
+ <div class="toggle-row">
531
+ <label class="toggle"><input id="transparent" data-lock type="checkbox" checked> Transparent Background</label>
532
+ </div>
533
+
534
+ <div class="color-row" style="margin: 10px 0">
535
+ <label for="bgColor">Opaque Bg Color</label>
536
+ <input id="bgColor" data-lock type="color" value="#10141b">
537
+ </div>
538
+
539
+ <div class="control">
540
+ <label class="control-label" for="backgroundMode">Cutout Logic</label>
541
+ <select id="backgroundMode" data-lock>
542
+ <option value="dark">Remove Dark Pixels</option>
543
+ <option value="light">Remove Light Pixels</option>
544
+ <option value="none">Keep All</option>
545
+ </select>
546
+ </div>
547
+
548
+ <div class="control">
549
+ <label class="control-label" for="cutoff">
550
+ <span>Cutout Threshold</span><output id="cutoffValue">5%</output>
551
+ </label>
552
+ <input id="cutoff" data-lock type="range" min="0" max="95" value="5">
553
+ </div>
554
+
555
+ <div class="control">
556
+ <label class="control-label" for="outputWidth">Output Width</label>
557
+ <input id="outputWidth" data-lock type="number" min="240" max="1280" step="80" value="480">
558
+ </div>
559
+
560
+ <div class="control">
561
+ <label class="control-label" for="duration">Duration (sec)</label>
562
+ <input id="duration" data-lock type="number" min="0.5" max="15" step="0.5" value="4">
563
+ </div>
564
+
565
+ <div class="control">
566
+ <label class="control-label" for="fps">Export FPS</label>
567
+ <input id="fps" data-lock type="number" min="4" max="20" step="1" value="12">
568
+ </div>
569
+
570
+ <div class="button-row" style="margin: 14px 0 9px">
571
+ <button class="btn" id="playPause" disabled data-lock>Play</button>
572
+ <button class="btn" id="saveFrame" data-lock>Save PNG</button>
573
+ </div>
574
+
575
+ <div class="export-grid">
576
+ <button class="btn primary" id="exportSvg" disabled data-lock>SVG</button>
577
+ <button class="btn" id="exportGif" disabled data-lock>GIF</button>
578
+ <button class="btn" id="exportWebm" disabled data-lock>WebM</button>
579
+ <button class="btn" id="exportMp4" disabled data-lock>MP4</button>
580
+ </div>
581
+
582
+ <p class="small-note">
583
+ WebM/MP4 export contains no audio. MP4 is always opaque. GIF transparency is 1-bit (on/off).
584
+ </p>
585
+ </section>
586
+ </aside>
587
+
588
+ <section class="stage">
589
+ <div class="stage-head">
590
+ <h2 class="stage-title">Preview</h2>
591
+ <span class="stage-hint">Checkerboard = Transparency</span>
592
+ </div>
593
+
594
+ <div class="preview">
595
+ <canvas id="canvas" width="480" height="270"></canvas>
596
+ <div class="placeholder" id="placeholder">
597
+ <div class="placeholder-icon">✦</div>
598
+ <div>Choose a video to start.<br>Add SVGs to use them as animated cells.</div>
599
+ </div>
600
+ </div>
601
+
602
+ <div class="timeline">
603
+ <button class="btn" id="timelinePlay" disabled data-lock style="min-height: 32px; padding: 6px 12px;">▶</button>
604
+ <input id="seek" type="range" min="0" max="1000" value="0" disabled data-lock>
605
+ <span class="time-label" id="timeLabel">0:00 / 0:00</span>
606
+ </div>
607
+
608
+ <p class="status" id="status" aria-live="polite">Ready. Load a video to begin.</p>
609
+ </section>
610
+ </div>
611
+ </div>
612
+
613
+ <!-- GIF encoding dependency. Other exports do not require this library. -->
614
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
615
+
616
+ <script>
617
+ (() => {
618
+ 'use strict';
619
+
620
+ const $ = id => document.getElementById(id);
621
+
622
+ const video = $('sourceVideo');
623
+ const canvas = $('canvas');
624
+ const ctx = canvas.getContext('2d', { alpha: true });
625
+
626
+ const sampleCanvas = document.createElement('canvas');
627
+ const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true });
628
+
629
+ const tintCanvas = document.createElement('canvas');
630
+ tintCanvas.width = 32;
631
+ tintCanvas.height = 32;
632
+ const tintCtx = tintCanvas.getContext('2d');
633
+
634
+ let videoUrl = null;
635
+ let svgAssets = [];
636
+ let busy = false;
637
+ let recordingOpaque = false;
638
+ let lastRenderedTime = -1;
639
+
640
+ window.setPaletteColor = function(colorHex) {
641
+ const picker = $('customColor');
642
+ if (picker) {
643
+ picker.value = colorHex;
644
+ renderSafely();
645
+ }
646
+ };
647
+
648
+ const exportButtons = [
649
+ $('exportSvg'), $('exportGif'), $('exportWebm'), $('exportMp4')
650
+ ];
651
+
652
+ function setStatus(message) {
653
+ $('status').textContent = message;
654
+ }
655
+
656
+ function syncActionButtons() {
657
+ const hasVideo = !!video.videoWidth;
658
+ $('playPause').disabled = busy || !hasVideo;
659
+ $('timelinePlay').disabled = busy || !hasVideo;
660
+ $('seek').disabled = busy || !hasVideo;
661
+ for (const button of exportButtons) {
662
+ button.disabled = busy || !hasVideo;
663
+ }
664
+ }
665
+
666
+ function setBusy(value) {
667
+ busy = value;
668
+ document.querySelectorAll('[data-lock]').forEach(el => {
669
+ el.disabled = value;
670
+ });
671
+ syncActionButtons();
672
+ }
673
+
674
+ function clamp(value, min, max) {
675
+ return Math.max(min, Math.min(max, value));
676
+ }
677
+
678
+ function updateLabels() {
679
+ $('cellValue').textContent = `${$('cellSize').value} px`;
680
+ $('scaleValue').textContent = Number($('scale').value).toFixed(2);
681
+ $('gapValue').textContent = `${$('gap').value} px`;
682
+
683
+ const contrast = Number($('contrast').value);
684
+ $('contrastValue').textContent = `${contrast > 0 ? '+' : ''}${contrast}`;
685
+
686
+ $('cutoffValue').textContent = `${$('cutoff').value}%`;
687
+
688
+ const brightness = Number($('brightness').value);
689
+ $('brightnessValue').textContent = `${brightness > 0 ? '+' : ''}${brightness}`;
690
+
691
+ const saturation = Number($('saturation').value);
692
+ $('saturationValue').textContent = `${saturation > 0 ? '+' : ''}${saturation}`;
693
+
694
+ $('tintOpacityValue').textContent = `${$('tintOpacity').value}%`;
695
+ }
696
+
697
+ function resizeCanvasForVideo() {
698
+ if (!video.videoWidth || !video.videoHeight) return;
699
+
700
+ const wantedWidth = clamp(Number($('outputWidth').value) || 480, 240, 1280);
701
+ const maxHeight = 1280;
702
+ const ratio = Math.min(
703
+ wantedWidth / video.videoWidth,
704
+ maxHeight / video.videoHeight
705
+ );
706
+
707
+ const width = Math.max(1, Math.round(video.videoWidth * ratio));
708
+ const height = Math.max(1, Math.round(video.videoHeight * ratio));
709
+
710
+ if (canvas.width !== width || canvas.height !== height) {
711
+ canvas.width = width;
712
+ canvas.height = height;
713
+ sampleCanvas.width = width;
714
+ sampleCanvas.height = height;
715
+ }
716
+ }
717
+
718
+ function adjustChannel(value, factor) {
719
+ return clamp(factor * (value - 128) + 128, 0, 255);
720
+ }
721
+
722
+ // Color Grading Logic
723
+ function gradeColor(r, g, b) {
724
+ // Brightness adjustment
725
+ const brightness = Number($('brightness').value);
726
+ if (brightness !== 0) {
727
+ const adj = brightness * 2.55;
728
+ r += adj;
729
+ g += adj;
730
+ b += adj;
731
+ }
732
+
733
+ // Saturation adjustment
734
+ const saturation = Number($('saturation').value);
735
+ if (saturation !== 0) {
736
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
737
+ const factor = 1 + (saturation / 100);
738
+ r = luma + (r - luma) * factor;
739
+ g = luma + (g - luma) * factor;
740
+ b = luma + (b - luma) * factor;
741
+ }
742
+
743
+ // Tint overlay
744
+ const tintOpacity = Number($('tintOpacity').value) / 100;
745
+ if (tintOpacity > 0) {
746
+ const hex = $('tintColor').value.replace(/^#/, '');
747
+ const tR = parseInt(hex.substring(0, 2), 16);
748
+ const tG = parseInt(hex.substring(2, 4), 16);
749
+ const tB = parseInt(hex.substring(4, 6), 16);
750
+
751
+ r = r * (1 - tintOpacity) + tR * tintOpacity;
752
+ g = g * (1 - tintOpacity) + tG * tintOpacity;
753
+ b = b * (1 - tintOpacity) + tB * tintOpacity;
754
+ }
755
+
756
+ return [
757
+ clamp(Math.round(r), 0, 255),
758
+ clamp(Math.round(g), 0, 255),
759
+ clamp(Math.round(b), 0, 255)
760
+ ];
761
+ }
762
+
763
+ function drawAssetCentered(targetCtx, asset, boxSize) {
764
+ const iw = asset.width || asset.image.naturalWidth || 100;
765
+ const ih = asset.height || asset.image.naturalHeight || 100;
766
+ const scale = Math.min(boxSize / iw, boxSize / ih);
767
+
768
+ targetCtx.drawImage(
769
+ asset.image,
770
+ -iw * scale / 2,
771
+ -ih * scale / 2,
772
+ iw * scale,
773
+ ih * scale
774
+ );
775
+ }
776
+
777
+ function drawBuiltin(targetCtx, type, size, color) {
778
+ const r = size / 2;
779
+ targetCtx.fillStyle = color;
780
+ targetCtx.beginPath();
781
+
782
+ switch (type) {
783
+ case 'square':
784
+ targetCtx.rect(-r, -r, size, size);
785
+ break;
786
+
787
+ case 'triangle':
788
+ targetCtx.moveTo(0, -r);
789
+ targetCtx.lineTo(r, r);
790
+ targetCtx.lineTo(-r, r);
791
+ targetCtx.closePath();
792
+ break;
793
+
794
+ case 'star':
795
+ for (let i = 0; i < 10; i++) {
796
+ const angle = -Math.PI / 2 + i * Math.PI / 5;
797
+ const radius = i % 2 === 0 ? r : r * 0.43;
798
+ const x = Math.cos(angle) * radius;
799
+ const y = Math.sin(angle) * radius;
800
+ if (i === 0) targetCtx.moveTo(x, y);
801
+ else targetCtx.lineTo(x, y);
802
+ }
803
+ targetCtx.closePath();
804
+ break;
805
+
806
+ case 'diamond':
807
+ targetCtx.moveTo(0, -r);
808
+ targetCtx.lineTo(r, 0);
809
+ targetCtx.lineTo(0, r);
810
+ targetCtx.lineTo(-r, 0);
811
+ targetCtx.closePath();
812
+ break;
813
+
814
+ case 'cross':
815
+ targetCtx.rect(-r * 0.28, -r, r * 0.56, size);
816
+ targetCtx.rect(-r, -r * 0.28, size, r * 0.56);
817
+ break;
818
+
819
+ default:
820
+ targetCtx.arc(0, 0, r, 0, Math.PI * 2);
821
+ break;
822
+ }
823
+
824
+ targetCtx.fill();
825
+ }
826
+
827
+ function drawTintedAsset(asset, color, size) {
828
+ tintCtx.clearRect(0, 0, tintCanvas.width, tintCanvas.height);
829
+ tintCtx.save();
830
+ tintCtx.translate(tintCanvas.width / 2, tintCanvas.height / 2);
831
+ drawAssetCentered(tintCtx, asset, tintCanvas.width - 2);
832
+ tintCtx.restore();
833
+
834
+ tintCtx.globalCompositeOperation = 'source-in';
835
+ tintCtx.fillStyle = color;
836
+ tintCtx.fillRect(0, 0, tintCanvas.width, tintCanvas.height);
837
+ tintCtx.globalCompositeOperation = 'source-over';
838
+
839
+ ctx.drawImage(
840
+ tintCanvas,
841
+ -size / 2,
842
+ -size / 2,
843
+ size,
844
+ size
845
+ );
846
+ }
847
+
848
+ function renderFrame(forceOpaque = false) {
849
+ if (!video.videoWidth || video.readyState < 2) return;
850
+
851
+ resizeCanvasForVideo();
852
+
853
+ const width = canvas.width;
854
+ const height = canvas.height;
855
+
856
+ sampleCtx.drawImage(video, 0, 0, width, height);
857
+ const pixels = sampleCtx.getImageData(0, 0, width, height).data;
858
+
859
+ ctx.clearRect(0, 0, width, height);
860
+
861
+ const isTransparent = $('transparent').checked && !forceOpaque;
862
+ if (!isTransparent) {
863
+ ctx.fillStyle = $('bgColor').value;
864
+ ctx.fillRect(0, 0, width, height);
865
+ }
866
+
867
+ const cell = Number($('cellSize').value);
868
+ const scaleSetting = Number($('scale').value);
869
+ const gap = Number($('gap').value);
870
+ const cutoff = Number($('cutoff').value) / 100;
871
+ const contrast = Number($('contrast').value);
872
+ const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
873
+ const columns = Math.ceil(width / cell);
874
+ const rows = Math.ceil(height / cell);
875
+ const sizeMode = $('sizeMode').value;
876
+ const backgroundMode = $('backgroundMode').value;
877
+ const colorMode = $('colorMode').value;
878
+ const rotate = $('rotate').checked;
879
+ const fallbackShape = $('fallbackShape').value;
880
+ const customColorVal = $('customColor').value || '#0a0a0a';
881
+
882
+ for (let row = 0; row < rows; row++) {
883
+ const y = row * cell;
884
+
885
+ for (let column = 0; column < columns; column++) {
886
+ const x = column * cell;
887
+ const sampleX = Math.min(width - 1, Math.floor(x + cell / 2));
888
+ const sampleY = Math.min(height - 1, Math.floor(y + cell / 2));
889
+ const index = (sampleY * width + sampleX) * 4;
890
+
891
+ const alpha = pixels[index + 3] / 255;
892
+ if (alpha < 0.04) continue;
893
+
894
+ let red = adjustChannel(pixels[index], contrastFactor);
895
+ let green = adjustChannel(pixels[index + 1], contrastFactor);
896
+ let blue = adjustChannel(pixels[index + 2], contrastFactor);
897
+
898
+ // Apply Color Grading
899
+ [red, green, blue] = gradeColor(red, green, blue);
900
+
901
+ const luma = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
902
+
903
+ if (backgroundMode === 'dark' && luma < cutoff) continue;
904
+ if (backgroundMode === 'light' && luma > 1 - cutoff) continue;
905
+
906
+ const brightness = sizeMode === 'dark' ? 1 - luma : luma;
907
+ const size = Math.min(
908
+ cell * 1.8,
909
+ Math.max(0, cell * scaleSetting * (0.12 + 0.88 * brightness) - gap)
910
+ );
911
+
912
+ if (size < 1) continue;
913
+
914
+ let color;
915
+ if (colorMode === 'white') {
916
+ color = '#ffffff';
917
+ } else if (colorMode === 'custom') {
918
+ color = customColorVal;
919
+ } else if (colorMode === 'shadesBlack') {
920
+ // Map brightness to deep charcoal & black shades (0 to 45)
921
+ const shade = Math.round(luma * 45);
922
+ color = `rgb(${shade},${shade},${shade})`;
923
+ } else {
924
+ color = `rgb(${Math.round(red)},${Math.round(green)},${Math.round(blue)})`;
925
+ }
926
+
927
+ const asset = svgAssets.length
928
+ ? svgAssets[(row * columns + column) % svgAssets.length]
929
+ : null;
930
+
931
+ ctx.save();
932
+ ctx.translate(x + cell / 2, y + cell / 2);
933
+ ctx.globalAlpha = alpha;
934
+
935
+ if (rotate) {
936
+ ctx.rotate((luma - 0.5) * Math.PI);
937
+ }
938
+
939
+ if (asset) {
940
+ if (colorMode === 'native') {
941
+ drawAssetCentered(ctx, asset, size);
942
+ } else {
943
+ drawTintedAsset(asset, color, size);
944
+ }
945
+ } else {
946
+ drawBuiltin(ctx, fallbackShape, size, color);
947
+ }
948
+
949
+ ctx.restore();
950
+ }
951
+ }
952
+
953
+ lastRenderedTime = video.currentTime;
954
+ $('placeholder').style.display = 'none';
955
+ }
956
+
957
+ function renderSafely() {
958
+ try {
959
+ renderFrame(recordingOpaque);
960
+ } catch (error) {
961
+ console.error(error);
962
+ setStatus(`Preview error: ${error.message}`);
963
+ }
964
+ }
965
+
966
+ function sanitizeSvg(source) {
967
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
968
+
969
+ if (doc.querySelector('parsererror')) {
970
+ throw new Error('The selected file is not valid SVG.');
971
+ }
972
+
973
+ const root = doc.documentElement;
974
+ if (!root || root.localName.toLowerCase() !== 'svg') {
975
+ throw new Error('No <svg> element was found in that file.');
976
+ }
977
+
978
+ const blocked = new Set(['script', 'foreignobject', 'iframe', 'object', 'embed']);
979
+
980
+ for (const element of [...root.querySelectorAll('*')]) {
981
+ const tag = element.localName.toLowerCase();
982
+ if (blocked.has(tag)) {
983
+ element.remove();
984
+ continue;
985
+ }
986
+
987
+ for (const attribute of [...element.attributes]) {
988
+ const name = attribute.name.toLowerCase();
989
+ const value = attribute.value.trim();
990
+
991
+ if (name.startsWith('on')) {
992
+ element.removeAttributeNode(attribute);
993
+ } else if ((name === 'href' || name === 'xlink:href' || name === 'src') &&
994
+ value &&
995
+ !value.startsWith('#') &&
996
+ !/^data:image\//i.test(value)) {
997
+ element.removeAttributeNode(attribute);
998
+ } else if (/url\(\s*(['"]?)(?:https?:|\/\/|file:|javascript:)/i.test(value)) {
999
+ element.removeAttributeNode(attribute);
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ for (const styleElement of [...root.querySelectorAll('style')]) {
1005
+ const content = styleElement.textContent;
1006
+ if (/@import/i.test(content) || /url\(\s*(['"]?)(?:https?:|\/\/|file:)/i.test(content)) {
1007
+ styleElement.remove();
1008
+ }
1009
+ }
1010
+
1011
+ if (!root.hasAttribute('xmlns')) {
1012
+ root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1013
+ }
1014
+
1015
+ let viewBox = root.getAttribute('viewBox') || '';
1016
+ let parsedValues = viewBox.trim().split(/[\s,]+/).map(Number);
1017
+ let width = 100;
1018
+ let height = 100;
1019
+
1020
+ if (parsedValues.length === 4 &&
1021
+ parsedValues.every(Number.isFinite) &&
1022
+ parsedValues[2] > 0 &&
1023
+ parsedValues[3] > 0) {
1024
+ width = parsedValues[2];
1025
+ height = parsedValues[3];
1026
+ } else {
1027
+ const widthAttr = parseFloat(root.getAttribute('width'));
1028
+ const heightAttr = parseFloat(root.getAttribute('height'));
1029
+
1030
+ if (Number.isFinite(widthAttr) && widthAttr > 0 &&
1031
+ Number.isFinite(heightAttr) && heightAttr > 0) {
1032
+ width = widthAttr;
1033
+ height = heightAttr;
1034
+ }
1035
+
1036
+ root.setAttribute('viewBox', `0 0 ${width} ${height}`);
1037
+ }
1038
+
1039
+ root.setAttribute('width', String(width));
1040
+ root.setAttribute('height', String(height));
1041
+
1042
+ return {
1043
+ text: new XMLSerializer().serializeToString(root),
1044
+ width,
1045
+ height
1046
+ };
1047
+ }
1048
+
1049
+ function loadSvgImage(file, parsedSvg) {
1050
+ const blob = new Blob([parsedSvg.text], { type: 'image/svg+xml;charset=utf-8' });
1051
+ const url = URL.createObjectURL(blob);
1052
+
1053
+ return new Promise((resolve, reject) => {
1054
+ const image = new Image();
1055
+
1056
+ image.onload = () => {
1057
+ resolve({
1058
+ name: file.name,
1059
+ image,
1060
+ url,
1061
+ width: parsedSvg.width,
1062
+ height: parsedSvg.height
1063
+ });
1064
+ };
1065
+
1066
+ image.onerror = () => {
1067
+ URL.revokeObjectURL(url);
1068
+ reject(new Error(`Could not load ${file.name}.`));
1069
+ };
1070
+
1071
+ image.src = url;
1072
+ });
1073
+ }
1074
+
1075
+ function updateIconStatus() {
1076
+ if (!svgAssets.length) {
1077
+ $('iconStatus').textContent = 'No SVGs loaded — fallback shape will be used.';
1078
+ return;
1079
+ }
1080
+
1081
+ const names = svgAssets.map(asset => asset.name).join(', ');
1082
+ $('iconStatus').textContent = `${svgAssets.length} SVG${svgAssets.length === 1 ? '' : 's'} loaded: ${names}`;
1083
+ }
1084
+
1085
+ function clearSvgAssets() {
1086
+ for (const asset of svgAssets) {
1087
+ URL.revokeObjectURL(asset.url);
1088
+ }
1089
+
1090
+ svgAssets = [];
1091
+ updateIconStatus();
1092
+ renderSafely();
1093
+ setStatus('Cleared all SVGs. Cells now draw the chosen fallback shape.');
1094
+ }
1095
+
1096
+ function formatTime(seconds) {
1097
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
1098
+ const mins = Math.floor(seconds / 60);
1099
+ const secs = Math.floor(seconds % 60);
1100
+ return `${mins}:${String(secs).padStart(2, '0')}`;
1101
+ }
1102
+
1103
+ function updateTimeline() {
1104
+ if (!video.videoWidth || !Number.isFinite(video.duration) || video.duration <= 0) {
1105
+ $('timeLabel').textContent = '0:00 / 0:00';
1106
+ return;
1107
+ }
1108
+
1109
+ const fraction = video.currentTime / video.duration;
1110
+ $('seek').value = String(Math.round(fraction * 1000));
1111
+ $('timeLabel').textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
1112
+ }
1113
+
1114
+ function seekTo(targetSeconds) {
1115
+ return new Promise((resolve, reject) => {
1116
+ const duration = video.duration;
1117
+ const maxTime = Number.isFinite(duration) && duration > 0
1118
+ ? Math.max(0, duration - 0.01)
1119
+ : Math.max(0, targetSeconds);
1120
+
1121
+ const boundedTarget = clamp(targetSeconds, 0, maxTime);
1122
+
1123
+ if (!video.seeking && Math.abs(video.currentTime - boundedTarget) < 0.001) {
1124
+ resolve();
1125
+ return;
1126
+ }
1127
+
1128
+ let timeoutId = null;
1129
+
1130
+ const handleSeeked = () => {
1131
+ clearTimeout(timeoutId);
1132
+ video.removeEventListener('seeked', handleSeeked);
1133
+ resolve();
1134
+ };
1135
+
1136
+ video.addEventListener('seeked', handleSeeked);
1137
+
1138
+ timeoutId = setTimeout(() => {
1139
+ video.removeEventListener('seeked', handleSeeked);
1140
+ reject(new Error('Seeking took too long.'));
1141
+ }, 8000);
1142
+
1143
+ try {
1144
+ video.currentTime = boundedTarget;
1145
+ } catch (error) {
1146
+ clearTimeout(timeoutId);
1147
+ video.removeEventListener('seeked', handleSeeked);
1148
+ reject(error);
1149
+ }
1150
+ });
1151
+ }
1152
+
1153
+ function exportSettings() {
1154
+ const fps = clamp(Math.round(Number($('fps').value) || 12), 4, 20);
1155
+ const requestedSeconds = clamp(Number($('duration').value) || 4, 0.5, 15);
1156
+ const frameCount = Math.max(1, Math.ceil(requestedSeconds * fps));
1157
+
1158
+ return {
1159
+ fps,
1160
+ frameCount,
1161
+ duration: frameCount / fps
1162
+ };
1163
+ }
1164
+
1165
+ async function captureFrames(onFrame, taskLabel) {
1166
+ if (!video.videoWidth) {
1167
+ throw new Error('Choose a video first.');
1168
+ }
1169
+
1170
+ const settings = exportSettings();
1171
+ const wasPlaying = !video.paused;
1172
+ const previousTime = video.currentTime;
1173
+ const maxDuration = Number.isFinite(video.duration) && video.duration > 0
1174
+ ? video.duration
1175
+ : null;
1176
+
1177
+ video.pause();
1178
+
1179
+ try {
1180
+ for (let i = 0; i < settings.frameCount; i++) {
1181
+ const targetTime = maxDuration
1182
+ ? (i / settings.fps) % maxDuration
1183
+ : i / settings.fps;
1184
+
1185
+ await seekTo(targetTime);
1186
+ await new Promise(resolve => requestAnimationFrame(resolve));
1187
+
1188
+ renderFrame(false);
1189
+
1190
+ await onFrame(i, {
1191
+ ...settings,
1192
+ width: canvas.width,
1193
+ height: canvas.height
1194
+ });
1195
+
1196
+ setStatus(`${taskLabel}: rendered frame ${i + 1} of ${settings.frameCount}…`);
1197
+ }
1198
+
1199
+ return {
1200
+ ...settings,
1201
+ width: canvas.width,
1202
+ height: canvas.height
1203
+ };
1204
+ } finally {
1205
+ try {
1206
+ await seekTo(previousTime);
1207
+ } catch (_) {}
1208
+
1209
+ if (wasPlaying) {
1210
+ await video.play().catch(() => {});
1211
+ }
1212
+
1213
+ renderSafely();
1214
+ updateTimeline();
1215
+ }
1216
+ }
1217
+
1218
+ function downloadBlob(blob, filename) {
1219
+ const url = URL.createObjectURL(blob);
1220
+ const link = document.createElement('a');
1221
+ link.href = url;
1222
+ link.download = filename;
1223
+ document.body.appendChild(link);
1224
+ link.click();
1225
+ link.remove();
1226
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
1227
+ }
1228
+
1229
+ function formatNumber(value) {
1230
+ return Number(value.toFixed(6)).toString();
1231
+ }
1232
+
1233
+ function makeAnimatedSvg(frames, width, height, fps) {
1234
+ const frameCount = frames.length;
1235
+ const totalDuration = frameCount / fps;
1236
+ const totalDurationText = formatNumber(totalDuration);
1237
+
1238
+ const imageElements = frames.map((dataUrl, index) => {
1239
+ let animationTag = '';
1240
+
1241
+ if (frameCount > 1) {
1242
+ let valuesAttribute;
1243
+ let keyTimesAttribute;
1244
+
1245
+ if (index === 0) {
1246
+ valuesAttribute = 'visible;hidden;hidden';
1247
+ keyTimesAttribute = `0;${formatNumber(1 / frameCount)};1`;
1248
+ } else if (index === frameCount - 1) {
1249
+ valuesAttribute = 'hidden;visible;hidden';
1250
+ keyTimesAttribute = `0;${formatNumber(index / frameCount)};1`;
1251
+ } else {
1252
+ valuesAttribute = 'hidden;visible;hidden;hidden';
1253
+ keyTimesAttribute = `0;${formatNumber(index / frameCount)};${formatNumber((index + 1) / frameCount)};1`;
1254
+ }
1255
+
1256
+ animationTag = `<animate attributeName="visibility" calcMode="discrete" values="${valuesAttribute}" keyTimes="${keyTimesAttribute}" dur="${totalDurationText}s" repeatCount="indefinite"/>`;
1257
+ }
1258
+
1259
+ const initialVisibility = index === 0 ? 'visible' : 'hidden';
1260
+
1261
+ return `<image x="0" y="0" width="${width}" height="${height}" visibility="${initialVisibility}" href="${dataUrl}" xlink:href="${dataUrl}">${animationTag}</image>`;
1262
+ }).join('\n');
1263
+
1264
+ return `<?xml version="1.0" encoding="UTF-8"?>
1265
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
1266
+ ${imageElements}
1267
+ </svg>`;
1268
+ }
1269
+
1270
+ $('uploadVideoBtn').addEventListener('click', () => $('videoInput').click());
1271
+ $('addSvgBtn').addEventListener('click', () => $('svgInput').click());
1272
+
1273
+ $('videoInput').addEventListener('change', event => {
1274
+ const file = event.target.files && event.target.files[0];
1275
+ if (!file) return;
1276
+
1277
+ video.pause();
1278
+
1279
+ if (videoUrl) {
1280
+ URL.revokeObjectURL(videoUrl);
1281
+ }
1282
+
1283
+ videoUrl = URL.createObjectURL(file);
1284
+ video.src = videoUrl;
1285
+ video.loop = true;
1286
+ video.muted = true;
1287
+ video.load();
1288
+
1289
+ $('placeholder').style.display = 'flex';
1290
+ setStatus(`Loading video: ${file.name}…`);
1291
+ event.target.value = '';
1292
+ });
1293
+
1294
+ video.addEventListener('loadedmetadata', () => {
1295
+ resizeCanvasForVideo();
1296
+ $('placeholder').style.display = 'none';
1297
+ updateTimeline();
1298
+ renderSafely();
1299
+ syncActionButtons();
1300
+ setStatus(`Loaded video (${video.videoWidth} × ${video.videoHeight}px). Ready.`);
1301
+
1302
+ video.play().catch(() => {
1303
+ setStatus('Video ready. Press Play to start preview playback.');
1304
+ });
1305
+ });
1306
+
1307
+ video.addEventListener('loadeddata', renderSafely);
1308
+
1309
+ video.addEventListener('seeked', () => {
1310
+ renderSafely();
1311
+ updateTimeline();
1312
+ });
1313
+
1314
+ video.addEventListener('timeupdate', updateTimeline);
1315
+
1316
+ video.addEventListener('play', () => {
1317
+ $('playPause').textContent = 'Pause';
1318
+ $('timelinePlay').textContent = '⏸';
1319
+ });
1320
+
1321
+ video.addEventListener('pause', () => {
1322
+ $('playPause').textContent = 'Play';
1323
+ $('timelinePlay').textContent = '▶';
1324
+ renderSafely();
1325
+ });
1326
+
1327
+ $('svgInput').addEventListener('change', async event => {
1328
+ const files = [...(event.target.files || [])];
1329
+ event.target.value = '';
1330
+ let added = 0;
1331
+
1332
+ for (const file of files) {
1333
+ if (!file.name.toLowerCase().endsWith('.svg') && file.type !== 'image/svg+xml') {
1334
+ continue;
1335
+ }
1336
+
1337
+ try {
1338
+ const parsed = sanitizeSvg(await file.text());
1339
+ const asset = await loadSvgImage(file, parsed);
1340
+ svgAssets.push(asset);
1341
+ added++;
1342
+ } catch (error) {
1343
+ console.warn(error);
1344
+ setStatus(error.message);
1345
+ }
1346
+ }
1347
+
1348
+ updateIconStatus();
1349
+ renderSafely();
1350
+
1351
+ if (added) {
1352
+ setStatus(`Added ${added} SVG${added === 1 ? '' : 's'}. All loaded SVGs cycle across the cells.`);
1353
+ }
1354
+ });
1355
+
1356
+ $('clearSvgBtn').addEventListener('click', clearSvgAssets);
1357
+
1358
+ function togglePlayback() {
1359
+ if (!video.videoWidth) return;
1360
+
1361
+ if (video.paused) {
1362
+ video.play().catch(() => setStatus('The video could not be played.'));
1363
+ } else {
1364
+ video.pause();
1365
+ }
1366
+ }
1367
+
1368
+ $('playPause').addEventListener('click', togglePlayback);
1369
+ $('timelinePlay').addEventListener('click', togglePlayback);
1370
+
1371
+ $('seek').addEventListener('input', event => {
1372
+ if (!video.videoWidth || !Number.isFinite(video.duration)) return;
1373
+
1374
+ video.pause();
1375
+ const fraction = Number(event.target.value) / 1000;
1376
+ video.currentTime = fraction * video.duration;
1377
+ });
1378
+
1379
+ const renderControlIds = [
1380
+ 'cellSize', 'scale', 'gap', 'contrast', 'sizeMode',
1381
+ 'backgroundMode', 'cutoff', 'fallbackShape', 'colorMode', 'customColor',
1382
+ 'rotate', 'transparent', 'bgColor', 'outputWidth',
1383
+ 'brightness', 'saturation', 'tintColor', 'tintOpacity'
1384
+ ];
1385
+
1386
+ for (const id of renderControlIds) {
1387
+ const el = $(id);
1388
+ if (!el) continue;
1389
+ el.addEventListener('input', () => {
1390
+ updateLabels();
1391
+ renderSafely();
1392
+ });
1393
+ el.addEventListener('change', () => {
1394
+ updateLabels();
1395
+ renderSafely();
1396
+ });
1397
+ }
1398
+
1399
+ $('colorMode').addEventListener('change', e => {
1400
+ $('customColorContainer').style.display = e.target.value === 'custom' ? 'block' : 'none';
1401
+ });
1402
+
1403
+ $('saveFrame').addEventListener('click', async () => {
1404
+ if (!video.videoWidth) {
1405
+ setStatus('Choose a video first.');
1406
+ return;
1407
+ }
1408
+
1409
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
1410
+ if (!blob) {
1411
+ setStatus('Could not create the PNG.');
1412
+ return;
1413
+ }
1414
+
1415
+ downloadBlob(blob, `brain-motion-${Date.now()}.png`);
1416
+ setStatus('Saved the current frame as a transparent PNG.');
1417
+ });
1418
+
1419
+ $('exportSvg').addEventListener('click', async () => {
1420
+ setBusy(true);
1421
+ setStatus('Preparing animated SVG frames…');
1422
+
1423
+ try {
1424
+ const frames = [];
1425
+ const meta = await captureFrames(async () => {
1426
+ frames.push(canvas.toDataURL('image/png'));
1427
+ }, 'SVG');
1428
+
1429
+ const svg = makeAnimatedSvg(frames, meta.width, meta.height, meta.fps);
1430
+ downloadBlob(
1431
+ new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
1432
+ `brain-motion-${Date.now()}.svg`
1433
+ );
1434
+
1435
+ setStatus('Saved animated SVG. It embeds transparent PNG frames inside an SVG animation.');
1436
+ } catch (error) {
1437
+ setStatus(`SVG export failed: ${error.message}`);
1438
+ } finally {
1439
+ setBusy(false);
1440
+ }
1441
+ });
1442
+
1443
+ async function fetchGifWorker() {
1444
+ const url = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
1445
+ const response = await fetch(url);
1446
+
1447
+ if (!response.ok) {
1448
+ throw new Error('Could not download the GIF encoder worker. Run this page from localhost and check your internet connection.');
1449
+ }
1450
+
1451
+ const workerSource = await response.text();
1452
+ return URL.createObjectURL(
1453
+ new Blob([workerSource], { type: 'application/javascript' })
1454
+ );
1455
+ }
1456
+
1457
+ $('exportGif').addEventListener('click', async () => {
1458
+ if (!window.GIF) {
1459
+ setStatus('GIF encoder did not load. Check your internet connection and reload the page.');
1460
+ return;
1461
+ }
1462
+
1463
+ setBusy(true);
1464
+ let workerUrl = null;
1465
+
1466
+ try {
1467
+ setStatus('Loading GIF encoder…');
1468
+ workerUrl = await fetchGifWorker();
1469
+
1470
+ const options = {
1471
+ workers: 2,
1472
+ quality: 10,
1473
+ repeat: 0,
1474
+ width: canvas.width,
1475
+ height: canvas.height,
1476
+ workerScript: workerUrl
1477
+ };
1478
+
1479
+ const usesTransparency = $('transparent').checked;
1480
+ const transparentKey = 0xff00ff;
1481
+
1482
+ if (usesTransparency) options.transparent = transparentKey;
1483
+
1484
+ const gif = new window.GIF(options);
1485
+ const gifCanvas = document.createElement('canvas');
1486
+ gifCanvas.width = canvas.width;
1487
+ gifCanvas.height = canvas.height;
1488
+
1489
+ const gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
1490
+
1491
+ const finished = new Promise((resolve, reject) => {
1492
+ gif.on('finished', resolve);
1493
+ gif.on('error', reject);
1494
+ gif.on('progress', progress => {
1495
+ setStatus(`Encoding GIF: ${Math.round(progress * 100)}%…`);
1496
+ });
1497
+ });
1498
+
1499
+ await captureFrames(async (index, meta) => {
1500
+ const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
1501
+ const data = frame.data;
1502
+
1503
+ for (let i = 0; i < data.length; i += 4) {
1504
+ if (usesTransparency && data[i + 3] < 128) {
1505
+ data[i] = 255;
1506
+ data[i + 1] = 0;
1507
+ data[i + 2] = 255;
1508
+ data[i + 3] = 255;
1509
+ } else {
1510
+ data[i + 3] = 255;
1511
+
1512
+ // Keep actual magenta artwork from matching the transparent key.
1513
+ if (usesTransparency && data[i] === 255 && data[i + 1] === 0 && data[i + 2] === 255) {
1514
+ data[i + 1] = 1;
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ gifCtx.putImageData(frame, 0, 0);
1520
+ gif.addFrame(gifCanvas, {
1521
+ copy: true,
1522
+ delay: Math.max(20, Math.round(1000 / meta.fps))
1523
+ });
1524
+
1525
+ setStatus(`GIF: preparing frame ${index + 1} of ${meta.frameCount}…`);
1526
+ }, 'GIF');
1527
+
1528
+ setStatus('Encoding GIF…');
1529
+ gif.render();
1530
+
1531
+ const blob = await finished;
1532
+ downloadBlob(blob, `brain-motion-${Date.now()}.gif`);
1533
+ setStatus('Saved GIF. GIF transparency is limited to transparent/opaque pixels.');
1534
+ } catch (error) {
1535
+ console.error(error);
1536
+ setStatus(`GIF export failed: ${error.message || 'Check that the page is served from localhost.'}`);
1537
+ } finally {
1538
+ if (workerUrl) URL.revokeObjectURL(workerUrl);
1539
+ setBusy(false);
1540
+ }
1541
+ });
1542
+
1543
+ async function exportRecordedVideo(kind) {
1544
+ if (!video.videoWidth) {
1545
+ setStatus('Choose a video first.');
1546
+ return;
1547
+ }
1548
+
1549
+ if (!window.MediaRecorder || !canvas.captureStream) {
1550
+ setStatus('This browser does not support canvas video recording. Try a current version of Chrome or Edge.');
1551
+ return;
1552
+ }
1553
+
1554
+ const mimeCandidates = kind === 'mp4'
1555
+ ? ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']
1556
+ : ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
1557
+
1558
+ const mimeType = mimeCandidates.find(type => MediaRecorder.isTypeSupported(type));
1559
+
1560
+ if (!mimeType) {
1561
+ setStatus(kind === 'mp4'
1562
+ ? 'MP4 recording is not supported by this browser. MP4 support is browser-dependent; use WebM instead.'
1563
+ : 'WebM recording is not supported by this browser.');
1564
+ return;
1565
+ }
1566
+
1567
+ setBusy(true);
1568
+
1569
+ const settings = exportSettings();
1570
+ const wasPlaying = !video.paused;
1571
+ const previousTime = video.currentTime;
1572
+ let stream = null;
1573
+ let recorder = null;
1574
+
1575
+ try {
1576
+ video.pause();
1577
+ await seekTo(0);
1578
+
1579
+ recordingOpaque = kind === 'mp4';
1580
+ renderFrame(recordingOpaque);
1581
+
1582
+ stream = canvas.captureStream(settings.fps);
1583
+
1584
+ recorder = new MediaRecorder(stream, {
1585
+ mimeType,
1586
+ videoBitsPerSecond: 5_000_000
1587
+ });
1588
+
1589
+ const chunks = [];
1590
+
1591
+ const recordingFinished = new Promise((resolve, reject) => {
1592
+ recorder.addEventListener('dataavailable', event => {
1593
+ if (event.data && event.data.size) chunks.push(event.data);
1594
+ });
1595
+
1596
+ recorder.addEventListener('stop', () => {
1597
+ resolve(new Blob(chunks, { type: recorder.mimeType || mimeType }));
1598
+ });
1599
+
1600
+ recorder.addEventListener('error', event => {
1601
+ reject(event.error || new Error('Video recording failed.'));
1602
+ });
1603
+ });
1604
+
1605
+ recorder.start(200);
1606
+ await video.play();
1607
+
1608
+ setStatus(`Recording ${kind.toUpperCase()} for ${settings.duration.toFixed(1)} seconds…`);
1609
+ await new Promise(resolve => setTimeout(resolve, settings.duration * 1000));
1610
+
1611
+ video.pause();
1612
+ if (recorder.state !== 'inactive') recorder.stop();
1613
+
1614
+ const blob = await recordingFinished;
1615
+ const extension = kind === 'mp4' ? 'mp4' : 'webm';
1616
+
1617
+ downloadBlob(blob, `brain-motion-${Date.now()}.${extension}`);
1618
+
1619
+ setStatus(kind === 'mp4'
1620
+ ? 'Saved MP4 with an opaque background. MP4 does not support transparency.'
1621
+ : 'Saved WebM. Transparent alpha depends on browser and codec support.');
1622
+ } catch (error) {
1623
+ console.error(error);
1624
+ setStatus(`Video export failed: ${error.message}`);
1625
+ } finally {
1626
+ recordingOpaque = false;
1627
+
1628
+ if (recorder && recorder.state !== 'inactive') {
1629
+ try { recorder.stop(); } catch (_) {}
1630
+ }
1631
+
1632
+ if (stream) stream.getTracks().forEach(track => track.stop());
1633
+
1634
+ try {
1635
+ await seekTo(previousTime);
1636
+ } catch (_) {}
1637
+
1638
+ if (wasPlaying) await video.play().catch(() => {});
1639
+
1640
+ renderSafely();
1641
+ setBusy(false);
1642
+ }
1643
+ }
1644
+
1645
+ $('exportWebm').addEventListener('click', () => exportRecordedVideo('webm'));
1646
+ $('exportMp4').addEventListener('click', () => exportRecordedVideo('mp4'));
1647
+
1648
+ function previewLoop() {
1649
+ if (video.videoWidth &&
1650
+ !video.paused &&
1651
+ video.readyState >= 2 &&
1652
+ video.currentTime !== lastRenderedTime) {
1653
+ renderSafely();
1654
+ }
1655
+
1656
+ requestAnimationFrame(previewLoop);
1657
+ }
1658
+
1659
+ updateLabels();
1660
+ updateIconStatus();
1661
+ syncActionButtons();
1662
+ requestAnimationFrame(previewLoop);
1663
+ })();
1664
+ </script>
1665
+ </body>
1666
+ </html>
assests/html_sample/ping_pong.html ADDED
File without changes
assests/illustration/COT_Chain_Of_thought_example.png ADDED
assests/illustration/agent_working_story.jpg ADDED

Git LFS Details

  • SHA256: 876c4b0374b4a9aceee37fda0a058acd48dca8ce9758eee908c867ca08390f3c
  • Pointer size: 131 Bytes
  • Size of remote file: 287 kB
assests/illustration/anthropic-light.svg ADDED
assests/illustration/docling.svg ADDED
assests/illustration/huggingface-color.svg ADDED
assests/illustration/langchain (1).webp ADDED
assests/illustration/langchain-color.svg ADDED
assests/illustration/llamaindex-color.svg ADDED
assests/illustration/rag_indexing.avif ADDED
assests/illustration/rag_retrieval_generation.avif ADDED
assests/illustration/smolagent.webp ADDED
assests/illustration/unstructured-color.svg ADDED
assests/learning reference/2 blue 3 brown.jpg ADDED
assests/learning reference/5 minutes engineering.jpg ADDED
assests/learning reference/analytics_india_magazine_logo.jpg ADDED
assests/learning reference/arenaai_logo.jpg ADDED
assests/learning reference/campusx.jpg ADDED
assests/learning reference/codewithharry.jpg ADDED
assests/learning reference/evolving_ai_logo.jpg ADDED
assests/learning reference/graduate_ml.jpg ADDED
assests/learning reference/krish_naik.jpg ADDED
assests/learning reference/nptel.jpg ADDED
assests/learning reference/varun mayya.jpg ADDED
assests/linkedin bg (4).png ADDED

Git LFS Details

  • SHA256: cf05862ac06d23326ebee8067aa66e71c9762fc1fce2a0d72163ca079a245c42
  • Pointer size: 131 Bytes
  • Size of remote file: 506 kB
assests/linkedin-profile-card.html ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>LinkedIn Dynamic Profile Card</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ margin: 0; min-height: 100vh; background: #0f1317; color: #fff;
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
12
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 28px; padding: 24px 12px;
13
+ }
14
+
15
+ /* Live image controls */
16
+ .controls { width: 100%; max-width: 485px; display: grid; gap: 10px; }
17
+ .field { display: grid; gap: 4px; font-size: 13px; color: #99A3BA; }
18
+ .row { display: flex; gap: 8px; }
19
+ .controls input[type=url] {
20
+ flex: 1; min-width: 0; padding: 9px 12px; border-radius: 8px; border: 1px solid #2A323D;
21
+ background: #181E24; color: #fff; font: inherit; font-size: 14px;
22
+ }
23
+ .controls input:focus-visible { outline: 2px solid #5CE1E6; outline-offset: 1px; }
24
+ .upload {
25
+ flex: none; display: flex; align-items: center; padding: 0 14px; border-radius: 8px; cursor: pointer;
26
+ background: #5CE1E6; color: #0A2540; font-size: 14px; font-weight: 600;
27
+ }
28
+ .upload:focus-within { outline: 2px solid #fff; outline-offset: 2px; }
29
+ .upload input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
30
+
31
+ /* Card wrapper */
32
+ .card-wrap { padding: 0; max-width: 100%; }
33
+
34
+ .card {
35
+ position: relative; width: 420px; max-width: 100%; overflow: visible;
36
+ background: #181E24; border: 1px solid #2A323D; border-radius: 20px;
37
+ color: #fff;
38
+ }
39
+
40
+ .banner {
41
+ position: relative; height: 150px; background: #232A32;
42
+ border-radius: 20px 20px 0 0; overflow: hidden;
43
+ display: flex; align-items: flex-start; justify-content: flex-start;
44
+ }
45
+ .banner-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center top; display: none; }
46
+ /* Sits to the right of the avatar so the two never overlap */
47
+ .banner-placeholder { padding: 16px 20px 0 176px; font-size: 13px; line-height: 1.4; color: #99A3BA; }
48
+
49
+ .avatar {
50
+ width: 140px; height: 140px; border-radius: 50%; border: 4px solid #181E24; box-shadow: none;
51
+ margin: -70px 0 0 20px; position: relative; background: #2A323D; overflow: hidden;
52
+ display: flex; align-items: center; justify-content: center; font-size: 12px; color: #99A3BA; text-align: center;
53
+ }
54
+ .avatar-img { width: 100%; height: 100%; object-fit: cover; display: none; }
55
+
56
+ .content { padding: 16px 20px 24px; }
57
+ .name { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 28px; font-weight: bold; line-height: 1.2; }
58
+ .name svg { flex: none; }
59
+ .headline { margin: 10px 0 0; font-size: 17px; line-height: 1.4; color: #D0D5DD; }
60
+ .location { margin: 8px 0 0; font-size: 15px; color: #99A3BA; }
61
+
62
+ .company { display: flex; align-items: center; gap: 16px; margin-top: 24px; }
63
+ .company-logo-box {
64
+ flex: none; width: 54px; height: 54px; border-radius: 8px; background: #fff; overflow: hidden;
65
+ display: flex; align-items: center; justify-content: center; text-align: center;
66
+ font-size: 8px; line-height: 1.1; color: #555; padding: 2px;
67
+ }
68
+ .company-logo { width: 100%; height: 100%; object-fit: contain; display: none; }
69
+ .company-name { font-size: 18px; font-weight: bold; }
70
+
71
+ </style>
72
+ </head>
73
+ <body>
74
+
75
+ <div class="controls" aria-label="Live image controls">
76
+ <div class="field">Banner Image URL
77
+ <div class="row">
78
+ <input id="bannerUrl" type="url" placeholder="https://…" aria-label="Banner Image URL">
79
+ <label class="upload">Upload<input id="bannerFile" type="file" accept="image/*"></label>
80
+ </div>
81
+ </div>
82
+ <div class="field">Profile Image URL
83
+ <div class="row">
84
+ <input id="profileUrl" type="url" placeholder="https://…" aria-label="Profile Image URL">
85
+ <label class="upload">Upload<input id="profileFile" type="file" accept="image/*"></label>
86
+ </div>
87
+ </div>
88
+ <div class="field">Company Logo URL
89
+ <div class="row">
90
+ <input id="companyUrl" type="url" placeholder="https://…" aria-label="Company Logo URL">
91
+ <label class="upload">Upload<input id="companyFile" type="file" accept="image/*"></label>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <div class="card-wrap">
97
+ <article class="card">
98
+ <div class="banner">
99
+ <img class="banner-img" alt="Banner">
100
+ <span class="banner-placeholder">Click top control panel to paste Banner Image URL</span>
101
+ </div>
102
+
103
+ <div class="avatar">
104
+ <img class="avatar-img" alt="Dev Patel">
105
+ <span class="avatar-placeholder">Profile image</span>
106
+ </div>
107
+
108
+ <div class="content">
109
+ <h1 class="name">Dev Patel
110
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#C5CBD6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-label="Verified">
111
+ <path d="M12 2.5l7.5 2.8v6c0 4.6-3.1 8.2-7.5 10.2-4.4-2-7.5-5.6-7.5-10.2v-6L12 2.5z"/>
112
+ <path d="M8.5 12.2l2.5 2.5 4.6-4.9"/>
113
+ </svg>
114
+ </h1>
115
+ <p class="headline">AI/ML Engineer @Codewinglet || Building Next-Gen AI Solutions ...</p>
116
+ <p class="location">Bardoli, Gujarat</p>
117
+
118
+ <div class="company">
119
+ <div class="company-logo-box">
120
+ <img class="company-logo" alt="Codewinglet logo">
121
+ <span class="logo-placeholder">Upload/paste custom logo URL</span>
122
+ </div>
123
+ <span class="company-name">Codewinglet Private Limited</span>
124
+ </div>
125
+ </div>
126
+ </article>
127
+ </div>
128
+
129
+ <script>
130
+ // Live image bindings: URL field + file upload -> image, with placeholder fallback
131
+ const bindings = [
132
+ { url: 'bannerUrl', file: 'bannerFile', img: '.banner-img', ph: '.banner-placeholder' },
133
+ { url: 'profileUrl', file: 'profileFile', img: '.avatar-img', ph: '.avatar-placeholder' },
134
+ { url: 'companyUrl', file: 'companyFile', img: '.company-logo', ph: '.logo-placeholder' }
135
+ ];
136
+
137
+ bindings.forEach(({ url, file, img, ph }) => {
138
+ const urlField = document.getElementById(url);
139
+ const fileField = document.getElementById(file);
140
+ const image = document.querySelector(img);
141
+ const placeholder = document.querySelector(ph);
142
+
143
+ const showPlaceholder = () => { image.style.display = 'none'; placeholder.style.display = ''; };
144
+ image.addEventListener('load', () => { image.style.display = 'block'; placeholder.style.display = 'none'; });
145
+ image.addEventListener('error', showPlaceholder);
146
+
147
+ urlField.addEventListener('input', () => {
148
+ fileField.value = '';
149
+ const v = urlField.value.trim();
150
+ if (!v) { image.removeAttribute('src'); showPlaceholder(); return; }
151
+ image.src = v;
152
+ });
153
+
154
+ fileField.addEventListener('change', () => {
155
+ const f = fileField.files[0];
156
+ if (!f) return;
157
+ urlField.value = '';
158
+ const reader = new FileReader();
159
+ reader.onload = () => { image.src = reader.result; };
160
+ reader.readAsDataURL(f);
161
+ });
162
+ });
163
+
164
+ </script>
165
+ </body>
166
+ </html>
assests/logos set whites/anthropic (1).svg ADDED
assests/logos set whites/antigravity-color.svg ADDED
assests/logos set whites/claude-color.svg ADDED
assests/logos set whites/codex-color (1).svg ADDED
assests/logos set whites/deepseek-color.svg ADDED
assests/logos set whites/gemini-color (1).svg ADDED
assests/logos set whites/grok.svg ADDED
assests/logos set whites/huggingface-color.svg ADDED
assests/logos set whites/kimi-color.svg ADDED