Add proper timeline editor
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,84 @@ from .patches import detect_model_type, apply_patches
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames):
|
||||
"""Convert pixel-space segment lengths to integer latent-space lengths using the
|
||||
largest-remainder method. Targets the full `latent_frames` when the pixel sum looks
|
||||
like full coverage (within one stride of latent_frames * stride). Otherwise targets
|
||||
round(total_pixel / temporal_stride) so partial-coverage timelines stay partial.
|
||||
"""
|
||||
if not pixel_lengths:
|
||||
return []
|
||||
total_pixel = sum(pixel_lengths)
|
||||
if total_pixel <= 0:
|
||||
return [1] * len(pixel_lengths)
|
||||
|
||||
naive_total = max(1, round(total_pixel / temporal_stride))
|
||||
target_total = min(latent_frames, naive_total)
|
||||
# Within one frame of full → user clearly intended full coverage; pin to latent_frames.
|
||||
if target_total >= latent_frames - 1:
|
||||
target_total = latent_frames
|
||||
|
||||
exact = [p * target_total / total_pixel for p in pixel_lengths]
|
||||
result = [int(e) for e in exact]
|
||||
diff = target_total - sum(result)
|
||||
if diff > 0:
|
||||
order = sorted(range(len(exact)), key=lambda i: -(exact[i] - int(exact[i])))
|
||||
for k in range(diff):
|
||||
result[order[k % len(order)]] += 1
|
||||
|
||||
# Ensure every segment has ≥ 1 latent frame (steal from the largest if needed).
|
||||
for i in range(len(result)):
|
||||
if result[i] < 1:
|
||||
max_idx = max(range(len(result)), key=lambda j: result[j])
|
||||
if result[max_idx] > 1:
|
||||
result[max_idx] -= 1
|
||||
result[i] = 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon):
|
||||
locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()]
|
||||
if not locals_list:
|
||||
raise ValueError("At least one local prompt is required (separate with |)")
|
||||
|
||||
arch, patch_size, temporal_stride = detect_model_type(model)
|
||||
|
||||
samples = latent["samples"]
|
||||
latent_frames = samples.shape[2]
|
||||
tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2])
|
||||
|
||||
parsed_lengths = None
|
||||
if segment_lengths.strip():
|
||||
pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()]
|
||||
parsed_lengths = _convert_to_latent_lengths(pixel_lengths, temporal_stride, latent_frames)
|
||||
|
||||
raw_tokenizer = get_raw_tokenizer(clip)
|
||||
full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list)
|
||||
|
||||
log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0])
|
||||
for i, (s, e) in enumerate(token_ranges):
|
||||
log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s)
|
||||
|
||||
conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt))
|
||||
|
||||
effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths)
|
||||
|
||||
log.info(
|
||||
"[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s",
|
||||
latent_frames, tokens_per_frame, effective_lengths,
|
||||
)
|
||||
|
||||
q_token_idx = build_segments(token_ranges, effective_lengths, epsilon)
|
||||
mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames)
|
||||
|
||||
patched = model.clone()
|
||||
apply_patches(patched, arch, mask_fn)
|
||||
|
||||
return patched, conditioning
|
||||
|
||||
|
||||
class PromptRelayEncode(io.ComfyNode):
|
||||
"""Encodes temporal local prompts and patches the model for Prompt Relay."""
|
||||
|
||||
@@ -58,50 +136,75 @@ class PromptRelayEncode(io.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon) -> io.NodeOutput:
|
||||
locals_list = [p.strip() for p in local_prompts.split("|") if p.strip()]
|
||||
if not locals_list:
|
||||
raise ValueError("At least one local prompt is required (separate with |)")
|
||||
patched, conditioning = _encode_relay(
|
||||
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon,
|
||||
)
|
||||
return io.NodeOutput(patched, conditioning)
|
||||
|
||||
arch, patch_size, temporal_stride = detect_model_type(model)
|
||||
|
||||
parsed_lengths = None
|
||||
if segment_lengths.strip():
|
||||
pixel_lengths = [int(x.strip()) for x in segment_lengths.split(",") if x.strip()]
|
||||
parsed_lengths = [max(1, round(p / temporal_stride)) for p in pixel_lengths]
|
||||
class PromptRelayEncodeTimeline(io.ComfyNode):
|
||||
"""WYSIWYG timeline variant — segments and lengths come from a visual editor in the node UI."""
|
||||
|
||||
raw_tokenizer = get_raw_tokenizer(clip)
|
||||
full_prompt, token_ranges = map_token_indices(raw_tokenizer, global_prompt, locals_list)
|
||||
|
||||
log.info("[PromptRelay] Global: tokens [0:%d] (%d tokens)", token_ranges[0][0], token_ranges[0][0])
|
||||
for i, (s, e) in enumerate(token_ranges):
|
||||
log.info("[PromptRelay] Segment %d: tokens [%d:%d] (%d tokens)", i, s, e, e - s)
|
||||
|
||||
conditioning = clip.encode_from_tokens_scheduled(clip.tokenize(full_prompt))
|
||||
|
||||
samples = latent["samples"]
|
||||
latent_frames = samples.shape[2]
|
||||
tokens_per_frame = (samples.shape[3] // patch_size[1]) * (samples.shape[4] // patch_size[2])
|
||||
|
||||
effective_lengths = distribute_segment_lengths(len(locals_list), latent_frames, parsed_lengths)
|
||||
|
||||
log.info(
|
||||
"[PromptRelay] Latent: %d frames, %d tokens/frame, segments: %s",
|
||||
latent_frames, tokens_per_frame, effective_lengths,
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="PromptRelayEncodeTimeline",
|
||||
display_name="Prompt Relay Encode (Timeline)",
|
||||
category="conditioning/prompt_relay",
|
||||
description=(
|
||||
"Same as Prompt Relay Encode, but local prompts and segment lengths are edited "
|
||||
"visually as draggable blocks on a timeline. The max_frames input only sets the "
|
||||
"timeline scale (pixel space) — actual frame count is still read from the latent."
|
||||
),
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Clip.Input("clip"),
|
||||
io.Latent.Input("latent", tooltip="Empty latent video — dimensions are read from its shape."),
|
||||
io.String.Input(
|
||||
"global_prompt", multiline=True, default="",
|
||||
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,
|
||||
tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.",
|
||||
),
|
||||
io.String.Input(
|
||||
"timeline_data", default="",
|
||||
tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand).",
|
||||
),
|
||||
io.String.Input(
|
||||
"local_prompts", multiline=True, default="",
|
||||
tooltip="Auto-populated from the timeline editor.",
|
||||
),
|
||||
io.String.Input(
|
||||
"segment_lengths", default="",
|
||||
tooltip="Auto-populated from the timeline editor (pixel-space frame counts).",
|
||||
),
|
||||
io.Float.Input(
|
||||
"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.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Model.Output(display_name="model"),
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
],
|
||||
)
|
||||
|
||||
q_token_idx = build_segments(token_ranges, effective_lengths, epsilon)
|
||||
mask_fn = create_mask_fn(q_token_idx, tokens_per_frame, latent_frames)
|
||||
|
||||
patched = model.clone()
|
||||
apply_patches(patched, arch, mask_fn)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model, clip, latent, global_prompt, max_frames, timeline_data, local_prompts, segment_lengths, epsilon) -> io.NodeOutput:
|
||||
patched, conditioning = _encode_relay(
|
||||
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon,
|
||||
)
|
||||
return io.NodeOutput(patched, conditioning)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptRelayEncode": PromptRelayEncode,
|
||||
"PromptRelayEncodeTimeline": PromptRelayEncodeTimeline,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptRelayEncode": "Prompt Relay Encode",
|
||||
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
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) {
|
||||
// First palette color not currently in use; otherwise generate one via golden-angle hue
|
||||
// rotation so additional segments stay visually distinct without colliding with the palette.
|
||||
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),
|
||||
// Backward compat: assign a stable color if missing.
|
||||
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.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; // { sourceIdx, targetIdx, startX, startY, active }
|
||||
this._settling = false; // true between reorder release and animation convergence
|
||||
this._inputBaseline = null; // length snapshot for revertible lengthInput edits
|
||||
this._textCommitTimer = null; // debounce handle for textarea-driven commits
|
||||
// Per-segment displayed X (animated). Keyed by segment array index.
|
||||
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);
|
||||
}
|
||||
|
||||
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.min = MIN_SEGMENT_LENGTH;
|
||||
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() {
|
||||
// stopPropagation prevents LiteGraph from treating clicks as node-drag/zoom
|
||||
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();
|
||||
}
|
||||
});
|
||||
// Keep textarea scrolling/typing from zooming or hijacking the graph
|
||||
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;
|
||||
// Update local_prompts widget immediately so a workflow run picks up the latest text
|
||||
// even if the debounced commit hasn't fired yet.
|
||||
if (this.localPromptsWidget) {
|
||||
this.localPromptsWidget.value = this.timeline.segments.map(s => s.prompt).join(" | ");
|
||||
}
|
||||
this.render();
|
||||
// Debounce the heavier timeline_data JSON write.
|
||||
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 v = parseInt(this.lengthInput.value, 10);
|
||||
if (!Number.isFinite(v) || v < MIN_SEGMENT_LENGTH) return;
|
||||
// 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.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);
|
||||
this.trimToFit();
|
||||
this.commit();
|
||||
this.updateUIFromSelection();
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
||||
this.resizeObserver.observe(this.container);
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// offsetWidth is the canvas's CSS pixel size (ignores LiteGraph's zoom transform);
|
||||
// getBoundingClientRect would return post-transform pixels and break hit-testing math.
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── Layout ───
|
||||
|
||||
pxPerFrame() {
|
||||
return this._cssWidth / this.getMaxFrames();
|
||||
}
|
||||
|
||||
// Layout: by default uses the visual (effective) order, which matches the natural array
|
||||
// order except during an active reorder, where the dragged segment previews at targetIdx.
|
||||
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;
|
||||
}
|
||||
|
||||
// Pick the target slot whose source-slot center is closest to where the dragged block's
|
||||
// center is currently rendered. This makes swaps trigger based on the block's visible
|
||||
// overlap (not raw cursor position), so dragOffset and segment-size differences feel natural.
|
||||
_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;
|
||||
}
|
||||
|
||||
// ─── Pointer ───
|
||||
|
||||
onPointerDown(e) {
|
||||
const { x, y } = this.localPos(e);
|
||||
// Any new interaction interrupts a post-reorder settle animation.
|
||||
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();
|
||||
// dragOffsetPx = where inside the block we clicked, so the block follows the cursor at that offset.
|
||||
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 = 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();
|
||||
// Render every move so the dragged block tracks the cursor in real time.
|
||||
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) {
|
||||
// Remap displayed positions so segments visually stay where they were on release.
|
||||
// After splice, segment at new index `i` is timeline.segments[effectiveOrder[i]] (pre-splice).
|
||||
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; // lerp the dragged block from cursor pos to its final slot
|
||||
}
|
||||
}
|
||||
this.reorder = null;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
localPos(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
// rect is post-CSS-transform; divide by the on-screen-to-CSS scale to get logical coords.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mutations ───
|
||||
|
||||
addSegment() {
|
||||
const max = this.getMaxFrames();
|
||||
const n = this.timeline.segments.length;
|
||||
// Refuse only when truly impossible — every segment (incl. the new one) at MIN won't fit.
|
||||
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); // shrink other segments to fit; protect the new one
|
||||
|
||||
// If still over (e.g., desired itself was too big), shrink the new one down to its slack.
|
||||
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();
|
||||
}
|
||||
|
||||
// Max length segment `idx` can be without pushing total past max_frames.
|
||||
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);
|
||||
}
|
||||
|
||||
// Reset all lengths to `baseline`, set segment `idx` to `newLen`, then borrow from
|
||||
// subsequent segments (one at a time, down to MIN) if the total exceeds max_frames.
|
||||
// Used by both boundary drag and the length input so they share identical semantics.
|
||||
_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);
|
||||
}
|
||||
|
||||
// Trim segments from the end (then second-to-last, etc.) until total fits max_frames.
|
||||
// protectIndex (optional): a segment to leave alone (e.g. the segment we just added).
|
||||
// Caller is responsible for committing afterward.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Spread segment lengths evenly across max_frames; leftover frames go to the first segments
|
||||
// so the total exactly equals max_frames (when feasible).
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── Persistence ───
|
||||
|
||||
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(", ");
|
||||
}
|
||||
|
||||
// ─── UI sync ───
|
||||
|
||||
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 = seg.length;
|
||||
}
|
||||
// Programmatic value change invalidates any in-progress baseline.
|
||||
this._inputBaseline = null;
|
||||
this.updateTotalLabel();
|
||||
}
|
||||
|
||||
updateTotalLabel() {
|
||||
const total = this.timeline.segments.reduce((a, s) => a + s.length, 0);
|
||||
const max = this.getMaxFrames();
|
||||
this.totalLabel.textContent = `Total: ${total} / ${max} frames`;
|
||||
}
|
||||
|
||||
// ─── Render ───
|
||||
|
||||
render() {
|
||||
// Compute target X for each segment from the effective (preview) order.
|
||||
const rects = this.segmentRects();
|
||||
this._targetX = new Map();
|
||||
for (const r of rects) this._targetX.set(r.index, r.x);
|
||||
|
||||
if (this.reorder?.active) {
|
||||
// The dragged segment follows the cursor in real time (no lerp). Other segments
|
||||
// lerp toward their preview-order slots.
|
||||
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) {
|
||||
// Post-release: lerp displayed positions toward their final slots without snapping.
|
||||
this._kickAnim();
|
||||
} else {
|
||||
// All other state changes (boundary drag, add/delete, length input, resize) snap.
|
||||
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();
|
||||
// Keep ticking only while there's lerp work to do — render() will re-kick when targets change.
|
||||
if (needsMore) {
|
||||
this._animRaf = requestAnimationFrame(() => this._tick());
|
||||
} else if (!this.reorder?.active) {
|
||||
// Animation has converged and no reorder is in progress — clear settling flag.
|
||||
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 = 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(String(f), x + 2, 2);
|
||||
}
|
||||
// Final tick at max if not aligned
|
||||
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;
|
||||
|
||||
// Empty timeline background
|
||||
ctx.fillStyle = "#101010";
|
||||
ctx.fillRect(0, blockY, w, blockH);
|
||||
|
||||
// Render in two passes so the dragged segment is on top during reorder.
|
||||
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);
|
||||
|
||||
// Label: prompt (wrapped to 2 lines) + frame range
|
||||
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 = `${r.frameStart}–${r.frameEnd} (${seg.length}f)`;
|
||||
const rangeTrunc = this.truncateText(ctx, range, drawW - 8);
|
||||
ctx.fillText(rangeTrunc, drawX + 4, blockY + blockH - 14);
|
||||
}
|
||||
|
||||
// Boundary handles (visible cue) — hidden during reorder since they're not interactive then.
|
||||
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) + "…" : "";
|
||||
}
|
||||
|
||||
// Greedy word wrap to at most two lines; line 2 ellipsizes if it still overflows.
|
||||
wrapTwoLines(ctx, text, maxWidth) {
|
||||
if (ctx.measureText(text).width <= maxWidth) return [text, ""];
|
||||
|
||||
const tokens = text.split(/(\s+)/); // alternating words and whitespace
|
||||
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;
|
||||
}
|
||||
|
||||
// First word is wider than maxWidth — fall back to char-level ellipsis on one line.
|
||||
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;
|
||||
// Best-effort flush so an in-flight prompt edit doesn't disappear if the user
|
||||
// removes the node mid-typing (commit is a no-op if widgets are gone).
|
||||
try { this.commit(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "PromptRelay.Timeline",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== "PromptRelayEncodeTimeline") 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,
|
||||
});
|
||||
|
||||
// Defer construction until widgets are settled (configure runs after onNodeCreated for saved nodes)
|
||||
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);
|
||||
// Rebuild from restored widget values
|
||||
setTimeout(() => {
|
||||
if (this._timelineEditor) {
|
||||
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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user