From 7e61a3fa5d8738acd773f2be9afd47fc3b3eed05 Mon Sep 17 00:00:00 2001 From: kijai <40791699+kijai@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:25:17 +0300 Subject: [PATCH] Add time_units option (frames or seconds) --- nodes.py | 13 +++- web/js/prompt_relay_timeline.js | 104 ++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/nodes.py b/nodes.py index 543d264..dd8e330 100644 --- a/nodes.py +++ b/nodes.py @@ -165,7 +165,7 @@ class PromptRelayEncodeTimeline(io.ComfyNode): tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.", ), io.Int.Input( - "max_frames", default=49, min=1, max=10000, step=1, + "max_frames", default=129, min=1, max=10000, step=1, tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.", ), io.String.Input( @@ -184,6 +184,14 @@ class PromptRelayEncodeTimeline(io.ComfyNode): "epsilon", default=1e-3, min=1e-6, max=0.99, step=1e-4, tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.", ), + io.Float.Input( + "fps", default=24.0, min=0.1, max=240.0, step=0.1, optional=True, + tooltip="Frames per second — only affects how time is displayed in the timeline editor when time_units is set to 'seconds'.", + ), + io.Combo.Input( + "time_units", options=["frames", "seconds"], default="frames", optional=True, + tooltip="Display the ruler, segment ranges, length input, and total in frames or seconds. Internal storage is always pixel-space frames.", + ), ], outputs=[ io.Model.Output(display_name="model"), @@ -191,8 +199,9 @@ class PromptRelayEncodeTimeline(io.ComfyNode): ], ) + @classmethod - def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon) -> io.NodeOutput: + def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon, fps=24.0, time_units="frames") -> io.NodeOutput: patched, conditioning = _encode_relay( model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, ) diff --git a/web/js/prompt_relay_timeline.js b/web/js/prompt_relay_timeline.js index f032997..11ef283 100644 --- a/web/js/prompt_relay_timeline.js +++ b/web/js/prompt_relay_timeline.js @@ -64,6 +64,8 @@ class TimelineEditor { 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.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"); @@ -94,6 +96,29 @@ class TimelineEditor { 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"; + } + + // Format an integer frame count for display in the current units. In seconds mode we show + // a tidy decimal (trims trailing zeros) so 24-frame chunks render as "1s" not "1.00s". + formatTime(frames) { + if (!this.isSecondsMode()) return String(frames); + const s = frames / this.getFps(); + return `${s.toFixed(2).replace(/\.?0+$/, "")}s`; + } + + // Length-suffix shown on each block. Frames mode adds an "f" suffix here (not in the + // ruler) so block labels read as a duration, not a frame index. + formatLength(frames) { + return this.isSecondsMode() ? this.formatTime(frames) : `${frames}f`; + } + buildDOM() { this.container.innerHTML = ""; this.container.style.cssText = ` @@ -130,7 +155,6 @@ class TimelineEditor { lengthLabel.textContent = "Length:"; this.lengthInput = document.createElement("input"); this.lengthInput.type = "number"; - this.lengthInput.min = MIN_SEGMENT_LENGTH; this.lengthInput.style.cssText = ` width: 70px; background: #2a2a2a; color: #eee; border: 1px solid #444; border-radius: 3px; padding: 2px 4px; @@ -229,13 +253,19 @@ class TimelineEditor { const idx = this.selectedIndex; const seg = this.timeline.segments[idx]; if (!seg) return; - const v = parseInt(this.lengthInput.value, 10); - if (!Number.isFinite(v) || v < MIN_SEGMENT_LENGTH) return; + const raw = parseFloat(this.lengthInput.value); + if (!Number.isFinite(raw)) return; + // In seconds mode the user types seconds; convert to whole frames since the + // backend pipeline is frame-based. Round so 0.5s @ 24fps → 12 frames. + const frames = Math.max( + MIN_SEGMENT_LENGTH, + Math.round(this.isSecondsMode() ? raw * this.getFps() : raw), + ); // Snapshot pre-edit state on the first keystroke so 20→30→20 reverts cleanly. if (!this._inputBaseline) { this._inputBaseline = this.timeline.segments.map(s => s.length); } - this._setLengthShifting(idx, v, this._inputBaseline); + this._setLengthShifting(idx, frames, this._inputBaseline); this.commit(); this.render(); this.updateTotalLabel(); @@ -255,6 +285,17 @@ class TimelineEditor { this.render(); }; } + // fps and time_units only affect display — re-render and refresh the editable readouts + // (length input, total label) so the active units stay in sync with the widget values. + for (const w of [this.fpsWidget, this.timeUnitsWidget]) { + if (!w) continue; + const prev = w.callback; + w.callback = (...args) => { + prev?.apply(w, args); + this.updateUIFromSelection(); + this.render(); + }; + } this.resizeObserver = new ResizeObserver(() => this.resizeCanvas()); this.resizeObserver.observe(this.container); @@ -396,7 +437,7 @@ class TimelineEditor { const segs = this.timeline.segments; this.commit(); - if (segs[this.selectedIndex]) this.lengthInput.value = segs[this.selectedIndex].length; + if (segs[this.selectedIndex]) this.lengthInput.value = this.lengthInputValueFor(segs[this.selectedIndex].length); this.updateTotalLabel(); this.render(); return; @@ -586,6 +627,13 @@ class TimelineEditor { // ─── UI sync ─── + // Value to put in the length for a given frame count, formatted in active units. + // Seconds mode shows up to 3 decimals (trimmed) so 1-frame steps are visible at any fps. + 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) { @@ -593,8 +641,11 @@ class TimelineEditor { this.lengthInput.value = ""; } else { if (this.textarea.value !== seg.prompt) this.textarea.value = seg.prompt; - this.lengthInput.value = seg.length; + this.lengthInput.value = this.lengthInputValueFor(seg.length); } + // Step the input by 1 frame's worth so spinner clicks/arrow keys move sensibly in either mode. + 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; // Programmatic value change invalidates any in-progress baseline. this._inputBaseline = null; this.updateTotalLabel(); @@ -603,7 +654,13 @@ class TimelineEditor { updateTotalLabel() { const total = this.timeline.segments.reduce((a, s) => a + s.length, 0); const max = this.getMaxFrames(); - this.totalLabel.textContent = `Total: ${total} / ${max} frames`; + 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 ─── @@ -677,9 +734,23 @@ class TimelineEditor { const ppf = this.pxPerFrame(); const targetLabelSpacing = 60; - let 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; } } + + // Pick a tick step. Seconds-mode chooses a "nice" duration in seconds and converts to + // frames so ticks land on whole-second boundaries when fps is integer; frames-mode + // uses the original frame-count nice list. + 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"; @@ -693,7 +764,7 @@ class TimelineEditor { ctx.moveTo(x, RULER_HEIGHT - 6); ctx.lineTo(x, RULER_HEIGHT); ctx.stroke(); - ctx.fillText(String(f), x + 2, 2); + ctx.fillText(this.formatTime(f), x + 2, 2); } // Final tick at max if not aligned const xMax = Math.floor(max * ppf) - 0.5; @@ -747,7 +818,7 @@ class TimelineEditor { ctx.fillStyle = "rgba(255,255,255,0.75)"; ctx.font = "10px monospace"; - const range = `${r.frameStart}–${r.frameEnd} (${seg.length}f)`; + 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); } @@ -814,6 +885,11 @@ class TimelineEditor { } } +// Workflows saved before fps/time_units existed restore with a shorter widgets_values +// array, leaving the new widgets at null / "". ComfyUI's input validator then rejects "" +// for the Float fps input — restore schema defaults on configure. +const APPENDED_WIDGET_DEFAULTS = [["fps", 24.0], ["time_units", "frames"]]; + app.registerExtension({ name: "PromptRelay.Timeline", @@ -855,6 +931,10 @@ app.registerExtension({ 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; + } // Rebuild from restored widget values setTimeout(() => { if (this._timelineEditor) {