by3axap commited on
Commit
a7cd329
·
verified ·
1 Parent(s): f9bfbc8

Update templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +257 -479
templates/index.html CHANGED
@@ -3,9 +3,10 @@
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
  :root {
10
  --bg-page: #ffffff;
11
  --text-main: #000000;
@@ -14,6 +15,7 @@
14
  --font-sans: 'Inter', sans-serif;
15
  --font-mono: 'JetBrains Mono', monospace;
16
  }
 
17
  body {
18
  margin: 0;
19
  display: flex;
@@ -23,6 +25,7 @@
23
  color: var(--text-main);
24
  overflow: hidden;
25
  }
 
26
  /* --- Sidebar --- */
27
  .sidebar {
28
  width: 340px;
@@ -36,7 +39,8 @@
36
  overflow-y: auto;
37
  flex-shrink: 0;
38
  }
39
- .section { margin-bottom: 40px; }
 
40
 
41
  .section-title {
42
  font-family: var(--font-mono);
@@ -47,7 +51,48 @@
47
  margin-bottom: 16px;
48
  display: block;
49
  }
50
- /* --- Controls --- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  .control-group { margin-bottom: 16px; width: 100%; }
52
 
53
  .label-row {
@@ -58,7 +103,7 @@
58
  margin-bottom: 4px;
59
  font-family: var(--font-mono);
60
  }
61
- /* Input Number Styling */
62
  .num-input {
63
  width: 50px;
64
  border: none;
@@ -68,10 +113,8 @@
68
  text-align: right;
69
  outline: none;
70
  color: var(--text-main);
71
- padding: 0;
72
  }
73
- .num-input:focus { color: blue; }
74
- /* Custom Range Slider Styling */
75
  input[type="range"] {
76
  width: 100%;
77
  -webkit-appearance: none;
@@ -79,25 +122,22 @@
79
  margin: 10px 0;
80
  cursor: pointer;
81
  }
82
-
83
  input[type="range"]::-webkit-slider-runnable-track {
84
  width: 100%;
85
  height: 1px;
86
  background: #000000;
87
- border: none;
88
  }
89
-
90
  input[type="range"]::-webkit-slider-thumb {
91
  -webkit-appearance: none;
92
  height: 14px;
93
  width: 4px;
94
  background: #000000;
95
- border-radius: 0;
96
- margin-top: -6.5px;
97
- border: none;
98
  }
99
- input[type="range"]:focus { outline: none; }
100
- /* --- File Buttons --- */
101
  input[type="file"] { display: none; }
102
  .btn-upload {
103
  display: flex;
@@ -107,40 +147,12 @@
107
  padding: 12px 0;
108
  border: 1px solid #000000;
109
  background: white;
110
- color: black;
111
  font-family: var(--font-mono);
112
  font-size: 11px;
113
  text-transform: uppercase;
114
  cursor: pointer;
115
- transition: all 0.2s;
116
- box-sizing: border-box;
117
- text-align: center;
118
- }
119
- .btn-upload:hover {
120
- background: #f5f5f5;
121
- }
122
- .btn-upload.filled {
123
- background: #000000;
124
- color: #ffffff;
125
- border-color: #000000;
126
- }
127
- .remove-link {
128
- display: block;
129
- text-align: center;
130
- font-family: var(--font-mono);
131
- font-size: 10px;
132
- color: #000000;
133
- text-decoration: underline;
134
- margin-top: 8px;
135
- cursor: pointer;
136
- border: none;
137
- background: none;
138
- width: 100%;
139
  }
140
- .remove-link:hover { color: red; }
141
- /* --- Action Buttons --- */
142
- .buttons-container { display: flex; gap: 12px; margin-top: auto; padding-top: 20px;}
143
-
144
  .btn-action {
145
  flex: 1;
146
  background: #000000;
@@ -151,76 +163,53 @@
151
  font-size: 11px;
152
  text-transform: uppercase;
153
  cursor: pointer;
154
- transition: opacity 0.2s;
155
  }
156
- .btn-action:hover { opacity: 0.8; }
157
  .btn-action.secondary {
158
  background: white;
159
  color: black;
160
  border: 1px solid black;
161
  }
162
- .btn-action:disabled {
163
- opacity: 0.3;
164
- cursor: not-allowed;
165
- background: #ccc;
166
- border-color: #ccc;
167
- color: #fff;
 
 
 
168
  }
169
- /* --- Preview Area --- */
 
170
  .preview-area {
171
  flex: 1;
172
- background-color: #ffffff;
173
  display: flex;
174
  align-items: center;
175
  justify-content: center;
176
- position: relative;
177
  padding: 40px;
178
  }
179
-
180
  canvas {
181
- box-shadow: 0 0 0 1px #f0f0f0;
182
  max-width: 100%;
183
  max-height: 100%;
184
- object-fit: contain;
185
- }
186
- /* Misc */
187
- select {
188
- width: 100%;
189
- padding: 5px 0;
190
- border: none;
191
- border-bottom: 1px solid #000000;
192
- font-family: var(--font-mono);
193
- font-size: 11px;
194
- outline: none;
195
- background: transparent;
196
- border-radius: 0;
197
- -webkit-appearance: none;
198
- }
199
-
200
- input[type="checkbox"] {
201
- accent-color: black;
202
- width: 13px;
203
- height: 13px;
204
  }
205
- /* Custom Color Picker Styling */
206
  input[type="color"] {
207
  -webkit-appearance: none;
208
- border: 1px solid #000000;
209
- width: 13px;
210
- height: 13px;
211
- padding: 0;
212
- background: none;
213
  cursor: pointer;
214
- border-radius: 0;
215
- }
216
- input[type="color"]::-webkit-color-swatch-wrapper {
217
  padding: 0;
218
  }
219
- input[type="color"]::-webkit-color-swatch {
220
- border: none;
221
- border-radius: 0;
222
- }
223
- /* Hidden sources */
224
  #source-video, #source-image { display: none; }
225
  </style>
226
  </head>
@@ -228,477 +217,266 @@
228
 
229
  <div class="sidebar">
230
  <div style="margin-bottom: 40px;">
231
- <h1 style="font-size: 14px; font-weight: 600; margin:0; letter-spacing: -0.02em;">CELL CLIPPER PRO</h1>
232
  </div>
233
 
234
  <div class="section">
235
- <span class="section-title">01 / Shape Pattern</span>
236
- <input type="file" id="shape_svg" accept=".svg">
237
- <label for="shape_svg" class="btn-upload" id="lbl-shape">Select SVG</label>
238
- <button id="rm-shape" class="remove-link" style="display:none;">remove</button>
 
239
  </div>
240
 
241
  <div class="section">
242
  <span class="section-title">02 / Content Source</span>
243
- <input type="file" id="content_file" accept=".png, .jpg, .jpeg, .mp4, .mov, .webm, .gif">
244
- <label for="content_file" class="btn-upload" id="lbl-content">Select Content</label>
245
- <button id="rm-content" class="remove-link" style="display:none;">remove</button>
246
  </div>
247
 
248
- <form id="params-form">
249
- <div class="section">
250
- <span class="section-title">03 / Parameters</span>
251
-
252
- <div class="control-group">
253
- <div class="label-row">
254
- <span>Density</span>
255
- <input type="number" class="num-input" id="num-density" value="40" min="5" max="150">
256
- </div>
257
- <input type="range" id="density" min="5" max="150" value="40">
258
- </div>
259
-
260
- <div class="control-group">
261
- <div class="label-row">
262
- <span>Gap X</span>
263
- <input type="number" class="num-input" id="num-gap_x" value="0" step="0.01" min="0" max="0.9">
264
- </div>
265
- <input type="range" id="gap_x" min="0" max="0.9" step="0.01" value="0">
266
- </div>
267
-
268
- <div class="control-group">
269
- <div class="label-row">
270
- <span>Gap Y</span>
271
- <input type="number" class="num-input" id="num-gap_y" value="0" step="0.01" min="0" max="0.9">
272
- </div>
273
- <input type="range" id="gap_y" min="0" max="0.9" step="0.01" value="0">
274
- </div>
275
-
276
- <div class="control-group">
277
- <div class="label-row">
278
- <span>Stagger</span>
279
- <input type="number" class="num-input" id="num-shift_stagger" value="0" step="0.01" min="0" max="1">
280
- </div>
281
- <input type="range" id="shift_stagger" min="0" max="1" step="0.01" value="0">
282
- </div>
283
-
284
- <div class="control-group" style="margin-top:24px;">
285
- <div class="label-row">
286
- <span>Scale</span>
287
- <input type="number" class="num-input" id="num-base_scale" value="1.0" step="0.05" min="0.1" max="2.0">
288
- </div>
289
- <input type="range" id="base_scale" min="0.1" max="2.0" step="0.05" value="1.0">
290
- </div>
291
 
292
- <div class="control-group">
293
- <div class="label-row">
294
- <span>Growth</span>
295
- <input type="number" class="num-input" id="num-growth" value="0" step="0.05" min="0" max="2.0">
296
- </div>
297
- <input type="range" id="growth" min="0" max="2.0" step="0.05" value="0">
298
- </div>
299
 
300
- <div class="control-group" style="display:flex; justify-content: space-between; align-items: center; margin-top:24px;">
301
- <div class="label-row" style="margin:0;">
302
- <label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
303
- <input type="checkbox" id="invert">
304
- <span>Invert Logic</span>
305
- </label>
306
- </div>
307
 
 
 
 
 
 
 
308
  <div style="display:flex; align-items:center; gap:8px;">
309
- <span style="font-size:11px; font-family:var(--font-mono); cursor: default;">COLOR</span>
310
- <input type="color" id="bg_color" value="#ffffff" title="Background Color">
311
- <input type="color" id="fill_color" value="#000000" title="Pattern Color">
312
  </div>
313
  </div>
314
-
315
- <div class="control-group" style="margin-top:16px;">
316
- <span style="font-size:10px; color:#aaa; font-family:var(--font-mono); display:block; margin-bottom:4px;">GROWTH TYPE</span>
317
- <select id="growth_type">
318
- <option value="sine">Diagonal Sine</option>
319
- <option value="linear">Linear Gradient</option>
320
- <option value="steps">Steps</option>
321
- </select>
322
- </div>
323
  </div>
324
- </form>
325
 
326
- <div class="buttons-container">
327
- <button class="btn-action secondary" id="btn-dl-svg">Download SVG</button>
328
- <button class="btn-action" id="btn-dl-video" disabled>Export MP4</button>
329
  </div>
330
- <div id="status-msg" style="font-size:10px; font-family:var(--font-mono); margin-top:10px; text-align:center;"></div>
331
  </div>
332
 
333
- <div class="preview-area" id="preview-wrapper">
334
  <canvas id="main-canvas" width="1400" height="900"></canvas>
335
-
336
- <img id="source-image" src="" crossorigin="anonymous">
337
- <video id="source-video" src="" playsinline autoplay loop muted crossorigin="anonymous"></video>
338
- <canvas id="helper-canvas" style="display:none;"></canvas>
339
- <img id="shape-img" src="" style="display:none;">
340
  </div>
341
 
 
 
 
 
342
  <script>
343
  // --- STATE ---
344
- let currentMode = 'image'; // 'image' | 'video'
345
- let isGifMode = false; // Флаг для GIF файлов
346
- let currentFile = null;
347
  let animationId = null;
348
- let customShapeLoaded = false;
349
-
350
- // Cached colored shape canvas to avoid recoloring every frame
351
- let cachedShapeCanvas = null;
352
- let lastFillColor = "#000000";
353
-
354
- // --- DOM ---
355
  const canvas = document.getElementById('main-canvas');
356
  const ctx = canvas.getContext('2d', { alpha: false });
357
- const helperCanvas = document.getElementById('helper-canvas');
358
- const helperCtx = helperCanvas.getContext('2d');
359
-
360
- const sourceImage = document.getElementById('source-image');
361
- const sourceVideo = document.getElementById('source-video');
362
- const shapeImg = document.getElementById('shape-img');
363
- const previewWrapper = document.getElementById('preview-wrapper');
364
- const btnVideoExport = document.getElementById('btn-dl-video');
365
-
366
- // --- UI HELPERS ---
367
- const setBtnState = (labelId, rmId, file, isActive) => {
368
- const lbl = document.getElementById(labelId);
369
- const rm = document.getElementById(rmId);
370
- if(isActive && file) {
371
- lbl.classList.add('filled');
372
- lbl.innerText = file.name;
373
- rm.style.display = 'block';
374
- } else {
375
- lbl.classList.remove('filled');
376
- lbl.innerText = labelId === 'lbl-shape' ? 'Select SVG' : 'Select Content';
377
- rm.style.display = 'none';
378
  }
379
- };
 
 
380
 
381
- // --- HELPER: TINT SHAPE ---
382
- function updateCachedShape(color) {
383
- if (!customShapeLoaded || !shapeImg.src) {
384
- cachedShapeCanvas = null;
385
- return;
386
- }
387
- const c = document.createElement('canvas');
388
- c.width = shapeImg.width || 100;
389
- c.height = shapeImg.height || 100;
390
- const x = c.getContext('2d');
391
- x.drawImage(shapeImg, 0, 0, c.width, c.height);
392
- x.globalCompositeOperation = 'source-in';
393
- x.fillStyle = color;
394
- x.fillRect(0, 0, c.width, c.height);
395
-
396
- cachedShapeCanvas = c;
397
- lastFillColor = color;
398
  }
399
 
400
- // --- EVENT LISTENERS ---
401
- // 1. Shape Upload
402
- const shapeInput = document.getElementById('shape_svg');
403
- shapeInput.addEventListener('change', (e) => {
404
- const file = e.target.files[0];
405
- if(!file) return;
406
- setBtnState('lbl-shape', 'rm-shape', file, true);
407
- const reader = new FileReader();
408
- reader.onload = (evt) => {
409
- const blob = new Blob([evt.target.result], {type: 'image/svg+xml'});
410
- shapeImg.src = URL.createObjectURL(blob);
411
- shapeImg.onload = () => {
412
- customShapeLoaded = true;
413
- updateCachedShape(document.getElementById('fill_color').value);
414
- requestRender();
415
- };
416
- };
417
- reader.readAsText(file);
418
- });
419
 
420
- document.getElementById('rm-shape').addEventListener('click', () => {
421
- shapeInput.value = '';
422
- customShapeLoaded = false;
423
- shapeImg.src = '';
424
- cachedShapeCanvas = null;
425
- setBtnState('lbl-shape', 'rm-shape', null, false);
426
  requestRender();
427
- });
428
 
429
- // 2. Content Upload (Unified with GIF and Video support)
430
- const contentInput = document.getElementById('content_file');
431
- contentInput.addEventListener('change', (e) => {
 
 
 
 
 
 
 
 
 
 
432
  const file = e.target.files[0];
433
  if(!file) return;
434
-
435
- currentFile = file;
436
- setBtnState('lbl-content', 'rm-content', file, true);
437
  const url = URL.createObjectURL(file);
438
- const type = file.type;
439
 
440
- // Check if it's a GIF
441
- isGifMode = file.name.toLowerCase().endsWith('.gif');
442
-
443
- sourceImage.src = "";
444
- sourceVideo.src = "";
445
- sourceVideo.pause();
446
-
447
- // If it's a static image (not GIF)
448
- if (type.startsWith('image/') && !isGifMode) {
449
- currentMode = 'image';
450
- sourceImage.src = url;
451
- sourceImage.onload = () => {
452
- cancelAnimationFrame(animationId);
453
- animationId = null;
454
- requestRender();
455
- };
456
-
457
- btnVideoExport.disabled = true;
458
- btnVideoExport.innerText = "Export MP4";
459
- document.getElementById('btn-dl-svg').disabled = false;
460
- }
461
- // If it's a video OR a GIF (Animated Content)
462
- else if (type.startsWith('video/') || isGifMode) {
463
  currentMode = 'video';
464
- if (isGifMode) {
465
- // Загружаем GIF как картинку, но рендерим в цикле
466
- sourceImage.src = url;
467
- sourceImage.onload = () => requestRender();
468
- } else {
469
- sourceVideo.src = url;
470
- sourceVideo.play();
471
- // Когда видео готово проигрываться, запускаем рендер
472
- sourceVideo.onplaying = () => requestRender();
473
- }
474
-
475
- btnVideoExport.disabled = false;
476
- btnVideoExport.innerText = isGifMode ? "Export GIF" : "Export MP4";
477
- document.getElementById('btn-dl-svg').disabled = true;
478
  }
 
479
  });
480
 
481
- document.getElementById('rm-content').addEventListener('click', () => {
482
- contentInput.value = '';
483
- currentFile = null;
484
- sourceImage.src = "";
485
- sourceVideo.src = "";
486
- sourceVideo.pause();
487
- isGifMode = false;
488
- setBtnState('lbl-content', 'rm-content', null, false);
489
-
490
- cancelAnimationFrame(animationId);
491
- animationId = null;
492
- currentMode = 'image';
493
- btnVideoExport.disabled = true;
494
- btnVideoExport.innerText = "Export MP4";
495
- document.getElementById('btn-dl-svg').disabled = false;
496
- requestRender();
497
- });
498
-
499
- // 3. Inputs Sync
500
- const paramIds = ['density', 'gap_x', 'gap_y', 'shift_stagger', 'base_scale', 'growth'];
501
- paramIds.forEach(id => {
502
- const slider = document.getElementById(id);
503
- const number = document.getElementById('num-' + id);
504
- slider.addEventListener('input', () => { number.value = slider.value; requestRender(); });
505
- number.addEventListener('input', () => { slider.value = number.value; requestRender(); });
506
- });
507
- document.getElementById('invert').addEventListener('change', requestRender);
508
- document.getElementById('growth_type').addEventListener('change', requestRender);
509
-
510
- // 4. Color Inputs
511
- const bgColorInput = document.getElementById('bg_color');
512
- const fillColorInput = document.getElementById('fill_color');
513
- bgColorInput.addEventListener('input', () => {
514
- previewWrapper.style.backgroundColor = bgColorInput.value;
515
- requestRender();
516
- });
517
- fillColorInput.addEventListener('input', () => {
518
- const color = fillColorInput.value;
519
- if(customShapeLoaded) updateCachedShape(color);
520
- requestRender();
521
- });
522
-
523
- // --- RENDER LOGIC ---
524
  function requestRender() {
525
- // Запускаем цикл для анимированного контента (Видео или GIF)
526
- if (currentMode === 'video') {
527
- // Если видео на паузе, рисуем один кадр. Если играет (или это GIF) - запускаем цикл
528
- if (!isGifMode && sourceVideo.paused) {
529
- drawFrame();
530
- } else {
531
- if (!animationId) renderLoop();
532
- }
533
  } else {
534
- // Для обычных картинок просто рисуем один раз
535
  drawFrame();
536
  }
537
  }
538
 
539
  function renderLoop() {
540
  drawFrame();
541
-
542
- // Продолжаем цикл только если режим video/gif и (если видео, то оно должно играть)
543
- if (currentMode === 'video') {
544
- if (!isGifMode && sourceVideo.paused) {
545
- animationId = null;
546
- } else {
547
- animationId = requestAnimationFrame(renderLoop);
548
- }
549
- } else {
550
- animationId = null;
551
- }
552
  }
553
 
554
  function drawFrame() {
555
- const w = canvas.width;
556
- const h = canvas.height;
557
-
558
  const density = parseInt(document.getElementById('density').value);
559
- const gapX = parseFloat(document.getElementById('gap_x').value);
560
- const gapY = parseFloat(document.getElementById('gap_y').value);
561
- const stagger = parseFloat(document.getElementById('shift_stagger').value);
562
- const baseScale = parseFloat(document.getElementById('base_scale').value);
563
- const growth = parseFloat(document.getElementById('growth').value);
564
- const growthType = document.getElementById('growth_type').value;
565
  const invert = document.getElementById('invert').checked;
566
- const bgColor = bgColorInput.value;
567
- const fillColor = fillColorInput.value;
568
 
569
  ctx.fillStyle = bgColor;
570
  ctx.fillRect(0, 0, w, h);
571
-
572
- let sourceData = null;
573
- let sw = 0;
574
-
575
- // Для видео используем sourceVideo, для GIF и картинок - sourceImage
576
- let activeSource = (currentMode === 'video' && !isGifMode) ? sourceVideo : sourceImage;
577
- let ready = false;
578
-
579
- if(currentMode === 'image' && sourceImage.src) ready = true;
580
- if(currentMode === 'video') {
581
- if (isGifMode && sourceImage.complete) ready = true;
582
- else if (!isGifMode && sourceVideo.readyState >= 2) ready = true;
583
- }
584
 
585
- if (ready) {
586
- helperCanvas.width = w;
587
- helperCanvas.height = h;
588
- helperCtx.drawImage(activeSource, 0, 0, w, h);
589
- const imgData = helperCtx.getImageData(0, 0, w, h);
590
- sourceData = imgData.data;
591
- sw = w;
592
- }
593
 
594
- ctx.fillStyle = fillColor;
595
-
596
  const step = w / density;
597
  const rows = Math.ceil(h / step);
598
- const cellWMax = step * (1.0 - gapX);
599
- const cellHMax = step * (1.0 - gapY);
600
- const startC = -Math.floor(density * 0.5);
601
- const endC = density * 1.5;
602
 
603
  for (let r = 0; r < rows; r++) {
604
- const rowShift = (r * stagger * step);
605
- for (let c = startC; c < endC; c++) {
606
- const gridX = c * step + rowShift;
607
- const gridY = r * step;
608
- if (gridX < -step || gridX > w) continue;
609
 
610
- let isVisible = true;
611
- if (sourceData) {
612
- const sx = Math.max(0, Math.min(Math.floor(gridX + step/2), w - 1));
613
- const sy = Math.max(0, Math.min(Math.floor(gridY + step/2), h - 1));
614
- const idx = (sy * sw + sx) * 4;
615
- const bright = (sourceData[idx] + sourceData[idx+1] + sourceData[idx+2]) / 3 / 255.0;
616
- isVisible = invert ? (bright > 0.5) : (bright < 0.5);
617
- }
618
- if (!isVisible) continue;
619
 
620
- let wave = 0;
621
- if (growthType === 'sine') wave = (Math.sin(c * 0.3 + r * 0.3) + 1) / 2;
622
- else if (growthType === 'linear') wave = ((c % density) / density);
623
- else if (growthType === 'steps') wave = (r % 5) / 5.0;
624
-
625
- let modW = Math.max(0.01, Math.min(1.0, 1.0 - (wave * growth)));
626
- const contentW = cellWMax * baseScale * modW;
627
- const contentH = cellHMax * baseScale;
628
 
629
  ctx.save();
630
- ctx.translate(gridX + step/2, gridY + step/2);
631
- if (customShapeLoaded && cachedShapeCanvas) {
632
- ctx.drawImage(cachedShapeCanvas, -contentW/2, -contentH/2, contentW, contentH);
 
633
  } else {
634
- ctx.fillRect(-contentW/2, -contentH/2, contentW, contentH);
 
635
  }
636
  ctx.restore();
637
  }
638
  }
639
  }
640
 
641
- // --- EXPORT ---
642
- document.getElementById('btn-dl-svg').addEventListener('click', async () => {
643
- const btn = document.getElementById('btn-dl-svg');
644
- btn.innerText = "Processing...";
645
- const form = new FormData();
646
- paramIds.forEach(id => form.append(id, document.getElementById(id).value));
647
- form.append('growth_type', document.getElementById('growth_type').value);
648
- form.append('invert', document.getElementById('invert').checked);
649
- form.append('bg_color', document.getElementById('bg_color').value);
650
- form.append('fill_color', document.getElementById('fill_color').value);
651
- if(shapeInput.files[0]) form.append('shape_svg', shapeInput.files[0]);
652
- if(currentFile && currentMode === 'image') form.append('raster_image', currentFile);
653
-
654
- try {
655
- const resp = await fetch('/download_svg', { method: 'POST', body: form });
656
- if (!resp.ok) throw new Error(await resp.text());
657
- const blob = await resp.blob();
658
- const a = document.createElement('a');
659
- a.href = window.URL.createObjectURL(blob);
660
- a.download = "pattern.svg";
661
- document.body.appendChild(a);
662
- a.click();
663
- a.remove();
664
- } catch (e) {
665
- alert("Error: " + e.message);
666
- }
667
- btn.innerText = "Download SVG";
668
  });
 
 
669
 
670
- btnVideoExport.addEventListener('click', async () => {
671
- btnVideoExport.innerText = "Rendering...";
672
- btnVideoExport.disabled = true;
673
- document.getElementById('status-msg').innerText = "Processing on server. Please wait...";
674
  const form = new FormData();
675
- paramIds.forEach(id => form.append(id, document.getElementById(id).value));
676
- form.append('growth_type', document.getElementById('growth_type').value);
 
 
 
 
 
677
  form.append('invert', document.getElementById('invert').checked);
678
  form.append('bg_color', document.getElementById('bg_color').value);
679
- form.append('fill_color', document.getElementById('fill_color').value);
680
- if(currentFile && currentMode === 'video') form.append('video_file', currentFile);
681
-
682
- try {
683
- const resp = await fetch('/process_video', { method: 'POST', body: form });
684
- if (!resp.ok) throw new Error(await resp.text());
685
- const blob = await resp.blob();
686
- const a = document.createElement('a');
687
- a.href = window.URL.createObjectURL(blob);
688
- a.download = isGifMode ? "render.gif" : "render.mp4";
689
- document.body.appendChild(a);
690
- a.click();
691
- a.remove();
692
- document.getElementById('status-msg').innerText = "";
693
- } catch (e) {
694
- alert("Error: " + e.message);
695
- document.getElementById('status-msg').innerText = "Error occurred.";
696
- }
697
- btnVideoExport.innerText = isGifMode ? "Export GIF" : "Export MP4";
698
- btnVideoExport.disabled = false;
699
  });
700
 
701
- requestRender();
702
  </script>
703
  </body>
704
  </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 / MULTI-PATTERN</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;
 
15
  --font-sans: 'Inter', sans-serif;
16
  --font-mono: 'JetBrains Mono', monospace;
17
  }
18
+
19
  body {
20
  margin: 0;
21
  display: flex;
 
25
  color: var(--text-main);
26
  overflow: hidden;
27
  }
28
+
29
  /* --- Sidebar --- */
30
  .sidebar {
31
  width: 340px;
 
39
  overflow-y: auto;
40
  flex-shrink: 0;
41
  }
42
+
43
+ .section { margin-bottom: 32px; }
44
 
45
  .section-title {
46
  font-family: var(--font-mono);
 
51
  margin-bottom: 16px;
52
  display: block;
53
  }
54
+
55
+ /* --- Pattern List (New) --- */
56
+ .patterns-list {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 8px;
60
+ margin-top: 12px;
61
+ }
62
+
63
+ .pattern-item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 10px;
67
+ padding: 8px;
68
+ border: 1px solid var(--border);
69
+ background: #fafafa;
70
+ border-radius: 2px;
71
+ }
72
+
73
+ .pattern-preview {
74
+ width: 24px;
75
+ height: 24px;
76
+ background: white;
77
+ border: 1px solid #eee;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ overflow: hidden;
82
+ }
83
+
84
+ .pattern-preview img { max-width: 80%; max-height: 80%; }
85
+
86
+ .pattern-name {
87
+ flex: 1;
88
+ font-family: var(--font-mono);
89
+ font-size: 9px;
90
+ white-space: nowrap;
91
+ overflow: hidden;
92
+ text-overflow: ellipsis;
93
+ }
94
+
95
+ /* --- Controls & UI --- */
96
  .control-group { margin-bottom: 16px; width: 100%; }
97
 
98
  .label-row {
 
103
  margin-bottom: 4px;
104
  font-family: var(--font-mono);
105
  }
106
+
107
  .num-input {
108
  width: 50px;
109
  border: none;
 
113
  text-align: right;
114
  outline: none;
115
  color: var(--text-main);
 
116
  }
117
+
 
118
  input[type="range"] {
119
  width: 100%;
120
  -webkit-appearance: none;
 
122
  margin: 10px 0;
123
  cursor: pointer;
124
  }
125
+
126
  input[type="range"]::-webkit-slider-runnable-track {
127
  width: 100%;
128
  height: 1px;
129
  background: #000000;
 
130
  }
131
+
132
  input[type="range"]::-webkit-slider-thumb {
133
  -webkit-appearance: none;
134
  height: 14px;
135
  width: 4px;
136
  background: #000000;
137
+ margin-top: -6.5px;
 
 
138
  }
139
+
140
+ /* --- Buttons --- */
141
  input[type="file"] { display: none; }
142
  .btn-upload {
143
  display: flex;
 
147
  padding: 12px 0;
148
  border: 1px solid #000000;
149
  background: white;
 
150
  font-family: var(--font-mono);
151
  font-size: 11px;
152
  text-transform: uppercase;
153
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  }
155
+
 
 
 
156
  .btn-action {
157
  flex: 1;
158
  background: #000000;
 
163
  font-size: 11px;
164
  text-transform: uppercase;
165
  cursor: pointer;
 
166
  }
167
+
168
  .btn-action.secondary {
169
  background: white;
170
  color: black;
171
  border: 1px solid black;
172
  }
173
+
174
+ .remove-btn {
175
+ color: #ff4444;
176
+ background: none;
177
+ border: none;
178
+ font-family: var(--font-mono);
179
+ font-size: 9px;
180
+ cursor: pointer;
181
+ text-decoration: underline;
182
  }
183
+
184
+ /* --- Preview --- */
185
  .preview-area {
186
  flex: 1;
187
+ background-color: #f7f7f7;
188
  display: flex;
189
  align-items: center;
190
  justify-content: center;
 
191
  padding: 40px;
192
  }
193
+
194
  canvas {
195
+ box-shadow: 0 20px 60px rgba(0,0,0,0.05);
196
  max-width: 100%;
197
  max-height: 100%;
198
+ background: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
+
201
  input[type="color"] {
202
  -webkit-appearance: none;
203
+ border: 1px solid #000;
204
+ width: 16px;
205
+ height: 16px;
 
 
206
  cursor: pointer;
207
+ background: none;
 
 
208
  padding: 0;
209
  }
210
+ input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
211
+ input[type="color"]::-webkit-color-swatch { border: none; }
212
+
 
 
213
  #source-video, #source-image { display: none; }
214
  </style>
215
  </head>
 
217
 
218
  <div class="sidebar">
219
  <div style="margin-bottom: 40px;">
220
+ <h1 style="font-size: 14px; font-weight: 600; margin:0; letter-spacing: -0.02em;">CELL CLIPPER PRO <span style="font-weight:400; color:var(--text-muted)">MULTI</span></h1>
221
  </div>
222
 
223
  <div class="section">
224
+ <span class="section-title">01 / SVG Patterns (Multiple)</span>
225
+ <input type="file" id="shape_svg" accept=".svg" multiple>
226
+ <label for="shape_svg" class="btn-upload" id="lbl-shape">Add SVG Files</label>
227
+ <div id="patterns-list" class="patterns-list">
228
+ </div>
229
  </div>
230
 
231
  <div class="section">
232
  <span class="section-title">02 / Content Source</span>
233
+ <input type="file" id="content_file" accept="image/*,video/*,.gif">
234
+ <label for="content_file" class="btn-upload" id="lbl-content">Select Media</label>
 
235
  </div>
236
 
237
+ <div class="section">
238
+ <span class="section-title">03 / Grid Parameters</span>
239
+
240
+ <div class="control-group">
241
+ <div class="label-row"><span>Density</span><input type="number" class="num-input" id="num-density" value="60"></div>
242
+ <input type="range" id="density" min="10" max="200" value="60">
243
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ <div class="control-group">
246
+ <div class="label-row"><span>Gap X</span><input type="number" class="num-input" id="num-gap_x" value="0.1" step="0.01"></div>
247
+ <input type="range" id="gap_x" min="0" max="0.9" step="0.01" value="0.1">
248
+ </div>
 
 
 
249
 
250
+ <div class="control-group">
251
+ <div class="label-row"><span>Scale</span><input type="number" class="num-input" id="num-base_scale" value="1.0" step="0.05"></div>
252
+ <input type="range" id="base_scale" min="0.1" max="2.5" step="0.05" value="1.0">
253
+ </div>
 
 
 
254
 
255
+ <div class="control-group" style="margin-top:24px;">
256
+ <div class="label-row" style="margin:0;">
257
+ <label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
258
+ <input type="checkbox" id="invert">
259
+ <span>Invert Logic</span>
260
+ </label>
261
  <div style="display:flex; align-items:center; gap:8px;">
262
+ <span style="font-size:10px; font-family:var(--font-mono);">BG</span>
263
+ <input type="color" id="bg_color" value="#ffffff">
 
264
  </div>
265
  </div>
 
 
 
 
 
 
 
 
 
266
  </div>
267
+ </div>
268
 
269
+ <div style="display:flex; gap:10px; margin-top:auto;">
270
+ <button class="btn-action secondary" id="btn-dl-svg">SVG</button>
271
+ <button class="btn-action" id="btn-dl-video" id="export-btn">Export</button>
272
  </div>
273
+ <div id="status-msg" style="font-size:9px; font-family:var(--font-mono); margin-top:10px; text-align:center; color:var(--text-muted)"></div>
274
  </div>
275
 
276
+ <div class="preview-area">
277
  <canvas id="main-canvas" width="1400" height="900"></canvas>
 
 
 
 
 
278
  </div>
279
 
280
+ <video id="source-video" playsinline autoplay loop muted crossorigin="anonymous"></video>
281
+ <img id="source-image" crossorigin="anonymous">
282
+ <canvas id="helper-canvas" style="display:none;"></canvas>
283
+
284
  <script>
285
  // --- STATE ---
286
+ let patterns = []; // [{id, file, color, img, canvas}]
287
+ let currentMode = 'image';
288
+ let isGif = false;
289
  let animationId = null;
290
+
 
 
 
 
 
 
291
  const canvas = document.getElementById('main-canvas');
292
  const ctx = canvas.getContext('2d', { alpha: false });
293
+ const hCanvas = document.getElementById('helper-canvas');
294
+ const hCtx = hCanvas.getContext('2d');
295
+ const video = document.getElementById('source-video');
296
+ const image = document.getElementById('source-image');
297
+
298
+ // --- PATTERN MANAGEMENT ---
299
+ document.getElementById('shape_svg').addEventListener('change', async (e) => {
300
+ for (let file of e.target.files) {
301
+ const url = URL.createObjectURL(file);
302
+ const img = new Image();
303
+ img.src = url;
304
+ await img.decode();
305
+
306
+ const p = {
307
+ id: Math.random().toString(36).substr(2, 9),
308
+ file: file,
309
+ color: '#000000',
310
+ img: img,
311
+ canvas: null
312
+ };
313
+ patterns.push(p);
314
  }
315
+ renderPatternList();
316
+ requestRender();
317
+ });
318
 
319
+ function renderPatternList() {
320
+ const container = document.getElementById('patterns-list');
321
+ container.innerHTML = '';
322
+ patterns.forEach((p, idx) => {
323
+ const item = document.createElement('div');
324
+ item.className = 'pattern-item';
325
+ item.innerHTML = `
326
+ <div class="pattern-preview"><img src="${p.img.src}"></div>
327
+ <div class="pattern-name">P${idx+1}: ${p.file.name}</div>
328
+ <input type="color" value="${p.color}" oninput="updatePColor('${p.id}', this.value)">
329
+ <button class="remove-btn" onclick="removeP('${p.id}')">✕</button>
330
+ `;
331
+ container.appendChild(item);
332
+ cachePatternCanvas(p);
333
+ });
 
 
334
  }
335
 
336
+ function updatePColor(id, color) {
337
+ const p = patterns.find(x => x.id === id);
338
+ if(p) { p.color = color; cachePatternCanvas(p); requestRender(); }
339
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
+ function removeP(id) {
342
+ patterns = patterns.filter(x => x.id !== id);
343
+ renderPatternList();
 
 
 
344
  requestRender();
345
+ }
346
 
347
+ function cachePatternCanvas(p) {
348
+ const c = document.createElement('canvas');
349
+ c.width = 100; c.height = 100;
350
+ const x = c.getContext('2d');
351
+ x.drawImage(p.img, 0, 0, 100, 100);
352
+ x.globalCompositeOperation = 'source-in';
353
+ x.fillStyle = p.color;
354
+ x.fillRect(0, 0, 100, 100);
355
+ p.canvas = c;
356
+ }
357
+
358
+ // --- CONTENT LOADING ---
359
+ document.getElementById('content_file').addEventListener('change', (e) => {
360
  const file = e.target.files[0];
361
  if(!file) return;
 
 
 
362
  const url = URL.createObjectURL(file);
363
+ isGif = file.name.toLowerCase().endsWith('.gif');
364
 
365
+ if(file.type.startsWith('video/')) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  currentMode = 'video';
367
+ video.src = url;
368
+ video.play();
369
+ } else {
370
+ currentMode = 'image';
371
+ image.src = url;
372
+ image.onload = () => requestRender();
 
 
 
 
 
 
 
 
373
  }
374
+ document.getElementById('lbl-content').innerText = file.name;
375
  });
376
 
377
+ // --- RENDER ENGINE ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  function requestRender() {
379
+ if(animationId) cancelAnimationFrame(animationId);
380
+ if(currentMode === 'video' || isGif) {
381
+ renderLoop();
 
 
 
 
 
382
  } else {
 
383
  drawFrame();
384
  }
385
  }
386
 
387
  function renderLoop() {
388
  drawFrame();
389
+ animationId = requestAnimationFrame(renderLoop);
 
 
 
 
 
 
 
 
 
 
390
  }
391
 
392
  function drawFrame() {
393
+ const w = canvas.width, h = canvas.height;
 
 
394
  const density = parseInt(document.getElementById('density').value);
395
+ const gap = parseFloat(document.getElementById('gap_x').value);
396
+ const scale = parseFloat(document.getElementById('base_scale').value);
 
 
 
 
397
  const invert = document.getElementById('invert').checked;
398
+ const bgColor = document.getElementById('bg_color').value;
 
399
 
400
  ctx.fillStyle = bgColor;
401
  ctx.fillRect(0, 0, w, h);
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
+ let source = (currentMode === 'video' && !isGif) ? video : image;
404
+ if(currentMode === 'image' && !image.src) return;
405
+ if(currentMode === 'video' && video.readyState < 2 && !isGif) return;
406
+
407
+ hCanvas.width = w; hCanvas.height = h;
408
+ hCtx.drawImage(source, 0, 0, w, h);
409
+ const pix = hCtx.getImageData(0, 0, w, h).data;
 
410
 
 
 
411
  const step = w / density;
412
  const rows = Math.ceil(h / step);
413
+ const nP = patterns.length;
 
 
 
414
 
415
  for (let r = 0; r < rows; r++) {
416
+ for (let c = 0; c < density; c++) {
417
+ const x = c * step, y = r * step;
418
+ const px = Math.floor(x + step/2), py = Math.floor(y + step/2);
419
+ const i = (py * w + px) * 4;
 
420
 
421
+ let bright = (pix[i] + pix[i+1] + pix[i+2]) / 3 / 255;
422
+ if(invert) bright = 1.0 - bright;
 
 
 
 
 
 
 
423
 
424
+ if(bright > 0.97) continue;
425
+
426
+ // ВЫБОР ПАТТЕРНА: распределяем индекс по яркости
427
+ // 0 (черный) -> паттерн 0, 1 (белый) -> паттерн N
428
+ let pIdx = Math.floor(bright * nP);
429
+ pIdx = Math.max(0, Math.min(nP - 1, pIdx));
430
+
431
+ const cellSize = step * (1 - gap) * scale;
432
 
433
  ctx.save();
434
+ ctx.translate(x + step/2, y + step/2);
435
+
436
+ if(nP > 0 && patterns[pIdx].canvas) {
437
+ ctx.drawImage(patterns[pIdx].canvas, -cellSize/2, -cellSize/2, cellSize, cellSize);
438
  } else {
439
+ ctx.fillStyle = "#000";
440
+ ctx.fillRect(-cellSize/2, -cellSize/2, cellSize, cellSize);
441
  }
442
  ctx.restore();
443
  }
444
  }
445
  }
446
 
447
+ // --- SYNC INPUTS ---
448
+ ['density', 'gap_x', 'base_scale'].forEach(id => {
449
+ const s = document.getElementById(id), n = document.getElementById('num-'+id);
450
+ s.oninput = () => { n.value = s.value; requestRender(); };
451
+ n.oninput = () => { s.value = n.value; requestRender(); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  });
453
+ document.getElementById('invert').onchange = requestRender;
454
+ document.getElementById('bg_color').oninput = requestRender;
455
 
456
+ // --- EXPORT ---
457
+ document.getElementById('btn-dl-svg').addEventListener('click', async () => {
 
 
458
  const form = new FormData();
459
+ patterns.forEach(p => {
460
+ form.append('shape_svg[]', p.file);
461
+ form.append('fill_colors[]', p.color);
462
+ });
463
+ form.append('density', document.getElementById('density').value);
464
+ form.append('gap_x', document.getElementById('gap_x').value);
465
+ form.append('base_scale', document.getElementById('base_scale').value);
466
  form.append('invert', document.getElementById('invert').checked);
467
  form.append('bg_color', document.getElementById('bg_color').value);
468
+
469
+ const media = document.getElementById('content_file').files[0];
470
+ if(media && currentMode === 'image') form.append('raster_image', media);
471
+
472
+ const res = await fetch('/download_svg', { method: 'POST', body: form });
473
+ const blob = await res.blob();
474
+ const a = document.createElement('a');
475
+ a.href = URL.createObjectURL(blob);
476
+ a.download = 'cell_clipper_multi.svg';
477
+ a.click();
 
 
 
 
 
 
 
 
 
 
478
  });
479
 
 
480
  </script>
481
  </body>
482
  </html>