Asset2Scene / canvas_editor.py
MetricMogul's picture
Update canvas_editor.py
f2a639d verified
import json
import os
import time
from urllib.parse import quote
EXPORT_TEXTBOX_ID = "scene_png_data"
def file_url(path: str) -> str:
abs_path = os.path.abspath(path)
return f"/gradio_api/file={quote(abs_path, safe='/')}"
def build_payload(files, mode="append"):
items = []
for path in (files or []):
if not path:
continue
items.append(
{
"name": os.path.basename(path),
"url": file_url(path),
}
)
return json.dumps(
{
"render_id": time.time(),
"mode": mode, # "append" or "reset"
"items": items,
},
ensure_ascii=False,
)
def payload_append(files):
return build_payload(files, mode="append")
def payload_reset(files):
return build_payload(files, mode="reset")
def empty_canvas_payload():
return build_payload([], mode="reset")
HTML_TEMPLATE = """
<div class="se-wrap">
<div class="se-toolbar">
<button class="se-btn" data-action="smaller" type="button">Smaller</button>
<button class="se-btn" data-action="bigger" type="button">Bigger</button>
<button class="se-btn" data-action="delete-selected" type="button">Delete selected</button>
<button class="se-btn" data-action="clear-selection" type="button">Clear selection</button>
<button class="se-btn" data-action="clear-scene" type="button">Clear scene</button>
</div>
<div class="se-scene"></div>
<div class="se-help">
Add selected asset view from the main panel. Tap/click a sprite to select it. Drag with finger or mouse.
</div>
</div>
"""
CSS_TEMPLATE = """
.se-wrap {
width: 100%;
}
.se-toolbar {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.se-btn {
border: 1px solid #4b5563;
background: #111827;
color: #f3f4f6;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
}
.se-btn:hover {
background: #1f2937;
}
.se-scene {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid #374151;
border-radius: 12px;
overflow: hidden;
touch-action: none;
background:
linear-gradient(45deg, #1f2937 25%, transparent 25%),
linear-gradient(-45deg, #1f2937 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1f2937 75%),
linear-gradient(-45deg, transparent 75%, #1f2937 75%);
background-size: 28px 28px;
background-position: 0 0, 0 14px, 14px -14px, -14px 0px;
}
.se-help {
margin-top: 8px;
font-size: 13px;
color: #9ca3af;
}
.se-sprite {
position: absolute;
max-width: none;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
cursor: grab;
border: 2px solid transparent;
border-radius: 8px;
box-sizing: border-box;
display: block;
}
.se-sprite.selected {
border-color: #60a5fa;
}
.se-sprite.dragging {
cursor: grabbing;
}
"""
JS_ON_LOAD = rf"""
const scene = element.querySelector(".se-scene");
const btnSmaller = element.querySelector('[data-action="smaller"]');
const btnBigger = element.querySelector('[data-action="bigger"]');
const btnDelete = element.querySelector('[data-action="delete-selected"]');
const btnClear = element.querySelector('[data-action="clear-selection"]');
const btnClearScene = element.querySelector('[data-action="clear-scene"]');
let lastRenderId = null;
let selected = null;
let spriteCounter = 0;
function getExportInput() {{
return document.querySelector("#{EXPORT_TEXTBOX_ID} textarea, #{EXPORT_TEXTBOX_ID} input");
}}
function setExportValue(value) {{
const target = getExportInput();
if (!target) return;
if (target.value === value) return;
target.value = value;
target.dispatchEvent(new Event("input", {{ bubbles: true }}));
target.dispatchEvent(new Event("change", {{ bubbles: true }}));
}}
function exportSceneToDataUrl() {{
const sprites = Array.from(scene.querySelectorAll(".se-sprite"));
if (!sprites.length) return "";
const scale = 2;
const width = Math.max(1, Math.round(scene.clientWidth));
const height = Math.max(1, Math.round(scene.clientHeight));
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.clearRect(0, 0, width, height);
const orderedSprites = sprites.slice().sort((a, b) => {{
const za = parseInt(a.style.zIndex || "0", 10);
const zb = parseInt(b.style.zIndex || "0", 10);
return za - zb;
}});
orderedSprites.forEach((node) => {{
const left = parseFloat(node.style.left || "0");
const top = parseFloat(node.style.top || "0");
const widthPx = parseFloat(node.style.width || "100");
let ratio = 1.0;
if (node.naturalWidth && node.naturalHeight) {{
ratio = node.naturalHeight / node.naturalWidth;
}} else {{
const rect = node.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {{
ratio = rect.height / rect.width;
}}
}}
const heightPx = widthPx * ratio;
ctx.drawImage(node, left, top, widthPx, heightPx);
}});
return canvas.toDataURL("image/png");
}}
function syncExport() {{
const dataUrl = exportSceneToDataUrl();
setExportValue(dataUrl);
}}
function clearSelection() {{
if (selected) selected.classList.remove("selected");
selected = null;
}}
function selectSprite(node) {{
clearSelection();
selected = node;
selected.classList.add("selected");
}}
function clampWidth(newWidth, sceneWidth) {{
const minW = 40;
const maxW = Math.max(minW, sceneWidth * 1.5);
return Math.max(minW, Math.min(maxW, newWidth));
}}
function resizeSelected(multiplier) {{
if (!selected) return;
const sceneRect = scene.getBoundingClientRect();
const currentWidth = selected.getBoundingClientRect().width;
const newWidth = clampWidth(currentWidth * multiplier, sceneRect.width);
selected.style.width = `${{newWidth}}px`;
syncExport();
}}
function deleteSelected() {{
if (!selected) return;
const node = selected;
clearSelection();
node.remove();
syncExport();
}}
function makeDraggable(node) {{
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let dragging = false;
node.addEventListener("pointerdown", (e) => {{
e.preventDefault();
e.stopPropagation();
selectSprite(node);
dragging = true;
node.classList.add("dragging");
node.style.zIndex = String(++spriteCounter);
node.setPointerCapture(e.pointerId);
startX = e.clientX;
startY = e.clientY;
startLeft = parseFloat(node.style.left || "0");
startTop = parseFloat(node.style.top || "0");
}});
node.addEventListener("pointermove", (e) => {{
if (!dragging) return;
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
node.style.left = `${{startLeft + dx}}px`;
node.style.top = `${{startTop + dy}}px`;
}});
function endDrag(e) {{
if (!dragging) return;
dragging = false;
node.classList.remove("dragging");
try {{
node.releasePointerCapture(e.pointerId);
}} catch (_) {{}}
syncExport();
}}
node.addEventListener("pointerup", endDrag);
node.addEventListener("pointercancel", endDrag);
}}
function createSprite(item, indexOffset = 0) {{
const sceneRect = scene.getBoundingClientRect();
const baseW = Math.max(140, sceneRect.width * 0.28);
const img = document.createElement("img");
img.className = "se-sprite";
img.alt = item.name || `sprite-${{spriteCounter + 1}}`;
img.dataset.assetUrl = item.url;
img.dataset.assetName = item.name || "";
img.style.left = `${{30 + indexOffset * 24}}px`;
img.style.top = `${{30 + indexOffset * 24}}px`;
img.style.width = `${{baseW}}px`;
img.style.zIndex = String(++spriteCounter);
img.addEventListener("click", (e) => {{
e.stopPropagation();
selectSprite(img);
}});
img.addEventListener("load", () => {{
syncExport();
}});
img.addEventListener("error", () => {{
console.error("Failed to load sprite:", item.url);
}});
img.src = item.url;
makeDraggable(img);
scene.appendChild(img);
}}
function render() {{
const raw = props.value || '{{"render_id": null, "mode": "append", "items": []}}';
let payload;
try {{
payload = JSON.parse(raw);
}} catch (e) {{
payload = {{ render_id: null, mode: "append", items: [] }};
}}
const renderId = payload.render_id;
const mode = payload.mode || "append";
const items = Array.isArray(payload.items) ? payload.items : [];
if (renderId === lastRenderId) return;
lastRenderId = renderId;
if (mode === "reset") {{
scene.innerHTML = "";
clearSelection();
setExportValue("");
}}
const existingUrls = new Set(
Array.from(scene.querySelectorAll(".se-sprite")).map((node) => node.dataset.assetUrl)
);
items.forEach((item, i) => {{
if (!item.url) return;
if (mode === "append" && existingUrls.has(item.url)) {{
return;
}}
createSprite(item, i + scene.querySelectorAll(".se-sprite").length);
}});
syncExport();
}}
scene.addEventListener("click", () => {{
clearSelection();
}});
if (btnSmaller) {{
btnSmaller.addEventListener("click", () => {{
resizeSelected(0.85);
}});
}}
if (btnBigger) {{
btnBigger.addEventListener("click", () => {{
resizeSelected(1.15);
}});
}}
if (btnDelete) {{
btnDelete.addEventListener("click", () => {{
deleteSelected();
}});
}}
if (btnClear) {{
btnClear.addEventListener("click", () => {{
clearSelection();
}});
}}
if (btnClearScene) {{
btnClearScene.addEventListener("click", () => {{
scene.innerHTML = "";
clearSelection();
setExportValue("");
}});
}}
render();
setInterval(render, 200);
"""