Files
WhatDreamsCost-Dumas/js/ltx_director.js
2026-06-02 12:22:23 +02:00

4058 lines
147 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- START OF FILE ltx_director.js ---
const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api;
// --- UI Constants & Configuration ---
const RULER_HEIGHT = 24;
const BLOCK_HEIGHT = 160; // Increased to make the image timeline area much taller
const AUDIO_TRACK_HEIGHT = 80;
const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT + AUDIO_TRACK_HEIGHT;
const HANDLE_HIT_PX = 14;
const MIN_SEGMENT_LENGTH = 6;
const MAX_THUMBNAIL_DIM = 512; // Increased to maintain quality for taller images
const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths", "guide_strength", "audio_data", "use_custom_audio"];
function hideWidget(w) {
if (!w) return;
if (!w._origType && w.type !== "hidden") w._origType = w.type;
// We don't set w.type = "hidden" anymore because it causes rendering issues in Nodes 2.0.
// Instead we use the computeSize = () => [0,0] trick which works in both V1 and V2.
w.hidden = true;
if (!w.options) w.options = {};
w.options.hidden = true;
w.computeSize = () => [0, 0];
if (w.element) w.element.style.display = "none";
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// --- Modern Dark/Grey UI CSS (ComfyUI Match) ---
const STYLES = `
.pr-wrapper {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
box-sizing: border-box;
padding-bottom: 4px;
}
.pr-wrapper.drag-active {
outline: 2px dashed #888;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.pr-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0px;
flex-wrap: wrap;
gap: 6px;
}
.pr-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pr-btn {
background: #222;
color: #e0e0e0;
border: 1px solid #111;
border-radius: 4px;
padding: 6px 12px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.pr-btn:hover {
background: #333;
border-color: #555;
}
.pr-btn-danger:hover {
background: #4a1515;
border-color: #cc4444;
color: #ffaaaa;
}
.pr-canvas {
border-radius: 6px;
border: 1px solid #111;
background: #2a2a2a;
cursor: pointer;
width: 100%;
outline: none;
display: block; /* Ensure no inline baseline gaps */
}
.pr-prop-container {
display: flex;
flex-direction: column;
width: 100%;
flex-grow: 1; /* Automatically scales to fill node height */
min-height: 40px;
}
.pr-prompt-area {
width: 100%;
height: 100%;
background: #222;
color: #e0e0e0;
border: 1px solid #111;
border-radius: 6px;
padding: 8px;
resize: none; /* Removed the manual resize corner handle */
font-size: 12px;
line-height: 1.4;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s ease;
}
.pr-prompt-area:focus {
border-color: #888;
}
.pr-audio-info {
width: 100%;
height: 100%;
background: #181818;
color: #aaa;
border: 1px solid #111;
border-radius: 6px;
padding: 10px;
font-size: 12px;
line-height: 1.6;
box-sizing: border-box;
display: none;
}
.pr-audio-info span { color: #fff; font-weight: 500; }
.pr-controls-group {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 4px;
box-sizing: border-box;
width: 100%;
}
.pr-strength-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
box-sizing: border-box;
}
.pr-height-resizer {
height: 6px;
background: #2a2a2a;
cursor: ns-resize;
border-radius: 3px;
margin: 2px 0;
transition: background 0.15s;
border: 1px solid #1e1e1e;
}
.pr-height-resizer:hover {
background: #444;
border-color: #555;
}
.pr-strength-label {
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
margin-left: auto;
}
.pr-strength-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
border: 1px solid #222;
}
.pr-strength-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #aaa;
cursor: pointer;
}
.pr-strength-slider:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pr-strength-input {
font-size: 12px;
color: #fff;
background: #222;
border: 1px solid #444;
border-radius: 4px;
width: 52px;
text-align: center;
padding: 3px;
}
.pr-strength-input::-webkit-outer-spin-button,
.pr-strength-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pr-strength-input[type=number] {
-moz-appearance: textfield;
}
.pr-strength-input:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pr-gap-menu {
position: fixed;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 9999;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
}
.pr-gap-menu-btn {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 14px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
text-align: left;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s ease;
}
.pr-gap-menu-btn:hover {
background: #3a3a3a;
border-color: #666;
}
.pr-player-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 2px 0;
flex-wrap: wrap;
width: 100%;
}
.pr-icon-btn {
background: #2a2a2a;
border: 1px solid #444;
color: #eee;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.pr-icon-btn * {
pointer-events: none;
}
.pr-icon-btn:hover {
color: #fff;
background: #3a3a3a;
border-color: #666;
}
.pr-icon-btn.active {
color: #4fff8f;
border-color: #4fff8f;
background: #1a3a2a;
}
.pr-seek-bar {
-webkit-appearance: none;
appearance: none;
height: 6px;
background: #444;
border-radius: 3px;
outline: none;
cursor: pointer;
border: 1px solid #222;
}
.pr-seek-bar::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ff4444;
cursor: pointer;
border: 2px solid #222;
}
.pr-timeline-viewport {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.pr-timeline-viewport::-webkit-scrollbar {
height: 10px;
}
.pr-timeline-viewport::-webkit-scrollbar-track {
background: #151515;
border-radius: 5px;
}
.pr-timeline-viewport::-webkit-scrollbar-thumb {
background: #444
border-radius: 5px;
border: 1px solid #000;
}
.pr-timeline-viewport::-webkit-scrollbar-thumb:hover {
background: #666
border-color: #000;
}
.pr-zoom-controls {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
}
.pr-zoom-slider {
width: 80px;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.pr-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #aaa;
cursor: pointer;
}
.pr-right-group {
display: flex;
align-items: center;
gap: 12px;
}
.pr-segment-bounds {
font-size: 12px;
color: #aaa;
font-family: monospace;
}
.pr-timecode {
font-size: 14px;
font-weight: bold;
color: #e0e0e0;
font-family: monospace;
}
.pr-settings-menu {
position: fixed;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 6px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
box-shadow: 0 4px 20px rgba(0,0,0,0.7);
min-width: 220px;
}
.pr-settings-title {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.06em;
padding-bottom: 4px;
border-bottom: 1px solid #333;
margin-bottom: 2px;
}
.pr-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pr-settings-label {
font-size: 12px;
color: #bbb;
flex: 1;
white-space: nowrap;
}
.pr-number-control {
display: flex;
align-items: center;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
overflow: hidden;
}
.pr-number-btn {
background: #333;
color: #aaa;
border: none;
width: 20px;
height: 22px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
user-select: none;
}
.pr-number-btn:hover {
background: #444;
color: #fff;
}
.pr-settings-input {
background: transparent;
color: #e0e0e0;
border: none;
padding: 0 4px;
font-size: 12px;
width: 50px;
height: 22px;
text-align: center;
font-family: monospace;
outline: none;
-moz-appearance: textfield;
}
.pr-settings-input::-webkit-outer-spin-button,
.pr-settings-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pr-settings-select {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #444;
border-radius: 4px;
padding: 3px 4px;
font-size: 12px;
width: 98px;
cursor: pointer;
}
.pr-settings-divider {
border: none;
border-top: 1px solid #2a2a2a;
margin: 2px 0;
}
.pr-settings-toggle-btn {
width: 100%;
background: #252525;
color: #aaa;
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
font-size: 11px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.pr-settings-toggle-btn:hover {
background: #2e2e2e;
color: #ccc;
border-color: #555;
}
.pr-settings-close-btn {
background: transparent;
color: #888;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s;
}
.pr-settings-close-btn:hover {
color: #fff;
background: rgba(255,255,255,0.1);
}
.pr-segmented-control {
display: flex;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
padding: 2px;
width: 110px;
height: 22px;
align-items: center;
box-sizing: border-box;
}
.pr-segment {
flex: 1;
text-align: center;
font-size: 10px;
font-weight: 500;
line-height: 18px;
cursor: pointer;
border-radius: 4px;
color: #888;
transition: all 0.15s ease;
}
.pr-segment.active {
background: #333;
color: #fff;
}
.pr-segment:hover:not(.active) {
color: #ccc;
}
/* Autocomplete suggestion styles */
.pr-autocomplete-menu {
position: fixed;
background: #181818;
border: 1px solid #444;
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 10000;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
min-width: 180px;
max-height: 200px;
overflow-y: auto;
}
.pr-autocomplete-item {
background: #252525;
color: #aaa;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 12px;
font-size: 11px;
font-family: monospace;
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.15s ease;
}
.pr-autocomplete-item:hover, .pr-autocomplete-item.active {
background: #1c222d;
color: #4fff8f;
border-color: #4fff8f;
}
.pr-autocomplete-item span {
font-weight: bold;
font-size: 12px;
}
.pr-autocomplete-item small {
color: #777;
font-size: 10px;
}
.pr-autocomplete-item.active small {
color: #4fff8f;
opacity: 0.8;
}
`;
if (!document.getElementById("prompt-relay-styles")) {
const styleEl = document.createElement("style");
styleEl.id = "prompt-relay-styles";
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
}
// --- Icons ---
const ICONS = {
upload: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>`,
audio: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
trash: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
text: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`,
play: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>`,
pause: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>`,
loop: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12A9 9 0 0 0 6 5.3L3 8"></path><polyline points="3 3 3 8 8 8"></polyline><path d="M3 12a9 9 0 0 0 15 6.7l3-2.7"></path><polyline points="21 21 21 16 16 16"></polyline></svg>`,
minus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
plus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
fit: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><polyline points="8 7 3 12 8 17"></polyline><polyline points="16 7 21 12 16 17"></polyline></svg>`,
gear: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
close: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`
};
// --- Data Models ---
function parseInitial(jsonStr) {
let parsed = { segments: [], audioSegments: [] };
try {
if (jsonStr) {
const p = JSON.parse(jsonStr);
if (Array.isArray(p.segments)) parsed.segments = p.segments;
if (Array.isArray(p.audioSegments)) parsed.audioSegments = p.audioSegments;
}
} catch (e) { }
let currentStart = 0;
for (let seg of parsed.segments) {
if (seg.start === undefined) {
seg.start = currentStart;
currentStart += seg.length;
}
// Guarantee ID assignment to prevent node loading drag breaks
if (!seg.id) {
seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
}
}
for (let seg of parsed.audioSegments) {
if (!seg.id) {
seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
}
if (seg.trimStart === undefined) seg.trimStart = 0;
}
return parsed;
}
class TimelineEditor {
constructor(node, container, domWidget) {
this.node = node;
this.container = container;
this.domWidget = domWidget;
// Track heights (dynamic)
this.rulerHeight = RULER_HEIGHT;
this.blockHeight = BLOCK_HEIGHT;
this.audioTrackHeight = AUDIO_TRACK_HEIGHT;
this.canvasHeight = CANVAS_HEIGHT;
// Core data
this.timeline = { segments: [], audioSegments: [] };
this.selectionType = "image"; // "image" or "audio"
this.selectedIndex = -1;
// Interactions
this._isDragging = false;
this._dragType = null;
this._dragStartX = 0;
this._dragInitialTimeline = null;
this.zoomLevel = 1.0;
this._lastZoom = 1.0;
this._lastScale = 1.0;
this._dragTargetId = null;
this._dragTargetIdRight = null;
this._previewSegments = null;
this._lastWidth = 0;
this._hoveredGapIdx = -1;
this._isHovering = false;
// Playback state
this.currentFrame = 0;
this.isPlaying = false;
this.isLooping = false;
this.audioContext = null;
this.activeAudioNodes = [];
this.playbackStartTime = 0;
this.playbackStartFrame = 0;
this._playLoopId = null;
// --- Ghost dragging state ---
this._ghostSegmentId = null;
this._ghostTrack = null;
this._ghostInitialTimeline = null;
// Attach to Python widgets
this._gapMenu = null; // Active gap popup menu element
this._gapMenuDismisser = null;
// Attach to Python widgets
this.durationFramesWidget = this.node.widgets.find(w => w.name === "duration_frames");
this.durationSecondsWidget = this.node.widgets.find(w => w.name === "duration_seconds");
this.frameRateWidget = this.node.widgets.find(w => w.name === "frame_rate");
this.timelineDataWidget = this.node.widgets.find(w => w.name === "timeline_data");
this.localPromptsWidget = this.node.widgets.find(w => w.name === "local_prompts");
this.segmentLengthsWidget = this.node.widgets.find(w => w.name === "segment_lengths");
this.guideStrengthWidget = this.node.widgets.find(w => w.name === "guide_strength");
this.displayModeWidget = this.node.widgets.find(w => w.name === "display_mode");
this.timeline = parseInitial(this.timelineDataWidget?.value);
this.loadImages();
this.createDOM();
if (this.timeline.segments.length > 0) {
this.selectedIndex = 0;
}
this.updateUIFromSelection();
this.commitChanges(true);
// Hide settings widgets by default to reduce node clutter.
// Deferred so all widget types are finalized before we touch them.
setTimeout(() => this.hideSettingsWidgets(), 0);
let isSyncing = false;
const origDurationFramesCallback = this.durationFramesWidget?.callback;
if (this.durationFramesWidget) {
this.durationFramesWidget.callback = (...args) => {
if (origDurationFramesCallback) origDurationFramesCallback.apply(this.durationFramesWidget, args);
if (!isSyncing && this.durationSecondsWidget) {
isSyncing = true;
this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3));
isSyncing = false;
}
this.commitChanges();
};
}
const origDurationSecondsCallback = this.durationSecondsWidget?.callback;
if (this.durationSecondsWidget) {
this.durationSecondsWidget.callback = (...args) => {
if (origDurationSecondsCallback) origDurationSecondsCallback.apply(this.durationSecondsWidget, args);
if (!isSyncing && this.durationFramesWidget) {
isSyncing = true;
const newFrames = Math.max(1, Math.round(this.durationSecondsWidget.value * this.getFrameRate()));
this.durationFramesWidget.value = newFrames;
if (this.durationFramesWidget.callback) this.durationFramesWidget.callback(newFrames);
isSyncing = false;
}
};
}
const origFrameRateCallback = this.frameRateWidget?.callback;
if (this.frameRateWidget) {
this.frameRateWidget.callback = (...args) => {
if (origFrameRateCallback) origFrameRateCallback.apply(this.frameRateWidget, args);
if (!isSyncing && this.durationSecondsWidget) {
isSyncing = true;
this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3));
isSyncing = false;
}
};
}
const origDisplayModeCallback = this.displayModeWidget?.callback;
if (this.displayModeWidget) {
this.displayModeWidget.callback = (...args) => {
if (origDisplayModeCallback) origDisplayModeCallback.apply(this.displayModeWidget, args);
this.updateWidgetVisibility();
this.updateUIFromSelection();
this.render();
};
this.updateWidgetVisibility(); // Initial trigger
}
// Polling is much more reliable in Comfy than ResizeObserver due to scale transforms
this._renderLoop = requestAnimationFrame(() => this.checkResize());
}
destroy() {
cancelAnimationFrame(this._renderLoop);
this.pauseAudio();
window.removeEventListener("keydown", this.handleKeyDown, true);
window.removeEventListener("paste", this.handlePaste, true);
if (this._autocompleteMenu) { this._autocompleteMenu.remove(); }
}
getDurationFrames() {
return parseInt((this.durationFramesWidget && this.durationFramesWidget.value > 0) ? this.durationFramesWidget.value : 24, 10);
}
getFrameRate() {
return parseInt((this.frameRateWidget && this.frameRateWidget.value > 0) ? this.frameRateWidget.value : 24, 10);
}
// Grow the timeline duration to fit `requiredFrames` if it is currently shorter.
// The timeline only ever grows — never shrinks — through this method.
growTimelineIfNeeded(requiredFrames) {
const current = this.getDurationFrames();
if (requiredFrames <= current) return; // already big enough
const newFrames = Math.ceil(requiredFrames);
if (this.durationFramesWidget) {
this.durationFramesWidget.value = newFrames;
}
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = parseFloat((newFrames / this.getFrameRate()).toFixed(3));
}
// Notify ComfyUI that the widget value changed so it serialises correctly.
if (window.app && window.app.graph) {
window.app.graph.setDirtyCanvas(true, true);
}
}
// Returns the maximum allowed zoom level, computed so that at max zoom
// the viewport shows exactly 4 seconds of the visual timeline.
getMaxZoom() {
const visualDurationSecs = this.getVisualDurationFrames() / this.getFrameRate();
const baseMaxZoom = Math.max(1, visualDurationSecs / 4);
// Limit max zoom to prevent canvas width from exceeding browser limits (causing crash)
const viewportWidth = this.viewport ? this.viewport.clientWidth : 1000;
const MAX_CANVAS_WIDTH = 32768; // Extended limit for modern browsers
const limitMaxZoom = MAX_CANVAS_WIDTH / Math.max(1, viewportWidth);
return Math.max(1, Math.min(baseMaxZoom, limitMaxZoom));
}
// Returns the visual timeline length in frames:
// the furthest segment end (across both tracks) × 1.30, with a floor of getDurationFrames().
// This is used for all rendering/positioning — the actual output duration is getDurationFrames().
getVisualDurationFrames() {
let furthest = 0;
for (const seg of this.timeline.segments) {
furthest = Math.max(furthest, seg.start + seg.length);
}
for (const seg of this.timeline.audioSegments) {
furthest = Math.max(furthest, seg.start + seg.length);
}
const outputDuration = this.getDurationFrames();
if (furthest <= 0) return outputDuration;
return Math.max(outputDuration, Math.ceil(furthest * 1.30));
}
// Sync the zoom slider's max attribute to the current getMaxZoom() value,
// clamping zoomLevel if it now exceeds the new max.
updateZoomSliderMax() {
if (!this.zoomSlider) return;
const maxZoom = this.getMaxZoom();
this.zoomSlider.max = maxZoom.toFixed(2);
if (this.zoomLevel > maxZoom) {
this.zoomLevel = maxZoom;
this.zoomSlider.value = maxZoom;
// Resize the canvas to match the clamped zoom
const viewportWidth = this.viewport ? this.viewport.clientWidth : 0;
if (viewportWidth > 0) {
const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel);
this.canvas.style.width = newCanvasWidth + "px";
this.resizeCanvas(newCanvasWidth);
}
}
}
loadImages() {
for (const seg of this.timeline.segments) {
if (seg.imageB64 && !seg.imgObj) {
seg.imgObj = new Image();
seg.imgObj.onload = () => this.render();
seg.imgObj.src = seg.imageB64;
}
}
}
createDOM() {
this.wrapper = document.createElement("div");
this.wrapper.className = "pr-wrapper";
this.wrapper.addEventListener("mouseenter", () => { this._isHovering = true; });
this.wrapper.addEventListener("mouseleave", () => { this._isHovering = false; });
this.handleKeyDown = (e) => {
const activeTag = document.activeElement ? document.activeElement.tagName : "";
if (activeTag === "INPUT" || activeTag === "TEXTAREA") return;
if ((e.key === "Delete" || e.key === "Backspace") && this.selectedIndex !== -1 && this._isHovering) {
this.deleteSelectedSegment();
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
} else if ((e.key === " " || e.code === "Space") && this._isHovering) {
this.togglePlay();
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
}
};
window.addEventListener("keydown", this.handleKeyDown, true);
this.handlePaste = (e) => {
if (this._isHovering) {
const activeTag = document.activeElement ? document.activeElement.tagName : "";
if (activeTag === "INPUT" || activeTag === "TEXTAREA") return;
if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) {
const imageFiles = Array.from(e.clipboardData.files).filter(f => f.type.startsWith("image/"));
if (imageFiles.length > 0) {
this.handleImageUpload(imageFiles, this.currentFrame);
e.preventDefault();
e.stopPropagation();
}
}
}
};
window.addEventListener("paste", this.handlePaste, true);
// --- Toolbar ---
const toolbar = document.createElement("div");
toolbar.className = "pr-toolbar";
const actionGroup = document.createElement("div");
actionGroup.className = "pr-actions";
this.fileInput = document.createElement("input");
this.fileInput.type = "file";
this.fileInput.accept = "image/*";
this.fileInput.multiple = true;
this.fileInput.style.display = "none";
this.fileInput.addEventListener("change", (e) => this.handleImageUpload(e.target.files));
this.audioFileInput = document.createElement("input");
this.audioFileInput.type = "file";
this.audioFileInput.accept = "audio/*";
this.audioFileInput.multiple = true;
this.audioFileInput.style.display = "none";
this.audioFileInput.addEventListener("change", (e) => this.handleAudioUpload(e.target.files));
const uploadBtn = document.createElement("button");
uploadBtn.className = "pr-btn";
uploadBtn.innerHTML = `${ICONS.upload} Add Image`;
uploadBtn.addEventListener("click", () => this.fileInput.click());
const uploadAudioBtn = document.createElement("button");
uploadAudioBtn.className = "pr-btn";
uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`;
uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click());
const addTextBtn = document.createElement("button");
addTextBtn.className = "pr-btn";
addTextBtn.innerHTML = `${ICONS.text} Add Text`;
addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace());
const deleteBtn = document.createElement("button");
deleteBtn.className = "pr-btn pr-btn-danger";
deleteBtn.innerHTML = `${ICONS.trash} Delete`;
deleteBtn.addEventListener("click", () => this.deleteSelectedSegment());
actionGroup.appendChild(this.fileInput);
actionGroup.appendChild(this.audioFileInput);
actionGroup.appendChild(uploadBtn);
actionGroup.appendChild(addTextBtn);
actionGroup.appendChild(uploadAudioBtn);
actionGroup.appendChild(deleteBtn);
toolbar.appendChild(actionGroup);
const rightGroup = document.createElement("div");
rightGroup.className = "pr-right-group";
this.segmentBoundsDisplay = document.createElement("div");
this.segmentBoundsDisplay.className = "pr-segment-bounds";
this.segmentBoundsDisplay.textContent = "Start: - | End: -";
this.timeCodeDisplay = document.createElement("div");
this.timeCodeDisplay.className = "pr-timecode";
this.timeCodeDisplay.textContent = this.formatTime(0);
const settingsBtn = document.createElement("button");
settingsBtn.className = "pr-btn";
settingsBtn.style.padding = "6px";
settingsBtn.style.justifyContent = "center";
settingsBtn.style.width = "28px";
settingsBtn.style.height = "28px";
settingsBtn.style.boxSizing = "border-box";
settingsBtn.innerHTML = ICONS.gear;
settingsBtn.title = "Settings";
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this._settingsMenu) {
this.dismissSettingsMenu();
} else {
this.showSettingsMenu(settingsBtn);
}
});
const toggleBtn = document.createElement("button");
toggleBtn.className = "pr-btn";
toggleBtn.style.padding = "6px 8px";
toggleBtn.style.fontSize = "11px";
toggleBtn.style.marginRight = "0px";
toggleBtn.textContent = "Custom Audio: OFF";
toggleBtn.title = "Toggle Custom Audio Output";
const updateToggleStyle = (isOn) => {
toggleBtn.textContent = isOn ? "Custom Audio: ON" : "Custom Audio: OFF";
if (isOn) {
toggleBtn.style.background = "#1c222d";
toggleBtn.style.borderColor = "#283142";
toggleBtn.style.color = "#e0e0e0";
} else {
toggleBtn.style.background = "#222";
toggleBtn.style.borderColor = "#111";
toggleBtn.style.color = "#e0e0e0";
}
};
toggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
const widget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (widget) {
widget.value = !widget.value;
updateToggleStyle(widget.value);
this.node.setDirtyCanvas(true, true);
}
});
// Initial state check (widgets might not be ready immediately)
setTimeout(() => {
const widget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (widget) {
updateToggleStyle(widget.value);
}
}, 100);
const helpBtn = document.createElement("button");
helpBtn.className = "pr-btn";
helpBtn.style.padding = "6px";
helpBtn.style.justifyContent = "center";
helpBtn.style.width = "28px";
helpBtn.style.height = "28px";
helpBtn.style.boxSizing = "border-box";
helpBtn.innerHTML = "?";
helpBtn.title = "Help / Documentation";
helpBtn.addEventListener("click", (e) => {
e.stopPropagation();
window.open("https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI", "_blank");
});
const btnGroup = document.createElement("div");
btnGroup.style.display = "flex";
btnGroup.style.gap = "6px";
btnGroup.style.alignItems = "center";
btnGroup.appendChild(toggleBtn);
btnGroup.appendChild(helpBtn);
btnGroup.appendChild(settingsBtn);
rightGroup.appendChild(btnGroup);
toolbar.appendChild(rightGroup);
// --- Canvas & Viewport ---
this.viewport = document.createElement("div");
this.viewport.className = "pr-timeline-viewport";
this.viewport.addEventListener("wheel", (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
let zoomDelta = e.deltaY > 0 ? -0.5 : 0.5;
this.zoomLevel = Math.max(1, Math.min(this.getMaxZoom(), this.zoomLevel + zoomDelta));
if (this.zoomSlider) this.zoomSlider.value = this.zoomLevel;
const oldWidth = this.canvas.offsetWidth;
const newWidth = this.viewport.clientWidth * this.zoomLevel;
const mouseX = e.clientX - this.viewport.getBoundingClientRect().left;
const scrollRatio = (this.viewport.scrollLeft + mouseX) / oldWidth;
this.canvas.style.width = newWidth + "px";
this.viewport.scrollLeft = scrollRatio * newWidth - mouseX;
}
}, { passive: false, capture: true });
this.canvas = document.createElement("canvas");
this.canvas.className = "pr-canvas";
this.ctx = this.canvas.getContext("2d");
this.canvas.style.width = "100%";
this.viewport.appendChild(this.canvas);
this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e));
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
this.canvas.style.height = `${CANVAS_HEIGHT}px`;
// --- Content Area Container ---
const propContainer = document.createElement("div");
propContainer.className = "pr-prop-container";
// --- Text Area (Image/Text) ---
this.promptInput = document.createElement("textarea");
this.promptInput.className = "pr-prompt-area";
this.promptInput.placeholder = "Enter prompt for selected segment... Type '@' for character shortcuts.";
this.promptInput.addEventListener("input", () => {
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value;
this.commitChanges();
}
});
// --- Audio Info Area ---
this.audioInfoArea = document.createElement("div");
this.audioInfoArea.className = "pr-audio-info";
propContainer.appendChild(this.promptInput);
propContainer.appendChild(this.audioInfoArea);
this.wrapper.addEventListener("dragover", (e) => {
e.preventDefault();
this.wrapper.classList.add("drag-active");
const { x, y } = this.getMousePos(e);
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
if (!logicalWidth || totalFrames <= 0) return;
const isAudioTrack = y > RULER_HEIGHT + this.blockHeight;
const trackType = isAudioTrack ? "audio" : "image";
const arrToModify = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments;
if (!this._ghostSegmentId || this._ghostTrack !== trackType) {
this._ghostSegmentId = "GHOST_" + Date.now();
this._ghostTrack = trackType;
this._ghostInitialTimeline = JSON.parse(JSON.stringify(arrToModify));
const frameRate = this.getFrameRate();
const newLength = Math.max(1, frameRate * 1);
let mouseFrameX = x * (totalFrames / logicalWidth);
let startFrame = clamp(Math.round(mouseFrameX - newLength / 2), 0, totalFrames - newLength);
this._ghostInitialTimeline.push({
id: this._ghostSegmentId,
start: startFrame,
length: newLength,
type: "ghost"
});
}
let mouseFrameX = x * (totalFrames / logicalWidth);
const ghost = this._ghostInitialTimeline.find(s => s.id === this._ghostSegmentId);
let D_mouse_start = mouseFrameX - ghost.length / 2;
this._previewSegments = this._applyCenterDragPhysics(
this._ghostInitialTimeline,
this._ghostSegmentId,
D_mouse_start,
mouseFrameX,
totalFrames,
totalFrames,
logicalWidth
);
this.render();
});
this.wrapper.addEventListener("dragleave", (e) => {
const rect = this.wrapper.getBoundingClientRect();
if (e.clientX < rect.left || e.clientX >= rect.right ||
e.clientY < rect.top || e.clientY >= rect.bottom) {
this.wrapper.classList.remove("drag-active");
this._ghostSegmentId = null;
this._ghostTrack = null;
this._ghostInitialTimeline = null;
this._previewSegments = null;
this.render();
}
});
this.wrapper.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
this.wrapper.classList.remove("drag-active");
let targetFrameStart = null;
let targetTrack = this._ghostTrack || "image";
if (this._ghostSegmentId && this._previewSegments) {
const ghost = this._previewSegments.find(s => s.id === this._ghostSegmentId);
if (ghost) {
targetFrameStart = ghost.resolvedStart !== undefined ? ghost.resolvedStart : ghost.start;
}
}
this._ghostSegmentId = null;
this._ghostTrack = null;
this._ghostInitialTimeline = null;
this._previewSegments = null;
this.render();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const imageFiles = [];
const audioFiles = [];
for (let file of e.dataTransfer.files) {
if (file.type.startsWith("audio/")) audioFiles.push(file);
if (file.type.startsWith("image/")) imageFiles.push(file);
}
// Let implicit intent handle mixing drops: use the track we hovered over
// for the first type we process, or fallback.
if (audioFiles.length > 0 && (targetTrack === "audio" || imageFiles.length === 0)) {
this.handleAudioUpload(audioFiles, targetFrameStart);
} else if (imageFiles.length > 0) {
this.handleImageUpload(imageFiles, targetFrameStart);
}
}
});
window.addEventListener("mousemove", (e) => this.onMouseMove(e));
window.addEventListener("mouseup", (e) => this.onMouseUp(e));
// --- Player Controls ---
const playerControls = document.createElement("div");
playerControls.className = "pr-player-controls";
this.playBtn = document.createElement("button");
this.playBtn.className = "pr-icon-btn";
this.playBtn.style.padding = "4px";
this.playBtn.innerHTML = ICONS.play;
this.playBtn.title = "Play/Pause Audio";
this.playBtn.addEventListener("click", () => this.togglePlay());
this.loopBtn = document.createElement("button");
this.loopBtn.className = "pr-icon-btn";
this.loopBtn.style.padding = "4px";
this.loopBtn.innerHTML = ICONS.loop;
this.loopBtn.title = "Toggle Loop";
this.loopBtn.addEventListener("click", () => this.toggleLoop());
this.seekBar = document.createElement("input");
this.seekBar.type = "range";
this.seekBar.className = "pr-seek-bar";
this.seekBar.min = "0";
this.seekBar.value = "0";
this.seekBar.style.flex = "1"; // take up remaining space
this.seekBar.addEventListener("input", (e) => {
this.currentFrame = parseInt(e.target.value, 10);
this.render();
if (this.isPlaying) {
this.playAudio();
}
});
// --- Zoom Controls ---
const zoomControls = document.createElement("div");
zoomControls.className = "pr-zoom-controls";
const zoomOutBtn = document.createElement("button");
zoomOutBtn.className = "pr-icon-btn";
zoomOutBtn.style.padding = "4px";
zoomOutBtn.innerHTML = ICONS.minus;
zoomOutBtn.title = "Zoom Out";
zoomOutBtn.addEventListener("click", () => {
const currentZoom = parseFloat(this.zoomSlider.value);
this.zoomSlider.value = Math.max(1, currentZoom - 0.5);
this.zoomSlider.dispatchEvent(new Event("input"));
});
this.zoomSlider = document.createElement("input");
this.zoomSlider.type = "range";
this.zoomSlider.className = "pr-zoom-slider";
this.zoomSlider.min = "1";
this.zoomSlider.max = "1"; // Updated dynamically via updateZoomSliderMax()
this.zoomSlider.step = "0.1";
this.zoomSlider.value = "1";
this.zoomSlider.title = "Zoom Level";
this.zoomSlider.addEventListener("input", (e) => {
this.zoomLevel = parseFloat(e.target.value);
const viewportWidth = this.viewport.clientWidth;
const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel);
this.canvas.style.width = newCanvasWidth + "px";
this.resizeCanvas(newCanvasWidth);
this._lastWidth = viewportWidth;
this._lastZoom = this.zoomLevel;
// Keep playhead centered
const totalFrames = this.getVisualDurationFrames();
const playheadRatio = this.currentFrame / totalFrames;
const newPlayheadX = playheadRatio * newCanvasWidth;
this.viewport.scrollLeft = newPlayheadX - (viewportWidth / 2);
});
const zoomInBtn = document.createElement("button");
zoomInBtn.className = "pr-icon-btn";
zoomInBtn.style.padding = "4px";
zoomInBtn.innerHTML = ICONS.plus;
zoomInBtn.title = "Zoom In";
zoomInBtn.addEventListener("click", () => {
const currentZoom = parseFloat(this.zoomSlider.value);
this.zoomSlider.value = Math.min(this.getMaxZoom(), currentZoom + 0.5);
this.zoomSlider.dispatchEvent(new Event("input"));
});
const zoomFitBtn = document.createElement("button");
zoomFitBtn.className = "pr-icon-btn";
zoomFitBtn.style.padding = "4px";
zoomFitBtn.style.marginLeft = "4px";
zoomFitBtn.innerHTML = ICONS.fit;
zoomFitBtn.title = "Zoom to Fit (show full timeline)";
zoomFitBtn.addEventListener("click", () => {
this.zoomLevel = 1;
this.zoomSlider.value = 1;
const viewportWidth = this.viewport.clientWidth;
this.canvas.style.width = viewportWidth + "px";
this.resizeCanvas(viewportWidth);
this._lastWidth = viewportWidth;
this._lastZoom = 1;
this.viewport.scrollLeft = 0;
});
zoomControls.appendChild(zoomOutBtn);
zoomControls.appendChild(this.zoomSlider);
zoomControls.appendChild(zoomInBtn);
zoomControls.appendChild(zoomFitBtn);
playerControls.appendChild(this.playBtn);
playerControls.appendChild(this.loopBtn);
playerControls.appendChild(this.seekBar);
playerControls.appendChild(zoomControls);
// --- Guide Strength Slider ---
this.strengthRow = document.createElement("div");
this.strengthRow.className = "pr-strength-row";
const strengthLabel = document.createElement("span");
strengthLabel.className = "pr-strength-label";
strengthLabel.textContent = "Guide Strength:";
this.strengthValue = document.createElement("input");
this.strengthValue.type = "text";
this.strengthValue.className = "pr-strength-input";
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
this.strengthValue.style.cursor = "ew-resize";
// Dragging logic for guide strength
let isDragging = false;
let startX = 0;
let startVal = 0;
let hasMoved = false;
this.strengthValue.addEventListener("mousedown", (e) => {
if (this.strengthValue.disabled) return;
startX = e.clientX;
startVal = parseFloat(this.strengthValue.value) || 1.0;
hasMoved = false;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
if (Math.abs(deltaX) > 3) {
hasMoved = true;
isDragging = true;
}
if (isDragging) {
moveEvent.preventDefault();
const sensitivity = 0.002;
let newVal = startVal + deltaX * sensitivity;
if (newVal < 0) newVal = 0;
if (newVal > 1) newVal = 1;
this.strengthValue.value = newVal.toFixed(2);
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
const seg = this.timeline.segments[this.selectedIndex];
if (seg.type !== "text") {
seg.guideStrength = newVal;
this.commitChanges();
}
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!hasMoved) {
this.strengthValue.focus();
this.strengthValue.select();
}
isDragging = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
this.strengthValue.addEventListener("change", (e) => {
let val = parseFloat(e.target.value);
if (isNaN(val)) val = 1;
val = Math.max(0, Math.min(1, val));
this.strengthValue.value = val.toFixed(2);
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
const seg = this.timeline.segments[this.selectedIndex];
if (seg.type !== "text") {
seg.guideStrength = val;
this.commitChanges();
}
}
});
this.strengthRow.appendChild(this.timeCodeDisplay);
this.strengthRow.appendChild(this.segmentBoundsDisplay);
this.strengthRow.appendChild(strengthLabel);
this.strengthRow.appendChild(this.strengthValue);
this.wrapper.appendChild(toolbar);
this.wrapper.appendChild(this.viewport);
const controlsGroup = document.createElement("div");
controlsGroup.className = "pr-controls-group";
controlsGroup.appendChild(this.strengthRow);
controlsGroup.appendChild(playerControls);
this.wrapper.appendChild(controlsGroup);
this.wrapper.appendChild(propContainer);
this.container.appendChild(this.wrapper);
// --- Initialize autocomplete popup support ---
this.setupAutocomplete();
}
// --- Auto-complete Popup Setup ---
setupAutocomplete() {
const input = this.promptInput;
if (!input) return;
const menu = document.createElement("div");
menu.className = "pr-autocomplete-menu";
menu.style.display = "none";
document.body.appendChild(menu);
this._autocompleteMenu = menu;
const suggestions = [
{ tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" },
{ tag: "@character1", label: "Character 1 (Full)" },
{ tag: "@character2", label: "Character 2 (Full)" },
{ tag: "@character3", label: "Character 3 (Full)" }
];
let activeIndex = 0;
let showMenu = false;
let queryStart = -1;
const hideMenu = () => {
menu.style.display = "none";
showMenu = false;
};
const getCaretCoordinates = () => {
const rect = input.getBoundingClientRect();
return {
left: rect.left,
top: rect.bottom + window.scrollY + 2
};
};
const updateMenu = () => {
if (!showMenu) return;
const text = input.value;
const cursor = input.selectionStart;
const query = text.slice(queryStart + 1, cursor).toLowerCase();
const filtered = suggestions.filter(s => s.tag.toLowerCase().includes("@" + query) || s.tag.toLowerCase().includes(query));
if (filtered.length === 0) {
hideMenu();
return;
}
menu.innerHTML = "";
// Clamp activeIndex inside the filtered results boundaries
if (activeIndex >= filtered.length) {
activeIndex = 0;
}
filtered.forEach((s, idx) => {
const item = document.createElement("div");
item.className = "pr-autocomplete-item" + (idx === activeIndex ? " active" : "");
item.innerHTML = `<span>${s.tag}</span><small>${s.label}</small>`;
item.addEventListener("mousedown", (e) => {
e.preventDefault(); // Prevent losing focus on textarea
insertSuggestion(s.tag);
});
menu.appendChild(item);
});
const coords = getCaretCoordinates();
menu.style.left = `${coords.left}px`;
menu.style.top = `${coords.top}px`;
menu.style.display = "flex";
};
const insertSuggestion = (tag) => {
const text = input.value;
const cursor = input.selectionStart;
const before = text.slice(0, queryStart);
const after = text.slice(cursor);
input.value = before + tag + " " + after;
input.selectionStart = input.selectionEnd = queryStart + tag.length + 1;
// Trigger input event to save changes in the node data
input.dispatchEvent(new Event("input"));
hideMenu();
input.focus();
};
input.addEventListener("keydown", (e) => {
if (showMenu) {
const items = menu.querySelectorAll(".pr-autocomplete-item");
if (items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = (activeIndex + 1) % items.length;
updateMenu();
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = (activeIndex - 1 + items.length) % items.length;
updateMenu();
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const activeItem = items[activeIndex];
if (activeItem) {
const tag = activeItem.querySelector("span").textContent;
insertSuggestion(tag);
}
} else if (e.key === "Escape") {
e.preventDefault();
hideMenu();
}
}
});
input.addEventListener("input", () => {
const text = input.value;
const cursor = input.selectionStart;
const textBeforeCursor = text.slice(0, cursor);
const lastAt = textBeforeCursor.lastIndexOf("@");
if (lastAt !== -1 && lastAt >= textBeforeCursor.search(/\s[^\s]*$/)) {
showMenu = true;
queryStart = lastAt;
updateMenu();
} else {
hideMenu();
}
});
input.addEventListener("blur", () => {
// Small delay to let mousedown register on menu items before closing
setTimeout(hideMenu, 150);
});
}
checkResize() {
const viewportWidth = this.viewport.clientWidth;
const currentScale = this.getRenderScale();
if (viewportWidth > 0 && (this._lastWidth !== viewportWidth || this._lastZoom !== this.zoomLevel || this._lastScale !== currentScale)) {
this._lastWidth = viewportWidth;
this._lastZoom = this.zoomLevel;
this._lastScale = currentScale;
const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel);
this.canvas.style.width = newCanvasWidth + "px";
this.resizeCanvas(newCanvasWidth);
}
this._renderLoop = requestAnimationFrame(() => this.checkResize());
}
getRenderScale() {
const dpr = window.devicePixelRatio || 1;
let graphScale = 1;
try {
if (window.app && window.app.canvas && window.app.canvas.ds && window.app.canvas.ds.scale) {
graphScale = window.app.canvas.ds.scale;
}
} catch (e) { }
// Scale up if zoomed in, but don't drop below 1x DPR if zoomed out
return dpr * Math.max(1, graphScale);
}
resizeCanvas(widthPx) {
const scale = this.getRenderScale();
const targetWidth = Math.round(widthPx * scale);
const targetHeight = Math.round(this.canvasHeight * scale);
this.canvas.width = targetWidth;
this.canvas.height = targetHeight;
this.ctx.setTransform(scale, 0, 0, scale, 0, 0);
this.render();
}
// Helper to map mouse events accurately regardless of canvas scaling
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.offsetWidth / rect.width;
const scaleY = this.canvas.offsetHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
return { x, y };
}
// --- Async Image Upload Logic (Handles multiple images simultaneously) ---
async handleImageUpload(files, targetFrameStart = null, explicitLength = null) {
const frameRate = this.getFrameRate();
const durationFrames = this.getDurationFrames();
const newLength = explicitLength !== null ? explicitLength : frameRate * 1; // Default to 1 second long
for (let file of files) {
if (!file.type.startsWith("image/")) continue;
await new Promise(async (resolve) => {
try {
const body = new FormData();
body.append("image", file);
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status !== 200) { resolve(); return; }
const data = await resp.json();
const filename = data.name;
const subfolder = data.subfolder || "";
const imageFile = subfolder ? subfolder + "/" + filename : filename;
const imgUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`);
const img = new Image();
img.onload = () => {
let newStart = targetFrameStart;
if (newStart === null) {
// Fallback: find the first free slot, or append past the end
newStart = 0;
this.timeline.segments.sort((a, b) => a.start - b.start);
for (let i = 0; i < this.timeline.segments.length; i++) {
let seg = this.timeline.segments[i];
if (newStart + newLength <= seg.start) break;
newStart = Math.max(newStart, seg.start + seg.length);
}
}
// Use the visual timeline as the physics bound so segments can
// land anywhere in the padded visual area without touching duration_frames.
const currentDuration = this.getVisualDurationFrames();
if (targetFrameStart !== null) {
// Resolve physics to push existing segments
let tempId = "TEMP_" + Date.now();
this.timeline.segments.push({ id: tempId, start: newStart, length: newLength, type: "temp" });
let result = this._applyCenterDragPhysics(this.timeline.segments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1);
// Update original segments with resolved physics to preserve imgObj
for (let shiftedSeg of result) {
let original = this.timeline.segments.find(s => s.id === shiftedSeg.id);
if (original) {
original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start;
}
}
let tempSeg = this.timeline.segments.find(s => s.id === tempId);
newStart = tempSeg.start;
this.timeline.segments = this.timeline.segments.filter(s => s.id !== tempId);
targetFrameStart = newStart + newLength; // For the next file in batch
}
// Use the full intended length — the timeline has already been grown to fit.
let constrainedLength = newLength;
const seg = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: newStart,
length: constrainedLength,
prompt: "",
type: "image",
imageFile: imageFile,
imageB64: imgUrl
};
const displayImg = new Image();
displayImg.onload = () => {
seg.imgObj = displayImg;
this.render();
resolve(); // Resolve promise letting next image process
};
displayImg.src = imgUrl;
this.timeline.segments.push(seg);
this.timeline.segments.sort((a, b) => a.start - b.start);
this.selectionType = "image";
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges(true);
};
img.src = imgUrl;
} catch (err) {
console.error("[PromptRelay] Image upload failed", err);
resolve();
}
});
}
this.fileInput.value = "";
}
// --- Async Audio Upload Logic ---
async handleAudioUpload(files, targetFrameStart = null) {
const frameRate = this.getFrameRate();
const durationFrames = this.getDurationFrames();
for (let file of files) {
if (!file.type.startsWith("audio/")) continue;
await new Promise(async (resolve) => {
try {
const body = new FormData();
body.append("image", file);
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status !== 200) { resolve(); return; }
const data = await resp.json();
const filename = data.name;
const subfolder = data.subfolder || "";
const audioFile = subfolder ? subfolder + "/" + filename : filename;
const arrayBuffer = await file.arrayBuffer();
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const clipDurationSecs = audioBuffer.duration;
const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate));
const channelData = audioBuffer.getChannelData(0);
const peaks = [];
const numPeaks = 200;
const step = Math.floor(channelData.length / numPeaks);
for (let i = 0; i < numPeaks; i++) {
let max = 0;
for (let j = 0; j < step; j++) {
const val = Math.abs(channelData[i * step + j]);
if (val > max) max = val;
}
peaks.push(max);
}
let newLength = clipFrames;
let newStart = targetFrameStart;
if (newStart === null) {
// Find the first free slot, or place past the end of all existing audio
newStart = 0;
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
for (let i = 0; i < this.timeline.audioSegments.length; i++) {
let seg = this.timeline.audioSegments[i];
if (newStart + newLength <= seg.start) break;
newStart = Math.max(newStart, seg.start + seg.length);
}
}
// Use the visual timeline as the physics bound so segments can
// land anywhere in the padded visual area without touching duration_frames.
const currentDuration = this.getVisualDurationFrames();
if (targetFrameStart !== null) {
let tempId = "TEMP_" + Date.now();
this.timeline.audioSegments.push({ id: tempId, start: newStart, length: newLength, type: "temp" });
let result = this._applyCenterDragPhysics(this.timeline.audioSegments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1);
for (let shiftedSeg of result) {
let original = this.timeline.audioSegments.find(s => s.id === shiftedSeg.id);
if (original) original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start;
}
let tempSeg = this.timeline.audioSegments.find(s => s.id === tempId);
newStart = tempSeg.start;
this.timeline.audioSegments = this.timeline.audioSegments.filter(s => s.id !== tempId);
targetFrameStart = newStart + newLength;
}
// Use the full clip length — timeline has already grown to fit.
let constrainedLength = newLength;
const seg = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
type: "audio",
start: newStart,
length: constrainedLength,
trimStart: 0,
audioDurationFrames: clipFrames,
audioFile: audioFile,
fileName: file.name,
waveformPeaks: peaks
};
this.timeline.audioSegments.push(seg);
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
this.selectionType = "audio";
this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges(true);
this.render();
resolve();
} catch (err) {
console.error("[PromptRelay] Audio processing failed", err);
resolve();
}
});
}
this.audioFileInput.value = "";
}
deleteSelectedSegment() {
if (this.selectionType === "audio") {
if (this.timeline.audioSegments.length === 0 || this.selectedIndex === -1) return;
this.timeline.audioSegments.splice(this.selectedIndex, 1);
this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
} else {
if (this.timeline.segments.length === 0 || this.selectedIndex === -1) return;
this.timeline.segments.splice(this.selectedIndex, 1);
this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
}
this.updateUIFromSelection();
this.commitChanges();
this.render();
}
formatTime(frames, dropSuffix = false) {
const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds";
if (mode === "seconds") {
const secs = frames / this.getFrameRate();
return dropSuffix ? secs.toFixed(2) : secs.toFixed(2) + "s";
}
return dropSuffix ? Math.round(frames).toString() : Math.round(frames) + " frames";
}
updateWidgetVisibility() {
const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds";
if (this.durationFramesWidget) {
// Always visible regardless of display mode
this.durationFramesWidget.type = "INT";
if (!this.durationFramesWidget.options) this.durationFramesWidget.options = {};
this.durationFramesWidget.options.hidden = false;
this.durationFramesWidget.hidden = false;
delete this.durationFramesWidget.computeSize;
}
if (this.durationSecondsWidget) {
// Always visible regardless of display mode
this.durationSecondsWidget.type = "FLOAT";
if (!this.durationSecondsWidget.options) this.durationSecondsWidget.options = {};
this.durationSecondsWidget.options.hidden = false;
this.durationSecondsWidget.hidden = false;
delete this.durationSecondsWidget.computeSize;
}
// Force node resize and redraw deferred to next tick
setTimeout(() => {
if (this.node && this.node.computeSize) {
const sz = this.node.computeSize();
this.node.size[1] = sz[1];
if (window.app && window.app.graph) {
window.app.graph.setDirtyCanvas(true, true);
}
}
}, 0);
}
updateUIFromSelection() {
let seg = null;
if (this.selectedIndex >= 0) {
if (this.selectionType === "audio") {
const origSeg = this.timeline.audioSegments[this.selectedIndex];
if (origSeg) {
const previewIsAudio = this._ghostTrack === 'audio' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio');
const arr = (this._previewSegments && previewIsAudio) ? this._previewSegments : this.timeline.audioSegments;
seg = arr.find(s => s.id === origSeg.id) || origSeg;
}
} else {
const origSeg = this.timeline.segments[this.selectedIndex];
if (origSeg) {
const previewIsImage = this._ghostTrack === 'image' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'image');
const arr = (this._previewSegments && previewIsImage) ? this._previewSegments : this.timeline.segments;
seg = arr.find(s => s.id === origSeg.id) || origSeg;
}
}
}
if (this.selectionType === "audio" && seg) {
this.promptInput.style.display = "none";
this.strengthRow.style.display = "flex";
this.audioInfoArea.style.display = "block";
this.audioInfoArea.innerHTML = `
File: <span>${seg.fileName || "Unknown"}</span><br>
Length: <span>${this.formatTime(seg.audioDurationFrames)}</span> Output Length: <span>${this.formatTime(seg.length)}</span><br>
Trim-in: <span>${this.formatTime(Math.round(seg.trimStart))}</span> Trim-Out: <span>${this.formatTime(Math.round(seg.audioDurationFrames - (seg.trimStart + seg.length)))}</span>
`;
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
} else {
this.audioInfoArea.style.display = "none";
this.promptInput.style.display = "block";
this.strengthRow.style.display = "flex";
if (seg) {
this.promptInput.value = seg.prompt || "";
this.promptInput.disabled = false;
const isImage = seg.type !== "text";
const strength = isImage ? (seg.guideStrength ?? 1.0) : 1.0;
this.strengthValue.value = strength.toFixed(2);
this.strengthValue.disabled = !isImage;
} else {
this.promptInput.value = "";
this.promptInput.disabled = true;
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
}
}
if (this.segmentBoundsDisplay) {
if (seg) {
const startStr = this.formatTime(seg.start, true);
const endStr = this.formatTime(seg.start + seg.length, true);
this.segmentBoundsDisplay.textContent = `Start: ${startStr} | End: ${endStr}`;
} else {
this.segmentBoundsDisplay.textContent = "Start: - | End: -";
}
}
}
// --- Rendering logic ---
render() {
const width = this.canvas.offsetWidth || this._lastWidth;
const height = this.canvasHeight;
const totalFrames = this.getVisualDurationFrames();
if (!width || width <= 0) return;
this.ctx.clearRect(0, 0, width, height);
// Render Track Backgrounds
this.ctx.fillStyle = "#111"; // Image track bg
this.ctx.fillRect(0, RULER_HEIGHT, width, this.blockHeight);
this.ctx.fillStyle = "#111"; // Audio track bg
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, this.audioTrackHeight);
// Determine which track the preview belongs to.
// _ghostTrack is set during HTML file drag-and-drop.
// During canvas mouse drags, _ghostTrack is null, so fall back to selectionType.
const previewIsAudio = this._ghostTrack === 'audio' ||
(this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio');
let renderSegments = (this._previewSegments && !previewIsAudio)
? this._previewSegments : this.timeline.segments;
let renderAudioSegments = (this._previewSegments && previewIsAudio)
? this._previewSegments : this.timeline.audioSegments;
const activeSegId = this.timeline.segments[this.selectedIndex]?.id;
const activeAudioSegId = this.timeline.audioSegments[this.selectedIndex]?.id;
// Sort segments so that the selected one is drawn last (on top)
const isImageSelection = this.selectionType === "image";
const sortedSegments = [...renderSegments].sort((a, b) => {
const aSel = isImageSelection && a.id === activeSegId;
const bSel = isImageSelection && b.id === activeSegId;
return aSel - bSel;
});
const isAudioSelection = this.selectionType === "audio";
const sortedAudioSegments = [...renderAudioSegments].sort((a, b) => {
const aSel = isAudioSelection && a.id === activeAudioSegId;
const bSel = isAudioSelection && b.id === activeAudioSegId;
return aSel - bSel;
});
// --- Draw Image/Text Segments ---
for (let i = 0; i < sortedSegments.length; i++) {
const seg = sortedSegments[i];
const startX = (seg.start / totalFrames) * width;
const pxWidth = (seg.length / totalFrames) * width;
const isSelected = (this.selectionType === "image" && seg.id === activeSegId);
const originalSeg = this.timeline.segments.find(s => s.id === seg.id);
const imgObj = originalSeg ? originalSeg.imgObj : seg.imgObj;
if ((this._isDragging && this.selectionType === "image" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) {
this.ctx.globalAlpha = 0.65;
} else {
this.ctx.globalAlpha = 1.0;
}
if (seg.type === "ghost") {
this.ctx.fillStyle = "#2a2a2a";
this.ctx.fillRect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.strokeStyle = "#777";
this.ctx.lineWidth = 2;
this.ctx.setLineDash([5, 5]);
this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
this.ctx.setLineDash([]);
this.ctx.fillStyle = "#aaa";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "bold 12px sans-serif";
this.ctx.fillText("Drop to Place", startX + pxWidth / 2, RULER_HEIGHT + this.blockHeight / 2);
} else {
this.ctx.fillStyle = seg.type === "text" ? "#000b12" : "#000";
this.ctx.fillRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
}
if (imgObj && imgObj.complete && imgObj.naturalWidth > 0 && seg.type !== "ghost") {
const imgRatio = imgObj.naturalWidth / imgObj.naturalHeight;
const boxRatio = pxWidth / this.blockHeight;
let drawW, drawH, drawX, drawY;
if (imgRatio > boxRatio) {
drawW = pxWidth; drawH = pxWidth / imgRatio;
drawX = startX; drawY = RULER_HEIGHT + (this.blockHeight - drawH) / 2;
} else {
drawH = this.blockHeight; drawW = this.blockHeight * imgRatio;
drawY = RULER_HEIGHT; drawX = startX + (pxWidth - drawW) / 2;
}
// Clip to segment bounds so tiled images don't bleed into adjacent segments
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
this.ctx.clip();
if (imgRatio > boxRatio) {
// Fits width, vertical letterboxing (black bars top/bottom) — keep as is
this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH);
} else {
// Fits height, horizontal letterboxing (black bars left/right) — tile horizontally
this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH);
// Tile left
let leftX = drawX - drawW;
while (leftX + drawW > startX) {
this.ctx.drawImage(imgObj, leftX, drawY, drawW, drawH);
leftX -= drawW;
}
// Tile right
let rightX = drawX + drawW;
while (rightX < startX + pxWidth) {
this.ctx.drawImage(imgObj, rightX, drawY, drawW, drawH);
rightX += drawW;
}
}
this.ctx.restore();
// --- Prompt subtitle overlay ---
if (seg.prompt && seg.type !== "ghost" && pxWidth > 24) {
const overlayH = Math.round(this.blockHeight * 0.20);
const overlayY = RULER_HEIGHT + this.blockHeight - overlayH;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, overlayY, pxWidth, overlayH);
this.ctx.clip();
// Translucent background
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(startX, overlayY, pxWidth, overlayH);
// Text
const fontSize = Math.min(11, overlayH * 0.58);
this.ctx.font = `${fontSize}px sans-serif`;
this.ctx.fillStyle = "#e0e3ed";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
// Measure and truncate to single line
const maxTextW = pxWidth - 10;
let label = seg.prompt;
if (this.ctx.measureText(label).width > maxTextW) {
while (label.length > 0 && this.ctx.measureText(label + "…").width > maxTextW) {
label = label.slice(0, -1);
}
label += "…";
}
this.ctx.fillText(label, startX + pxWidth / 2, overlayY + overlayH / 2);
this.ctx.restore();
}
} else if (seg.type === "text") {
const pad = 8;
const boxW = pxWidth - pad * 2;
if (boxW > 12) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX + pad, RULER_HEIGHT + pad, boxW, this.blockHeight - pad * 2);
this.ctx.clip();
this.ctx.fillStyle = "#e0e3ed";
this.ctx.font = "11px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "top";
const label = seg.prompt || "(no prompt)";
const words = label.split(" ");
const lineH = 15;
let line = "";
let lines = [];
for (const word of words) {
const test = line ? line + " " + word : word;
if (this.ctx.measureText(test).width > boxW && line) {
lines.push(line);
line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
const maxLines = Math.max(1, Math.floor((this.blockHeight - pad * 2) / lineH));
if (lines.length > maxLines) {
lines = lines.slice(0, maxLines);
lines[lines.length - 1] += "…";
}
const totalTextHeight = lines.length * lineH;
let ty = RULER_HEIGHT + (this.blockHeight - totalTextHeight) / 2 + 2;
for (const l of lines) {
this.ctx.fillText(l, startX + pxWidth / 2, ty);
ty += lineH;
}
this.ctx.restore();
}
}
if (isSelected) {
this.ctx.strokeStyle = "#fff";
this.ctx.lineWidth = 2;
this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
this.ctx.fillStyle = "#fff";
this.ctx.beginPath();
this.ctx.roundRect(startX, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2);
this.ctx.fill();
this.ctx.beginPath();
this.ctx.roundRect(startX + pxWidth - 4, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2);
this.ctx.fill();
} else {
this.ctx.strokeStyle = "#000";
this.ctx.lineWidth = 1.5;
this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
}
this.ctx.globalAlpha = 1.0;
}
// --- Draw Audio Segments ---
for (let i = 0; i < sortedAudioSegments.length; i++) {
const seg = sortedAudioSegments[i];
const startX = (seg.start / totalFrames) * width;
const pxWidth = (seg.length / totalFrames) * width;
const isSelected = (this.selectionType === "audio" && seg.id === activeAudioSegId);
const trackY = RULER_HEIGHT + this.blockHeight;
if ((this._isDragging && this.selectionType === "audio" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) {
this.ctx.globalAlpha = 0.65;
} else {
this.ctx.globalAlpha = 1.0;
}
if (seg.type === "ghost") {
this.ctx.fillStyle = "#1a1a1a";
this.ctx.fillRect(startX, trackY, pxWidth, this.audioTrackHeight);
this.ctx.strokeStyle = "#555";
this.ctx.lineWidth = 2;
this.ctx.setLineDash([5, 5]);
this.ctx.strokeRect(startX, trackY, pxWidth, this.audioTrackHeight);
this.ctx.setLineDash([]);
this.ctx.fillStyle = "#888";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "bold 12px sans-serif";
this.ctx.fillText("Drop Audio", startX + pxWidth / 2, trackY + this.audioTrackHeight / 2);
} else {
this.drawAudioSegmentVisuals(this.ctx, seg, isSelected, trackY, this.audioTrackHeight, startX, pxWidth);
}
this.ctx.globalAlpha = 1.0;
}
// --- Draw Ruler & Divider AFTER segments to prevent overlap ---
// Ruler Background
this.ctx.fillStyle = "#1e1e1e";
this.ctx.fillRect(0, 0, width, RULER_HEIGHT);
// Crisp Ruler Text
this.ctx.fillStyle = "#aaa";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "10px sans-serif";
const frameRate = this.getFrameRate();
const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds";
// Define logical steps for both modes
let steps;
if (mode === "seconds") {
steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
} else {
steps = [1, 2, 5, 10, 24, 48, 120, 240, 480, 960, 1920];
}
const minSpacingPx = 60;
let majorStep = steps[steps.length - 1];
for (let i = 0; i < steps.length; i++) {
const stepFrames = mode === "seconds" ? steps[i] * frameRate : steps[i];
const spacingPx = (stepFrames / totalFrames) * width;
if (spacingPx >= minSpacingPx) {
majorStep = steps[i];
break;
}
}
const majorStepFrames = mode === "seconds" ? majorStep * frameRate : majorStep;
let minorStep;
if (mode === "seconds") {
if (majorStep <= 0.2) minorStep = majorStep / 2;
else if (majorStep <= 1) minorStep = majorStep / 5;
else if (majorStep <= 5) minorStep = 1;
else if (majorStep <= 15) minorStep = 5;
else if (majorStep <= 30) minorStep = 10;
else if (majorStep <= 60) minorStep = 10;
else minorStep = majorStep / 5;
} else {
if (majorStep <= 5) minorStep = 1;
else if (majorStep <= 10) minorStep = 2;
else if (majorStep <= 24) minorStep = 6;
else if (majorStep <= 48) minorStep = 12;
else minorStep = majorStep / 5;
}
const minorStepFrames = mode === "seconds" ? minorStep * frameRate : minorStep;
this.ctx.fillStyle = "#444";
const totalMinorTicks = Math.floor(totalFrames / minorStepFrames);
for (let i = 0; i <= totalMinorTicks; i++) {
const frameVal = i * minorStepFrames;
if (Math.abs(frameVal % majorStepFrames) < 0.1) continue;
const x = (frameVal / totalFrames) * width;
this.ctx.fillRect(Math.floor(x), RULER_HEIGHT - 3, 1, 3);
}
this.ctx.fillStyle = "#aaa";
const totalMajorTicks = Math.floor(totalFrames / majorStepFrames);
for (let i = 0; i <= totalMajorTicks; i++) {
const frameVal = i * majorStepFrames;
const x = (frameVal / totalFrames) * width;
this.ctx.fillStyle = "#aaa";
this.ctx.fillRect(Math.floor(x), RULER_HEIGHT - 6, 1, 6);
if (frameVal > 0 && frameVal < totalFrames) {
this.ctx.textAlign = "center";
this.ctx.fillText(this.formatTime(frameVal, true), x, RULER_HEIGHT / 2);
}
}
this.ctx.textAlign = "left";
const zeroLabel = mode === "seconds" ? "0" : this.formatTime(0, true);
this.ctx.fillText(zeroLabel, 4, RULER_HEIGHT / 2);
// Divider
this.ctx.fillStyle = "#333";
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, 1);
// Draw gap "+" buttons
if (!this._isDragging) {
const BTN_R = 12;
const gapRegions = this.getGapRegions();
for (let i = 0; i < gapRegions.length; i++) {
const gap = gapRegions[i];
if (gap.widthPx < BTN_R * 2 + 8) continue;
const hov = this._hoveredGapIdx === i;
const BTN_W = 18;
const BTN_H = 18;
this.ctx.beginPath();
this.ctx.roundRect(gap.centerX - BTN_W / 2, gap.centerY - BTN_H / 2, BTN_W, BTN_H, 4);
this.ctx.fillStyle = hov ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.05)";
this.ctx.fill();
this.ctx.fillStyle = hov ? "#fff" : "#888";
this.ctx.font = "14px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText("+", gap.centerX, gap.centerY + 1);
}
}
// --- Out-of-duration shadow overlay ---
// Draw a translucent black mask over the region beyond the actual output duration
// so the user can clearly see which content will be included in the render.
const outputFrames = this.getDurationFrames();
if (outputFrames < totalFrames) {
const cutoffX = (outputFrames / totalFrames) * width;
// Semi-transparent black overlay on both tracks
this.ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
this.ctx.fillRect(cutoffX, RULER_HEIGHT, width - cutoffX, this.blockHeight + this.audioTrackHeight);
// Subtle tinted ruler overlay
this.ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
this.ctx.fillRect(cutoffX, 0, width - cutoffX, RULER_HEIGHT);
/*
// Dashed boundary line at the output duration cutoff
this.ctx.save();
this.ctx.strokeStyle = "rgba(255, 80, 80, 0.7)";
this.ctx.lineWidth = 1.5;
this.ctx.setLineDash([5, 4]);
this.ctx.beginPath();
this.ctx.moveTo(cutoffX, 0);
this.ctx.lineTo(cutoffX, CANVAS_HEIGHT);
this.ctx.stroke();
this.ctx.setLineDash([]);
this.ctx.restore();
*/
}
// --- Draw Playhead ---
const playheadX = (this.currentFrame / totalFrames) * width;
// Playhead Line
this.ctx.beginPath();
this.ctx.moveTo(playheadX, 14);
this.ctx.lineTo(playheadX, this.canvasHeight);
this.ctx.strokeStyle = "#ff4444";
this.ctx.lineWidth = 1.5;
this.ctx.stroke();
// Playhead Handle (Polygon above numbers)
this.ctx.fillStyle = "#ff4444";
this.ctx.beginPath();
this.ctx.moveTo(playheadX - 6, 0);
this.ctx.lineTo(playheadX + 6, 0);
this.ctx.lineTo(playheadX + 6, 8);
this.ctx.lineTo(playheadX, 14);
this.ctx.lineTo(playheadX - 6, 8);
this.ctx.fill();
// Draw vertical grab bar on the right edge of viewport for resizing width
const grabBarW = 4;
const grabBarH = 50;
const grabBarX = this.viewport.scrollLeft + this.viewport.clientWidth - grabBarW - 3;
const grabBarY = RULER_HEIGHT + (this.blockHeight + this.audioTrackHeight - grabBarH) / 2;
this.ctx.fillStyle = "rgba(40, 40, 40, 0.6)";
this.ctx.beginPath();
this.ctx.roundRect(grabBarX, grabBarY, grabBarW, grabBarH, 2);
this.ctx.fill();
// Draw horizontal grab bar at the bottom of viewport for resizing height
const hBarW = 50;
const hBarH = 4;
const hBarX = this.viewport.scrollLeft + (this.viewport.clientWidth - hBarW) / 2;
const hBarY = this.canvasHeight - hBarH - 3; // 3px from the bottom edge
this.ctx.fillStyle = "rgba(20, 20, 20, 0.8)";
this.ctx.beginPath();
this.ctx.roundRect(hBarX, hBarY, hBarW, hBarH, 2);
this.ctx.fill();
this.updatePlayerUI();
}
drawAudioSegmentVisuals(ctx, seg, isSelected, yOffset, trackHeight, startX, pxWidth) {
ctx.fillStyle = isSelected ? "#2a4a3a" : "#1a2a1a";
ctx.fillRect(startX, yOffset + 2, pxWidth, trackHeight - 3);
if (seg.waveformPeaks && pxWidth > 0) {
ctx.fillStyle = isSelected ? "rgba(100, 255, 100, 0.6)" : "rgba(100, 255, 100, 0.3)";
const startRatio = seg.trimStart / seg.audioDurationFrames;
const endRatio = (seg.trimStart + seg.length) / seg.audioDurationFrames;
const peakCount = seg.waveformPeaks.length;
const centerY = yOffset + trackHeight / 2;
ctx.beginPath();
for (let i = 0; i < pxWidth; i++) {
const pixelRatio = i / pxWidth;
const globalRatio = startRatio + pixelRatio * (endRatio - startRatio);
const peakIdx = Math.floor(globalRatio * peakCount);
if (peakIdx >= 0 && peakIdx < peakCount) {
const val = seg.waveformPeaks[peakIdx];
const amp = (val * (trackHeight - 12) / 2) * 0.9;
ctx.fillRect(startX + i, centerY - amp, 1, amp * 2);
}
}
}
ctx.strokeStyle = isSelected ? "#4fff8f" : "#000";
ctx.lineWidth = 1.5;
ctx.strokeRect(startX, yOffset + 2, pxWidth, trackHeight - 3);
if (isSelected) {
ctx.fillStyle = "#4fff8f";
ctx.beginPath();
ctx.roundRect(startX, yOffset + trackHeight / 2 - 12, 4, 24, 2);
ctx.fill();
ctx.beginPath();
ctx.roundRect(startX + pxWidth - 4, yOffset + trackHeight / 2 - 12, 4, 24, 2);
ctx.fill();
}
ctx.fillStyle = "#ccc";
ctx.font = "11px sans-serif";
ctx.textBaseline = "top";
ctx.textAlign = "left";
ctx.save();
ctx.beginPath();
ctx.rect(startX, yOffset + 2, pxWidth, trackHeight - 3);
ctx.clip();
let text = seg.fileName || "Audio Track";
const maxWidth = pxWidth - 12;
if (ctx.measureText(text).width > maxWidth && maxWidth > 0) {
while (text.length > 0 && ctx.measureText(text + "...").width > maxWidth) {
text = text.slice(0, -1);
}
text = text + "...";
}
ctx.fillText(text, startX + 6, yOffset + 8);
ctx.restore();
}
// --- Interaction Logic ---
getHitTest(mouseX, mouseY) {
const width = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
// Check Playhead Handle first
const playheadX = (this.currentFrame / totalFrames) * width;
if (mouseY <= 24 && Math.abs(mouseX - playheadX) <= 12) {
return { type: "playhead" };
}
if (mouseY <= RULER_HEIGHT) {
return { type: "ruler" };
}
if (mouseY < RULER_HEIGHT || mouseY > this.canvasHeight) return null;
const isAudioTrack = mouseY > RULER_HEIGHT + this.blockHeight;
const trackSegments = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments;
const trackType = isAudioTrack ? "audio" : "image";
if (trackSegments.length === 0) return null;
// The variables width and totalFrames are already declared above.
let sortedSegments = [...trackSegments]
.map((s, i) => ({ ...s, originalIndex: i }))
.sort((a, b) => a.start - b.start);
const HANDLE_CORE = 4;
for (let i = 0; i < sortedSegments.length; i++) {
const seg = sortedSegments[i];
const startX = (seg.start / totalFrames) * width;
const pxWidth = (seg.length / totalFrames) * width;
const endX = startX + pxWidth;
const prevSeg = sortedSegments[i - 1];
const nextSeg = sortedSegments[i + 1];
const isLeftJoint = prevSeg && prevSeg.start + prevSeg.length === seg.start;
if (!isLeftJoint) {
if (Math.abs(mouseX - startX) <= HANDLE_HIT_PX) {
return { type: "edge", index: seg.originalIndex, dir: "left", track: trackType };
}
}
const isRightJoint = nextSeg && nextSeg.start === seg.start + seg.length;
if (isRightJoint) {
const dx = mouseX - endX;
if (Math.abs(dx) <= HANDLE_HIT_PX) {
if (dx < -HANDLE_CORE) {
return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType };
} else if (dx > HANDLE_CORE) {
return { type: "edge", index: nextSeg.originalIndex, dir: "left", track: trackType };
} else {
return { type: "joint", leftIndex: seg.originalIndex, rightIndex: nextSeg.originalIndex, track: trackType };
}
}
} else {
if (Math.abs(mouseX - endX) <= HANDLE_HIT_PX) {
return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType };
}
}
}
for (let i = 0; i < sortedSegments.length; i++) {
const seg = sortedSegments[i];
const startX = (seg.start / totalFrames) * width;
const pxWidth = (seg.length / totalFrames) * width;
const endX = startX + pxWidth;
if (mouseX >= startX && mouseX < endX) {
return { type: "center", index: seg.originalIndex, track: trackType };
}
}
return null;
}
onMouseDown(e) {
if (e.button !== 0) return;
const { x, y } = this.getMousePos(e);
const isOverDivider = Math.abs(y - (RULER_HEIGHT + this.blockHeight)) <= 4;
if (isOverDivider) {
this._isDragging = true;
this._dragType = "divider";
this._startBlockHeight = this.blockHeight;
this._startAudioTrackHeight = this.audioTrackHeight;
this._startY = y;
return;
}
const isAtBottom = Math.abs(y - this.canvasHeight) <= 15;
if (isAtBottom) {
this._isDragging = true;
this._dragType = "height_resize";
this._startBlockHeight = this.blockHeight;
this._startY = y;
document.body.style.userSelect = "none";
return;
}
const viewRect = this.viewport.getBoundingClientRect();
const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20;
if (isAtRightEdge) {
this._isDragging = true;
this._dragType = "width_resize";
this._startNodeWidth = this.node.size[0];
this._startX = e.clientX;
document.body.style.userSelect = "none";
return;
}
if (y >= RULER_HEIGHT && y <= this.canvasHeight) {
const BTN_R = 12;
const gapRegions = this.getGapRegions();
for (let i = 0; i < gapRegions.length; i++) {
const gap = gapRegions[i];
if (gap.widthPx < BTN_R * 2 + 8) continue;
const dx = x - gap.centerX, dy2 = y - gap.centerY;
if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) {
if (gap.track === "audio") {
// Direct to audio upload
this.promptAddAudioInGap(gap.frameStart, gap.frameEnd);
} else {
this.showGapMenu(e.clientX, e.clientY, gap);
}
return;
}
}
}
const hit = this.getHitTest(x, y);
if (!hit) {
// Only deselect if they clicked the same track but hit empty space
const clickedTrack = y > RULER_HEIGHT + this.blockHeight ? "audio" : "image";
if (this.selectionType === clickedTrack) {
this.selectedIndex = -1;
this.updateUIFromSelection();
}
this.render();
return;
}
if (hit.type === "playhead" || hit.type === "ruler") {
this._isDragging = true;
this._dragType = "playhead";
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
let mouseFrameX = x * (totalFrames / logicalWidth);
this.currentFrame = clamp(mouseFrameX, 0, totalFrames);
this.render();
if (this.isPlaying) {
this.playAudio();
}
return;
}
this.selectionType = hit.track;
const targetArray = hit.track === "audio" ? this.timeline.audioSegments : this.timeline.segments;
if (hit.type === "joint") {
this.selectedIndex = hit.leftIndex;
this.updateUIFromSelection();
this._dragType = "joint";
this._dragTargetId = targetArray[hit.leftIndex].id;
this._dragTargetIdRight = targetArray[hit.rightIndex].id;
} else if (hit.type === "center") {
this.selectedIndex = hit.index;
this.updateUIFromSelection();
this._dragType = "center";
} else {
if (this.selectedIndex !== hit.index) {
this.selectedIndex = hit.index;
this.updateUIFromSelection();
}
this._dragType = hit.dir;
}
this._isDragging = true;
this._previewSegments = null;
this._dragStartX = x;
this._dragInitialTimeline = JSON.parse(JSON.stringify(targetArray));
if (hit.type !== "joint") {
this._dragTargetId = targetArray[hit.index].id;
}
this.render();
}
onMouseMove(e) {
const { x: mouseX, y: mouseY } = this.getMousePos(e);
if (!this._isDragging) {
let newHoveredGapIdx = -1;
const BTN_R = 12;
const gapRegions = this.getGapRegions();
for (let i = 0; i < gapRegions.length; i++) {
const gap = gapRegions[i];
if (gap.widthPx < BTN_R * 2 + 8) continue;
const dx = mouseX - gap.centerX, dy2 = mouseY - gap.centerY;
if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) { newHoveredGapIdx = i; break; }
}
if (this._hoveredGapIdx !== newHoveredGapIdx) {
this._hoveredGapIdx = newHoveredGapIdx;
this.render();
}
const isOverDivider = Math.abs(mouseY - (RULER_HEIGHT + this.blockHeight)) <= 4;
const isAtBottom = Math.abs(mouseY - this.canvasHeight) <= 15;
const viewRect = this.viewport.getBoundingClientRect();
const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20;
const hit = this.getHitTest(mouseX, mouseY);
if (isOverDivider || isAtBottom) {
this.canvas.style.cursor = "ns-resize";
} else if (isAtRightEdge) {
this.canvas.style.cursor = "ew-resize";
} else if (newHoveredGapIdx >= 0) {
this.canvas.style.cursor = "pointer";
} else if (hit?.type === "edge") {
this.canvas.style.cursor = "ew-resize";
} else if (hit?.type === "joint") {
this.canvas.style.cursor = "col-resize";
} else if (hit?.type === "center") {
this.canvas.style.cursor = "grab";
} else if (hit?.type === "playhead") {
this.canvas.style.cursor = "ew-resize";
} else {
this.canvas.style.cursor = "default";
}
return;
}
if (this._dragType === "divider") {
this.canvas.style.cursor = "ns-resize";
const deltaY = mouseY - this._startY;
const minBlockH = 50;
const minAudioH = 50;
let newBlockHeight = this._startBlockHeight + deltaY;
let newAudioTrackHeight = this._startAudioTrackHeight - deltaY;
if (newBlockHeight < minBlockH) {
newBlockHeight = minBlockH;
newAudioTrackHeight = this._startBlockHeight + this._startAudioTrackHeight - minBlockH;
}
if (newAudioTrackHeight < minAudioH) {
newAudioTrackHeight = minAudioH;
newBlockHeight = this._startBlockHeight + this._startAudioTrackHeight - minAudioH;
}
this.blockHeight = newBlockHeight;
this.audioTrackHeight = newAudioTrackHeight;
this.render();
return;
}
if (this._dragType === "height_resize") {
this.canvas.style.cursor = "ns-resize";
const deltaY = mouseY - this._startY;
this.blockHeight = Math.max(100, this._startBlockHeight + deltaY);
this.canvasHeight = this.rulerHeight + this.blockHeight + this.audioTrackHeight;
this.canvas.style.height = `${this.canvasHeight}px`;
this.resizeCanvas(this.canvas.offsetWidth);
this.render();
if (this.node && this.node.computeSize) {
const sz = this.node.computeSize();
this.node.size[1] = sz[1];
if (window.app && window.app.graph) {
window.app.graph.setDirtyCanvas(true, true);
}
}
return;
}
if (this._dragType === "width_resize") {
this.canvas.style.cursor = "ew-resize";
const deltaX = e.clientX - this._startX;
this.node.size[0] = Math.max(300, this._startNodeWidth + deltaX);
if (window.app && window.app.graph) {
window.app.graph.setDirtyCanvas(true, true);
}
return;
}
if (this._dragType === "playhead") {
this.canvas.style.cursor = "ew-resize";
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
let mouseFrameX = mouseX * (totalFrames / logicalWidth);
this.currentFrame = clamp(mouseFrameX, 0, totalFrames);
this.render();
if (this.isPlaying) {
this.playAudio(); // Scrub (restart from new position)
}
return;
}
this.canvas.style.cursor = this._dragType === "center" ? "grabbing" :
this._dragType === "joint" ? "col-resize" : "ew-resize";
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
const durationFrames = totalFrames;
const dragDelta = Math.round((mouseX - this._dragStartX) * (totalFrames / logicalWidth));
let t = JSON.parse(JSON.stringify(this._dragInitialTimeline));
// --- Rolling Edit (Slide Edit) ---
if (this._dragType === "joint") {
let leftIdx = t.findIndex(s => s.id === this._dragTargetId);
let rightIdx = t.findIndex(s => s.id === this._dragTargetIdRight);
if (leftIdx >= 0 && rightIdx >= 0) {
let origLeft = this._dragInitialTimeline.find(s => s.id === this._dragTargetId);
let origRight = this._dragInitialTimeline.find(s => s.id === this._dragTargetIdRight);
let maxDeltaRight = origRight.length - MIN_SEGMENT_LENGTH;
let maxDeltaLeft = origLeft.length - MIN_SEGMENT_LENGTH;
if (this.selectionType === "audio") {
// Drag LEFT: right clip extends left by un-trimming its head.
// Can only un-trim as much as the right clip has been trimmed (trimStart >= 0).
maxDeltaLeft = Math.min(maxDeltaLeft, origRight.trimStart || 0);
// Drag RIGHT: left clip extends right by consuming its remaining tail audio.
// Can only extend as far as the left clip's unplayed tail allows.
let availLeftTail = (origLeft.audioDurationFrames || origLeft.length) - ((origLeft.trimStart || 0) + origLeft.length);
maxDeltaRight = Math.min(maxDeltaRight, availLeftTail);
}
let safeDelta = clamp(dragDelta, -maxDeltaLeft, maxDeltaRight);
t[leftIdx].length = origLeft.length + safeDelta;
t[rightIdx].start = origRight.start + safeDelta;
t[rightIdx].length = origRight.length - safeDelta;
if (this.selectionType === "audio") {
t[rightIdx].trimStart = origRight.trimStart + safeDelta;
}
}
}
// --- Edge & Center Drags ---
else {
const targetIdx = t.findIndex((s) => s.id === this._dragTargetId);
if (targetIdx < 0) return;
if (this._dragType === "right") {
let newLen = t[targetIdx].length + dragDelta;
let maxPossibleLength = totalFrames - t[targetIdx].start;
let nextSeg = t.find(s => s.start >= t[targetIdx].start + t[targetIdx].length && s.id !== t[targetIdx].id);
if (nextSeg) {
maxPossibleLength = nextSeg.start - t[targetIdx].start;
}
if (this.selectionType === "audio") {
maxPossibleLength = Math.min(maxPossibleLength, (t[targetIdx].audioDurationFrames || t[targetIdx].length) - (t[targetIdx].trimStart || 0));
}
t[targetIdx].length = Math.max(MIN_SEGMENT_LENGTH, Math.min(newLen, maxPossibleLength));
} else if (this._dragType === "left") {
let newStart = t[targetIdx].start + dragDelta;
let minPossibleStart = 0;
let prevSeg = t.slice().reverse().find(s => s.start + s.length <= t[targetIdx].start && s.id !== t[targetIdx].id);
if (prevSeg) {
minPossibleStart = prevSeg.start + prevSeg.length;
}
if (this.selectionType === "audio") {
minPossibleStart = Math.max(minPossibleStart, t[targetIdx].start - (t[targetIdx].trimStart || 0));
}
let maxStart = t[targetIdx].start + t[targetIdx].length - MIN_SEGMENT_LENGTH;
newStart = Math.max(minPossibleStart, Math.min(newStart, maxStart));
let diff = newStart - t[targetIdx].start;
t[targetIdx].start = newStart;
t[targetIdx].length -= diff;
if (this.selectionType === "audio") {
t[targetIdx].trimStart += diff;
}
} else if (this._dragType === "center") {
let initT = this._dragInitialTimeline;
let dIdx = initT.findIndex(s => s.id === this._dragTargetId);
if (dIdx < 0) return;
let D = JSON.parse(JSON.stringify(initT[dIdx]));
let D_mouse_start = D.start + dragDelta;
let mouseFrameX = mouseX * (totalFrames / logicalWidth);
t = this._applyCenterDragPhysics(initT, D.id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth);
}
}
this._previewSegments = t;
this.updateUIFromSelection(); // Live update of trim values
this.render();
}
_applyCenterDragPhysics(initT, D_id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth) {
let t_copy = JSON.parse(JSON.stringify(initT));
let dIdx = t_copy.findIndex(s => s.id === D_id);
if (dIdx < 0) return t_copy;
let D = t_copy[dIdx];
let D_clamped_start = clamp(D_mouse_start, 0, durationFrames - D.length);
let baseSegments = t_copy.filter(s => s.id !== D.id);
let insertIdx = baseSegments.length;
for (let i = 0; i < baseSegments.length; i++) {
let centerBase = baseSegments[i].start + baseSegments[i].length / 2;
if (mouseFrameX < centerBase) {
insertIdx = i;
break;
}
}
let leftBound = insertIdx > 0 ? baseSegments[insertIdx - 1].start + baseSegments[insertIdx - 1].length : 0;
let rightBound = insertIdx < baseSegments.length ? baseSegments[insertIdx].start : durationFrames;
if (rightBound - leftBound >= D.length) {
D_clamped_start = clamp(D_clamped_start, leftBound, rightBound - D.length);
} else {
let gapCenter = (leftBound + rightBound) / 2;
D_clamped_start = gapCenter - D.length / 2;
}
let t_test = [];
for (let i = 0; i < insertIdx; i++) {
t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start });
}
t_test.push({ ...D, start: D_clamped_start, original_start: D_clamped_start });
let D_index = insertIdx;
for (let i = insertIdx; i < baseSegments.length; i++) {
t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start });
}
for (let i = D_index + 1; i < t_test.length; i++) {
let prev = t_test[i - 1];
t_test[i].start = Math.max(t_test[i].original_start, prev.start + prev.length);
}
for (let i = D_index - 1; i >= 0; i--) {
let next = t_test[i + 1];
t_test[i].start = Math.min(t_test[i].original_start, next.start - t_test[i].length);
}
let rightCursor = durationFrames;
for (let i = t_test.length - 1; i >= 0; i--) {
if (t_test[i].start + t_test[i].length > rightCursor) {
t_test[i].start = rightCursor - t_test[i].length;
}
rightCursor = t_test[i].start;
}
let leftCursor = 0;
for (let i = 0; i < t_test.length; i++) {
if (t_test[i].start < leftCursor) {
t_test[i].start = leftCursor;
}
leftCursor = t_test[i].start + t_test[i].length;
}
let result = t_test.map(s => {
let clean = { ...s };
delete clean.original_start;
return clean;
});
let draggedPreview = result.find(s => s.id === D.id);
if (draggedPreview) {
draggedPreview.resolvedStart = draggedPreview.start;
}
return result;
}
onMouseUp(e) {
document.body.style.userSelect = "";
if (this._isDragging) {
if (this._previewSegments) {
const targetArray = this.selectionType === "audio" ? this.timeline.audioSegments : this.timeline.segments;
const mappedArray = this._previewSegments.map(ps => {
const orig = targetArray.find(s => s.id === ps.id);
let finalStart = ps.resolvedStart !== undefined ? ps.resolvedStart : ps.start;
let newPs = { ...ps, start: finalStart };
if (orig && orig.imgObj) newPs.imgObj = orig.imgObj;
delete newPs.resolvedStart;
return newPs;
});
if (this.selectionType === "audio") {
this.timeline.audioSegments = mappedArray;
if (this._dragTargetId) this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === this._dragTargetId);
} else {
this.timeline.segments = mappedArray;
if (this._dragTargetId) this.selectedIndex = this.timeline.segments.findIndex(s => s.id === this._dragTargetId);
}
}
this._isDragging = false;
this._previewSegments = null;
this._ghostTrack = null;
this.canvas.style.cursor = "default";
this.commitChanges();
}
}
// --- Backend Data Sync ---
commitChanges(skipRender = false) {
let sortedSegments = [...this.timeline.segments].sort((a, b) => a.start - b.start);
let contiguousLengths = [];
let contiguousPrompts = [];
let currentCursor = 0;
const durationFrames = this.getDurationFrames();
// Build segment lengths clipped at the duration cutoff.
// - Gaps before the first segment, or between segments, are absorbed into the adjacent
// segment's length (same as before), but are also clipped at durationFrames.
// - Segments that start at or past the cutoff are excluded entirely.
// - Segments that cross the cutoff are trimmed so their end = durationFrames exactly.
let pendingGap = 0;
for (let seg of sortedSegments) {
// Skip segments entirely outside the duration.
if (seg.start >= durationFrames) break;
if (seg.start > currentCursor) {
// Gap between the cursor and this segment — clip it at the cutoff too.
const gapLength = Math.min(seg.start, durationFrames) - currentCursor;
if (contiguousLengths.length > 0) {
contiguousLengths[contiguousLengths.length - 1] += gapLength;
} else {
pendingGap += gapLength;
}
}
// Clip segment end at the duration cutoff.
const clippedEnd = Math.min(seg.start + seg.length, durationFrames);
const clippedLength = clippedEnd - seg.start;
contiguousLengths.push(clippedLength + pendingGap);
contiguousPrompts.push(seg.prompt || "");
pendingGap = 0;
currentCursor = seg.start + seg.length; // advance by the real (unclipped) end for gap detection
}
// If segments don't fill to the end of the duration, pad the last segment to reach it.
const clampedCursor = Math.min(currentCursor, durationFrames);
if (contiguousLengths.length > 0 && clampedCursor < durationFrames) {
contiguousLengths[contiguousLengths.length - 1] += durationFrames - clampedCursor;
}
const toSave = {
segments: sortedSegments.map(s => {
const { imgObj, ...rest } = s;
return rest;
}),
audioSegments: (this.timeline.audioSegments || []).map(s => ({ ...s }))
};
const jsonStr = JSON.stringify(toSave);
if (this.timelineDataWidget) this.timelineDataWidget.value = jsonStr;
if (this.localPromptsWidget) {
this.localPromptsWidget.value = contiguousPrompts.join(" | ");
}
if (this.segmentLengthsWidget) {
this.segmentLengthsWidget.value = contiguousLengths.join(",");
}
if (this.guideStrengthWidget) {
const imgStrengths = sortedSegments
.filter(s => s.type !== "text")
.map(s => (s.guideStrength !== undefined ? s.guideStrength : 1.0).toFixed(2));
this.guideStrengthWidget.value = imgStrengths.join(",");
}
// Keep zoom slider max in sync with the current timeline duration.
this.updateZoomSliderMax();
setTimeout(() => {
if (this.node && this.node.computeSize) {
const sz = this.node.computeSize();
this.node.size[1] = sz[1];
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
}, 0);
if (!skipRender) this.render();
}
// --- Gap Region Calculation ---
getGapRegions() {
const totalFrames = this.getVisualDurationFrames();
const outputFrames = this.getDurationFrames();
const width = this.canvas.offsetWidth || this._lastWidth || 0;
const gaps = [];
if (!width) return gaps;
// Image gaps
let cursor = 0;
const sortedImg = [...this.timeline.segments].sort((a, b) => a.start - b.start);
for (const seg of sortedImg) {
if (seg.start > cursor) {
const x0 = (cursor / totalFrames) * width;
const x1 = (seg.start / totalFrames) * width;
gaps.push({ track: 'image', frameStart: cursor, frameEnd: seg.start, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight / 2, widthPx: x1 - x0 });
}
cursor = seg.start + seg.length;
}
if (cursor < outputFrames) {
const x0 = (cursor / totalFrames) * width;
const x1 = (outputFrames / totalFrames) * width;
gaps.push({ track: 'image', frameStart: cursor, frameEnd: outputFrames, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight / 2, widthPx: x1 - x0 });
}
// Audio gaps
cursor = 0;
const sortedAud = [...this.timeline.audioSegments].sort((a, b) => a.start - b.start);
for (const seg of sortedAud) {
if (seg.start > cursor) {
const x0 = (cursor / totalFrames) * width;
const x1 = (seg.start / totalFrames) * width;
gaps.push({ track: 'audio', frameStart: cursor, frameEnd: seg.start, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight / 2, widthPx: x1 - x0 });
}
cursor = seg.start + seg.length;
}
if (cursor < outputFrames) {
const x0 = (cursor / totalFrames) * width;
const x1 = (outputFrames / totalFrames) * width;
gaps.push({ track: 'audio', frameStart: cursor, frameEnd: outputFrames, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight / 2, widthPx: x1 - x0 });
}
return gaps;
}
promptAddAudioInGap(frameStart, frameEnd) {
const fi = document.createElement("input");
fi.type = "file";
fi.accept = "audio/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) this.handleAudioUpload([ev.target.files[0]], frameStart);
});
fi.click();
}
// --- Context Menu ---
onContextMenu(e) {
e.preventDefault();
const { x: mouseX, y: mouseY } = this.getMousePos(e);
const trackHeight = this.blockHeight;
const isAudioTrack = mouseY >= RULER_HEIGHT + trackHeight && mouseY <= RULER_HEIGHT + trackHeight + this.audioTrackHeight;
const isImageTrack = mouseY >= RULER_HEIGHT && mouseY <= RULER_HEIGHT + trackHeight;
const logicalWidth = this.canvas.offsetWidth || 1;
const totalFrames = this.getVisualDurationFrames();
const cursor = mouseX * (totalFrames / logicalWidth);
let clickedSeg = null;
let trackType = "";
if (isAudioTrack) {
clickedSeg = this.timeline.audioSegments.find(s => cursor >= s.start && cursor <= s.start + s.length);
trackType = "audio";
} else if (isImageTrack) {
clickedSeg = this.timeline.segments.find(s => cursor >= s.start && cursor <= s.start + s.length);
trackType = clickedSeg ? clickedSeg.type : "";
}
if (clickedSeg) {
this.showContextMenu(e.clientX, e.clientY, clickedSeg, trackType);
} else if (isAudioTrack || isImageTrack) {
const gapRegions = this.getGapRegions();
const currentTrack = isAudioTrack ? "audio" : "image";
let gap = gapRegions.find(g => cursor >= g.frameStart && cursor <= g.frameEnd && g.track === currentTrack);
if (!gap) {
const startFrame = Math.round(cursor);
gap = {
track: currentTrack,
frameStart: startFrame,
frameEnd: startFrame + Math.max(1, this.getFrameRate())
};
}
gap.clickedFrame = cursor;
this.showGapContextMenu(e.clientX, e.clientY, gap);
}
}
showContextMenu(clientX, clientY, seg, trackType) {
this.dismissContextMenu();
const menu = document.createElement("div");
menu.className = "pr-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const isImage = trackType !== "audio" && trackType !== "text" && seg.imageB64;
if (isImage) {
const copyBtn = document.createElement("button");
copyBtn.className = "pr-gap-menu-btn";
copyBtn.innerHTML = `Copy Image`;
copyBtn.onclick = async () => {
try {
const res = await fetch(seg.imageB64);
const blob = await res.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
} catch (err) {
console.error("Failed to copy image", err);
}
this.dismissContextMenu();
};
menu.appendChild(copyBtn);
const saveBtn = document.createElement("button");
saveBtn.className = "pr-gap-menu-btn";
saveBtn.innerHTML = `Save Image`;
saveBtn.onclick = () => {
const a = document.createElement("a");
a.href = seg.imageB64;
a.download = "timeline_image.jpg";
a.click();
this.dismissContextMenu();
};
menu.appendChild(saveBtn);
const openBtn = document.createElement("button");
openBtn.className = "pr-gap-menu-btn";
openBtn.innerHTML = `Open Image in New Tab`;
openBtn.onclick = () => {
const win = window.open();
if (win) {
win.document.write(`<body style="margin:0;display:flex;justify-content:center;align-items:center;background:#0e0e0e;height:100vh;"><img style="max-width:100%;max-height:100%;" src="${seg.imageB64}" /></body>`);
win.document.close();
}
this.dismissContextMenu();
};
menu.appendChild(openBtn);
}
if (trackType !== "audio") {
const copyPromptBtn = document.createElement("button");
copyPromptBtn.className = "pr-gap-menu-btn";
copyPromptBtn.innerHTML = `Copy Prompt`;
copyPromptBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(seg.prompt || "");
} catch (err) {
console.error("Failed to copy prompt", err);
}
this.dismissContextMenu();
};
menu.appendChild(copyPromptBtn);
}
const copySegBtn = document.createElement("button");
copySegBtn.className = "pr-gap-menu-btn";
copySegBtn.innerHTML = `Copy Segment`;
copySegBtn.onclick = () => {
this._copiedSegment = { ...seg, id: Date.now().toString() + Math.random().toString(36).substr(2, 5) };
this._copiedSegmentTrack = trackType === "audio" ? "audio" : "image";
this.dismissContextMenu();
};
menu.appendChild(copySegBtn);
const currentTrack = trackType === "audio" ? "audio" : "image";
if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) {
const pasteReplaceBtn = document.createElement("button");
pasteReplaceBtn.className = "pr-gap-menu-btn";
pasteReplaceBtn.innerHTML = `Paste & Replace`;
pasteReplaceBtn.onclick = () => {
const newSeg = {
...this._copiedSegment,
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: seg.start,
length: this._copiedSegment.length
};
const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments;
const idx = targetArray.findIndex(s => s.id === seg.id);
if (idx >= 0) targetArray[idx] = newSeg;
this.commitChanges();
this.dismissContextMenu();
};
menu.appendChild(pasteReplaceBtn);
}
const delBtn = document.createElement("button");
delBtn.className = "pr-gap-menu-btn";
delBtn.innerHTML = `Delete`;
delBtn.style.color = "#ff4444";
delBtn.onclick = () => {
this.selectionType = trackType === "audio" ? "audio" : "image";
const list = trackType === "audio" ? this.timeline.audioSegments : this.timeline.segments;
this.selectedIndex = list.findIndex(s => s.id === seg.id);
this.deleteSelectedSegment();
this.dismissContextMenu();
};
menu.appendChild(delBtn);
document.body.appendChild(menu);
this._contextMenu = menu;
setTimeout(() => {
this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); };
document.addEventListener("pointerdown", this._contextMenuDismisser, true);
}, 0);
}
showGapContextMenu(clientX, clientY, gap) {
this.dismissContextMenu();
const menu = document.createElement("div");
menu.className = "pr-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const currentTrack = gap.track === "audio" ? "audio" : "image";
if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) {
const pasteBtn = document.createElement("button");
pasteBtn.className = "pr-gap-menu-btn";
pasteBtn.innerHTML = `Paste Segment`;
pasteBtn.onclick = () => {
const startFrame = Math.round(gap.clickedFrame !== undefined ? gap.clickedFrame : gap.frameStart);
const gapLength = gap.frameEnd - startFrame;
const newSeg = {
...this._copiedSegment,
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: startFrame,
length: Math.min(this._copiedSegment.length, gapLength)
};
const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments;
targetArray.push(newSeg);
targetArray.sort((a, b) => a.start - b.start);
this.commitChanges();
this.dismissContextMenu();
};
menu.appendChild(pasteBtn);
}
if (currentTrack === "image") {
const textBtn = document.createElement("button");
textBtn.className = "pr-gap-menu-btn";
textBtn.innerHTML = `${ICONS.text} Text Segment`;
textBtn.onclick = () => {
this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text");
this.dismissContextMenu();
};
menu.appendChild(textBtn);
const imgBtn = document.createElement("button");
imgBtn.className = "pr-gap-menu-btn";
imgBtn.innerHTML = `${ICONS.upload} Image Segment`;
imgBtn.onclick = () => {
this.dismissContextMenu();
const fi = document.createElement("input");
fi.type = "file"; fi.accept = "image/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) {
const gapLength = gap.frameEnd - gap.frameStart;
this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength);
}
});
fi.click();
};
menu.appendChild(imgBtn);
}
document.body.appendChild(menu);
this._contextMenu = menu;
setTimeout(() => {
this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); };
document.addEventListener("pointerdown", this._contextMenuDismisser, true);
}, 0);
}
dismissContextMenu() {
if (this._contextMenu) { this._contextMenu.remove(); this._contextMenu = null; }
if (this._contextMenuDismisser) { document.removeEventListener("pointerdown", this._contextMenuDismisser, true); this._contextMenuDismisser = null; }
}
// --- Gap Popup Menu ---
showGapMenu(clientX, clientY, gap) {
this.dismissGapMenu();
const menu = document.createElement("div");
menu.className = "pr-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const textBtn = document.createElement("button");
textBtn.className = "pr-gap-menu-btn";
textBtn.innerHTML = `${ICONS.text} Text Segment`;
textBtn.addEventListener("click", () => {
this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text");
this.dismissGapMenu();
});
const imgBtn = document.createElement("button");
imgBtn.className = "pr-gap-menu-btn";
imgBtn.innerHTML = `${ICONS.upload} Image Segment`;
imgBtn.addEventListener("click", () => {
this.dismissGapMenu();
const fi = document.createElement("input");
fi.type = "file"; fi.accept = "image/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) {
const gapLength = gap.frameEnd - gap.frameStart;
this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength);
}
});
fi.click();
});
menu.appendChild(textBtn);
menu.appendChild(imgBtn);
const currentTrack = gap.track === "audio" ? "audio" : "image";
if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) {
const pasteBtn = document.createElement("button");
pasteBtn.className = "pr-gap-menu-btn";
pasteBtn.innerHTML = `Paste Segment`;
pasteBtn.onclick = () => {
const gapLength = gap.frameEnd - gap.frameStart;
let finalLength = Math.min(this._copiedSegment.length, gapLength);
if (currentTrack === "image") {
finalLength = gapLength;
}
const newSeg = {
...this._copiedSegment,
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: gap.frameStart,
length: finalLength
};
const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments;
targetArray.push(newSeg);
targetArray.sort((a, b) => a.start - b.start);
this.commitChanges();
this.dismissGapMenu();
};
menu.appendChild(pasteBtn);
}
document.body.appendChild(menu);
this._gapMenu = menu;
setTimeout(() => {
this._gapMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissGapMenu(); };
document.addEventListener("pointerdown", this._gapMenuDismisser, true);
}, 0);
}
dismissGapMenu() {
if (this._gapMenu) { this._gapMenu.remove(); this._gapMenu = null; }
if (this._gapMenuDismisser) { document.removeEventListener("pointerdown", this._gapMenuDismisser, true); this._gapMenuDismisser = null; }
}
// --- Settings Menu ---
// Widgets that are managed by the settings menu (hidden from node by default).
get _settingsWidgetNames() {
return ["display_mode", "epsilon", "divisible_by", "img_compression"];
}
// Hide all settings widgets on the node (called on init).
hideSettingsWidgets() {
for (const name of this._settingsWidgetNames) {
const w = this.node.widgets?.find(w => w.name === name);
if (w) hideWidget(w);
// Also remove corresponding input slot if it exists and is NOT connected
// to prevent overlapping issues in classic ComfyUI (nodes v1)
if (this.node.inputs) {
const inputIdx = this.node.inputs.findIndex(i => i.name === name);
if (inputIdx !== -1) {
const input = this.node.inputs[inputIdx];
if (input.link == null) {
this.node.removeInput(inputIdx);
}
}
}
}
this.updateWidgetVisibility();
// Workaround: toggle display mode to force ComfyUI to refresh the node
if (this.displayModeWidget) {
const origVal = this.displayModeWidget.value;
const otherVal = origVal === "frames" ? "seconds" : "frames";
this.displayModeWidget.value = otherVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal);
this.displayModeWidget.value = origVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal);
}
}
// Restore all settings widgets on the node.
showSettingsWidgets() {
for (const name of this._settingsWidgetNames) {
const w = this.node.widgets?.find(w => w.name === name);
if (!w) continue;
const typeMap = {
display_mode: "combo", epsilon: "FLOAT", divisible_by: "INT",
img_compression: "INT",
};
w.type = typeMap[name] || w._origType || "number";
w.hidden = false;
if (w.options) w.options.hidden = false;
delete w.computeSize;
if (w.element) w.element.style.display = "";
}
this.updateWidgetVisibility();
// Workaround: toggle display mode to force ComfyUI to refresh the node
if (this.displayModeWidget) {
const origVal = this.displayModeWidget.value;
const otherVal = origVal === "frames" ? "seconds" : "frames";
this.displayModeWidget.value = otherVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal);
this.displayModeWidget.value = origVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal);
}
}
_makeSettingRow(label, inputEl) {
const row = document.createElement("div");
row.className = "pr-settings-row";
const lbl = document.createElement("span");
lbl.className = "pr-settings-label";
lbl.textContent = label;
row.appendChild(lbl);
row.appendChild(inputEl);
return row;
}
showSettingsMenu(anchorEl) {
this.dismissSettingsMenu();
const menu = document.createElement("div");
menu.className = "pr-settings-menu";
// Title & Close Button Container
const titleContainer = document.createElement("div");
titleContainer.className = "pr-settings-title";
titleContainer.style.display = "flex";
titleContainer.style.justifyContent = "space-between";
titleContainer.style.alignItems = "center";
const titleText = document.createElement("span");
titleText.textContent = "Timeline Settings";
titleContainer.appendChild(titleText);
const closeBtn = document.createElement("button");
closeBtn.className = "pr-settings-close-btn";
closeBtn.innerHTML = ICONS.close;
closeBtn.title = "Close Settings";
closeBtn.addEventListener("click", () => this.dismissSettingsMenu());
titleContainer.appendChild(closeBtn);
menu.appendChild(titleContainer);
// Helper: fire a widget's callback safely
const fireCallback = (w, val) => {
w.value = val;
if (w.callback) {
try { w.callback(val, app.canvas, this.node, null, null); } catch (e) { }
}
if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true);
};
// --- Display Mode ---
const dmWidget = this.node.widgets?.find(w => w.name === "display_mode");
if (dmWidget) {
const ctrl = document.createElement("div");
ctrl.className = "pr-segmented-control";
const framesSeg = document.createElement("div");
framesSeg.className = "pr-segment";
framesSeg.textContent = "Frames";
const secondsSeg = document.createElement("div");
secondsSeg.className = "pr-segment";
secondsSeg.textContent = "Seconds";
const updateActive = (val) => {
if (val === "frames") {
framesSeg.classList.add("active");
secondsSeg.classList.remove("active");
} else {
secondsSeg.classList.add("active");
framesSeg.classList.remove("active");
}
};
updateActive(dmWidget.value);
const onSegClick = (val) => {
fireCallback(dmWidget, val);
updateActive(val);
// Update ruler/timecode immediately
if (this.updateWidgetVisibility) this.updateWidgetVisibility();
if (this.updateUIFromSelection) this.updateUIFromSelection();
this.render();
};
framesSeg.addEventListener("click", () => onSegClick("frames"));
secondsSeg.addEventListener("click", () => onSegClick("seconds"));
ctrl.appendChild(secondsSeg);
ctrl.appendChild(framesSeg);
menu.appendChild(this._makeSettingRow("Display Mode", ctrl));
}
const divider1 = document.createElement("hr");
divider1.className = "pr-settings-divider";
menu.appendChild(divider1);
// Helper to create scrubbable number control with horizontal buttons
const createScrubbableNumberControl = (w, step, min, max, isFloat = false) => {
const container = document.createElement("div");
container.className = "pr-number-control";
const decBtn = document.createElement("button");
decBtn.className = "pr-number-btn";
decBtn.textContent = "-";
const inp = document.createElement("input");
inp.type = "number";
inp.className = "pr-settings-input";
inp.value = w.value;
inp.step = step.toString();
inp.min = min.toString();
inp.max = max.toString();
const incBtn = document.createElement("button");
incBtn.className = "pr-number-btn";
incBtn.textContent = "+";
decBtn.addEventListener("click", () => {
let val = parseFloat(inp.value) - step;
if (val < min) val = min;
inp.value = isFloat ? val.toFixed(4) : Math.round(val);
fireCallback(w, parseFloat(inp.value));
});
incBtn.addEventListener("click", () => {
let val = parseFloat(inp.value) + step;
if (val > max) val = max;
inp.value = isFloat ? val.toFixed(4) : Math.round(val);
fireCallback(w, parseFloat(inp.value));
});
inp.addEventListener("change", () => {
let val = parseFloat(inp.value);
if (isNaN(val)) val = w.value;
if (val < min) val = min;
if (val > max) val = max;
inp.value = isFloat ? val.toFixed(4) : Math.round(val);
fireCallback(w, parseFloat(inp.value));
});
// Dragging logic
let isDragging = false;
let startX = 0;
let startVal = 0;
let hasMoved = false;
inp.style.cursor = "ew-resize";
inp.addEventListener("mousedown", (e) => {
startX = e.clientX;
startVal = parseFloat(inp.value);
hasMoved = false;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
if (Math.abs(deltaX) > 3) {
hasMoved = true;
isDragging = true;
}
if (isDragging) {
moveEvent.preventDefault();
const sensitivity = isFloat ? 0.001 : 0.5;
let newVal = startVal + deltaX * sensitivity;
if (newVal < min) newVal = min;
if (newVal > max) newVal = max;
inp.value = isFloat ? newVal.toFixed(4) : Math.round(newVal);
fireCallback(w, parseFloat(inp.value));
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!hasMoved) {
inp.focus();
inp.select();
}
isDragging = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
container.appendChild(decBtn);
container.appendChild(inp);
container.appendChild(incBtn);
return container;
};
// --- Epsilon ---
const epsWidget = this.node.widgets?.find(w => w.name === "epsilon");
if (epsWidget) {
menu.appendChild(this._makeSettingRow("Epsilon", createScrubbableNumberControl(epsWidget, 0.0001, 0.0001, 0.99, true)));
}
// --- Divisible By ---
const divByWidget = this.node.widgets?.find(w => w.name === "divisible_by");
if (divByWidget) {
menu.appendChild(this._makeSettingRow("Divisible By", createScrubbableNumberControl(divByWidget, 1, 1, 256, false)));
}
// --- Img Compression ---
const compWidget = this.node.widgets?.find(w => w.name === "img_compression");
if (compWidget) {
menu.appendChild(this._makeSettingRow("Img Compression", createScrubbableNumberControl(compWidget, 1, 0, 100, false)));
}
// --- Global Prompt Toggle ---
const globalPromptWidget = this.node.widgets?.find(w => w.name === "global_prompt");
if (globalPromptWidget) {
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = !(globalPromptWidget.options && globalPromptWidget.options.hidden);
cb.style.cursor = "pointer";
cb.addEventListener("change", () => {
const isVisible = cb.checked;
if (!globalPromptWidget.options) globalPromptWidget.options = {};
globalPromptWidget.options.hidden = !isVisible;
if (isVisible) {
delete globalPromptWidget.computeSize;
globalPromptWidget.hidden = false;
if (globalPromptWidget.element) globalPromptWidget.element.style.display = "";
} else {
globalPromptWidget.computeSize = () => [0, 0];
globalPromptWidget.hidden = true;
if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none";
}
// Force refresh via display mode double-toggle trick
if (this.displayModeWidget) {
const origVal = this.displayModeWidget.value;
const otherVal = origVal === "frames" ? "seconds" : "frames";
this.displayModeWidget.value = otherVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal);
this.displayModeWidget.value = origVal;
if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal);
}
});
menu.appendChild(this._makeSettingRow("Use Global Prompt", cb));
}
// --- Show/Hide on Node Toggle ---
const toggleBtn = document.createElement("button");
toggleBtn.className = "pr-settings-toggle-btn";
const widgetsVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden)));
toggleBtn.textContent = widgetsVisible ? "Hide Widgets on Node" : "Show Widgets on Node";
toggleBtn.addEventListener("click", () => {
const nowVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden)));
if (nowVisible) {
this.hideSettingsWidgets();
toggleBtn.textContent = "Show Widgets on Node";
} else {
this.showSettingsWidgets();
toggleBtn.textContent = "Hide Widgets on Node";
}
});
menu.appendChild(toggleBtn);
// Position the menu below the anchor button (pop down)
document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect();
const menuW = menu.offsetWidth || 230;
const menuH = menu.offsetHeight || 350;
let left = rect.right - menuW;
let top = rect.bottom + 6;
if (left < 4) left = 4;
// Fallback to top if it overflows the bottom of the screen
if (top + menuH > window.innerHeight - 4) {
top = rect.top - menuH - 6;
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
this._settingsMenu = menu;
setTimeout(() => {
this._settingsDismisser = (ev) => {
if (!menu.contains(ev.target) && !anchorEl.contains(ev.target)) this.dismissSettingsMenu();
};
document.addEventListener("mousedown", this._settingsDismisser);
}, 0);
}
dismissSettingsMenu() {
if (this._settingsMenu) { this._settingsMenu.remove(); this._settingsMenu = null; }
if (this._settingsDismisser) { document.removeEventListener("mousedown", this._settingsDismisser); this._settingsDismisser = null; }
}
addSegmentInGap(frameStart, frameEnd, type = "text") {
const seg = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: frameStart, length: frameEnd - frameStart,
prompt: "", type,
};
this.timeline.segments.push(seg);
this.timeline.segments.sort((a, b) => a.start - b.start);
this.selectionType = "image";
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges();
}
addTextSegmentFreeSpace() {
const frameRate = this.getFrameRate();
const newLength = Math.max(1, frameRate); // 1 second default
const sorted = [...this.timeline.segments].sort((a, b) => a.start - b.start);
let newStart = 0;
for (const seg of sorted) {
if (newStart + newLength <= seg.start) break;
newStart = Math.max(newStart, seg.start + seg.length);
}
// Place the segment at the first free slot in the visual timeline (no output duration change).
const durationFrames = this.getVisualDurationFrames();
const seg = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: newStart, length: Math.min(newLength, Math.max(newLength, durationFrames - newStart)),
prompt: "", type: "text",
};
this.timeline.segments.push(seg);
this.timeline.segments.sort((a, b) => a.start - b.start);
this.selectionType = "image";
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges();
}
// --- Audio Player Engine ---
updatePlayerUI() {
if (!this.playBtn || !this.loopBtn) return;
this.playBtn.innerHTML = this.isPlaying ? ICONS.pause : ICONS.play;
if (this.isLooping) {
this.loopBtn.classList.add("active");
} else {
this.loopBtn.classList.remove("active");
}
if (this.seekBar) {
this.seekBar.max = this.getVisualDurationFrames();
this.seekBar.value = this.currentFrame;
}
if (this.timeCodeDisplay) {
this.timeCodeDisplay.textContent = this.formatTime(this.currentFrame);
}
}
togglePlay() {
if (this.isPlaying) {
this.pauseAudio();
} else {
if (this.currentFrame >= this.getVisualDurationFrames()) {
this.currentFrame = 0;
}
this.playAudio();
}
}
toggleLoop() {
this.isLooping = !this.isLooping;
this.updatePlayerUI();
}
async playAudio() {
this.pauseAudio(true); // clear any existing playback, but don't suspend context if scrubbing
this._playCounter = (this._playCounter || 0) + 1;
const playId = this._playCounter;
this._currentPlayId = playId;
this.isPlaying = true;
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.audioContext.state !== 'running') {
try { await this.audioContext.resume(); } catch (e) { }
}
if (this._currentPlayId !== playId || !this.isPlaying) return;
this.updatePlayerUI();
const frameRate = this.getFrameRate();
this.playbackStartFrame = this.currentFrame;
this.playbackStartTime = this.audioContext.currentTime;
// Decode and schedule all audio segments that happen AT or AFTER currentFrame
for (let seg of this.timeline.audioSegments) {
const segStartFrame = seg.start;
const segEndFrame = seg.start + seg.length;
if (segEndFrame <= this.currentFrame) continue;
try {
// Build audio buffer: fetch from server URL if audioFile is set, otherwise fall back to audioB64
let audioBuffer;
if (seg.audioFile) {
const audioUrl = api.apiURL(`/view?filename=${encodeURIComponent(seg.audioFile.split("/").pop())}&type=input&subfolder=${encodeURIComponent(seg.audioFile.includes("/") ? seg.audioFile.split("/").slice(0, -1).join("/") : "")}`);
const resp = await fetch(audioUrl);
const arrayBuffer = await resp.arrayBuffer();
audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
} else if (seg.audioB64) {
const binaryString = window.atob(seg.audioB64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
audioBuffer = await this.audioContext.decodeAudioData(bytes.buffer);
} else {
continue;
}
if (this._currentPlayId !== playId || !this.isPlaying) return;
const framesToSkipInSegment = Math.max(0, this.currentFrame - segStartFrame);
const waitFrames = Math.max(0, segStartFrame - this.currentFrame);
const waitTimeSec = waitFrames / frameRate;
const fileOffsetFrames = seg.trimStart + framesToSkipInSegment;
const fileOffsetSec = fileOffsetFrames / frameRate;
const playDurationFrames = seg.length - framesToSkipInSegment;
const playDurationSec = playDurationFrames / frameRate;
if (playDurationSec <= 0) continue;
const bufferNode = this.audioContext.createBufferSource();
bufferNode.buffer = audioBuffer;
bufferNode["connect"](this.audioContext.destination);
const startTime = this.audioContext.currentTime + waitTimeSec;
bufferNode.start(startTime, fileOffsetSec, playDurationSec);
this.activeAudioNodes.push(bufferNode);
} catch (err) {
console.error("Playback decode error for segment:", err);
}
}
if (this._currentPlayId !== playId || !this.isPlaying) return;
const loop = () => {
if (!this.isPlaying || this._currentPlayId !== playId) return;
const elapsedSec = this.audioContext.currentTime - this.playbackStartTime;
const elapsedFrames = elapsedSec * frameRate;
this.currentFrame = this.playbackStartFrame + elapsedFrames;
const visualDurationFrames = this.getVisualDurationFrames();
const durationFrames = this.getDurationFrames();
if (this.isLooping) {
const loopBound = (this.playbackStartFrame >= durationFrames) ? visualDurationFrames : durationFrames;
if (this.currentFrame >= loopBound) {
this.currentFrame = 0;
this.playAudio(); // Restart playback
return;
}
} else {
if (this.currentFrame >= visualDurationFrames) {
this.currentFrame = visualDurationFrames;
this.pauseAudio();
this.render();
return;
}
}
this.render();
this._playLoopId = requestAnimationFrame(loop);
};
this._playLoopId = requestAnimationFrame(loop);
}
pauseAudio(isScrubbing = false) {
this.isPlaying = false;
this._currentPlayId = null;
if (!isScrubbing && this.audioContext && this.audioContext.state === 'running') {
try { this.audioContext.suspend(); } catch (e) { }
}
for (let node of this.activeAudioNodes) {
try { node.stop(); } catch (e) { }
try { node.disconnect(); } catch (e) { }
}
this.activeAudioNodes = [];
if (this._playLoopId) {
cancelAnimationFrame(this._playLoopId);
this._playLoopId = null;
}
this.updatePlayerUI();
}
}
// --- Node Registration Hooks ---
const APPENDED_WIDGET_DEFAULTS = [
["timeline_data", "{}"],
["local_prompts", ""],
["segment_lengths", ""],
];
app.registerExtension({
name: "LTXDirector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "LTXDirector") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
for (const [name, def] of APPENDED_WIDGET_DEFAULTS) {
if (!this.widgets?.find(w => w.name === name)) {
this.addWidget("string", name, def, () => { });
}
}
for (const w of this.widgets) {
if (HIDDEN_WIDGET_NAMES.includes(w.name)) hideWidget(w);
}
// Set default width to be wider on creation (approx 2.5x default ~220px)
this.size[0] = 1000;
// Force default for img_compression if not set (ComfyUI sometimes skips optional defaults)
const compWidget = this.widgets?.find(w => w.name === "img_compression");
if (compWidget && (compWidget.value === undefined || compWidget.value === null || compWidget.value === 0)) {
compWidget.value = 18;
}
// Global Prompt is now left completely visible on creation!
const container = document.createElement("div");
const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, {
getValue: () => "",
setValue: () => { },
});
widget.computeSize = function (width) {
const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT;
return [width, canvasH + 235];
};
const self = this;
setTimeout(() => {
try {
self._timelineEditor = new TimelineEditor(self, container, widget);
} catch (err) {
console.error("[PromptRelay] timeline editor init failed:", err);
}
}, 0);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._timelineEditor?.destroy();
return onRemoved?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.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) {
this._timelineEditor.timeline = parseInitial(this._timelineEditor.timelineDataWidget?.value);
this._timelineEditor.loadImages();
this._timelineEditor.selectionType = "image";
this._timelineEditor.selectedIndex = clamp(
this._timelineEditor.selectedIndex, -1,
Math.max(-1, this._timelineEditor.timeline.segments.length - 1)
);
this._timelineEditor.updateUIFromSelection();
this._timelineEditor.render();
}
}, 0);
return out;
};
}
},
});