// Image upload, drag/drop, paste, and graph-position media targeting. import { api } from "../../../../scripts/api.js"; import { app } from "../../../../scripts/app.js"; import { LOCAL_MEDIA_MANAGER_DROP_TYPE } from "./constants.js"; import { activeCanvasGraph, addTimelineWindowListener, errorText, graphNodes, isTargetNode, makeId, setTimelineStatus, } from "./runtime.js"; import { activeLayerIndex, ensureSelectedKeyframe, isEmptyLayer, mirrorBaseLayer, normalizeFileInfo, normalizeKeyframes, normalizeLayer, preloadImage, rejectLayerEditIfLocked, selectedKeyframeIndex, setSelectedKeyframe, syncTimelineWidget, uploadMode, viewUrl, } from "./model.js"; import { bounds, formatSlotSeconds } from "./timeline_ops.js"; export async function uploadSelectedImage(node, file, keyframeId, options = {}) { if (rejectLayerEditIfLocked(node, "Image upload")) return false; const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); const index = keyframeId == null ? selectedKeyframeIndex(node, keyframes) : keyframes.findIndex((keyframe) => keyframe.id === keyframeId); if (index == null || index < 0 || index >= keyframes.length) return; const body = new FormData(); body.append("image", file); body.append("type", "input"); if (options.pasted) { body.append("subfolder", "pasted"); } else { body.append("overwrite", "true"); } const response = await api.fetchApi("/upload/image", { method: "POST", body }); if (!response.ok) throw new Error(`Image upload failed: ${response.status}`); const payload = await response.json(); const currentKeyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); const currentIndex = keyframeId == null ? selectedKeyframeIndex(node, currentKeyframes) : currentKeyframes.findIndex((keyframe) => keyframe.id === keyframeId); if (currentIndex == null || currentIndex < 0 || currentIndex >= currentKeyframes.length) return; const uploaded = normalizeFileInfo(payload) || { filename: payload.name || file.name, subfolder: payload.subfolder || "", type: payload.type || "input", }; const keyframe = currentKeyframes[currentIndex]; const layers = Array.isArray(keyframe.layers) ? keyframe.layers : []; const currentMode = uploadMode(node); const layerIndex = activeLayerIndex(keyframe); const activeEmptyIndex = layerIndex >= 0 && isEmptyLayer(layers[layerIndex]) ? layerIndex : -1; if (activeEmptyIndex >= 0) { const currentLayer = layers[activeEmptyIndex]; const layer = normalizeLayer({ ...currentLayer, image: uploaded, empty: false, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); layers[activeEmptyIndex] = layer; keyframe.layers = layers; keyframe.selectedLayerId = layer.id; } else if (currentMode === "layer" && layers.length) { const layer = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); layers.push(layer); keyframe.layers = layers; keyframe.selectedLayerId = layer.id; } else if (currentMode === "layer") { const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); keyframe.layers = [base]; keyframe.selectedLayerId = base.id; } else if (layers.length) { const replaceIndex = layerIndex >= 0 ? layerIndex : 0; const nextLayer = normalizeLayer({ ...layers[replaceIndex], image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1, }); layers[replaceIndex] = nextLayer; keyframe.layers = layers; keyframe.selectedLayerId = nextLayer.id; } else { const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); keyframe.layers = [base]; keyframe.selectedLayerId = base.id; } mirrorBaseLayer(keyframe); currentKeyframes[currentIndex] = keyframe; node.properties.etk_ltxv_timeline_keyframes = currentKeyframes; setSelectedKeyframe(node, currentIndex, currentKeyframes); preloadImage(node, currentKeyframes[currentIndex]); syncTimelineWidget(node); const action = options.pasted ? "Pasted" : "Uploaded"; const target = currentMode === "layer" ? "layer" : activeEmptyIndex >= 0 ? "empty layer" : "active layer"; setTimelineStatus(node, `${action} ${uploaded.filename || uploaded.name || "image"} to ${target} at ${formatSlotSeconds(node, keyframe.slot)}.`, "success"); node.setDirtyCanvas(true, true); } export function nodeFromGraphMouse(graphMouse) { if (!graphMouse) return null; for (const node of graphNodes(activeCanvasGraph())) { if (!isTargetNode(node)) continue; if ( graphMouse[0] >= node.pos[0] && graphMouse[0] <= node.pos[0] + node.size[0] && graphMouse[1] >= node.pos[1] && graphMouse[1] <= node.pos[1] + node.size[1] ) { return node; } } return null; } export function eventToGraphPosition(event) { const canvas = app.canvas; const canvasElement = canvas ? canvas.canvas || canvas.canvas_element : null; if (!canvas) return null; if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { return canvas.graph_mouse ? [canvas.graph_mouse[0], canvas.graph_mouse[1]] : null; } let offset = null; if (typeof canvas.convertEventToCanvasOffset === "function") { offset = canvas.convertEventToCanvasOffset(event); } if (!offset) { const rect = canvasElement ? canvasElement.getBoundingClientRect() : { left: 0, top: 0 }; offset = [event.clientX - rect.left, event.clientY - rect.top]; } if (!Array.isArray(offset)) { const offsetX = offset[0] != null ? offset[0] : offset.x; const offsetY = offset[1] != null ? offset[1] : offset.y; offset = [offsetX, offsetY]; } if (!Number.isFinite(offset[0]) || !Number.isFinite(offset[1])) return null; if (canvas.ds && typeof canvas.ds.convertOffsetToCanvas === "function") { return canvas.ds.convertOffsetToCanvas(offset); } if (typeof canvas.convertOffsetToCanvas === "function") { return canvas.convertOffsetToCanvas(offset); } if (canvas.ds && canvas.ds.offset && canvas.ds.scale) { return [(offset[0] - canvas.ds.offset[0]) / canvas.ds.scale, (offset[1] - canvas.ds.offset[1]) / canvas.ds.scale]; } return offset; } export function previewTargetFromGraphPosition(graphPos) { if (!graphPos) return null; for (const node of graphNodes(activeCanvasGraph())) { if (!isTargetNode(node)) continue; const localPos = [graphPos[0] - node.pos[0], graphPos[1] - node.pos[1]]; if (isPreviewPosition(node, localPos) || isTimelineImageInputPosition(node, graphPos)) { return { node, localPos }; } } return null; } export function isPreviewPosition(node, localPos) { if (!node || !localPos) return false; const { padX, width, previewY, previewH } = bounds(node); const [x, y] = localPos; return x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH; } export function isTimelineImageInput(input) { if (!input) return false; const name = String(input.name || "").toLowerCase(); const type = String(input.type || "").toUpperCase(); return type === "IMAGE" || name === "image" || name === "images"; } export function graphInputSlotPosition(node, input, index) { if (typeof node.getInputPos === "function") return node.getInputPos(index); if (typeof node.getConnectionPos === "function") return node.getConnectionPos(true, index); if (Array.isArray(input.pos)) return [node.pos[0] + input.pos[0], node.pos[1] + input.pos[1]]; return [node.pos[0] + 10, node.pos[1] + (index + 0.7) * 20]; } export function isTimelineImageInputPosition(node, graphPos) { if (!node || !graphPos || !node.inputs) return false; if (typeof node.getSlotInPosition === "function") { const slot = node.getSlotInPosition(graphPos[0], graphPos[1]); if (slot && isTimelineImageInput(slot.input)) return true; } if (typeof node.getInputOnPos === "function") { const input = node.getInputOnPos(graphPos); if (isTimelineImageInput(input)) return true; } for (let i = 0; i < node.inputs.length; i++) { const input = node.inputs[i]; if (!isTimelineImageInput(input)) continue; const pos = graphInputSlotPosition(node, input, i); if (Math.abs(graphPos[0] - pos[0]) <= 18 && Math.abs(graphPos[1] - pos[1]) <= 14) return true; } return false; } export function previewTargetFromEvent(event) { const graphPos = eventToGraphPosition(event); const target = previewTargetFromGraphPosition(graphPos); if (target) window.etkTimelineLastPreviewTarget = target; return target; } export function currentPreviewTargetFromPointer() { const canvas = app.canvas; const graphMouse = canvas ? canvas.graph_mouse : null; return previewTargetFromGraphPosition(graphMouse); } export function isSupportedImageFile(file) { if (!file) return false; if (file.type && /^image\/(png|jpe?g|webp)$/i.test(file.type)) return true; return /\.(png|jpe?g|webp)$/i.test(file.name || ""); } export function dragUriTypes(dataTransfer) { if (!dataTransfer || !dataTransfer.types) return []; const types = Array.from(dataTransfer.types); return types.filter( (type) => type === "text/uri-list" || type === "text/x-moz-url" || type === "text/plain" || type === LOCAL_MEDIA_MANAGER_DROP_TYPE ); } export function localMediaManagerUri(raw) { if (!raw) return ""; try { const payload = JSON.parse(raw); const path = payload && (payload.path || payload.filepath || payload.filePath); if (!path) return payload && payload.url ? String(payload.url) : ""; const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href; const url = new URL("/local_image_gallery/view", base); url.searchParams.set("filepath", path); return url.toString(); } catch { return String(raw || "").trim(); } } export function dragUriFromPayload(dataTransfer) { const types = dragUriTypes(dataTransfer); if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) { const raw = dataTransfer.getData ? dataTransfer.getData(LOCAL_MEDIA_MANAGER_DROP_TYPE) : ""; const uri = localMediaManagerUri(raw); if (uri) return uri; } for (const type of types) { if (type === LOCAL_MEDIA_MANAGER_DROP_TYPE) continue; const raw = dataTransfer.getData ? dataTransfer.getData(type) : ""; const uri = String(raw || "").split("\n").find((line) => line.trim() && !line.trim().startsWith("#")); if (uri) return uri.trim(); } return ""; } export function normalizeDropUrl(url) { if (!url) return null; if (/\/local_image_gallery\/thumbnail$/i.test(url.pathname) && url.searchParams.has("filepath")) { const viewUrl = new URL("/local_image_gallery/view", url); viewUrl.searchParams.set("filepath", url.searchParams.get("filepath")); return viewUrl; } return url; } export function isSupportedDropUrl(url) { if (!url) return false; if (/\/local_image_gallery\/view$/i.test(url.pathname)) return url.searchParams.has("filepath"); if (/\/view$/i.test(url.pathname)) return url.searchParams.has("filepath") || url.searchParams.has("filename"); if (url.searchParams.has("filename")) return true; return /\.(png|jpe?g|webp)$/i.test(url.pathname); } export function sameOriginDropUrl(dataTransfer) { const uri = dragUriFromPayload(dataTransfer); if (!uri) return null; try { const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href; const url = new URL(uri, base); const origin = typeof location !== "undefined" && location.origin ? location.origin : window.location && window.location.origin; if (origin && url.origin !== origin) return null; const normalizedUrl = normalizeDropUrl(url); if (!isSupportedDropUrl(normalizedUrl)) return null; return normalizedUrl; } catch { return null; } } export function fileNameFromDropUrl(url) { const filepath = url.searchParams.get("filepath") || url.searchParams.get("filename") || url.pathname; const cleanPath = String(filepath || "image.png").split(/[\\/]/).filter(Boolean).pop() || "image.png"; return /\.(png|jpe?g|webp)$/i.test(cleanPath) ? cleanPath : `${cleanPath}.png`; } export async function imageFileFromDropUrl(url) { if (!url || typeof fetch !== "function") return null; try { const response = await fetch(url.toString()); if (!response || !response.ok) return null; const blob = await response.blob(); const file = new File([blob], fileNameFromDropUrl(url), { type: blob.type || "image/png" }); return isSupportedImageFile(file) ? file : null; } catch { return null; } } export function hasImageFilePayload(dataTransfer) { if (!dataTransfer) return false; if (dataTransfer.items && dataTransfer.items.length) { const items = Array.from(dataTransfer.items); if (items.some( (item) => (item.kind === "file" && (!item.type || /^image\/(png|jpe?g|webp)$/i.test(item.type))) || (item.kind === "string" && (item.type === "text/uri-list" || item.type === "text/x-moz-url" || item.type === "text/plain" || item.type === LOCAL_MEDIA_MANAGER_DROP_TYPE)) )) { return true; } } if (dataTransfer.files && dataTransfer.files.length) return Array.from(dataTransfer.files).some(isSupportedImageFile); if (dataTransfer.types && Array.from(dataTransfer.types).includes("Files")) return true; return dragUriTypes(dataTransfer).length > 0; } export function imageFileFromPayload(payload) { if (!payload) return null; if (payload.files && payload.files.length) { const file = Array.from(payload.files).find(isSupportedImageFile); if (file) return file; } if (payload.items && payload.items.length) { for (const item of Array.from(payload.items)) { if (item.kind !== "file") continue; const file = item.getAsFile ? item.getAsFile() : null; if (isSupportedImageFile(file)) return file; } } return null; } export function shouldPreferDropUrl(payload, dropUrl) { if (!dropUrl) return false; const types = payload && payload.types ? Array.from(payload.types) : []; if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) return true; return /\/local_image_gallery\/view$/i.test(dropUrl.pathname) && dropUrl.searchParams.has("filepath"); } export async function imageFileFromDropPayload(payload, dropUrl = null) { const resolvedDropUrl = dropUrl || sameOriginDropUrl(payload); if (shouldPreferDropUrl(payload, resolvedDropUrl)) { return (await imageFileFromDropUrl(resolvedDropUrl)) || imageFileFromPayload(payload); } return imageFileFromPayload(payload) || imageFileFromDropUrl(resolvedDropUrl); } export function setDropHoverNode(node) { if (window.etkTimelineDropHoverNode === node) return; if (window.etkTimelineDropHoverNode) { window.etkTimelineDropHoverNode.etkTimelineDropHover = false; window.etkTimelineDropHoverNode.setDirtyCanvas(true, true); } window.etkTimelineDropHoverNode = node || null; if (node) { node.etkTimelineDropHover = true; node.setDirtyCanvas(true, true); } } export function clearDropHoverNode() { setDropHoverNode(null); } export function lastPreviewTarget() { const target = window.etkTimelineLastPreviewTarget; if (!target || !target.node || !target.node.graph) return null; return target; } export function isEditableClipboardTarget(target) { if (!target) return false; const tagName = String(target.tagName || "").toUpperCase(); if (target.isContentEditable) return true; if (tagName === "TEXTAREA" || tagName === "SELECT") return true; if (tagName !== "INPUT") return false; const inputType = String(target.type || "text").toLowerCase(); return !["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "search", "submit"].includes(inputType); } export function hasSelectedDocumentText() { if (typeof window.getSelection !== "function") return false; const selection = window.getSelection(); return !!selection && String(selection.toString ? selection.toString() : "").trim().length > 0; } export function shouldIgnoreClipboardEvent(event) { if (!event || event.defaultPrevented) return true; if (event.shiftKey) return true; return isEditableClipboardTarget(event.target) || hasSelectedDocumentText(); } export function stopDropOrPasteEvent(event) { event.preventDefault(); event.stopPropagation(); if (event.stopImmediatePropagation) event.stopImmediatePropagation(); } export function stopKeyboardShortcutEvent(event) { event.preventDefault(); event.stopPropagation(); if (event.stopImmediatePropagation) event.stopImmediatePropagation(); } export async function uploadImageToNode(node, file, options = {}) { if (!node || !file) return false; if (rejectLayerEditIfLocked(node, "Image upload")) return false; const keyframe = ensureSelectedKeyframe(node); if (!keyframe) { setTimelineStatus(node, "No free keyframe positions are available for this image.", "error"); return false; } await uploadSelectedImage(node, file, keyframe.id, options); return true; } export async function uploadImageToPreviewTarget(target, file, options = {}) { if (!target || !file) return false; return uploadImageToNode(target.node, file, options); } export function installTimelinePasteFiles(node) { if (node.etkTimelinePasteFilesInstalled) return; const previousPasteFiles = typeof node.pasteFiles === "function" ? node.pasteFiles : null; const installedPasteFiles = function (files) { const file = Array.from(files || []).find(isSupportedImageFile); if (!file) return previousPasteFiles ? previousPasteFiles.call(this, files) : false; void uploadImageToNode(this || node, file, { pasted: true }).catch((error) => { setTimelineStatus(this || node, `Pasted image upload failed: ${errorText(error)}`, "error"); console.error("[ETK timeline] Pasted image upload failed", error); }); return true; }; node.pasteFiles = installedPasteFiles; node.etkTimelinePasteFilesInstalled = true; const previousOnRemoved = node.onRemoved; node.onRemoved = function () { if (this.pasteFiles === installedPasteFiles) this.pasteFiles = previousPasteFiles || undefined; if (previousOnRemoved) return previousOnRemoved.apply(this, arguments); }; } export function installTimelineDragDropHooks(node) { if (node.etkTimelineDragDropInstalled) return; const previousOnDragOver = typeof node.onDragOver === "function" ? node.onDragOver : null; const previousOnDragDrop = typeof node.onDragDrop === "function" ? node.onDragDrop : null; const installedOnDragOver = function (event) { if (hasImageFilePayload(event && event.dataTransfer)) { if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; return true; } return previousOnDragOver ? previousOnDragOver.apply(this, arguments) : false; }; const installedOnDragDrop = async function (event, claimEvent = true) { const payload = event && event.dataTransfer; const file = imageFileFromPayload(payload); const dropUrl = sameOriginDropUrl(payload); if (!file && !dropUrl) { return previousOnDragDrop ? previousOnDragDrop.apply(this, arguments) : false; } if (claimEvent) stopDropOrPasteEvent(event); try { const resolvedFile = await imageFileFromDropPayload(payload, dropUrl); if (!resolvedFile) return false; await uploadImageToNode(this || node, resolvedFile); return true; } catch (error) { setTimelineStatus(this || node, `Dropped image upload failed: ${errorText(error)}`, "error"); console.error("[ETK timeline] Dropped image upload failed", error); return false; } }; node.onDragOver = installedOnDragOver; node.onDragDrop = installedOnDragDrop; node.etkTimelineDragDropInstalled = true; const previousOnRemoved = node.onRemoved; node.onRemoved = function () { if (this.onDragOver === installedOnDragOver) this.onDragOver = previousOnDragOver || undefined; if (this.onDragDrop === installedOnDragDrop) this.onDragDrop = previousOnDragDrop || undefined; if (previousOnRemoved) return previousOnRemoved.apply(this, arguments); }; } export function installFileDropCapture() { addTimelineWindowListener( "fileDrop.mousemove", "mousemove", (event) => { const target = previewTargetFromEvent(event); if (target) window.etkTimelineLastPreviewTarget = target; }, { capture: true, passive: true } ); addTimelineWindowListener( "fileDrop.dragover", "dragover", (event) => { const target = hasImageFilePayload(event.dataTransfer) ? previewTargetFromEvent(event) : null; if (!target) { clearDropHoverNode(); return; } setDropHoverNode(target.node); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; stopDropOrPasteEvent(event); }, { capture: true, passive: false } ); addTimelineWindowListener( "fileDrop.drop", "drop", async (event) => { const target = previewTargetFromEvent(event); const file = target ? imageFileFromPayload(event.dataTransfer) : null; const dropUrl = target ? sameOriginDropUrl(event.dataTransfer) : null; clearDropHoverNode(); if (!target || (!file && !dropUrl)) return; stopDropOrPasteEvent(event); try { const resolvedFile = await imageFileFromDropPayload(event.dataTransfer, dropUrl); if (!resolvedFile) return; await uploadImageToPreviewTarget(target, resolvedFile); } catch (error) { setTimelineStatus(target.node, `Dropped image upload failed: ${errorText(error)}`, "error"); console.error("[ETK timeline] Dropped image upload failed", error); } }, { capture: true, passive: false } ); addTimelineWindowListener("fileDrop.dragend", "dragend", clearDropHoverNode, true); addTimelineWindowListener( "fileDrop.dragleave", "dragleave", (event) => { if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) { clearDropHoverNode(); } }, true ); } export function installClipboardPasteCapture() { addTimelineWindowListener( "clipboardPaste.paste", "paste", async (event) => { if (shouldIgnoreClipboardEvent(event)) return; const target = currentPreviewTargetFromPointer(); const file = target ? imageFileFromPayload(event.clipboardData) : null; const dropUrl = target ? sameOriginDropUrl(event.clipboardData) : null; if (!target || (!file && !dropUrl)) return; stopDropOrPasteEvent(event); try { const resolvedFile = await imageFileFromDropPayload(event.clipboardData, dropUrl); if (!resolvedFile) return; await uploadImageToPreviewTarget(target, resolvedFile, { pasted: true }); } catch (error) { setTimelineStatus(target.node, `Pasted image upload failed: ${errorText(error)}`, "error"); console.error("[ETK timeline] Pasted image upload failed", error); } }, { capture: true, passive: false } ); }