// Copyright WanGP. Subject to the WanGP license. (function () { const EVENT_TYPE = "WAN2GP_MOTION_DESIGNER"; const CONTROL_MESSAGE_TYPE = "WAN2GP_MOTION_DESIGNER_CONTROL"; const RENDER_MODES = { CUT_DRAG: "cut_drag", CLASSIC: "classic", TRAJECTORY: "trajectory", }; const MIME_CANDIDATES = [ "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", ]; const COLOR_POOL = ["#4cc2ff", "#ff9f43", "#8d78ff", "#ff5e8a", "#32d5a4", "#f9d65c"]; const POLYGON_EDGE_COLOR = "#ff4d57"; const TRAJECTORY_EDGE_COLOR = "#4d9bff"; const DEFAULT_CLASSIC_OUTLINE_WIDTH = 1; const BACKGROUND_RENDER_DELAY_MS = 500; const state = { baseImage: null, baseCanvas: document.createElement("canvas"), backgroundCanvas: document.createElement("canvas"), altBackgroundImage: null, altBackgroundDataUrl: null, resolution: { width: 832, height: 480 }, fitMode: "cover", scene: { fps: 16, totalFrames: 81, }, layers: [], activeLayerId: null, showPatchedBackground: true, baseCanvasDataUrl: null, previewMode: false, classicOutlineWidth: DEFAULT_CLASSIC_OUTLINE_WIDTH, backgroundDataUrl: null, dragContext: null, renderMode: RENDER_MODES.CUT_DRAG, modeToggleVisible: true, animation: { playhead: 0, playing: false, loop: true, lastTime: 0, }, transfer: { pending: false, mask: null, guide: null, backgroundImage: null, }, export: { running: false, mode: null, variant: "mask", renderMode: RENDER_MODES.CUT_DRAG, canvas: null, ctx: null, recorder: null, stream: null, chunks: [], frame: 0, totalFrames: 0, mimeType: null, cancelled: false, }, currentWorkflowStage: "scene", userPanelOverride: null, currentExpandedPanel: null, shapeMode: "rectangle", theme: "light", }; const dom = {}; document.addEventListener("DOMContentLoaded", init); function init() { cacheDom(); applyTheme(state.theme); syncSceneInputs(); bindEvents(); initCollapsiblePanels(); matchCanvasResolution(); resetLayers(); updatePreviewModeUI(); updateWorkflowPanels(); updateSpeedControlVisibility(); updateSpeedRatioLabel(); updateCanvasPlaceholder(); updateModeToggleUI(); applyModeToggleVisibility(); updateClassicOutlineControls(); updateTrajectoryModeUI(); initExternalBridge(); setupHeightObserver(); requestAnimationFrame(render); updateBackgroundToggleUI(); } function setModalOpen(open) { const locked = Boolean(open); document.documentElement.classList.toggle("modal-open", locked); document.body.classList.toggle("modal-open", locked); try { window.parent?.postMessage({ type: "WAN2GP_MOTION_DESIGNER_MODAL_LOCK", open: locked }, "*"); } catch (err) { console.warn("[MotionDesigner] Unable to notify parent about modal state", err); } } function cacheDom() { dom.canvas = document.getElementById("editorCanvas"); dom.ctx = dom.canvas.getContext("2d"); dom.badge = document.getElementById("canvasGuide"); dom.statusBadge = document.getElementById("statusBadge"); dom.sourceChooser = document.getElementById("sourceChooser"); dom.targetWidthInput = document.getElementById("targetWidthInput"); dom.targetHeightInput = document.getElementById("targetHeightInput"); dom.fitModeSelect = document.getElementById("fitModeSelect"); dom.applyResolutionBtn = document.getElementById("applyResolutionBtn"); dom.sceneFpsInput = document.getElementById("sceneFpsInput"); dom.sceneFrameCountInput = document.getElementById("sceneFrameCountInput"); dom.scaleStartInput = document.getElementById("scaleStartInput"); dom.scaleEndInput = document.getElementById("scaleEndInput"); dom.rotationStartInput = document.getElementById("rotationStartInput"); dom.rotationEndInput = document.getElementById("rotationEndInput"); dom.speedModeSelect = document.getElementById("speedModeSelect"); dom.speedRatioInput = document.getElementById("speedRatioInput"); dom.speedRatioLabel = document.getElementById("speedRatioLabel"); dom.speedRatioRow = document.getElementById("speedRatioRow"); dom.speedRatioValue = document.getElementById("speedRatioValue"); dom.hideOutsideRangeToggle = document.getElementById("hideOutsideRangeToggle"); dom.tensionInput = document.getElementById("tensionInput"); dom.tensionValueLabel = document.getElementById("tensionValue"); dom.startFrameInput = document.getElementById("startFrameInput"); dom.endFrameInput = document.getElementById("endFrameInput"); dom.timelineSlider = document.getElementById("timelineSlider"); dom.previewModeBtn = document.getElementById("previewModeBtn"); dom.playPauseBtn = document.getElementById("playPauseBtn"); dom.loopToggle = document.getElementById("loopToggle"); dom.classicOutlineControl = document.getElementById("classicOutlineControl"); dom.classicOutlineSlider = document.getElementById("classicOutlineSlider"); dom.classicOutlineValue = document.getElementById("classicOutlineValue"); dom.modeSwitcher = document.getElementById("modeSwitcher"); dom.modeCutDragBtn = document.getElementById("modeCutDragBtn"); dom.modeClassicBtn = document.getElementById("modeClassicBtn"); dom.modeTrajectoryBtn = document.getElementById("modeTrajectoryBtn"); dom.canvasFooter = document.getElementById("canvasFooter"); dom.downloadMaskBtn = document.getElementById("downloadMaskBtn"); dom.sendToWangpBtn = document.getElementById("sendToWangpBtn"); dom.backgroundToggleBtn = document.getElementById("backgroundToggleBtn"); dom.backgroundFileInput = document.getElementById("backgroundFileInput"); dom.saveDefinitionBtn = document.getElementById("saveDefinitionBtn"); dom.loadDefinitionBtn = document.getElementById("loadDefinitionBtn"); dom.definitionFileInput = document.getElementById("definitionFileInput"); dom.downloadBackgroundBtn = document.getElementById("downloadBackgroundBtn"); dom.exportOverlay = document.getElementById("exportOverlay"); dom.exportProgressBar = document.getElementById("exportProgressBar"); dom.exportLabel = document.getElementById("exportLabel"); dom.cancelExportBtn = document.getElementById("cancelExportBtn"); dom.activeObjectLabel = document.getElementById("activeObjectLabel"); dom.contextDeleteBtn = document.getElementById("contextDeleteBtn"); dom.contextResetBtn = document.getElementById("contextResetBtn"); dom.contextUndoBtn = document.getElementById("contextUndoBtn"); dom.contextButtonGroup = document.getElementById("contextButtonGroup"); dom.shapeModeSelect = document.getElementById("shapeModeSelect"); dom.objectPanelBody = document.querySelector('.panel[data-panel="object"] .panel-body'); dom.canvasPlaceholder = document.getElementById("canvasPlaceholder"); dom.canvasUploadBtn = document.getElementById("canvasUploadBtn"); dom.themeToggleBtn = document.getElementById("themeToggleBtn"); dom.unloadSceneBtn = document.getElementById("unloadSceneBtn"); dom.unloadOverlay = document.getElementById("unloadOverlay"); dom.confirmUnloadBtn = document.getElementById("confirmUnloadBtn"); dom.cancelUnloadBtn = document.getElementById("cancelUnloadBtn"); } function applyTheme(theme) { state.theme = theme === "dark" ? "dark" : "light"; document.body.classList.toggle("theme-dark", state.theme === "dark"); if (dom.themeToggleBtn) { dom.themeToggleBtn.textContent = state.theme === "dark" ? "Light Mode" : "Dark Mode"; } } function toggleTheme() { applyTheme(state.theme === "dark" ? "light" : "dark"); } function bindEvents() { dom.sourceChooser.addEventListener("change", handleSourceFile); dom.applyResolutionBtn.addEventListener("click", () => { if (state.baseImage) { applyResolution(); } }); if (dom.classicOutlineSlider) { dom.classicOutlineSlider.addEventListener("input", (evt) => { handleClassicOutlineChange(evt.target.value); }); } dom.sceneFpsInput.addEventListener("input", handleSceneFpsChange); dom.sceneFrameCountInput.addEventListener("change", handleSceneFrameCountChange); dom.sceneFrameCountInput.addEventListener("blur", handleSceneFrameCountChange); dom.scaleStartInput.addEventListener("input", () => handleLayerAnimInput("scaleStart", parseFloat(dom.scaleStartInput.value))); dom.scaleEndInput.addEventListener("input", () => handleLayerAnimInput("scaleEnd", parseFloat(dom.scaleEndInput.value))); dom.rotationStartInput.addEventListener("input", () => handleLayerAnimInput("rotationStart", parseFloat(dom.rotationStartInput.value))); dom.rotationEndInput.addEventListener("input", () => handleLayerAnimInput("rotationEnd", parseFloat(dom.rotationEndInput.value))); dom.speedModeSelect.addEventListener("change", () => { handleLayerAnimInput("speedMode", dom.speedModeSelect.value); updateSpeedControlVisibility(); }); dom.speedRatioInput.addEventListener("input", () => { handleLayerAnimInput("speedRatio", parseFloat(dom.speedRatioInput.value)); updateSpeedRatioLabel(); }); dom.startFrameInput.addEventListener("input", () => handleLayerFrameInput("startFrame", parseInt(dom.startFrameInput.value, 10))); dom.endFrameInput.addEventListener("input", () => handleLayerFrameInput("endFrame", parseInt(dom.endFrameInput.value, 10))); dom.hideOutsideRangeToggle.addEventListener("change", (evt) => handleHideOutsideRangeToggle(evt.target.checked)); dom.tensionInput.addEventListener("input", () => handleLayerAnimInput("tension", parseFloat(dom.tensionInput.value))); dom.timelineSlider.addEventListener("input", handleTimelineScrub); dom.previewModeBtn.addEventListener("click", togglePreviewMode); dom.playPauseBtn.addEventListener("click", togglePlayback); dom.loopToggle.addEventListener("change", (evt) => { state.animation.loop = evt.target.checked; }); dom.downloadMaskBtn.addEventListener("click", () => startExport("download")); dom.sendToWangpBtn.addEventListener("click", handleSendToWangp); if (dom.backgroundToggleBtn && dom.backgroundFileInput) { dom.backgroundToggleBtn.addEventListener("click", handleBackgroundToggle); dom.backgroundFileInput.addEventListener("change", handleBackgroundFileSelected); } if (dom.saveDefinitionBtn) { dom.saveDefinitionBtn.addEventListener("click", handleSaveDefinition); } if (dom.loadDefinitionBtn && dom.definitionFileInput) { dom.loadDefinitionBtn.addEventListener("click", (evt) => { evt.preventDefault(); dom.definitionFileInput.value = ""; dom.definitionFileInput.click(); }); dom.definitionFileInput.addEventListener("change", handleDefinitionFileSelected); } dom.downloadBackgroundBtn.addEventListener("click", downloadBackgroundImage); dom.cancelExportBtn.addEventListener("click", cancelExport); dom.contextDeleteBtn.addEventListener("click", handleContextDelete); dom.contextResetBtn.addEventListener("click", handleContextReset); dom.contextUndoBtn.addEventListener("click", handleContextUndo); if (dom.unloadSceneBtn) { dom.unloadSceneBtn.addEventListener("click", promptUnloadScene); } if (dom.shapeModeSelect) { dom.shapeModeSelect.addEventListener("change", (evt) => { handleShapeModeSelectChange(evt.target.value || "polygon"); }); dom.shapeModeSelect.value = state.shapeMode; } if (dom.canvasUploadBtn && dom.sourceChooser) { dom.canvasUploadBtn.addEventListener("click", (evt) => { evt.preventDefault(); evt.stopPropagation(); dom.sourceChooser.click(); }); } if (dom.canvasPlaceholder && dom.sourceChooser) { dom.canvasPlaceholder.addEventListener("click", (evt) => { if (evt.target === dom.canvasUploadBtn) { return; } dom.sourceChooser.click(); }); } if (dom.confirmUnloadBtn) { dom.confirmUnloadBtn.addEventListener("click", () => { hideUnloadPrompt(); handleUnloadScene(); }); } if (dom.cancelUnloadBtn) { dom.cancelUnloadBtn.addEventListener("click", hideUnloadPrompt); } if (dom.themeToggleBtn) { dom.themeToggleBtn.addEventListener("click", (evt) => { evt.preventDefault(); toggleTheme(); }); } if (dom.modeCutDragBtn) { dom.modeCutDragBtn.addEventListener("click", () => setRenderMode(RENDER_MODES.CUT_DRAG)); } if (dom.modeClassicBtn) { dom.modeClassicBtn.addEventListener("click", () => setRenderMode(RENDER_MODES.CLASSIC)); } if (dom.modeTrajectoryBtn) { dom.modeTrajectoryBtn.addEventListener("click", () => setRenderMode(RENDER_MODES.TRAJECTORY)); } dom.canvas.addEventListener("pointerdown", onPointerDown); dom.canvas.addEventListener("pointermove", onPointerMove); dom.canvas.addEventListener("pointerup", onPointerUp); dom.canvas.addEventListener("pointerleave", onPointerUp); dom.canvas.addEventListener("dblclick", onCanvasDoubleClick); dom.canvas.addEventListener("contextmenu", onCanvasContextMenu); } function initCollapsiblePanels() { dom.panelMap = {}; dom.collapsiblePanels = Array.from(document.querySelectorAll(".panel.collapsible")); dom.collapsiblePanels.forEach((panel) => { const id = panel.dataset.panel; if (!id) { return; } const toggle = panel.querySelector(".panel-toggle"); dom.panelMap[id] = { element: panel, toggle }; if (panel.dataset.fixed === "true") { panel.classList.add("expanded"); if (toggle) { toggle.setAttribute("aria-disabled", "true"); } return; } if (toggle) { toggle.addEventListener("click", () => { expandPanel(id, true); }); } }); if (!state.currentExpandedPanel && dom.panelMap.scene) { expandPanel("scene"); } } function setRenderMode(mode) { let normalized; if (mode === RENDER_MODES.CLASSIC) { normalized = RENDER_MODES.CLASSIC; } else if (mode === RENDER_MODES.TRAJECTORY) { normalized = RENDER_MODES.TRAJECTORY; } else { normalized = RENDER_MODES.CUT_DRAG; } if (state.renderMode === normalized) { updateModeToggleUI(); updateClassicOutlineControls(); updateTrajectoryModeUI(); return; } const previousMode = state.renderMode; state.renderMode = normalized; // Handle mode switching edge cases handleModeSwitchCleanup(previousMode, normalized); updateModeToggleUI(); updateClassicOutlineControls(); updateBackgroundToggleUI(); updateTrajectoryModeUI(); } function handleModeSwitchCleanup(fromMode, toMode) { const wasTrajectory = fromMode === RENDER_MODES.TRAJECTORY; const isTrajectory = toMode === RENDER_MODES.TRAJECTORY; if (wasTrajectory && !isTrajectory) { // Switching FROM trajectory mode: remove ALL trajectory-only layers const trajectoryLayers = state.layers.filter((l) => isTrajectoryOnlyLayer(l)); if (trajectoryLayers.length > 0) { state.layers = state.layers.filter((l) => !isTrajectoryOnlyLayer(l)); state.activeLayerId = state.layers[0]?.id || null; recomputeBackgroundFill(); refreshLayerSelect(); updateBadge(); updateActionAvailability(); if (state.baseImage) { setStatus("Switched mode. Trajectory layers removed.", "info"); } } // Add a new empty layer for shape creation if none exist if (state.layers.length === 0 && state.baseImage) { addLayer(state.shapeMode); } } else if (!wasTrajectory && isTrajectory) { // Switching TO trajectory mode: remove ALL non-trajectory layers const shapeLayers = state.layers.filter((l) => !isTrajectoryOnlyLayer(l)); if (shapeLayers.length > 0) { state.layers = state.layers.filter((l) => isTrajectoryOnlyLayer(l)); state.activeLayerId = state.layers[0]?.id || null; recomputeBackgroundFill(); refreshLayerSelect(); updateBadge(); updateActionAvailability(); if (state.baseImage) { setStatus("Trajectory mode. Shape layers cleared.", "info"); } } if (state.baseImage && state.layers.length === 0) { setStatus("Trajectory mode. Click to place trajectory points.", "info"); } } } function updateModeToggleUI() { if (!dom.modeCutDragBtn || !dom.modeClassicBtn) { return; } const isClassic = state.renderMode === RENDER_MODES.CLASSIC; const isCutDrag = state.renderMode === RENDER_MODES.CUT_DRAG; const isTrajectory = state.renderMode === RENDER_MODES.TRAJECTORY; dom.modeCutDragBtn.classList.toggle("active", isCutDrag); dom.modeClassicBtn.classList.toggle("active", isClassic); dom.modeCutDragBtn.setAttribute("aria-pressed", isCutDrag.toString()); dom.modeClassicBtn.setAttribute("aria-pressed", isClassic.toString()); if (dom.modeTrajectoryBtn) { dom.modeTrajectoryBtn.classList.toggle("active", isTrajectory); dom.modeTrajectoryBtn.setAttribute("aria-pressed", isTrajectory.toString()); } } function setModeToggleVisibility(visible) { state.modeToggleVisible = Boolean(visible); applyModeToggleVisibility(); } function applyModeToggleVisibility() { if (!dom.modeSwitcher) { return; } dom.modeSwitcher.classList.toggle("is-hidden", !state.modeToggleVisible); updateBackgroundToggleUI(); } function updateTrajectoryModeUI() { const isTrajectory = state.renderMode === RENDER_MODES.TRAJECTORY; // Hide shape selector in trajectory mode (points only, no shapes) const shapeSelector = document.querySelector(".shape-selector"); if (shapeSelector) { shapeSelector.classList.toggle("is-hidden", isTrajectory); } // Hide scale and rotation controls in trajectory mode (not applicable to points) const scaleStartLabel = dom.scaleStartInput?.closest(".multi-field"); const rotationStartLabel = dom.rotationStartInput?.closest(".multi-field"); if (scaleStartLabel) { scaleStartLabel.classList.toggle("is-hidden", isTrajectory); } if (rotationStartLabel) { rotationStartLabel.classList.toggle("is-hidden", isTrajectory); } // Update Send button label for trajectory mode if (dom.sendToWangpBtn) { dom.sendToWangpBtn.textContent = isTrajectory ? "Export Trajectories" : "Send to Video Generator"; } // Update Preview button label for trajectory mode if (dom.previewModeBtn && !state.previewMode) { dom.previewModeBtn.textContent = isTrajectory ? "Preview Trajectories" : "Preview Mask"; } } function initExternalBridge() { if (typeof window === "undefined") { return; } window.addEventListener("message", handleExternalControlMessage); window.Wan2gpCutDrag = { setMode: (mode) => setRenderMode(mode), setModeToggleVisibility: (visible) => setModeToggleVisibility(visible), }; } function handleExternalControlMessage(event) { if (!event || typeof event.data !== "object" || event.data === null) { return; } const payload = event.data; if (payload.type !== CONTROL_MESSAGE_TYPE) { return; } switch (payload.action) { case "setMode": setRenderMode(payload.value); break; case "setModeToggleVisibility": setModeToggleVisibility(payload.value !== false); break; default: break; } } function setStatus(message, variant = "muted") { if (!dom.statusBadge) { return; } dom.statusBadge.textContent = message; dom.statusBadge.classList.remove("muted", "success", "warn", "error"); dom.statusBadge.classList.add( variant === "success" ? "success" : variant === "warn" ? "warn" : variant === "error" ? "error" : "muted", ); } function notify(message, variant = "muted") { setStatus(message, variant); console[variant === "error" ? "error" : variant === "warn" ? "warn" : "log"]("[MotionDesigner]", message); } function matchCanvasResolution() { state.baseCanvas.width = state.resolution.width; state.baseCanvas.height = state.resolution.height; state.backgroundCanvas.width = state.resolution.width; state.backgroundCanvas.height = state.resolution.height; dom.canvas.width = state.resolution.width; dom.canvas.height = state.resolution.height; } function resetLayers() { state.layers = []; state.activeLayerId = null; if (state.baseImage) { addLayer(state.shapeMode); } state.backgroundDataUrl = null; if (dom.timelineSlider) { dom.timelineSlider.value = "0"; } state.animation.playhead = 0; state.animation.playing = false; if (dom.playPauseBtn) { dom.playPauseBtn.textContent = "Play"; } updateBadge(); updateActionAvailability(); updateCanvasPlaceholder(); updateLayerAnimationInputs(); } function createLayer(name, colorIndex, shapeOverride) { return { id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `layer_${Date.now()}_${Math.random()}`, name, color: COLOR_POOL[colorIndex % COLOR_POOL.length], polygon: [], polygonClosed: false, polygonPreviewPoint: null, selectedPolygonIndex: -1, path: [], pathLocked: false, selectedPathIndex: -1, localPolygon: [], anchor: { x: 0, y: 0 }, objectCut: null, pathMeta: null, renderPath: [], renderSegmentMap: [], shapeType: shapeOverride || state.shapeMode || "polygon", shapeDraft: null, tempPolygon: null, shapeMeta: null, startFrame: 0, endFrame: state.scene.totalFrames, scaleStart: 1, scaleEnd: 1, rotationStart: 0, rotationEnd: 0, speedMode: "none", speedRatio: 1, tension: 0, hideOutsideRange: true, }; } function handleShapeModeSelectChange(mode) { const normalized = mode || "polygon"; state.shapeMode = normalized; const layer = getActiveLayer(); if (!layer) { return; } const stage = getLayerStage(layer); const hasPolygonPoints = Array.isArray(layer.polygon) && layer.polygon.length > 0; if (stage === "polygon" && !layer.polygonClosed && !hasPolygonPoints) { layer.shapeType = normalized; layer.shapeDraft = null; layer.tempPolygon = null; layer.shapeMeta = null; layer.polygonPreviewPoint = null; } } function addLayer(shapeOverride) { const layer = createLayer(`Object ${state.layers.length + 1}`, state.layers.length, shapeOverride); state.layers.push(layer); state.activeLayerId = layer.id; updateLayerPathCache(layer); refreshLayerSelect(); updateBindings(); updateBadge(); updateActionAvailability(); return layer; } function addTrajectoryLayer(initialPoint) { // Creates a trajectory-only layer for trajectory mode (no shape/polygon) const layer = createLayer(`Trajectory ${state.layers.length + 1}`, state.layers.length, "trajectory"); // Mark polygon as closed so we skip polygon creation and go directly to trajectory layer.polygonClosed = true; layer.shapeType = "trajectory"; // Add initial trajectory point if (initialPoint) { layer.path.push({ x: initialPoint.x, y: initialPoint.y }); } state.layers.push(layer); state.activeLayerId = layer.id; updateLayerPathCache(layer); refreshLayerSelect(); updateBindings(); updateBadge(); updateActionAvailability(); return layer; } function removeLayer() { if (!state.activeLayerId) { return; } state.layers = state.layers.filter((layer) => layer.id !== state.activeLayerId); state.activeLayerId = state.layers[0]?.id || null; refreshLayerSelect(); updateBindings(); recomputeBackgroundFill(); updateBadge(); updateActionAvailability(); } function refreshLayerSelect() { updateLayerAnimationInputs(); } function updateLayerAnimationInputs() { if (!dom.activeObjectLabel) { return; } const layer = getActiveLayer(); if (dom.objectPanelBody) { dom.objectPanelBody.classList.toggle("disabled", !layer); } const layerIndex = layer ? state.layers.findIndex((item) => item.id === layer.id) : -1; dom.activeObjectLabel.textContent = layerIndex >= 0 ? String(layerIndex + 1) : "None"; if (dom.shapeModeSelect) { dom.shapeModeSelect.value = layer ? layer.shapeType || "polygon" : state.shapeMode; } const inputs = [ dom.scaleStartInput, dom.scaleEndInput, dom.rotationStartInput, dom.rotationEndInput, dom.startFrameInput, dom.endFrameInput, dom.speedModeSelect, dom.speedRatioInput, dom.tensionInput, ]; inputs.forEach((input) => { if (input) { input.disabled = !layer; if (input === dom.startFrameInput || input === dom.endFrameInput) { input.min = 0; input.max = state.scene.totalFrames; } } }); if (dom.hideOutsideRangeToggle) { dom.hideOutsideRangeToggle.disabled = !layer; } if (!layer) { if (dom.scaleStartInput) dom.scaleStartInput.value = 1; if (dom.scaleEndInput) dom.scaleEndInput.value = 1; if (dom.rotationStartInput) dom.rotationStartInput.value = 0; if (dom.rotationEndInput) dom.rotationEndInput.value = 0; if (dom.startFrameInput) dom.startFrameInput.value = 0; if (dom.endFrameInput) dom.endFrameInput.value = state.scene.totalFrames; if (dom.speedModeSelect) dom.speedModeSelect.value = "none"; if (dom.speedRatioInput) dom.speedRatioInput.value = 1; if (dom.tensionInput) dom.tensionInput.value = 0; if (dom.tensionValueLabel) dom.tensionValueLabel.textContent = "0%"; if (dom.hideOutsideRangeToggle) dom.hideOutsideRangeToggle.checked = true; updateSpeedControlVisibility(null); updateSpeedRatioLabel(); return; } if (dom.scaleStartInput) dom.scaleStartInput.value = layer.scaleStart ?? 1; if (dom.scaleEndInput) dom.scaleEndInput.value = layer.scaleEnd ?? 1; if (dom.rotationStartInput) dom.rotationStartInput.value = layer.rotationStart ?? 0; if (dom.rotationEndInput) dom.rotationEndInput.value = layer.rotationEnd ?? 0; if (dom.startFrameInput) dom.startFrameInput.value = layer.startFrame ?? 0; if (dom.endFrameInput) dom.endFrameInput.value = layer.endFrame ?? state.scene.totalFrames; if (dom.speedModeSelect) dom.speedModeSelect.value = layer.speedMode || "none"; if (dom.speedRatioInput) dom.speedRatioInput.value = layer.speedRatio ?? 1; if (dom.tensionInput) dom.tensionInput.value = layer.tension ?? 0; if (dom.tensionValueLabel) dom.tensionValueLabel.textContent = `${Math.round((layer.tension ?? 0) * 100)}%`; if (dom.hideOutsideRangeToggle) dom.hideOutsideRangeToggle.checked = layer.hideOutsideRange !== false; updateSpeedControlVisibility(layer); updateSpeedRatioLabel(); } function setActiveLayer(layerId) { if (!layerId) { state.activeLayerId = null; updateBindings(); updateBadge(); updateActionAvailability(); return; } const layer = state.layers.find((item) => item.id === layerId); if (!layer) { return; } state.activeLayerId = layer.id; ensureTrajectoryEditable(layer); updateBindings(); updateBadge(); updateActionAvailability(); } function updateBindings() { const layer = getActiveLayer(); updateLayerAnimationInputs(); } function getActiveLayer() { return state.layers.find((layer) => layer.id === state.activeLayerId) || null; } function getLayerById(layerId) { if (!layerId) { return null; } return state.layers.find((layer) => layer.id === layerId) || null; } function isLayerEmpty(layer) { if (!layer) { return false; } const hasPolygon = layer.polygonClosed || layer.polygon.length > 0; const hasPath = Array.isArray(layer.path) && layer.path.length > 0; const hasCut = Boolean(layer.objectCut); return !hasPolygon && !hasPath && !hasCut; } function handleSourceFile(event) { const file = event.target.files && event.target.files[0]; if (!file) { return; } state.baseImage = null; updateCanvasPlaceholder(); resetLayers(); setStatus("Loading scene...", "warn"); const reader = new FileReader(); reader.onload = async () => { try { await loadBaseImage(reader.result); applyResolution(); setStatus("Scene ready. Draw the polygon.", "success"); } catch (err) { console.error("Failed to load scene", err); setStatus("Unable to load the selected file.", "error"); } }; reader.onerror = () => setStatus("Unable to read the selected file.", "error"); reader.readAsDataURL(file); } async function loadBaseImage(dataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { state.baseImage = img; updateCanvasPlaceholder(); const width = clamp(Math.round(img.width) || 832, 128, 4096); const height = clamp(Math.round(img.height) || 480, 128, 4096); if (dom.targetWidthInput) { dom.targetWidthInput.value = width; } if (dom.targetHeightInput) { dom.targetHeightInput.value = height; } if (state.layers.length === 0) { addLayer(state.shapeMode); } resolve(); }; img.onerror = reject; img.src = dataUrl; }); } function applyResolution() { const width = clamp(Number(dom.targetWidthInput.value) || 832, 128, 4096); const height = clamp(Number(dom.targetHeightInput.value) || 480, 128, 4096); dom.targetWidthInput.value = width; dom.targetHeightInput.value = height; state.resolution = { width, height }; state.fitMode = dom.fitModeSelect.value; matchCanvasResolution(); if (state.baseImage) { const ctx = state.baseCanvas.getContext("2d"); ctx.clearRect(0, 0, width, height); const ratio = state.fitMode === "cover" ? Math.max(width / state.baseImage.width, height / state.baseImage.height) : Math.min(width / state.baseImage.width, height / state.baseImage.height); const newWidth = state.baseImage.width * ratio; const newHeight = state.baseImage.height * ratio; const offsetX = (width - newWidth) / 2; const offsetY = (height - newHeight) / 2; ctx.drawImage(state.baseImage, offsetX, offsetY, newWidth, newHeight); state.baseCanvasDataUrl = state.baseCanvas.toDataURL("image/png"); } else { state.baseCanvasDataUrl = null; } state.layers.forEach((layer) => resetLayerGeometry(layer)); recomputeBackgroundFill(); updateBadge(); updateActionAvailability(); } function resetLayerGeometry(layer) { layer.polygon = []; layer.polygonClosed = false; layer.polygonPreviewPoint = null; layer.selectedPolygonIndex = -1; layer.path = []; layer.pathLocked = false; layer.selectedPathIndex = -1; layer.localPolygon = []; layer.anchor = { x: 0, y: 0 }; layer.objectCut = null; layer.pathMeta = null; layer.renderPath = []; layer.renderSegmentMap = []; layer.shapeType = layer.shapeType || state.shapeMode || "polygon"; layer.shapeDraft = null; layer.tempPolygon = null; layer.shapeMeta = null; layer.startFrame = 0; layer.endFrame = state.scene.totalFrames; layer.scaleStart = 1; layer.scaleEnd = 1; layer.rotationStart = 0; layer.rotationEnd = 0; layer.speedMode = layer.speedMode || "none"; layer.speedRatio = layer.speedRatio ?? 1; layer.tension = 0; layer.hideOutsideRange = true; updateLayerPathCache(layer); } function finishPolygon() { const layer = getActiveLayer(); if (!layer) { return; } if (layer.polygonClosed) { computeLayerAssets(layer); return; } if (layer.polygon.length < 3) { setStatus("Add at least three points for the polygon.", "warn"); return; } layer.polygonClosed = true; computeLayerAssets(layer); setStatus("Polygon closed. Draw the trajectory.", "success"); enterTrajectoryCreationMode(layer, true); updateBadge(); updateActionAvailability(); } function undoPolygonPoint() { const layer = getActiveLayer(); if (!layer || layer.polygon.length === 0) { return; } layer.polygon.pop(); layer.polygonClosed = false; layer.objectCut = null; recomputeBackgroundFill(); updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); } function resetPolygon() { const layer = getActiveLayer(); if (!layer) { return; } layer.polygon = []; layer.polygonClosed = false; layer.polygonPreviewPoint = null; layer.shapeDraft = null; layer.tempPolygon = null; layer.shapeMeta = null; layer.objectCut = null; recomputeBackgroundFill(); updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); } function computeLayerAssets(layer) { if (!state.baseImage || !layer.polygonClosed || layer.polygon.length < 3) { return; } const bounds = polygonBounds(layer.polygon); const width = Math.max(1, Math.round(bounds.width)); const height = Math.max(1, Math.round(bounds.height)); const cutCanvas = document.createElement("canvas"); cutCanvas.width = width; cutCanvas.height = height; const ctx = cutCanvas.getContext("2d"); ctx.save(); ctx.translate(-bounds.minX, -bounds.minY); drawPolygonPath(ctx, layer.polygon); ctx.clip(); ctx.drawImage(state.baseCanvas, 0, 0); ctx.restore(); layer.localPolygon = layer.polygon.map((pt) => ({ x: pt.x - bounds.minX, y: pt.y - bounds.minY })); const centroid = polygonCentroid(layer.polygon); layer.anchor = { x: centroid.x - bounds.minX, y: centroid.y - bounds.minY }; layer.objectCut = { canvas: cutCanvas }; recomputeBackgroundFill(); updateLayerPathCache(layer); } function recomputeBackgroundFill() { const ctx = state.backgroundCanvas.getContext("2d"); ctx.clearRect(0, 0, state.backgroundCanvas.width, state.backgroundCanvas.height); ctx.drawImage(state.baseCanvas, 0, 0); const closedLayers = state.layers.filter((layer) => layer.polygonClosed && layer.polygon.length >= 3); if (closedLayers.length === 0) { state.backgroundDataUrl = state.backgroundCanvas.toDataURL("image/png"); return; } const maskCanvas = document.createElement("canvas"); maskCanvas.width = state.backgroundCanvas.width; maskCanvas.height = state.backgroundCanvas.height; const maskCtx = maskCanvas.getContext("2d"); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.fillStyle = "#fff"; closedLayers.forEach((layer) => { maskCtx.beginPath(); drawPolygonPath(maskCtx, layer.polygon); maskCtx.fill(); }); const imageData = ctx.getImageData(0, 0, state.backgroundCanvas.width, state.backgroundCanvas.height); const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height); if (state.altBackgroundImage) { try { const tmpCanvas = document.createElement("canvas"); tmpCanvas.width = state.backgroundCanvas.width; tmpCanvas.height = state.backgroundCanvas.height; const tmpCtx = tmpCanvas.getContext("2d"); tmpCtx.fillStyle = "#000"; tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); tmpCtx.drawImage( state.altBackgroundImage, 0, 0, state.altBackgroundImage.width, state.altBackgroundImage.height, 0, 0, tmpCanvas.width, tmpCanvas.height ); const altImageData = tmpCtx.getImageData(0, 0, tmpCanvas.width, tmpCanvas.height); // Blend alt background only where mask is white. const altData = altImageData.data; const maskBuf = maskData.data; const baseData = imageData.data; for (let i = 0; i < maskBuf.length; i += 4) { if (maskBuf[i] > 10) { baseData[i] = altData[i]; baseData[i + 1] = altData[i + 1]; baseData[i + 2] = altData[i + 2]; baseData[i + 3] = 255; } } } catch (err) { console.warn("Failed to apply alt background", err); inpaintImageData(imageData, maskData, { featherPasses: 2, diffusionPasses: 8 }); } } else { inpaintImageData(imageData, maskData, { featherPasses: 2, diffusionPasses: 8 }); } ctx.putImageData(imageData, 0, 0); state.backgroundDataUrl = state.backgroundCanvas.toDataURL("image/png"); } function lockTrajectory() { const layer = getActiveLayer(); if (!layer) { return; } if (layer.path.length < 1) { setStatus("Add at least one node to animate the object.", "warn"); return; } layer.pathLocked = true; updateLayerPathCache(layer); setStatus("Trajectory ready. Preview or export the mask.", "success"); updateBadge(); updateActionAvailability(); } function undoTrajectoryPoint() { const layer = getActiveLayer(); if (!layer || layer.path.length === 0) { return; } layer.path.pop(); updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); } function resetTrajectory() { const layer = getActiveLayer(); if (!layer) { return; } layer.path = []; layer.pathLocked = false; layer.selectedPathIndex = -1; updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); } function handleSceneFpsChange() { if (!dom.sceneFpsInput) { return; } const value = clamp(Number(dom.sceneFpsInput.value) || state.scene.fps, 1, 240); state.scene.fps = value; dom.sceneFpsInput.value = value; } function handleSceneFrameCountChange() { if (!dom.sceneFrameCountInput) { return; } const value = clamp(parseInt(dom.sceneFrameCountInput.value, 10) || state.scene.totalFrames, 1, 5000); // Only adjust and sync if the value actually changed. if (value !== state.scene.totalFrames) { state.scene.totalFrames = value; dom.sceneFrameCountInput.value = value; state.layers.forEach((layer) => { layer.startFrame = clamp(layer.startFrame ?? 0, 0, value); layer.endFrame = clamp(layer.endFrame ?? value, layer.startFrame, value); }); updateLayerAnimationInputs(); } } function syncSceneInputs() { if (dom.sceneFpsInput) { dom.sceneFpsInput.value = state.scene.fps; } if (dom.sceneFrameCountInput) { dom.sceneFrameCountInput.value = state.scene.totalFrames; } } function handleLayerAnimInput(key, rawValue) { const layer = getActiveLayer(); if (!layer) { return; } switch (key) { case "scaleStart": layer.scaleStart = clamp(Number(rawValue) || 1, 0.1, 3); break; case "scaleEnd": layer.scaleEnd = clamp(Number(rawValue) || 1, 0.1, 3); break; case "rotationStart": layer.rotationStart = clamp(Number(rawValue) || 0, -360, 360); break; case "rotationEnd": layer.rotationEnd = clamp(Number(rawValue) || 0, -360, 360); break; case "speedMode": layer.speedMode = rawValue || "none"; break; case "speedRatio": layer.speedRatio = clamp(Number(rawValue) || 1, 1, 100); break; case "tension": layer.tension = clamp(Number(rawValue) || 0, 0, 1); updateLayerPathCache(layer); break; default: break; } updateLayerAnimationInputs(); } function handleLayerFrameInput(key, rawValue) { const layer = getActiveLayer(); if (!layer) { return; } const total = state.scene.totalFrames; if (key === "startFrame") { const value = clamp(Number(rawValue) || 0, 0, total); layer.startFrame = Math.min(value, layer.endFrame ?? total); } else if (key === "endFrame") { const value = clamp(Number(rawValue) || total, 0, total); layer.endFrame = Math.max(value, layer.startFrame ?? 0); } layer.startFrame = clamp(layer.startFrame ?? 0, 0, total); layer.endFrame = clamp(layer.endFrame ?? total, layer.startFrame, total); updateLayerAnimationInputs(); } function handleHideOutsideRangeToggle(enabled) { const layer = getActiveLayer(); if (!layer) { return; } layer.hideOutsideRange = Boolean(enabled); updateLayerAnimationInputs(); } function updateSpeedControlVisibility(layer = getActiveLayer()) { if (!dom.speedModeSelect || !dom.speedRatioRow) { return; } const mode = layer?.speedMode || dom.speedModeSelect.value || "none"; const hide = mode === "none"; dom.speedRatioRow.classList.toggle("is-hidden", hide); if (dom.speedRatioLabel) { dom.speedRatioLabel.classList.toggle("is-hidden", hide); } if (dom.speedRatioInput) { dom.speedRatioInput.disabled = hide || !layer; } } function updateSpeedRatioLabel() { if (!dom.speedRatioValue || !dom.speedRatioInput) { return; } const value = Number(dom.speedRatioInput.value) || 1; dom.speedRatioValue.textContent = `${value.toFixed(0)}x`; } function updateCanvasPlaceholder() { if (!dom.canvasPlaceholder) { return; } const show = !state.baseImage; dom.canvasPlaceholder.classList.toggle("visible", show); if (dom.canvasFooter) { dom.canvasFooter.classList.toggle("hidden-controls", show); } } function handleClassicOutlineChange(value) { const numeric = clamp(Number(value) || DEFAULT_CLASSIC_OUTLINE_WIDTH, 0.5, 10); state.classicOutlineWidth = numeric; updateClassicOutlineControls(); } function updateClassicOutlineControls() { if (!dom.classicOutlineControl) { return; } const width = getClassicOutlineWidth(); const isClassic = state.renderMode === RENDER_MODES.CLASSIC; dom.classicOutlineControl.classList.toggle("is-hidden", !isClassic); if (dom.classicOutlineSlider) { dom.classicOutlineSlider.value = String(width); dom.classicOutlineSlider.disabled = !isClassic; } updateClassicOutlineLabel(width); } function updateClassicOutlineLabel(widthOverride) { if (!dom.classicOutlineValue) { return; } const width = typeof widthOverride === "number" ? widthOverride : getClassicOutlineWidth(); dom.classicOutlineValue.textContent = `${width.toFixed(1)}px`; } function promptUnloadScene(evt) { evt?.preventDefault(); if (!dom.unloadOverlay) { handleUnloadScene(); return; } dom.unloadOverlay.classList.remove("hidden"); setModalOpen(true); } function hideUnloadPrompt() { dom.unloadOverlay?.classList.add("hidden"); setModalOpen(false); } function handleUnloadScene() { if (!state.baseImage && state.layers.every((layer) => !layer.polygonClosed)) { return; } state.baseImage = null; state.backgroundDataUrl = null; state.baseCanvasDataUrl = null; const baseCtx = state.baseCanvas.getContext("2d"); baseCtx?.clearRect(0, 0, state.baseCanvas.width, state.baseCanvas.height); const bgCtx = state.backgroundCanvas.getContext("2d"); bgCtx?.clearRect(0, 0, state.backgroundCanvas.width, state.backgroundCanvas.height); if (dom.sourceChooser) { dom.sourceChooser.value = ""; } resetLayers(); updateCanvasPlaceholder(); setStatus("Scene unloaded. Upload a new image to continue.", "warn"); hideUnloadPrompt(); } function handleTimelineScrub(event) { const value = clamp(Number(event.target.value) || 0, 0, 1); state.animation.playhead = value; if (state.animation.playing) { state.animation.playing = false; dom.playPauseBtn.textContent = "Play"; } } function togglePlayback() { if (state.layers.length === 0) { return; } state.animation.playing = !state.animation.playing; dom.playPauseBtn.textContent = state.animation.playing ? "Pause" : "Play"; if (state.animation.playing) { state.animation.lastTime = performance.now(); requestAnimationFrame(previewTick); } } function togglePreviewMode() { state.previewMode = !state.previewMode; updatePreviewModeUI(); } function updatePreviewModeUI() { if (!dom.previewModeBtn) { return; } dom.previewModeBtn.textContent = state.previewMode ? "Exit Preview" : "Mask Preview"; if (state.previewMode) { dom.previewModeBtn.classList.add("active"); } else { dom.previewModeBtn.classList.remove("active"); } } function previewTick(timestamp) { if (!state.animation.playing) { return; } const delta = (timestamp - state.animation.lastTime) / 1000; state.animation.lastTime = timestamp; const durationSeconds = Math.max(state.scene.totalFrames / Math.max(state.scene.fps, 1), 0.01); state.animation.playhead += delta / durationSeconds; if (state.animation.playhead >= 1) { if (state.animation.loop) { state.animation.playhead = state.animation.playhead % 1; } else { state.animation.playhead = 1; state.animation.playing = false; dom.playPauseBtn.textContent = "Play"; } } dom.timelineSlider.value = state.animation.playhead.toFixed(3); scheduleVisualFrame(previewTick); } function onPointerDown(evt) { if (evt.button !== 0) { return; } if (!state.baseImage) { dom.sourceChooser?.click(); return; } const pt = canvasPointFromEvent(evt); let layer = getActiveLayer(); let stage = getLayerStage(layer); const preferredLayerId = stage === "trajectory" && layer ? layer.id : null; const globalPathHandle = findPathHandleAtPoint(pt, 12, preferredLayerId); if (globalPathHandle) { if (!layer || globalPathHandle.layerId !== layer.id) { setActiveLayer(globalPathHandle.layerId); layer = getActiveLayer(); stage = getLayerStage(layer); } const targetLayer = getActiveLayer(); if (targetLayer) { ensureTrajectoryEditable(targetLayer); targetLayer.selectedPathIndex = globalPathHandle.index; state.dragContext = { type: "path", layerId: targetLayer.id, index: globalPathHandle.index }; } evt.preventDefault(); return; } const pathEdgeHit = stage === "trajectory" && layer && findBaseSegmentIndex(layer, pt, 10) !== -1 ? true : false; const hitLayer = pathEdgeHit ? layer : pickLayerFromPoint(pt); layer = getActiveLayer(); let selectionChanged = false; stage = getLayerStage(layer); const inCreationMode = stage === "polygon"; if (hitLayer) { if (!layer || hitLayer.id !== layer.id) { if (!inCreationMode) { setActiveLayer(hitLayer.id); layer = getActiveLayer(); stage = getLayerStage(layer); selectionChanged = true; } } else { ensureTrajectoryEditable(layer); stage = getLayerStage(layer); } } else if (layer && stage === "locked") { setActiveLayer(null); layer = null; stage = "none"; selectionChanged = true; } // In trajectory mode, handle differently if (state.renderMode === RENDER_MODES.TRAJECTORY) { // If no layer exists, or existing layer is an empty non-trajectory layer, create a trajectory layer const isEmptyRegularLayer = layer && !isTrajectoryOnlyLayer(layer) && isLayerEmpty(layer); if (!layer || isEmptyRegularLayer) { // Remove the empty regular layer if it exists if (isEmptyRegularLayer) { state.layers = state.layers.filter((l) => l.id !== layer.id); } const newLayer = addTrajectoryLayer(pt); setActiveLayer(newLayer.id); layer = newLayer; stage = getLayerStage(layer); selectionChanged = false; evt.preventDefault(); return; } // Existing trajectory layer - add point to it if (isTrajectoryOnlyLayer(layer) && !layer.pathLocked) { addTrajectoryPoint(pt); evt.preventDefault(); return; } } if (!layer) { const newLayer = addLayer(state.shapeMode); setActiveLayer(newLayer.id); layer = newLayer; stage = getLayerStage(layer); selectionChanged = false; } if (selectionChanged && layer && layer.polygonClosed) { enterTrajectoryCreationMode(layer, true); stage = getLayerStage(layer); } ensureTrajectoryEditable(layer); layer.shapeType = layer.shapeType || state.shapeMode || "polygon"; stage = getLayerStage(layer); if (!layer.polygonClosed) { let handledShape = false; if (layer.shapeType === "rectangle") { handledShape = handleRectangleDrawing(layer, pt); } else if (layer.shapeType === "circle") { handledShape = handleCircleDrawing(layer, pt); } if (handledShape) { evt.preventDefault(); return; } } state.dragContext = null; const polygonHandle = getPolygonHandleAtPoint(layer, pt, 10); if (polygonHandle) { layer.selectedPolygonIndex = polygonHandle.index; state.dragContext = { type: "polygon", layerId: layer.id, index: polygonHandle.index, modified: false, handleRole: polygonHandle.role || "corner", edgeIndex: polygonHandle.edgeIndex ?? null, }; if (layer.shapeType === "circle" && layer.shapeMeta?.center) { state.dragContext.circleCenter = { x: layer.shapeMeta.center.x, y: layer.shapeMeta.center.y }; } evt.preventDefault(); return; } if (!layer.polygonClosed) { addPolygonPoint(pt); evt.preventDefault(); return; } const pathIndex = findHandle(layer.path, pt, 12); if (pathIndex !== -1) { layer.selectedPathIndex = pathIndex; state.dragContext = { type: "path", layerId: layer.id, index: pathIndex }; evt.preventDefault(); return; } const canDragLayer = hitLayer && hitLayer.id === layer.id && stage !== "polygon"; if (canDragLayer) { state.dragContext = { type: "layer", layerId: layer.id, start: pt, polygonSnapshot: layer.polygon.map((point) => ({ x: point.x, y: point.y })), pathSnapshot: layer.path.map((point) => ({ x: point.x, y: point.y })), shapeMetaSnapshot: cloneShapeMeta(layer.shapeMeta), moved: false, }; evt.preventDefault(); return; } if (!layer.pathLocked) { if (!selectionChanged) { addTrajectoryPoint(pt); } evt.preventDefault(); } } function onPointerMove(evt) { if (!state.baseImage) { return; } const layer = getActiveLayer(); const pt = canvasPointFromEvent(evt); if (state.dragContext && layer && state.dragContext.layerId === layer.id) { if (state.dragContext.type === "polygon") { if (layer.shapeType === "rectangle" && layer.polygon.length >= 4) { updateRectangleHandleDrag(layer, state.dragContext.index, pt, state.dragContext); state.dragContext.modified = true; } else if (layer.shapeType === "circle" && layer.shapeMeta?.center) { updateCircleHandleDrag(layer, pt, state.dragContext); state.dragContext.modified = true; } else if (layer.polygon[state.dragContext.index]) { const prev = layer.polygon[(state.dragContext.index - 1 + layer.polygon.length) % layer.polygon.length]; layer.polygon[state.dragContext.index] = applyAxisLock(prev, pt, evt.shiftKey); state.dragContext.modified = true; } } else if (state.dragContext.type === "path" && layer.path[state.dragContext.index]) { const prev = layer.path[(state.dragContext.index - 1 + layer.path.length) % layer.path.length]; layer.path[state.dragContext.index] = applyAxisLock(prev, pt, evt.shiftKey); updateLayerPathCache(layer); } else if (state.dragContext.type === "layer" && layer.polygonClosed) { const dx = pt.x - state.dragContext.start.x; const dy = pt.y - state.dragContext.start.y; if (dx === 0 && dy === 0 && !state.dragContext.moved) { evt.preventDefault(); return; } if (dx !== 0 || dy !== 0) { state.dragContext.moved = true; } layer.polygon = state.dragContext.polygonSnapshot.map((point) => ({ x: point.x + dx, y: point.y + dy, })); layer.path = state.dragContext.pathSnapshot.map((point) => ({ x: point.x + dx, y: point.y + dy, })); if (state.dragContext.shapeMetaSnapshot) { layer.shapeMeta = translateShapeMeta(state.dragContext.shapeMetaSnapshot, dx, dy); } updateLayerPathCache(layer); } evt.preventDefault(); } else if (layer && !layer.polygonClosed) { if (layer.shapeType === "rectangle") { updateRectanglePreview(layer, pt); } else if (layer.shapeType === "circle") { updateCirclePreview(layer, pt); } else { layer.tempPolygon = null; layer.polygonPreviewPoint = pt; } } } function onPointerUp() { if (state.dragContext) { const layer = getLayerById(state.dragContext.layerId); if ( state.dragContext.type === "layer" && state.dragContext.moved && layer && layer.polygonClosed && layer.polygon.length >= 3 ) { computeLayerAssets(layer); } else if ( state.dragContext.type === "polygon" && state.dragContext.modified && layer && layer.polygonClosed && layer.polygon.length >= 3 ) { computeLayerAssets(layer); } } state.dragContext = null; } function onCanvasDoubleClick(evt) { const layer = getActiveLayer(); if (!layer) { return; } const point = canvasPointFromEvent(evt); if (removePolygonPoint(layer, point)) { return; } if (removePathPoint(layer, point)) { return; } if (insertPointOnPolygonEdge(layer, point)) { return; } if (insertPointOnPathEdge(layer, point)) { return; } if (!layer.polygonClosed && layer.polygon.length >= 3) { finishPolygon(); } else if (layer.polygonClosed && layer.path.length >= 1 && !layer.pathLocked) { lockTrajectory(); } } function onCanvasContextMenu(evt) { evt.preventDefault(); const layer = getActiveLayer(); if (!layer) { return; } if (!layer.polygonClosed) { if (layer.polygon.length >= 3) { finishPolygon(); state.dragContext = null; return; } if (layer.polygon.length > 0) { resetPolygon(); setStatus("Polygon creation cancelled.", "warn"); } if (isLayerEmpty(layer)) { removeLayer(); } else { setActiveLayer(null); } state.dragContext = null; return; } const pathCount = Array.isArray(layer.path) ? layer.path.length : 0; if (pathCount === 0) { layer.pathLocked = true; setActiveLayer(null); state.dragContext = null; updateBadge(); updateActionAvailability(); return; } if (!layer.pathLocked) { lockTrajectory(); setActiveLayer(null); state.dragContext = null; return; } setActiveLayer(null); state.dragContext = null; } function getLayerStage(layer) { if (!layer) { return "none"; } if (!layer.polygonClosed) { return "polygon"; } if (!layer.pathLocked) { return "trajectory"; } return "locked"; } function ensureTrajectoryEditable(layer) { if (!layer || !layer.polygonClosed) { return; } if (layer.pathLocked && (!Array.isArray(layer.path) || layer.path.length <= 1)) { layer.pathLocked = false; } } function enterTrajectoryCreationMode(layer, resetSelection = false) { if (!layer || !layer.polygonClosed) { return; } if (!Array.isArray(layer.path)) { layer.path = []; } layer.pathLocked = false; if (resetSelection) { layer.selectedPathIndex = -1; } } function handleContextDelete() { if (!getActiveLayer()) { return; } removeLayer(); } function handleContextReset() { const layer = getActiveLayer(); if (!layer) { return; } const stage = getLayerStage(layer); if (stage === "polygon") { resetPolygon(); } else { resetTrajectory(); } } function handleContextUndo() { const layer = getActiveLayer(); if (!layer) { return; } const stage = getLayerStage(layer); if (stage === "polygon") { undoPolygonPoint(); } else if (stage === "trajectory") { undoTrajectoryPoint(); } } function applyAxisLock(reference, target, enable) { if (!enable || !reference) { return { x: target.x, y: target.y }; } const dx = Math.abs(target.x - reference.x); const dy = Math.abs(target.y - reference.y); if (dx > dy) { return { x: target.x, y: reference.y }; } return { x: reference.x, y: target.y }; } function addPolygonPoint(pt) { const layer = getActiveLayer(); if (!layer) { return; } layer.polygon.push(pt); layer.polygonPreviewPoint = null; updateBadge(); updateActionAvailability(); } function addTrajectoryPoint(pt) { const layer = getActiveLayer(); if (!layer || !layer.polygonClosed) { return; } layer.path.push(pt); updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); } function handleRectangleDrawing(layer, point) { if (!layer || layer.shapeType !== "rectangle") { return false; } if (!layer.shapeDraft || layer.shapeDraft.type !== "rectangle") { layer.shapeDraft = { type: "rectangle", start: { x: point.x, y: point.y } }; layer.tempPolygon = null; layer.polygonPreviewPoint = null; return true; } const start = layer.shapeDraft.start; if (!start) { layer.shapeDraft = { type: "rectangle", start: { x: point.x, y: point.y } }; return true; } if (Math.abs(point.x - start.x) < 1 && Math.abs(point.y - start.y) < 1) { return true; } const rectPoints = buildRectanglePoints(start, point); if (rectPoints.length < 4) { return true; } layer.polygon = rectPoints; layer.tempPolygon = null; layer.shapeDraft = null; layer.polygonPreviewPoint = null; layer.shapeMeta = { type: "rectangle", bounds: rectangleBoundsFromPoints(rectPoints), }; finishPolygon(); return true; } function handleCircleDrawing(layer, point) { if (!layer || layer.shapeType !== "circle") { return false; } if (!layer.shapeDraft || layer.shapeDraft.type !== "circle") { layer.shapeDraft = { type: "circle", center: { x: point.x, y: point.y } }; layer.tempPolygon = null; layer.polygonPreviewPoint = null; return true; } const center = layer.shapeDraft.center; if (!center) { layer.shapeDraft = { type: "circle", center: { x: point.x, y: point.y } }; return true; } const radius = Math.max(distance(center, point), 4); layer.polygon = buildCirclePolygon(center, radius); layer.tempPolygon = null; layer.shapeDraft = null; layer.polygonPreviewPoint = null; layer.shapeMeta = { type: "circle", center: { x: center.x, y: center.y }, radius, }; finishPolygon(); return true; } function updateRectanglePreview(layer, point) { if (!layer || layer.shapeType !== "rectangle" || !layer.shapeDraft || layer.shapeDraft.type !== "rectangle") { return; } layer.polygonPreviewPoint = null; layer.tempPolygon = buildRectanglePoints(layer.shapeDraft.start, point); } function updateCirclePreview(layer, point) { if (!layer || layer.shapeType !== "circle" || !layer.shapeDraft || layer.shapeDraft.type !== "circle") { return; } const center = layer.shapeDraft.center; if (!center) { return; } layer.polygonPreviewPoint = null; const radius = Math.max(distance(center, point), 2); layer.tempPolygon = buildCirclePolygon(center, radius); } function updateBadge() { updateWorkflowPanels(); if (!dom.badge) { return; } if (!state.baseImage) { dom.badge.textContent = ""; dom.badge.style.display = "none"; return; } dom.badge.style.display = "block"; const layer = getActiveLayer(); if (!layer) { dom.badge.textContent = "Left click anywhere on the canvas to start a new object."; dom.badge.style.display = "block"; return; } if (!layer.polygonClosed) { dom.badge.textContent = "Left click to draw the polygon. Double click to split edges. Right click to close."; dom.badge.style.display = "block"; return; } if (layer.path.length < 1) { dom.badge.textContent = "Add trajectory points with left click. Right click to lock the path."; dom.badge.style.display = "block"; return; } if (!layer.pathLocked) { dom.badge.textContent = "Continue refining the trajectory or right click to lock it."; dom.badge.style.display = "block"; return; } dom.badge.textContent = "Adjust animation ranges or start another object."; dom.badge.style.display = "block"; } function updateActionAvailability() { const layer = getActiveLayer(); updateContextButtons(layer); } function updateContextButtons(layer) { if (!dom.contextDeleteBtn || !dom.contextResetBtn || !dom.contextUndoBtn) { return; } if (dom.contextButtonGroup) { dom.contextButtonGroup.classList.toggle("is-hidden", !layer); } if (!layer) { dom.contextDeleteBtn.disabled = true; dom.contextResetBtn.disabled = true; dom.contextUndoBtn.disabled = true; dom.contextDeleteBtn.textContent = "Delete"; dom.contextResetBtn.textContent = "Reset"; dom.contextUndoBtn.textContent = "Undo"; return; } const stage = getLayerStage(layer); dom.contextDeleteBtn.disabled = false; dom.contextDeleteBtn.textContent = "Delete Object"; if (stage === "polygon") { dom.contextResetBtn.disabled = layer.polygon.length === 0; dom.contextUndoBtn.disabled = layer.polygon.length === 0; dom.contextResetBtn.textContent = "Reset Polygon"; dom.contextUndoBtn.textContent = "Undo Point"; } else if (stage === "trajectory") { dom.contextResetBtn.disabled = layer.path.length === 0; dom.contextUndoBtn.disabled = layer.path.length === 0; dom.contextResetBtn.textContent = "Reset Trajectory"; dom.contextUndoBtn.textContent = "Undo Point"; } else { dom.contextResetBtn.disabled = false; dom.contextUndoBtn.disabled = true; dom.contextResetBtn.textContent = "Reset Trajectory"; dom.contextUndoBtn.textContent = "Undo Point"; } } function expandPanel(panelId, userInitiated = false) { if (!dom.collapsiblePanels) { return; } dom.collapsiblePanels.forEach((panel) => { const isMatch = panel.dataset.panel === panelId; if (panel.dataset.fixed === "true") { panel.classList.add("expanded"); } else { panel.classList.toggle("expanded", isMatch); } }); state.currentExpandedPanel = panelId; if (userInitiated) { state.userPanelOverride = panelId; } else if (!state.userPanelOverride) { state.userPanelOverride = null; } } function getWorkflowStage() { if (!state.baseImage) { return "scene"; } return "object"; } function updateWorkflowPanels() { if (!dom.collapsiblePanels || dom.collapsiblePanels.length === 0) { return; } const stage = getWorkflowStage(); if (state.currentWorkflowStage !== stage) { state.currentWorkflowStage = stage; state.userPanelOverride = null; } if (!state.userPanelOverride) { expandPanel(stage); } } function layerReadyForExport(layer) { if (!layer) { return false; } // Trajectory-only layers are ready if they have at least one trajectory point if (layer.shapeType === "trajectory") { return Array.isArray(layer.path) && layer.path.length >= 1; } if (!layer.objectCut || !layer.polygonClosed) { return false; } // Allow static objects (no trajectory) as long as their shape is finalized. return true; } function isTrajectoryOnlyLayer(layer) { return layer && layer.shapeType === "trajectory"; } function downloadBackgroundImage() { if (!state.backgroundDataUrl) { setStatus("Background image not ready yet.", "warn"); return; } const link = document.createElement("a"); link.href = state.backgroundDataUrl; link.download = "motion_designer_background.png"; link.click(); } function getExportBackgroundImage(renderMode = state.renderMode) { return state.baseCanvasDataUrl; } function resetTransferState() { state.transfer.pending = false; state.transfer.mask = null; state.transfer.guide = null; state.transfer.backgroundImage = null; } function handleSendToWangp() { // Handle trajectory mode export separately if (state.renderMode === RENDER_MODES.TRAJECTORY) { exportTrajectoryData(); return; } if (state.renderMode === RENDER_MODES.CUT_DRAG) { state.transfer.pending = true; state.transfer.mask = null; state.transfer.guide = null; state.transfer.backgroundImage = getExportBackgroundImage(); } else { resetTransferState(); } setStatus("Preparing motion mask for WanGP...", "info"); const started = startExport("wangp", "mask"); if (!started && state.renderMode === RENDER_MODES.CUT_DRAG) { resetTransferState(); } } function exportTrajectoryData() { const readyLayers = state.layers.filter((layer) => isTrajectoryOnlyLayer(layer) && layerReadyForExport(layer)); if (readyLayers.length === 0) { setStatus("Add at least one trajectory point before exporting.", "warn"); return; } setStatus("Exporting trajectory data...", "info"); const totalFrames = Math.max(1, Math.round(state.scene.totalFrames)); const width = state.resolution.width || 1; const height = state.resolution.height || 1; const numLayers = readyLayers.length; // Build trajectories array: [T, N, 2] where T=frames, N=trajectory count, 2=X,Y (normalized to [0,1]) // Frames outside the layer's [startFrame, endFrame] range get [-1, -1] const trajectories = []; for (let frame = 0; frame < totalFrames; frame++) { const frameData = []; readyLayers.forEach((layer) => { const startFrame = layer.startFrame ?? 0; const endFrame = layer.endFrame ?? totalFrames; // Check if object exists at this frame if (frame < startFrame || frame > endFrame) { frameData.push([-1, -1]); return; } // Compute progress within the layer's active range const rangeFrames = endFrame - startFrame; const progress = rangeFrames === 0 ? 0 : (frame - startFrame) / rangeFrames; const position = computeTrajectoryPosition(layer, progress); if (position) { // Normalize coordinates to [0, 1] range frameData.push([position.x / width, position.y / height]); } else { // Use first point if no position computed const firstPt = layer.path[0] || { x: 0, y: 0 }; frameData.push([firstPt.x / width, firstPt.y / height]); } }); trajectories.push(frameData); } const metadata = { renderMode: "trajectory", width: state.resolution.width, height: state.resolution.height, fps: state.scene.fps, totalFrames: totalFrames, trajectoryCount: numLayers, }; // Get background image for image_start const backgroundImage = getTrajectoryBackgroundImage(); // Send trajectory data to WanGP const success = sendTrajectoryPayload(trajectories, metadata, backgroundImage); if (success) { setStatus("Trajectory data sent to WanGP.", "success"); } } function getTrajectoryBackgroundImage() { if (!state.baseImage) { return null; } // Export the base canvas as data URL try { return state.baseCanvas.toDataURL("image/png"); } catch (err) { console.warn("Failed to export background image", err); return null; } } function computeTrajectoryPosition(layer, progress) { if (!layer || !Array.isArray(layer.path) || layer.path.length === 0) { return null; } // If only one point, return it directly if (layer.path.length === 1) { return { x: layer.path[0].x, y: layer.path[0].y }; } // Use the cached render path for interpolation const renderPath = getRenderPath(layer); if (!renderPath || renderPath.length === 0) { return { x: layer.path[0].x, y: layer.path[0].y }; } if (renderPath.length === 1) { return { x: renderPath[0].x, y: renderPath[0].y }; } // Interpolate along the render path based on progress const meta = layer.pathMeta || computePathMeta(renderPath); if (!meta || meta.total === 0) { return { x: renderPath[0].x, y: renderPath[0].y }; } // Apply speed mode if configured const adjustedProgress = getSpeedProfileProgress(layer, progress); const targetDist = adjustedProgress * meta.total; // meta.lengths is cumulative: [0, dist0to1, dist0to2, ...] // Find the segment where targetDist falls for (let i = 1; i < renderPath.length; i++) { const cumulativeDist = meta.lengths[i]; if (targetDist <= cumulativeDist) { const prevCumulativeDist = meta.lengths[i - 1]; const segLen = cumulativeDist - prevCumulativeDist; const segProgress = segLen > 0 ? (targetDist - prevCumulativeDist) / segLen : 0; const p0 = renderPath[i - 1]; const p1 = renderPath[i]; return { x: p0.x + (p1.x - p0.x) * segProgress, y: p0.y + (p1.y - p0.y) * segProgress, }; } } // Return last point if we've exceeded the path return { x: renderPath[renderPath.length - 1].x, y: renderPath[renderPath.length - 1].y }; } function sendTrajectoryPayload(trajectories, metadata, backgroundImage) { try { window.parent?.postMessage( { type: EVENT_TYPE, trajectoryData: trajectories, metadata, backgroundImage, isTrajectoryExport: true }, "*", ); return true; } catch (err) { console.error("Unable to send trajectory data to WanGP", err); setStatus("Failed to send trajectory data to WanGP.", "error"); return false; } } function startExport(mode, variant = "mask") { const readyLayers = state.layers.filter((layer) => layerReadyForExport(layer)); if (readyLayers.length === 0) { setStatus("Prepare at least one object and trajectory before exporting.", "warn"); return false; } const mimeType = pickSupportedMimeType(); if (!mimeType) { notify("MediaRecorder with VP8/VP9 support is required to export the mask.", "error"); return false; } if (state.export.running) { return false; } const totalFrames = Math.max(1, Math.round(state.scene.totalFrames)); state.export.running = true; state.export.mode = mode; state.export.variant = variant === "guide" ? "guide" : "mask"; state.export.renderMode = state.renderMode; state.export.frame = 0; state.export.totalFrames = totalFrames; state.export.mimeType = mimeType; state.export.chunks = []; state.export.cancelled = false; state.export.canvas = document.createElement("canvas"); state.export.canvas.width = state.resolution.width; state.export.canvas.height = state.resolution.height; state.export.ctx = state.export.canvas.getContext("2d", { alpha: false }); state.export.stream = state.export.canvas.captureStream(Math.max(state.scene.fps, 1)); state.export.track = state.export.stream.getVideoTracks()[0] || null; const pixelCount = state.resolution.width * state.resolution.height; const targetFps = Math.max(state.scene.fps, 1); const targetBitrate = Math.round( Math.min(16_000_000, Math.max(3_000_000, pixelCount * targetFps * 0.12)) ); state.export.recorder = new MediaRecorder(state.export.stream, { mimeType, videoBitsPerSecond: targetBitrate }); state.export.recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { state.export.chunks.push(event.data); } }; state.export.recorder.onstop = finalizeExport; const timeslice = Math.max(10, Math.round(1000 / targetFps)); state.export.recorder.start(timeslice); setModalOpen(true); dom.exportOverlay?.classList.remove("hidden"); dom.exportProgressBar.style.width = "0%"; const labelPrefix = state.export.variant === "guide" ? "Rendering Control Frame" : "Rendering Mask Frame"; dom.exportLabel.textContent = `${labelPrefix} 0 / ${totalFrames}`; renderNextExportFrame(); return true; } function cancelExport() { if (!state.export.running) { dom.exportOverlay?.classList.add("hidden"); setModalOpen(false); return; } state.export.cancelled = true; try { state.export.recorder.stop(); } catch (err) { console.warn("Failed stopping recorder", err); } dom.exportOverlay?.classList.add("hidden"); state.export.running = false; resetTransferState(); setStatus("Export cancelled.", "warn"); setModalOpen(false); } function renderNextExportFrame() { if (!state.export.running || state.export.cancelled) { return; } if (state.export.frame >= state.export.totalFrames) { state.export.running = false; const frameDelayMs = 1000 / Math.max(state.scene.fps, 1); const recorder = state.export.recorder; if (recorder && recorder.state === "recording") { try { // Flush any buffered data before stopping to avoid losing the last frame. recorder.requestData(); } catch (err) { console.warn("Unable to request final data chunk", err); } setTimeout(() => { try { if (recorder.state === "recording") { recorder.stop(); } } catch (err) { console.warn("Unable to stop recorder", err); } }, Math.max(50, frameDelayMs * 1.5)); } else { try { recorder?.stop(); } catch (err) { console.warn("Unable to stop recorder", err); } } return; } const progress = state.export.totalFrames === 1 ? 0 : state.export.frame / (state.export.totalFrames - 1); if (state.export.variant === "guide") { drawGuideFrame(state.export.ctx, progress); } else { drawMaskFrame(state.export.ctx, progress); } if (state.export.track && typeof state.export.track.requestFrame === "function") { try { state.export.track.requestFrame(); } catch (err) { console.warn("requestFrame failed", err); } } const percentage = ((state.export.frame + 1) / state.export.totalFrames) * 100; dom.exportProgressBar.style.width = `${percentage}%`; const labelPrefix = state.export.variant === "guide" ? "Rendering Control Frame" : "Rendering Mask Frame"; dom.exportLabel.textContent = `${labelPrefix} ${state.export.frame + 1} / ${state.export.totalFrames}`; state.export.frame += 1; const frameDelay = 1000 / Math.max(state.scene.fps, 1); setTimeout(renderNextExportFrame, frameDelay); } function serializeLayer(layer) { if (!layer) { return null; } const clonePoints = (pts) => (Array.isArray(pts) ? pts.map((p) => ({ x: p.x, y: p.y })) : []); const serializeShapeMeta = (meta) => { if (!meta) return null; if (meta.type === "rectangle" && meta.bounds) { return { type: "rectangle", bounds: { ...meta.bounds } }; } if (meta.type === "circle" && meta.center) { return { type: "circle", center: { ...meta.center }, radius: meta.radius }; } return null; }; return { id: layer.id, name: layer.name, color: layer.color, polygon: clonePoints(layer.polygon), polygonClosed: !!layer.polygonClosed, path: clonePoints(layer.path), pathLocked: !!layer.pathLocked, shapeType: layer.shapeType || "polygon", shapeMeta: serializeShapeMeta(layer.shapeMeta), startFrame: layer.startFrame ?? 0, endFrame: layer.endFrame ?? state.scene.totalFrames, scaleStart: layer.scaleStart ?? 1, scaleEnd: layer.scaleEnd ?? 1, rotationStart: layer.rotationStart ?? 0, rotationEnd: layer.rotationEnd ?? 0, speedMode: layer.speedMode || "none", speedRatio: layer.speedRatio ?? 1, tension: layer.tension ?? 0, hideOutsideRange: !!layer.hideOutsideRange, }; } function deserializeLayer(def, colorIndex) { const layer = createLayer(def.name || `Object ${colorIndex + 1}`, colorIndex, def.shapeType || "polygon"); layer.id = def.id || layer.id; layer.color = def.color || layer.color; layer.polygon = Array.isArray(def.polygon) ? def.polygon.map((p) => ({ x: p.x, y: p.y })) : []; layer.polygonClosed = !!def.polygonClosed; layer.path = Array.isArray(def.path) ? def.path.map((p) => ({ x: p.x, y: p.y })) : []; layer.pathLocked = !!def.pathLocked; layer.shapeType = def.shapeType || layer.shapeType; layer.shapeMeta = def.shapeMeta || null; layer.startFrame = clamp(def.startFrame ?? 0, 0, state.scene.totalFrames); layer.endFrame = clamp(def.endFrame ?? state.scene.totalFrames, layer.startFrame, state.scene.totalFrames); layer.scaleStart = def.scaleStart ?? 1; layer.scaleEnd = def.scaleEnd ?? 1; layer.rotationStart = def.rotationStart ?? 0; layer.rotationEnd = def.rotationEnd ?? 0; layer.speedMode = def.speedMode || "none"; layer.speedRatio = def.speedRatio ?? 1; layer.tension = def.tension ?? 0; layer.hideOutsideRange = !!def.hideOutsideRange; updateLayerPathCache(layer); if (state.baseImage && layer.polygonClosed && layer.polygon.length >= 3) { computeLayerAssets(layer); } return layer; } function buildDefinition() { return { resolution: { ...state.resolution }, fitMode: state.fitMode, renderMode: state.renderMode, altBackground: state.altBackgroundDataUrl || null, showPatchedBackground: state.showPatchedBackground, classicOutlineWidth: state.classicOutlineWidth, scene: { fps: state.scene.fps, totalFrames: state.scene.totalFrames }, layers: state.layers.map(serializeLayer).filter(Boolean), }; } function applyDefinition(def) { if (!def || typeof def !== "object") { notify("Invalid definition file.", "error"); return; } if (!state.baseImage) { notify("Load a base image first, then load the definition.", "warn"); return; } try { if (def.resolution && def.resolution.width && def.resolution.height) { state.resolution = { width: clamp(Number(def.resolution.width) || state.resolution.width, 128, 4096), height: clamp(Number(def.resolution.height) || state.resolution.height, 128, 4096), }; if (dom.targetWidthInput) dom.targetWidthInput.value = state.resolution.width; if (dom.targetHeightInput) dom.targetHeightInput.value = state.resolution.height; matchCanvasResolution(); } if (def.fitMode) { state.fitMode = def.fitMode; if (dom.fitModeSelect) dom.fitModeSelect.value = state.fitMode; applyResolution(); } if (def.scene) { state.scene.fps = clamp(Number(def.scene.fps) || state.scene.fps, 1, 240); state.scene.totalFrames = clamp(Number(def.scene.totalFrames) || state.scene.totalFrames, 1, 5000); syncSceneInputs(); } if (def.renderMode) { setRenderMode(def.renderMode); } if (def.altBackground) { loadAltBackground(def.altBackground); } else { clearAltBackground(false); } if (typeof def.showPatchedBackground === "boolean") { state.showPatchedBackground = def.showPatchedBackground; } if (typeof def.classicOutlineWidth === "number") { state.classicOutlineWidth = def.classicOutlineWidth; } state.layers = []; const layers = Array.isArray(def.layers) ? def.layers : []; layers.forEach((ldef, idx) => { const layer = deserializeLayer(ldef, idx); state.layers.push(layer); }); state.activeLayerId = state.layers[0]?.id || null; recomputeBackgroundFill(); refreshLayerSelect(); updateLayerAnimationInputs(); updateBadge(); updateActionAvailability(); updateCanvasPlaceholder(); updateClassicOutlineControls(); } catch (err) { console.warn("Failed to apply definition", err); notify("Failed to load definition.", "error"); } } function handleSaveDefinition() { const def = buildDefinition(); const blob = new Blob([JSON.stringify(def, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "motion_designer_definition.json"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } function handleDefinitionFileSelected(event) { const file = event.target?.files?.[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(String(reader.result || "{}")); applyDefinition(parsed); } catch (err) { console.warn("Failed to parse definition", err); notify("Invalid definition file.", "error"); } }; reader.readAsText(file); } function loadAltBackground(dataUrl) { if (!dataUrl) { clearAltBackground(false); return; } const img = new Image(); img.onload = () => { state.altBackgroundImage = img; state.altBackgroundDataUrl = dataUrl; state.showPatchedBackground = true; updateBackgroundToggleUI(); recomputeBackgroundFill(); }; img.onerror = () => { clearAltBackground(true); }; img.src = dataUrl; } function clearAltBackground(shouldAlert = false) { state.altBackgroundImage = null; state.altBackgroundDataUrl = null; updateBackgroundToggleUI(); recomputeBackgroundFill(); if (shouldAlert) { notify("Alternative background failed to load. Restoring inpainted fill.", "warn"); } } function handleBackgroundFileSelected(event) { const file = event.target?.files?.[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = () => { loadAltBackground(String(reader.result || "")); }; reader.readAsDataURL(file); } function handleBackgroundToggle(evt) { evt?.preventDefault(); if (state.renderMode !== RENDER_MODES.CUT_DRAG) { notify("Setting an alternate background is only available in Cut & Drag mode.", "warn"); return; } if (!state.baseImage) { notify("Load a base image first, then set an alternate background.", "warn"); return; } if (state.altBackgroundImage) { clearAltBackground(false); } else if (dom.backgroundFileInput) { dom.backgroundFileInput.value = ""; dom.backgroundFileInput.click(); } } function updateBackgroundToggleUI() { if (!dom.backgroundToggleBtn) { return; } const hasAlt = !!state.altBackgroundImage; const show = state.renderMode === RENDER_MODES.CUT_DRAG; dom.backgroundToggleBtn.classList.toggle("is-hidden", !show); dom.backgroundToggleBtn.textContent = hasAlt ? "Clear Background" : "Set Default Background"; } function finalizeExport() { dom.exportOverlay?.classList.add("hidden"); const blob = new Blob(state.export.chunks, { type: state.export.mimeType || "video/webm" }); state.export.running = false; if (state.export.cancelled) { state.export.chunks = []; setModalOpen(false); return; } const durationSeconds = state.scene.totalFrames / Math.max(state.scene.fps, 1); prepareExportBlob(blob, durationSeconds); setModalOpen(false); } function buildExportMetadata() { return { mimeType: state.export.mimeType || "video/webm", width: state.resolution.width, height: state.resolution.height, fps: state.scene.fps, duration: state.scene.totalFrames / Math.max(state.scene.fps, 1), totalFrames: state.export.totalFrames, renderedFrames: state.export.frame, expectedFrames: state.export.totalFrames, renderMode: state.export.renderMode, variant: state.export.variant, }; } async function prepareExportBlob(blob, durationSeconds) { const preparedBlob = blob; if (state.export.mode === "download") { const link = document.createElement("a"); link.href = URL.createObjectURL(preparedBlob); link.download = "motion_designer_mask.webm"; link.click(); setStatus("Mask video downloaded.", "success"); state.export.chunks = []; return; } const reader = new FileReader(); reader.onloadend = () => { try { const dataUrl = reader.result || ""; const payload = typeof dataUrl === "string" ? dataUrl.split(",")[1] || "" : ""; const metadata = buildExportMetadata(); handleWanGPExportPayload(payload, metadata); } catch (err) { console.error("Unable to forward mask to WanGP", err); setStatus("Failed to send mask to WanGP.", "error"); resetTransferState(); } finally { state.export.chunks = []; } }; reader.readAsDataURL(preparedBlob); } function handleWanGPExportPayload(payload, metadata) { if (state.export.renderMode === RENDER_MODES.CUT_DRAG && state.transfer.pending) { if (state.export.variant === "mask") { state.transfer.mask = { payload, metadata }; if (!state.transfer.backgroundImage) { state.transfer.backgroundImage = getExportBackgroundImage(state.export.renderMode); } setStatus("Mask ready. Rendering motion preview...", "info"); startExport("wangp", "guide"); return; } if (state.export.variant === "guide") { state.transfer.guide = { payload, metadata }; if (!dispatchPendingTransfer()) { console.warn("Unable to dispatch Motion Designer transfer. Missing mask or metadata."); } return; } } const success = sendMotionDesignerPayload({ payload, metadata, backgroundImage: getExportBackgroundImage(state.export.renderMode), }); if (success) { resetTransferState(); setStatus("Mask sent to WanGP.", "success"); } } function dispatchPendingTransfer() { if (!state.transfer.pending || !state.transfer.mask || !state.transfer.guide) { return false; } const success = sendMotionDesignerPayload({ payload: state.transfer.mask.payload, metadata: state.transfer.mask.metadata, backgroundImage: state.transfer.backgroundImage || getExportBackgroundImage(state.export.renderMode), guidePayload: state.transfer.guide.payload, guideMetadata: state.transfer.guide.metadata, }); if (success) { setStatus("Mask and preview sent to WanGP.", "success"); resetTransferState(); } return success; } function sendMotionDesignerPayload({ payload, metadata, backgroundImage, guidePayload = null, guideMetadata = null }) { try { window.parent?.postMessage( { type: EVENT_TYPE, payload, metadata, backgroundImage, guidePayload, guideMetadata }, "*", ); return true; } catch (err) { console.error("Unable to forward mask to WanGP", err); setStatus("Failed to send mask to WanGP.", "error"); return false; } } function pickSupportedMimeType() { if (!window.MediaRecorder) { return null; } for (const mime of MIME_CANDIDATES) { if (MediaRecorder.isTypeSupported(mime)) { return mime; } } return null; } function drawMaskFrame(ctx, progress, options = {}) { const { fillBackground = false, backgroundColor = "#000", fillColor = "white" } = options; const currentRenderMode = state.export.running ? state.export.renderMode : state.renderMode; const outlineMode = currentRenderMode === RENDER_MODES.CLASSIC; const isTrajectoryMode = currentRenderMode === RENDER_MODES.TRAJECTORY; const outlineWidth = outlineMode ? getClassicOutlineWidth() : 0; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); state.layers .filter((layer) => layerReadyForExport(layer)) .forEach((layer, index) => { // Handle trajectory-only layers differently if (isTrajectoryOnlyLayer(layer)) { if (isTrajectoryMode) { drawTrajectoryMarker(ctx, layer, progress, index, fillColor); } return; } const transform = computeTransform(layer, progress); if (!transform || layer.localPolygon.length === 0) { return; } ctx.save(); ctx.translate(transform.position.x, transform.position.y); ctx.rotate((transform.rotation * Math.PI) / 180); ctx.scale(transform.scale, transform.scale); ctx.translate(-layer.anchor.x, -layer.anchor.y); if (outlineMode) { ctx.strokeStyle = fillColor; ctx.lineWidth = outlineWidth; ctx.lineJoin = "round"; ctx.lineCap = "round"; drawLocalPolygon(ctx, layer.localPolygon); ctx.stroke(); } else { ctx.fillStyle = fillColor; drawLocalPolygon(ctx, layer.localPolygon); ctx.fill(); } ctx.restore(); }); } function drawTrajectoryMarker(ctx, layer, progress, index, fillColor) { const position = computeTrajectoryPosition(layer, progress); if (!position) { return; } const markerRadius = 5; const color = layer.color || COLOR_POOL[index % COLOR_POOL.length]; ctx.save(); // Draw filled circle with the layer color for visibility ctx.beginPath(); ctx.arc(position.x, position.y, markerRadius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.restore(); } function drawGuideFrame(ctx, progress) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); if (!state.baseImage) { ctx.fillStyle = state.theme === "dark" ? "#000000" : "#ffffff"; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); return; } const currentRenderMode = state.export.running ? state.export.renderMode : state.renderMode; const useClassic = currentRenderMode === RENDER_MODES.CLASSIC; const usePatchedBackground = !useClassic && state.showPatchedBackground && state.backgroundDataUrl; if (usePatchedBackground) { ctx.drawImage(state.backgroundCanvas, 0, 0); } else { ctx.drawImage(state.baseCanvas, 0, 0); } drawPreviewObjects(ctx, { progress }); } function render() { const ctx = dom.ctx; if (state.previewMode) { // In trajectory mode, show base image with animated markers if (state.renderMode === RENDER_MODES.TRAJECTORY) { drawTrajectoryPreview(ctx, state.animation.playhead); } else { drawMaskFrame(ctx, state.animation.playhead, { fillBackground: true, backgroundColor: "#04060b", fillColor: "white", }); } } else { ctx.clearRect(0, 0, dom.canvas.width, dom.canvas.height); if (state.baseImage) { const useClassic = state.renderMode === RENDER_MODES.CLASSIC; const usePatchedBackground = !useClassic && state.showPatchedBackground && state.backgroundDataUrl; if (usePatchedBackground) { ctx.drawImage(state.backgroundCanvas, 0, 0); } else { ctx.drawImage(state.baseCanvas, 0, 0); } drawPolygonOverlay(ctx); drawPreviewObjects(ctx); drawTrajectoryOverlay(ctx); } else { ctx.fillStyle = state.theme === "dark" ? "#000000" : "#ffffff"; ctx.fillRect(0, 0, dom.canvas.width, dom.canvas.height); } } scheduleVisualFrame(render); } function scheduleVisualFrame(callback) { if (shouldRunHotRenderLoop()) { requestAnimationFrame(callback); return; } window.setTimeout(() => callback(performance.now()), BACKGROUND_RENDER_DELAY_MS); } function shouldRunHotRenderLoop() { if (typeof document.visibilityState === "string" && document.visibilityState !== "visible") { return false; } if (!dom.canvas || !dom.canvas.isConnected) { return false; } const rect = dom.canvas.getBoundingClientRect(); return rect.width > 2 && rect.height > 2; } function drawTrajectoryPreview(ctx, progress) { // Black background like other preview modes ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = "#04060b"; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); // Draw white dots at current position for each trajectory state.layers .filter((layer) => isTrajectoryOnlyLayer(layer) && layerReadyForExport(layer)) .forEach((layer) => { const position = computeTrajectoryPosition(layer, progress); if (!position) { return; } ctx.save(); ctx.beginPath(); ctx.arc(position.x, position.y, 5, 0, Math.PI * 2); ctx.fillStyle = "white"; ctx.fill(); ctx.restore(); }); } function drawPolygonOverlay(ctx) { state.layers.forEach((layer) => { const points = getPolygonDisplayPoints(layer); if (!points || points.length === 0) { return; } const isClosed = layer.polygonClosed || (!layer.polygonClosed && layer.shapeType !== "polygon" && points.length >= 3); ctx.save(); ctx.strokeStyle = POLYGON_EDGE_COLOR; ctx.globalAlpha = layer.id === state.activeLayerId ? 1 : 0.45; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } if (isClosed) { ctx.closePath(); if (layer.polygonClosed && points.length > 2) { ctx.fillStyle = "rgba(255,77,87,0.08)"; ctx.fill(); } } else if (layer.shapeType === "polygon" && layer.polygonPreviewPoint) { ctx.lineTo(layer.polygonPreviewPoint.x, layer.polygonPreviewPoint.y); } ctx.stroke(); const handles = getPolygonHandlePoints(layer, points); handles.forEach((handle) => { ctx.beginPath(); ctx.arc(handle.x, handle.y, 6, 0, Math.PI * 2); ctx.fillStyle = handle.index === layer.selectedPolygonIndex ? "#ff9aa6" : "#081320"; ctx.fill(); ctx.strokeStyle = POLYGON_EDGE_COLOR; ctx.stroke(); }); ctx.restore(); }); } function drawTrajectoryOverlay(ctx) { const isTrajectoryMode = state.renderMode === RENDER_MODES.TRAJECTORY; const isPlaying = state.animation.playing; const playhead = state.animation.playhead; state.layers.forEach((layer, layerIndex) => { const points = getRenderPath(layer); if (!points || points.length === 0) { return; } const showPath = points.length > 1; const isActive = layer.id === state.activeLayerId; ctx.save(); ctx.strokeStyle = TRAJECTORY_EDGE_COLOR; ctx.globalAlpha = isActive ? 0.95 : 0.5; ctx.lineWidth = 2; if (showPath) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.stroke(); } // Draw anchor indicator (start point) ctx.beginPath(); ctx.arc(points[0].x, points[0].y, 5, 0, Math.PI * 2); ctx.fillStyle = isActive ? "#57f0b7" : "#1a2b3d"; ctx.fill(); ctx.strokeStyle = TRAJECTORY_EDGE_COLOR; ctx.stroke(); // Draw user nodes layer.path.forEach((point, index) => { ctx.beginPath(); ctx.arc(point.x, point.y, 6, 0, Math.PI * 2); ctx.fillStyle = index === layer.selectedPathIndex ? "#ffd69b" : "#1e1507"; ctx.fill(); ctx.strokeStyle = "#ffbe7a"; ctx.stroke(); }); // Draw animated position marker when playing (in trajectory mode) if (isTrajectoryMode && (isPlaying || playhead > 0) && isTrajectoryOnlyLayer(layer) && layerReadyForExport(layer)) { const position = computeTrajectoryPosition(layer, playhead); if (position) { const color = layer.color || COLOR_POOL[layerIndex % COLOR_POOL.length]; // Draw animated marker with glow ctx.globalAlpha = 1; ctx.shadowColor = color; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(position.x, position.y, 8, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); // Inner dot ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(position.x, position.y, 3, 0, Math.PI * 2); ctx.fillStyle = "#ffffff"; ctx.fill(); } } ctx.restore(); }); } function getRenderPath(layer) { if (!layer) { return []; } if (Array.isArray(layer.renderPath) && layer.renderPath.length > 0) { return layer.renderPath; } return getEffectivePath(layer); } function getSceneFrameCount() { const value = Number(state.scene.totalFrames); return Math.max(1, Number.isFinite(value) ? value : 1); } function progressToFrame(progress) { return clamp(progress, 0, 1) * getSceneFrameCount(); } function getLayerFrameWindow(layer) { const total = getSceneFrameCount(); const rawStart = layer && typeof layer.startFrame === "number" ? layer.startFrame : 0; const rawEnd = layer && typeof layer.endFrame === "number" ? layer.endFrame : total; const start = clamp(rawStart, 0, total); const end = clamp(rawEnd, start, total); return { start, end }; } function getLayerTimelineProgress(layer, timelineProgress) { if (!layer) { return null; } const { start, end } = getLayerFrameWindow(layer); const currentFrame = progressToFrame(timelineProgress); const hideOutside = layer.hideOutsideRange !== false; if (currentFrame < start) { return hideOutside ? null : 0; } if (currentFrame > end) { return hideOutside ? null : 1; } const span = Math.max(end - start, 0.0001); return clamp((currentFrame - start) / span, 0, 1); } function drawPreviewObjects(ctx, options = {}) { const previewProgress = typeof options.progress === "number" ? clamp(options.progress, 0, 1) : state.animation.playhead; const useClassic = state.renderMode === RENDER_MODES.CLASSIC; const outlineWidth = useClassic ? getClassicOutlineWidth() : 0; state.layers.forEach((layer) => { if (!layer.objectCut) { return; } const hasPath = Array.isArray(layer.path) && layer.path.length > 0; if (!layer.pathLocked && !hasPath) { return; } const transform = computeTransform(layer, previewProgress); if (!transform) { return; } ctx.save(); ctx.translate(transform.position.x, transform.position.y); ctx.rotate((transform.rotation * Math.PI) / 180); ctx.scale(transform.scale, transform.scale); ctx.translate(-layer.anchor.x, -layer.anchor.y); if (useClassic) { if (!Array.isArray(layer.localPolygon) || layer.localPolygon.length === 0) { ctx.restore(); return; } ctx.globalAlpha = 1; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = outlineWidth; ctx.lineJoin = "round"; ctx.lineCap = "round"; drawLocalPolygon(ctx, layer.localPolygon); ctx.stroke(); } else { ctx.globalAlpha = 0.92; ctx.drawImage(layer.objectCut.canvas, 0, 0); } ctx.restore(); }); } function computeTransform(layer, progress) { if (!layer || !layer.objectCut) { return null; } const points = getRenderPath(layer); if (!points || points.length === 0) { return null; } const localProgress = getLayerTimelineProgress(layer, progress); if (localProgress === null) { return null; } const speedProgress = getSpeedProfileProgress(layer, localProgress); const meta = layer.pathMeta || computePathMeta(points) || { lengths: [0], total: 0 }; let position; if (points.length === 1 || !meta || meta.total === 0) { position = points[0]; } else { const target = speedProgress * meta.total; let idx = 0; while (idx < meta.lengths.length - 1 && target > meta.lengths[idx + 1]) { idx++; } const segStart = points[idx]; const segEnd = points[Math.min(idx + 1, points.length - 1)]; const segStartDist = meta.lengths[idx]; const segLen = Math.max(meta.lengths[idx + 1] - segStartDist, 0.0001); const t = (target - segStartDist) / segLen; position = { x: lerp(segStart.x, segEnd.x, t), y: lerp(segStart.y, segEnd.y, t) }; } const scaleStart = typeof layer.scaleStart === "number" ? layer.scaleStart : 1; const scaleEnd = typeof layer.scaleEnd === "number" ? layer.scaleEnd : 1; const rotationStart = typeof layer.rotationStart === "number" ? layer.rotationStart : 0; const rotationEnd = typeof layer.rotationEnd === "number" ? layer.rotationEnd : 0; const scale = lerp(scaleStart, scaleEnd, speedProgress); const rotation = lerp(rotationStart, rotationEnd, speedProgress); return { position, scale, rotation }; } function getSpeedProfileProgress(layer, progress) { const ratio = clamp(Number(layer?.speedRatio) || 1, 1, 100); const mode = layer?.speedMode || "accelerate"; const segments = buildSpeedSegments(mode, ratio); return evaluateSpeedSegments(clamp(progress, 0, 1), segments); } function buildSpeedSegments(mode, ratio) { const minSpeed = 1; const maxSpeed = ratio; switch (mode) { case "none": return []; case "decelerate": return [{ duration: 1, start: maxSpeed, end: minSpeed }]; case "accelerate-decelerate": return [ { duration: 0.5, start: minSpeed, end: maxSpeed }, { duration: 0.5, start: maxSpeed, end: minSpeed }, ]; case "decelerate-accelerate": return [ { duration: 0.5, start: maxSpeed, end: minSpeed }, { duration: 0.5, start: minSpeed, end: maxSpeed }, ]; case "accelerate": default: return [{ duration: 1, start: minSpeed, end: maxSpeed }]; } } function evaluateSpeedSegments(progress, segments) { if (!Array.isArray(segments) || segments.length === 0) { return progress; } const totalArea = segments.reduce((sum, seg) => sum + seg.duration * ((seg.start + seg.end) / 2), 0) || 1; let accumulatedTime = 0; let accumulatedArea = 0; for (const seg of segments) { const segEndTime = accumulatedTime + seg.duration; if (progress >= segEndTime) { accumulatedArea += seg.duration * ((seg.start + seg.end) / 2); accumulatedTime = segEndTime; continue; } const localDuration = seg.duration; const localT = localDuration === 0 ? 0 : (progress - accumulatedTime) / localDuration; const areaInSegment = localDuration * (seg.start * localT + 0.5 * (seg.end - seg.start) * localT * localT); return (accumulatedArea + areaInSegment) / totalArea; } return 1; } function getClassicOutlineWidth() { const width = typeof state.classicOutlineWidth === "number" ? state.classicOutlineWidth : DEFAULT_CLASSIC_OUTLINE_WIDTH; return clamp(width, 0.5, 10); } function buildRectanglePoints(a, b) { if (!a || !b) { return []; } const minX = Math.min(a.x, b.x); const maxX = Math.max(a.x, b.x); const minY = Math.min(a.y, b.y); const maxY = Math.max(a.y, b.y); return [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }, ]; } function rectangleBoundsFromPoints(points) { if (!Array.isArray(points) || points.length === 0) { return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; } const bounds = polygonBounds(points); return { minX: bounds.minX, minY: bounds.minY, maxX: bounds.minX + bounds.width, maxY: bounds.minY + bounds.height, }; } function buildCirclePolygon(center, radius, segments = 48) { if (!center) { return []; } const segCount = Math.max(16, segments); const pts = []; for (let i = 0; i < segCount; i++) { const angle = (i / segCount) * Math.PI * 2; pts.push({ x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, }); } return pts; } function buildCircleHandles(center, radius) { if (!center) { return []; } const r = Math.max(radius, 4); return [ { x: center.x + r, y: center.y, index: 0 }, { x: center.x, y: center.y + r, index: 1 }, { x: center.x - r, y: center.y, index: 2 }, { x: center.x, y: center.y - r, index: 3 }, ]; } function inpaintImageData(imageData, maskData, options = {}) { if (!imageData || !maskData) { return; } const width = imageData.width; const height = imageData.height; const totalPixels = width * height; if (!width || !height || totalPixels === 0) { return; } const config = typeof options === "object" && options !== null ? options : {}; const featherPasses = clamp(Math.round(config.featherPasses ?? 2), 0, 5); const diffusionPasses = clamp(Math.round(config.diffusionPasses ?? 8), 0, 20); const mask = new Uint8Array(totalPixels); const maskBuffer = maskData.data; let hasMaskedPixels = false; for (let i = 0; i < totalPixels; i++) { const masked = maskBuffer[i * 4 + 3] > 10; mask[i] = masked ? 1 : 0; hasMaskedPixels = hasMaskedPixels || masked; } if (!hasMaskedPixels) { return; } const source = imageData.data; const output = new Uint8ClampedArray(source); const dist = new Float32Array(totalPixels); const sumR = new Float32Array(totalPixels); const sumG = new Float32Array(totalPixels); const sumB = new Float32Array(totalPixels); const sumW = new Float32Array(totalPixels); const INF = 1e9; for (let idx = 0; idx < totalPixels; idx++) { const offset = idx * 4; if (!mask[idx]) { dist[idx] = 0; sumR[idx] = source[offset]; sumG[idx] = source[offset + 1]; sumB[idx] = source[offset + 2]; sumW[idx] = 1; } else { dist[idx] = INF; sumR[idx] = 0; sumG[idx] = 0; sumB[idx] = 0; sumW[idx] = 0; } } const diagCost = 1.41421356237; const forwardNeighbors = [ { dx: -1, dy: 0, cost: 1 }, { dx: 0, dy: -1, cost: 1 }, { dx: -1, dy: -1, cost: diagCost }, { dx: 1, dy: -1, cost: diagCost }, ]; const backwardNeighbors = [ { dx: 1, dy: 0, cost: 1 }, { dx: 0, dy: 1, cost: 1 }, { dx: 1, dy: 1, cost: diagCost }, { dx: -1, dy: 1, cost: diagCost }, ]; function relax(idx, nIdx, cost) { const cand = dist[nIdx] + cost; const tolerance = 1e-3; if (cand + tolerance < dist[idx]) { dist[idx] = cand; sumR[idx] = sumR[nIdx]; sumG[idx] = sumG[nIdx]; sumB[idx] = sumB[nIdx]; sumW[idx] = sumW[nIdx]; } else if (Math.abs(cand - dist[idx]) <= tolerance) { sumR[idx] += sumR[nIdx]; sumG[idx] += sumG[nIdx]; sumB[idx] += sumB[nIdx]; sumW[idx] += sumW[nIdx]; } } // Two full passes (forward + backward) to approximate an 8-connected distance field carrying color. for (let pass = 0; pass < 2; pass++) { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; forwardNeighbors.forEach(({ dx, dy, cost }) => { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) { return; } const nIdx = ny * width + nx; if (dist[nIdx] + cost < dist[idx]) { relax(idx, nIdx, cost); } }); } } for (let y = height - 1; y >= 0; y--) { for (let x = width - 1; x >= 0; x--) { const idx = y * width + x; backwardNeighbors.forEach(({ dx, dy, cost }) => { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) { return; } const nIdx = ny * width + nx; if (dist[nIdx] + cost < dist[idx]) { relax(idx, nIdx, cost); } }); } } } for (let idx = 0; idx < totalPixels; idx++) { if (!mask[idx]) { continue; } const offset = idx * 4; const w = sumW[idx] > 0 ? sumW[idx] : 1; output[offset] = sumR[idx] / w; output[offset + 1] = sumG[idx] / w; output[offset + 2] = sumB[idx] / w; output[offset + 3] = 255; } const neighborDeltas = [ [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1], ]; if (featherPasses > 0) { const temp = new Uint8ClampedArray(output.length); for (let pass = 0; pass < featherPasses; pass++) { temp.set(output); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; if (!mask[idx]) { continue; } let r = 0; let g = 0; let b = 0; let count = 0; for (let i = 0; i < neighborDeltas.length; i++) { const dx = neighborDeltas[i][0]; const dy = neighborDeltas[i][1]; const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) { continue; } const nIdx = ny * width + nx; const offset = nIdx * 4; r += temp[offset]; g += temp[offset + 1]; b += temp[offset + 2]; count++; } if (count > 0) { const offset = idx * 4; output[offset] = r / count; output[offset + 1] = g / count; output[offset + 2] = b / count; output[offset + 3] = 255; } } } } } if (diffusionPasses > 0) { const temp = new Uint8ClampedArray(output.length); for (let pass = 0; pass < diffusionPasses; pass++) { temp.set(output); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; if (!mask[idx]) { continue; } let r = 0; let g = 0; let b = 0; let count = 0; for (let i = 0; i < neighborDeltas.length; i++) { const dx = neighborDeltas[i][0]; const dy = neighborDeltas[i][1]; const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) { continue; } const nIdx = ny * width + nx; const offset = nIdx * 4; r += temp[offset]; g += temp[offset + 1]; b += temp[offset + 2]; count++; } if (count > 0) { const offset = idx * 4; output[offset] = r / count; output[offset + 1] = g / count; output[offset + 2] = b / count; output[offset + 3] = 255; } } } } } imageData.data.set(output); } function getPolygonDisplayPoints(layer) { if (!layer) { return []; } if (!layer.polygonClosed && Array.isArray(layer.tempPolygon) && layer.tempPolygon.length > 0) { return layer.tempPolygon; } return Array.isArray(layer.polygon) ? layer.polygon : []; } function getPolygonHandlePoints(layer, displayPoints = []) { if (!layer) { return []; } if (layer.shapeType === "circle" && layer.shapeMeta?.center) { return buildCircleHandles(layer.shapeMeta.center, layer.shapeMeta.radius || 0); } if (layer.shapeType === "rectangle" && layer.polygonClosed && displayPoints.length >= 4) { const handles = displayPoints.slice(0, 4).map((pt, index) => ({ x: pt.x, y: pt.y, index, role: "corner", })); for (let i = 0; i < 4; i++) { const start = displayPoints[i]; const end = displayPoints[(i + 1) % 4]; handles.push({ x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, index: `edge-${i}`, role: "edge", edgeIndex: i, }); } return handles; } return displayPoints.map((pt, index) => ({ x: pt.x, y: pt.y, index })); } function getPolygonHandleAtPoint(layer, cursor, radius = 10) { const handles = getPolygonHandlePoints(layer, getPolygonDisplayPoints(layer)); for (const handle of handles) { if (distance(handle, cursor) <= radius) { return handle; } } return null; } function updateRectangleHandleDrag(layer, handleIndex, point, dragContext) { if (!layer || layer.polygon.length < 4 || typeof point?.x !== "number" || typeof point?.y !== "number") { return; } const bounds = layer.shapeMeta?.bounds || rectangleBoundsFromPoints(layer.polygon); let minX = bounds.minX; let minY = bounds.minY; let maxX = bounds.maxX; let maxY = bounds.maxY; const minSize = 1; const role = dragContext?.handleRole || "corner"; if (role === "edge") { const edge = dragContext?.edgeIndex ?? -1; if (edge === 0) { minY = Math.min(point.y, maxY - minSize); } else if (edge === 1) { maxX = Math.max(point.x, minX + minSize); } else if (edge === 2) { maxY = Math.max(point.y, minY + minSize); } else if (edge === 3) { minX = Math.min(point.x, maxX - minSize); } } else if (typeof handleIndex === "number") { switch (handleIndex) { case 0: minX = Math.min(point.x, maxX - minSize); minY = Math.min(point.y, maxY - minSize); break; case 1: maxX = Math.max(point.x, minX + minSize); minY = Math.min(point.y, maxY - minSize); break; case 2: maxX = Math.max(point.x, minX + minSize); maxY = Math.max(point.y, minY + minSize); break; case 3: minX = Math.min(point.x, maxX - minSize); maxY = Math.max(point.y, minY + minSize); break; default: break; } } maxX = Math.max(maxX, minX + minSize); maxY = Math.max(maxY, minY + minSize); minX = Math.min(minX, maxX - minSize); minY = Math.min(minY, maxY - minSize); const rectPoints = buildRectanglePoints({ x: minX, y: minY }, { x: maxX, y: maxY }); layer.polygon = rectPoints; layer.shapeMeta = { type: "rectangle", bounds: { minX, minY, maxX, maxY }, }; } function updateCircleHandleDrag(layer, point, dragContext) { if (!layer) { return; } const center = dragContext?.circleCenter || layer.shapeMeta?.center; if (!center) { return; } const radius = Math.max(distance(center, point), 4); layer.shapeMeta = { type: "circle", center: { x: center.x, y: center.y }, radius, }; layer.polygon = buildCirclePolygon(center, radius); } function cloneShapeMeta(meta) { if (!meta) { return null; } if (meta.type === "rectangle") { return { type: "rectangle", bounds: meta.bounds ? { minX: meta.bounds.minX, minY: meta.bounds.minY, maxX: meta.bounds.maxX, maxY: meta.bounds.maxY } : null, }; } if (meta.type === "circle") { return { type: "circle", center: meta.center ? { x: meta.center.x, y: meta.center.y } : null, radius: meta.radius, }; } return null; } function translateShapeMeta(meta, dx, dy) { if (!meta) { return null; } if (meta.type === "rectangle" && meta.bounds) { return { type: "rectangle", bounds: { minX: meta.bounds.minX + dx, minY: meta.bounds.minY + dy, maxX: meta.bounds.maxX + dx, maxY: meta.bounds.maxY + dy, }, }; } if (meta.type === "circle" && meta.center) { return { type: "circle", center: { x: meta.center.x + dx, y: meta.center.y + dy }, radius: meta.radius, }; } return cloneShapeMeta(meta); } function canvasPointFromEvent(evt) { const rect = dom.canvas.getBoundingClientRect(); const x = ((evt.clientX - rect.left) / rect.width) * dom.canvas.width; const y = ((evt.clientY - rect.top) / rect.height) * dom.canvas.height; return { x, y }; } function notifyParentHeight() { if (typeof window === "undefined" || !window.parent) { return; } const height = (document.querySelector(".app-shell")?.offsetHeight || document.body?.scrollHeight || dom.canvas?.height || 0) + 40; window.parent.postMessage({ type: "WAN2GP_MOTION_DESIGNER_RESIZE", height }, "*"); } function setupHeightObserver() { if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(() => notifyParentHeight()); const shell = document.querySelector(".app-shell") || document.body; observer.observe(shell); } else { window.addEventListener("load", notifyParentHeight); } window.addEventListener("resize", notifyParentHeight); notifyParentHeight(); } function findHandle(points, cursor, radius) { for (let i = 0; i < points.length; i++) { if (distance(points[i], cursor) <= radius) { return i; } } return -1; } function findPathHandleAtPoint(point, radius = 12, preferredLayerId = null) { if (preferredLayerId) { const preferredLayer = getLayerById(preferredLayerId); if ( preferredLayer && preferredLayer.polygonClosed && Array.isArray(preferredLayer.path) && preferredLayer.path.length > 0 ) { const preferredIndex = findHandle(preferredLayer.path, point, radius); if (preferredIndex !== -1) { return { layerId: preferredLayer.id, index: preferredIndex }; } } } for (let i = state.layers.length - 1; i >= 0; i--) { const layer = state.layers[i]; if ( !layer || layer.id === preferredLayerId || !layer.polygonClosed || !Array.isArray(layer.path) || layer.path.length === 0 ) { continue; } const index = findHandle(layer.path, point, radius); if (index !== -1) { return { layerId: layer.id, index }; } } return null; } function drawPolygonPath(ctx, points) { if (points.length === 0) { return; } ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.closePath(); } function drawLocalPolygon(ctx, points) { if (points.length === 0) { return; } ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.closePath(); } function pickLayerFromPoint(point) { for (let i = state.layers.length - 1; i >= 0; i--) { const layer = state.layers[i]; if (!layer) { continue; } if (layer.polygon.length >= 3 && pointInPolygon(point, layer.polygon)) { return layer; } if (isPointNearPolygon(point, layer)) { return layer; } if (layer.path && layer.path.length > 0 && isPointNearPath(point, layer)) { return layer; } } return null; } function isPointNearPolygon(point, layer, tolerance = 10) { if (!layer || layer.polygon.length === 0) { return false; } for (let i = 0; i < layer.polygon.length; i++) { if (distance(point, layer.polygon[i]) <= tolerance) { return true; } } const segmentCount = layer.polygonClosed ? layer.polygon.length : layer.polygon.length - 1; for (let i = 0; i < segmentCount; i++) { const start = layer.polygon[i]; const end = layer.polygon[(i + 1) % layer.polygon.length]; if (pointToSegmentDistance(point, start, end) <= tolerance) { return true; } } return false; } function isPointNearPath(point, layer, tolerance = 10) { const pathPoints = getRenderPath(layer); if (!pathPoints || pathPoints.length === 0) { return false; } for (let i = 0; i < pathPoints.length; i++) { if (distance(point, pathPoints[i]) <= tolerance) { return true; } } for (let i = 0; i < pathPoints.length - 1; i++) { if (pointToSegmentDistance(point, pathPoints[i], pathPoints[i + 1]) <= tolerance) { return true; } } return false; } function getStartPosition(layer) { if (!layer || layer.polygon.length === 0) { return null; } return polygonCentroid(layer.polygon); } function getEffectivePath(layer) { if (!layer) { return []; } const start = getStartPosition(layer); const nodes = Array.isArray(layer.path) ? layer.path.slice() : []; return start ? [start, ...nodes] : nodes; } function updateLayerPathCache(layer) { if (!layer) { return; } const basePath = getEffectivePath(layer); if (!Array.isArray(basePath) || basePath.length === 0) { layer.renderPath = []; layer.renderSegmentMap = []; layer.pathMeta = null; return; } if (basePath.length === 1) { layer.renderPath = basePath.slice(); layer.renderSegmentMap = []; layer.pathMeta = computePathMeta(layer.renderPath); return; } const tensionAmount = clamp(layer.tension ?? 0, 0, 1); if (tensionAmount > 0) { const curved = buildRenderPath(basePath, tensionAmount); layer.renderPath = curved.points; layer.renderSegmentMap = curved.segmentMap; } else { layer.renderPath = basePath.slice(); layer.renderSegmentMap = []; for (let i = 0; i < layer.renderPath.length - 1; i++) { layer.renderSegmentMap.push(i); } } layer.pathMeta = computePathMeta(layer.renderPath); } function buildRenderPath(points, tensionAmount) { const samples = Math.max(6, Math.round(8 + tensionAmount * 20)); const cardinalTension = clamp(1 - tensionAmount, 0, 1); const rendered = [points[0]]; const segmentMap = []; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i - 1] || points[i]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || p2; for (let step = 1; step <= samples; step++) { const t = step / samples; rendered.push(cardinalPoint(p0, p1, p2, p3, t, cardinalTension)); segmentMap.push(i); } } return { points: rendered, segmentMap }; } function cardinalPoint(p0, p1, p2, p3, t, tension) { const t2 = t * t; const t3 = t2 * t; const s = (1 - tension) / 2; const m1x = (p2.x - p0.x) * s; const m1y = (p2.y - p0.y) * s; const m2x = (p3.x - p1.x) * s; const m2y = (p3.y - p1.y) * s; const h00 = 2 * t3 - 3 * t2 + 1; const h10 = t3 - 2 * t2 + t; const h01 = -2 * t3 + 3 * t2; const h11 = t3 - t2; return { x: h00 * p1.x + h10 * m1x + h01 * p2.x + h11 * m2x, y: h00 * p1.y + h10 * m1y + h01 * p2.y + h11 * m2y, }; } function findBaseSegmentIndex(layer, point, tolerance = 10) { if (!layer) { return -1; } const basePoints = getEffectivePath(layer); if (!basePoints || basePoints.length < 2) { return -1; } for (let i = 0; i < basePoints.length - 1; i++) { if (pointToSegmentDistance(point, basePoints[i], basePoints[i + 1]) <= tolerance) { return i; } } const renderPoints = getRenderPath(layer); const map = Array.isArray(layer.renderSegmentMap) ? layer.renderSegmentMap : []; if (renderPoints.length > 1 && map.length === renderPoints.length - 1) { for (let i = 0; i < renderPoints.length - 1; i++) { if (pointToSegmentDistance(point, renderPoints[i], renderPoints[i + 1]) <= tolerance) { const mapped = map[i]; if (typeof mapped === "number" && mapped >= 0) { return mapped; } } } } return -1; } function pointInPolygon(point, polygon) { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x; const yi = polygon[i].y; const xj = polygon[j].x; const yj = polygon[j].y; const intersect = yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-8) + xi; if (intersect) { inside = !inside; } } return inside; } function polygonBounds(points) { const xs = points.map((p) => p.x); const ys = points.map((p) => p.y); const minX = Math.min(...xs); const minY = Math.min(...ys); const maxX = Math.max(...xs); const maxY = Math.max(...ys); return { minX, minY, width: maxX - minX || 1, height: maxY - minY || 1, }; } function polygonCentroid(points) { let x = 0; let y = 0; let signedArea = 0; for (let i = 0; i < points.length; i++) { const p0 = points[i]; const p1 = points[(i + 1) % points.length]; const a = p0.x * p1.y - p1.x * p0.y; signedArea += a; x += (p0.x + p1.x) * a; y += (p0.y + p1.y) * a; } signedArea *= 0.5; if (signedArea === 0) { return points[0]; } x = x / (6 * signedArea); y = y / (6 * signedArea); return { x, y }; } function computePathMeta(points) { if (points.length < 2) { return null; } const lengths = [0]; let total = 0; for (let i = 1; i < points.length; i++) { total += distance(points[i - 1], points[i]); lengths.push(total); } return { lengths, total }; } function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } function lerp(a, b, t) { return a + (b - a) * clamp(t, 0, 1); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function insertPointOnPolygonEdge(layer, point, tolerance = 10) { if ( !layer || (layer.shapeType && layer.shapeType !== "polygon") || !Array.isArray(layer.polygon) || layer.polygon.length < 2 ) { return false; } const segments = layer.polygonClosed ? layer.polygon.length : layer.polygon.length - 1; for (let i = 0; i < segments; i++) { const start = layer.polygon[i]; const end = layer.polygon[(i + 1) % layer.polygon.length]; if (pointToSegmentDistance(point, start, end) <= tolerance) { layer.polygon.splice(i + 1, 0, { x: point.x, y: point.y }); layer.selectedPolygonIndex = i + 1; if (layer.polygonClosed) { computeLayerAssets(layer); } updateBadge(); updateActionAvailability(); return true; } } return false; } function insertPointOnPathEdge(layer, point, tolerance = 10) { if (!layer || !layer.polygonClosed || !Array.isArray(layer.path)) { return false; } const segmentIndex = findBaseSegmentIndex(layer, point, tolerance); if (segmentIndex === -1) { return false; } const insertIndex = Math.max(segmentIndex, 0); layer.path.splice(insertIndex, 0, { x: point.x, y: point.y }); layer.selectedPathIndex = insertIndex; updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); return true; } function removePolygonPoint(layer, point, tolerance = 8) { if (!layer || (layer.shapeType && layer.shapeType !== "polygon") || layer.polygon.length === 0) { return false; } for (let i = 0; i < layer.polygon.length; i++) { if (distance(point, layer.polygon[i]) <= tolerance) { layer.polygon.splice(i, 1); layer.selectedPolygonIndex = layer.polygon.length > 0 ? Math.min(i, layer.polygon.length - 1) : -1; if (layer.polygon.length < 3) { layer.polygonClosed = false; layer.objectCut = null; recomputeBackgroundFill(); } else if (layer.polygonClosed) { computeLayerAssets(layer); } updateBadge(); updateActionAvailability(); updateLayerPathCache(layer); return true; } } return false; } function removePathPoint(layer, point, tolerance = 8) { if (!layer || !Array.isArray(layer.path) || layer.path.length === 0) { return false; } for (let i = 0; i < layer.path.length; i++) { if (distance(point, layer.path[i]) <= tolerance) { layer.path.splice(i, 1); layer.selectedPathIndex = layer.path.length > 0 ? Math.min(i, layer.path.length - 1) : -1; if (layer.path.length <= 1) { layer.pathLocked = false; } updateLayerPathCache(layer); updateBadge(); updateActionAvailability(); return true; } } return false; } function pointToSegmentDistance(point, start, end) { const dx = end.x - start.x; const dy = end.y - start.y; const lengthSq = dx * dx + dy * dy; if (lengthSq === 0) { return distance(point, start); } let t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq; t = Math.max(0, Math.min(1, t)); const proj = { x: start.x + t * dx, y: start.y + t * dy }; return distance(point, proj); } })();