diff --git a/web/hidden_base64_loader.js b/web/hidden_base64_loader.js deleted file mode 100644 index cbc8c41..0000000 --- a/web/hidden_base64_loader.js +++ /dev/null @@ -1,141 +0,0 @@ -import { app } from "../../scripts/app.js"; - -app.registerExtension({ - name: "Comfy.RikanHiddenBase64ImageLoader", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "RikanHiddenBase64ImageLoader") { - const onNodeCreated = nodeType.prototype.onNodeCreated; - - nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; - - this.show_custom_image = false; - this.custom_image_element = null; - - const base64Widget = this.widgets.find((w) => w.name === "base64_data"); - if (base64Widget) { - // Memberikan ruang 85px di LiteGraph (60px untuk text area + 25px untuk margin bawah) - base64Widget.computeSize = function() { - return [this.width || 200, 85]; - }; - - // Tinggi elemen fisik tetap 60px agar rapi - if (base64Widget.inputEl) { - base64Widget.inputEl.style.setProperty("height", "60px", "important"); - base64Widget.inputEl.style.setProperty("max-height", "60px", "important"); - base64Widget.inputEl.style.setProperty("min-height", "60px", "important"); - base64Widget.inputEl.style.setProperty("resize", "none", "important"); - base64Widget.inputEl.style.overflowY = "scroll"; - base64Widget.inputEl.style.overflowX = "hidden"; - base64Widget.inputEl.style.wordBreak = "break-all"; - } - } - - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = "image/*"; - fileInput.style.display = "none"; - document.body.appendChild(fileInput); - - fileInput.addEventListener("change", (e) => { - const file = e.target.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - const dataUri = event.target.result; - const rawBase64 = dataUri.split(',')[1]; - - if (base64Widget) { - base64Widget.value = rawBase64; - } - - this.custom_image_element = new Image(); - this.custom_image_element.src = dataUri; - this.custom_image_element.onload = () => { - this.size[1] = this.computeSize()[1]; - this.setDirtyCanvas(true, true); - }; - }; - reader.readAsDataURL(file); - } - }); - - this.addWidget("button", "Upload Image", "upload", () => { - fileInput.click(); - }); - - requestAnimationFrame(() => { - this.size[1] = this.computeSize()[1]; - this.setDirtyCanvas(true, true); - }); - - setTimeout(() => { - if (base64Widget && base64Widget.value) { - this.custom_image_element = new Image(); - this.custom_image_element.src = "data:image/png;base64," + base64Widget.value; - this.custom_image_element.onload = () => { - this.setDirtyCanvas(true, true); - }; - } - }, 100); - - return r; - }; - - const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; - nodeType.prototype.getExtraMenuOptions = function (_, options) { - if (getExtraMenuOptions) { - getExtraMenuOptions.apply(this, arguments); - } - - options.unshift({ - content: this.show_custom_image ? "Hide Image" : "Show Image", - callback: () => { - this.show_custom_image = !this.show_custom_image; - - if (!this.show_custom_image) { - this.size[1] = this.computeSize()[1]; - } - - this.setDirtyCanvas(true, true); - } - }); - }; - - const onDrawForeground = nodeType.prototype.onDrawForeground; - nodeType.prototype.onDrawForeground = function (ctx) { - if (onDrawForeground) { - onDrawForeground.apply(this, arguments); - } - - if (this.show_custom_image && this.custom_image_element) { - const padding = 10; - const w = this.size[0] - padding * 2; - - let y = 30; - - const btnWidget = this.widgets ? this.widgets.find(w => w.name === "upload" || w.type === "button") : null; - if (btnWidget && btnWidget.last_y) { - y = btnWidget.last_y + 30; - } else if (this.widgets && this.widgets.length > 0) { - y = this.widgets[this.widgets.length - 1].last_y + 30; - } - - const imgW = this.custom_image_element.width; - const imgH = this.custom_image_element.height; - - const scale = w / imgW; - const drawW = w; - const drawH = imgH * scale; - - ctx.drawImage(this.custom_image_element, padding, y, drawW, drawH); - - const requiredHeight = y + drawH + padding; - if (this.size[1] < requiredHeight) { - this.size[1] = requiredHeight; - } - } - }; - } - } -}); \ No newline at end of file diff --git a/web/multi_lora_gate.js b/web/multi_lora_gate.js deleted file mode 100644 index 16f5a13..0000000 --- a/web/multi_lora_gate.js +++ /dev/null @@ -1,197 +0,0 @@ -import { app } from "../../scripts/app.js"; - -app.registerExtension({ - name: "prompt_relay.MultiLoraGateNative", - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "RikanPromptRelayMultiLoraGate") { - - const syncData = (node) => { - if (!node || !node.widgets) return; - const dataWidget = node.widgets.find(w => w.name === "lora_data"); - if (!dataWidget) return; - - const loras = []; - let maxIndex = 0; - for (const w of node.widgets) { - if (w.name && w.name.startsWith("enable_")) { - const idx = parseInt(w.name.split("_")[1]); - if (idx > maxIndex) maxIndex = idx; - } - } - - for (let i = 1; i <= maxIndex; i++) { - const wToggle = node.widgets.find(w => w.name === `enable_${i}`); - const wLora = node.widgets.find(w => w.name === `lora_${i}`); - const wSeg = node.widgets.find(w => w.name === `segment_${i}`); - const wMod = node.widgets.find(w => w.name === `modelStr_${i}`); - - if (wToggle && wLora && wSeg && wMod) { - loras.push({ - enable: wToggle.value, - name: wLora.value, - segment: wSeg.value, - modelStr: wMod.value - }); - } - } - dataWidget.value = JSON.stringify(loras); - }; - - const addLoraRow = function(node, index, loraName = "None") { - const getLoraList = () => { - const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; - if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { - return ["None"].concat(loraLoader.nodeData.input.required.lora_name[0] || []); - } - return ["None"]; - }; - - const wToggle = node.addWidget("toggle", `enable_${index}`, true, () => syncData(node)); - const wLora = node.addWidget("combo", `lora_${index}`, loraName, () => syncData(node), { values: getLoraList() }); - - // LiteGraph membagi step dengan 10 untuk tombol klik. - // step: 10 artinya tombol panah menambah 1. Default segment berurutan (0, 1, 2...) - const defaultSeg = Math.max(0, index - 1); - const wSeg = node.addWidget("number", `segment_${index}`, defaultSeg, () => syncData(node), { min: 0, max: 99, step: 10, precision: 0 }); - - // step: 0.1 artinya tombol panah menambah 0.01. Default: 0.8 - const wMod = node.addWidget("number", `modelStr_${index}`, 0.80, () => syncData(node), { min: -10.0, max: 10.0, step: 0.1, precision: 2 }); - - if (node.btnAddLora && node.btnRemoveLora) { - const idx2 = node.widgets.indexOf(node.btnAddLora); - if (idx2 !== -1) node.widgets.splice(idx2, 1); - - const idx3 = node.widgets.indexOf(node.btnRemoveLora); - if (idx3 !== -1) node.widgets.splice(idx3, 1); - - node.widgets.push(node.btnAddLora); - node.widgets.push(node.btnRemoveLora); - } - syncData(node); - }; - - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; - - this.serialize_widgets = true; - this.loraCount = 0; - - const wData = this.widgets.find(w => w.name === "lora_data"); - if (wData) { - wData.type = "hidden"; - wData.hidden = true; - wData.computeSize = () => [0, -4]; - } - - this.btnToggleAll = this.addWidget("button", "🔄 Toggle All", "toggle_all", () => { - let allOn = true; - for (const w of this.widgets) { - if (w.name && w.name.startsWith("enable_") && w.value === false) { - allOn = false; - break; - } - } - const targetState = !allOn; - for (const w of this.widgets) { - if (w.name && w.name.startsWith("enable_")) { - w.value = targetState; - } - } - syncData(this); - this.setDirtyCanvas(true, true); - }); - - this.btnAddLora = this.addWidget("button", "➕ Add LoRA", "add_lora", (value, widget, node, pos, event) => { - const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; - let loras = ["None"]; - if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { - loras = loraLoader.nodeData.input.required.lora_name[0].filter(l => l !== "None"); - } - - const menuItems = loras.map(loraName => ({ - content: loraName, - callback: () => { - this.loraCount++; - addLoraRow(this, this.loraCount, loraName); - this.computeSize(); - this.setDirtyCanvas(true, true); - } - })); - - if (menuItems.length === 0) { - menuItems.push({ content: "No LoRAs found!", callback: () => {} }); - } - - const e = event || app.canvas.last_mouse_event; - new LiteGraph.ContextMenu(menuItems, { event: e, title: "Choose a LoRA" }); - }); - - this.btnRemoveLora = this.addWidget("button", "➖ Remove Last LoRA", "remove_lora", () => { - if (this.loraCount > 0) { - const suffix = `_${this.loraCount}`; - for (let i = this.widgets.length - 1; i >= 0; i--) { - if (this.widgets[i].name && this.widgets[i].name.endsWith(suffix)) { - this.widgets.splice(i, 1); - } - } - this.loraCount--; - syncData(this); - this.computeSize(); - this.setDirtyCanvas(true, true); - } - }); - - return r; - }; - - const onConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function (info) { - if (info && info.widgets_values) { - this.loraCount = 0; - - let jsonData = "[]"; - for(let v of info.widgets_values) { - if (typeof v === "string" && v.startsWith("[") && v.endsWith("]")) { - jsonData = v; - break; - } - } - - try { - const savedLoras = JSON.parse(jsonData); - for (let i = 0; i < savedLoras.length; i++) { - const lora = savedLoras[i]; - const idx = i + 1; - this.loraCount = idx; - addLoraRow(this, idx, lora.name); - - const wToggle = this.widgets.find(w => w.name === `enable_${idx}`); - const wSegment = this.widgets.find(w => w.name === `segment_${idx}`); - const wModel = this.widgets.find(w => w.name === `modelStr_${idx}`); - - if (wToggle) wToggle.value = lora.enable; - if (wSegment) wSegment.value = lora.segment; - if (wModel) wModel.value = lora.modelStr; - } - - const wData = this.widgets.find(w => w.name === "lora_data"); - if (wData) { - wData.type = "hidden"; - wData.hidden = true; - wData.computeSize = () => [0, -4]; - wData.value = jsonData; - } - - } catch (e) { - console.error("[Rikan MultiLoraGate] Failed to parse saved JSON data:", e); - } - } - - if (onConfigure) { - onConfigure.apply(this, arguments); - } - }; - } - } -}); \ No newline at end of file diff --git a/web/prompt_relay_timeline.js b/web/prompt_relay_timeline.js deleted file mode 100644 index 8e29f44..0000000 --- a/web/prompt_relay_timeline.js +++ /dev/null @@ -1,907 +0,0 @@ -const { app } = window.comfyAPI.app; - -const PALETTE = [ - "#4f8edc", "#e07b3a", "#5cb85c", "#d9534f", "#9b6cd6", - "#a07060", "#e377c2", "#7f7f7f", "#c4c447", "#3fbac4", -]; - -const RULER_HEIGHT = 22; -const BLOCK_HEIGHT = 64; -const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT; -const HANDLE_HIT_PX = 6; -const REORDER_THRESHOLD_PX = 6; -const MIN_SEGMENT_LENGTH = 1; -const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths"]; - -function hideWidget(w) { - if (!w) return; - w.type = "hidden"; - w.hidden = true; - w.computeSize = () => [0, -4]; -} - -function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } - -function pickColor(existingColors) { - for (const c of PALETTE) if (!existingColors.has(c)) return c; - const idx = existingColors.size; - const hue = (idx * 137.508) % 360; - return `hsl(${hue.toFixed(0)}, 55%, 55%)`; -} - -function defaultTimeline(maxFrames) { - const half = Math.max(MIN_SEGMENT_LENGTH, Math.floor(maxFrames / 2)); - return { - segments: [ - { prompt: "", length: half, color: PALETTE[0] }, - { prompt: "", length: Math.max(MIN_SEGMENT_LENGTH, maxFrames - half), color: PALETTE[1] }, - ], - }; -} - -function parseInitial(jsonStr, maxFrames) { - if (!jsonStr) return defaultTimeline(maxFrames); - try { - const obj = JSON.parse(jsonStr); - if (Array.isArray(obj?.segments) && obj.segments.length > 0) { - return { - segments: obj.segments.map((s, i) => ({ - prompt: typeof s.prompt === "string" ? s.prompt : "", - length: Math.max(MIN_SEGMENT_LENGTH, parseInt(s.length, 10) || MIN_SEGMENT_LENGTH), - color: typeof s.color === "string" ? s.color : PALETTE[i % PALETTE.length], - })), - }; - } - } catch (_) {} - return defaultTimeline(maxFrames); -} - -class TimelineEditor { - constructor(node, container) { - this.node = node; - this.container = container; - - this.maxFramesWidget = node.widgets.find(w => w.name === "max_frames"); - this.fpsWidget = node.widgets.find(w => w.name === "fps"); - this.secondsWidget = node.widgets.find(w => w.name === "seconds"); - this.timeUnitsWidget = node.widgets.find(w => w.name === "time_units"); - this.timelineDataWidget = node.widgets.find(w => w.name === "timeline_data"); - this.localPromptsWidget = node.widgets.find(w => w.name === "local_prompts"); - this.segmentLengthsWidget = node.widgets.find(w => w.name === "segment_lengths"); - - this.timeline = parseInitial(this.timelineDataWidget?.value, this.getMaxFrames()); - this.selectedIndex = 0; - this.hoverIndex = -1; - this.hoverHandle = -1; - this.dragHandle = -1; - this.dragStart = null; - this.reorder = null; - this._settling = false; - this._inputBaseline = null; - this._textCommitTimer = null; - this._displayedX = new Map(); - this._targetX = new Map(); - this._animRaf = null; - - this.buildDOM(); - this.bindEvents(); - this.syncWidgetsFromTimeline(); - this.updateUIFromSelection(); - this.render(); - } - - getMaxFrames() { - return Math.max(1, parseInt(this.maxFramesWidget?.value, 10) || 1); - } - - getFps() { - const v = parseFloat(this.fpsWidget?.value); - return Number.isFinite(v) && v > 0 ? v : 24; - } - - isSecondsMode() { - return this.timeUnitsWidget?.value === "seconds"; - } - - formatTime(frames) { - if (!this.isSecondsMode()) return String(frames); - const s = frames / this.getFps(); - return `${s.toFixed(2).replace(/\.?0+$/, "")}s`; - } - - formatLength(frames) { - return this.isSecondsMode() ? this.formatTime(frames) : `${frames}f`; - } - - buildDOM() { - this.container.innerHTML = ""; - this.container.style.cssText = ` - display: flex; flex-direction: column; gap: 6px; - padding: 6px 8px; box-sizing: border-box; - font-family: sans-serif; font-size: 11px; color: #ddd; - width: 100%; height: 100%; - `; - - this.canvas = document.createElement("canvas"); - this.canvas.style.cssText = ` - width: 100%; height: ${CANVAS_HEIGHT}px; - display: block; background: #1a1a1a; border-radius: 4px; - cursor: default; - `; - this.container.appendChild(this.canvas); - this.ctx = this.canvas.getContext("2d"); - - this.textarea = document.createElement("textarea"); - this.textarea.placeholder = "Click a segment above to edit its prompt…"; - this.textarea.style.cssText = ` - width: 100%; min-height: 60px; flex: 1 1 auto; - box-sizing: border-box; resize: none; - background: #2a2a2a; color: #eee; border: 1px solid #444; - border-radius: 4px; padding: 6px; font-family: inherit; font-size: 12px; - `; - this.container.appendChild(this.textarea); - - const row = document.createElement("div"); - row.style.cssText = "display: flex; gap: 6px; align-items: center;"; - - const lengthLabel = document.createElement("label"); - lengthLabel.style.cssText = "display: flex; align-items: center; gap: 4px;"; - lengthLabel.textContent = "Length:"; - this.lengthInput = document.createElement("input"); - this.lengthInput.type = "number"; - this.lengthInput.style.cssText = ` - width: 70px; background: #2a2a2a; color: #eee; - border: 1px solid #444; border-radius: 3px; padding: 2px 4px; - `; - lengthLabel.appendChild(this.lengthInput); - row.appendChild(lengthLabel); - - this.totalLabel = document.createElement("span"); - this.totalLabel.style.cssText = "color: #888; margin-left: 4px;"; - row.appendChild(this.totalLabel); - - const spacer = document.createElement("div"); - spacer.style.flex = "1"; - row.appendChild(spacer); - - this.addBtn = this.makeButton("+ Add", "Add a new segment. Steals space from existing segments (from the end) if the timeline is full."); - this.distributeBtn = this.makeButton("Equalize", "Set every segment to the same length so the total exactly fills max_frames."); - this.deleteBtn = this.makeButton("Delete", "Remove the currently selected segment. Disabled when only one segment is left."); - - row.appendChild(this.addBtn); - row.appendChild(this.distributeBtn); - row.appendChild(this.deleteBtn); - - this.container.appendChild(row); - } - - makeButton(label, tooltip) { - const b = document.createElement("button"); - b.textContent = label; - if (tooltip) b.title = tooltip; - b.style.cssText = ` - background: #3a3a3a; color: #eee; border: 1px solid #555; - border-radius: 3px; padding: 3px 10px; cursor: pointer; font-size: 11px; - `; - b.addEventListener("mouseenter", () => b.style.background = "#4a4a4a"); - b.addEventListener("mouseleave", () => b.style.background = "#3a3a3a"); - return b; - } - - bindEvents() { - this.canvas.addEventListener("pointerdown", e => { e.stopPropagation(); this.onPointerDown(e); }); - this.canvas.addEventListener("pointermove", e => { e.stopPropagation(); this.onPointerMove(e); }); - this.canvas.addEventListener("pointerup", e => { e.stopPropagation(); this.onPointerUp(e); }); - this.canvas.addEventListener("contextmenu", e => { e.preventDefault(); e.stopPropagation(); }); - this.canvas.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); - this.canvas.addEventListener("pointerleave", () => { - if (this.dragHandle < 0) { - this.hoverIndex = -1; - this.hoverHandle = -1; - this.canvas.style.cursor = "default"; - this.render(); - } - }); - - this.textarea.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); - this.textarea.addEventListener("pointerdown", e => e.stopPropagation()); - this.lengthInput.addEventListener("pointerdown", e => e.stopPropagation()); - this.lengthInput.addEventListener("wheel", e => e.stopPropagation(), { passive: true }); - - this.textarea.addEventListener("input", () => { - const seg = this.timeline.segments[this.selectedIndex]; - if (!seg) return; - seg.prompt = this.textarea.value; - if (this.localPromptsWidget) { - this.localPromptsWidget.value = this.timeline.segments.map(s => s.prompt).join(" | "); - } - this.render(); - if (this._textCommitTimer) clearTimeout(this._textCommitTimer); - this._textCommitTimer = setTimeout(() => { - this._textCommitTimer = null; - this.commit(); - }, 120); - }); - this.textarea.addEventListener("blur", () => { - if (this._textCommitTimer) { - clearTimeout(this._textCommitTimer); - this._textCommitTimer = null; - this.commit(); - } - }); - this.lengthInput.addEventListener("focus", () => { this._inputBaseline = null; }); - this.lengthInput.addEventListener("blur", () => { this._inputBaseline = null; }); - this.lengthInput.addEventListener("input", () => { - const idx = this.selectedIndex; - const seg = this.timeline.segments[idx]; - if (!seg) return; - const raw = parseFloat(this.lengthInput.value); - if (!Number.isFinite(raw)) return; - - const frames = Math.max( - MIN_SEGMENT_LENGTH, - Math.round(this.isSecondsMode() ? raw * this.getFps() : raw), - ); - if (!this._inputBaseline) { - this._inputBaseline = this.timeline.segments.map(s => s.length); - } - this._setLengthShifting(idx, frames, this._inputBaseline); - this.commit(); - this.render(); - this.updateTotalLabel(); - }); - - this.addBtn.addEventListener("click", () => this.addSegment()); - this.distributeBtn.addEventListener("click", () => this.distributeEvenly()); - this.deleteBtn.addEventListener("click", () => this.deleteSelected()); - - if (this.maxFramesWidget) { - const prev = this.maxFramesWidget.callback; - this.maxFramesWidget.callback = (...args) => { - prev?.apply(this.maxFramesWidget, args); - if (this.secondsWidget && this.fpsWidget) { - this.secondsWidget.value = parseFloat((this.maxFramesWidget.value / this.fpsWidget.value).toFixed(3)); - } - this.trimToFit(); - this.commit(); - this.updateUIFromSelection(); - this.render(); - }; - } - - if (this.secondsWidget) { - const prev = this.secondsWidget.callback; - this.secondsWidget.callback = (...args) => { - prev?.apply(this.secondsWidget, args); - if (this.maxFramesWidget && this.fpsWidget) { - this.maxFramesWidget.value = Math.max(1, Math.round(this.secondsWidget.value * this.fpsWidget.value)); - this.trimToFit(); - this.commit(); - } - this.updateUIFromSelection(); - this.render(); - }; - } - - if (this.fpsWidget) { - const prev = this.fpsWidget.callback; - this.fpsWidget.callback = (...args) => { - prev?.apply(this.fpsWidget, args); - if (this.secondsWidget && this.maxFramesWidget) { - this.secondsWidget.value = parseFloat((this.maxFramesWidget.value / this.fpsWidget.value).toFixed(3)); - } - this.updateUIFromSelection(); - this.render(); - }; - } - - if (this.timeUnitsWidget) { - const prev = this.timeUnitsWidget.callback; - this.timeUnitsWidget.callback = (...args) => { - prev?.apply(this.timeUnitsWidget, args); - this.updateUIFromSelection(); - this.render(); - }; - } - - this.resizeObserver = new ResizeObserver(() => this.resizeCanvas()); - this.resizeObserver.observe(this.container); - this.resizeCanvas(); - } - - resizeCanvas() { - const dpr = window.devicePixelRatio || 1; - const w = Math.max(50, Math.floor(this.canvas.offsetWidth)); - this.canvas.width = w * dpr; - this.canvas.height = CANVAS_HEIGHT * dpr; - this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - this._cssWidth = w; - this.render(); - } - - pxPerFrame() { - return this._cssWidth / this.getMaxFrames(); - } - - segmentRects(order) { - const segs = this.timeline.segments; - const ord = order ?? this._getEffectiveOrder(); - const ppf = this.pxPerFrame(); - const rects = []; - let cursor = 0; - for (let visualPos = 0; visualPos < ord.length; visualPos++) { - const idx = ord[visualPos]; - const len = segs[idx].length; - rects.push({ index: idx, visualPos, x: cursor * ppf, w: len * ppf, frameStart: cursor, frameEnd: cursor + len }); - cursor += len; - } - return rects; - } - - _getEffectiveOrder() { - const n = this.timeline.segments.length; - const natural = Array.from({ length: n }, (_, i) => i); - if (!this.reorder?.active || this.reorder.targetIdx === this.reorder.sourceIdx) return natural; - const order = natural.filter(i => i !== this.reorder.sourceIdx); - order.splice(this.reorder.targetIdx, 0, this.reorder.sourceIdx); - return order; - } - - _computeReorderTarget() { - const ppf = this.pxPerFrame(); - const sourceIdx = this.reorder.sourceIdx; - const sourceLen = this.timeline.segments[sourceIdx].length; - const blockCenterFrame = - (this.reorder.cursorX - this.reorder.dragOffsetPx) / ppf + sourceLen / 2; - - const others = []; - for (let i = 0; i < this.timeline.segments.length; i++) { - if (i !== sourceIdx) others.push(this.timeline.segments[i].length); - } - - let bestTarget = 0; - let bestDist = Infinity; - let cum = 0; - for (let target = 0; target <= others.length; target++) { - const sourceCenter = cum + sourceLen / 2; - const dist = Math.abs(blockCenterFrame - sourceCenter); - if (dist < bestDist) { bestDist = dist; bestTarget = target; } - if (target < others.length) cum += others[target]; - } - return bestTarget; - } - - hitBoundary(mx) { - const rects = this.segmentRects(); - for (let i = 0; i < rects.length; i++) { - const right = rects[i].x + rects[i].w; - if (Math.abs(mx - right) <= HANDLE_HIT_PX) return i; - } - return -1; - } - - hitBlock(mx, my) { - if (my < RULER_HEIGHT) return -1; - const rects = this.segmentRects(); - for (const r of rects) { - if (mx >= r.x && mx < r.x + r.w) return r.index; - } - return -1; - } - - onPointerDown(e) { - const { x, y } = this.localPos(e); - this._settling = false; - const handle = this.hitBoundary(x); - if (handle >= 0) { - this.dragHandle = handle; - const segs = this.timeline.segments; - this.dragStart = { - x, - initialLengths: segs.map(s => s.length), - }; - this.canvas.setPointerCapture(e.pointerId); - return; - } - const block = this.hitBlock(x, y); - if (block >= 0) { - this.selectedIndex = block; - this.updateUIFromSelection(); - this.render(); - const sourceX = this._displayedX.get(block) ?? 0; - this.reorder = { - sourceIdx: block, targetIdx: block, - startX: x, startY: y, - cursorX: x, - dragOffsetPx: x - sourceX, - active: false, - }; - try { this.canvas.setPointerCapture(e.pointerId); } catch (_) {} - } - } - - onPointerMove(e) { - const { x, y } = this.localPos(e); - if (this.dragHandle >= 0) { - const ppf = this.pxPerFrame(); - const dxFrames = Math.round((x - this.dragStart.x) / ppf); - const handle = this.dragHandle; - const initial = this.dragStart.initialLengths; - this._setLengthShifting(handle, initial[handle] + dxFrames, initial); - - const segs = this.timeline.segments; - this.commit(); - if (segs[this.selectedIndex]) this.lengthInput.value = this.lengthInputValueFor(segs[this.selectedIndex].length); - this.updateTotalLabel(); - this.render(); - return; - } - - if (this.reorder) { - const dx = x - this.reorder.startX; - const dy = y - this.reorder.startY; - if (!this.reorder.active && Math.hypot(dx, dy) > REORDER_THRESHOLD_PX) { - this.reorder.active = true; - this.canvas.style.cursor = "grabbing"; - } - if (this.reorder.active) { - this.reorder.cursorX = x; - this.reorder.targetIdx = this._computeReorderTarget(); - this.render(); - return; - } - } - - const handle = this.hitBoundary(x); - const block = handle >= 0 ? -1 : this.hitBlock(x, y); - if (handle !== this.hoverHandle || block !== this.hoverIndex) { - this.hoverHandle = handle; - this.hoverIndex = block; - this.canvas.style.cursor = handle >= 0 ? "ew-resize" : (block >= 0 ? "pointer" : "default"); - this.render(); - } - } - - onPointerUp(e) { - if (this.dragHandle >= 0) { - try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {} - this.dragHandle = -1; - this.dragStart = null; - } - if (this.reorder) { - try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {} - if (this.reorder.active) { - this.canvas.style.cursor = "default"; - const { sourceIdx, targetIdx } = this.reorder; - if (sourceIdx !== targetIdx) { - const effective = this._getEffectiveOrder(); - const oldDisplayed = new Map(this._displayedX); - const seg = this.timeline.segments.splice(sourceIdx, 1)[0]; - this.timeline.segments.splice(targetIdx, 0, seg); - this._displayedX = new Map(); - for (let newIdx = 0; newIdx < effective.length; newIdx++) { - const oldIdx = effective[newIdx]; - if (oldDisplayed.has(oldIdx)) this._displayedX.set(newIdx, oldDisplayed.get(oldIdx)); - } - this.selectedIndex = targetIdx; - this.commit(); - this.updateUIFromSelection(); - this._settling = true; - } - } - this.reorder = null; - this.render(); - } - } - - localPos(e) { - const rect = this.canvas.getBoundingClientRect(); - const sx = (rect.width / this.canvas.offsetWidth) || 1; - const sy = (rect.height / this.canvas.offsetHeight) || 1; - return { - x: (e.clientX - rect.left) / sx, - y: (e.clientY - rect.top) / sy, - }; - } - - addSegment() { - const max = this.getMaxFrames(); - const n = this.timeline.segments.length; - if (max < (n + 1) * MIN_SEGMENT_LENGTH) return; - - const desired = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / (n + 1))); - const newIdx = n; - const usedColors = new Set(this.timeline.segments.map(s => s.color)); - this.timeline.segments.push({ prompt: "", length: desired, color: pickColor(usedColors) }); - this.trimToFit(newIdx); - - let total = this.timeline.segments.reduce((a, s) => a + s.length, 0); - if (total > max) { - this.timeline.segments[newIdx].length -= (total - max); - } - - this.selectedIndex = newIdx; - this.commit(); - this.updateUIFromSelection(); - this.updateTotalLabel(); - this.render(); - } - - maxLengthFor(idx) { - const max = this.getMaxFrames(); - let others = 0; - for (let i = 0; i < this.timeline.segments.length; i++) { - if (i !== idx) others += this.timeline.segments[i].length; - } - return Math.max(MIN_SEGMENT_LENGTH, max - others); - } - - _setLengthShifting(idx, newLen, baseline) { - const segs = this.timeline.segments; - const max = this.getMaxFrames(); - for (let i = 0; i < segs.length; i++) segs[i].length = baseline[i]; - segs[idx].length = Math.max(MIN_SEGMENT_LENGTH, newLen); - let total = segs.reduce((a, s) => a + s.length, 0); - for (let i = idx + 1; i < segs.length && total > max; i++) { - const reducible = segs[i].length - MIN_SEGMENT_LENGTH; - const take = Math.min(reducible, total - max); - segs[i].length -= take; - total -= take; - } - if (total > max) segs[idx].length -= (total - max); - } - - trimToFit(protectIndex = -1) { - const max = this.getMaxFrames(); - let total = this.timeline.segments.reduce((a, s) => a + s.length, 0); - for (let i = this.timeline.segments.length - 1; i >= 0 && total > max; i--) { - if (i === protectIndex) continue; - const seg = this.timeline.segments[i]; - const reducible = seg.length - MIN_SEGMENT_LENGTH; - const take = Math.min(reducible, total - max); - seg.length -= take; - total -= take; - } - } - - distributeEvenly() { - const max = this.getMaxFrames(); - const n = this.timeline.segments.length; - if (n === 0) return; - const base = Math.max(MIN_SEGMENT_LENGTH, Math.floor(max / n)); - const remainder = Math.max(0, max - base * n); - for (let i = 0; i < n; i++) { - this.timeline.segments[i].length = base + (i < remainder ? 1 : 0); - } - this.commit(); - this.updateUIFromSelection(); - this.render(); - } - - deleteSelected() { - if (this.timeline.segments.length <= 1) return; - this.timeline.segments.splice(this.selectedIndex, 1); - this.selectedIndex = clamp(this.selectedIndex, 0, this.timeline.segments.length - 1); - this.commit(); - this.updateUIFromSelection(); - this.updateTotalLabel(); - this.render(); - } - - commit() { - this.syncWidgetsFromTimeline(); - this.node.graph?.setDirtyCanvas?.(true, true); - } - - syncWidgetsFromTimeline() { - const segs = this.timeline.segments; - if (this.timelineDataWidget) this.timelineDataWidget.value = JSON.stringify(this.timeline); - if (this.localPromptsWidget) this.localPromptsWidget.value = segs.map(s => s.prompt).join(" | "); - if (this.segmentLengthsWidget) this.segmentLengthsWidget.value = segs.map(s => s.length).join(", "); - } - - lengthInputValueFor(frames) { - if (!this.isSecondsMode()) return String(frames); - return (frames / this.getFps()).toFixed(3).replace(/\.?0+$/, ""); - } - - updateUIFromSelection() { - const seg = this.timeline.segments[this.selectedIndex]; - if (!seg) { - this.textarea.value = ""; - this.lengthInput.value = ""; - } else { - if (this.textarea.value !== seg.prompt) this.textarea.value = seg.prompt; - this.lengthInput.value = this.lengthInputValueFor(seg.length); - } - this.lengthInput.step = this.isSecondsMode() ? (1 / this.getFps()).toFixed(4) : "1"; - this.lengthInput.min = this.isSecondsMode() ? (MIN_SEGMENT_LENGTH / this.getFps()).toFixed(4) : MIN_SEGMENT_LENGTH; - this._inputBaseline = null; - this.updateTotalLabel(); - } - - updateTotalLabel() { - const total = this.timeline.segments.reduce((a, s) => a + s.length, 0); - const max = this.getMaxFrames(); - if (this.isSecondsMode()) { - const fps = this.getFps(); - const fmt = (f) => (f / fps).toFixed(2).replace(/\.?0+$/, ""); - this.totalLabel.textContent = `Total: ${fmt(total)} / ${fmt(max)} s @ ${fps}fps`; - } else { - this.totalLabel.textContent = `Total: ${total} / ${max} frames`; - } - } - - render() { - const rects = this.segmentRects(); - this._targetX = new Map(); - for (const r of rects) this._targetX.set(r.index, r.x); - - if (this.reorder?.active) { - const sourceIdx = this.reorder.sourceIdx; - const sourcePos = this.reorder.cursorX - this.reorder.dragOffsetPx; - this._targetX.set(sourceIdx, sourcePos); - this._displayedX.set(sourceIdx, sourcePos); - this._kickAnim(); - } else if (this._settling) { - this._kickAnim(); - } else { - for (const [idx, target] of this._targetX) this._displayedX.set(idx, target); - this._draw(); - } - } - - _kickAnim() { - if (this._animRaf) return; - this._animRaf = requestAnimationFrame(() => this._tick()); - } - - _tick() { - this._animRaf = null; - let needsMore = false; - const speed = 0.15; - for (const [idx, target] of this._targetX) { - const cur = this._displayedX.get(idx); - if (cur === undefined) { this._displayedX.set(idx, target); continue; } - const diff = target - cur; - if (Math.abs(diff) < 0.3) { - this._displayedX.set(idx, target); - } else { - this._displayedX.set(idx, cur + diff * speed); - needsMore = true; - } - } - this._draw(); - if (needsMore) { - this._animRaf = requestAnimationFrame(() => this._tick()); - } else if (!this.reorder?.active) { - this._settling = false; - } - } - - _draw() { - const ctx = this.ctx; - const w = this._cssWidth; - ctx.clearRect(0, 0, w, CANVAS_HEIGHT); - this.drawRuler(ctx, w); - this.drawSegments(ctx, w); - } - - drawRuler(ctx, w) { - const max = this.getMaxFrames(); - ctx.fillStyle = "#222"; - ctx.fillRect(0, 0, w, RULER_HEIGHT); - - const ppf = this.pxPerFrame(); - const targetLabelSpacing = 60; - - let step; - if (this.isSecondsMode()) { - const fps = this.getFps(); - const target = targetLabelSpacing / (ppf * fps); - const nice = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300]; - let chosen = nice[nice.length - 1]; - for (const s of nice) { if (s >= target) { chosen = s; break; } } - step = Math.max(1, Math.round(chosen * fps)); - } else { - step = Math.max(1, Math.round(targetLabelSpacing / ppf)); - const niceSteps = [1, 2, 4, 5, 8, 10, 16, 20, 25, 50, 100]; - for (const s of niceSteps) { if (s >= step) { step = s; break; } } - } - - ctx.strokeStyle = "#444"; - ctx.fillStyle = "#aaa"; - ctx.font = "10px sans-serif"; - ctx.textBaseline = "top"; - ctx.lineWidth = 1; - - for (let f = 0; f <= max; f += step) { - const x = Math.floor(f * ppf) + 0.5; - ctx.beginPath(); - ctx.moveTo(x, RULER_HEIGHT - 6); - ctx.lineTo(x, RULER_HEIGHT); - ctx.stroke(); - ctx.fillText(this.formatTime(f), x + 2, 2); - } - const xMax = Math.floor(max * ppf) - 0.5; - ctx.strokeStyle = "#666"; - ctx.beginPath(); - ctx.moveTo(xMax, 0); - ctx.lineTo(xMax, RULER_HEIGHT); - ctx.stroke(); - } - - drawSegments(ctx, w) { - const rects = this.segmentRects(); - const blockY = RULER_HEIGHT + 2; - const blockH = BLOCK_HEIGHT - 4; - - ctx.fillStyle = "#101010"; - ctx.fillRect(0, blockY, w, blockH); - - const dragIdx = this.reorder?.active ? this.reorder.sourceIdx : -1; - const rendered = [...rects].sort((a, b) => (a.index === dragIdx ? 1 : 0) - (b.index === dragIdx ? 1 : 0)); - - for (const r of rendered) { - const seg = this.timeline.segments[r.index]; - const color = seg.color || PALETTE[r.index % PALETTE.length]; - const isSelected = r.index === this.selectedIndex; - const isHover = r.index === this.hoverIndex; - const isDragging = r.index === dragIdx; - - const drawX = Math.floor(this._displayedX.get(r.index) ?? r.x); - const drawW = Math.max(2, Math.floor(r.w)); - - ctx.fillStyle = color; - ctx.globalAlpha = isDragging ? 0.9 : (isSelected ? 1.0 : (isHover ? 0.9 : 0.75)); - ctx.fillRect(drawX, blockY, drawW, blockH); - ctx.globalAlpha = 1.0; - - ctx.strokeStyle = isDragging ? "#ffd54f" : (isSelected ? "#fff" : "rgba(0,0,0,0.4)"); - ctx.lineWidth = isDragging || isSelected ? 2 : 1; - ctx.strokeRect(drawX + 0.5, blockY + 0.5, drawW - 1, blockH - 1); - - ctx.fillStyle = "#fff"; - ctx.font = "11px sans-serif"; - ctx.textBaseline = "top"; - const label = seg.prompt || `(segment ${r.index + 1})`; - const [line1, line2] = this.wrapTwoLines(ctx, label, drawW - 8); - ctx.fillText(line1, drawX + 4, blockY + 4); - if (line2) ctx.fillText(line2, drawX + 4, blockY + 18); - - ctx.fillStyle = "rgba(255,255,255,0.75)"; - ctx.font = "10px monospace"; - const range = `${this.formatTime(r.frameStart)}–${this.formatTime(r.frameEnd)} (${this.formatLength(seg.length)})`; - const rangeTrunc = this.truncateText(ctx, range, drawW - 8); - ctx.fillText(rangeTrunc, drawX + 4, blockY + blockH - 14); - } - - if (!this.reorder?.active) { - for (let i = 0; i < rects.length; i++) { - const r = rects[i]; - const drawX = this._displayedX.get(r.index) ?? r.x; - const right = Math.floor(drawX + r.w); - const isHover = i === this.hoverHandle || i === this.dragHandle; - ctx.fillStyle = isHover ? "#fff" : "rgba(255,255,255,0.4)"; - ctx.fillRect(right - 1, blockY + 4, 2, blockH - 8); - } - } - } - - truncateText(ctx, text, maxWidth) { - if (ctx.measureText(text).width <= maxWidth) return text; - let lo = 0, hi = text.length; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (ctx.measureText(text.slice(0, mid) + "…").width <= maxWidth) lo = mid; - else hi = mid - 1; - } - return lo > 0 ? text.slice(0, lo) + "…" : ""; - } - - wrapTwoLines(ctx, text, maxWidth) { - if (ctx.measureText(text).width <= maxWidth) return [text, ""]; - - const tokens = text.split(/(\s+)/); - let line1 = ""; - let consumed = 0; - for (let i = 0; i < tokens.length; i++) { - const candidate = line1 + tokens[i]; - if (ctx.measureText(candidate).width > maxWidth) break; - line1 = candidate; - consumed = i + 1; - } - - if (!line1.trim()) return [this.truncateText(ctx, text, maxWidth), ""]; - - let line2 = tokens.slice(consumed).join("").trim(); - if (!line2) return [line1.trimEnd(), ""]; - if (ctx.measureText(line2).width > maxWidth) { - line2 = this.truncateText(ctx, line2, maxWidth); - } - return [line1.trimEnd(), line2]; - } - - destroy() { - this.resizeObserver?.disconnect(); - if (this._animRaf) cancelAnimationFrame(this._animRaf); - if (this._textCommitTimer) { - clearTimeout(this._textCommitTimer); - this._textCommitTimer = null; - try { this.commit(); } catch (_) {} - } - } -} - -// Menambahkan default untuk seconds agar node lama yang belum punya tidak error -const APPENDED_WIDGET_DEFAULTS = [["fps", 24.0], ["seconds", 5.375], ["time_units", "seconds"]]; - -app.registerExtension({ - name: "PromptRelay.Timeline", - - async beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== "RikanPromptRelayEncodeTimeline") return; - - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated?.apply(this, arguments); - - for (const name of HIDDEN_WIDGET_NAMES) { - hideWidget(this.widgets.find(w => w.name === name)); - } - - const container = document.createElement("div"); - this._timelineWidget = this.addDOMWidget("prompt_relay_timeline", "PromptRelayTimeline", container, { - serialize: false, - hideOnZoom: false, - getMinHeight: () => 220, - getHeight: () => 220, - }); - - const self = this; - setTimeout(() => { - try { - self._timelineEditor = new TimelineEditor(self, container); - } catch (err) { - console.error("[PromptRelay] timeline editor init failed:", err); - } - }, 0); - - const onRemoved = this.onRemoved; - this.onRemoved = function () { - this._timelineEditor?.destroy(); - return onRemoved?.apply(this, arguments); - }; - - const onConfigure = this.onConfigure; - this.onConfigure = function (info) { - const out = onConfigure?.apply(this, arguments); - for (const [name, def] of APPENDED_WIDGET_DEFAULTS) { - const w = this.widgets.find(x => x.name === name); - if (w && (w.value == null || w.value === "")) w.value = def; - } - - setTimeout(() => { - if (this._timelineEditor) { - if (this._timelineEditor.secondsWidget && this._timelineEditor.fpsWidget && this._timelineEditor.maxFramesWidget) { - this._timelineEditor.secondsWidget.value = parseFloat((this._timelineEditor.maxFramesWidget.value / this._timelineEditor.fpsWidget.value).toFixed(3)); - } - this._timelineEditor.timeline = parseInitial( - this._timelineEditor.timelineDataWidget?.value, - this._timelineEditor.getMaxFrames(), - ); - this._timelineEditor.selectedIndex = clamp( - this._timelineEditor.selectedIndex, 0, - this._timelineEditor.timeline.segments.length - 1, - ); - this._timelineEditor.updateUIFromSelection(); - this._timelineEditor.render(); - } - }, 10); - return out; - }; - - return r; - }; - }, -}); \ No newline at end of file