by3axap commited on
Commit
76fc602
·
verified ·
1 Parent(s): c5edb6b

Update templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +304 -242
templates/index.html CHANGED
@@ -3,16 +3,15 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>CELL CLIPPER PRO / V3.0</title>
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=JetBrains+Mono&display=swap');
9
 
10
  :root {
11
  --bg-page: #ffffff;
12
  --text-main: #000000;
13
- --text-muted: #888888;
14
  --border: #e0e0e0;
15
- --active: #000000;
16
  --font-sans: 'Inter', sans-serif;
17
  --font-mono: 'JetBrains Mono', monospace;
18
  }
@@ -29,106 +28,142 @@
29
 
30
  /* --- Sidebar --- */
31
  .sidebar {
32
- width: 380px;
33
- background: #fafafa;
34
  border-right: 1px solid var(--border);
35
  display: flex;
36
  flex-direction: column;
37
- padding: 24px;
38
  box-sizing: border-box;
39
  z-index: 10;
40
  overflow-y: auto;
41
  flex-shrink: 0;
42
  }
43
 
44
- .section { margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; }
45
- .section:last-child { border: none; }
46
-
47
  .section-title {
48
  font-family: var(--font-mono);
49
  font-size: 10px;
50
  color: var(--text-muted);
51
  text-transform: uppercase;
52
  letter-spacing: 0.1em;
53
- margin-bottom: 12px;
54
  display: block;
55
- font-weight: 600;
56
  }
57
 
58
  /* --- Controls --- */
59
- .control-group { margin-bottom: 12px; width: 100%; }
60
 
61
  .label-row {
62
  display: flex;
63
  justify-content: space-between;
64
- align-items: center;
65
  font-size: 11px;
66
- margin-bottom: 6px;
67
  font-family: var(--font-mono);
68
  }
69
 
70
- /* Number Input styling */
71
  .num-input {
72
  width: 50px;
73
  border: none;
74
- border-bottom: 1px solid var(--text-muted);
75
  background: transparent;
76
  font-family: var(--font-mono);
77
  font-size: 11px;
78
  text-align: right;
79
  outline: none;
80
- padding: 2px 0;
 
81
  }
82
- .num-input:focus { border-bottom: 1px solid black; }
83
 
 
84
  input[type="range"] {
85
  width: 100%;
86
  -webkit-appearance: none;
87
  background: transparent;
88
- margin: 8px 0;
 
89
  }
 
 
90
  input[type="range"]::-webkit-slider-runnable-track {
91
- width: 100%; height: 2px; background: #ddd;
 
 
 
92
  }
 
 
93
  input[type="range"]::-webkit-slider-thumb {
94
- -webkit-appearance: none; height: 12px; width: 12px;
95
- background: black; border-radius: 50%; margin-top: -5px; cursor: pointer;
 
 
 
 
 
96
  }
97
 
98
- /* --- File Inputs --- */
 
 
99
  input[type="file"] { display: none; }
100
- .file-upload-label {
101
- display: block;
102
- border: 1px dashed var(--text-muted);
103
- padding: 12px;
104
- text-align: center;
105
- cursor: pointer;
106
- font-size: 11px;
 
 
 
107
  font-family: var(--font-mono);
 
108
  text-transform: uppercase;
 
109
  transition: all 0.2s;
110
- background: white;
111
- color: var(--text-muted);
112
  }
113
- .file-upload-label:hover { border-color: black; color: black; }
114
- .file-upload-label.active { border-style: solid; border-color: black; color: black; background: #f0f0f0; }
115
-
116
- /* --- Tabs for Media --- */
117
- .media-tabs { display: flex; gap: 10px; margin-bottom: 10px; }
118
- .tab-btn {
119
- flex: 1; border: 1px solid var(--border); background: white;
120
- padding: 8px; font-size: 10px; font-family: var(--font-mono); cursor: pointer;
121
  }
122
- .tab-btn.active { background: black; color: white; border-color: black; }
123
 
124
- /* --- Buttons --- */
125
- .buttons-container { display: flex; gap: 10px; margin-top: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  .btn-action {
127
  flex: 1;
128
- background: var(--text-main);
129
  color: white;
130
  border: none;
131
- padding: 14px;
132
  font-family: var(--font-mono);
133
  font-size: 11px;
134
  text-transform: uppercase;
@@ -136,13 +171,23 @@
136
  transition: opacity 0.2s;
137
  }
138
  .btn-action:hover { opacity: 0.8; }
139
- .btn-action.secondary { background: white; color: black; border: 1px solid black; }
140
- .btn-action:disabled { opacity: 0.5; cursor: not-allowed; }
 
 
 
 
 
 
 
 
 
 
141
 
142
  /* --- Preview Area --- */
143
  .preview-area {
144
  flex: 1;
145
- background-color: #f0f0f0;
146
  display: flex;
147
  align-items: center;
148
  justify-content: center;
@@ -151,53 +196,59 @@
151
  }
152
 
153
  canvas {
154
- background: white;
155
- box-shadow: 0 20px 50px rgba(0,0,0,0.1);
156
  max-width: 100%;
157
  max-height: 100%;
158
  object-fit: contain;
159
  }
160
 
161
- /* Hidden media sources */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  #source-video, #source-image { display: none; }
163
  </style>
164
  </head>
165
  <body>
166
 
167
  <div class="sidebar">
168
- <div style="margin-bottom: 20px;">
169
- <h1 style="font-size: 14px; font-weight: 600; letter-spacing: -0.02em; margin:0;">CELL CLIPPER PRO</h1>
170
- <span style="font-size: 10px; color: #888; font-family: var(--font-mono);">REALTIME PROCESSING</span>
171
  </div>
172
 
173
  <div class="section">
174
- <span class="section-title">01 / Shape (SVG)</span>
175
  <input type="file" id="shape_svg" accept=".svg">
176
- <label for="shape_svg" class="file-upload-label" id="lbl-shape">Default Square</label>
 
177
  </div>
178
 
179
  <div class="section">
180
- <span class="section-title">02 / Source Media</span>
181
-
182
- <div class="media-tabs">
183
- <button class="tab-btn active" onclick="setMode('image')">IMAGE</button>
184
- <button class="tab-btn" onclick="setMode('video')">VIDEO</button>
185
- </div>
186
-
187
- <div id="upload-image-box">
188
- <input type="file" id="raster_image" accept=".png, .jpg, .jpeg">
189
- <label for="raster_image" class="file-upload-label" id="lbl-raster">Upload Image</label>
190
- </div>
191
-
192
- <div id="upload-video-box" style="display:none;">
193
- <input type="file" id="video_file" accept=".mp4, .mov, .webm">
194
- <label for="video_file" class="file-upload-label" id="lbl-video">Upload Video (MP4)</label>
195
- </div>
196
  </div>
197
 
198
  <form id="params-form">
199
  <div class="section">
200
- <span class="section-title">03 / Grid Layout</span>
201
 
202
  <div class="control-group">
203
  <div class="label-row">
@@ -230,14 +281,10 @@
230
  </div>
231
  <input type="range" id="shift_stagger" min="0" max="1" step="0.01" value="0">
232
  </div>
233
- </div>
234
 
235
- <div class="section">
236
- <span class="section-title">04 / Transformation</span>
237
-
238
- <div class="control-group">
239
  <div class="label-row">
240
- <span>Base Scale</span>
241
  <input type="number" class="num-input" id="num-base_scale" value="1.0" step="0.05" min="0.1" max="2.0">
242
  </div>
243
  <input type="range" id="base_scale" min="0.1" max="2.0" step="0.05" value="1.0">
@@ -245,7 +292,7 @@
245
 
246
  <div class="control-group">
247
  <div class="label-row">
248
- <span>Growth Factor</span>
249
  <input type="number" class="num-input" id="num-growth" value="0" step="0.05" min="0" max="2.0">
250
  </div>
251
  <input type="range" id="growth" min="0" max="2.0" step="0.05" value="0">
@@ -253,7 +300,7 @@
253
 
254
  <div class="control-group">
255
  <div class="label-row">
256
- <label style="display:flex; align-items:center; gap:5px; cursor:pointer;">
257
  <input type="checkbox" id="invert">
258
  <span>Invert Logic</span>
259
  </label>
@@ -261,8 +308,8 @@
261
  </div>
262
 
263
  <div class="control-group">
264
- <span style="font-size:10px; color:#888; font-family:var(--font-mono);">GROWTH PATTERN</span>
265
- <select id="growth_type" style="width:100%; margin-top:5px; padding:5px; font-family:var(--font-mono);">
266
  <option value="sine">Diagonal Sine</option>
267
  <option value="linear">Linear Gradient</option>
268
  <option value="steps">Steps</option>
@@ -272,10 +319,10 @@
272
  </form>
273
 
274
  <div class="buttons-container">
275
- <button class="btn-action secondary" id="btn-dl-svg">SVG Image</button>
276
- <button class="btn-action" id="btn-dl-video" disabled>Render MP4</button>
277
  </div>
278
- <div id="status-msg" style="font-size:10px; font-family:var(--font-mono); margin-top:10px; color:blue;"></div>
279
  </div>
280
 
281
  <div class="preview-area">
@@ -283,21 +330,20 @@
283
 
284
  <img id="source-image" src="" crossorigin="anonymous">
285
  <video id="source-video" src="" playsinline loop muted crossorigin="anonymous"></video>
286
-
287
  <canvas id="helper-canvas" style="display:none;"></canvas>
288
  <img id="shape-img" src="" style="display:none;">
289
  </div>
290
 
291
  <script>
292
  // --- STATE ---
293
- let currentMode = 'image'; // 'image' or 'video'
294
- let isVideoPlaying = false;
295
  let animationId = null;
296
  let customShapeLoaded = false;
297
 
298
- // --- DOM ELEMENTS ---
299
  const canvas = document.getElementById('main-canvas');
300
- const ctx = canvas.getContext('2d', { alpha: false }); // Optimize
301
  const helperCanvas = document.getElementById('helper-canvas');
302
  const helperCtx = helperCanvas.getContext('2d');
303
 
@@ -305,94 +351,117 @@
305
  const sourceVideo = document.getElementById('source-video');
306
  const shapeImg = document.getElementById('shape-img');
307
 
308
- // --- INPUTS SYNC ---
309
- const paramIds = ['density', 'gap_x', 'gap_y', 'shift_stagger', 'base_scale', 'growth'];
310
-
311
- paramIds.forEach(id => {
312
- const slider = document.getElementById(id);
313
- const number = document.getElementById('num-' + id);
314
-
315
- slider.addEventListener('input', () => { number.value = slider.value; requestRender(); });
316
- number.addEventListener('input', () => { slider.value = number.value; requestRender(); });
317
- });
318
-
319
- document.getElementById('invert').addEventListener('change', requestRender);
320
- document.getElementById('growth_type').addEventListener('change', requestRender);
321
-
322
- // --- MODE SWITCH ---
323
- window.setMode = (mode) => {
324
- currentMode = mode;
325
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
326
- if(mode === 'image') {
327
- document.querySelectorAll('.tab-btn')[0].classList.add('active');
328
- document.getElementById('upload-image-box').style.display = 'block';
329
- document.getElementById('upload-video-box').style.display = 'none';
330
- document.getElementById('btn-dl-video').disabled = true;
331
- document.getElementById('btn-dl-svg').disabled = false;
332
- sourceVideo.pause();
333
  } else {
334
- document.querySelectorAll('.tab-btn')[1].classList.add('active');
335
- document.getElementById('upload-image-box').style.display = 'none';
336
- document.getElementById('upload-video-box').style.display = 'block';
337
- document.getElementById('btn-dl-video').disabled = false;
338
- document.getElementById('btn-dl-svg').disabled = true; // SVG from video not supported frame-by-frame here
339
- if(sourceVideo.src) sourceVideo.play();
340
  }
341
- requestRender();
342
  };
343
 
344
- // --- FILE HANDLERS ---
345
-
346
- // 1. Shape SVG
347
- document.getElementById('shape_svg').addEventListener('change', (e) => {
 
348
  const file = e.target.files[0];
349
  if(!file) return;
350
- document.getElementById('lbl-shape').innerText = file.name;
351
- document.getElementById('lbl-shape').classList.add('active');
352
 
353
  const reader = new FileReader();
354
  reader.onload = (evt) => {
355
- // Convert SVG to Data URL for Canvas drawing
356
- const svgStr = evt.target.result;
357
- const blob = new Blob([svgStr], {type: 'image/svg+xml'});
358
- const url = URL.createObjectURL(blob);
359
  shapeImg.onload = () => { customShapeLoaded = true; requestRender(); };
360
- shapeImg.src = url;
361
  };
362
  reader.readAsText(file);
363
  });
364
 
365
- // 2. Raster Image
366
- document.getElementById('raster_image').addEventListener('change', (e) => {
367
- const file = e.target.files[0];
368
- if(!file) return;
369
- document.getElementById('lbl-raster').innerText = file.name;
370
- document.getElementById('lbl-raster').classList.add('active');
371
- const url = URL.createObjectURL(file);
372
- sourceImage.onload = () => { requestRender(); };
373
- sourceImage.src = url;
374
  });
375
 
376
- // 3. Video
377
- document.getElementById('video_file').addEventListener('change', (e) => {
 
378
  const file = e.target.files[0];
379
  if(!file) return;
380
- document.getElementById('lbl-video').innerText = file.name;
381
- document.getElementById('lbl-video').classList.add('active');
 
 
382
  const url = URL.createObjectURL(file);
383
- sourceVideo.src = url;
384
- sourceVideo.play();
385
- requestRender(); // Start loop
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  });
387
 
388
- // --- RENDER LOOP ---
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  function requestRender() {
390
  if (currentMode === 'video' && !sourceVideo.paused) {
391
- if (!animationId) {
392
- renderLoop();
393
- }
394
  } else {
395
- // Static render
396
  drawFrame();
397
  }
398
  }
@@ -406,7 +475,6 @@
406
  }
407
  }
408
 
409
- // --- CORE LOGIC (JS version of Python logic) ---
410
  function drawFrame() {
411
  const w = canvas.width;
412
  const h = canvas.height;
@@ -425,23 +493,21 @@
425
  ctx.fillStyle = "white";
426
  ctx.fillRect(0, 0, w, h);
427
 
428
- // Prepare Source Data (Pixel reading)
429
  let sourceData = null;
430
- let sw = 0, sh = 0;
431
-
432
- // Draw source to helper canvas to read pixels
433
- let activeSource = (currentMode === 'video') ? sourceVideo : sourceImage;
434
 
435
- if (activeSource && ((activeSource.tagName === 'IMG' && activeSource.src) || (activeSource.tagName === 'VIDEO' && activeSource.readyState >= 2))) {
 
 
 
 
 
436
  helperCanvas.width = w;
437
  helperCanvas.height = h;
438
- // Scale object fit cover
439
- // Simple fit: stretch for now to match python logic
440
  helperCtx.drawImage(activeSource, 0, 0, w, h);
441
-
442
- // Get all pixels at once (Optimization)
443
  const imgData = helperCtx.getImageData(0, 0, w, h);
444
- sourceData = imgData.data; // R,G,B,A array
445
  sw = w;
446
  }
447
 
@@ -453,46 +519,41 @@
453
  const cellWMax = step * (1.0 - gapX);
454
  const cellHMax = step * (1.0 - gapY);
455
 
 
 
 
 
456
  for (let r = 0; r < rows; r++) {
457
  const rowShift = (r * stagger * step);
458
- // Optimization range
459
- for (let c = -Math.floor(density/2); c < density * 1.5; c++) {
460
  const gridX = c * step + rowShift;
461
  const gridY = r * step;
462
 
463
  if (gridX < -step || gridX > w) continue;
464
 
465
- // 1. Visibility / Mask Logic
466
- let brightness = 0; // Black
467
  let isVisible = true;
468
-
469
  if (sourceData) {
470
  const sx = Math.max(0, Math.min(Math.floor(gridX + step/2), w - 1));
471
  const sy = Math.max(0, Math.min(Math.floor(gridY + step/2), h - 1));
472
-
473
  const idx = (sy * sw + sx) * 4;
474
- const rVal = sourceData[idx];
475
- const gVal = sourceData[idx+1];
476
- const bVal = sourceData[idx+2];
 
477
 
478
- // Simple grayscale
479
- brightness = (rVal + gVal + bVal) / 3 / 255.0;
480
-
481
  const limit = 0.5;
482
- isVisible = invert ? (brightness > limit) : (brightness < limit);
483
  }
484
 
485
  if (!isVisible) continue;
486
 
487
- // 2. Growth Logic
488
  let wave = 0;
489
- if (growthType === 'sine') {
490
- wave = (Math.sin(c * 0.3 + r * 0.3) + 1) / 2;
491
- } else if (growthType === 'linear') {
492
- wave = ((c % density) / density);
493
- } else if (growthType === 'steps') {
494
- wave = (r % 5) / 5.0;
495
- }
496
 
497
  let modW = 1.0 - (wave * growth);
498
  modW = Math.max(0.01, Math.min(1.0, modW));
@@ -503,17 +564,13 @@
503
  const cx = gridX + step/2;
504
  const cy = gridY + step/2;
505
 
506
- // Draw Shape
507
  ctx.save();
508
  ctx.translate(cx, cy);
509
 
510
  if (customShapeLoaded) {
511
- // Draw SVG image
512
- // Needs scaling calc to match contentW/H
513
- // Assuming square SVG
514
  ctx.drawImage(shapeImg, -contentW/2, -contentH/2, contentW, contentH);
515
  } else {
516
- // Default Rect
517
  ctx.fillRect(-contentW/2, -contentH/2, contentW, contentH);
518
  }
519
  ctx.restore();
@@ -521,82 +578,87 @@
521
  }
522
  }
523
 
524
- // --- EXPORT HANDLERS ---
525
-
526
- // 1. Download SVG (via Python for correctness or JS)
527
- // Using Python to keep SVG logic clean and downloadable
528
  document.getElementById('btn-dl-svg').addEventListener('click', async () => {
 
 
 
529
  const form = new FormData();
530
- // Append all inputs
531
  paramIds.forEach(id => form.append(id, document.getElementById(id).value));
532
  form.append('growth_type', document.getElementById('growth_type').value);
533
  form.append('invert', document.getElementById('invert').checked);
534
-
535
- const shp = document.getElementById('shape_svg').files[0];
536
- if(shp) form.append('shape_svg', shp);
537
-
538
- const rast = document.getElementById('raster_image').files[0];
539
- if(rast) form.append('raster_image', rast);
540
 
541
- const btn = document.getElementById('btn-dl-svg');
542
- btn.innerText = "Generating...";
543
 
544
- const res = await fetch('/download_svg', { method: 'POST', body: form });
545
- if(res.ok) {
546
- const blob = await res.blob();
547
- const url = URL.createObjectURL(blob);
 
 
 
 
 
 
 
 
548
  const a = document.createElement('a');
549
  a.href = url;
550
- a.download = 'pattern.svg';
 
551
  a.click();
552
- } else {
553
- alert("Error generating SVG");
 
554
  }
555
- btn.innerText = "SVG IMAGE";
556
  });
557
 
558
- // 2. Download Video (via Python)
559
  document.getElementById('btn-dl-video').addEventListener('click', async () => {
 
 
 
 
 
560
  const form = new FormData();
561
  paramIds.forEach(id => form.append(id, document.getElementById(id).value));
562
  form.append('growth_type', document.getElementById('growth_type').value);
563
  form.append('invert', document.getElementById('invert').checked);
564
-
565
- const vid = document.getElementById('video_file').files[0];
566
- if(!vid) { alert("Please upload a video first"); return; }
567
- form.append('video_file', vid);
568
 
569
- const btn = document.getElementById('btn-dl-video');
570
- btn.innerText = "Rendering (Wait)...";
571
- btn.disabled = true;
572
- document.getElementById('status-msg').innerText = "Processing video on server. This may take a minute...";
 
573
 
574
  try {
575
- const res = await fetch('/process_video', { method: 'POST', body: form });
576
- if(res.ok) {
577
- const blob = await res.blob();
578
- const url = URL.createObjectURL(blob);
579
- const a = document.createElement('a');
580
- a.href = url;
581
- a.download = 'processed_video.mp4';
582
- a.click();
583
- document.getElementById('status-msg').innerText = "Done!";
584
- } else {
585
- const txt = await res.text();
586
- alert("Error: " + txt);
587
- document.getElementById('status-msg').innerText = "Error.";
588
- }
589
- } catch(e) {
590
- console.error(e);
591
- alert("Network error");
592
  }
593
- btn.innerText = "RENDER MP4";
594
  btn.disabled = false;
595
  });
596
 
597
  // Init
598
  requestRender();
599
- </script>
600
 
 
601
  </body>
602
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CELL CLIPPER PRO</title>
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=JetBrains+Mono&display=swap');
9
 
10
  :root {
11
  --bg-page: #ffffff;
12
  --text-main: #000000;
13
+ --text-muted: #aaaaaa;
14
  --border: #e0e0e0;
 
15
  --font-sans: 'Inter', sans-serif;
16
  --font-mono: 'JetBrains Mono', monospace;
17
  }
 
28
 
29
  /* --- Sidebar --- */
30
  .sidebar {
31
+ width: 340px;
32
+ background: #ffffff;
33
  border-right: 1px solid var(--border);
34
  display: flex;
35
  flex-direction: column;
36
+ padding: 32px;
37
  box-sizing: border-box;
38
  z-index: 10;
39
  overflow-y: auto;
40
  flex-shrink: 0;
41
  }
42
 
43
+ .section { margin-bottom: 40px; }
44
+
 
45
  .section-title {
46
  font-family: var(--font-mono);
47
  font-size: 10px;
48
  color: var(--text-muted);
49
  text-transform: uppercase;
50
  letter-spacing: 0.1em;
51
+ margin-bottom: 16px;
52
  display: block;
 
53
  }
54
 
55
  /* --- Controls --- */
56
+ .control-group { margin-bottom: 16px; width: 100%; }
57
 
58
  .label-row {
59
  display: flex;
60
  justify-content: space-between;
61
+ align-items: baseline;
62
  font-size: 11px;
63
+ margin-bottom: 4px;
64
  font-family: var(--font-mono);
65
  }
66
 
67
+ /* Input Number Styling - No underline, plain text */
68
  .num-input {
69
  width: 50px;
70
  border: none;
 
71
  background: transparent;
72
  font-family: var(--font-mono);
73
  font-size: 11px;
74
  text-align: right;
75
  outline: none;
76
+ color: var(--text-main);
77
+ padding: 0;
78
  }
79
+ .num-input:focus { color: blue; } /* Индикация фокуса цветом, а не линией */
80
 
81
+ /* Custom Range Slider Styling */
82
  input[type="range"] {
83
  width: 100%;
84
  -webkit-appearance: none;
85
  background: transparent;
86
+ margin: 10px 0;
87
+ cursor: pointer;
88
  }
89
+
90
+ /* The Track (Thin black line) */
91
  input[type="range"]::-webkit-slider-runnable-track {
92
+ width: 100%;
93
+ height: 1px;
94
+ background: #000000;
95
+ border: none;
96
  }
97
+
98
+ /* The Thumb (Thick black line/rect) */
99
  input[type="range"]::-webkit-slider-thumb {
100
+ -webkit-appearance: none;
101
+ height: 14px;
102
+ width: 4px;
103
+ background: #000000;
104
+ border-radius: 0;
105
+ margin-top: -6.5px; /* (height/2 - track_height/2) */
106
+ border: none;
107
  }
108
 
109
+ input[type="range"]:focus { outline: none; }
110
+
111
+ /* --- File Buttons --- */
112
  input[type="file"] { display: none; }
113
+
114
+ .btn-upload {
115
+ display: flex;
116
+ justify-content: center;
117
+ align-items: center;
118
+ width: 100%;
119
+ padding: 12px 0;
120
+ border: 1px solid #000000;
121
+ background: white;
122
+ color: black;
123
  font-family: var(--font-mono);
124
+ font-size: 11px;
125
  text-transform: uppercase;
126
+ cursor: pointer;
127
  transition: all 0.2s;
128
+ box-sizing: border-box;
129
+ text-align: center;
130
  }
131
+
132
+ .btn-upload:hover {
133
+ background: #f5f5f5;
 
 
 
 
 
134
  }
 
135
 
136
+ /* Active State (Black Filled) */
137
+ .btn-upload.filled {
138
+ background: #000000;
139
+ color: #ffffff;
140
+ border-color: #000000;
141
+ }
142
+
143
+ .remove-link {
144
+ display: block;
145
+ text-align: center;
146
+ font-family: var(--font-mono);
147
+ font-size: 10px;
148
+ color: #000000;
149
+ text-decoration: underline;
150
+ margin-top: 8px;
151
+ cursor: pointer;
152
+ border: none;
153
+ background: none;
154
+ width: 100%;
155
+ }
156
+ .remove-link:hover { color: red; }
157
+
158
+ /* --- Action Buttons --- */
159
+ .buttons-container { display: flex; gap: 12px; margin-top: auto; padding-top: 20px;}
160
+
161
  .btn-action {
162
  flex: 1;
163
+ background: #000000;
164
  color: white;
165
  border: none;
166
+ padding: 14px 0;
167
  font-family: var(--font-mono);
168
  font-size: 11px;
169
  text-transform: uppercase;
 
171
  transition: opacity 0.2s;
172
  }
173
  .btn-action:hover { opacity: 0.8; }
174
+ .btn-action.secondary {
175
+ background: white;
176
+ color: black;
177
+ border: 1px solid black;
178
+ }
179
+ .btn-action:disabled {
180
+ opacity: 0.3;
181
+ cursor: not-allowed;
182
+ background: #ccc;
183
+ border-color: #ccc;
184
+ color: #fff;
185
+ }
186
 
187
  /* --- Preview Area --- */
188
  .preview-area {
189
  flex: 1;
190
+ background-color: #ffffff; /* White background */
191
  display: flex;
192
  align-items: center;
193
  justify-content: center;
 
196
  }
197
 
198
  canvas {
199
+ box-shadow: 0 0 0 1px #f0f0f0; /* Subtle border for visibility on white */
 
200
  max-width: 100%;
201
  max-height: 100%;
202
  object-fit: contain;
203
  }
204
 
205
+ /* Misc */
206
+ select {
207
+ width: 100%;
208
+ padding: 5px 0;
209
+ border: none;
210
+ border-bottom: 1px solid #000000;
211
+ font-family: var(--font-mono);
212
+ font-size: 11px;
213
+ outline: none;
214
+ background: transparent;
215
+ border-radius: 0;
216
+ -webkit-appearance: none;
217
+ }
218
+
219
+ /* Checkbox custom styling (simple) */
220
+ input[type="checkbox"] {
221
+ accent-color: black;
222
+ }
223
+
224
+ /* Hidden sources */
225
  #source-video, #source-image { display: none; }
226
  </style>
227
  </head>
228
  <body>
229
 
230
  <div class="sidebar">
231
+ <div style="margin-bottom: 40px;">
232
+ <h1 style="font-size: 14px; font-weight: 600; margin:0; letter-spacing: -0.02em;">CELL CLIPPER PRO</h1>
 
233
  </div>
234
 
235
  <div class="section">
236
+ <span class="section-title">01 / Shape Pattern</span>
237
  <input type="file" id="shape_svg" accept=".svg">
238
+ <label for="shape_svg" class="btn-upload" id="lbl-shape">Select SVG</label>
239
+ <button id="rm-shape" class="remove-link" style="display:none;">remove</button>
240
  </div>
241
 
242
  <div class="section">
243
+ <span class="section-title">02 / Content Source</span>
244
+ <input type="file" id="content_file" accept=".png, .jpg, .jpeg, .mp4, .mov, .webm">
245
+ <label for="content_file" class="btn-upload" id="lbl-content">Select Content</label>
246
+ <button id="rm-content" class="remove-link" style="display:none;">remove</button>
 
 
 
 
 
 
 
 
 
 
 
 
247
  </div>
248
 
249
  <form id="params-form">
250
  <div class="section">
251
+ <span class="section-title">03 / Parameters</span>
252
 
253
  <div class="control-group">
254
  <div class="label-row">
 
281
  </div>
282
  <input type="range" id="shift_stagger" min="0" max="1" step="0.01" value="0">
283
  </div>
 
284
 
285
+ <div class="control-group" style="margin-top:24px;">
 
 
 
286
  <div class="label-row">
287
+ <span>Scale</span>
288
  <input type="number" class="num-input" id="num-base_scale" value="1.0" step="0.05" min="0.1" max="2.0">
289
  </div>
290
  <input type="range" id="base_scale" min="0.1" max="2.0" step="0.05" value="1.0">
 
292
 
293
  <div class="control-group">
294
  <div class="label-row">
295
+ <span>Growth</span>
296
  <input type="number" class="num-input" id="num-growth" value="0" step="0.05" min="0" max="2.0">
297
  </div>
298
  <input type="range" id="growth" min="0" max="2.0" step="0.05" value="0">
 
300
 
301
  <div class="control-group">
302
  <div class="label-row">
303
+ <label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
304
  <input type="checkbox" id="invert">
305
  <span>Invert Logic</span>
306
  </label>
 
308
  </div>
309
 
310
  <div class="control-group">
311
+ <span style="font-size:10px; color:#aaa; font-family:var(--font-mono); display:block; margin-bottom:4px;">GROWTH TYPE</span>
312
+ <select id="growth_type">
313
  <option value="sine">Diagonal Sine</option>
314
  <option value="linear">Linear Gradient</option>
315
  <option value="steps">Steps</option>
 
319
  </form>
320
 
321
  <div class="buttons-container">
322
+ <button class="btn-action secondary" id="btn-dl-svg">Download SVG</button>
323
+ <button class="btn-action" id="btn-dl-video" disabled>Export MP4</button>
324
  </div>
325
+ <div id="status-msg" style="font-size:10px; font-family:var(--font-mono); margin-top:10px; text-align:center;"></div>
326
  </div>
327
 
328
  <div class="preview-area">
 
330
 
331
  <img id="source-image" src="" crossorigin="anonymous">
332
  <video id="source-video" src="" playsinline loop muted crossorigin="anonymous"></video>
 
333
  <canvas id="helper-canvas" style="display:none;"></canvas>
334
  <img id="shape-img" src="" style="display:none;">
335
  </div>
336
 
337
  <script>
338
  // --- STATE ---
339
+ let currentMode = 'image'; // 'image' | 'video'
340
+ let currentFile = null; // Храним сам объект файла
341
  let animationId = null;
342
  let customShapeLoaded = false;
343
 
344
+ // --- DOM ---
345
  const canvas = document.getElementById('main-canvas');
346
+ const ctx = canvas.getContext('2d', { alpha: false });
347
  const helperCanvas = document.getElementById('helper-canvas');
348
  const helperCtx = helperCanvas.getContext('2d');
349
 
 
351
  const sourceVideo = document.getElementById('source-video');
352
  const shapeImg = document.getElementById('shape-img');
353
 
354
+ // --- UI HELPERS ---
355
+ const setBtnState = (labelId, rmId, file, isActive) => {
356
+ const lbl = document.getElementById(labelId);
357
+ const rm = document.getElementById(rmId);
358
+ if(isActive && file) {
359
+ lbl.classList.add('filled');
360
+ lbl.innerText = file.name;
361
+ rm.style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  } else {
363
+ lbl.classList.remove('filled');
364
+ lbl.innerText = labelId === 'lbl-shape' ? 'Select SVG' : 'Select Content';
365
+ rm.style.display = 'none';
 
 
 
366
  }
 
367
  };
368
 
369
+ // --- EVENT LISTENERS ---
370
+
371
+ // 1. Shape Upload
372
+ const shapeInput = document.getElementById('shape_svg');
373
+ shapeInput.addEventListener('change', (e) => {
374
  const file = e.target.files[0];
375
  if(!file) return;
376
+
377
+ setBtnState('lbl-shape', 'rm-shape', file, true);
378
 
379
  const reader = new FileReader();
380
  reader.onload = (evt) => {
381
+ const blob = new Blob([evt.target.result], {type: 'image/svg+xml'});
382
+ shapeImg.src = URL.createObjectURL(blob);
 
 
383
  shapeImg.onload = () => { customShapeLoaded = true; requestRender(); };
 
384
  };
385
  reader.readAsText(file);
386
  });
387
 
388
+ document.getElementById('rm-shape').addEventListener('click', () => {
389
+ shapeInput.value = '';
390
+ customShapeLoaded = false;
391
+ shapeImg.src = '';
392
+ setBtnState('lbl-shape', 'rm-shape', null, false);
393
+ requestRender();
 
 
 
394
  });
395
 
396
+ // 2. Content Upload (Unified)
397
+ const contentInput = document.getElementById('content_file');
398
+ contentInput.addEventListener('change', (e) => {
399
  const file = e.target.files[0];
400
  if(!file) return;
401
+
402
+ currentFile = file; // Save for export
403
+ setBtnState('lbl-content', 'rm-content', file, true);
404
+
405
  const url = URL.createObjectURL(file);
406
+ const type = file.type;
407
+
408
+ // Reset previous sources
409
+ sourceImage.src = "";
410
+ sourceVideo.src = "";
411
+ sourceVideo.pause();
412
+
413
+ if (type.startsWith('image/')) {
414
+ currentMode = 'image';
415
+ sourceImage.src = url;
416
+ sourceImage.onload = () => requestRender();
417
+
418
+ document.getElementById('btn-dl-video').disabled = true;
419
+ document.getElementById('btn-dl-svg').disabled = false;
420
+ }
421
+ else if (type.startsWith('video/')) {
422
+ currentMode = 'video';
423
+ sourceVideo.src = url;
424
+ sourceVideo.play();
425
+
426
+ document.getElementById('btn-dl-video').disabled = false;
427
+ document.getElementById('btn-dl-svg').disabled = true; // SVG export only for static
428
+ requestRender();
429
+ }
430
+ });
431
+
432
+ document.getElementById('rm-content').addEventListener('click', () => {
433
+ contentInput.value = '';
434
+ currentFile = null;
435
+ sourceImage.src = "";
436
+ sourceVideo.src = "";
437
+ sourceVideo.pause();
438
+ setBtnState('lbl-content', 'rm-content', null, false);
439
+
440
+ // Reset defaults
441
+ currentMode = 'image';
442
+ document.getElementById('btn-dl-video').disabled = true;
443
+ document.getElementById('btn-dl-svg').disabled = false;
444
+ requestRender();
445
  });
446
 
447
+ // 3. Inputs Sync
448
+ const paramIds = ['density', 'gap_x', 'gap_y', 'shift_stagger', 'base_scale', 'growth'];
449
+ paramIds.forEach(id => {
450
+ const slider = document.getElementById(id);
451
+ const number = document.getElementById('num-' + id);
452
+
453
+ slider.addEventListener('input', () => { number.value = slider.value; requestRender(); });
454
+ number.addEventListener('input', () => { slider.value = number.value; requestRender(); });
455
+ });
456
+
457
+ document.getElementById('invert').addEventListener('change', requestRender);
458
+ document.getElementById('growth_type').addEventListener('change', requestRender);
459
+
460
+ // --- RENDER LOGIC ---
461
  function requestRender() {
462
  if (currentMode === 'video' && !sourceVideo.paused) {
463
+ if (!animationId) renderLoop();
 
 
464
  } else {
 
465
  drawFrame();
466
  }
467
  }
 
475
  }
476
  }
477
 
 
478
  function drawFrame() {
479
  const w = canvas.width;
480
  const h = canvas.height;
 
493
  ctx.fillStyle = "white";
494
  ctx.fillRect(0, 0, w, h);
495
 
496
+ // Read Pixel Data
497
  let sourceData = null;
498
+ let sw = 0;
 
 
 
499
 
500
+ let activeSource = (currentMode === 'video') ? sourceVideo : sourceImage;
501
+ let ready = false;
502
+ if(currentMode === 'image' && sourceImage.src) ready = true;
503
+ if(currentMode === 'video' && sourceVideo.readyState >= 2) ready = true;
504
+
505
+ if (ready) {
506
  helperCanvas.width = w;
507
  helperCanvas.height = h;
 
 
508
  helperCtx.drawImage(activeSource, 0, 0, w, h);
 
 
509
  const imgData = helperCtx.getImageData(0, 0, w, h);
510
+ sourceData = imgData.data;
511
  sw = w;
512
  }
513
 
 
519
  const cellWMax = step * (1.0 - gapX);
520
  const cellHMax = step * (1.0 - gapY);
521
 
522
+ // Optimization: loop range
523
+ const startC = -Math.floor(density * 0.5);
524
+ const endC = density * 1.5;
525
+
526
  for (let r = 0; r < rows; r++) {
527
  const rowShift = (r * stagger * step);
528
+
529
+ for (let c = startC; c < endC; c++) {
530
  const gridX = c * step + rowShift;
531
  const gridY = r * step;
532
 
533
  if (gridX < -step || gridX > w) continue;
534
 
535
+ // 1. Brightness
 
536
  let isVisible = true;
 
537
  if (sourceData) {
538
  const sx = Math.max(0, Math.min(Math.floor(gridX + step/2), w - 1));
539
  const sy = Math.max(0, Math.min(Math.floor(gridY + step/2), h - 1));
 
540
  const idx = (sy * sw + sx) * 4;
541
+ const rV = sourceData[idx];
542
+ const gV = sourceData[idx+1];
543
+ const bV = sourceData[idx+2];
544
+ const bright = (rV + gV + bV) / 3 / 255.0;
545
 
 
 
 
546
  const limit = 0.5;
547
+ isVisible = invert ? (bright > limit) : (bright < limit);
548
  }
549
 
550
  if (!isVisible) continue;
551
 
552
+ // 2. Growth
553
  let wave = 0;
554
+ if (growthType === 'sine') wave = (Math.sin(c * 0.3 + r * 0.3) + 1) / 2;
555
+ else if (growthType === 'linear') wave = ((c % density) / density);
556
+ else if (growthType === 'steps') wave = (r % 5) / 5.0;
 
 
 
 
557
 
558
  let modW = 1.0 - (wave * growth);
559
  modW = Math.max(0.01, Math.min(1.0, modW));
 
564
  const cx = gridX + step/2;
565
  const cy = gridY + step/2;
566
 
567
+ // Draw
568
  ctx.save();
569
  ctx.translate(cx, cy);
570
 
571
  if (customShapeLoaded) {
 
 
 
572
  ctx.drawImage(shapeImg, -contentW/2, -contentH/2, contentW, contentH);
573
  } else {
 
574
  ctx.fillRect(-contentW/2, -contentH/2, contentW, contentH);
575
  }
576
  ctx.restore();
 
578
  }
579
  }
580
 
581
+ // --- EXPORT ---
582
+
583
+ // Download SVG
 
584
  document.getElementById('btn-dl-svg').addEventListener('click', async () => {
585
+ const btn = document.getElementById('btn-dl-svg');
586
+ btn.innerText = "Processing...";
587
+
588
  const form = new FormData();
 
589
  paramIds.forEach(id => form.append(id, document.getElementById(id).value));
590
  form.append('growth_type', document.getElementById('growth_type').value);
591
  form.append('invert', document.getElementById('invert').checked);
 
 
 
 
 
 
592
 
593
+ // Attach SVG Shape
594
+ if(shapeInput.files[0]) form.append('shape_svg', shapeInput.files[0]);
595
 
596
+ // Attach Image (if existing)
597
+ // Note: Logic expects 'raster_image' key for images
598
+ if(currentFile && currentMode === 'image') {
599
+ form.append('raster_image', currentFile);
600
+ }
601
+
602
+ try {
603
+ const resp = await fetch('/download_svg', { method: 'POST', body: form });
604
+ if (!resp.ok) throw new Error(await resp.text());
605
+
606
+ const blob = await resp.blob();
607
+ const url = window.URL.createObjectURL(blob);
608
  const a = document.createElement('a');
609
  a.href = url;
610
+ a.download = "pattern.svg";
611
+ document.body.appendChild(a);
612
  a.click();
613
+ a.remove();
614
+ } catch (e) {
615
+ alert("Error: " + e.message);
616
  }
617
+ btn.innerText = "Download SVG";
618
  });
619
 
620
+ // Download Video
621
  document.getElementById('btn-dl-video').addEventListener('click', async () => {
622
+ const btn = document.getElementById('btn-dl-video');
623
+ btn.innerText = "Rendering...";
624
+ btn.disabled = true;
625
+ document.getElementById('status-msg').innerText = "Processing on server. Please wait...";
626
+
627
  const form = new FormData();
628
  paramIds.forEach(id => form.append(id, document.getElementById(id).value));
629
  form.append('growth_type', document.getElementById('growth_type').value);
630
  form.append('invert', document.getElementById('invert').checked);
 
 
 
 
631
 
632
+ // Attach Video
633
+ // Note: Logic expects 'video_file' key
634
+ if(currentFile && currentMode === 'video') {
635
+ form.append('video_file', currentFile);
636
+ }
637
 
638
  try {
639
+ const resp = await fetch('/process_video', { method: 'POST', body: form });
640
+ if (!resp.ok) throw new Error(await resp.text());
641
+
642
+ const blob = await resp.blob();
643
+ const url = window.URL.createObjectURL(blob);
644
+ const a = document.createElement('a');
645
+ a.href = url;
646
+ a.download = "render.mp4";
647
+ document.body.appendChild(a);
648
+ a.click();
649
+ a.remove();
650
+ document.getElementById('status-msg').innerText = "";
651
+ } catch (e) {
652
+ alert("Error: " + e.message);
653
+ document.getElementById('status-msg').innerText = "Error occurred.";
 
 
654
  }
655
+ btn.innerText = "Export MP4";
656
  btn.disabled = false;
657
  });
658
 
659
  // Init
660
  requestRender();
 
661
 
662
+ </script>
663
  </body>
664
  </html>