aeolian83 commited on
Commit
be098c2
·
1 Parent(s): 0c0232f

preview & high light

Browse files
Files changed (1) hide show
  1. app.py +257 -49
app.py CHANGED
@@ -8,6 +8,7 @@ from utils import svg_to_png_base64
8
  import pathlib
9
  from logger import setup_logger
10
  import html
 
11
 
12
  load_dotenv(override=True)
13
  logger = setup_logger()
@@ -79,13 +80,8 @@ class SVGAnimationGenerator:
79
  },
80
  {
81
  "role": "assistant",
82
- "content": [
83
- {
84
- "type": "text",
85
- "text": "<animation_plan>"
86
- }
87
- ]
88
- }
89
  ],
90
  )
91
  response_text = response.content[0].text
@@ -160,13 +156,15 @@ class SVGAnimationGenerator:
160
  if decomposed_svg_match and animation_suggestions_match:
161
  decomposed_svg_text = decomposed_svg_match.group(1).strip()
162
  animation_suggestions = animation_suggestions_match.group(1).strip()
163
-
164
  # Create viewer HTML with XSS protection
165
  viewer_html = create_svg_viewer_html(decomposed_svg_text)
166
-
167
  return decomposed_svg_text, animation_suggestions, viewer_html
168
  else:
169
- error_message = "Decomposed SVG and Animation Suggestion not found in response."
 
 
170
  error_html = create_error_html(error_message)
171
  return "", "", error_html
172
  except Exception as e:
@@ -189,7 +187,7 @@ class SVGAnimationGenerator:
189
  )
190
  response_text = response.content[0].text
191
  logger.info(f"Model Response:\n{response_text}")
192
-
193
  # Extract HTML content from Claude's response
194
  html_match = re.search(
195
  r"<html_output>(.*?)</html_output>", response_text, re.DOTALL
@@ -208,6 +206,35 @@ class SVGAnimationGenerator:
208
  return f"<html><body><h3>{error_msg}</h3></body></html>", ""
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  generator = SVGAnimationGenerator()
212
 
213
 
@@ -222,28 +249,207 @@ def create_error_html(message: str) -> str:
222
 
223
 
224
  def create_svg_viewer_html(svg_content: str) -> str:
225
- """Create SVG viewer HTML."""
226
- # Basic SVG validation - ensure it starts with <svg and ends with </svg>
227
- svg_content = svg_content.strip()
228
- if not svg_content.startswith('<svg') or not svg_content.endswith('</svg>'):
229
  return create_error_html("Invalid SVG format")
230
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  return f"""
232
- <div style='padding: 20px; background: #fff; border: 1px solid #eee; border-radius: 8px; display: block;'>
233
- <div style='display: block; align-items: center; margin-bottom: 10px;'>
234
- </div>
235
- <div id='animation-container' style='min-height: 300px; display: flex; justify-content: center; align-items: center; background: #fafafa; border-radius: 4px; padding: 20px;'>
236
- {svg_content}
237
- </div>
 
 
238
  </div>
239
  """
240
 
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def process_svg(svg_file):
243
  if svg_file is None:
244
  return "Please upload an SVG file"
245
  try:
246
- with open(svg_file, "r", encoding="utf-8") as f:
 
 
 
247
  svg_content = f.read()
248
  parsed_svg = generator.parse_svg(svg_content)
249
  return parsed_svg.get("svg_content", "")
@@ -290,10 +496,10 @@ def predict_decompose_group(svg_file, svg_text, object_name):
290
  decomposed_svg_viewer = create_svg_viewer_html(decomposed_svg)
291
 
292
  return (
293
- decomposed_svg, # For svg_content_hidden
294
- decomposed_svg, # For groups_summary (분석 결과 표시)
295
- animation_suggestions, # For animation_suggestion
296
- decomposed_svg_viewer, # For decomposed_svg_viewer
297
  )
298
 
299
 
@@ -301,10 +507,10 @@ def update_preview_from_html(html_content: str) -> str:
301
  """Update animation preview from manually edited HTML content."""
302
  if not html_content.strip():
303
  return create_error_html("⚠️ HTML content is empty")
304
-
305
  try:
306
  # Create iframe HTML - only escape quotes for srcdoc attribute
307
- safe_html_content = html_content.replace('"', '&quot;')
308
  preview_html = f"""
309
  <div style='padding: 20px; background: #fff; border: 1px solid #eee; border-radius: 8px; display: block;'>
310
  <div style='display: block; align-items: center; margin-bottom: 10px;'>
@@ -320,7 +526,7 @@ def update_preview_from_html(html_content: str) -> str:
320
  </div>
321
  """
322
  return preview_html
323
-
324
  except Exception as e:
325
  return create_error_html(f"❌ Error updating preview: {str(e)}")
326
 
@@ -336,8 +542,10 @@ def create_animation_preview(animation_desc: str, svg_content: str) -> tuple:
336
  return error_html, ""
337
 
338
  try:
339
- animation_response, html_content = generator.generate_animation(animation_desc, svg_content)
340
-
 
 
341
  if not html_content:
342
  error_html = create_error_html("❌ Failed to generate animation HTML")
343
  return error_html, animation_response
@@ -351,7 +559,7 @@ def create_animation_preview(animation_desc: str, svg_content: str) -> tuple:
351
  print(f"Animation preview saved to: {html_path}")
352
 
353
  # Create iframe HTML - only escape quotes for srcdoc attribute
354
- safe_html_content = html_content.replace('"', '&quot;')
355
  preview_html = f"""
356
  <div style='padding: 20px; background: #fff; border: 1px solid #eee; border-radius: 8px; display: block;'>
357
  <div style='display: block; align-items: center; margin-bottom: 10px;'>
@@ -365,9 +573,9 @@ def create_animation_preview(animation_desc: str, svg_content: str) -> tuple:
365
  </div>
366
  </div>
367
  """
368
-
369
  return preview_html, html_content
370
-
371
  except Exception as e:
372
  error_html = create_error_html(f"❌ Error creating animation: {str(e)}")
373
  return error_html, ""
@@ -497,7 +705,7 @@ with demo:
497
  """,
498
  )
499
 
500
- with gr.Column():
501
  with gr.Row(scale=1):
502
  with gr.Column(scale=1):
503
  gr.Markdown("## 📂 HTML Output")
@@ -513,26 +721,26 @@ with demo:
513
  fn=predict_decompose_group,
514
  inputs=[svg_file, svg_text, object_name],
515
  outputs=[
516
- svg_content_hidden, # Store decomposed SVG for later use
517
- groups_summary, # Show analysis results
518
  animation_suggestion, # Show animation suggestions
519
- decomposed_svg_viewer, # Show SVG preview
520
  ],
521
  )
522
-
523
  groups_feedback_btn.click(
524
  fn=generator.feedback_decompose_group,
525
  inputs=[
526
- svg_content_hidden, # Pass the SVG content directly
527
- groups_feedback, # Pass the feedback text
528
  ],
529
  outputs=[
530
- svg_content_hidden, # Update hidden SVG content
531
  animation_suggestion, # Update animation suggestions
532
- decomposed_svg_viewer, # Update SVG preview
533
  ],
534
  )
535
-
536
  animate_btn.click(
537
  fn=create_animation_preview,
538
  inputs=[
@@ -540,11 +748,11 @@ with demo:
540
  svg_content_hidden,
541
  ],
542
  outputs=[
543
- animation_preview, # Animation preview HTML
544
- output_html, # Raw HTML output
545
  ],
546
  )
547
-
548
  # Real-time preview update when HTML is manually edited
549
  output_html.change(
550
  fn=update_preview_from_html,
@@ -553,4 +761,4 @@ with demo:
553
  )
554
 
555
  if __name__ == "__main__":
556
- demo.launch(share=True)
 
8
  import pathlib
9
  from logger import setup_logger
10
  import html
11
+ import uuid
12
 
13
  load_dotenv(override=True)
14
  logger = setup_logger()
 
80
  },
81
  {
82
  "role": "assistant",
83
+ "content": [{"type": "text", "text": "<animation_plan>"}],
84
+ },
 
 
 
 
 
85
  ],
86
  )
87
  response_text = response.content[0].text
 
156
  if decomposed_svg_match and animation_suggestions_match:
157
  decomposed_svg_text = decomposed_svg_match.group(1).strip()
158
  animation_suggestions = animation_suggestions_match.group(1).strip()
159
+
160
  # Create viewer HTML with XSS protection
161
  viewer_html = create_svg_viewer_html(decomposed_svg_text)
162
+
163
  return decomposed_svg_text, animation_suggestions, viewer_html
164
  else:
165
+ error_message = (
166
+ "Decomposed SVG and Animation Suggestion not found in response."
167
+ )
168
  error_html = create_error_html(error_message)
169
  return "", "", error_html
170
  except Exception as e:
 
187
  )
188
  response_text = response.content[0].text
189
  logger.info(f"Model Response:\n{response_text}")
190
+
191
  # Extract HTML content from Claude's response
192
  html_match = re.search(
193
  r"<html_output>(.*?)</html_output>", response_text, re.DOTALL
 
206
  return f"<html><body><h3>{error_msg}</h3></body></html>", ""
207
 
208
 
209
+ def _sanitize_svg(svg: str) -> str:
210
+ """안전한 미리보기를 위해 script 태그/inline on* 핸들러 제거"""
211
+ # <script>...</script> 제거
212
+ svg = re.sub(r"<\s*script\b[^>]*>.*?<\s*/\s*script\s*>", "", svg, flags=re.I | re.S)
213
+ # onload, onclick 등 inline 핸들러 제거
214
+ svg = re.sub(r"\son[a-zA-Z]+\s*=\s*(['\"]).*?\1", "", svg, flags=re.I | re.S)
215
+ return svg
216
+
217
+
218
+ def _fix_svg_markup(svg: str) -> str:
219
+ """뷰어에 맞게 SVG 속성 정리(크기/비율/문제 스타일 수정)"""
220
+ s = svg
221
+
222
+ # preserveAspectRatio 보정
223
+ s = s.replace('preserveAspectRatio="none"', 'preserveAspectRatio="xMidYMid meet"')
224
+
225
+ # position:absolute 등 문제 스타일 제거/대체
226
+ s = s.replace(
227
+ 'style="display: block; overflow: hidden; position: absolute; left: 0px; top: 0px;"',
228
+ 'style="display:block; width:100%; height:100%;"',
229
+ )
230
+
231
+ # width/height 절대값 → 상대값
232
+ s = re.sub(r'width="[^"]+"', 'width="100%"', s, count=1)
233
+ s = re.sub(r'height="[^"]+"', 'height="100%"', s, count=1)
234
+
235
+ return s
236
+
237
+
238
  generator = SVGAnimationGenerator()
239
 
240
 
 
249
 
250
 
251
  def create_svg_viewer_html(svg_content: str) -> str:
252
+ """Decomposed SVG 인터랙티브(외곽선 하이라이트/툴팁) 미리보기로 iframe에서 렌더링"""
253
+ svg = (svg_content or "").strip()
254
+ if not (svg.startswith("<svg") and svg.endswith("</svg>")):
 
255
  return create_error_html("Invalid SVG format")
256
+
257
+ svg = _sanitize_svg(svg)
258
+ svg = _fix_svg_markup(svg)
259
+
260
+ uid = f"svg-preview-{uuid.uuid4().hex}"
261
+
262
+ doc = f"""<!doctype html>
263
+ <html>
264
+ <head>
265
+ <meta charset="utf-8">
266
+ <style>
267
+ :root {{
268
+ --hl-color: #ff3b30; /* 🔧 원하는 색으로 바꿔도 됨 */
269
+ --hl-width: 3.5px;
270
+ }}
271
+
272
+ #{uid} svg {{
273
+ max-width: 100%;
274
+ max-height: 100%;
275
+ display: block;
276
+ }}
277
+
278
+ /* 포인터 이벤트는 도형 요소만 */
279
+ #{uid} svg g,
280
+ #{uid} svg path,
281
+ #{uid} svg rect,
282
+ #{uid} svg circle,
283
+ #{uid} svg ellipse,
284
+ #{uid} svg polygon,
285
+ #{uid} svg polyline,
286
+ #{uid} svg line,
287
+ #{uid} svg text {{
288
+ cursor: pointer;
289
+ pointer-events: visiblePainted;
290
+ transition: filter .12s ease, stroke-width .12s ease;
291
+ }}
292
+
293
+ /* 보조 요소는 포인터/효과 제외 */
294
+ #{uid} svg defs,
295
+ #{uid} svg clipPath,
296
+ #{uid} svg mask,
297
+ #{uid} svg title,
298
+ #{uid} svg desc {{
299
+ pointer-events: none !important;
300
+ }}
301
+
302
+ /* ✅ 외곽선 하이라이트: 기존 stroke가 있어��� 강제로 덮어쓰기 */
303
+ #{uid} .hl {{
304
+ stroke: var(--hl-color) !important;
305
+ stroke-width: var(--hl-width) !important;
306
+ paint-order: stroke fill; /* stroke를 위로 */
307
+ vector-effect: non-scaling-stroke;
308
+ filter: drop-shadow(0 0 6px rgba(0,0,0,.35));
309
+ }}
310
+
311
+ /* 필요하다면 다른 요소 미세 디밍 (기본 OFF) */
312
+ /* #{uid} .dim {{ opacity: .65; }} */
313
+
314
+ #{uid}-wrap {{
315
+ position:relative; padding:20px; background:#fff;
316
+ border:1px solid #eee; border-radius:8px; height:100%; box-sizing:border-box;
317
+ }}
318
+ #{uid} {{
319
+ border:1px solid #ddd; border-radius:8px; background:#fafafa;
320
+ height:100%; min-height:360px; display:flex; align-items:center;
321
+ justify-content:center; padding:20px; position:relative; box-sizing:border-box;
322
+ }}
323
+ #{uid}-tooltip {{
324
+ position: absolute; display: none; pointer-events: none;
325
+ background: rgba(0,0,0,0.9); color: #fff; border: 2px solid #fff;
326
+ border-radius: 6px; padding: 6px 10px; font-size: 12px; font-weight: 600;
327
+ white-space: nowrap; z-index: 10;
328
+ }}
329
+ </style>
330
+ </head>
331
+ <body style="margin:0;height:100vh;">
332
+ <div id="{uid}-wrap">
333
+ <div id="{uid}" class="svg-container">
334
+ {svg}
335
+ <div class="tooltip" id="{uid}-tooltip"></div>
336
+ </div>
337
+ </div>
338
+
339
+ <script>
340
+ (function() {{
341
+ const root = document.getElementById("{uid}");
342
+ if (!root) return;
343
+ const svg = root.querySelector("svg");
344
+ if (!svg) return;
345
+
346
+ const tooltip = document.getElementById("{uid}-tooltip");
347
+ const GEOM_SEL = "g,path,rect,circle,ellipse,polygon,polyline,line,text";
348
+ const DIM_OTHERS = false; // 🔧 true로 바꾸면 나머지 살짝 디밍
349
+
350
+ function allGeom() {{
351
+ return svg.querySelectorAll(GEOM_SEL);
352
+ }}
353
+
354
+ function closestWithId(node) {{
355
+ let cur = node;
356
+ while (cur && cur !== svg) {{
357
+ if (cur.id) return cur;
358
+ cur = cur.parentNode;
359
+ }}
360
+ return null;
361
+ }}
362
+
363
+ function clearMarks() {{
364
+ svg.querySelectorAll(".hl").forEach(el => el.classList.remove("hl"));
365
+ if (DIM_OTHERS) svg.querySelectorAll(".dim").forEach(el => el.classList.remove("dim"));
366
+ }}
367
+
368
+ function highlightOwner(owner) {{
369
+ // 그룹이면 하위 도형 전체에 hl, 도형이면 자기 자신에만
370
+ const targets = owner.matches(GEOM_SEL)
371
+ ? [owner, ...owner.querySelectorAll(GEOM_SEL)]
372
+ : [...owner.querySelectorAll(GEOM_SEL)];
373
+ targets.forEach(el => el.classList.add("hl"));
374
+ }}
375
+
376
+ function dimExcept(owner) {{
377
+ if (!DIM_OTHERS) return;
378
+ const keep = new Set([owner, ...owner.querySelectorAll(GEOM_SEL)]);
379
+ allGeom().forEach(el => {{
380
+ if (!keep.has(el)) el.classList.add("dim");
381
+ }});
382
+ }}
383
+
384
+ function moveTooltip(e) {{
385
+ const rect = root.getBoundingClientRect();
386
+ tooltip.style.left = (e.clientX - rect.left + 10) + "px";
387
+ tooltip.style.top = (e.clientY - rect.top + 10) + "px";
388
+ }}
389
+
390
+ svg.addEventListener("pointerover", (e) => {{
391
+ const owner = closestWithId(e.target);
392
+ clearMarks();
393
+ if (owner && owner.id) {{
394
+ highlightOwner(owner);
395
+ dimExcept(owner);
396
+ tooltip.textContent = owner.id;
397
+ tooltip.style.display = "block";
398
+ moveTooltip(e);
399
+ }} else {{
400
+ tooltip.style.display = "none";
401
+ }}
402
+ }});
403
+
404
+ svg.addEventListener("pointermove", (e) => {{
405
+ if (tooltip.style.display === "block") moveTooltip(e);
406
+ }});
407
+
408
+ svg.addEventListener("pointerleave", () => {{
409
+ clearMarks();
410
+ tooltip.style.display = "none";
411
+ }});
412
+ }})();
413
+ </script>
414
+ </body>
415
+ </html>
416
+ """
417
+ safe_doc = doc.replace('"', "&quot;")
418
+
419
  return f"""
420
+ <div style="width:100%; height:520px;">
421
+ <iframe
422
+ srcdoc="{safe_doc}"
423
+ width="100%"
424
+ height="100%"
425
+ style="border:none; border-radius:8px; overflow:hidden;"
426
+ sandbox="allow-scripts allow-same-origin">
427
+ </iframe>
428
  </div>
429
  """
430
 
431
 
432
+ def _extract_path_from_gradio_file(svg_file) -> str | None:
433
+ # gr.File 값이 문자열 경로인 경우
434
+ if isinstance(svg_file, (str, pathlib.Path)):
435
+ return str(svg_file)
436
+ # dict 형태({ 'name': '/tmp/...' })로 들어오는 경우
437
+ if isinstance(svg_file, dict) and "name" in svg_file:
438
+ return svg_file["name"]
439
+ # NamedString 등 파일 객체에 name 속성이 있는 경우
440
+ if hasattr(svg_file, "name"):
441
+ return svg_file.name
442
+ return None
443
+
444
+
445
  def process_svg(svg_file):
446
  if svg_file is None:
447
  return "Please upload an SVG file"
448
  try:
449
+ path = _extract_path_from_gradio_file(svg_file)
450
+ if not path:
451
+ return "Invalid file input. Please upload a valid SVG file."
452
+ with open(path, "r", encoding="utf-8") as f:
453
  svg_content = f.read()
454
  parsed_svg = generator.parse_svg(svg_content)
455
  return parsed_svg.get("svg_content", "")
 
496
  decomposed_svg_viewer = create_svg_viewer_html(decomposed_svg)
497
 
498
  return (
499
+ decomposed_svg, # For svg_content_hidden
500
+ decomposed_svg, # For groups_summary (분석 결과 표시)
501
+ animation_suggestions, # For animation_suggestion
502
+ decomposed_svg_viewer, # For decomposed_svg_viewer
503
  )
504
 
505
 
 
507
  """Update animation preview from manually edited HTML content."""
508
  if not html_content.strip():
509
  return create_error_html("⚠️ HTML content is empty")
510
+
511
  try:
512
  # Create iframe HTML - only escape quotes for srcdoc attribute
513
+ safe_html_content = html_content.replace('"', "&quot;")
514
  preview_html = f"""
515
  <div style='padding: 20px; background: #fff; border: 1px solid #eee; border-radius: 8px; display: block;'>
516
  <div style='display: block; align-items: center; margin-bottom: 10px;'>
 
526
  </div>
527
  """
528
  return preview_html
529
+
530
  except Exception as e:
531
  return create_error_html(f"❌ Error updating preview: {str(e)}")
532
 
 
542
  return error_html, ""
543
 
544
  try:
545
+ animation_response, html_content = generator.generate_animation(
546
+ animation_desc, svg_content
547
+ )
548
+
549
  if not html_content:
550
  error_html = create_error_html("❌ Failed to generate animation HTML")
551
  return error_html, animation_response
 
559
  print(f"Animation preview saved to: {html_path}")
560
 
561
  # Create iframe HTML - only escape quotes for srcdoc attribute
562
+ safe_html_content = html_content.replace('"', "&quot;")
563
  preview_html = f"""
564
  <div style='padding: 20px; background: #fff; border: 1px solid #eee; border-radius: 8px; display: block;'>
565
  <div style='display: block; align-items: center; margin-bottom: 10px;'>
 
573
  </div>
574
  </div>
575
  """
576
+
577
  return preview_html, html_content
578
+
579
  except Exception as e:
580
  error_html = create_error_html(f"❌ Error creating animation: {str(e)}")
581
  return error_html, ""
 
705
  """,
706
  )
707
 
708
+ with gr.Column():
709
  with gr.Row(scale=1):
710
  with gr.Column(scale=1):
711
  gr.Markdown("## 📂 HTML Output")
 
721
  fn=predict_decompose_group,
722
  inputs=[svg_file, svg_text, object_name],
723
  outputs=[
724
+ svg_content_hidden, # Store decomposed SVG for later use
725
+ groups_summary, # Show analysis results
726
  animation_suggestion, # Show animation suggestions
727
+ decomposed_svg_viewer, # Show SVG preview
728
  ],
729
  )
730
+
731
  groups_feedback_btn.click(
732
  fn=generator.feedback_decompose_group,
733
  inputs=[
734
+ svg_content_hidden, # Pass the SVG content directly
735
+ groups_feedback, # Pass the feedback text
736
  ],
737
  outputs=[
738
+ svg_content_hidden, # Update hidden SVG content
739
  animation_suggestion, # Update animation suggestions
740
+ decomposed_svg_viewer, # Update SVG preview
741
  ],
742
  )
743
+
744
  animate_btn.click(
745
  fn=create_animation_preview,
746
  inputs=[
 
748
  svg_content_hidden,
749
  ],
750
  outputs=[
751
+ animation_preview, # Animation preview HTML
752
+ output_html, # Raw HTML output
753
  ],
754
  )
755
+
756
  # Real-time preview update when HTML is manually edited
757
  output_html.change(
758
  fn=update_preview_from_html,
 
761
  )
762
 
763
  if __name__ == "__main__":
764
+ demo.launch(share=True)