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 MOTION_TRACK_HEIGHT = 80; // used as Motion Guide track height
const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT + MOTION_TRACK_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", "inpaint_audio", "use_custom_motion", "override_audio"];
function hideWidget(w) {
if (!w) return;
w.hidden = true;
if (!w.options) w.options = {};
w.options.hidden = true;
// Use computeSize and draw overrides to safely collapse in LiteGraph
// without triggering ComfyUI's "convert to input slot" auto-behavior.
if (!window.LiteGraph || !window.LiteGraph.vueNodesMode) {
w.computeSize = () => [0, -4]; // -4 cancels out ComfyUI's hardcoded 4px widget padding
if (!w._hiddenDrawHooked) {
w._origDraw = w.hasOwnProperty('draw') ? w.draw : undefined;
w._hiddenDrawHooked = true;
}
w.draw = () => { };
}
if (w.element) w.element.style.display = "none";
if (w.callback) w.callback(w.value);
}
function showWidget(w) {
if (!w) return;
w.hidden = false;
if (w.options) w.options.hidden = false;
if (!window.LiteGraph || !window.LiteGraph.vueNodesMode) {
delete w.computeSize;
if (w._hiddenDrawHooked) {
if (w._origDraw !== undefined) {
w.draw = w._origDraw;
} else {
delete w.draw;
}
delete w._hiddenDrawHooked;
}
}
if (w.element) w.element.style.display = "";
if (w.callback) w.callback(w.value);
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// --- Modern Dark/Grey UI CSS (ComfyUI Match) ---
const STYLES = `
.prcs-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;
}
.prcs-wrapper.drag-active {
outline: 2px dashed #888;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.prcs-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0px;
flex-wrap: wrap;
gap: 6px;
}
.prcs-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.prcs-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;
}
.prcs-btn:hover:not(:disabled) {
background: #333;
border-color: #555;
}
.prcs-btn.toggle-on {
background: #1c222d;
border-color: #283142;
color: #e0e0e0;
}
.prcs-btn.toggle-on:hover:not(:disabled) {
background: #2a3445;
border-color: #3b4b66;
}
.prcs-btn-danger:hover:not(:disabled) {
background: #4a1515;
border-color: #cc4444;
color: #ffaaaa;
}
.prcs-canvas {
background: #2a2a2a;
cursor: pointer;
width: 100%;
outline: none;
display: block; /* Ensure no inline baseline gaps */
}
.prcs-prop-container {
display: flex;
flex-direction: column;
width: 100%;
flex-grow: 1; /* Automatically scales to fill node height */
min-height: 40px;
}
.prcs-prompt-wrapper {
position: relative;
width: 100%;
height: 100%;
background: #222;
border: 1px solid #111;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.2s ease, opacity 0.2s ease;
overflow: hidden;
}
.prcs-prompt-wrapper.focus-active {
border-color: #888;
}
.prcs-wrapper.has-focus .prcs-prompt-wrapper:not(.focus-active),
.prcs-wrapper:has(.prcs-prompt-wrapper.focus-active) .prcs-prompt-wrapper:not(.focus-active) {
opacity: 0.65;
}
.prcs-prompt-label {
position: absolute;
top: 5px;
left: 8px;
font-size: 9px;
font-weight: bold;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
pointer-events: none;
user-select: none;
z-index: 5;
}
.prcs-prompt-area {
position: absolute;
top: 20px;
left: 0;
width: 100%;
height: calc(100% - 20px);
background: transparent;
color: #e0e0e0;
border: none;
padding: 0 8px 8px 8px;
resize: none; /* Removed the manual resize corner handle */
font-size: 12px;
line-height: 1.4;
box-sizing: border-box;
outline: none;
}
.prcs-prompt-area:focus {
border-color: #888;
}
.prcs-motion-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;
}
.prcs-motion-info span { color: #fff; font-weight: 500; }
.prcs-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;
}
.prcs-audio-info span { color: #fff; font-weight: 500; }
.prcs-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%;
}
.prcs-strength-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
box-sizing: border-box;
}
.prcs-height-resizer {
height: 6px;
background: #2a2a2a;
cursor: ns-resize;
border-radius: 3px;
margin: 2px 0;
transition: background 0.15s;
border: 1px solid #1e1e1e;
}
.prcs-height-resizer:hover {
background: #444;
border-color: #555;
}
.prcs-strength-label {
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
margin-left: auto;
user-select: none;
-webkit-user-select: none;
}
.prcs-strength-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
border: 1px solid #222;
}
.prcs-strength-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #aaa;
cursor: pointer;
}
.prcs-strength-slider:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.prcs-strength-input {
font-size: 12px;
color: #fff;
background: #222;
border: 1px solid #444;
border-radius: 4px;
width: 52px;
text-align: center;
padding: 3px;
user-select: none;
-webkit-user-select: none;
}
.prcs-strength-input::-webkit-outer-spin-button,
.prcs-strength-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.prcs-strength-input[type=number] {
-moz-appearance: textfield;
}
.prcs-strength-input:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.prcs-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);
}
.prcs-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;
}
.prcs-gap-menu-btn:hover {
background: #3a3a3a;
border-color: #666;
}
.prcs-player-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 2px 0;
flex-wrap: wrap;
width: 100%;
}
.prcs-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;
}
.prcs-icon-btn * {
pointer-events: none;
}
.prcs-icon-btn:hover {
color: #fff;
background: #3a3a3a;
border-color: #666;
}
.prcs-icon-btn.active {
color: #4fff8f;
border-color: #4fff8f;
background: #1a3a2a;
}
.prcs-seek-bar {
-webkit-appearance: none;
appearance: none;
height: 6px;
background: #444;
border-radius: 3px;
outline: none;
cursor: pointer;
border: 1px solid #222;
}
.prcs-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;
}
.prcs-timeline-viewport {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 10px;
box-sizing: content-box;
}
.prcs-timeline-viewport::-webkit-scrollbar {
height: 10px;
}
.prcs-timeline-viewport::-webkit-scrollbar-track {
background: #151515;
border-radius: 5px;
}
.prcs-timeline-viewport::-webkit-scrollbar-thumb {
background: #444;
border-radius: 5px;
border: 1px solid #000;
}
.prcs-timeline-viewport::-webkit-scrollbar-thumb:hover {
background: #666;
border-color: #000;
}
.prcs-zoom-controls {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
}
.prcs-zoom-slider {
width: 80px;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.prcs-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #aaa;
cursor: pointer;
}
.prcs-right-group {
display: flex;
align-items: center;
gap: 12px;
}
.prcs-segment-bounds {
font-size: 12px;
color: #aaa;
font-family: monospace;
user-select: none;
-webkit-user-select: none;
}
.prcs-timecode {
font-size: 14px;
font-weight: bold;
color: #e0e0e0;
font-family: monospace;
user-select: none;
-webkit-user-select: none;
}
.prcs-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: 250px;
width: 560px;
max-width: 92vw;
max-height: 60vh;
overflow-y: auto;
}
.prcs-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;
}
.prcs-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.prcs-settings-label {
font-size: 12px;
color: #bbb;
flex: 1;
white-space: nowrap;
}
.prcs-number-control {
display: flex;
align-items: center;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
overflow: hidden;
}
.prcs-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;
}
.prcs-number-btn:hover {
background: #444;
color: #fff;
}
.prcs-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;
}
.prcs-settings-input::-webkit-outer-spin-button,
.prcs-settings-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.prcs-settings-select {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #444;
border-radius: 4px;
padding: 3px 4px;
font-size: 12px;
width: 98px;
cursor: pointer;
}
.prcs-settings-divider {
border: none;
border-top: 1px solid #2a2a2a;
margin: 2px 0;
}
.prcs-settings-toggle-btn {
width: 100%;
box-sizing: border-box;
margin: 0;
background: #252525;
color: #fff;
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
font-size: 11px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.prcs-settings-toggle-btn:hover {
background: #2e2e2e;
color: #fff;
border-color: #555;
}
.prcs-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;
}
.prcs-settings-close-btn:hover {
color: #fff;
background: rgba(255,255,255,0.1);
}
.prcs-segmented-control {
display: flex;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
padding: 2px;
width: 110px;
height: 25px;
align-items: center;
box-sizing: border-box;
}
.prcs-segment {
flex: 1;
text-align: center;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
cursor: pointer;
border-radius: 4px;
color: #888;
transition: all 0.15s ease;
}
.prcs-segment.active {
background: #333;
color: #fff;
}
.prcs-settings-divider {
border-top: 1px solid #333;
margin: 4px 0;
}
/* --- Character reference slots --- */
.prcs-characters-container { display: flex; justify-content: space-between; gap: 12px; margin-top: 6px; margin-bottom: 4px; box-sizing: border-box; width: 100%; flex-shrink: 0; }
.prcs-character-slot { flex: 1; background: #1e1e1e; border: 1.5px dashed #444; border-radius: 8px; height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 4px; position: relative; cursor: pointer; overflow: hidden; transition: all 0.2s ease; box-sizing: border-box; }
.prcs-character-slot:hover { border-color: #666; background: #252525; }
.prcs-character-slot.drag-over { border-color: #4fff8f; background: rgba(79, 255, 143, 0.05); }
.prcs-character-label { font-size: 10px; font-weight: bold; color: #888; margin-bottom: 2px; pointer-events: none; }
.prcs-character-placeholder { font-size: 9px; color: #666; text-align: center; pointer-events: none; margin-top: 10px; }
.prcs-character-previews-row { display: flex; width: 100%; height: 52px; gap: 4px; position: relative; }
.prcs-character-preview-wrapper { flex: 1; height: 100%; position: relative; overflow: hidden; border-radius: 3px; background: #111; }
.prcs-character-preview { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.prcs-character-delete { position: absolute; top: 2px; right: 2px; background: rgba(0, 0, 0, 0.85); color: #ff4444; border: none; border-radius: 50%; width: 14px; height: 14px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 9px; transition: background 0.15s; z-index: 10; padding: 0; }
.prcs-character-delete:hover { background: #ff4444; color: #fff; }
.prcs-character-validate-btn { position: absolute; bottom: -8px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.85); color: #e0e0e0; border: 1px solid #444; border-radius: 3px; padding: 2px 8px; font-size: 9px; font-weight: bold; cursor: pointer; transition: all 0.15s; z-index: 20; }
.prcs-character-validate-btn:hover { background: #4fff8f; color: #000; border-color: #4fff8f; }
.prcs-character-validate-btn.loading { background: #333; color: #888; cursor: wait; pointer-events: none; }
.prcs-character-desc { width: 100%; height: 38px; background: #111; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; font-size: 9px; resize: none; box-sizing: border-box; padding: 2px 4px; margin-top: 10px; outline: none; font-family: inherit; z-index: 10; }
.prcs-character-desc:focus { border-color: #4fff8f; }
/* --- @char autocomplete popup --- */
.prcs-autocomplete-menu { position: fixed; background: #181818; border: 1px solid #444; border-radius: 6px; padding: 4px; display: flex; flex-direction: column; gap: 2px; z-index: 100000; box-shadow: 0 4px 16px rgba(0,0,0,0.6); min-width: 180px; max-height: 200px; overflow-y: auto; }
.prcs-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; }
.prcs-autocomplete-item:hover, .prcs-autocomplete-item.active { background: #1c222d; color: #4fff8f; border-color: #4fff8f; }
.prcs-autocomplete-item span { font-weight: bold; font-size: 12px; }
.prcs-autocomplete-item small { color: #777; font-size: 10px; }
.prcs-autocomplete-item.active small { color: #4fff8f; opacity: 0.8; }
/* --- Ref Option toolbar dropdown --- */
.prcs-ref-option-select { background: #1e1e1e; color: #e0e0e0; border: 1px solid #3a3a3a; border-radius: 6px; height: 28px; padding: 0 8px; font-size: 11px; font-weight: 500; cursor: pointer; outline: none; transition: all 0.15s ease; }
.prcs-ref-option-select:hover { border-color: #4fff8f; color: #fff; }
.prcs-ref-option-select:focus { border-color: #4fff8f; }
`;
let styleEl = document.getElementById("prompt-relay-styles-cs");
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "prompt-relay-styles-cs";
document.head.appendChild(styleEl);
}
styleEl.textContent = STYLES;
// --- Icons ---
const ICONS = {
upload: ``,
audio: ``,
motion: ``,
trash: ``,
text: ``,
play: ``,
pause: ``,
loop: ``,
minus: ``,
plus: ``,
fit: ``,
gear: ``,
close: ``,
start: ``,
end: ``,
mark: ``,
help: ``,
magnet: ``
};
// --- Data Models ---
function parseInitial(jsonStr) {
let parsed = {
segments: [],
motionSegments: [],
audioSegments: [],
global_prompt: "",
retake_global_prompt: "",
mainTrackEnabled: true,
audioTrackEnabled: true,
motionTrackEnabled: true,
propHeight: 90,
globalPropHeight: 60,
showFilenames: true,
overrideAudio: false,
inpaint_audio: true,
retakeMode: false,
retakeStart: 24,
retakeLength: 48,
retakePrompt: "",
retakeStrength: 1.0,
retakeVideo: null,
normalStartFrame: 0,
normalDurationFrames: 120,
reference_mode: "OFF",
analyzeProvider: "ollama",
analyzeBaseUrl: "",
analyzeModel: "",
characters: [
{ images: [], description: "" },
{ images: [], description: "" },
{ images: [], description: "" }
]
};
try {
if (jsonStr) {
const p = JSON.parse(jsonStr);
if (p.global_prompt !== undefined) parsed.global_prompt = p.global_prompt;
if (p.retake_global_prompt !== undefined) parsed.retake_global_prompt = p.retake_global_prompt;
if (p.mainTrackEnabled !== undefined) parsed.mainTrackEnabled = p.mainTrackEnabled;
if (p.audioTrackEnabled !== undefined) parsed.audioTrackEnabled = p.audioTrackEnabled;
if (p.motionTrackEnabled !== undefined) parsed.motionTrackEnabled = p.motionTrackEnabled;
if (p.propHeight !== undefined) parsed.propHeight = p.propHeight;
if (p.globalPropHeight !== undefined) parsed.globalPropHeight = p.globalPropHeight;
if (p.showFilenames !== undefined) parsed.showFilenames = p.showFilenames;
if (p.overrideAudio !== undefined) parsed.overrideAudio = p.overrideAudio;
if (p.inpaint_audio !== undefined) parsed.inpaint_audio = p.inpaint_audio;
if (p.retakeMode !== undefined) parsed.retakeMode = p.retakeMode;
if (p.retakeStart !== undefined) parsed.retakeStart = p.retakeStart;
if (p.retakeLength !== undefined) parsed.retakeLength = p.retakeLength;
if (p.retakePrompt !== undefined) parsed.retakePrompt = p.retakePrompt;
if (p.retakeStrength !== undefined) parsed.retakeStrength = p.retakeStrength;
if (p.retakeVideo !== undefined) parsed.retakeVideo = p.retakeVideo;
if (p.normalStartFrame !== undefined) parsed.normalStartFrame = p.normalStartFrame;
if (p.normalDurationFrames !== undefined) parsed.normalDurationFrames = p.normalDurationFrames;
if (p.reference_mode !== undefined) parsed.reference_mode = p.reference_mode;
if (p.analyzeProvider !== undefined) parsed.analyzeProvider = p.analyzeProvider;
if (p.analyzeBaseUrl !== undefined) parsed.analyzeBaseUrl = p.analyzeBaseUrl;
if (p.analyzeModel !== undefined) parsed.analyzeModel = p.analyzeModel;
if (Array.isArray(p.characters)) {
parsed.characters = p.characters.map(c => ({
images: Array.isArray(c.images) ? c.images : [],
description: c.description || ""
}));
while (parsed.characters.length < 3) {
parsed.characters.push({ images: [], description: "" });
}
}
if (Array.isArray(p.segments)) {
parsed.segments = p.segments.map(s => {
const { imgObj, videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
});
}
if (Array.isArray(p.motionSegments)) {
parsed.motionSegments = p.motionSegments.map(s => {
const { videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
});
}
if (Array.isArray(p.audioSegments)) {
parsed.audioSegments = p.audioSegments.map(s => {
const { _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _decoding, ...rest } = s;
return rest;
});
}
}
} 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);
}
if (seg.isEndFrame === undefined) {
seg.isEndFrame = false;
}
}
for (let seg of parsed.motionSegments) {
if (!seg.id) {
seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
}
if (seg.trimStart === undefined) seg.trimStart = 0;
}
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.motionTrackHeight = MOTION_TRACK_HEIGHT;
this.audioTrackHeight = AUDIO_TRACK_HEIGHT;
this.canvasHeight = CANVAS_HEIGHT;
// Core data
this.timeline = { segments: [], motionSegments: [], audioSegments: [] };
this.selectionType = "image"; // "image", "motion", or "audio"
this.selectedSegmentIds = [];
this._selectedIndex = -1;
this._audioTrackWasEnabledBeforeOverride = false;
// Selection box tracking
this._isSelectingBox = false;
this._selectBoxStart = null;
this._selectBoxCurrent = null;
this._selectBoxInitialSelectedIds = null;
// 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;
// File handling
this.currentFileHandle = 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.startFramesWidget = this.node.widgets.find(w => w.name === "start_frame");
this.startSecondsWidget = this.node.widgets.find(w => w.name === "start_second");
this.endFramesWidget = this.node.widgets.find(w => w.name === "end_frame");
this.endSecondsWidget = this.node.widgets.find(w => w.name === "end_second");
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");
// Track the last-known frame rate so we can compute the rescale ratio
// inside the frameRateWidget callback (the widget value is already updated
// to the new value before the callback fires, so we can't read "old" from it).
this._prevFrameRate = this.getFrameRate();
this._prevStartFrames = this.getStartFrames();
this._prevStartSeconds = this.startSecondsWidget ? this.startSecondsWidget.value : 0;
console.log("[LTXDirector debug] Constructor: timelineDataWidget value:", this.timelineDataWidget?.value);
this.timeline = parseInitial(this.timelineDataWidget?.value);
this.retakeMode = this.timeline.retakeMode === true;
if (this.retakeMode) {
if (this.timeline.retake_global_prompt) {
if (!this.node.properties) this.node.properties = {};
this.node.properties.global_prompt = this.timeline.retake_global_prompt;
}
} else {
if (this.timeline.global_prompt) {
if (!this.node.properties) this.node.properties = {};
this.node.properties.global_prompt = this.timeline.global_prompt;
}
}
console.log("[LTXDirector debug] Constructor: parsed timeline:", JSON.stringify(this.timeline));
// Treat this.timeline (from timeline_data widget) as the absolute source of truth!
this.mainTrackEnabled = this.timeline.mainTrackEnabled !== false;
this.audioTrackEnabled = this.timeline.audioTrackEnabled !== false;
this.motionTrackEnabled = this.timeline.motionTrackEnabled !== false;
// Sync the properties dictionary too so they match
this.node.properties.mainTrackEnabled = this.mainTrackEnabled;
this.node.properties.audioTrackEnabled = this.audioTrackEnabled;
this.node.properties.motionTrackEnabled = this.motionTrackEnabled;
if (this.timeline.showFilenames !== undefined) {
this.node.properties.showFilenames = this.timeline.showFilenames;
}
if (this.timeline.overrideAudio !== undefined) {
this.node.properties.overrideAudio = this.timeline.overrideAudio;
}
if (this.timeline.inpaint_audio !== undefined) {
this.node.properties.inpaint_audio = this.timeline.inpaint_audio;
}
// Sync widgets to match the timeline data
const inpaintWidget = this.node.widgets?.find(w => w.name === "inpaint_audio");
if (inpaintWidget && this.timeline.inpaint_audio !== undefined) {
inpaintWidget.value = this.timeline.inpaint_audio;
}
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget && this.timeline.overrideAudio !== undefined) {
overrideWidget.value = this.timeline.overrideAudio;
}
this._audioTrackWasEnabledBeforeOverride = this.node.properties.audioTrackWasEnabledBeforeOverride || false;
this.loadMedia();
this.createDOM();
this.updateRetakeUIState();
if (this.timeline.segments.length > 0) {
this.selectedIndex = 0;
}
this.updateUIFromSelection();
this.syncWidgetsAndUI();
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;
// --- Start Callbacks ---
const origStartFramesCallback = this.startFramesWidget?.callback;
if (this.startFramesWidget) {
this.startFramesWidget.callback = (...args) => {
if (origStartFramesCallback) origStartFramesCallback.apply(this.startFramesWidget, args);
if (!isSyncing && this.startSecondsWidget && this.durationFramesWidget && this.endFramesWidget) {
isSyncing = true;
let newStartFrames = this.getStartFrames();
const endFrame = this.endFramesWidget.value || 1;
let newDurationFrames = Math.max(1, endFrame - newStartFrames);
if (newDurationFrames <= 1) {
newStartFrames = endFrame - 1;
this.startFramesWidget.value = newStartFrames;
newDurationFrames = 1;
}
this.startSecondsWidget.value = parseFloat((newStartFrames / this.getFrameRate()).toFixed(3));
this.durationFramesWidget.value = newDurationFrames;
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = parseFloat((newDurationFrames / this.getFrameRate()).toFixed(3));
}
this._prevStartFrames = newStartFrames;
this._prevStartSeconds = this.startSecondsWidget.value;
isSyncing = false;
}
this.commitChanges();
};
}
const origStartSecondsCallback = this.startSecondsWidget?.callback;
if (this.startSecondsWidget) {
this.startSecondsWidget.callback = (...args) => {
if (origStartSecondsCallback) origStartSecondsCallback.apply(this.startSecondsWidget, args);
if (!isSyncing && this.startFramesWidget && this.durationSecondsWidget && this.endFramesWidget) {
isSyncing = true;
let newStartSeconds = this.startSecondsWidget.value;
let newStartFrames = Math.max(0, Math.round(newStartSeconds * this.getFrameRate()));
const endFrame = this.endFramesWidget.value || 1;
let newDurationFrames = Math.max(1, endFrame - newStartFrames);
if (newDurationFrames <= 1) {
newStartFrames = endFrame - 1;
newStartSeconds = newStartFrames / this.getFrameRate();
this.startSecondsWidget.value = parseFloat(newStartSeconds.toFixed(3));
newDurationFrames = 1;
}
this.startFramesWidget.value = newStartFrames;
this.durationFramesWidget.value = newDurationFrames;
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = parseFloat((newDurationFrames / this.getFrameRate()).toFixed(3));
}
this._prevStartFrames = newStartFrames;
this._prevStartSeconds = this.startSecondsWidget.value;
isSyncing = false;
}
this.commitChanges();
};
}
// --- End Callbacks ---
const origEndFramesCallback = this.endFramesWidget?.callback;
if (this.endFramesWidget) {
this.endFramesWidget.callback = (...args) => {
if (origEndFramesCallback) origEndFramesCallback.apply(this.endFramesWidget, args);
if (!isSyncing && this.endSecondsWidget && this.durationFramesWidget && this.startFramesWidget) {
isSyncing = true;
let newEndFrames = this.endFramesWidget.value;
const startFrame = this.startFramesWidget.value || 0;
let newDurationFrames = Math.max(1, newEndFrames - startFrame);
if (newDurationFrames <= 1) {
newEndFrames = startFrame + 1;
this.endFramesWidget.value = newEndFrames;
newDurationFrames = 1;
}
this.endSecondsWidget.value = parseFloat((newEndFrames / this.getFrameRate()).toFixed(3));
this.durationFramesWidget.value = newDurationFrames;
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = parseFloat((newDurationFrames / this.getFrameRate()).toFixed(3));
}
isSyncing = false;
}
this.commitChanges();
};
}
const origEndSecondsCallback = this.endSecondsWidget?.callback;
if (this.endSecondsWidget) {
this.endSecondsWidget.callback = (...args) => {
if (origEndSecondsCallback) origEndSecondsCallback.apply(this.endSecondsWidget, args);
if (!isSyncing && this.endFramesWidget && this.durationSecondsWidget && this.startFramesWidget) {
isSyncing = true;
let newEndSeconds = this.endSecondsWidget.value;
let newEndFrames = Math.max(1, Math.round(newEndSeconds * this.getFrameRate()));
const startFrame = this.startFramesWidget.value || 0;
let newDurationFrames = Math.max(1, newEndFrames - startFrame);
if (newDurationFrames <= 1) {
newEndFrames = startFrame + 1;
newEndSeconds = newEndFrames / this.getFrameRate();
this.endSecondsWidget.value = parseFloat(newEndSeconds.toFixed(3));
newDurationFrames = 1;
}
this.endFramesWidget.value = newEndFrames;
this.durationFramesWidget.value = newDurationFrames;
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = parseFloat((newDurationFrames / this.getFrameRate()).toFixed(3));
}
isSyncing = false;
}
this.commitChanges();
};
}
// --- Duration Callbacks ---
const origDurationFramesCallback = this.durationFramesWidget?.callback;
if (this.durationFramesWidget) {
this.durationFramesWidget.callback = (...args) => {
if (origDurationFramesCallback) origDurationFramesCallback.apply(this.durationFramesWidget, args);
if (!isSyncing && this.durationSecondsWidget && this.startFramesWidget && this.endFramesWidget) {
isSyncing = true;
this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3));
const newEndFrames = this.startFramesWidget.value + this.getDurationFrames();
this.endFramesWidget.value = newEndFrames;
this.endSecondsWidget.value = parseFloat((newEndFrames / 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 && this.startFramesWidget && this.endFramesWidget) {
isSyncing = true;
const newFrames = Math.max(1, Math.round(this.durationSecondsWidget.value * this.getFrameRate()));
this.durationFramesWidget.value = newFrames;
const newEndFrames = this.startFramesWidget.value + newFrames;
this.endFramesWidget.value = newEndFrames;
this.endSecondsWidget.value = parseFloat((newEndFrames / this.getFrameRate()).toFixed(3));
isSyncing = false;
}
this.commitChanges();
};
}
const origFrameRateCallback = this.frameRateWidget?.callback;
if (this.frameRateWidget) {
this.frameRateWidget.callback = (...args) => {
if (origFrameRateCallback) origFrameRateCallback.apply(this.frameRateWidget, args);
// Keep start_seconds and end_seconds constant; recompute frames to match the new rate.
if (!isSyncing && this.durationSecondsWidget && this.durationFramesWidget) {
isSyncing = true;
const newFPS = this.getFrameRate();
// Recompute all segment frame values from their seconds snapshots.
// Using the snapshot avoids cumulative rounding errors when the user
// drags the slider rapidly through many intermediate FPS values.
this._rebaseSegmentsToFPS(newFPS);
if (this.startSecondsWidget && this.startFramesWidget) {
const newStartFrames = Math.max(0, Math.round(this.startSecondsWidget.value * newFPS));
this.startFramesWidget.value = newStartFrames;
this._prevStartFrames = newStartFrames;
}
if (this.endSecondsWidget && this.endFramesWidget) {
const newEndFrames = Math.max(1, Math.round(this.endSecondsWidget.value * newFPS));
this.endFramesWidget.value = newEndFrames;
}
const newFrames = Math.max(1, Math.round(this.durationSecondsWidget.value * newFPS));
this.durationFramesWidget.value = newFrames;
// Update our tracked previous rate now that the change is complete.
this._prevFrameRate = newFPS;
isSyncing = false;
}
this.commitChanges();
};
}
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());
}
isMultiSelectActive() {
if (!this.selectedSegmentIds || this.selectedSegmentIds.length <= 1) return false;
const baseIds = new Set();
for (const id of this.selectedSegmentIds) {
const baseId = (id.endsWith("_v") || id.endsWith("_a")) ? id.slice(0, -2) : id;
baseIds.add(baseId);
}
return baseIds.size > 1;
}
updateSelectionFromBox() {
if (!this._selectBoxStart || !this._selectBoxCurrent) return;
const width = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
if (!width || totalFrames <= 0) return;
const sx = this._selectBoxStart.x;
const sy = this._selectBoxStart.y;
const cx = this._selectBoxCurrent.x;
const cy = this._selectBoxCurrent.y;
const left = Math.min(sx, cx);
const right = Math.max(sx, cx);
const top = Math.min(sy, cy);
const bottom = Math.max(sy, cy);
const newSelectedIds = new Set(this._selectBoxInitialSelectedIds || []);
for (const track of ["image", "motion", "audio"]) {
const arr = this.getSegmentArray(track);
if (!arr) continue;
let trackTop = 0;
let trackBottom = 0;
if (track === "image") {
trackTop = RULER_HEIGHT;
trackBottom = RULER_HEIGHT + this.blockHeight;
} else if (track === "audio") {
trackTop = RULER_HEIGHT + this.blockHeight;
trackBottom = RULER_HEIGHT + this.blockHeight + this.audioTrackHeight;
} else if (track === "motion") {
trackTop = RULER_HEIGHT + this.blockHeight + this.audioTrackHeight;
trackBottom = RULER_HEIGHT + this.blockHeight + this.audioTrackHeight + this.motionTrackHeight;
}
for (const seg of arr) {
const startX = (seg.start / totalFrames) * width;
const pxWidth = (seg.length / totalFrames) * width;
const endX = startX + pxWidth;
// Check rect intersection
const intersects = (left <= endX && right >= startX && top <= trackBottom && bottom >= trackTop);
if (intersects) {
newSelectedIds.add(seg.id);
const sibId = seg.id.endsWith("_v") ? seg.id.slice(0, -2) + "_a" : (seg.id.endsWith("_a") ? seg.id.slice(0, -2) + "_v" : null);
if (sibId) {
newSelectedIds.add(sibId);
}
}
}
}
this.selectedSegmentIds = Array.from(newSelectedIds);
this.syncSelectionTypeAndIndex();
}
syncSelectionTypeAndIndex() {
if (!this.selectedSegmentIds || this.selectedSegmentIds.length === 0) {
this._selectedIndex = -1;
return;
}
if (this.isMultiSelectActive()) {
this._selectedIndex = -1;
return;
}
// Sync single selection (which might be video + audio sibling)
const firstId = this.selectedSegmentIds[0];
for (const track of ["image", "motion", "audio"]) {
const arr = this.getSegmentArray(track);
const idx = arr.findIndex(s => s.id === firstId);
if (idx !== -1) {
this.selectionType = track;
this._selectedIndex = idx;
break;
}
}
}
get selectedIndex() {
return this._selectedIndex;
}
set selectedIndex(val) {
this._selectedIndex = val;
if (this.selectedSegmentIds && !this.isMultiSelectActive()) {
if (val === -1) {
this.selectedSegmentIds = [];
} else {
const arr = this.getSegmentArray(this.selectionType);
const seg = arr ? arr[val] : null;
if (seg) {
this.selectedSegmentIds = [seg.id];
if (seg.id.endsWith("_v")) {
const sibId = seg.id.slice(0, -2) + "_a";
if (!this.selectedSegmentIds.includes(sibId)) this.selectedSegmentIds.push(sibId);
} else if (seg.id.endsWith("_a")) {
const sibId = seg.id.slice(0, -2) + "_v";
if (!this.selectedSegmentIds.includes(sibId)) this.selectedSegmentIds.push(sibId);
}
} else {
this.selectedSegmentIds = [];
}
}
}
}
destroy() {
cancelAnimationFrame(this._renderLoop);
this.pauseAudio();
window.removeEventListener("keydown", this.handleKeyDown, true);
window.removeEventListener("paste", this.handlePaste, true);
}
getStartFrames() {
return parseInt((this.startFramesWidget && this.startFramesWidget.value >= 0) ? this.startFramesWidget.value : 0, 10);
}
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);
}
}
// Force all start/end/duration widgets to match the retake video's duration exactly.
syncWidgetsToRetakeDuration(durationFrames) {
if (durationFrames <= 0) return;
const rate = this.getFrameRate();
const durationSeconds = parseFloat((durationFrames / rate).toFixed(3));
const wasSuppressing = this._suppressCommit;
this._suppressCommit = true;
if (this.startFramesWidget) {
this.startFramesWidget.value = 0;
if (this.startFramesWidget.callback) {
try { this.startFramesWidget.callback(0); } catch (_) {}
}
}
if (this.startSecondsWidget) {
this.startSecondsWidget.value = 0;
}
if (this.durationFramesWidget) {
this.durationFramesWidget.value = durationFrames;
if (this.durationFramesWidget.callback) {
try { this.durationFramesWidget.callback(durationFrames); } catch (_) {}
}
}
if (this.durationSecondsWidget) {
this.durationSecondsWidget.value = durationSeconds;
}
if (this.endFramesWidget) {
this.endFramesWidget.value = durationFrames;
}
if (this.endSecondsWidget) {
this.endSecondsWidget.value = durationSeconds;
}
this._suppressCommit = wasSuppressing;
}
// 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() {
if (this.retakeMode) {
if (this.timeline.retakeVideo) {
const baseVideoDur = this.timeline.retakeVideo.videoDurationFrames || 0;
// Add 15% visual buffer duration on the right to prevent the video segment
// from being cut off by the DOM clipping (right ~9% of the viewport is clipped by ComfyUI).
return Math.max(24, Math.ceil(baseVideoDur * 1.15));
} else {
return 24;
}
}
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);
}
for (const seg of this.timeline.motionSegments) {
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);
}
}
}
_liveScrubVideo(seg, edge) {
if (!seg || (seg.type !== "video" && seg.type !== "motion_video")) return;
this._ensureVideoEl(seg);
if (!seg.videoEl) return;
const targetSec = edge === "end"
? (seg.trimStart + seg.length) / this.getFrameRate()
: seg.trimStart / this.getFrameRate();
seg._scrubTargetSec = targetSec;
}
_liveScrubPlayhead() {
const targetFrame = this.currentFrame;
if (this.retakeMode && this.timeline.retakeVideo) {
const retakeVid = this.timeline.retakeVideo;
this._ensureVideoEl(retakeVid);
if (retakeVid.videoEl) {
const targetSec = targetFrame / this.getFrameRate();
retakeVid._scrubTargetSec = targetSec;
}
return;
}
const seg = this.timeline.segments.find(s => s.type === "video" && targetFrame >= s.start && targetFrame < s.start + s.length);
if (seg) {
this._ensureVideoEl(seg);
if (seg.videoEl) {
const targetSec = (seg.trimStart + (targetFrame - seg.start)) / this.getFrameRate();
seg._scrubTargetSec = targetSec;
}
}
const motionSeg = this.timeline.motionSegments.find(s => s.type === "motion_video" && targetFrame >= s.start && targetFrame < s.start + s.length);
if (motionSeg) {
this._ensureVideoEl(motionSeg);
if (motionSeg.videoEl) {
const targetSec = (motionSeg.trimStart + (targetFrame - motionSeg.start)) / this.getFrameRate();
motionSeg._scrubTargetSec = targetSec;
}
}
}
async _ensureThumbnails(seg) {
if (seg.thumbnails) return;
if (seg._extractingThumbs) return;
const fileKey = seg.imageFile || seg.videoFile || seg._blobUrl;
if (!fileKey) return;
this._thumbnailCache = this._thumbnailCache || new Map();
this._thumbnailPromises = this._thumbnailPromises || new Map();
if (this._thumbnailCache.has(fileKey)) {
seg.thumbnails = this._thumbnailCache.get(fileKey);
this.render();
return;
}
if (this._thumbnailPromises.has(fileKey)) {
seg._extractingThumbs = true;
try {
const thumbs = await this._thumbnailPromises.get(fileKey);
seg.thumbnails = thumbs;
} catch (err) {
console.error("Failed to await thumbnails promise:", err);
} finally {
seg._extractingThumbs = false;
this.render();
}
return;
}
// Otherwise, we extract the thumbnails
seg._extractingThumbs = true;
seg.thumbnails = [];
const extractPromise = (async () => {
const thumbs = [];
const parts = fileKey.split(/[/\\\\]/);
const filename = parts.pop() || '';
const subfolder = parts.join('/');
const vidUrl = seg._blobUrl || (seg.videoEl ? seg.videoEl.src : null) || api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`);
const bgVid = document.createElement('video');
bgVid.crossOrigin = "Anonymous";
bgVid.muted = true;
bgVid.preload = 'auto';
try {
await new Promise(r => {
let resolved = false;
const done = () => {
if (!resolved) {
resolved = true;
r();
}
};
bgVid.onloadeddata = done;
bgVid.onerror = done;
bgVid.src = vidUrl;
if (bgVid.readyState >= 2) {
done();
}
});
if (!bgVid.duration) {
return thumbs;
}
const duration = bgVid.duration;
const isLargeFile = seg.fileSize > 500 * 1024 * 1024;
const numFrames = isLargeFile ? 10 : Math.max(5, Math.min(25, Math.ceil(duration * 1.0)));
const canvas = document.createElement('canvas');
let w = bgVid.videoWidth, h = bgVid.videoHeight;
if (w === 0 || h === 0) return thumbs;
if (h > this.blockHeight) {
w = Math.round(w * (this.blockHeight / h));
h = this.blockHeight;
}
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
for (let i = 0; i < numFrames; i++) {
// Check if the file/segment is still active in the current timeline
const exists = this.timeline.segments.find(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey) ||
this.timeline.motionSegments.find(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey) ||
(this.timeline.retakeVideo && (this.timeline.retakeVideo.imageFile === fileKey || this.timeline.retakeVideo._blobUrl === fileKey));
if (!exists) break;
const time = (i / numFrames) * duration;
bgVid.currentTime = time;
await new Promise(r => {
let resolved = false;
const onSeek = () => { if (!resolved) { resolved = true; r(); } };
bgVid.onseeked = onSeek;
setTimeout(onSeek, 1000);
});
ctx.drawImage(bgVid, 0, 0, w, h);
const img = new Image();
img.src = canvas.toDataURL('image/jpeg', 0.5);
await new Promise(r => { img.onload = r; });
thumbs.push({ time, img });
// Propagate the partial progress live to all active segments sharing this file
const matchingSegs = [
...this.timeline.segments.filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey),
...(this.timeline.motionSegments || []).filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey)
];
if (this.timeline.retakeVideo && (this.timeline.retakeVideo.imageFile === fileKey || this.timeline.retakeVideo._blobUrl === fileKey)) {
matchingSegs.push(this.timeline.retakeVideo);
}
for (const ms of matchingSegs) {
ms.thumbnails = thumbs;
}
this.render();
}
} catch (err) {
console.error("Thumbnail extraction loop failed:", err);
} finally {
try {
bgVid.pause();
bgVid.onloadeddata = null;
bgVid.onerror = null;
bgVid.onseeked = null;
bgVid.src = "";
bgVid.load();
} catch (_) { }
}
return thumbs;
})();
this._thumbnailPromises.set(fileKey, extractPromise);
try {
const thumbs = await extractPromise;
this._thumbnailCache.set(fileKey, thumbs);
const matchingSegs = [
...this.timeline.segments.filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey),
...(this.timeline.motionSegments || []).filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey)
];
if (this.timeline.retakeVideo && (this.timeline.retakeVideo.imageFile === fileKey || this.timeline.retakeVideo._blobUrl === fileKey)) {
matchingSegs.push(this.timeline.retakeVideo);
}
for (const ms of matchingSegs) {
ms.thumbnails = thumbs;
ms._extractingThumbs = false;
// If fileKey is a blob URL, and the segment now has a server file path, cache under that path too
if (fileKey.startsWith("blob:")) {
const serverKey = ms.imageFile || ms.videoFile;
if (serverKey) {
this._thumbnailCache.set(serverKey, thumbs);
}
}
}
} catch (err) {
console.error("Extraction error:", err);
const matchingSegs = [
...this.timeline.segments.filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey),
...(this.timeline.motionSegments || []).filter(s => s.imageFile === fileKey || s.videoFile === fileKey || s._blobUrl === fileKey)
];
if (this.timeline.retakeVideo && (this.timeline.retakeVideo.imageFile === fileKey || this.timeline.retakeVideo._blobUrl === fileKey)) {
matchingSegs.push(this.timeline.retakeVideo);
}
for (const ms of matchingSegs) {
ms._extractingThumbs = false;
}
} finally {
this._thumbnailPromises.delete(fileKey);
this.render();
}
}
getSegmentArray(trackType) {
if (trackType === "motion") return this.timeline.motionSegments;
if (trackType === "audio") return this.timeline.audioSegments;
return this.timeline.segments;
}
getSnappedPlayhead(mouseFrameX, logicalWidth) {
if (!this.isSnapping) return mouseFrameX;
const totalFrames = this.getVisualDurationFrames();
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const snapCandidates = [0, this.getDurationFrames()];
// Add start and end frames of active generation range
snapCandidates.push(this.getStartFrames());
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
if (this.retakeMode) {
if (this.timeline.retakeVideo) {
const baseVideoDur = this.timeline.retakeVideo.videoDurationFrames || 0;
snapCandidates.push(baseVideoDur);
}
if (this.timeline.retakeStart !== undefined) {
snapCandidates.push(this.timeline.retakeStart);
if (this.timeline.retakeLength !== undefined) {
snapCandidates.push(this.timeline.retakeStart + this.timeline.retakeLength);
}
}
}
const allTracks = [
this.timeline.segments || [],
this.timeline.motionSegments || [],
this.timeline.audioSegments || []
];
for (const track of allTracks) {
for (const seg of track) {
snapCandidates.push(seg.start);
snapCandidates.push(seg.start + seg.length);
}
}
let bestFrame = mouseFrameX;
let minDiff = thresholdFrames;
for (const candidate of snapCandidates) {
const diff = Math.abs(mouseFrameX - candidate);
if (diff < minDiff) {
minDiff = diff;
bestFrame = candidate;
}
}
return bestFrame;
}
getTrackFromY(y) {
if (y > RULER_HEIGHT + this.blockHeight + this.audioTrackHeight) return "motion";
if (y > RULER_HEIGHT + this.blockHeight) return "audio";
return "image";
}
_ensureVideoEl(seg) {
if (!seg) return;
if (seg.videoEl) {
if (seg.videoEl.duration && !seg.videoDurationFrames) {
const frameRate = this.getFrameRate();
seg.videoDurationFrames = Math.max(1, Math.ceil(seg.videoEl.duration * frameRate));
}
if (this.retakeMode && seg === this.timeline.retakeVideo && seg.videoDurationFrames) {
this.syncWidgetsToRetakeDuration(seg.videoDurationFrames);
this.updateZoomSliderMax();
this.commitChanges(true);
}
return;
}
const cacheKey = seg.imageFile || seg.videoFile || seg._blobUrl;
if (!cacheKey) return;
this._videoElementsCache = this._videoElementsCache || new Map();
if (this._videoElementsCache.has(cacheKey)) {
// Reuse the existing shared video element — do NOT re-seek it.
// Running initVideoSeek on an already-initialized element causes cascading seeks
// when multiple split segments share it (e.g. seg2 seeks to 5min, seg3 seeks to 10min),
// which breaks playback on long videos. Just grab the reference and ensure thumbnails.
seg.videoEl = this._videoElementsCache.get(cacheKey);
if (seg.videoEl.duration && !seg.videoDurationFrames) {
const frameRate = this.getFrameRate();
seg.videoDurationFrames = Math.max(1, Math.ceil(seg.videoEl.duration * frameRate));
}
if (this.retakeMode && seg === this.timeline.retakeVideo && seg.videoDurationFrames) {
this.syncWidgetsToRetakeDuration(seg.videoDurationFrames);
this.updateZoomSliderMax();
this.commitChanges(true);
}
this._ensureThumbnails(seg);
return;
}
const isRetake = seg === this.timeline?.retakeVideo;
const isVideo = (seg.type === "video" || isRetake) && (seg.imageFile || seg._blobUrl);
const isMotionVideo = seg.type === "motion_video" && seg.videoFile;
if (!isVideo && !isMotionVideo) return;
const fileKey = (seg.type === "video" || isRetake) ? seg.imageFile : seg.videoFile;
let vidUrl = seg._blobUrl;
if (!vidUrl && fileKey) {
const fileParts = fileKey.split(/[/\\\\]/);
const justName = fileParts.pop() || '';
const subfolder = fileParts.join('/');
vidUrl = api.apiURL(`/view?filename=${encodeURIComponent(justName)}&type=input&subfolder=${encodeURIComponent(subfolder)}`);
}
if (!vidUrl) return;
const vid = document.createElement('video');
vid.crossOrigin = "Anonymous";
vid.muted = true;
vid.preload = 'auto';
seg.videoEl = vid;
this._videoElementsCache.set(cacheKey, vid);
vid.addEventListener('seeked', () => {
this.render();
});
const onSeekedHandler = () => {
vid.removeEventListener('seeked', onSeekedHandler);
if (!seg.imageB64 || !seg.imgObj) {
const canvas = document.createElement('canvas');
canvas.width = Math.min(vid.videoWidth, 512);
canvas.height = Math.round((vid.videoHeight / vid.videoWidth) * canvas.width);
canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
seg.imageB64 = canvas.toDataURL('image/jpeg');
const img = new Image();
img.onload = () => {
seg.imgObj = img;
this.render();
this.commitChanges(true);
};
img.src = seg.imageB64;
} else {
this.render();
}
};
let seekInitialized = false;
const initVideoSeek = () => {
if (seekInitialized) return;
seekInitialized = true;
if (vid.duration) {
const frameRate = this.getFrameRate();
const clipFrames = Math.max(1, Math.ceil(vid.duration * frameRate));
seg.videoDurationFrames = clipFrames;
if (this.retakeMode && seg === this.timeline.retakeVideo) {
this.syncWidgetsToRetakeDuration(clipFrames);
this.updateZoomSliderMax();
this.commitChanges(true);
}
}
vid.addEventListener('seeked', onSeekedHandler);
vid.currentTime = (seg.trimStart || 0) / this.getFrameRate() + 0.01;
this._ensureThumbnails(seg);
};
vid.addEventListener('loadedmetadata', initVideoSeek, { once: true });
vid.addEventListener('loadeddata', initVideoSeek, { once: true });
vid.src = vidUrl;
if (vid.readyState >= 1) {
initVideoSeek();
}
}
async _getOrExtractAudio(seg) {
if (!seg.audioFile) return;
const isVideoFile = seg.audioFile.toLowerCase().match(/\.(mp4|webm|mkv|avi|mov|m4v|flv|wmv)$/);
if (!isVideoFile) return;
this._audioExtractionPromises = this._audioExtractionPromises || new Map();
const fileKey = seg.audioFile;
if (this._audioExtractionPromises.has(fileKey)) {
try {
const res = await this._audioExtractionPromises.get(fileKey);
if (res && res.audio_file && res.peaks) {
seg.audioFile = res.audio_file;
seg.waveformPeaks = res.peaks;
}
} catch (err) {
console.warn("[LTXDirector] Awaiting shared server audio extract promise failed:", err);
}
return;
}
const extractionPromise = (async () => {
const resp = await api.fetchApi(`/ltx_director_get_audio?filename=${encodeURIComponent(fileKey)}`);
if (resp.status === 200) {
return await resp.json();
}
throw new Error(`Server returned status ${resp.status}`);
})();
this._audioExtractionPromises.set(fileKey, extractionPromise);
try {
const res = await extractionPromise;
if (res && res.audio_file && res.peaks) {
seg.audioFile = res.audio_file;
seg.waveformPeaks = res.peaks;
// Update all other segments matching this fileKey in the timeline
const allAudioSegs = this.timeline.audioSegments || [];
for (const s of allAudioSegs) {
if (s.audioFile === fileKey) {
s.audioFile = res.audio_file;
s.waveformPeaks = res.peaks;
}
}
}
} catch (err) {
console.warn("[LTXDirector] Server audio check/extract failed:", err);
} finally {
this._audioExtractionPromises.delete(fileKey);
}
}
_extractAudioOnClient(file, audSegId, blobUrl) {
(async () => {
try {
const arrayBuffer = await file.arrayBuffer();
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0);
const peaks = this._computeWaveformPeaks(channelData);
for (let s of this.timeline.audioSegments) {
if (s.id === audSegId || (blobUrl && s._blobUrl === blobUrl)) {
s.waveformPeaks = peaks;
s._decoding = false;
s._audioBuffer = audioBuffer;
}
}
this.render();
} catch (e) {
console.warn("No audio in video or decode failed", e);
for (let s of this.timeline.audioSegments) {
if (s.id === audSegId || (blobUrl && s._blobUrl === blobUrl)) {
s._decoding = false;
}
}
this.render();
}
})();
}
_isAudioDecodingAllowed(seg) {
if (seg.audioFile && seg.audioFile.toLowerCase().match(/\.(wav|mp3|ogg|flac|m4a)$/)) {
return true;
}
const isVideo = (seg.audioFile && seg.audioFile.toLowerCase().match(/\.(mp4|webm|mkv|avi|mov|m4v|flv|wmv)$/)) ||
(!seg.audioFile && seg._blobUrl);
if (isVideo) {
const isSmall = seg.fileSize && seg.fileSize <= 100 * 1024 * 1024;
return !!isSmall;
}
return true;
}
_ensureAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return this.audioContext;
}
_getAudioCacheKey(seg) {
if (seg.audioFile) return seg.audioFile;
if (seg._blobUrl) return seg._blobUrl;
if (seg.audioB64) return `b64:${seg.audioB64.length}:${seg.audioB64.slice(0, 64)}`;
return null;
}
_getAudioUrl(seg) {
if (seg._blobUrl) return seg._blobUrl;
const parts = (seg.audioFile || "").split(/[/\\\\]/);
const filename = parts.pop() || "";
const subfolder = parts.join("/");
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`);
}
async _decodeBase64Audio(audioB64) {
const binaryString = window.atob(audioB64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return await this._ensureAudioContext().decodeAudioData(bytes.buffer);
}
_computeWaveformPeaks(channelData, numPeaks = 200) {
if (!channelData || !channelData.length) return Array(numPeaks).fill(0);
const step = Math.max(1, Math.floor(channelData.length / numPeaks));
const peaks = [];
for (let i = 0; i < numPeaks; i++) {
let max = 0;
for (let j = 0; j < step; j++) {
const idx = i * step + j;
if (idx >= channelData.length) break;
const val = Math.abs(channelData[idx]);
if (val > max) max = val;
}
peaks.push(max);
}
return peaks;
}
async _loadAudioBuffer(seg) {
if (!seg.audioFile && !seg._blobUrl && !seg.audioB64) return null;
const audioContext = this._ensureAudioContext();
this._audioBufferCache = this._audioBufferCache || new Map();
this._audioBufferPromises = this._audioBufferPromises || new Map();
const cacheKey = this._getAudioCacheKey(seg);
if (cacheKey && this._audioBufferCache.has(cacheKey)) {
return this._audioBufferCache.get(cacheKey);
}
if (cacheKey && this._audioBufferPromises.has(cacheKey)) {
return await this._audioBufferPromises.get(cacheKey);
}
const decodePromise = (async () => {
if (seg.audioB64 && !seg.audioFile && !seg._blobUrl) {
return await this._decodeBase64Audio(seg.audioB64);
}
const resp = await fetch(this._getAudioUrl(seg));
const arrayBuffer = await resp.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer);
})();
if (cacheKey) {
this._audioBufferPromises.set(cacheKey, decodePromise);
}
try {
const audioBuffer = await decodePromise;
if (cacheKey) {
this._audioBufferCache.set(cacheKey, audioBuffer);
}
return audioBuffer;
} finally {
if (cacheKey) {
this._audioBufferPromises.delete(cacheKey);
}
}
}
async _preloadAudioSegment(seg) {
if (seg._audioBuffer || seg._decoding) return;
if (!seg.audioFile && !seg._blobUrl) return;
seg._decoding = true;
if (!this._isDragging) this.render();
try {
await this._getOrExtractAudio(seg);
if (!this._isAudioDecodingAllowed(seg)) {
seg._decoding = false;
return;
}
const audioBuffer = await this._loadAudioBuffer(seg);
const matchingSegs = this.timeline.audioSegments.filter(s => s.audioFile === seg.audioFile || s._blobUrl === seg._blobUrl);
for (const s of matchingSegs) {
s._audioBuffer = audioBuffer;
s._decoding = false;
}
} catch (err) {
console.warn("Failed to preload audio segment:", err);
seg._decoding = false;
} finally {
if (!this._isDragging) this.render();
}
}
async _preloadMotionAudioSegment(seg) {
if (seg._audioBuffer || seg._decodingAudio) return;
if (!seg.videoFile && !seg._blobUrl) return;
seg._decodingAudio = true;
try {
const mockSeg = {
audioFile: seg.videoFile || seg.fileName,
_blobUrl: seg._blobUrl,
fileSize: seg.fileSize
};
await this._getOrExtractAudio(mockSeg);
if (!this._isAudioDecodingAllowed(mockSeg)) {
seg._decodingAudio = false;
return;
}
const audioBuffer = await this._loadAudioBuffer(mockSeg);
seg._audioBuffer = audioBuffer;
} catch (e) {
console.warn("Failed to preload motion audio segment:", e);
} finally {
seg._decodingAudio = false;
}
}
loadMedia() {
for (const seg of this.timeline.segments) {
if (seg.imageB64 && !seg.imgObj) {
seg.imgObj = new Image();
seg.imgObj.onload = () => { if (!this._isDragging) this.render(); };
seg.imgObj.src = seg.imageB64;
}
if (seg.type === "video") {
this._ensureVideoEl(seg);
this._ensureThumbnails(seg);
}
}
if (this.timeline.motionSegments) {
const isOverrideAudio = !!(this.node.properties.overrideAudio || this.timeline.overrideAudio);
for (const seg of this.timeline.motionSegments) {
if (seg.imageB64 && !seg.imgObj) {
seg.imgObj = new Image();
seg.imgObj.onload = () => { if (!this._isDragging) this.render(); };
seg.imgObj.src = seg.imageB64;
}
if (seg.type === "motion_video") {
this._ensureVideoEl(seg);
this._ensureThumbnails(seg);
if (isOverrideAudio) {
this._preloadMotionAudioSegment(seg);
}
}
}
}
if (this.timeline.audioSegments) {
for (const seg of this.timeline.audioSegments) {
if (seg.type === "audio") {
this._preloadAudioSegment(seg);
}
}
}
if (this.timeline.retakeVideo) {
this._ensureVideoEl(this.timeline.retakeVideo);
this._ensureThumbnails(this.timeline.retakeVideo);
}
}
createDOM() {
this.wrapper = document.createElement("div");
this.wrapper.className = "prcs-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;
const isCtrl = e.ctrlKey || e.metaKey;
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();
} else if ((e.key === "b" || e.key === "B") && isCtrl && this._isHovering) {
if (this.selectedIndex !== -1) {
const arr = this.getSegmentArray(this.selectionType);
const seg = arr[this.selectedIndex];
if (seg) this.splitSegmentAtPlayhead(seg, this.selectionType);
}
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
} else if ((e.key === "c" || e.key === "C") && isCtrl && this._isHovering) {
if (this.selectedIndex !== -1) {
const arr = this.getSegmentArray(this.selectionType);
const seg = arr[this.selectedIndex];
if (seg) {
window._ltxCopiedSegmentCS = { main: { ...seg }, sibling: null };
window._ltxCopiedSegmentTypeCS = this.selectionType;
// Keep image/video elements
if (seg.imgObj) window._ltxCopiedSegmentCS.main.imgObj = seg.imgObj;
if (seg.videoEl) window._ltxCopiedSegmentCS.main.videoEl = seg.videoEl;
if (seg.id.endsWith("_v") || seg.id.endsWith("_a")) {
const isVid = seg.id.endsWith("_v");
const sibId = isVid ? seg.id.slice(0, -2) + "_a" : seg.id.slice(0, -2) + "_v";
const sibArr = isVid ? this.timeline.audioSegments : this.timeline.segments;
const sib = sibArr.find(s => s.id === sibId);
if (sib) {
window._ltxCopiedSegmentCS.sibling = { ...sib };
if (sib.imgObj) window._ltxCopiedSegmentCS.sibling.imgObj = sib.imgObj;
if (sib.videoEl) window._ltxCopiedSegmentCS.sibling.videoEl = sib.videoEl;
}
}
}
}
} else if ((e.key === "v" || e.key === "V") && isCtrl && this._isHovering) {
if (window._ltxCopiedSegmentCS) {
this.pasteCopiedSegment();
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
}
} else if ((e.key === "s" || e.key === "S") && !isCtrl && this._isHovering) {
this.isSnapping = !this.isSnapping;
this.node.properties.isSnapping = this.isSnapping;
if (typeof this.updateSnapStyle === "function") {
this.updateSnapStyle();
}
this.commitChanges();
this.render();
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
} else if ((e.key === "i" || e.key === "I") && !isCtrl && this._isHovering) {
if (this.startFramesWidget) {
this.startFramesWidget.value = this.currentFrame;
if (this.startFramesWidget.callback) {
this.startFramesWidget.callback(this.currentFrame);
}
this.commitChanges();
this.render();
}
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
} else if ((e.key === "o" || e.key === "O") && !isCtrl && this._isHovering) {
if (this.endFramesWidget) {
this.endFramesWidget.value = this.currentFrame;
if (this.endFramesWidget.callback) {
this.endFramesWidget.callback(this.currentFrame);
}
this.commitChanges();
this.render();
}
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
} else if ((e.key === "x" || e.key === "X") && !isCtrl && this._isHovering) {
this.markCurrentSelection();
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 = "prcs-toolbar";
const actionGroup = document.createElement("div");
actionGroup.className = "prcs-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));
this.motionFileInput = document.createElement("input");
this.motionFileInput.type = "file";
this.motionFileInput.accept = "video/*";
this.motionFileInput.multiple = true;
this.motionFileInput.style.display = "none";
this.motionFileInput.addEventListener("change", (e) => this.handleMotionUpload(e.target.files));
this.videoFileInput = document.createElement("input");
this.videoFileInput.type = "file";
this.videoFileInput.accept = "video/*";
this.videoFileInput.multiple = true;
this.videoFileInput.style.display = "none";
this.videoFileInput.addEventListener("change", (e) => this.handleVideoUpload(e.target.files));
const uploadBtn = document.createElement("button");
uploadBtn.className = "prcs-btn";
uploadBtn.innerHTML = `${ICONS.upload} Add Image`;
uploadBtn.addEventListener("click", () => this.fileInput.click());
this.uploadBtn = uploadBtn;
const uploadAudioBtn = document.createElement("button");
uploadAudioBtn.className = "prcs-btn";
uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`;
uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click());
this.uploadAudioBtn = uploadAudioBtn;
const uploadMotionBtn = document.createElement("button");
uploadMotionBtn.className = "prcs-btn";
uploadMotionBtn.innerHTML = `${ICONS.motion} Add IC Video`;
uploadMotionBtn.addEventListener("click", () => this.motionFileInput.click());
this.uploadMotionBtn = uploadMotionBtn;
const uploadVideoBtn = document.createElement("button");
uploadVideoBtn.className = "prcs-btn";
uploadVideoBtn.innerHTML = ` Add Video`;
uploadVideoBtn.addEventListener("click", () => this.videoFileInput.click());
this.uploadVideoBtn = uploadVideoBtn;
const addTextBtn = document.createElement("button");
addTextBtn.className = "prcs-btn";
addTextBtn.innerHTML = `${ICONS.text} Add Text`;
addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace());
this.addTextBtn = addTextBtn;
const deleteBtn = document.createElement("button");
deleteBtn.className = "prcs-btn prcs-btn-danger";
deleteBtn.innerHTML = `${ICONS.trash} Delete`;
deleteBtn.addEventListener("click", () => this.deleteSelectedSegment());
this.deleteBtn = deleteBtn;
// --- Ref Option dropdown (sits to the right of Delete) ---
const refOptionSelect = document.createElement("select");
refOptionSelect.className = "prcs-ref-option-select";
refOptionSelect.title = "Character reference mode";
const REF_OPTIONS = [
{ value: "Ghost Mask (End)", label: "Ghost Mask mode" },
{ value: "Licon MSR (Prefix)", label: "Licon MSR" },
{ value: "OFF", label: "OFF" },
];
REF_OPTIONS.forEach(opt => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
refOptionSelect.appendChild(o);
});
refOptionSelect.value = this.timeline.reference_mode || "OFF";
refOptionSelect.addEventListener("change", (e) => {
this.timeline.reference_mode = e.target.value;
this.commitChanges();
});
this.refOptionSelect = refOptionSelect;
actionGroup.appendChild(this.fileInput);
actionGroup.appendChild(this.audioFileInput);
actionGroup.appendChild(this.motionFileInput);
actionGroup.appendChild(this.videoFileInput);
actionGroup.appendChild(uploadBtn);
actionGroup.appendChild(addTextBtn);
actionGroup.appendChild(uploadAudioBtn);
actionGroup.appendChild(uploadVideoBtn);
actionGroup.appendChild(uploadMotionBtn);
actionGroup.appendChild(deleteBtn);
actionGroup.appendChild(refOptionSelect);
// Retake-mode-only delete button (shown next to Add Video when retakeMode is on)
const deleteRetakeBtn = document.createElement("button");
deleteRetakeBtn.className = "prcs-btn prcs-btn-danger";
deleteRetakeBtn.innerHTML = `${ICONS.trash} Delete`;
deleteRetakeBtn.title = "Remove retake video";
deleteRetakeBtn.style.display = "none"; // hidden until retakeMode + video loaded
deleteRetakeBtn.addEventListener("click", () => {
this._deleteRetakeVideo();
});
this.deleteRetakeBtn = deleteRetakeBtn;
actionGroup.appendChild(deleteRetakeBtn);
toolbar.appendChild(actionGroup);
const rightGroup = document.createElement("div");
rightGroup.className = "prcs-right-group";
this.segmentBoundsDisplay = document.createElement("div");
this.segmentBoundsDisplay.className = "prcs-segment-bounds";
this.segmentBoundsDisplay.textContent = "Start: - | End: - | Length: -";
this.timeCodeDisplay = document.createElement("div");
this.timeCodeDisplay.className = "prcs-timecode";
this.timeCodeDisplay.textContent = this.formatTime(0);
const settingsBtn = document.createElement("button");
settingsBtn.className = "prcs-btn";
settingsBtn.style.padding = "6px";
settingsBtn.style.display = "flex";
settingsBtn.style.alignItems = "center";
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 inpaintToggleBtn = document.createElement("button");
inpaintToggleBtn.className = "prcs-btn";
inpaintToggleBtn.style.padding = "4px 0px";
inpaintToggleBtn.style.fontSize = "9px";
inpaintToggleBtn.style.lineHeight = "1";
inpaintToggleBtn.style.marginRight = "0px";
inpaintToggleBtn.style.marginTop = "8px"; // Adjust this value to fine-tune spacing between the title and button
inpaintToggleBtn.style.width = "72px";
inpaintToggleBtn.style.whiteSpace = "nowrap";
inpaintToggleBtn.style.textAlign = "center";
inpaintToggleBtn.style.justifyContent = "center";
inpaintToggleBtn.style.alignItems = "center";
inpaintToggleBtn.style.gap = "0px";
inpaintToggleBtn.style.boxSizing = "border-box";
inpaintToggleBtn.style.borderRadius = "2px";
inpaintToggleBtn.textContent = "Inpaint: ON";
inpaintToggleBtn.title = "Toggle Audio Inpainting in Gaps";
this.updateInpaintToggleStyle = (isOn) => {
inpaintToggleBtn.textContent = isOn ? "Inpaint: ON" : "Inpaint: OFF";
if (isOn) {
inpaintToggleBtn.classList.add("toggle-on");
} else {
inpaintToggleBtn.classList.remove("toggle-on");
}
};
this.syncInpaintState = () => {
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget && !customAudioWidget.value) {
inpaintToggleBtn.disabled = true;
inpaintToggleBtn.style.opacity = "0.4";
inpaintToggleBtn.style.cursor = "default";
inpaintToggleBtn.title = "Audio Inpainting requires Custom Audio to be ON";
} else {
inpaintToggleBtn.disabled = false;
inpaintToggleBtn.style.opacity = "1.0";
inpaintToggleBtn.style.cursor = "pointer";
inpaintToggleBtn.title = "Toggle Audio Inpainting in Gaps";
}
};
inpaintToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (inpaintToggleBtn.disabled) return;
const widget = this.node.widgets?.find(w => w.name === "inpaint_audio");
if (widget) {
widget.value = !widget.value;
if (this.node.properties) {
this.node.properties.inpaint_audio = widget.value;
}
this.updateInpaintToggleStyle(widget.value);
this.commitChanges(true);
this.node.setDirtyCanvas(true, true);
}
});
// Initial state check (widgets might not be ready immediately)
setTimeout(() => {
const inpaintWidget = this.node.widgets?.find(w => w.name === "inpaint_audio");
if (inpaintWidget) {
this.updateInpaintToggleStyle(inpaintWidget.value);
}
}, 100);
const overrideAudioToggleBtn = document.createElement("button");
overrideAudioToggleBtn.className = "prcs-btn";
overrideAudioToggleBtn.style.padding = "4px 0px";
overrideAudioToggleBtn.style.fontSize = "9px";
overrideAudioToggleBtn.style.lineHeight = "1";
overrideAudioToggleBtn.style.marginRight = "0px";
overrideAudioToggleBtn.style.marginTop = "8px"; // Adjust this value to fine-tune spacing between the title and button
overrideAudioToggleBtn.style.width = "72px";
overrideAudioToggleBtn.style.whiteSpace = "nowrap";
overrideAudioToggleBtn.style.textAlign = "center";
overrideAudioToggleBtn.style.justifyContent = "center";
overrideAudioToggleBtn.style.alignItems = "center";
overrideAudioToggleBtn.style.gap = "0px";
overrideAudioToggleBtn.style.boxSizing = "border-box";
overrideAudioToggleBtn.style.borderRadius = "2px";
overrideAudioToggleBtn.textContent = "Audio: OFF";
overrideAudioToggleBtn.title = "Override Audio: Use audio from IC-LoRA Video";
this.updateOverrideAudioToggleStyle = (isOn) => {
overrideAudioToggleBtn.textContent = isOn ? "Audio: ON" : "Audio: OFF";
if (isOn) {
overrideAudioToggleBtn.classList.add("toggle-on");
} else {
overrideAudioToggleBtn.classList.remove("toggle-on");
}
};
overrideAudioToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (overrideAudioToggleBtn.disabled) return;
const widget = this.node.widgets?.find(w => w.name === "override_audio");
if (widget) {
widget.value = !widget.value;
this.node.properties.overrideAudio = widget.value;
this.updateOverrideAudioToggleStyle(widget.value);
if (widget.value) {
// When this is toggled on, the audio track will automatically be disabled/muted.
this._audioTrackWasEnabledBeforeOverride = this.audioTrackEnabled;
this.audioTrackEnabled = false;
updateTrackIcon(this.audioTrackLabel._eyeBtn, "audio", false);
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget) {
customAudioWidget.value = false;
if (this.updateToggleStyle) this.updateToggleStyle(false);
}
inpaintToggleBtn.disabled = true;
inpaintToggleBtn.style.opacity = "0.3";
if (this.timeline.motionSegments) {
for (const seg of this.timeline.motionSegments) {
if (seg.type === "motion_video") {
this._preloadMotionAudioSegment(seg);
}
}
}
} else {
// When toggled off, restore the audio track status if it was previously enabled
if (this._audioTrackWasEnabledBeforeOverride) {
this.audioTrackEnabled = true;
updateTrackIcon(this.audioTrackLabel._eyeBtn, "audio", true);
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget) {
customAudioWidget.value = true;
if (this.updateToggleStyle) this.updateToggleStyle(true);
}
inpaintToggleBtn.disabled = false;
inpaintToggleBtn.style.opacity = "1.0";
}
this._audioTrackWasEnabledBeforeOverride = false;
}
this.commitChanges(true);
this.render();
}
});
// Initial state check (widgets might not be ready immediately)
setTimeout(() => {
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget) {
this.updateOverrideAudioToggleStyle(overrideWidget.value);
}
}, 100);
const helpBtn = document.createElement("button");
helpBtn.className = "prcs-btn";
helpBtn.style.padding = "6px";
helpBtn.style.display = "flex";
helpBtn.style.alignItems = "center";
helpBtn.style.justifyContent = "center";
helpBtn.style.width = "28px";
helpBtn.style.height = "28px";
helpBtn.style.boxSizing = "border-box";
helpBtn.innerHTML = ICONS.help;
helpBtn.title = "Help / Documentation";
helpBtn.addEventListener("click", (e) => {
e.stopPropagation();
window.open("https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI", "_blank");
});
this.isSnapping = this.node.properties.isSnapping !== false;
const snapBtn = document.createElement("button");
snapBtn.className = "prcs-btn";
snapBtn.style.padding = "6px";
snapBtn.style.display = "flex";
snapBtn.style.alignItems = "center";
snapBtn.style.justifyContent = "center";
snapBtn.style.width = "28px";
snapBtn.style.height = "28px";
snapBtn.style.boxSizing = "border-box";
snapBtn.innerHTML = ICONS.magnet;
const updateSnapStyle = () => {
snapBtn.title = this.isSnapping ? "Disable Snapping (Magnet)" : "Enable Snapping (Magnet)";
if (this.isSnapping) {
snapBtn.classList.add("toggle-on");
} else {
snapBtn.classList.remove("toggle-on");
}
};
this.updateSnapStyle = updateSnapStyle;
updateSnapStyle();
snapBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.isSnapping = !this.isSnapping;
this.node.properties.isSnapping = this.isSnapping;
updateSnapStyle();
this.commitChanges();
this.render();
});
const startBtn = document.createElement("button");
startBtn.className = "prcs-btn";
startBtn.style.padding = "6px";
startBtn.style.display = "flex";
startBtn.style.alignItems = "center";
startBtn.style.justifyContent = "center";
startBtn.style.width = "28px";
startBtn.style.height = "28px";
startBtn.style.boxSizing = "border-box";
startBtn.innerHTML = ICONS.start;
startBtn.title = "Set Start Frame";
startBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.retakeMode) return;
if (this.startFramesWidget) {
this.startFramesWidget.value = this.currentFrame;
if (this.startFramesWidget.callback) {
this.startFramesWidget.callback(this.currentFrame);
}
this.commitChanges();
this.render();
}
});
const endBtn = document.createElement("button");
endBtn.className = "prcs-btn";
endBtn.style.padding = "6px";
endBtn.style.display = "flex";
endBtn.style.alignItems = "center";
endBtn.style.justifyContent = "center";
endBtn.style.width = "28px";
endBtn.style.height = "28px";
endBtn.style.boxSizing = "border-box";
endBtn.innerHTML = ICONS.end;
endBtn.title = "Set End Frame";
endBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.retakeMode) return;
if (this.endFramesWidget) {
this.endFramesWidget.value = this.currentFrame;
if (this.endFramesWidget.callback) {
this.endFramesWidget.callback(this.currentFrame);
}
this.commitChanges();
this.render();
}
});
const markBtn = document.createElement("button");
markBtn.className = "prcs-btn";
markBtn.style.padding = "6px";
markBtn.style.display = "flex";
markBtn.style.alignItems = "center";
markBtn.style.justifyContent = "center";
markBtn.style.width = "28px";
markBtn.style.height = "28px";
markBtn.style.boxSizing = "border-box";
markBtn.innerHTML = ICONS.mark;
markBtn.title = "Mark Selection (X)";
markBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.retakeMode) return;
this.markCurrentSelection();
});
const retakeToggleBtn = document.createElement("button");
retakeToggleBtn.className = "prcs-btn";
retakeToggleBtn.style.padding = "4px 8px";
retakeToggleBtn.style.display = "flex";
retakeToggleBtn.style.alignItems = "center";
retakeToggleBtn.style.justifyContent = "center";
retakeToggleBtn.style.gap = "6px";
retakeToggleBtn.style.height = "28px";
retakeToggleBtn.style.boxSizing = "border-box";
retakeToggleBtn.innerHTML = ` Retake Mode (BETA)`;
const updateRetakeStyle = () => {
retakeToggleBtn.title = this.retakeMode ? "Switch to Multi-Clip Timeline" : "Switch to Retake Tab";
if (this.retakeMode) {
retakeToggleBtn.classList.add("toggle-on");
} else {
retakeToggleBtn.classList.remove("toggle-on");
}
};
this.updateRetakeStyle = updateRetakeStyle;
updateRetakeStyle();
retakeToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Stop and mute any active playback first
this.pauseAudio();
// Save current input value to the mode we are EXITING
if (this.retakeMode) {
this.timeline.retake_global_prompt = this.globalPromptInput ? this.globalPromptInput.value : "";
} else {
this.timeline.global_prompt = this.globalPromptInput ? this.globalPromptInput.value : "";
// Backup normal mode values before entering Retake Mode
this.timeline.normalStartFrame = this.getStartFrames();
this.timeline.normalDurationFrames = this.getDurationFrames();
}
this.retakeMode = !this.retakeMode;
this.timeline.retakeMode = this.retakeMode;
if (this.node.properties) {
this.node.properties.retakeMode = this.retakeMode;
}
// Adjust widgets for the new mode
if (this.retakeMode) {
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoDurationFrames) {
this.syncWidgetsToRetakeDuration(this.timeline.retakeVideo.videoDurationFrames);
}
} else {
// Restore normal mode backup
this._suppressCommit = true;
if (this.timeline.normalStartFrame !== undefined && this.startFramesWidget) {
this.startFramesWidget.value = this.timeline.normalStartFrame;
if (this.startFramesWidget.callback) {
try { this.startFramesWidget.callback(this.timeline.normalStartFrame); } catch (_) {}
}
}
if (this.timeline.normalDurationFrames !== undefined && this.durationFramesWidget) {
this.durationFramesWidget.value = this.timeline.normalDurationFrames;
if (this.durationFramesWidget.callback) {
try { this.durationFramesWidget.callback(this.timeline.normalDurationFrames); } catch (_) {}
}
}
this._suppressCommit = false;
}
this.updateRetakeUIState();
this.commitChanges();
this.render();
});
const btnGroup = document.createElement("div");
btnGroup.style.display = "flex";
btnGroup.style.gap = "6px";
btnGroup.style.alignItems = "center";
btnGroup.appendChild(retakeToggleBtn);
btnGroup.appendChild(snapBtn);
btnGroup.appendChild(startBtn);
btnGroup.appendChild(endBtn);
btnGroup.appendChild(markBtn);
btnGroup.appendChild(helpBtn);
btnGroup.appendChild(settingsBtn);
rightGroup.appendChild(btnGroup);
toolbar.appendChild(rightGroup);
// --- Canvas & Viewport ---
this.viewport = document.createElement("div");
this.viewport.className = "prcs-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;
if (this.node) this.node.setDirtyCanvas?.(true, true);
else if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true);
}
}, { passive: false, capture: true });
this.canvas = document.createElement("canvas");
this.canvas.className = "prcs-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 ---
if (!this.node.properties) this.node.properties = {};
if (this.node.properties.showFilenames === undefined) {
this.node.properties.showFilenames = (this.timeline.showFilenames !== undefined) ? this.timeline.showFilenames : true;
}
if (this.node.properties.overrideAudio === undefined) {
this.node.properties.overrideAudio = (this.timeline.overrideAudio !== undefined) ? this.timeline.overrideAudio : false;
}
if (this.node.properties.propHeight === undefined && this.timeline.propHeight !== undefined) {
this.node.properties.propHeight = this.timeline.propHeight;
}
this.initialPropHeight = this.node.properties.propHeight || 90;
this.propHeight = this.initialPropHeight;
const propContainer = document.createElement("div");
propContainer.className = "prcs-prop-container";
propContainer.style.position = "relative";
propContainer.style.flex = "none";
propContainer.style.height = `${this.propHeight}px`;
propContainer.style.marginBottom = "5px"; // Add some spacing between the two prompt boxes
this.propContainer = propContainer;
if (this.node.properties.globalPropHeight === undefined && this.timeline.globalPropHeight !== undefined) {
this.node.properties.globalPropHeight = this.timeline.globalPropHeight;
}
if (!this.node.properties.globalPropHeight) this.node.properties.globalPropHeight = 60;
this.globalPropHeight = this.node.properties.globalPropHeight;
const globalPropContainer = document.createElement("div");
globalPropContainer.className = "prcs-prop-container";
globalPropContainer.style.position = "relative";
globalPropContainer.style.flex = "none";
globalPropContainer.style.height = `${this.globalPropHeight}px`;
this.globalPropContainer = globalPropContainer;
const globalPromptWrapper = document.createElement("div");
globalPromptWrapper.className = "prcs-prompt-wrapper";
globalPromptWrapper.style.width = "100%";
globalPromptWrapper.style.height = "100%";
this.globalPromptLabel = document.createElement("div");
this.globalPromptLabel.className = "prcs-prompt-label";
this.globalPromptLabel.textContent = "Global Prompt";
globalPromptWrapper.appendChild(this.globalPromptLabel);
this.globalPromptInput = document.createElement("textarea");
this.globalPromptInput.className = "prcs-prompt-area";
this.globalPromptInput.placeholder = "Enter global prompt here...";
this.globalPromptInput.spellcheck = true;
globalPromptWrapper.appendChild(this.globalPromptInput);
this.globalPromptInput.addEventListener("focus", () => {
globalPromptWrapper.classList.add("focus-active");
this.wrapper.classList.add("has-focus");
});
this.globalPromptInput.addEventListener("blur", () => {
globalPromptWrapper.classList.remove("focus-active");
this.wrapper.classList.remove("has-focus");
});
let saveTimeout = null;
const triggerAutoSave = () => {
try {
const canvasEl = app.canvasEl || app.canvas?.canvas;
if (canvasEl) {
canvasEl.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }));
}
if (app.canvas && app.canvas.checkState) app.canvas.checkState();
if (app.canvas && app.canvas.captureCanvasState) app.canvas.captureCanvasState();
} catch (_) { }
};
this.globalPromptInput.addEventListener("input", (e) => {
const val = e.target.value;
this.syncGlobalPrompt(val);
if (this.selectionType === "motion") {
this.promptInput.value = val;
}
this.commitChanges(true);
this.render();
// Debounce ComfyUI auto-save by 300ms to avoid lag while typing
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(triggerAutoSave, 300);
});
this.globalPromptInput.addEventListener("blur", () => {
if (saveTimeout) clearTimeout(saveTimeout);
triggerAutoSave();
});
const globalPropResizer = document.createElement("div");
globalPropResizer.style.position = "absolute";
globalPropResizer.style.bottom = "0px";
globalPropResizer.style.left = "0px";
globalPropResizer.style.width = "100%";
globalPropResizer.style.height = "12px"; // Hit area
globalPropResizer.style.cursor = "ns-resize";
globalPropResizer.style.display = "flex";
globalPropResizer.style.justifyContent = "center";
globalPropResizer.style.alignItems = "flex-end";
globalPropResizer.style.paddingBottom = "4px";
globalPropResizer.style.zIndex = "10";
globalPropResizer.innerHTML = `
`;
let isGlobalResizing = false;
let startGlobalY = 0;
let startGlobalH = 0;
globalPropResizer.addEventListener("mousedown", (ev) => {
isGlobalResizing = true;
startGlobalY = ev.clientY;
startGlobalH = this.globalPropHeight;
ev.stopPropagation();
ev.preventDefault();
});
document.addEventListener("mousemove", (ev) => {
if (isGlobalResizing) {
const newH = Math.max(60, startGlobalH + (ev.clientY - startGlobalY));
this.globalPropHeight = newH;
this.node.properties.globalPropHeight = newH;
globalPropContainer.style.height = `${newH}px`;
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);
}
}
}
});
document.addEventListener("mouseup", () => {
if (isGlobalResizing) {
isGlobalResizing = false;
}
});
globalPropContainer.appendChild(globalPromptWrapper);
globalPropContainer.appendChild(globalPropResizer);
const propResizer = document.createElement("div");
propResizer.style.position = "absolute";
propResizer.style.bottom = "0px";
propResizer.style.left = "0px";
propResizer.style.width = "100%";
propResizer.style.height = "12px"; // Hit area
propResizer.style.cursor = "ns-resize";
propResizer.style.display = "flex";
propResizer.style.justifyContent = "center";
propResizer.style.alignItems = "flex-end";
propResizer.style.paddingBottom = "4px";
propResizer.innerHTML = ``;
propResizer.addEventListener("mousedown", (e) => {
e.preventDefault();
const startY = e.clientY;
const startH = this.propHeight;
const doDrag = (ev) => {
if (ev.buttons === 0) {
stopDrag();
return;
}
const newH = Math.max(90, startH + (ev.clientY - startY));
this.propHeight = newH;
this.node.properties.propHeight = newH;
propContainer.style.height = `${newH}px`;
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);
}
}
};
const stopDrag = () => {
window.removeEventListener("mousemove", doDrag, true);
window.removeEventListener("mouseup", stopDrag, true);
document.body.style.cursor = "default";
};
document.body.style.cursor = "ns-resize";
window.addEventListener("mousemove", doDrag, true);
window.addEventListener("mouseup", stopDrag, true);
});
// --- Text Area (Image/Text) ---
this.promptWrapper = document.createElement("div");
this.promptWrapper.className = "prcs-prompt-wrapper";
this.promptWrapper.style.width = "100%";
this.promptWrapper.style.height = "100%";
this.promptWrapper.style.display = "none";
this.segmentPromptLabel = document.createElement("div");
this.segmentPromptLabel.className = "prcs-prompt-label";
this.segmentPromptLabel.textContent = "Segment Prompt";
this.promptWrapper.appendChild(this.segmentPromptLabel);
this.promptInput = document.createElement("textarea");
this.promptInput.className = "prcs-prompt-area";
this.promptInput.placeholder = "No segment selected!";
this.promptInput.style.opacity = "0.4";
this.promptWrapper.appendChild(this.promptInput);
this.promptInput.addEventListener("focus", () => {
this.promptWrapper.classList.add("focus-active");
this.wrapper.classList.add("has-focus");
});
this.promptInput.addEventListener("blur", () => {
this.promptWrapper.classList.remove("focus-active");
this.wrapper.classList.remove("has-focus");
});
this.promptInput.addEventListener("input", () => {
if (this.retakeMode) {
this.timeline.retakePrompt = this.promptInput.value;
this.commitChanges();
return;
}
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value;
this.commitChanges();
} else if (this.selectionType === "motion") {
const val = this.promptInput.value;
if (this.globalPromptInput) {
this.globalPromptInput.value = val;
}
this.syncGlobalPrompt(val);
this.commitChanges(true);
this.render();
// Debounce ComfyUI auto-save by 300ms to avoid lag while typing
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(triggerAutoSave, 300);
}
});
// --- Motion Info Area ---
this.motionInfoArea = document.createElement("div");
this.motionInfoArea.className = "prcs-motion-info";
// --- Audio Info Area ---
this.audioInfoArea = document.createElement("div");
this.audioInfoArea.className = "prcs-audio-info";
propContainer.appendChild(this.promptWrapper);
propContainer.appendChild(this.motionInfoArea);
propContainer.appendChild(this.audioInfoArea);
propContainer.appendChild(propResizer);
this.wrapper.addEventListener("dragover", (e) => {
e.preventDefault();
this.wrapper.classList.add("drag-active");
if (this.retakeMode) {
return; // Skip ghost segments rendering when in retakeMode
}
const { x, y } = this.getMousePos(e);
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
if (!logicalWidth || totalFrames <= 0) return;
const trackType = this.getTrackFromY(y);
const arrToModify = this.getSegmentArray(trackType);
if (!this._ghostSegmentId || this._ghostTrack !== trackType) {
this._ghostSegmentId = "GHOST_" + Date.now();
this._ghostTrack = trackType;
this._ghostInitialTimeline = arrToModify.map(s => ({ ...s }));
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
);
for (let ps of this._previewSegments) {
const orig = arrToModify.find(s => s.id === ps.id);
if (orig) {
ps.videoEl = orig.videoEl;
ps.imgObj = orig.imgObj;
if (orig.thumbnails) ps.thumbnails = orig.thumbnails;
}
}
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 = [];
const videoFiles = [];
for (let file of e.dataTransfer.files) {
if (file.type.startsWith("video/")) videoFiles.push(file);
else if (file.type.startsWith("audio/")) audioFiles.push(file);
else 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 (videoFiles.length > 0) {
if (targetTrack === "motion") {
this.handleMotionUpload(videoFiles, targetFrameStart);
} else {
this.handleVideoUpload(videoFiles, targetFrameStart);
}
} else 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 = "prcs-player-controls";
this.playBtn = document.createElement("button");
this.playBtn.className = "prcs-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 = "prcs-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 = "prcs-seek-bar";
this.seekBar.min = "0";
this.seekBar.value = "0";
this.seekBar.style.flex = "1"; // take up remaining space
this.seekBar.addEventListener("input", (e) => {
let val = parseInt(e.target.value, 10);
if (this.retakeMode && this.timeline.retakeVideo) {
const baseVideoDur = this.timeline.retakeVideo.videoDurationFrames || 0;
if (val > baseVideoDur) {
val = baseVideoDur;
this.seekBar.value = val;
}
}
this.currentFrame = val;
this.updateSeekBarBackground();
this.render();
if (this.isPlaying) {
this.playAudio();
}
});
// --- Zoom Controls ---
const zoomControls = document.createElement("div");
zoomControls.className = "prcs-zoom-controls";
const zoomOutBtn = document.createElement("button");
zoomOutBtn.className = "prcs-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 = "prcs-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);
if (this.node) this.node.setDirtyCanvas?.(true, true);
else if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true);
});
const zoomInBtn = document.createElement("button");
zoomInBtn.className = "prcs-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 = "prcs-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;
if (this.node) this.node.setDirtyCanvas?.(true, true);
else if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true);
});
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 = "prcs-strength-row";
this.strengthLabel = document.createElement("span");
this.strengthLabel.className = "prcs-strength-label";
this.strengthLabel.textContent = "Guide Strength:";
this.strengthValue = document.createElement("input");
this.strengthValue.type = "text";
this.strengthValue.className = "prcs-strength-input";
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
this.strengthValue.style.cursor = "ew-resize";
this.vidStrLabel = document.createElement("span");
this.vidStrLabel.className = "prcs-strength-label";
this.vidStrLabel.textContent = "Video Strength:";
this.vidStrLabel.style.display = "none";
this.vidStrValue = document.createElement("input");
this.vidStrValue.type = "text";
this.vidStrValue.className = "prcs-strength-input";
this.vidStrValue.value = "1.00";
this.vidStrValue.style.display = "none";
this.vidStrValue.style.width = "40px";
this.vidStrValue.style.cursor = "ew-resize";
this.vidAttnLabel = document.createElement("span");
this.vidAttnLabel.className = "prcs-strength-label";
this.vidAttnLabel.textContent = "Video Attn:";
this.vidAttnLabel.style.display = "none";
this.vidAttnLabel.style.marginLeft = "10px";
this.vidAttnValue = document.createElement("input");
this.vidAttnValue.type = "text";
this.vidAttnValue.className = "prcs-strength-input";
this.vidAttnValue.value = "0.65";
this.vidAttnValue.style.display = "none";
this.vidAttnValue.style.width = "40px";
this.vidAttnValue.style.cursor = "ew-resize";
this.vidStrValue.addEventListener("change", (e) => {
let val = parseFloat(e.target.value);
if (isNaN(val)) val = 1.0;
val = Math.max(0, Math.min(1, val));
this.vidStrValue.value = val.toFixed(2);
if (this.selectionType === "motion" && this.timeline.motionSegments[this.selectedIndex]) {
this.timeline.motionSegments[this.selectedIndex].videoStrength = val;
this.commitChanges();
}
});
this.vidAttnValue.addEventListener("change", (e) => {
let val = parseFloat(e.target.value);
if (isNaN(val)) val = 0.65;
val = Math.max(0, Math.min(1, val));
this.vidAttnValue.value = val.toFixed(2);
if (this.selectionType === "motion" && this.timeline.motionSegments[this.selectedIndex]) {
this.timeline.motionSegments[this.selectedIndex].videoAttentionStrength = val;
this.commitChanges();
}
});
// Dragging logic for video strength
this.vidStrValue.addEventListener("mousedown", (e) => {
if (this.vidStrValue.disabled) return;
const vStrStartX = e.clientX;
const vStrStartVal = parseFloat(this.vidStrValue.value) || 1.0;
let vStrHasMoved = false;
let vStrIsDragging = false;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - vStrStartX;
if (Math.abs(deltaX) > 3) {
vStrHasMoved = true;
vStrIsDragging = true;
}
if (vStrIsDragging) {
moveEvent.preventDefault();
const sensitivity = 0.002;
let newVal = vStrStartVal + deltaX * sensitivity;
if (newVal < 0) newVal = 0;
if (newVal > 1) newVal = 1;
this.vidStrValue.value = newVal.toFixed(2);
if (this.selectionType === "motion" && this.timeline.motionSegments[this.selectedIndex]) {
this.timeline.motionSegments[this.selectedIndex].videoStrength = newVal;
this.commitChanges();
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!vStrHasMoved) {
this.vidStrValue.focus();
this.vidStrValue.select();
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
// Dragging logic for video attention strength
this.vidAttnValue.addEventListener("mousedown", (e) => {
if (this.vidAttnValue.disabled) return;
const vAttnStartX = e.clientX;
const vAttnStartVal = parseFloat(this.vidAttnValue.value) || 0.65;
let vAttnHasMoved = false;
let vAttnIsDragging = false;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - vAttnStartX;
if (Math.abs(deltaX) > 3) {
vAttnHasMoved = true;
vAttnIsDragging = true;
}
if (vAttnIsDragging) {
moveEvent.preventDefault();
const sensitivity = 0.002;
let newVal = vAttnStartVal + deltaX * sensitivity;
if (newVal < 0) newVal = 0;
if (newVal > 1) newVal = 1;
this.vidAttnValue.value = newVal.toFixed(2);
if (this.selectionType === "motion" && this.timeline.motionSegments[this.selectedIndex]) {
this.timeline.motionSegments[this.selectedIndex].videoAttentionStrength = newVal;
this.commitChanges();
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!vAttnHasMoved) {
this.vidAttnValue.focus();
this.vidAttnValue.select();
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
// 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.retakeMode) {
this.timeline.retakeStrength = newVal;
this.commitChanges();
} else 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.retakeMode) {
this.timeline.retakeStrength = val;
this.commitChanges();
} else 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(this.strengthLabel);
this.strengthRow.appendChild(this.strengthValue);
this.strengthRow.appendChild(this.vidStrLabel);
this.strengthRow.appendChild(this.vidStrValue);
this.strengthRow.appendChild(this.vidAttnLabel);
this.strengthRow.appendChild(this.vidAttnValue);
// Layout container for sidebar + viewport
this.layoutContainer = document.createElement("div");
this.layoutContainer.className = "prcs-timeline-layout";
this.layoutContainer.style.display = "flex";
this.layoutContainer.style.flexDirection = "row";
this.layoutContainer.style.width = "100%";
this.layoutContainer.style.border = "1px solid #111";
this.layoutContainer.style.borderRadius = "6px";
this.layoutContainer.style.overflow = "hidden";
// Sidebar
this.sidebar = document.createElement("div");
this.sidebar.className = "prcs-timeline-sidebar";
this.sidebar.style.width = "120px";
this.sidebar.style.flexShrink = "0";
this.sidebar.style.display = "flex";
this.sidebar.style.flexDirection = "column";
this.sidebar.style.borderRight = "1px solid #111";
this.sidebar.style.boxSizing = "border-box";
this.sidebar.style.backgroundColor = "#1e1e1e";
this.sidebar.style.userSelect = "none";
// Spacer for Ruler
this.rulerSpacer = document.createElement("div");
this.rulerSpacer.style.height = `${RULER_HEIGHT}px`;
this.rulerSpacer.style.width = "100%";
this.rulerSpacer.style.borderBottom = "1px solid #111";
this.rulerSpacer.style.backgroundColor = "#1e1e1e";
this.rulerSpacer.style.boxSizing = "border-box";
this.rulerSpacer.style.flexShrink = "0";
this.sidebar.appendChild(this.rulerSpacer);
const getTrackIconHtml = (trackId, isEnabled) => {
if (trackId === "audio") {
if (isEnabled) {
return ``;
} else {
return ``;
}
} else {
return ``;
}
};
const updateTrackIcon = (btn, trackId, isEnabled) => {
btn.style.color = isEnabled ? "#aaa" : "#444";
btn.innerHTML = getTrackIconHtml(trackId, isEnabled);
};
this.updateTrackIcon = updateTrackIcon;
const createTrackLabel = (text, bgColor, trackId, isEnabled, toggleCallback) => {
const el = document.createElement("div");
el.style.display = "flex";
el.style.flexDirection = "column";
el.style.alignItems = "center";
el.style.justifyContent = "center";
el.style.borderBottom = "1px solid #111";
el.style.backgroundColor = bgColor;
el.style.boxSizing = "border-box";
el.style.gap = "4px";
el.style.overflow = "hidden";
el.style.position = "relative";
el.style.flexShrink = "0";
const headerRow = document.createElement("div");
headerRow.style.display = "flex";
headerRow.style.alignItems = "center";
headerRow.style.justifyContent = "center";
headerRow.style.gap = "6px";
const textSpan = document.createElement("span");
textSpan.style.color = "#ccc";
textSpan.style.fontSize = "12px";
textSpan.style.fontWeight = "bold";
textSpan.style.lineHeight = "1";
textSpan.style.display = "inline-flex";
textSpan.style.alignItems = "center";
textSpan.textContent = text;
const eyeBtn = document.createElement("div");
eyeBtn.style.cursor = "pointer";
eyeBtn.style.display = "inline-flex";
eyeBtn.style.alignItems = "center";
eyeBtn.style.justifyContent = "center";
eyeBtn.style.width = "14px";
eyeBtn.style.height = "14px";
eyeBtn.style.color = isEnabled ? "#aaa" : "#444";
eyeBtn.innerHTML = getTrackIconHtml(trackId, isEnabled);
eyeBtn.addEventListener("click", (e) => {
e.stopPropagation();
toggleCallback();
});
// Store reference so we can update it later
el._eyeBtn = eyeBtn;
headerRow.appendChild(textSpan);
headerRow.appendChild(eyeBtn);
el.appendChild(headerRow);
return el;
};
this.mainTrackLabel = createTrackLabel("MAIN", "#1e1e1e", "main", this.mainTrackEnabled, () => {
this.mainTrackEnabled = !this.mainTrackEnabled;
updateTrackIcon(this.mainTrackLabel._eyeBtn, "main", this.mainTrackEnabled);
this.commitChanges(true);
this.render();
});
this.audioTrackLabel = createTrackLabel("AUDIO", "#1e1e1e", "audio", this.audioTrackEnabled, () => {
this.audioTrackEnabled = !this.audioTrackEnabled;
updateTrackIcon(this.audioTrackLabel._eyeBtn, "audio", this.audioTrackEnabled);
if (this.audioTrackEnabled) {
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget && overrideWidget.value) {
overrideWidget.value = false;
this.node.properties.overrideAudio = false;
if (this.updateOverrideAudioToggleStyle) this.updateOverrideAudioToggleStyle(false);
}
this._audioTrackWasEnabledBeforeOverride = false;
}
// Auto-disable custom audio if track disabled
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget) {
if (!this.audioTrackEnabled) {
// Store previous state just in case, though the user requested it auto-enables
this._prevCustomAudioState = customAudioWidget.value;
customAudioWidget.value = false;
} else {
// Auto-turn it back on as requested
customAudioWidget.value = true;
}
if (this.updateToggleStyle) this.updateToggleStyle(customAudioWidget.value);
}
// Disable toggle buttons visually
inpaintToggleBtn.disabled = !this.audioTrackEnabled;
inpaintToggleBtn.style.opacity = this.audioTrackEnabled ? "1.0" : "0.3";
this.commitChanges(true);
this.render();
});
this.audioTrackLabel.appendChild(inpaintToggleBtn);
// Initialize audio toggle states immediately
inpaintToggleBtn.disabled = !this.audioTrackEnabled;
inpaintToggleBtn.style.opacity = this.audioTrackEnabled ? "1.0" : "0.3";
this.motionTrackLabel = createTrackLabel("IC-LoRA Video", "#1e1e1e", "motion", this.motionTrackEnabled, () => {
this.motionTrackEnabled = !this.motionTrackEnabled;
updateTrackIcon(this.motionTrackLabel._eyeBtn, "motion", this.motionTrackEnabled);
// Auto-disable custom motion if track disabled
const customMotionWidget = this.node.widgets?.find(w => w.name === "use_custom_motion");
if (customMotionWidget) {
if (!this.motionTrackEnabled) {
customMotionWidget.value = false;
} else {
customMotionWidget.value = true;
}
}
overrideAudioToggleBtn.disabled = !this.motionTrackEnabled;
overrideAudioToggleBtn.style.opacity = this.motionTrackEnabled ? "1.0" : "0.3";
if (!this.motionTrackEnabled) {
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget && overrideWidget.value) {
overrideWidget.value = false;
this.node.properties.overrideAudio = false;
if (this.updateOverrideAudioToggleStyle) this.updateOverrideAudioToggleStyle(false);
// Restore audio track if it was previously enabled
if (this._audioTrackWasEnabledBeforeOverride) {
this.audioTrackEnabled = true;
updateTrackIcon(this.audioTrackLabel._eyeBtn, "audio", true);
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget) {
customAudioWidget.value = true;
if (this.updateToggleStyle) this.updateToggleStyle(true);
}
inpaintToggleBtn.disabled = false;
inpaintToggleBtn.style.opacity = "1.0";
}
this._audioTrackWasEnabledBeforeOverride = false;
}
}
this.commitChanges(true);
this.render();
});
this.motionTrackLabel.appendChild(overrideAudioToggleBtn);
// Initialize motion override states immediately
overrideAudioToggleBtn.disabled = !this.motionTrackEnabled;
overrideAudioToggleBtn.style.opacity = this.motionTrackEnabled ? "1.0" : "0.3";
this.sidebar.appendChild(this.mainTrackLabel);
this.sidebar.appendChild(this.audioTrackLabel);
this.sidebar.appendChild(this.motionTrackLabel);
const setupSidebarLabelResizing = (labelEl, dragType) => {
labelEl.addEventListener("mousemove", (e) => {
if (this.retakeMode) {
labelEl.style.cursor = "default";
return;
}
if (this._isDragging) return;
const rect = labelEl.getBoundingClientRect();
const y = e.clientY - rect.top;
if (rect.height - y <= 8) {
labelEl.style.cursor = "ns-resize";
} else {
labelEl.style.cursor = "default";
}
});
labelEl.addEventListener("mousedown", (e) => {
if (this.retakeMode) return;
if (e.button !== 0) return;
if (e.target.closest("svg") || e.target.style.cursor === "pointer" || window.getComputedStyle(e.target).cursor === "pointer") {
return;
}
const rect = labelEl.getBoundingClientRect();
const y = e.clientY - rect.top;
if (rect.height - y <= 8) {
this._isDragging = true;
this._dragType = dragType;
this._startBlockHeight = this.blockHeight;
this._startAudioTrackHeight = this.audioTrackHeight;
this._startMotionTrackHeight = this.motionTrackHeight;
this._startY = this.getMousePos(e).y;
document.body.style.userSelect = "none";
document.body.style.cursor = "ns-resize";
e.preventDefault();
e.stopPropagation();
}
});
};
setupSidebarLabelResizing(this.mainTrackLabel, "divider");
setupSidebarLabelResizing(this.audioTrackLabel, "audio_divider");
setupSidebarLabelResizing(this.motionTrackLabel, "height_resize");
this.updateSidebarHeights();
this.layoutContainer.appendChild(this.sidebar);
// Viewport takes remaining space
this.viewport.style.flexGrow = "1";
this.viewport.style.minWidth = "0";
this.layoutContainer.appendChild(this.viewport);
this.wrapper.appendChild(toolbar);
this.wrapper.appendChild(this.layoutContainer);
const controlsGroup = document.createElement("div");
controlsGroup.className = "prcs-controls-group";
controlsGroup.appendChild(this.strengthRow);
controlsGroup.appendChild(playerControls);
this.wrapper.appendChild(controlsGroup);
this.wrapper.appendChild(propContainer);
this.wrapper.appendChild(this.globalPropContainer);
// --- Character reference slots (3 @char panels) at the bottom of the editor ---
this.createCharacterSlots(this.wrapper);
// --- @char autocomplete on both prompt fields ---
if (this.globalPromptInput) this.setupAutocomplete(this.globalPromptInput);
if (this.promptInput) this.setupAutocomplete(this.promptInput);
// Keep the Ref Option dropdown in sync with whatever was loaded.
if (this.refOptionSelect) {
this.refOptionSelect.value = this.timeline.reference_mode || "OFF";
}
this.container.appendChild(this.wrapper);
}
syncWidgetsAndUI() {
console.log("[LTXDirector debug] syncWidgetsAndUI() called.");
console.log(` - mainTrackEnabled: ${this.mainTrackEnabled}`);
console.log(` - audioTrackEnabled: ${this.audioTrackEnabled}`);
console.log(` - motionTrackEnabled: ${this.motionTrackEnabled}`);
// 1. Sync the widgets with the loaded track enablement states
const customAudioWidget = this.node.widgets?.find(w => w.name === "use_custom_audio");
if (customAudioWidget) {
customAudioWidget.value = this.audioTrackEnabled;
console.log(` - Set use_custom_audio widget value to ${this.audioTrackEnabled}`);
}
const customMotionWidget = this.node.widgets?.find(w => w.name === "use_custom_motion");
if (customMotionWidget) {
customMotionWidget.value = this.motionTrackEnabled;
console.log(` - Set use_custom_motion widget value to ${this.motionTrackEnabled}`);
}
// 2. Sync the track icon buttons
if (this.mainTrackLabel?._eyeBtn && this.updateTrackIcon) {
this.updateTrackIcon(this.mainTrackLabel._eyeBtn, "main", this.mainTrackEnabled);
console.log(" - Updated main track eye icon");
}
if (this.audioTrackLabel?._eyeBtn && this.updateTrackIcon) {
this.updateTrackIcon(this.audioTrackLabel._eyeBtn, "audio", this.audioTrackEnabled);
console.log(" - Updated audio track eye icon");
}
if (this.motionTrackLabel?._eyeBtn && this.updateTrackIcon) {
this.updateTrackIcon(this.motionTrackLabel._eyeBtn, "motion", this.motionTrackEnabled);
console.log(" - Updated motion track eye icon");
}
// 3. Sync the inpaint button disabled/opacity state
const inpaintToggleBtn = this.audioTrackLabel?.querySelector(".prcs-btn");
if (inpaintToggleBtn) {
inpaintToggleBtn.disabled = !this.audioTrackEnabled;
inpaintToggleBtn.style.opacity = this.audioTrackEnabled ? "1.0" : "0.3";
console.log(` - Updated inpaint toggle button disabled: ${inpaintToggleBtn.disabled}`);
}
if (this.updateInpaintToggleStyle) {
const inpaintWidget = this.node.widgets?.find(w => w.name === "inpaint_audio");
if (inpaintWidget) {
console.log(` - calling updateInpaintToggleStyle with ${inpaintWidget.value}`);
this.updateInpaintToggleStyle(inpaintWidget.value);
}
}
// 4. Sync the override audio button disabled/opacity state
const overrideAudioToggleBtn = this.motionTrackLabel?.querySelector(".prcs-btn");
if (overrideAudioToggleBtn) {
overrideAudioToggleBtn.disabled = !this.motionTrackEnabled;
overrideAudioToggleBtn.style.opacity = this.motionTrackEnabled ? "1.0" : "0.3";
console.log(` - Updated override audio toggle button disabled: ${overrideAudioToggleBtn.disabled}`);
}
if (this.updateOverrideAudioToggleStyle) {
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget) {
console.log(` - calling updateOverrideAudioToggleStyle with ${overrideWidget.value}`);
this.updateOverrideAudioToggleStyle(overrideWidget.value);
}
}
}
checkResize() {
this.syncLayoutToNode(false);
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);
if (this.node) this.node.setDirtyCanvas?.(true, true);
else if (window.app && window.app.graph) window.app.graph.setDirtyCanvas(true, true);
}
this._renderLoop = requestAnimationFrame(() => this.checkResize());
}
syncLayoutToNode(forceRender = true) {
const nodeWidth = this.node?.size?.[0] || 1375;
const targetWidth = Math.max(10, nodeWidth - 30);
if (this.container) {
this.container.style.width = `${targetWidth}px`;
this.container.style.maxWidth = `${targetWidth}px`;
this.container.style.setProperty("height", "auto", "important");
this.container.style.boxSizing = "border-box";
}
if (this.wrapper) {
this.wrapper.style.width = "100%";
this.wrapper.style.maxWidth = "100%";
this.wrapper.style.setProperty("height", "auto", "important");
this.wrapper.style.boxSizing = "border-box";
}
if (this.viewport) {
this.viewport.style.boxSizing = "content-box";
this.viewport.style.height = `${this.canvasHeight}px`;
this.viewport.style.minHeight = `${this.canvasHeight}px`;
this.viewport.style.flexShrink = "0";
}
if (this.layoutContainer) {
this.layoutContainer.style.flexShrink = "0";
}
const viewportWidth = this.viewport?.clientWidth || targetWidth;
const canvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel);
const currentWidth = parseFloat(this.canvas?.style?.width) || 0;
if (viewportWidth > 0 && Math.abs(currentWidth - canvasWidth) > 1) {
this.canvas.style.width = `${canvasWidth}px`;
this.resizeCanvas(canvasWidth);
this._lastWidth = viewportWidth;
this._lastZoom = this.zoomLevel;
if (forceRender) this.render();
}
}
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);
body.append("subfolder", "whatdreamscost");
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 physicsCenter = newStart + this.getFrameRate() / 2;
let result = this._applyCenterDragPhysics(this.timeline.segments, tempId, newStart, physicsCenter, currentDuration, currentDuration, 1);
let siblingPhysics = (this.timeline.audioSegments || []).map(s => ({ ...s }));
this._resolveGlobalPhysics(result, siblingPhysics, currentDuration, this.timeline.segments, this.timeline.audioSegments);
// 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;
}
}
for (let shiftedSib of siblingPhysics) {
let originalSib = this.timeline.audioSegments.find(s => s.id === shiftedSib.id);
if (originalSib) {
originalSib.start = shiftedSib.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);
if (!this.retakeMode) {
this.growTimelineIfNeeded(seg.start + seg.length);
}
this.updateUIFromSelection();
this.commitChanges(true);
};
img.src = imgUrl;
} catch (err) {
console.error("[PromptRelay] Image upload failed", err);
resolve();
}
});
}
this.fileInput.value = "";
}
// Shared chunked upload helper for all video types in the LTX Director.
// Files <= 50 MB go through ComfyUI's standard /upload/image endpoint;
// larger files are split into 50 MB chunks and sent to the LTX Director's
// own /ltx_director_upload_chunk endpoint to bypass the 413 size limit.
async _uploadVideoFile(file) {
const CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
const safeFileName = file.name.replace(/[^a-zA-Z0-9.\-_]/g, '_');
// First check if the file already exists on the server to de-duplicate
try {
const checkResp = await api.fetchApi(`/ltx_director_check_file?filename=${encodeURIComponent(safeFileName)}&size=${file.size}`);
if (checkResp.status === 200) {
const checkResult = await checkResp.json();
if (checkResult.exists) {
console.log(`[LTXDirector] File already exists: ${checkResult.name}. Reusing existing file.`);
return checkResult.name;
}
}
} catch (e) {
console.warn("[LTXDirector] Failed to check for existing file, proceeding with upload", e);
}
if (file.size > CHUNK_SIZE) {
// --- Chunked path ---
const safeName = Date.now() + "_" + safeFileName;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const formData = new FormData();
formData.append("file", chunk);
formData.append("filename", safeName);
formData.append("chunk_index", i);
formData.append("total_chunks", totalChunks);
const resp = await api.fetchApi("/ltx_director_upload_chunk", { method: "POST", body: formData });
if (resp.status !== 200) throw new Error("LTX Director video chunk upload failed");
}
return safeName; // filename (no subfolder) in the input dir
} else {
// --- Single-shot path (small file) ---
const body = new FormData();
body.append("image", file);
body.append("subfolder", "whatdreamscost");
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status !== 200) throw new Error(`LTX Director video upload failed: ${resp.statusText}`);
const data = await resp.json();
const subfolder = data.subfolder || "";
return subfolder ? subfolder + "/" + data.name : data.name;
}
}
async handleVideoUpload(files, targetFrameStart = null) {
const frameRate = this.getFrameRate();
if (this.retakeMode) {
const file = files[0];
if (!file || !file.type.startsWith("video/")) return;
// Clean up previous retake video if one exists
if (this.timeline.retakeVideo) {
const oldVid = this.timeline.retakeVideo;
if (oldVid.videoEl) {
oldVid.videoEl.pause();
oldVid.videoEl.src = "";
oldVid.videoEl.load();
}
if (oldVid._blobUrl) {
URL.revokeObjectURL(oldVid._blobUrl);
}
}
const blobUrl = URL.createObjectURL(file);
const vid = document.createElement('video');
vid.crossOrigin = "Anonymous";
vid.preload = 'auto';
vid.muted = true;
await new Promise((resolve) => {
vid.onloadeddata = async () => {
vid.onloadeddata = null;
const clipDurationSecs = vid.duration || 1;
const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate));
this.timeline.retakeVideo = {
fileName: file.name,
imageFile: "",
videoDurationFrames: clipFrames,
_blobUrl: blobUrl,
fileSize: file.size,
videoEl: vid,
_uploading: true
};
// Initialize retake region to the middle 50% of the clip (25%–75%)
const retakeLen = Math.max(1, Math.round(clipFrames * 0.5));
const retakeStartFrame = Math.round((clipFrames - retakeLen) / 2);
this.timeline.retakeStart = retakeStartFrame;
this.timeline.retakeLength = retakeLen;
if (this.timeline.retakePrompt === undefined) this.timeline.retakePrompt = "";
if (this.timeline.retakeStrength === undefined) this.timeline.retakeStrength = 1.0;
// Start background upload
this._uploadVideoFile(file).then(filePath => {
if (this.timeline.retakeVideo) {
this.timeline.retakeVideo.imageFile = filePath;
this.timeline.retakeVideo._uploading = false;
}
this.commitChanges(true);
this.render();
}).catch(e => {
console.error(e);
if (this.timeline.retakeVideo) {
this.timeline.retakeVideo._uploading = false;
}
this.commitChanges(true);
this.render();
});
this._ensureThumbnails(this.timeline.retakeVideo);
this.syncWidgetsToRetakeDuration(clipFrames);
this.commitChanges(true);
this.render();
resolve();
};
vid.src = blobUrl;
});
return;
}
for (let file of files) {
if (!file.type.startsWith("video/")) continue;
await new Promise(async (resolve) => {
try {
// Use a local blob URL so the video element loads instantly from disk —
// no waiting for the server upload before the segment appears.
const blobUrl = URL.createObjectURL(file);
const vid = document.createElement('video');
vid.crossOrigin = "Anonymous";
vid.preload = 'auto';
vid.muted = true;
vid.onloadeddata = async () => {
vid.onloadeddata = null; // prevent re-firing if src changes or browser buffers more data
const clipDurationSecs = vid.duration || 1;
const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate));
let newLength = clipFrames;
let newStart = targetFrameStart;
if (newStart === null) {
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);
}
}
const currentDuration = this.getVisualDurationFrames();
if (targetFrameStart !== null) {
let tempId = "TEMP_" + Date.now();
let tempVidId = tempId + "_v";
let tempAudId = tempId + "_a";
this.timeline.segments.push({ id: tempVidId, start: newStart, length: newLength, type: "temp" });
this.timeline.audioSegments.push({ id: tempAudId, start: newStart, length: newLength, type: "temp" });
let physicsCenter = newStart + this.getFrameRate() / 2;
let resultSegments = this._applyCenterDragPhysics(this.timeline.segments, tempVidId, newStart, physicsCenter, currentDuration, currentDuration, 1);
let resultAudioSegments = this._applyCenterDragPhysics(this.timeline.audioSegments, tempAudId, newStart, physicsCenter, currentDuration, currentDuration, 1);
this._resolveGlobalPhysics(resultSegments, resultAudioSegments, currentDuration, this.timeline.segments, this.timeline.audioSegments);
for (let shiftedSeg of resultSegments) {
let original = this.timeline.segments.find(s => s.id === shiftedSeg.id);
if (original) original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start;
}
for (let shiftedSib of resultAudioSegments) {
let originalSib = this.timeline.audioSegments.find(s => s.id === shiftedSib.id);
if (originalSib) originalSib.start = shiftedSib.resolvedStart !== undefined ? shiftedSib.resolvedStart : shiftedSib.start;
}
let tempVidSeg = resultSegments.find(s => s.id === tempVidId);
newStart = tempVidSeg.start;
this.timeline.segments = this.timeline.segments.filter(s => s.id !== tempVidId);
this.timeline.audioSegments = this.timeline.audioSegments.filter(s => s.id !== tempAudId);
targetFrameStart = newStart + newLength;
}
const sharedId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const vidSeg = {
id: sharedId + "_v",
type: "video",
start: newStart,
length: newLength,
trimStart: 0,
videoDurationFrames: clipFrames,
imageFile: "", // filled in once background upload completes
fileName: file.name,
prompt: "",
videoEl: vid,
_uploading: true,
_blobUrl: blobUrl,
fileSize: file.size
};
const audSeg = {
id: sharedId + "_a",
type: "audio",
start: newStart,
length: newLength,
trimStart: 0,
audioDurationFrames: clipFrames,
audioFile: "", // filled in once background upload completes
fileName: file.name,
waveformPeaks: [],
_uploading: true,
_decoding: true,
_blobUrl: blobUrl,
fileSize: file.size
};
// Extract first-frame thumbnail from local blob — instant
vid.currentTime = 0.01;
vid.onseeked = () => {
vid.onseeked = null;
const canvas = document.createElement('canvas');
canvas.width = Math.min(vid.videoWidth, 512);
canvas.height = Math.round((vid.videoHeight / vid.videoWidth) * canvas.width);
const ctx = canvas.getContext('2d');
ctx.drawImage(vid, 0, 0, canvas.width, canvas.height);
vidSeg.imageB64 = canvas.toDataURL('image/jpeg');
const imgObj = new Image();
imgObj.onload = () => { vidSeg.imgObj = imgObj; this.render(); };
imgObj.src = vidSeg.imageB64;
// Add to timeline immediately
this.timeline.segments.push(vidSeg);
this.timeline.audioSegments.push(audSeg);
this.timeline.segments.sort((a, b) => a.start - b.start);
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
if (!this.retakeMode) {
this.growTimelineIfNeeded(vidSeg.start + vidSeg.length);
}
this.selectionType = "image";
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === vidSeg.id);
this.updateUIFromSelection();
this.commitChanges(true);
resolve(); // resolve immediately — don't block on upload
this._ensureThumbnails(vidSeg);
// Background audio extraction (waveform peaks) — runs while user can already work
const IS_LARGE_FILE = file.size > 100 * 1024 * 1024;
if (IS_LARGE_FILE) {
console.log(`[LTXDirector] Large file detected (${(file.size / 1024 / 1024).toFixed(1)} MB). Offloading audio extraction to server.`);
} else {
this._extractAudioOnClient(file, audSeg.id, blobUrl);
}
// Background upload — runs while the user can already work.
// We intentionally do NOT change vid.src after upload — the blob URL
// works perfectly for local playback. Only imageFile/audioFile
// need updating so Python can find the file at generation time.
this._uploadVideoFile(file).then(filePath => {
for (let s of this.timeline.segments) {
if (s._blobUrl === blobUrl || s.id === vidSeg.id) {
s.imageFile = filePath;
s._uploading = false;
}
}
for (let s of this.timeline.audioSegments) {
if (s._blobUrl === blobUrl || s.id === audSeg.id) {
s.audioFile = filePath;
s._uploading = false;
}
}
if (blobUrl && filePath) {
this._thumbnailCache = this._thumbnailCache || new Map();
this._thumbnailPromises = this._thumbnailPromises || new Map();
if (this._thumbnailCache.has(blobUrl)) {
this._thumbnailCache.set(filePath, this._thumbnailCache.get(blobUrl));
}
if (this._thumbnailPromises.has(blobUrl)) {
this._thumbnailPromises.set(filePath, this._thumbnailPromises.get(blobUrl));
}
}
// Query server for extracted WAV audio file and waveform peaks
if (filePath) {
api.fetchApi(`/ltx_director_get_audio?filename=${encodeURIComponent(filePath)}`)
.then(r => r.json())
.then(res => {
if (res.audio_file && res.peaks) {
for (let s of this.timeline.audioSegments) {
if (s.audioFile === filePath || s._blobUrl === blobUrl) {
s.audioFile = res.audio_file;
s.waveformPeaks = res.peaks;
s._decoding = false;
this._preloadAudioSegment(s);
}
}
} else {
// Fallback
if (IS_LARGE_FILE) {
console.warn("[LTXDirector] Server audio extraction failed for large file, skipping.");
for (let s of this.timeline.audioSegments) {
if (s.audioFile === filePath || s._blobUrl === blobUrl) {
s._decoding = false;
}
}
} else {
this._extractAudioOnClient(file, audSeg.id, blobUrl);
}
}
this.commitChanges(true);
this.render();
})
.catch(err => {
console.error("[LTXDirector] Server audio extraction query failed:", err);
for (let s of this.timeline.audioSegments) {
if (s.audioFile === filePath || s._blobUrl === blobUrl) {
s._decoding = false;
}
}
this.render();
});
} else {
this.commitChanges(true);
this.render();
}
}).catch(err => {
console.error("[LTXDirector] Background video upload failed", err);
const currentVidSeg = this.timeline.segments.find(s => s.id === vidSeg.id);
if (currentVidSeg) currentVidSeg._uploading = false;
const currentAudSeg = this.timeline.audioSegments.find(s => s.id === audSeg.id);
if (currentAudSeg) currentAudSeg._uploading = false;
this.render();
});
};
};
vid.onerror = (e) => {
console.error("Video load error", e);
URL.revokeObjectURL(blobUrl);
resolve();
};
vid.src = blobUrl;
} catch (err) {
console.error("Video upload failed", err);
resolve();
}
});
}
if (this.videoFileInput) {
this.videoFileInput.value = "";
}
}
async generateVideoPreviewThumbs(file, count = 18) {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
video.src = url;
await new Promise((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error("preview metadata failed"));
});
const duration = Math.max(0.001, video.duration || 0.001);
const canvas = document.createElement("canvas");
const maxW = 160, maxH = 90;
const scale = Math.min(maxW / Math.max(1, video.videoWidth || maxW), maxH / Math.max(1, video.videoHeight || maxH));
canvas.width = Math.max(1, Math.round((video.videoWidth || maxW) * scale));
canvas.height = Math.max(1, Math.round((video.videoHeight || maxH) * scale));
const ctx = canvas.getContext("2d");
const thumbs = [];
const seekTo = (t) => new Promise((resolve) => {
let finished = false;
const done = () => {
if (finished) return;
finished = true;
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
thumbs.push(canvas.toDataURL("image/jpeg", 0.78));
} catch (_) { }
resolve();
};
video.onseeked = done;
video.currentTime = Math.min(duration - 0.001, Math.max(0, t));
setTimeout(done, 700);
});
for (let i = 0; i < count; i++) {
const t = (duration * (i + 0.5)) / count;
await seekTo(t);
}
URL.revokeObjectURL(url);
return thumbs.filter(Boolean);
}
// --- Async Motion Video Upload Logic ---
async handleMotionUpload(files, targetFrameStart = null) {
const frameRate = this.getFrameRate();
for (let file of files) {
if (!(file.type.startsWith("video/") || file.name.toLowerCase().match(/\.(mp4|webm|mkv|avi|mov|m4v|flv|wmv)$/))) continue;
await new Promise(async (resolve) => {
try {
// Load from local blob immediately — no waiting for server upload
const blobUrl = URL.createObjectURL(file);
const vid = document.createElement('video');
vid.crossOrigin = "Anonymous";
vid.preload = 'auto';
vid.muted = true;
vid.onerror = (e) => { console.error("Motion video load error", e); URL.revokeObjectURL(blobUrl); resolve(); };
vid.onloadeddata = () => {
vid.onloadeddata = null; // prevent re-firing if src changes or browser buffers more data
const clipDurationSecs = vid.duration || 1;
const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate));
let newLength = clipFrames;
let newStart = targetFrameStart;
if (newStart === null) {
newStart = 0;
this.timeline.motionSegments.sort((a, b) => a.start - b.start);
for (let i = 0; i < this.timeline.motionSegments.length; i++) {
let s = this.timeline.motionSegments[i];
if (newStart + newLength <= s.start) break;
newStart = Math.max(newStart, s.start + s.length);
}
}
const currentDuration = this.getVisualDurationFrames();
if (targetFrameStart !== null) {
let tempId = "TEMP_" + Date.now();
this.timeline.motionSegments.push({ id: tempId, start: newStart, length: newLength, type: "temp" });
let result = this._applyCenterDragPhysics(this.timeline.motionSegments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1);
for (let shiftedSeg of result) {
let original = this.timeline.motionSegments.find(s => s.id === shiftedSeg.id);
if (original) original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start;
}
let tempSeg = this.timeline.motionSegments.find(s => s.id === tempId);
newStart = tempSeg.start;
this.timeline.motionSegments = this.timeline.motionSegments.filter(s => s.id !== tempId);
targetFrameStart = newStart + newLength;
}
const seg = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
type: "motion_video",
start: newStart,
length: newLength,
trimStart: 0,
videoDurationFrames: clipFrames,
videoFile: "", // filled in once background upload completes
fileName: file.name,
videoStrength: 1.0,
videoAttentionStrength: 0.65,
resampleMode: "nearest",
previewThumbs: [],
previewThumbSourceFrames: clipFrames,
videoEl: vid,
_uploading: true,
_blobUrl: blobUrl,
fileSize: file.size
};
vid.currentTime = 0.01;
vid.onseeked = () => {
vid.onseeked = null;
const canvas = document.createElement('canvas');
canvas.width = Math.min(vid.videoWidth, 512);
canvas.height = Math.round((vid.videoHeight / vid.videoWidth) * canvas.width);
const ctx = canvas.getContext('2d');
ctx.drawImage(vid, 0, 0, canvas.width, canvas.height);
seg.imageB64 = canvas.toDataURL('image/jpeg');
const imgObj = new Image();
imgObj.onload = () => { seg.imgObj = imgObj; this.render(); };
imgObj.src = seg.imageB64;
// Add to timeline immediately
this.timeline.motionSegments.push(seg);
this.timeline.motionSegments.sort((a, b) => a.start - b.start);
if (!this.retakeMode) {
this.growTimelineIfNeeded(seg.start + seg.length);
}
this.selectionType = "motion";
this.selectedIndex = this.timeline.motionSegments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges(true);
resolve(); // resolve immediately — don't block on upload
this._ensureThumbnails(seg);
// Background upload — runs while the user can already work.
// We intentionally do NOT change vid.src after upload — the blob URL
// works perfectly for local playback. Only videoFile needs updating
// so Python can find the file at generation time.
this._uploadVideoFile(file).then(filePath => {
for (let s of this.timeline.motionSegments) {
if (s._blobUrl === blobUrl || s.id === seg.id) {
s.videoFile = filePath;
s._uploading = false;
}
}
if (blobUrl && filePath) {
this._thumbnailCache = this._thumbnailCache || new Map();
this._thumbnailPromises = this._thumbnailPromises || new Map();
if (this._thumbnailCache.has(blobUrl)) {
this._thumbnailCache.set(filePath, this._thumbnailCache.get(blobUrl));
}
if (this._thumbnailPromises.has(blobUrl)) {
this._thumbnailPromises.set(filePath, this._thumbnailPromises.get(blobUrl));
}
}
const isOverrideAudio = !!(this.node.properties.overrideAudio || this.timeline.overrideAudio);
if (isOverrideAudio) {
const s = this.timeline.motionSegments.find(s => s.id === seg.id);
if (s) {
this._preloadMotionAudioSegment(s);
}
}
this.commitChanges(true);
this.render();
}).catch(err => {
console.error("[LTXDirector] Background motion video upload failed", err);
const currentSeg = this.timeline.motionSegments.find(s => s.id === seg.id);
if (currentSeg) currentSeg._uploading = false;
this.render();
});
};
};
vid.src = blobUrl;
} catch (err) {
console.error("[LTXDirector] Motion video processing failed", err);
resolve();
}
});
}
}
// --- 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);
body.append("subfolder", "whatdreamscost");
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 = this._computeWaveformPeaks(channelData);
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 physicsCenter = newStart + this.getFrameRate() / 2;
let result = this._applyCenterDragPhysics(this.timeline.audioSegments, tempId, newStart, physicsCenter, currentDuration, currentDuration, 1);
let siblingPhysics = (this.timeline.segments || []).map(s => ({ ...s }));
this._resolveGlobalPhysics(siblingPhysics, result, currentDuration, this.timeline.segments, this.timeline.audioSegments);
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;
}
for (let shiftedSib of siblingPhysics) {
let originalSib = this.timeline.segments.find(s => s.id === shiftedSib.id);
if (originalSib) {
originalSib.start = shiftedSib.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,
_audioBuffer: audioBuffer
};
this.timeline.audioSegments.push(seg);
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
if (!this.retakeMode) {
this.growTimelineIfNeeded(seg.start + seg.length);
}
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 = "";
}
markSegment(seg) {
if (!seg) return;
const newStart = Math.round(seg.start);
const newEnd = Math.max(newStart + 1, Math.round(seg.start + seg.length));
const currentStart = this.getStartFrames();
const currentEnd = this.endFramesWidget ? parseInt(this.endFramesWidget.value, 10) : (currentStart + this.getDurationFrames());
let targetStart = newStart;
let targetEnd = newEnd;
if (currentStart === newStart && currentEnd === newEnd) {
const allSegs = [
...(this.timeline.segments || []),
...(this.timeline.motionSegments || []),
...(this.timeline.audioSegments || [])
];
let lastSegmentEnd = 0;
for (const s of allSegs) {
if (s.start + s.length > lastSegmentEnd) {
lastSegmentEnd = s.start + s.length;
}
}
if (lastSegmentEnd <= 0) {
lastSegmentEnd = this.getDurationFrames();
}
targetStart = 0;
targetEnd = Math.max(1, Math.round(lastSegmentEnd));
}
if (this.startFramesWidget && this.endFramesWidget) {
this.startFramesWidget.value = targetStart;
this.endFramesWidget.value = targetEnd;
if (this.startFramesWidget.callback) {
this.startFramesWidget.callback(targetStart);
}
if (this.endFramesWidget.callback) {
this.endFramesWidget.callback(targetEnd);
}
this.commitChanges();
this.render();
}
}
markCurrentSelection() {
if (this.retakeMode) {
if (this.timeline.retakeVideo) {
const baseVideoDur = this.timeline.retakeVideo.videoDurationFrames || 24;
const targetStart = 0;
const targetEnd = baseVideoDur;
if (this.startFramesWidget && this.endFramesWidget) {
this.startFramesWidget.value = targetStart;
this.endFramesWidget.value = targetEnd;
if (this.startFramesWidget.callback) {
this.startFramesWidget.callback(targetStart);
}
if (this.endFramesWidget.callback) {
this.endFramesWidget.callback(targetEnd);
}
this.commitChanges();
this.render();
}
}
return;
}
const allSegs = [
...(this.timeline.segments || []),
...(this.timeline.motionSegments || []),
...(this.timeline.audioSegments || [])
];
let targetSegs = [];
if (this.selectedSegmentIds && this.selectedSegmentIds.length > 0) {
targetSegs = allSegs.filter(s => this.selectedSegmentIds.includes(s.id));
}
if (targetSegs.length === 0 && this.selectedIndex >= 0 && this.selectionType) {
const arr = this.getSegmentArray(this.selectionType);
if (arr && arr[this.selectedIndex]) {
targetSegs = [arr[this.selectedIndex]];
}
}
if (targetSegs.length === 0) return;
let minStart = Infinity;
let maxEnd = -Infinity;
for (const s of targetSegs) {
if (s.start < minStart) {
minStart = s.start;
}
if (s.start + s.length > maxEnd) {
maxEnd = s.start + s.length;
}
}
if (minStart !== Infinity && maxEnd !== -Infinity) {
const newStart = Math.round(minStart);
const newEnd = Math.max(newStart + 1, Math.round(maxEnd));
const currentStart = this.getStartFrames();
const currentEnd = this.endFramesWidget ? parseInt(this.endFramesWidget.value, 10) : (currentStart + this.getDurationFrames());
let targetStart = newStart;
let targetEnd = newEnd;
if (currentStart === newStart && currentEnd === newEnd) {
let lastSegmentEnd = 0;
for (const s of allSegs) {
if (s.start + s.length > lastSegmentEnd) {
lastSegmentEnd = s.start + s.length;
}
}
if (lastSegmentEnd <= 0) {
lastSegmentEnd = this.getDurationFrames();
}
targetStart = 0;
targetEnd = Math.max(1, Math.round(lastSegmentEnd));
}
if (this.startFramesWidget && this.endFramesWidget) {
this.startFramesWidget.value = targetStart;
this.endFramesWidget.value = targetEnd;
if (this.startFramesWidget.callback) {
this.startFramesWidget.callback(targetStart);
}
if (this.endFramesWidget.callback) {
this.endFramesWidget.callback(targetEnd);
}
this.commitChanges();
this.render();
}
}
}
deleteSelectedSegment() {
if (this.selectedSegmentIds && this.isMultiSelectActive()) {
const idsToDelete = new Set(this.selectedSegmentIds);
for (const id of this.selectedSegmentIds) {
if (id.endsWith("_v")) idsToDelete.add(id.slice(0, -2) + "_a");
else if (id.endsWith("_a")) idsToDelete.add(id.slice(0, -2) + "_v");
}
this.timeline.segments = this.timeline.segments.filter(s => !idsToDelete.has(s.id));
this.timeline.motionSegments = this.timeline.motionSegments.filter(s => !idsToDelete.has(s.id));
this.timeline.audioSegments = this.timeline.audioSegments.filter(s => !idsToDelete.has(s.id));
this.selectedSegmentIds = [];
this.selectedIndex = -1;
} else {
const delSibling = (seg) => {
if (!seg || !seg.id) return;
const isVid = seg.id.endsWith("_v");
const isAud = seg.id.endsWith("_a");
if (!isVid && !isAud) return;
const siblingId = isVid ? seg.id.slice(0, -2) + "_a" : seg.id.slice(0, -2) + "_v";
const siblingArray = isVid ? this.timeline.audioSegments : this.timeline.segments;
const sIdx = siblingArray.findIndex(s => s.id === siblingId);
if (sIdx !== -1) siblingArray.splice(sIdx, 1);
};
if (this.selectionType === "audio") {
if (this.timeline.audioSegments.length === 0 || this.selectedIndex === -1) return;
delSibling(this.timeline.audioSegments[this.selectedIndex]);
this.timeline.audioSegments.splice(this.selectedIndex, 1);
this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
} else if (this.selectionType === "motion") {
if (this.timeline.motionSegments.length === 0 || this.selectedIndex === -1) return;
delSibling(this.timeline.motionSegments[this.selectedIndex]);
this.timeline.motionSegments.splice(this.selectedIndex, 1);
this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
} else {
if (this.timeline.segments.length === 0 || this.selectedIndex === -1) return;
delSibling(this.timeline.segments[this.selectedIndex]);
this.timeline.segments.splice(this.selectedIndex, 1);
this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
}
this.selectedSegmentIds = [];
}
this.updateUIFromSelection();
this.commitChanges();
this.render();
}
getCanonicalTrack(track) {
if (track === "image" || track === "video" || track === "text") return "image";
if (track === "audio") return "audio";
if (track === "motion" || track === "motion_video") return "motion";
return track;
}
pasteCopiedSegment() {
if (!window._ltxCopiedSegmentCS || !window._ltxCopiedSegmentTypeCS) return;
const trackType = window._ltxCopiedSegmentTypeCS;
const startFrame = Math.round(this.currentFrame);
this.pasteSegmentAtFrame(window._ltxCopiedSegmentCS.main, trackType, window._ltxCopiedSegmentCS.sibling, startFrame);
}
pasteSegmentAtFrame(copiedSegData, copiedTrack, siblingSegData, startFrame) {
const isAudio = copiedTrack === "audio";
const randId = () => Date.now().toString() + Math.random().toString(36).substr(2, 5);
const baseId = randId();
let mainSeg = { ...copiedSegData };
let sibSeg = siblingSegData ? { ...siblingSegData } : null;
if (sibSeg) {
mainSeg.id = baseId + (isAudio ? "_a" : "_v");
sibSeg.id = baseId + (isAudio ? "_v" : "_a");
} else {
if (mainSeg.id && (mainSeg.id.endsWith("_v") || mainSeg.id.endsWith("_a"))) {
mainSeg.id = mainSeg.id.slice(0, -2);
} else {
mainSeg.id = baseId;
}
}
if (mainSeg.thumbnails) mainSeg.thumbnails = [...mainSeg.thumbnails];
if (sibSeg && sibSeg.thumbnails) sibSeg.thumbnails = [...sibSeg.thumbnails];
mainSeg.start = startFrame;
if (sibSeg) sibSeg.start = startFrame;
const mainArr = isAudio ? [...this.timeline.audioSegments] : (copiedTrack === "motion" ? [...this.timeline.motionSegments] : [...this.timeline.segments]);
mainArr.push(mainSeg);
mainArr.sort((a, b) => a.start - b.start);
const sibArr = isAudio ? [...this.timeline.segments] : [...this.timeline.audioSegments];
if (sibSeg) {
sibArr.push(sibSeg);
sibArr.sort((a, b) => a.start - b.start);
}
const durationFrames = this.getDurationFrames();
const totalFrames = this.getVisualDurationFrames();
const width = this.canvas.offsetWidth || this._lastWidth;
const mainInit = mainArr.map(s => ({ ...s }));
const sibInit = sibSeg ? sibArr.map(s => ({ ...s })) : null;
let finalMain, finalSib;
finalMain = this._applyCenterDragPhysics(mainInit, mainSeg.id, startFrame, startFrame + mainSeg.length / 2, durationFrames, totalFrames, width, true);
if (sibSeg) {
finalSib = this._applyCenterDragPhysics(sibInit, sibSeg.id, startFrame, startFrame + sibSeg.length / 2, durationFrames, totalFrames, width, true);
}
if (sibSeg) {
const activeTimeline = isAudio ? finalMain : finalSib;
const siblingTimeline = isAudio ? finalSib : finalMain;
this._resolveGlobalPhysics(activeTimeline, siblingTimeline, durationFrames, mainInit, sibInit);
}
const restoreDOM = (outArr, refArr) => {
for (let ps of outArr) {
const orig = refArr.find(s => s.id === ps.id);
if (orig) {
ps.videoEl = orig.videoEl;
ps.imgObj = orig.imgObj;
if (orig.thumbnails) ps.thumbnails = orig.thumbnails;
}
}
};
restoreDOM(finalMain, mainArr);
if (sibSeg) restoreDOM(finalSib, sibArr);
if (copiedTrack === "audio") {
this.timeline.audioSegments = finalMain;
if (sibSeg) this.timeline.segments = finalSib;
} else if (copiedTrack === "motion") {
this.timeline.motionSegments = finalMain;
} else {
this.timeline.segments = finalMain;
if (sibSeg) this.timeline.audioSegments = finalSib;
}
this.selectionType = copiedTrack;
this.selectedIndex = this.getSegmentArray(copiedTrack).findIndex(s => s.id === mainSeg.id);
if (!this.retakeMode) {
this.growTimelineIfNeeded(mainSeg.start + mainSeg.length);
}
this.updateUIFromSelection();
this.commitChanges();
this.render();
}
splitSegmentAtPlayhead(seg, trackType) {
if (this.isPlaying) {
this.pauseAudio();
}
const splitFrame = Math.round(this.currentFrame);
if (splitFrame <= seg.start || splitFrame >= seg.start + seg.length) {
return;
}
const isVidLink = (trackType === "image" || trackType === "video") && seg.id.endsWith("_v");
const isAudLink = trackType === "audio" && seg.id.endsWith("_a");
let sibling = null;
if (isVidLink) {
sibling = this.timeline.audioSegments.find(s => s.id === seg.id.slice(0, -2) + "_a");
} else if (isAudLink) {
sibling = this.timeline.segments.find(s => s.id === seg.id.slice(0, -2) + "_v");
}
const randId = () => Date.now().toString() + Math.random().toString(36).substr(2, 5);
const leftBase = randId();
const rightBase = randId();
const leftLen = splitFrame - seg.start;
const rightLen = seg.start + seg.length - splitFrame;
if (sibling) {
const videoSeg = isVidLink ? seg : sibling;
const audioSeg = isVidLink ? sibling : seg;
const leftVid = {
...videoSeg,
id: leftBase + "_v",
length: leftLen,
videoEl: null,
_blobUrl: videoSeg._blobUrl || (videoSeg.videoEl ? videoSeg.videoEl.src : null),
thumbnails: videoSeg.thumbnails ? [...videoSeg.thumbnails] : null
};
const leftAud = {
...audioSeg,
id: leftBase + "_a",
length: leftLen,
_blobUrl: videoSeg._blobUrl || (videoSeg.videoEl ? videoSeg.videoEl.src : null)
};
let rightImageB64 = videoSeg.imageB64;
let rightImgObj = videoSeg.imgObj;
if (videoSeg.thumbnails && videoSeg.thumbnails.length > 0) {
const targetTime = ((videoSeg.trimStart || 0) + leftLen) / this.getFrameRate();
let nearest = videoSeg.thumbnails[0];
let minDiff = Infinity;
for (const t of videoSeg.thumbnails) {
const diff = Math.abs(t.time - targetTime);
if (diff < minDiff) {
minDiff = diff;
nearest = t;
}
}
if (nearest && nearest.img) {
rightImageB64 = nearest.img.src;
rightImgObj = nearest.img;
}
}
const rightVid = {
...videoSeg,
id: rightBase + "_v",
start: splitFrame,
length: rightLen,
trimStart: (videoSeg.trimStart || 0) + leftLen,
videoEl: null,
imageB64: rightImageB64,
imgObj: rightImgObj,
_blobUrl: videoSeg._blobUrl || (videoSeg.videoEl ? videoSeg.videoEl.src : null),
thumbnails: videoSeg.thumbnails ? [...videoSeg.thumbnails] : null
};
const rightAud = {
...audioSeg,
id: rightBase + "_a",
start: splitFrame,
length: rightLen,
trimStart: (audioSeg.trimStart || 0) + leftLen,
_blobUrl: videoSeg._blobUrl || (videoSeg.videoEl ? videoSeg.videoEl.src : null)
};
this.timeline.segments = this.timeline.segments.filter(s => s.id !== videoSeg.id);
this.timeline.audioSegments = this.timeline.audioSegments.filter(s => s.id !== audioSeg.id);
this.timeline.segments.push(leftVid, rightVid);
this.timeline.audioSegments.push(leftAud, rightAud);
this.timeline.segments.sort((a, b) => a.start - b.start);
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
this.selectionType = trackType;
const targetId = trackType === "audio" ? leftAud.id : leftVid.id;
const targetArray = this.getSegmentArray(trackType);
this.selectedIndex = targetArray.findIndex(s => s.id === targetId);
} else {
const targetArray = this.getSegmentArray(trackType);
const leftSeg = {
...seg,
id: leftBase,
length: leftLen
};
if (seg.type === "video" || seg.type === "motion_video") {
leftSeg.videoEl = null;
leftSeg._blobUrl = seg._blobUrl || (seg.videoEl ? seg.videoEl.src : null);
leftSeg.thumbnails = seg.thumbnails ? [...seg.thumbnails] : null;
}
let rightImageB64 = seg.imageB64;
let rightImgObj = seg.imgObj;
if (seg.thumbnails && seg.thumbnails.length > 0) {
const targetTime = ((seg.trimStart || 0) + leftLen) / this.getFrameRate();
let nearest = seg.thumbnails[0];
let minDiff = Infinity;
for (const t of seg.thumbnails) {
const diff = Math.abs(t.time - targetTime);
if (diff < minDiff) {
minDiff = diff;
nearest = t;
}
}
if (nearest && nearest.img) {
rightImageB64 = nearest.img.src;
rightImgObj = nearest.img;
}
}
const rightSeg = {
...seg,
id: rightBase,
start: splitFrame,
length: rightLen,
trimStart: (seg.trimStart || 0) + leftLen
};
if (seg.type === "video" || seg.type === "motion_video") {
rightSeg.videoEl = null;
rightSeg.imageB64 = rightImageB64;
rightSeg.imgObj = rightImgObj;
rightSeg._blobUrl = seg._blobUrl || (seg.videoEl ? seg.videoEl.src : null);
rightSeg.thumbnails = seg.thumbnails ? [...seg.thumbnails] : null;
}
const idx = targetArray.findIndex(s => s.id === seg.id);
if (idx !== -1) {
targetArray.splice(idx, 1);
}
targetArray.push(leftSeg, rightSeg);
targetArray.sort((a, b) => a.start - b.start);
this.selectionType = trackType;
this.selectedIndex = targetArray.findIndex(s => s.id === leftSeg.id);
}
this.loadMedia();
this.updateUIFromSelection();
this.commitChanges();
this.render();
}
formatTime(frames, dropSuffix = false) {
const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds";
if (mode === "seconds") {
const secs = Math.round(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";
const isSeconds = mode === "seconds";
const isLiteGraph = !window.LiteGraph || !window.LiteGraph.vueNodesMode;
if (isSeconds) {
if (this.startFramesWidget) hideWidget(this.startFramesWidget);
if (this.endFramesWidget) hideWidget(this.endFramesWidget);
if (this.durationFramesWidget) hideWidget(this.durationFramesWidget);
if (this.startSecondsWidget) showWidget(this.startSecondsWidget);
if (this.endSecondsWidget) showWidget(this.endSecondsWidget);
if (this.durationSecondsWidget) showWidget(this.durationSecondsWidget);
// LiteGraph: remove frame input slots, restore second input slots
if (isLiteGraph && this.node.inputs) {
for (const name of ["start_frame", "end_frame", "duration_frames"]) {
const idx = this.node.inputs.findIndex(i => i.name === name);
if (idx !== -1 && this.node.inputs[idx].link == null) {
this.node.removeInput(idx);
}
}
for (const [name, type] of [["start_second", "FLOAT"], ["end_second", "FLOAT"], ["duration_seconds", "FLOAT"]]) {
if (!this.node.inputs.find(i => i.name === name)) {
const w = this.node.widgets?.find(w => w.name === name);
const slot = this.node.addInput(name, type);
// keep the slot linked to its widget
if (w && slot != null) {
const inp = this.node.inputs[this.node.inputs.length - 1];
if (inp) inp.widget = { name };
}
}
}
}
} else {
if (this.startSecondsWidget) hideWidget(this.startSecondsWidget);
if (this.endSecondsWidget) hideWidget(this.endSecondsWidget);
if (this.durationSecondsWidget) hideWidget(this.durationSecondsWidget);
if (this.startFramesWidget) showWidget(this.startFramesWidget);
if (this.endFramesWidget) showWidget(this.endFramesWidget);
if (this.durationFramesWidget) showWidget(this.durationFramesWidget);
// LiteGraph: remove second input slots, restore frame input slots
if (isLiteGraph && this.node.inputs) {
for (const name of ["start_second", "end_second", "duration_seconds"]) {
const idx = this.node.inputs.findIndex(i => i.name === name);
if (idx !== -1 && this.node.inputs[idx].link == null) {
this.node.removeInput(idx);
}
}
for (const [name, type] of [["start_frame", "INT"], ["end_frame", "INT"], ["duration_frames", "INT"]]) {
if (!this.node.inputs.find(i => i.name === name)) {
const slot = this.node.addInput(name, type);
if (slot != null) {
const inp = this.node.inputs[this.node.inputs.length - 1];
if (inp) inp.widget = { name };
}
}
}
}
}
// 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);
}
getGlobalPrompt() {
if (this.globalPromptInput) {
return this.globalPromptInput.value || "";
}
let val = "";
if (this.node) {
const globalInput = this.node.inputs?.find(i => i.name === "global_prompt");
if (globalInput && globalInput.link !== null && globalInput.link !== undefined) {
const link = window.app.graph?.links?.[globalInput.link];
if (link) {
const originNode = window.app.graph.getNodeById(link.origin_id);
if (originNode && originNode.widgets && originNode.widgets.length > 0) {
val = originNode.widgets[0].value || "";
}
}
} else {
const w = this.node.widgets?.find(x => x.name === "global_prompt");
if (w) {
val = w.value || "";
} else {
val = this.node.properties?.global_prompt || "";
}
}
}
return val;
}
syncGlobalPrompt(val) {
if (this.node.properties) {
this.node.properties.global_prompt = val;
}
if (this.retakeMode) {
this.timeline.retake_global_prompt = val;
} else {
this.timeline.global_prompt = val;
}
const globalInput = this.node.inputs?.find(i => i.name === "global_prompt");
let synced = false;
if (globalInput && globalInput.link !== null && globalInput.link !== undefined) {
const link = window.app.graph?.links?.[globalInput.link];
if (link) {
const originNode = window.app.graph.getNodeById(link.origin_id);
if (originNode && originNode.widgets && originNode.widgets.length > 0) {
const w = originNode.widgets[0];
const oldVal = w.value;
w.value = val;
if (originNode.onWidgetChanged) {
originNode.onWidgetChanged(w.name, val, oldVal, w);
}
if (w.callback) {
try {
originNode.widgets[0].callback(val);
} catch (err) { }
}
synced = true;
}
}
}
if (!synced) {
const w = this.node.widgets?.find(x => x.name === "global_prompt");
if (w) {
const oldVal = w.value;
w.value = val;
if (this.node.onWidgetChanged) {
this.node.onWidgetChanged(w.name, val, oldVal, w);
}
if (w.callback) {
try {
w.callback(val);
} catch (err) { }
}
}
}
if (this.globalPromptInput && this.globalPromptInput.value !== val) {
this.globalPromptInput.value = val;
}
if (this.node) {
this.node.setDirtyCanvas(true, false);
}
if (window.app?.graph) {
if (window.app.graph.change) window.app.graph.change();
if (window.app.graph.onNodeChanged) window.app.graph.onNodeChanged(this.node);
if (window.app.graph.onStateChanged) window.app.graph.onStateChanged();
}
}
updateUIFromSelection() {
if (this.selectedSegmentIds && this.isMultiSelectActive()) {
if (this.globalPromptInput) {
this.globalPromptInput.disabled = true;
this.globalPromptInput.style.opacity = "0.35";
}
if (this.promptWrapper) this.promptWrapper.style.display = "block";
if (this.promptInput) {
this.promptInput.value = "";
this.promptInput.placeholder = "(Multiple Segments Selected)";
this.promptInput.disabled = true;
this.promptInput.style.opacity = "0.35";
}
if (this.segmentPromptLabel) {
this.segmentPromptLabel.style.display = "block";
this.segmentPromptLabel.textContent = "Segment Prompt";
}
if (this.strengthRow) this.strengthRow.style.display = "flex";
if (this.strengthLabel) this.strengthLabel.style.display = "inline";
if (this.strengthValue) {
this.strengthValue.style.display = "inline-block";
this.strengthValue.value = "";
this.strengthValue.placeholder = "(Multiple)";
this.strengthValue.disabled = true;
this.strengthValue.style.opacity = "0.35";
}
if (this.vidStrLabel) this.vidStrLabel.style.display = "none";
if (this.vidStrValue) {
this.vidStrValue.style.display = "none";
this.vidStrValue.disabled = true;
this.vidStrValue.style.opacity = "0.35";
}
if (this.vidAttnLabel) this.vidAttnLabel.style.display = "none";
if (this.vidAttnValue) {
this.vidAttnValue.style.display = "none";
this.vidAttnValue.disabled = true;
this.vidAttnValue.style.opacity = "0.35";
}
if (this.audioInfoArea) this.audioInfoArea.style.display = "none";
if (this.motionInfoArea) this.motionInfoArea.style.display = "none";
if (this.segmentBoundsDisplay) {
this.segmentBoundsDisplay.textContent = "Multiple Segments Selected";
}
return;
}
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 if (this.selectionType === "motion") {
const origSeg = this.timeline.motionSegments[this.selectedIndex];
if (origSeg) {
const previewIsMotion = this._ghostTrack === 'motion' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'motion');
const arr = (this._previewSegments && previewIsMotion) ? this._previewSegments : this.timeline.motionSegments;
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;
}
}
}
// Reset default disabled/opacity values
if (this.vidStrValue) {
this.vidStrValue.disabled = false;
this.vidStrValue.style.opacity = "";
}
if (this.vidAttnValue) {
this.vidAttnValue.disabled = false;
this.vidAttnValue.style.opacity = "";
}
if (this.strengthValue) {
this.strengthValue.style.opacity = "";
this.strengthValue.placeholder = "";
}
if (this.promptInput) {
this.promptInput.placeholder = "";
this.promptInput.style.opacity = "";
}
if (this.retakeMode) {
if (this.promptWrapper) this.promptWrapper.style.display = "block";
this.promptInput.disabled = false;
this.promptInput.style.opacity = "1.0";
this.promptInput.placeholder = "Enter prompt for retake region...";
this.promptInput.value = this.timeline.retakePrompt || "";
this.strengthRow.style.display = "flex";
this.strengthLabel.style.display = "inline";
this.strengthLabel.textContent = "Guide Strength:";
this.strengthValue.style.display = "inline-block";
this.strengthValue.disabled = true;
this.strengthValue.style.opacity = "0.35";
this.strengthValue.value = (this.timeline.retakeStrength ?? 1.0).toFixed(2);
this.vidStrLabel.style.display = "none";
this.vidStrValue.style.display = "none";
this.vidAttnLabel.style.display = "none";
this.vidAttnValue.style.display = "none";
this.audioInfoArea.style.display = "none";
this.motionInfoArea.style.display = "none";
if (this.segmentBoundsDisplay) {
const startStr = this.formatTime(this.timeline.retakeStart, true);
const endStr = this.formatTime(this.timeline.retakeStart + this.timeline.retakeLength, true);
const lengthStr = this.formatTime(this.timeline.retakeLength, true);
this.segmentBoundsDisplay.textContent = `Start: ${startStr} | End: ${endStr} | Length: ${lengthStr}`;
}
} else if (this.selectionType === "audio" && seg) {
if (this.globalPromptInput) {
this.globalPromptInput.disabled = false;
this.globalPromptInput.style.opacity = "1.0";
}
if (this.promptWrapper) this.promptWrapper.style.display = "none";
this.strengthRow.style.display = "flex";
this.strengthLabel.style.display = "inline";
this.strengthLabel.textContent = "Guide Strength:";
this.strengthValue.style.display = "inline-block";
this.vidStrLabel.style.display = "none";
this.vidStrValue.style.display = "none";
this.vidAttnLabel.style.display = "none";
this.vidAttnValue.style.display = "none";
this.audioInfoArea.style.display = "block";
this.motionInfoArea.style.display = "none";
this.audioInfoArea.innerHTML = `
File: ${seg.fileName || "Unknown"}
Length: ${this.formatTime(seg.audioDurationFrames)} Output Length: ${this.formatTime(seg.length)}
Trim-in: ${this.formatTime(Math.round(seg.trimStart))} Trim-Out: ${this.formatTime(Math.round(seg.audioDurationFrames - (seg.trimStart + seg.length)))}
`;
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
} else if (this.selectionType === "motion" && seg) {
if (this.globalPromptInput) {
this.globalPromptInput.disabled = true;
this.globalPromptInput.style.opacity = "0.4";
}
if (this.promptWrapper) this.promptWrapper.style.display = "block";
this.promptInput.disabled = false;
this.promptInput.style.opacity = "1.0";
this.promptInput.placeholder = "Global prompt (syncs across all IC LoRA segments)...";
this.promptInput.value = this.getGlobalPrompt();
if (this.segmentPromptLabel) {
this.segmentPromptLabel.style.display = "block";
this.segmentPromptLabel.textContent = "Global Prompt (IC-LoRA)";
}
this.strengthRow.style.display = "flex";
this.strengthLabel.style.display = "none";
this.strengthValue.style.display = "none";
this.vidStrLabel.style.display = "inline";
this.vidStrValue.style.display = "inline-block";
this.vidAttnLabel.style.display = "inline";
this.vidAttnValue.style.display = "inline-block";
this.vidStrValue.value = (seg.videoStrength ?? 1.0).toFixed(2);
this.vidAttnValue.value = (seg.videoAttentionStrength ?? 0.65).toFixed(2);
this.audioInfoArea.style.display = "none";
this.motionInfoArea.style.display = "none";
} else {
if (this.segmentPromptLabel) {
this.segmentPromptLabel.style.display = "block";
this.segmentPromptLabel.textContent = "Segment Prompt";
}
if (this.globalPromptInput) {
this.globalPromptInput.disabled = false;
this.globalPromptInput.style.opacity = "1.0";
}
this.audioInfoArea.style.display = "none";
this.motionInfoArea.style.display = "none";
if (this.promptWrapper) this.promptWrapper.style.display = "block";
this.strengthRow.style.display = "flex";
this.strengthLabel.style.display = "inline";
this.strengthLabel.textContent = "Guide Strength:";
this.strengthValue.style.display = "inline-block";
this.vidStrLabel.style.display = "none";
this.vidStrValue.style.display = "none";
this.vidAttnLabel.style.display = "none";
this.vidAttnValue.style.display = "none";
if (seg) {
if (this.selectionType !== "motion") {
this.promptInput.value = seg.prompt || "";
this.promptInput.placeholder = "Enter prompt for selected segment...";
}
this.promptInput.disabled = false;
this.promptInput.style.opacity = "1.0";
const isImage = (this.selectionType === "image") && (seg.type === "image" || seg.type === "video");
const strength = isImage ? (seg.guideStrength ?? 1.0) : 1.0;
this.strengthValue.value = strength.toFixed(2);
this.strengthValue.disabled = !isImage;
this.strengthValue.style.opacity = isImage ? "1.0" : "0.35";
} else {
this.promptInput.value = "";
this.promptInput.placeholder = "No segment selected!";
this.promptInput.disabled = true;
this.promptInput.style.opacity = "0.4";
this.strengthValue.value = "1.00";
this.strengthValue.disabled = true;
this.strengthValue.style.opacity = "0.35";
}
}
if (this.segmentBoundsDisplay && !this.retakeMode) {
if (seg) {
const startStr = this.formatTime(seg.start, true);
const endStr = this.formatTime(seg.start + seg.length, true);
const lengthStr = this.formatTime(seg.length, true);
this.segmentBoundsDisplay.textContent = `Start: ${startStr} | End: ${endStr} | Length: ${lengthStr}`;
} else {
this.segmentBoundsDisplay.textContent = "Start: - | End: - | Length: -";
}
}
}
updateRetakeUIState() {
const isRetake = this.retakeMode;
if (this.globalPromptInput) {
const p = isRetake ? (this.timeline.retake_global_prompt || "") : (this.timeline.global_prompt || "");
if (this.globalPromptInput.value !== p) {
this.globalPromptInput.value = p;
this.syncGlobalPrompt(p);
}
}
// 1. Set track heights
if (isRetake) {
if (this.blockHeight > 0 && this.audioTrackHeight > 0) {
this._oldBlockHeight = this.blockHeight;
this._oldAudioTrackHeight = this.audioTrackHeight;
this._oldMotionTrackHeight = this.motionTrackHeight;
}
this.blockHeight = this.canvasHeight - this.rulerHeight;
this.audioTrackHeight = 0;
this.motionTrackHeight = 0;
// In retake mode, uploadVideoBtn stays as "Add Video" (same as normal mode)
if (this.mainTrackLabel) {
const textSpan = this.mainTrackLabel.querySelector("span");
if (textSpan) textSpan.textContent = "VIDEO";
if (this.mainTrackLabel._eyeBtn) this.mainTrackLabel._eyeBtn.style.display = "none";
this.mainTrackLabel.style.backgroundColor = "#1e1e1e";
this.audioTrackLabel.style.display = "none";
this.motionTrackLabel.style.display = "none";
}
if (this.sidebar) this.sidebar.style.backgroundColor = "#1e1e1e";
if (this.rulerSpacer) this.rulerSpacer.style.backgroundColor = "#1e1e1e";
} else {
this.blockHeight = this._oldBlockHeight ?? BLOCK_HEIGHT;
this.audioTrackHeight = this._oldAudioTrackHeight ?? AUDIO_TRACK_HEIGHT;
this.motionTrackHeight = this._oldMotionTrackHeight ?? MOTION_TRACK_HEIGHT;
if (this.uploadVideoBtn) {
this.uploadVideoBtn.innerHTML = ` Add Video`;
}
if (this.mainTrackLabel) {
const textSpan = this.mainTrackLabel.querySelector("span");
if (textSpan) textSpan.textContent = "MAIN";
if (this.mainTrackLabel._eyeBtn) this.mainTrackLabel._eyeBtn.style.display = "inline-flex";
this.mainTrackLabel.style.backgroundColor = "#1e1e1e";
this.audioTrackLabel.style.display = "flex";
this.motionTrackLabel.style.display = "flex";
}
if (this.sidebar) this.sidebar.style.backgroundColor = "#1e1e1e";
if (this.rulerSpacer) this.rulerSpacer.style.backgroundColor = "#1e1e1e";
}
this.updateSidebarHeights();
// Reset zoom to fit viewport when entering retake mode so full video is visible
if (isRetake) {
this.zoomLevel = 1;
if (this.zoomSlider) this.zoomSlider.value = 1;
this.updateZoomSliderMax();
const vw = this.viewport ? this.viewport.clientWidth : 0;
if (vw > 0) {
this.resizeCanvas(vw);
this._lastWidth = vw;
this._lastZoom = 1;
if (this.viewport) this.viewport.scrollLeft = 0;
}
}
// 2. Hide/show toolbar action buttons
if (this.uploadBtn) this.uploadBtn.style.display = isRetake ? "none" : "";
if (this.addTextBtn) this.addTextBtn.style.display = isRetake ? "none" : "";
if (this.uploadAudioBtn) this.uploadAudioBtn.style.display = isRetake ? "none" : "";
if (this.uploadMotionBtn) this.uploadMotionBtn.style.display = isRetake ? "none" : "";
if (this.deleteBtn) this.deleteBtn.style.display = isRetake ? "none" : "";
// deleteRetakeBtn is visible whenever Retake Mode is active
if (this.deleteRetakeBtn) {
this.deleteRetakeBtn.style.display = isRetake ? "" : "none";
}
// 3. Update the toggle button class/title
if (this.updateRetakeStyle) this.updateRetakeStyle();
// 4. Update the prompt labels
if (this.segmentPromptLabel) {
this.segmentPromptLabel.textContent = isRetake ? "Retake Prompt" : "Local Prompt";
}
// 5. Update UI selection inputs
this.updateUIFromSelection();
}
updateSidebarHeights() {
if (this.mainTrackLabel) {
this.mainTrackLabel.style.height = `${this.blockHeight}px`;
this.audioTrackLabel.style.height = `${this.audioTrackHeight}px`;
this.motionTrackLabel.style.height = `${this.motionTrackHeight}px`;
}
}
// --- Rendering logic ---
render() {
if (!this.canvas) return;
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);
// Lazy load active video/motion segments
const targetFrame = this.currentFrame;
if (this.retakeMode && this.timeline.retakeVideo) {
this._ensureVideoEl(this.timeline.retakeVideo);
} else {
const activeSeg = this.timeline.segments.find(s => s.type === "video" && targetFrame >= s.start && targetFrame < s.start + s.length);
if (activeSeg) this._ensureVideoEl(activeSeg);
if (this.timeline.motionSegments) {
const activeMotionSeg = this.timeline.motionSegments.find(s => s.type === "motion_video" && targetFrame >= s.start && targetFrame < s.start + s.length);
if (activeMotionSeg) this._ensureVideoEl(activeMotionSeg);
}
}
if (this.selectedIndex !== -1) {
const selSeg = this.getSegmentArray(this.selectionType)[this.selectedIndex];
if (selSeg && (selSeg.type === "video" || selSeg.type === "motion_video")) {
this._ensureVideoEl(selSeg);
}
}
if (this._isDragging && this._dragTargetId) {
const dragSeg = this.timeline.segments.find(s => s.id === this._dragTargetId) ||
(this.timeline.motionSegments && this.timeline.motionSegments.find(s => s.id === this._dragTargetId));
if (dragSeg && (dragSeg.type === "video" || dragSeg.type === "motion_video")) {
this._ensureVideoEl(dragSeg);
}
}
// Render Track Backgrounds
this.ctx.fillStyle = "#121212"; // Image track bg
this.ctx.fillRect(0, RULER_HEIGHT, width, this.blockHeight);
this.ctx.fillStyle = "#141414"; // Audio track bg
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, this.audioTrackHeight);
this.ctx.fillStyle = "#121212"; // Motion track bg
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight + this.audioTrackHeight, width, this.motionTrackHeight);
// 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');
const previewIsMotion = this._ghostTrack === 'motion' ||
(this._previewSegments && this._ghostTrack === null && this.selectionType === 'motion');
const previewIsImage = !previewIsAudio && !previewIsMotion;
let renderSegments = this.timeline.segments;
let renderAudioSegments = this.timeline.audioSegments;
let renderMotionSegments = this.timeline.motionSegments;
if (this._isDragging && this._multiDragPreviewTimelines) {
if (this._multiDragPreviewTimelines.image) renderSegments = this._multiDragPreviewTimelines.image;
if (this._multiDragPreviewTimelines.motion) renderMotionSegments = this._multiDragPreviewTimelines.motion;
if (this._multiDragPreviewTimelines.audio) renderAudioSegments = this._multiDragPreviewTimelines.audio;
} else {
const previewIsAudio = this._ghostTrack === 'audio' ||
(this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio');
const previewIsMotion = this._ghostTrack === 'motion' ||
(this._previewSegments && this._ghostTrack === null && this.selectionType === 'motion');
const previewIsImage = !previewIsAudio && !previewIsMotion;
if (this._previewSegments && previewIsImage) renderSegments = this._previewSegments;
else if (this._previewSiblingSegments && previewIsAudio) renderSegments = this._previewSiblingSegments;
if (this._previewSegments && previewIsAudio) renderAudioSegments = this._previewSegments;
else if (this._previewSiblingSegments && previewIsImage) renderAudioSegments = this._previewSiblingSegments;
if (this._previewSegments && previewIsMotion) renderMotionSegments = this._previewSegments;
}
const sortedSegments = [...renderSegments].sort((a, b) => {
const aSel = this.selectedSegmentIds.includes(a.id) ? 1 : 0;
const bSel = this.selectedSegmentIds.includes(b.id) ? 1 : 0;
return aSel - bSel;
});
const sortedMotionSegments = [...renderMotionSegments].sort((a, b) => {
const aSel = this.selectedSegmentIds.includes(a.id) ? 1 : 0;
const bSel = this.selectedSegmentIds.includes(b.id) ? 1 : 0;
return aSel - bSel;
});
const sortedAudioSegments = [...renderAudioSegments].sort((a, b) => {
const aSel = this.selectedSegmentIds.includes(a.id) ? 1 : 0;
const bSel = this.selectedSegmentIds.includes(b.id) ? 1 : 0;
return aSel - bSel;
});
if (this.retakeMode) {
// Draw Retake Mode Filmstrip and Overlay
const retakeVid = this.timeline.retakeVideo;
const frameRate = this.getFrameRate();
if (retakeVid) {
const showLivePreview = this.isPlaying || (this._isDragging && (this._dragType === "playhead" || this._dragType === "retake_left" || this._dragType === "retake_right" || this._dragType === "retake_center"));
// Calculate the actual visual width of the base video block
const baseVideoDur = retakeVid.videoDurationFrames || 0;
const videoWidthPx = totalFrames > 0 ? (baseVideoDur / totalFrames) * width : width;
if (showLivePreview) {
let targetTime = this.currentFrame / frameRate;
if (this._isDragging) {
if (this._dragType === "retake_left") {
targetTime = (this.timeline.retakeStart ?? 0) / frameRate;
} else if (this._dragType === "retake_right") {
targetTime = ((this.timeline.retakeStart ?? 0) + (this.timeline.retakeLength ?? baseVideoDur)) / frameRate;
} else if (this._dragType === "retake_center") {
targetTime = (this.timeline.retakeStart ?? 0) / frameRate;
}
}
let drawSource = null;
const useLiveVideo = this.isPlaying || (this._isDragging ? this._dragType !== "playhead" : true);
if (useLiveVideo && retakeVid.videoEl && retakeVid.videoEl.readyState >= 2 && !retakeVid.videoEl.seeking) {
drawSource = retakeVid.videoEl;
} else if (retakeVid.thumbnails && retakeVid.thumbnails.length > 0) {
let nearestImg = retakeVid.thumbnails[0].img;
let minDiff = Infinity;
for (const t of retakeVid.thumbnails) {
const diff = Math.abs(t.time - targetTime);
if (diff < minDiff) {
minDiff = diff;
nearestImg = t.img;
}
}
drawSource = nearestImg;
} else {
drawSource = retakeVid.videoEl || (retakeVid.imgObj && retakeVid.imgObj.complete ? retakeVid.imgObj : null);
}
this.ctx.fillStyle = "#000";
this.ctx.fillRect(0, RULER_HEIGHT + 1, videoWidthPx, this.blockHeight - 2);
if (drawSource) {
const isVid = !!drawSource.videoWidth;
const natW = isVid ? drawSource.videoWidth : drawSource.naturalWidth;
const natH = isVid ? drawSource.videoHeight : drawSource.naturalHeight;
if (natW > 0) {
const imgRatio = natW / natH;
const trackRatio = videoWidthPx / this.blockHeight;
let drawW, drawH, drawX, drawY;
if (imgRatio > trackRatio) {
drawW = videoWidthPx;
drawH = videoWidthPx / imgRatio;
drawX = 0;
drawY = RULER_HEIGHT + (this.blockHeight - drawH) / 2;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(0, RULER_HEIGHT + 1, videoWidthPx, this.blockHeight - 2);
this.ctx.clip();
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
this.ctx.restore();
} else {
drawH = this.blockHeight;
drawW = this.blockHeight * imgRatio;
drawY = RULER_HEIGHT;
drawX = (videoWidthPx - drawW) / 2;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(0, RULER_HEIGHT + 1, videoWidthPx, this.blockHeight - 2);
this.ctx.clip();
// Draw centered preview frame
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
// Tile to the left
let leftX = drawX - drawW;
while (leftX + drawW > 0) {
this.ctx.drawImage(drawSource, leftX, drawY, drawW, drawH);
leftX -= drawW;
}
// Tile to the right
let rightX = drawX + drawW;
while (rightX < videoWidthPx) {
this.ctx.drawImage(drawSource, rightX, drawY, drawW, drawH);
rightX += drawW;
}
this.ctx.restore();
}
}
}
} else {
// Static state: pick the midpoint thumbnail and tile it at its natural aspect ratio,
// matching the visual appearance of the live-preview path.
const durationSecs = baseVideoDur / frameRate;
const midTime = durationSecs / 2;
let drawSource = null;
if (retakeVid.thumbnails && retakeVid.thumbnails.length > 0) {
let nearestImg = retakeVid.thumbnails[0].img;
let minDiff = Infinity;
for (const t of retakeVid.thumbnails) {
const diff = Math.abs(t.time - midTime);
if (diff < minDiff) {
minDiff = diff;
nearestImg = t.img;
}
}
drawSource = nearestImg;
} else {
drawSource = retakeVid.imgObj && retakeVid.imgObj.complete ? retakeVid.imgObj : null;
}
this.ctx.fillStyle = "#000";
this.ctx.fillRect(0, RULER_HEIGHT + 1, videoWidthPx, this.blockHeight - 2);
if (drawSource) {
const isVid = !!drawSource.videoWidth;
const natW = isVid ? drawSource.videoWidth : drawSource.naturalWidth;
const natH = isVid ? drawSource.videoHeight : drawSource.naturalHeight;
if (natW > 0) {
const imgRatio = natW / natH;
const trackRatio = videoWidthPx / this.blockHeight;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(0, RULER_HEIGHT + 1, videoWidthPx, this.blockHeight - 2);
this.ctx.clip();
if (imgRatio > trackRatio) {
// Video is wider than the track: fill width, letterbox top/bottom
const drawW = videoWidthPx;
const drawH = videoWidthPx / imgRatio;
const drawY = RULER_HEIGHT + (this.blockHeight - drawH) / 2;
this.ctx.drawImage(drawSource, 0, drawY, drawW, drawH);
} else {
// Video is taller/square: fill height and tile left+right at natural AR
const drawH = this.blockHeight;
const drawW = drawH * imgRatio;
const drawX = (videoWidthPx - drawW) / 2;
const drawY = RULER_HEIGHT;
// Draw centered tile
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
// Tile to the left
let leftX = drawX - drawW;
while (leftX + drawW > 0) {
this.ctx.drawImage(drawSource, leftX, drawY, drawW, drawH);
leftX -= drawW;
}
// Tile to the right
let rightX = drawX + drawW;
while (rightX < videoWidthPx) {
this.ctx.drawImage(drawSource, rightX, drawY, drawW, drawH);
rightX += drawW;
}
}
this.ctx.restore();
}
}
}
if (retakeVid._uploading || retakeVid._extractingThumbs) {
this.ctx.save();
this.ctx.fillStyle = "rgba(0, 14, 37, 0.8)";
const upText = retakeVid._extractingThumbs ? "Extracting frames..." : "Uploading base video...";
this.ctx.font = "bold 11px sans-serif";
const upW = this.ctx.measureText(upText).width + 20;
this.ctx.fillRect(10, RULER_HEIGHT + 35, upW, 24);
this.ctx.fillStyle = "#fff";
this.ctx.textBaseline = "middle";
this.ctx.textAlign = "center";
this.ctx.fillText(upText, 10 + upW / 2, RULER_HEIGHT + 47);
this.ctx.restore();
}
} else {
// No video loaded: Render a placeholder box with upload instructions centered on active timeline
this.ctx.fillStyle = "#121212";
this.ctx.fillRect(0, RULER_HEIGHT + 1, width, this.blockHeight - 2);
// In retake mode, center the placeholder across the visible viewport
const activeStart = this.viewport ? this.viewport.scrollLeft : 0;
let activeWidth = this.viewport ? this.viewport.clientWidth : width;
// The right ~9% of the DOM is clipped, so squish the box to visually center it in the unclipped area
activeWidth = activeWidth * 0.91;
this.ctx.strokeStyle = "#555";
this.ctx.lineWidth = 2;
this.ctx.setLineDash([6, 6]);
this.ctx.strokeRect(activeStart + 12, RULER_HEIGHT + 12, Math.max(10, activeWidth - 24), this.blockHeight - 24);
this.ctx.setLineDash([]);
this.ctx.fillStyle = "#888";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.font = "14px sans-serif";
this.ctx.fillText("Drag & Drop or Click to Add a Video", activeStart + activeWidth / 2, RULER_HEIGHT + this.blockHeight / 2);
}
// Only draw the retake region overlay, borders, handles, and label if a video is loaded
if (this.timeline.retakeVideo) {
// Draw the white outline retake region overlay box bounded by retakeStart and retakeLength.
// Tint outside the box (locked/preserved regions) with a dark blue-grey tint overlay (rgba(3, 5, 12, 0.75)).
const retakeStart = this.timeline.retakeStart ?? 0;
const retakeLength = this.timeline.retakeLength ?? totalFrames;
const baseVideoDur = this.timeline.retakeVideo.videoDurationFrames || 0;
const videoWidthPx = totalFrames > 0 ? (baseVideoDur / totalFrames) * width : width;
const rX1 = (retakeStart / totalFrames) * width;
const rX2 = ((retakeStart + retakeLength) / totalFrames) * width;
// Tint preserved left region
if (rX1 > 0) {
this.ctx.fillStyle = "rgba(0, 0, 0, 0.70)";
this.ctx.fillRect(0, RULER_HEIGHT + 1, rX1, this.blockHeight - 2);
}
// Tint preserved right region (only up to videoWidthPx, not the padding zone)
if (rX2 < videoWidthPx) {
this.ctx.fillStyle = "rgba(0, 0, 0, 0.70)";
this.ctx.fillRect(rX2, RULER_HEIGHT + 1, videoWidthPx - rX2, this.blockHeight - 2);
}
// Draw the Retake Overlay Box
const boxW = rX2 - rX1;
// White border
this.ctx.strokeStyle = "#ffffff";
this.ctx.lineWidth = 2.5;
this.ctx.strokeRect(rX1, RULER_HEIGHT + 1, boxW, this.blockHeight - 2);
// Draw handles on the left and right edges
this.ctx.fillStyle = "#ffffff";
this.ctx.beginPath();
this.ctx.roundRect(rX1 - 3, RULER_HEIGHT + this.blockHeight / 2 - 20, 6, 40, 3);
this.ctx.fill();
this.ctx.beginPath();
this.ctx.roundRect(rX2 - 3, RULER_HEIGHT + this.blockHeight / 2 - 20, 6, 40, 3);
this.ctx.fill();
// Draw "RETAKE REGION" centered label inside the retake box
{
const labelPadX = 14;
const labelPadY = 7;
const labelFontSize = 15;
const labelText = "RETAKE REGION";
const labelY = RULER_HEIGHT + this.blockHeight - labelFontSize - labelPadY * 2 - 4;
const labelCenterX = rX1 + boxW / 2;
this.ctx.save();
// Clip to retake region so text/bg never bleeds outside
this.ctx.beginPath();
this.ctx.rect(rX1, RULER_HEIGHT, boxW, this.blockHeight);
this.ctx.clip();
this.ctx.font = `bold ${labelFontSize}px sans-serif`;
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
// Truncate if too narrow
let displayText = labelText;
const maxTextW = Math.max(0, boxW - labelPadX * 2 - 8);
if (this.ctx.measureText(displayText).width > maxTextW) {
while (displayText.length > 0 && this.ctx.measureText(displayText + "…").width > maxTextW) {
displayText = displayText.slice(0, -1);
}
displayText = displayText.length > 0 ? displayText + "…" : "";
}
if (displayText.length > 0) {
const textW = this.ctx.measureText(displayText).width;
const bgW = textW + labelPadX * 2;
const bgH = labelFontSize + labelPadY * 2;
const bgX = labelCenterX - bgW / 2;
const bgY = labelY - bgH / 2;
// Background pill
this.ctx.fillStyle = "rgba(0, 0, 0, 0.62)";
this.ctx.beginPath();
this.ctx.roundRect(bgX, bgY, bgW, bgH, 3);
this.ctx.fill();
// Label text
this.ctx.fillStyle = "#ffffff";
this.ctx.fillText(displayText, labelCenterX, labelY);
}
this.ctx.restore();
}
// Show video info badge / filename (styled exactly like a regular video segment, drawn on top of overlays)
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(0, RULER_HEIGHT, videoWidthPx, this.blockHeight);
this.ctx.clip();
// 1. Draw the "VIDEO" label badge
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(0, RULER_HEIGHT + 1, 42, 16);
this.ctx.fillStyle = "#fff";
this.ctx.font = "bold 10px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText("VIDEO", 21, RULER_HEIGHT + 9);
// 2. Draw the filename badge
if (this.node.properties.showFilenames && videoWidthPx > 46) {
let rawPath = retakeVid.imageFile || retakeVid.fileName || "";
let fname = rawPath.split(/[/\\]/).pop() || "";
this.ctx.font = "9px sans-serif";
this.ctx.textAlign = "left";
this.ctx.textBaseline = "middle";
const maxFileTextW = videoWidthPx - 42 - 10;
if (this.ctx.measureText(fname).width > maxFileTextW) {
while (fname.length > 0 && this.ctx.measureText(fname + "…").width > maxFileTextW) {
fname = fname.slice(0, -1);
}
fname += "…";
}
const textW = this.ctx.measureText(fname).width;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.50)";
this.ctx.fillRect(43, RULER_HEIGHT + 1, textW + 8, 16);
this.ctx.fillStyle = "#fff";
this.ctx.fillText(fname, 47, RULER_HEIGHT + 9);
}
this.ctx.restore();
}
} else {
// --- Draw Image/Text Segments ---
for (let i = 0; i < sortedSegments.length; i++) {
const seg = sortedSegments[i];
const rawStartX = (seg.start / totalFrames) * width;
const rawEndX = ((seg.start + seg.length) / totalFrames) * width;
const startX = Math.floor(rawStartX);
const pxWidth = Math.max(1, Math.floor(rawEndX) - startX);
const isSelected = this.selectedSegmentIds.includes(seg.id);
const originalSeg = this.timeline.segments.find(s => s.id === seg.id);
const imgObj = originalSeg ? originalSeg.imgObj : seg.imgObj;
const videoEl = originalSeg ? originalSeg.videoEl : seg.videoEl;
const isPlayheadOverSeg = (this.currentFrame >= seg.start && this.currentFrame < seg.start + seg.length);
const isScrubbingThis = this._isDragging && (this._dragTargetId === seg.id || this._dragTargetIdRight === seg.id);
const isLiveActive = this.isPlaying && isPlayheadOverSeg;
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);
}
let drawSource = null;
if (isLiveActive && videoEl && videoEl.readyState >= 2) {
drawSource = videoEl;
} else {
if (seg.type === "video" && seg.thumbnails && seg.thumbnails.length > 0) {
const targetTime = seg._scrubTargetSec !== undefined
? seg._scrubTargetSec
: (isPlayheadOverSeg ? (this.currentFrame - seg.start + seg.trimStart) / this.getFrameRate() : seg.trimStart / this.getFrameRate());
let nearestImg = seg.thumbnails[0].img;
let minDiff = Infinity;
for (const t of seg.thumbnails) {
const diff = Math.abs(t.time - targetTime);
if (diff < minDiff) {
minDiff = diff;
nearestImg = t.img;
}
}
drawSource = nearestImg;
} else {
drawSource = imgObj && imgObj.complete ? imgObj : null;
}
}
if (drawSource && seg.type !== "ghost") {
const isVid = !!drawSource.videoWidth;
const natW = isVid ? drawSource.videoWidth : drawSource.naturalWidth;
const natH = isVid ? drawSource.videoHeight : drawSource.naturalHeight;
if (natW > 0) {
const imgRatio = natW / natH;
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(drawSource, drawX, drawY, drawW, drawH);
} else {
// Fits height, horizontal letterboxing (black bars left/right)
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
// Tile left
let leftX = drawX - drawW;
while (leftX + drawW > startX) {
this.ctx.drawImage(drawSource, leftX, drawY, drawW, drawH);
leftX -= drawW;
}
// Tile right
let rightX = drawX + drawW;
while (rightX < startX + pxWidth) {
this.ctx.drawImage(drawSource, rightX, drawY, drawW, drawH);
rightX += drawW;
}
}
this.ctx.restore();
}
}
if ((seg.type === "video" || drawSource) && seg.type !== "ghost") {
if (seg.type === "video" && pxWidth > 0) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(startX, RULER_HEIGHT + 1, 42, 16);
this.ctx.fillStyle = "#fff";
this.ctx.font = "bold 10px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText("VIDEO", startX + 21, RULER_HEIGHT + 9);
this.ctx.restore();
// Uploading / Loading indicator badge (bottom-left corner)
if ((seg._uploading || seg._extractingThumbs) && pxWidth > 60) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
this.ctx.font = "bold 9px sans-serif";
const upText = seg._extractingThumbs ? "Loading..." : "Uploading...";
const upW = this.ctx.measureText(upText).width + 10;
this.ctx.fillStyle = "rgba(0, 14, 37, 0.7)";
this.ctx.fillRect(startX + 1, RULER_HEIGHT + this.blockHeight - 17, upW, 16);
this.ctx.fillStyle = "#fff";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText(upText, startX + 1 + upW / 2, RULER_HEIGHT + this.blockHeight - 9);
this.ctx.restore();
}
// Filename next to VIDEO tag
if (this.node.properties.showFilenames && pxWidth > 46) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
let rawPath = seg.imageFile || "";
let fname = rawPath.split(/[/\\]/).pop() || "";
this.ctx.font = "9px sans-serif";
this.ctx.textAlign = "left";
this.ctx.textBaseline = "middle";
const maxFileTextW = pxWidth - 42 - 10;
if (this.ctx.measureText(fname).width > maxFileTextW) {
while (fname.length > 0 && this.ctx.measureText(fname + "…").width > maxFileTextW) {
fname = fname.slice(0, -1);
}
fname += "…";
}
const textW = this.ctx.measureText(fname).width;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.50)";
this.ctx.fillRect(startX + 43, RULER_HEIGHT + 1, textW + 8, 16);
this.ctx.fillStyle = "#fff";
this.ctx.fillText(fname, startX + 47, RULER_HEIGHT + 9);
this.ctx.restore();
}
} else if (seg.type === "image" && pxWidth > 0) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(startX, RULER_HEIGHT + 1, 42, 16);
this.ctx.fillStyle = "#fff";
this.ctx.font = "bold 10px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText("IMAGE", startX + 21, RULER_HEIGHT + 9);
this.ctx.restore();
// Filename next to IMAGE tag
if (this.node.properties.showFilenames && pxWidth > 46) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
let rawPath = seg.imageFile || "";
let fname = rawPath.split(/[/\\]/).pop() || "";
this.ctx.font = "9px sans-serif";
this.ctx.textAlign = "left";
this.ctx.textBaseline = "middle";
const maxFileTextW = pxWidth - 42 - 10;
if (this.ctx.measureText(fname).width > maxFileTextW) {
while (fname.length > 0 && this.ctx.measureText(fname + "…").width > maxFileTextW) {
fname = fname.slice(0, -1);
}
fname += "…";
}
const textW = this.ctx.measureText(fname).width;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.50)";
this.ctx.fillRect(startX + 43, RULER_HEIGHT + 1, textW + 8, 16);
this.ctx.fillStyle = "#fff";
this.ctx.fillText(fname, startX + 47, RULER_HEIGHT + 9);
this.ctx.restore();
}
}
if (seg.type === "image" && seg.isEndFrame && pxWidth > 0) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, RULER_HEIGHT, pxWidth, this.blockHeight);
this.ctx.clip();
this.ctx.font = "bold 9px sans-serif";
const badgeText = "END FRAME";
const badgeTextW = this.ctx.measureText(badgeText).width;
const badgeW = badgeTextW + 10;
const badgeH = 16;
const badgeX = startX + pxWidth - badgeW;
const badgeY = RULER_HEIGHT + 1;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(badgeX, badgeY, badgeW, badgeH);
this.ctx.fillStyle = "#fff";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText(badgeText, badgeX + badgeW / 2, badgeY + badgeH / 2);
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) {
const outlineColor = "#fff";
this.ctx.strokeStyle = outlineColor;
this.ctx.lineWidth = 2;
this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2);
if (!this.isMultiSelectActive()) {
this.ctx.fillStyle = outlineColor;
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 Motion Segments ---
for (let i = 0; i < sortedMotionSegments.length; i++) {
const seg = sortedMotionSegments[i];
const startX = Math.floor((seg.start / totalFrames) * width);
const rawEndX = ((seg.start + seg.length) / totalFrames) * width;
const pxWidth = Math.max(1, Math.floor(rawEndX) - startX);
const isSelected = this.selectedSegmentIds.includes(seg.id);
const trackY = RULER_HEIGHT + this.blockHeight + this.audioTrackHeight;
if ((this._isDragging && this.selectionType === "motion" && 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.motionTrackHeight);
this.ctx.strokeStyle = "#555";
this.ctx.lineWidth = 2;
this.ctx.setLineDash([5, 5]);
this.ctx.strokeRect(startX, trackY, pxWidth, this.motionTrackHeight);
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 Motion", startX + pxWidth / 2, trackY + this.motionTrackHeight / 2);
} else {
this.ctx.fillStyle = "#000";
this.ctx.fillRect(startX, trackY + 1, pxWidth, this.motionTrackHeight - 2);
const originalSeg = this.timeline.motionSegments.find(s => s.id === seg.id);
const imgObj = originalSeg ? originalSeg.imgObj : seg.imgObj;
const videoEl = originalSeg ? originalSeg.videoEl : seg.videoEl;
const isPlayheadOverSeg = (this.currentFrame >= seg.start && this.currentFrame < seg.start + seg.length);
const isScrubbingThis = this._isDragging && (this._dragTargetId === seg.id || this._dragTargetIdRight === seg.id);
const isLiveActive = this.isPlaying && isPlayheadOverSeg;
let drawSource = null;
if (isLiveActive && videoEl && videoEl.readyState >= 2) {
drawSource = videoEl;
} else {
if (seg.type === "motion_video" && seg.thumbnails && seg.thumbnails.length > 0) {
const targetTime = seg._scrubTargetSec !== undefined
? seg._scrubTargetSec
: (isPlayheadOverSeg ? (this.currentFrame - seg.start + seg.trimStart) / this.getFrameRate() : seg.trimStart / this.getFrameRate());
let nearestImg = seg.thumbnails[0].img;
let minDiff = Infinity;
for (const t of seg.thumbnails) {
const diff = Math.abs(t.time - targetTime);
if (diff < minDiff) {
minDiff = diff;
nearestImg = t.img;
}
}
drawSource = nearestImg;
} else {
drawSource = imgObj && imgObj.complete ? imgObj : null;
}
}
if (drawSource && seg.type !== "ghost") {
const natW = drawSource.videoWidth || drawSource.naturalWidth;
const natH = drawSource.videoHeight || drawSource.naturalHeight;
if (natW > 0) {
const imgRatio = natW / natH;
const boxRatio = pxWidth / this.motionTrackHeight;
let drawW, drawH, drawX, drawY;
if (imgRatio > boxRatio) {
drawW = pxWidth; drawH = pxWidth / imgRatio;
drawX = startX; drawY = trackY + (this.motionTrackHeight - drawH) / 2;
} else {
drawH = this.motionTrackHeight; drawW = this.motionTrackHeight * imgRatio;
drawY = trackY; drawX = startX + (pxWidth - drawW) / 2;
}
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, trackY + 1, pxWidth, this.motionTrackHeight - 2);
this.ctx.clip();
if (imgRatio > boxRatio) {
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
} else {
this.ctx.drawImage(drawSource, drawX, drawY, drawW, drawH);
let leftX = drawX - drawW;
while (leftX + drawW > startX) {
this.ctx.drawImage(drawSource, leftX, drawY, drawW, drawH);
leftX -= drawW;
}
let rightX = drawX + drawW;
while (rightX < startX + pxWidth) {
this.ctx.drawImage(drawSource, rightX, drawY, drawW, drawH);
rightX += drawW;
}
}
this.ctx.restore();
}
}
if (pxWidth > 0 && seg.type !== "ghost") {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, trackY, pxWidth, this.motionTrackHeight);
this.ctx.clip();
this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)";
this.ctx.fillRect(startX, trackY + 1, 75, 16);
this.ctx.fillStyle = "#fff";
this.ctx.font = "bold 10px sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText("IC-LoRA Video", startX + 37, trackY + 9);
this.ctx.restore();
// Uploading / Loading indicator badge (bottom-left corner)
if ((seg._uploading || seg._extractingThumbs) && pxWidth > 60) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, trackY, pxWidth, this.motionTrackHeight);
this.ctx.clip();
this.ctx.font = "bold 9px sans-serif";
const upText = seg._extractingThumbs ? "Loading..." : "Uploading...";
const upW = this.ctx.measureText(upText).width + 10;
this.ctx.fillStyle = "rgba(0, 14, 37, 0.7)";
this.ctx.fillRect(startX + 1, trackY + this.motionTrackHeight - 17, upW, 16);
this.ctx.fillStyle = "#fff";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
this.ctx.fillText(upText, startX + 1 + upW / 2, trackY + this.motionTrackHeight - 9);
this.ctx.restore();
}
// Filename next to IC-LoRA Video tag
if (this.node.properties.showFilenames && pxWidth > 80) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(startX, trackY, pxWidth, this.motionTrackHeight);
this.ctx.clip();
let rawPath = seg.videoFile || "";
let fname = rawPath.split(/[/\\]/).pop() || "";
this.ctx.font = "9px sans-serif";
this.ctx.textAlign = "left";
this.ctx.textBaseline = "middle";
const maxFileTextW = pxWidth - 75 - 10;
if (this.ctx.measureText(fname).width > maxFileTextW) {
while (fname.length > 0 && this.ctx.measureText(fname + "…").width > maxFileTextW) {
fname = fname.slice(0, -1);
}
fname += "…";
}
const textW = this.ctx.measureText(fname).width;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.50)";
this.ctx.fillRect(startX + 76, trackY + 1, textW + 8, 16);
this.ctx.fillStyle = "#fff";
this.ctx.fillText(fname, startX + 80, trackY + 9);
this.ctx.restore();
}
}
// --- Global Prompt subtitle overlay ---
const globalPromptStr = this.getGlobalPrompt();
if (globalPromptStr && seg.type !== "ghost" && pxWidth > 24) {
const overlayH = Math.round(this.motionTrackHeight * 0.25);
const overlayY = trackY + this.motionTrackHeight - 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 = globalPromptStr;
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();
}
if (isSelected) {
this.ctx.strokeStyle = "#fff";
this.ctx.lineWidth = 2;
this.ctx.strokeRect(startX, trackY + 1, pxWidth, this.motionTrackHeight - 2);
if (!this.isMultiSelectActive()) {
this.ctx.fillStyle = "#fff";
this.ctx.beginPath();
this.ctx.roundRect(startX, trackY + this.motionTrackHeight / 2 - 12, 4, 24, 2);
this.ctx.fill();
this.ctx.beginPath();
this.ctx.roundRect(startX + pxWidth - 4, trackY + this.motionTrackHeight / 2 - 12, 4, 24, 2);
this.ctx.fill();
}
} else {
this.ctx.strokeStyle = "#000";
this.ctx.lineWidth = 1.5;
this.ctx.strokeRect(startX, trackY + 1, pxWidth, this.motionTrackHeight - 2);
}
}
this.ctx.globalAlpha = 1.0;
}
// --- Draw Audio Segments ---
for (let i = 0; i < sortedAudioSegments.length; i++) {
const seg = sortedAudioSegments[i];
const rawStartX = (seg.start / totalFrames) * width;
const rawEndX = ((seg.start + seg.length) / totalFrames) * width;
const startX = Math.floor(rawStartX);
const pxWidth = Math.max(1, Math.floor(rawEndX) - startX);
const isSelected = this.selectedSegmentIds.includes(seg.id);
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 {
const showHandles = !this.isMultiSelectActive();
const outlineColor = isSelected ? "#fff" : null;
this.drawAudioSegmentVisuals(this.ctx, seg, isSelected, trackY, this.audioTrackHeight, startX, pxWidth, outlineColor, showHandles);
}
this.ctx.globalAlpha = 1.0;
}
// --- Dim Disabled Tracks ---
this.ctx.fillStyle = "rgba(0, 0, 0, 0.65)";
if (!this.mainTrackEnabled) {
this.ctx.fillRect(0, RULER_HEIGHT, width, this.blockHeight);
}
if (!this.audioTrackEnabled) {
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, this.audioTrackHeight);
}
if (!this.motionTrackEnabled) {
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight + this.audioTrackHeight, width, this.motionTrackHeight);
}
}
// --- 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 = "#111";
this.ctx.fillRect(0, RULER_HEIGHT - 1, width, 1);
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight - 1, width, 1);
this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight + this.audioTrackHeight - 1, width, 1);
// Draw gap "+" buttons
if (!this._isDragging && !this.retakeMode) {
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 ---
// Skip in retake mode — the retake region has its own overlay and the
// start/end frame widgets are locked, so this overlay would be misleading.
if (!this.retakeMode) {
const startFrames = this.getStartFrames();
const durationFrames = this.getDurationFrames();
const outputFrames = startFrames + durationFrames;
if (startFrames > 0) {
const startX = (startFrames / totalFrames) * width;
this.ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
this.ctx.fillRect(0, RULER_HEIGHT, startX, this.blockHeight + this.motionTrackHeight + this.audioTrackHeight);
this.ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
this.ctx.fillRect(0, 0, startX, RULER_HEIGHT);
}
if (outputFrames < totalFrames) {
const cutoffX = (outputFrames / totalFrames) * width;
// Semi-transparent black overlay on both tracks
this.ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
this.ctx.fillRect(cutoffX, RULER_HEIGHT, width - cutoffX, this.blockHeight + this.motionTrackHeight + this.audioTrackHeight);
// Subtle tinted ruler overlay
this.ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
this.ctx.fillRect(cutoffX, 0, width - cutoffX, RULER_HEIGHT);
}
}
// --- 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.motionTrackHeight + 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 visibleBottom = Math.min(this.canvasHeight, this.viewport.scrollTop + this.viewport.clientHeight);
const hBarY = visibleBottom - hBarH - 3; // 3px from the visible 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();
// --- Draw Selection Box Overlay ---
if (this._isSelectingBox && this._selectBoxStart && this._selectBoxCurrent) {
const sx = this._selectBoxStart.x;
const sy = this._selectBoxStart.y;
const cx = this._selectBoxCurrent.x;
const cy = this._selectBoxCurrent.y;
const left = Math.min(sx, cx);
const top = Math.min(sy, cy);
const rectWidth = Math.abs(cx - sx);
const rectHeight = Math.abs(cy - sy);
this.ctx.save();
this.ctx.fillStyle = "rgba(59, 130, 246, 0.2)";
this.ctx.fillRect(left, top, rectWidth, rectHeight);
this.ctx.strokeStyle = "rgba(29, 78, 216, 0.9)";
this.ctx.lineWidth = 1.5;
this.ctx.setLineDash([4, 4]);
this.ctx.strokeRect(left, top, rectWidth, rectHeight);
this.ctx.setLineDash([]);
this.ctx.restore();
}
this.updatePlayerUI();
}
drawAudioSegmentVisuals(ctx, seg, isSelected, yOffset, trackHeight, startX, pxWidth, outlineColor = null, showHandles = true) {
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);
}
}
}
const strokeColor = outlineColor || (isSelected ? "#4fff8f" : "#000");
ctx.strokeStyle = strokeColor;
ctx.lineWidth = isSelected || outlineColor ? 2 : 1.5;
ctx.strokeRect(startX, yOffset + 2, pxWidth, trackHeight - 3);
if ((isSelected || outlineColor) && showHandles) {
ctx.fillStyle = strokeColor;
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();
// Show Uploading or Decoding badge in bottom-left if applicable
if ((seg._uploading || seg._decoding) && pxWidth > 60) {
ctx.save();
ctx.beginPath();
ctx.rect(startX, yOffset + 2, pxWidth, trackHeight - 3);
ctx.clip();
ctx.font = "bold 9px sans-serif";
const upText = seg._decoding ? "Decoding..." : "Uploading...";
const upW = ctx.measureText(upText).width + 10;
ctx.fillStyle = "rgba(0, 14, 37, 0.7)";
ctx.fillRect(startX + 1, yOffset + trackHeight - 17, upW, 14);
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(upText, startX + 1 + upW / 2, yOffset + trackHeight - 10);
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 trackType = this.getTrackFromY(mouseY);
const trackSegments = this.getSegmentArray(trackType);
if (trackSegments.length === 0) return null;
// Helper to check if a segment (or its sibling video/audio counterpart) is uploading/decoding
const isSegmentProcessing = (s) => {
if (!s) return false;
if (s._uploading || s._decoding) return true;
const isVid = s.id?.endsWith("_v");
const isAud = s.id?.endsWith("_a");
if (isVid || isAud) {
const siblingId = isVid ? s.id.slice(0, -2) + "_a" : s.id.slice(0, -2) + "_v";
const siblingArray = isVid ? this.timeline.audioSegments : this.timeline.segments;
const sibling = siblingArray.find(x => x.id === siblingId);
if (sibling && (sibling._uploading || sibling._decoding)) {
return true;
}
}
return false;
};
// 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) {
if (!isSegmentProcessing(seg)) {
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) {
if (!isSegmentProcessing(seg)) {
return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType };
}
} else if (dx > HANDLE_CORE) {
if (!isSegmentProcessing(nextSeg)) {
return { type: "edge", index: nextSeg.originalIndex, dir: "left", track: trackType };
}
} else {
if (!isSegmentProcessing(seg) && !isSegmentProcessing(nextSeg)) {
return { type: "joint", leftIndex: seg.originalIndex, rightIndex: nextSeg.originalIndex, track: trackType };
}
}
}
} else {
if (Math.abs(mouseX - endX) <= HANDLE_HIT_PX) {
if (!isSegmentProcessing(seg)) {
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 === 2 && this.retakeMode) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.button !== 0) return;
const { x, y } = this.getMousePos(e);
// In retake mode: block box selection — no multi-segment operations allowed
if (e.shiftKey && !this.retakeMode) {
this._isSelectingBox = true;
this._isDragging = true;
this._dragType = "box_select";
this._selectBoxStart = { x, y };
this._selectBoxCurrent = { x, y };
this._selectBoxInitialSelectedIds = (e.ctrlKey || e.metaKey) ? [...this.selectedSegmentIds] : [];
this.selectedSegmentIds = [...this._selectBoxInitialSelectedIds];
this.syncSelectionTypeAndIndex();
this.updateUIFromSelection();
this.render();
return;
}
// Canvas height and width resizing apply in both modes.
const visibleBottom = Math.min(this.canvasHeight, this.viewport.scrollTop + this.viewport.clientHeight);
const isAtBottom = Math.abs(y - visibleBottom) <= 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;
}
// Track height dividers only apply in normal timeline mode.
if (!this.retakeMode) {
const isOverDivider = Math.abs(y - (RULER_HEIGHT + this.blockHeight)) <= 8;
const isOverAudioDivider = Math.abs(y - (RULER_HEIGHT + this.blockHeight + this.audioTrackHeight)) <= 8;
if (isOverDivider) {
this._isDragging = true;
this._dragType = "divider";
this._startBlockHeight = this.blockHeight;
this._startAudioTrackHeight = this.audioTrackHeight;
this._startY = y;
return;
} else if (isOverAudioDivider) {
this._isDragging = true;
this._dragType = "audio_divider";
this._startMotionTrackHeight = this.motionTrackHeight;
this._startAudioTrackHeight = this.audioTrackHeight;
this._startY = y;
return;
}
}
if (this.retakeMode) {
// If no video is loaded on the retake timeline, clicking in the timeline opens the file explorer
if (y >= RULER_HEIGHT && y <= RULER_HEIGHT + this.blockHeight) {
if (!this.timeline.retakeVideo) {
if (this.videoFileInput) {
this.videoFileInput.click();
}
return;
}
}
if (y < RULER_HEIGHT) {
this._isDragging = true;
this._dragType = "playhead";
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
let mouseFrameX = x * (totalFrames / logicalWidth);
mouseFrameX = this.getSnappedPlayhead(mouseFrameX, logicalWidth);
const clampMax = this.timeline.retakeVideo ? (this.timeline.retakeVideo.videoDurationFrames || totalFrames) : totalFrames;
this.currentFrame = clamp(mouseFrameX, 0, clampMax);
// Pause only the RAF playback loop so we can seek the video directly during scrub.
// The video element itself keeps playing; we'll resume the loop on mouseup.
this._retakeScrubWasPlaying = this.isPlaying;
if (this.isPlaying) {
this.isPlaying = false;
this._currentPlayId = null;
}
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = this.currentFrame / this.getFrameRate();
}
this.render();
return;
}
if (y >= RULER_HEIGHT && y <= RULER_HEIGHT + this.blockHeight) {
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
const retakeStart = this.timeline.retakeStart ?? 0;
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
const retakeLength = this.timeline.retakeLength ?? baseVideoDur;
const x1 = (retakeStart / totalFrames) * logicalWidth;
const x2 = ((retakeStart + retakeLength) / totalFrames) * logicalWidth;
const threshold = HANDLE_HIT_PX;
if (this.timeline.retakeVideo && Math.abs(x - x1) <= threshold) {
this._isDragging = true;
this._dragType = "retake_left";
this._dragStartX = x;
this._dragStartRetakeStart = retakeStart;
this._dragStartRetakeLength = retakeLength;
return;
} else if (this.timeline.retakeVideo && Math.abs(x - x2) <= threshold) {
this._isDragging = true;
this._dragType = "retake_right";
this._dragStartX = x;
this._dragStartRetakeStart = retakeStart;
this._dragStartRetakeLength = retakeLength;
return;
} else if (this.timeline.retakeVideo && x > x1 && x < x2) {
this._isDragging = true;
this._dragType = "retake_center";
this._dragStartX = x;
this._dragStartRetakeStart = retakeStart;
this._dragStartRetakeLength = retakeLength;
return;
} else {
this._isDragging = true;
this._dragType = "playhead";
let mouseFrameX = x * (totalFrames / logicalWidth);
mouseFrameX = this.getSnappedPlayhead(mouseFrameX, logicalWidth);
const clampMax = this.timeline.retakeVideo ? (this.timeline.retakeVideo.videoDurationFrames || totalFrames) : totalFrames;
this.currentFrame = clamp(mouseFrameX, 0, clampMax);
// Pause only the RAF playback loop so we can seek the video directly during scrub.
this._retakeScrubWasPlaying = this.isPlaying;
if (this.isPlaying) {
this.isPlaying = false;
this._currentPlayId = null;
}
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = this.currentFrame / this.getFrameRate();
}
this.render();
return;
}
}
// Retake mode consumed the interaction — do NOT fall through to normal timeline
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) {
const currentTrack = gap.track;
const hasCopied = this._copiedSegment || window._ltxCopiedSegmentCS;
const copiedTrack = this._copiedSegmentTrack || window._ltxCopiedSegmentTypeCS;
const isCompatible = hasCopied && this.getCanonicalTrack(copiedTrack) === currentTrack;
if (currentTrack === "motion" && !isCompatible) {
this.promptAddMotionInGap(gap.frameStart, gap.frameEnd);
} else if (currentTrack === "audio" && !isCompatible) {
this.promptAddAudioInGap(gap.frameStart, gap.frameEnd);
} else {
this.showGapMenu(e.clientX, e.clientY, gap);
}
return;
}
}
}
const isCtrl = e.ctrlKey || e.metaKey;
const hit = this.getHitTest(x, y);
if (!hit) {
if (!isCtrl) {
this.selectedSegmentIds = [];
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);
mouseFrameX = this.getSnappedPlayhead(mouseFrameX, logicalWidth);
this.currentFrame = clamp(mouseFrameX, 0, totalFrames);
this._liveScrubPlayhead();
this.render();
if (this.isPlaying) {
this.playAudio();
}
return;
}
const clickedTrack = hit.track;
const targetArray = this.getSegmentArray(clickedTrack);
let clickedId = null;
let clickedIdx = -1;
if (hit.type === "joint") {
clickedIdx = hit.leftIndex;
} else {
clickedIdx = hit.index;
}
if (clickedIdx !== -1 && targetArray[clickedIdx]) {
clickedId = targetArray[clickedIdx].id;
}
if (clickedId) {
if (isCtrl) {
const sibId = clickedId.endsWith("_v") ? clickedId.slice(0, -2) + "_a" : (clickedId.endsWith("_a") ? clickedId.slice(0, -2) + "_v" : null);
const isSelected = this.selectedSegmentIds.includes(clickedId);
if (isSelected) {
this.selectedSegmentIds = this.selectedSegmentIds.filter(id => id !== clickedId && id !== sibId);
} else {
if (!this.selectedSegmentIds.includes(clickedId)) this.selectedSegmentIds.push(clickedId);
if (sibId && !this.selectedSegmentIds.includes(sibId)) this.selectedSegmentIds.push(sibId);
}
if (this.selectedSegmentIds.length > 0) {
this.selectionType = clickedTrack;
this.selectedIndex = clickedIdx;
} else {
this.selectedIndex = -1;
}
this._multiDragClickPendingDeselect = null;
} else {
if (this.selectedSegmentIds.includes(clickedId)) {
this._multiDragClickPendingDeselect = clickedId;
} else {
this.selectedSegmentIds = [clickedId];
const sibId = clickedId.endsWith("_v") ? clickedId.slice(0, -2) + "_a" : (clickedId.endsWith("_a") ? clickedId.slice(0, -2) + "_v" : null);
if (sibId && !this.selectedSegmentIds.includes(sibId)) this.selectedSegmentIds.push(sibId);
this.selectionType = clickedTrack;
this.selectedIndex = clickedIdx;
this._multiDragClickPendingDeselect = null;
}
}
}
this.updateUIFromSelection();
if (this.isMultiSelectActive()) {
this._isDragging = true;
this._dragType = "center";
this._dragStartX = x;
this._isMultiDraggingAndMoved = false;
this._multiDragInitialSegments = {
image: this.timeline.segments.map(s => ({ ...s })),
motion: this.timeline.motionSegments.map(s => ({ ...s })),
audio: this.timeline.audioSegments.map(s => ({ ...s }))
};
this._multiDragPreviewTimelines = null;
} else {
this.selectionType = hit.track;
if (hit.type === "joint") {
this.selectedIndex = hit.leftIndex;
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._dragType = "center";
} else {
if (this.selectedIndex !== hit.index) {
this.selectedIndex = hit.index;
}
this._dragType = hit.dir;
}
this._isDragging = true;
this._previewSegments = null;
this._previewSiblingSegments = null;
this._dragStartX = x;
this._dragInitialTimeline = targetArray.map(s => ({ ...s }));
this._dragInitialSiblingTimeline = this.selectionType === "motion" ? null : (this.selectionType === "audio" ? this.timeline.segments : this.timeline.audioSegments).map(s => ({ ...s }));
if (hit.type !== "joint") {
this._dragTargetId = targetArray[hit.index].id;
}
}
if (this.isPlaying) {
this.pauseAudio();
}
this.render();
}
onMouseMove(e) {
const { x: mouseX, y: mouseY } = this.getMousePos(e);
if (this._isSelectingBox && this._dragType === "box_select") {
this.canvas.style.cursor = "crosshair";
this._selectBoxCurrent = { x: mouseX, y: mouseY };
this.updateSelectionFromBox();
this.render();
return;
}
if (this.retakeMode && !this._isDragging) {
const visibleBottom = Math.min(this.canvasHeight, this.viewport.scrollTop + this.viewport.clientHeight);
const isAtBottom = Math.abs(mouseY - visibleBottom) <= 15;
const viewRect = this.viewport.getBoundingClientRect();
const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20;
if (isAtBottom) {
this.canvas.style.cursor = "ns-resize";
return;
} else if (isAtRightEdge) {
this.canvas.style.cursor = "ew-resize";
return;
}
if (mouseY >= RULER_HEIGHT && mouseY <= RULER_HEIGHT + this.blockHeight) {
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
const retakeStart = this.timeline.retakeStart ?? 0;
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
const retakeLength = this.timeline.retakeLength ?? baseVideoDur;
const x1 = (retakeStart / totalFrames) * logicalWidth;
const x2 = ((retakeStart + retakeLength) / totalFrames) * logicalWidth;
const threshold = HANDLE_HIT_PX;
if (Math.abs(mouseX - x1) <= threshold || Math.abs(mouseX - x2) <= threshold) {
this.canvas.style.cursor = "ew-resize";
} else if (mouseX > x1 && mouseX < x2) {
this.canvas.style.cursor = "move";
} else {
this.canvas.style.cursor = "default";
}
} else if (mouseY < RULER_HEIGHT) {
this.canvas.style.cursor = "ew-resize";
} else {
this.canvas.style.cursor = "default";
}
return;
}
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)) <= 8;
const isOverAudioDivider = Math.abs(mouseY - (RULER_HEIGHT + this.blockHeight + this.audioTrackHeight)) <= 8;
const visibleBottom = Math.min(this.canvasHeight, this.viewport.scrollTop + this.viewport.clientHeight);
const isAtBottom = Math.abs(mouseY - visibleBottom) <= 15;
const viewRect = this.viewport.getBoundingClientRect();
const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20;
const hit = this.getHitTest(mouseX, mouseY);
if (isOverDivider || isOverAudioDivider || 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.retakeMode && this._isDragging) {
const totalFrames = this.getVisualDurationFrames();
const logicalWidth = this.canvas.offsetWidth;
const deltaX = mouseX - this._dragStartX;
const deltaFrames = Math.round(deltaX * (totalFrames / logicalWidth));
const frameRate = this.getFrameRate();
// Handle playhead drag in retakeMode — the RAF loop is paused, so seek directly
if (this._dragType === "playhead") {
this.canvas.style.cursor = "ew-resize";
let mouseFrameX = mouseX * (totalFrames / logicalWidth);
mouseFrameX = this.getSnappedPlayhead(mouseFrameX, logicalWidth);
const clampMax = this.timeline.retakeVideo ? (this.timeline.retakeVideo.videoDurationFrames || totalFrames) : totalFrames;
this.currentFrame = clamp(mouseFrameX, 0, clampMax);
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = this.currentFrame / frameRate;
}
this.render();
return;
}
if (this._dragType === "retake_left") {
this.canvas.style.cursor = "ew-resize";
let newStart = this._dragStartRetakeStart + deltaFrames;
let newLength = this._dragStartRetakeLength - deltaFrames;
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
const candidates = [0, this.currentFrame, baseVideoDur];
let bestStart = newStart;
let minDiff = thresholdFrames;
for (const c of candidates) {
const diff = Math.abs(newStart - c);
if (diff < minDiff) {
minDiff = diff;
bestStart = c;
}
}
if (bestStart !== newStart) {
newStart = bestStart;
newLength = this._dragStartRetakeStart + this._dragStartRetakeLength - newStart;
}
}
if (newStart < 0) {
newStart = 0;
newLength = this._dragStartRetakeStart + this._dragStartRetakeLength;
}
if (newLength < MIN_SEGMENT_LENGTH) {
newLength = MIN_SEGMENT_LENGTH;
newStart = this._dragStartRetakeStart + this._dragStartRetakeLength - MIN_SEGMENT_LENGTH;
}
this.timeline.retakeStart = newStart;
this.timeline.retakeLength = newLength;
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = newStart / frameRate;
}
this.render();
this.updateUIFromSelection();
return;
}
if (this._dragType === "retake_right") {
this.canvas.style.cursor = "ew-resize";
let newLength = this._dragStartRetakeLength + deltaFrames;
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
let newEnd = this._dragStartRetakeStart + newLength;
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const candidates = [0, this.currentFrame, baseVideoDur];
let bestEnd = newEnd;
let minDiff = thresholdFrames;
for (const c of candidates) {
const diff = Math.abs(newEnd - c);
if (diff < minDiff) {
minDiff = diff;
bestEnd = c;
}
}
if (bestEnd !== newEnd) {
newEnd = bestEnd;
newLength = newEnd - this._dragStartRetakeStart;
}
}
if (this._dragStartRetakeStart + newLength > baseVideoDur) {
newLength = baseVideoDur - this._dragStartRetakeStart;
}
if (newLength < MIN_SEGMENT_LENGTH) {
newLength = MIN_SEGMENT_LENGTH;
}
this.timeline.retakeLength = newLength;
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = (this.timeline.retakeStart + newLength) / frameRate;
}
this.render();
this.updateUIFromSelection();
return;
}
if (this._dragType === "retake_center") {
this.canvas.style.cursor = "grabbing";
let newStart = this._dragStartRetakeStart + deltaFrames;
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
const candidates = [0, this.currentFrame, baseVideoDur];
let bestStart = newStart;
let minDiff = thresholdFrames;
for (const c of candidates) {
const diffLeft = Math.abs(newStart - c);
if (diffLeft < minDiff) {
minDiff = diffLeft;
bestStart = c;
}
const diffRight = Math.abs((newStart + this._dragStartRetakeLength) - c);
if (diffRight < minDiff) {
minDiff = diffRight;
bestStart = c - this._dragStartRetakeLength;
}
}
newStart = bestStart;
}
if (newStart < 0) {
newStart = 0;
}
const baseVideoDur = this.timeline.retakeVideo?.videoDurationFrames ?? totalFrames;
if (newStart + this._dragStartRetakeLength > baseVideoDur) {
newStart = baseVideoDur - this._dragStartRetakeLength;
}
this.timeline.retakeStart = newStart;
if (this.timeline.retakeVideo && this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = newStart / frameRate;
}
this.render();
this.updateUIFromSelection();
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.updateSidebarHeights();
this.render();
return;
}
if (this._dragType === "audio_divider") {
this.canvas.style.cursor = "ns-resize";
const deltaY = mouseY - this._startY;
const minMotionH = 50;
const minAudioH = 50;
// Divider moves down: audio gets bigger, motion gets smaller
let newAudioTrackHeight = this._startAudioTrackHeight + deltaY;
let newMotionTrackHeight = this._startMotionTrackHeight - deltaY;
if (newAudioTrackHeight < minAudioH) {
newAudioTrackHeight = minAudioH;
newMotionTrackHeight = this._startAudioTrackHeight + this._startMotionTrackHeight - minAudioH;
}
if (newMotionTrackHeight < minMotionH) {
newMotionTrackHeight = minMotionH;
newAudioTrackHeight = this._startAudioTrackHeight + this._startMotionTrackHeight - minMotionH;
}
this.motionTrackHeight = newMotionTrackHeight;
this.audioTrackHeight = newAudioTrackHeight;
this.updateSidebarHeights();
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.motionTrackHeight + this.audioTrackHeight;
this.canvas.style.height = `${this.canvasHeight}px`;
this.resizeCanvas(this.canvas.offsetWidth);
this.updateSidebarHeights();
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);
mouseFrameX = this.getSnappedPlayhead(mouseFrameX, logicalWidth);
this.currentFrame = clamp(mouseFrameX, 0, totalFrames);
this._liveScrubPlayhead();
this.render();
if (this.isPlaying) {
this.playAudio(); // Scrub (restart from new position)
}
return;
}
if (this._multiDragInitialSegments) {
this.canvas.style.cursor = "grabbing";
this._isMultiDraggingAndMoved = true;
const logicalWidth = this.canvas.offsetWidth;
const totalFrames = this.getVisualDurationFrames();
const durationFrames = totalFrames;
let dragDelta = Math.round((mouseX - this._dragStartX) * (totalFrames / logicalWidth));
const selectedIds = this.selectedSegmentIds;
// Group Blocking Physics Calculation
let maxLeftShift = Infinity;
let maxRightShift = Infinity;
for (const track of ["image", "motion", "audio"]) {
const allTrackSegs = this._multiDragInitialSegments[track];
if (!allTrackSegs) continue;
const selectedOnTrack = allTrackSegs.filter(s => selectedIds.includes(s.id));
const nonSelectedOnTrack = allTrackSegs.filter(s => !selectedIds.includes(s.id));
if (selectedOnTrack.length === 0) continue;
for (const S of selectedOnTrack) {
// Find closest non-selected segment to the left on the same track
let closestLeftEnd = 0;
for (const L of nonSelectedOnTrack) {
if (L.start + L.length <= S.start) {
closestLeftEnd = Math.max(closestLeftEnd, L.start + L.length);
}
}
const spaceLeft = S.start - closestLeftEnd;
maxLeftShift = Math.min(maxLeftShift, spaceLeft);
// Find closest non-selected segment to the right on the same track
let closestRightStart = durationFrames;
for (const R of nonSelectedOnTrack) {
if (R.start >= S.start + S.length) {
closestRightStart = Math.min(closestRightStart, R.start);
}
}
const spaceRight = closestRightStart - (S.start + S.length);
maxRightShift = Math.min(maxRightShift, spaceRight);
}
}
// Clamp drag delta
let clampedDragDelta = clamp(dragDelta, -maxLeftShift, maxRightShift);
// Apply snapping if active
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
let bestAdjustment = null;
let minDiff = thresholdFrames;
// Collect snap candidates
const snapCandidates = [0, this.getDurationFrames(), this.getStartFrames(), this.currentFrame];
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
for (const track of ["image", "motion", "audio"]) {
const allTrackSegs = this._multiDragInitialSegments[track];
if (!allTrackSegs) continue;
const nonSelectedOnTrack = allTrackSegs.filter(s => !selectedIds.includes(s.id));
for (const L of nonSelectedOnTrack) {
snapCandidates.push(L.start);
snapCandidates.push(L.start + L.length);
}
}
// Test all selected segments against candidates
for (const track of ["image", "motion", "audio"]) {
const allTrackSegs = this._multiDragInitialSegments[track];
if (!allTrackSegs) continue;
const selectedOnTrack = allTrackSegs.filter(s => selectedIds.includes(s.id));
for (const S of selectedOnTrack) {
const targetStart = S.start + clampedDragDelta;
const targetEnd = S.start + S.length + clampedDragDelta;
for (const cand of snapCandidates) {
// Check start edge
const diffStart = cand - targetStart;
if (Math.abs(diffStart) < minDiff) {
minDiff = Math.abs(diffStart);
bestAdjustment = diffStart;
}
// Check end edge
const diffEnd = cand - targetEnd;
if (Math.abs(diffEnd) < minDiff) {
minDiff = Math.abs(diffEnd);
bestAdjustment = diffEnd;
}
}
}
}
if (bestAdjustment !== null) {
const adjustedDelta = clampedDragDelta + bestAdjustment;
if (adjustedDelta >= -maxLeftShift && adjustedDelta <= maxRightShift) {
clampedDragDelta = adjustedDelta;
}
}
}
// Compute previews
this._multiDragPreviewTimelines = {
image: this._multiDragInitialSegments.image.map(s => {
if (selectedIds.includes(s.id)) {
return { ...s, start: s.start + clampedDragDelta };
}
return s;
}),
motion: this._multiDragInitialSegments.motion.map(s => {
if (selectedIds.includes(s.id)) {
return { ...s, start: s.start + clampedDragDelta };
}
return s;
}),
audio: this._multiDragInitialSegments.audio.map(s => {
if (selectedIds.includes(s.id)) {
return { ...s, start: s.start + clampedDragDelta };
}
return s;
})
};
// Scrub support for video segments being moved
for (const track of ["image", "motion"]) {
const prevSegs = this._multiDragPreviewTimelines[track];
for (const s of prevSegs) {
if (selectedIds.includes(s.id) && (s.type === "video" || s.type === "motion_video")) {
this._liveScrubVideo(s, "start");
}
}
}
this.render();
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;
let dragDelta = Math.round((mouseX - this._dragStartX) * (totalFrames / logicalWidth));
let t = this._dragInitialTimeline.map(s => ({ ...s }));
// --- 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" || origRight.type === "video") {
// 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);
}
if (this.selectionType === "audio" || origLeft.type === "video") {
// 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 origDur = origLeft.audioDurationFrames || origLeft.videoDurationFrames || origLeft.length;
let availLeftTail = origDur - ((origLeft.trimStart || 0) + origLeft.length);
maxDeltaRight = Math.min(maxDeltaRight, availLeftTail);
}
// Apply snapping to the shared boundary position
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const jointPos = origLeft.start + origLeft.length + dragDelta;
let bestJoint = jointPos;
let minDiff = thresholdFrames;
const snapCandidates = [0, this.getDurationFrames(), this.currentFrame];
snapCandidates.push(this.getStartFrames());
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
const allTracks = [
this.timeline.segments || [],
this.timeline.motionSegments || [],
this.timeline.audioSegments || []
];
const ignoreIds = [String(this._dragTargetId), String(this._dragTargetIdRight)];
for (const track of allTracks) {
for (const seg of track) {
if (ignoreIds.includes(String(seg.id))) continue;
snapCandidates.push(seg.start);
snapCandidates.push(seg.start + seg.length);
}
}
for (const candidate of snapCandidates) {
const diff = Math.abs(jointPos - candidate);
if (diff < minDiff) {
minDiff = diff;
bestJoint = candidate;
}
}
dragDelta = bestJoint - (origLeft.start + origLeft.length);
}
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].type === "video") {
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;
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
const targetEnd = t[targetIdx].start + newLen;
let bestEnd = targetEnd;
let minDiff = thresholdFrames;
const snapCandidates = [0, this.getDurationFrames(), this.currentFrame];
// Add start and end frames of active generation range
snapCandidates.push(this.getStartFrames());
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
const allTracks = [
this.timeline.segments || [],
this.timeline.motionSegments || [],
this.timeline.audioSegments || []
];
const ignoreSegmentIds = [String(this._dragTargetId)];
const isVid = String(this._dragTargetId).endsWith("_v");
const isAud = String(this._dragTargetId).endsWith("_a");
if (isVid || isAud) {
const siblingId = isVid ? String(this._dragTargetId).slice(0, -2) + "_a" : String(this._dragTargetId).slice(0, -2) + "_v";
ignoreSegmentIds.push(siblingId);
}
for (const track of allTracks) {
for (const seg of track) {
if (ignoreSegmentIds.includes(String(seg.id))) continue;
snapCandidates.push(seg.start);
snapCandidates.push(seg.start + seg.length);
}
}
for (const candidate of snapCandidates) {
const diff = Math.abs(targetEnd - candidate);
if (diff < minDiff) {
minDiff = diff;
bestEnd = candidate;
}
}
newLen = bestEnd - t[targetIdx].start;
dragDelta = newLen - t[targetIdx].length;
}
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;
}
// Check sibling track obstacles if linked
const isVid = String(this._dragTargetId).endsWith("_v");
const isAud = String(this._dragTargetId).endsWith("_a");
const siblingId = (isVid || isAud) ? (isVid ? String(this._dragTargetId).slice(0, -2) + "_a" : String(this._dragTargetId).slice(0, -2) + "_v") : null;
if (siblingId && this._dragInitialSiblingTimeline) {
let nextSibSeg = this._dragInitialSiblingTimeline.find(s => s.start >= t[targetIdx].start + t[targetIdx].length && s.id !== siblingId);
if (nextSibSeg) {
let sibMaxPossible = nextSibSeg.start - t[targetIdx].start;
maxPossibleLength = Math.min(maxPossibleLength, sibMaxPossible);
}
}
if (this.selectionType === "audio" || t[targetIdx].type === "video" || t[targetIdx].type === "motion_video") {
const origDur = t[targetIdx].audioDurationFrames || t[targetIdx].videoDurationFrames || t[targetIdx].length;
maxPossibleLength = Math.min(maxPossibleLength, origDur - (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;
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
let bestStart = newStart;
let minDiff = thresholdFrames;
const snapCandidates = [0, this.getDurationFrames(), this.currentFrame];
// Add start and end frames of active generation range
snapCandidates.push(this.getStartFrames());
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
const allTracks = [
this.timeline.segments || [],
this.timeline.motionSegments || [],
this.timeline.audioSegments || []
];
const ignoreSegmentIds = [String(this._dragTargetId)];
const isVid = String(this._dragTargetId).endsWith("_v");
const isAud = String(this._dragTargetId).endsWith("_a");
if (isVid || isAud) {
const siblingId = isVid ? String(this._dragTargetId).slice(0, -2) + "_a" : String(this._dragTargetId).slice(0, -2) + "_v";
ignoreSegmentIds.push(siblingId);
}
for (const track of allTracks) {
for (const seg of track) {
if (ignoreSegmentIds.includes(String(seg.id))) continue;
snapCandidates.push(seg.start);
snapCandidates.push(seg.start + seg.length);
}
}
for (const candidate of snapCandidates) {
const diff = Math.abs(newStart - candidate);
if (diff < minDiff) {
minDiff = diff;
bestStart = candidate;
}
}
newStart = bestStart;
dragDelta = newStart - t[targetIdx].start;
}
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;
}
// Check sibling track obstacles if linked
const isVid = String(this._dragTargetId).endsWith("_v");
const isAud = String(this._dragTargetId).endsWith("_a");
const siblingId = (isVid || isAud) ? (isVid ? String(this._dragTargetId).slice(0, -2) + "_a" : String(this._dragTargetId).slice(0, -2) + "_v") : null;
if (siblingId && this._dragInitialSiblingTimeline) {
let prevSibSeg = this._dragInitialSiblingTimeline.slice().reverse().find(s => s.start + s.length <= t[targetIdx].start && s.id !== siblingId);
if (prevSibSeg) {
let sibMinPossible = prevSibSeg.start + prevSibSeg.length;
minPossibleStart = Math.max(minPossibleStart, sibMinPossible);
}
}
if (this.selectionType === "audio" || t[targetIdx].type === "video" || t[targetIdx].type === "motion_video") {
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].type === "video" || t[targetIdx].type === "motion_video") {
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 = { ...initT[dIdx] };
let D_mouse_start = D.start + dragDelta;
let mouseFrameX = mouseX * (totalFrames / logicalWidth);
if (this.isSnapping) {
const thresholdFrames = (15 / logicalWidth) * totalFrames;
let bestStart = D_mouse_start;
let minDiff = thresholdFrames;
const snapCandidates = [0, this.getDurationFrames(), this.currentFrame];
// Add start and end frames of active generation range
snapCandidates.push(this.getStartFrames());
if (this.endFramesWidget && this.endFramesWidget.value !== undefined) {
snapCandidates.push(parseInt(this.endFramesWidget.value, 10));
}
const allTracks = [
this.timeline.segments || [],
this.timeline.motionSegments || [],
this.timeline.audioSegments || []
];
const ignoreSegmentIds = [String(this._dragTargetId)];
const isVid = String(this._dragTargetId).endsWith("_v");
const isAud = String(this._dragTargetId).endsWith("_a");
if (isVid || isAud) {
const siblingId = isVid ? String(this._dragTargetId).slice(0, -2) + "_a" : String(this._dragTargetId).slice(0, -2) + "_v";
ignoreSegmentIds.push(siblingId);
}
for (const track of allTracks) {
for (const seg of track) {
if (ignoreSegmentIds.includes(String(seg.id))) continue;
snapCandidates.push(seg.start);
snapCandidates.push(seg.start + seg.length);
}
}
for (const candidate of snapCandidates) {
// Check start snap
const diffStart = Math.abs(D_mouse_start - candidate);
if (diffStart < minDiff) {
minDiff = diffStart;
bestStart = candidate;
}
// Check end snap
const diffEnd = Math.abs((D_mouse_start + D.length) - candidate);
if (diffEnd < minDiff) {
minDiff = diffEnd;
bestStart = candidate - D.length;
}
}
const rawStart = D_mouse_start;
D_mouse_start = bestStart;
const snapOffset = D_mouse_start - rawStart;
dragDelta = D_mouse_start - D.start;
mouseFrameX += snapOffset;
}
t = this._applyCenterDragPhysics(initT, D.id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth);
if (this._dragInitialSiblingTimeline) {
let siblingPhysics = null;
if (this._dragTargetId.endsWith("_v") || this._dragTargetId.endsWith("_a")) {
const isVid = this._dragTargetId.endsWith("_v");
const siblingId = isVid ? this._dragTargetId.slice(0, -2) + "_a" : this._dragTargetId.slice(0, -2) + "_v";
siblingPhysics = this._applyCenterDragPhysics(this._dragInitialSiblingTimeline, siblingId, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth);
// Ensure initial sync for the dragged segment so the solver starts from a good state
const activeFinal = t.find(s => s.id === this._dragTargetId);
const siblingFinal = siblingPhysics.find(s => s.id === siblingId);
if (activeFinal && siblingFinal && activeFinal.start !== siblingFinal.start) {
const origStart = D.start;
const activeDelta = Math.abs(activeFinal.start - origStart);
const siblingDelta = Math.abs(siblingFinal.start - origStart);
const finalStart = activeDelta < siblingDelta ? activeFinal.start : siblingFinal.start;
const finalMouseX = finalStart + D.length / 2;
t = this._applyCenterDragPhysics(initT, D.id, finalStart, finalMouseX, durationFrames, totalFrames, logicalWidth, true);
siblingPhysics = this._applyCenterDragPhysics(this._dragInitialSiblingTimeline, siblingId, finalStart, finalMouseX, durationFrames, totalFrames, logicalWidth, true);
}
} else {
siblingPhysics = this._dragInitialSiblingTimeline.map(s => ({ ...s }));
}
// Resolve all secondary pushes to keep linked clips together
this._resolveGlobalPhysics(t, siblingPhysics, durationFrames, initT, this._dragInitialSiblingTimeline);
this._previewSiblingSegments = siblingPhysics;
}
}
}
const targetArray = this.getSegmentArray(this.selectionType);
this._restoreTransientProperties(t, targetArray);
if (this._dragType === "left") {
this._liveScrubVideo(t.find(s => s.id === this._dragTargetId), "start");
} else if (this._dragType === "right") {
this._liveScrubVideo(t.find(s => s.id === this._dragTargetId), "end");
} else if (this._dragType === "joint") {
this._liveScrubVideo(t.find(s => s.id === this._dragTargetId), "end");
this._liveScrubVideo(t.find(s => s.id === this._dragTargetIdRight), "start");
}
const syncSibling = (targetId, activeArray) => {
if (!targetId || this._dragType === "center") return; // Center drag handles physics separately above
const isVid = targetId.endsWith("_v");
const isAud = targetId.endsWith("_a");
if (!isVid && !isAud) return;
const siblingId = isVid ? targetId.slice(0, -2) + "_a" : targetId.slice(0, -2) + "_v";
if (!this._previewSiblingSegments) {
this._previewSiblingSegments = this._dragInitialSiblingTimeline.map(s => ({ ...s }));
}
const sibling = this._previewSiblingSegments.find(s => s.id === siblingId);
const active = activeArray.find(s => s.id === targetId);
if (sibling && active) {
sibling.start = active.start;
sibling.length = active.length;
if (active.trimStart !== undefined) sibling.trimStart = active.trimStart;
}
};
syncSibling(this._dragTargetId, t);
if (this._dragType === "joint") syncSibling(this._dragTargetIdRight, t);
this._previewSegments = t;
if (this._previewSiblingSegments) {
let siblingArray = null;
if (this.selectionType === "audio") siblingArray = this.timeline.segments;
else if (this.selectionType === "image") siblingArray = this.timeline.audioSegments;
if (siblingArray) {
this._restoreTransientProperties(this._previewSiblingSegments, siblingArray);
}
}
this.updateUIFromSelection(); // Live update of trim values
this.render();
}
_applyCenterDragPhysics(initT, D_id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth, forceStart = false) {
let t_copy = initT.map(s => ({ ...s }));
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;
}
}
if (!forceStart) {
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;
}
_resolveGlobalPhysics(activeTimeline, siblingTimeline, durationFrames, activeInitial, siblingInitial) {
if (!siblingTimeline) return;
let changed = true;
let iters = 0;
while (changed && iters < 10) {
changed = false;
iters++;
let syncedActiveIndices = [];
let syncedSiblingIndices = [];
// 1. Sync linked clips
for (let i = 0; i < activeTimeline.length; i++) {
let seg = activeTimeline[i];
if (seg.id.endsWith("_v") || seg.id.endsWith("_a")) {
const isVid = seg.id.endsWith("_v");
const sibId = isVid ? seg.id.slice(0, -2) + "_a" : seg.id.slice(0, -2) + "_v";
let sibIndex = siblingTimeline.findIndex(s => s.id === sibId);
if (sibIndex >= 0) {
let sib = siblingTimeline[sibIndex];
if (sib.start !== seg.start) {
let origStart = seg.start;
if (activeInitial) {
const origSeg = activeInitial.find(s => s.id === seg.id);
if (origSeg) origStart = origSeg.start;
}
let sibOrigStart = sib.start;
if (siblingInitial) {
const origSib = siblingInitial.find(s => s.id === sib.id);
if (origSib) sibOrigStart = origSib.start;
}
const dSeg = Math.abs(seg.start - origStart);
const dSib = Math.abs(sib.start - sibOrigStart);
// The segment that was pushed furthest dictates the new position
const targetStart = dSeg > dSib ? seg.start : sib.start;
if (seg.start !== targetStart) {
seg.start = targetStart;
changed = true;
syncedActiveIndices.push(i);
}
if (sib.start !== targetStart) {
sib.start = targetStart;
changed = true;
syncedSiblingIndices.push(sibIndex);
}
}
}
}
}
// 2. Resolve overlaps on both tracks by pushing outward from epicenters
if (changed) {
const sweepTrack = (track, epicenterIndices) => {
let didChange = false;
for (let epIndex of epicenterIndices) {
// Push elements to the right of the epicenter
for (let i = epIndex + 1; i < track.length; i++) {
let prev = track[i - 1];
let targetStart = prev.start + prev.length;
if (track[i].start < targetStart) {
track[i].start = targetStart;
didChange = true;
}
}
// Push elements to the left of the epicenter
for (let i = epIndex - 1; i >= 0; i--) {
let next = track[i + 1];
let targetStart = next.start - track[i].length;
if (track[i].start > targetStart) {
track[i].start = targetStart;
didChange = true;
}
}
}
// Boundary clamping to ensure nothing falls off the edges
let rightCursor = durationFrames;
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].start + track[i].length > rightCursor) {
let newStart = rightCursor - track[i].length;
if (track[i].start !== newStart) { track[i].start = newStart; didChange = true; }
}
rightCursor = track[i].start;
}
let leftCursor = 0;
for (let i = 0; i < track.length; i++) {
if (track[i].start < leftCursor) {
let newStart = leftCursor;
if (track[i].start !== newStart) { track[i].start = newStart; didChange = true; }
}
leftCursor = track[i].start + track[i].length;
}
return didChange;
};
sweepTrack(activeTimeline, syncedActiveIndices);
sweepTrack(siblingTimeline, syncedSiblingIndices);
}
}
}
_restoreTransientProperties(copiedSegs, originalSegs) {
if (!copiedSegs || !originalSegs) return;
for (let ps of copiedSegs) {
const orig = originalSegs.find(s => s.id === ps.id);
if (orig) {
if (orig._uploading !== undefined) ps._uploading = orig._uploading;
if (orig._decoding !== undefined) ps._decoding = orig._decoding;
if (orig._blobUrl !== undefined) ps._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) ps._audioBuffer = orig._audioBuffer;
if (orig.imgObj !== undefined) ps.imgObj = orig.imgObj;
if (orig.videoEl !== undefined) ps.videoEl = orig.videoEl;
if (orig.thumbnails !== undefined) ps.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) ps._extractingThumbs = orig._extractingThumbs;
}
}
}
onMouseUp(e) {
document.body.style.userSelect = "";
document.body.style.cursor = "";
if (e.button === 2 && this.retakeMode) {
e.preventDefault();
e.stopPropagation();
return;
}
if (this.retakeMode) {
if (this._isDragging) {
const wasPlayheadDrag = this._dragType === "playhead";
const wasPlaying = this._retakeScrubWasPlaying;
this._retakeScrubWasPlaying = false;
if (this.timeline.retakeVideo && this.timeline.retakeVideo._scrubTargetSec !== undefined) {
if (this.timeline.retakeVideo.videoEl) {
this.timeline.retakeVideo.videoEl.currentTime = this.timeline.retakeVideo._scrubTargetSec;
}
delete this.timeline.retakeVideo._scrubTargetSec;
}
this._isDragging = false;
this._dragType = null;
this.canvas.style.cursor = "default";
this.commitChanges();
// If playback was active before the scrub, resume from the new scrub position
if (wasPlayheadDrag && wasPlaying) {
this.playAudio();
} else {
this.render();
}
}
return;
}
// Commit scrub target to actual video element so it's ready for playback
const commitScrub = (segs) => {
if (!segs) return;
for (const seg of segs) {
if (seg._scrubTargetSec !== undefined) {
if (seg.videoEl) seg.videoEl.currentTime = seg._scrubTargetSec;
delete seg._scrubTargetSec;
}
}
};
commitScrub(this.timeline.segments);
commitScrub(this.timeline.motionSegments);
commitScrub(this._previewSegments);
commitScrub(this._previewSiblingSegments);
if (this._multiDragPreviewTimelines) {
commitScrub(this._multiDragPreviewTimelines.image);
commitScrub(this._multiDragPreviewTimelines.motion);
}
if (this._isDragging) {
if (this._dragType === "box_select") {
this._isSelectingBox = false;
this._selectBoxStart = null;
this._selectBoxCurrent = null;
this._selectBoxInitialSelectedIds = null;
this._isDragging = false;
this.canvas.style.cursor = "default";
this.updateUIFromSelection();
this.render();
this.commitChanges();
return;
}
if (this._multiDragPreviewTimelines) {
if (this._multiDragPreviewTimelines.image) {
this.timeline.segments = this._multiDragPreviewTimelines.image.map(ps => {
const orig = this.timeline.segments.find(s => s.id === ps.id);
if (orig) {
if (orig.imgObj) ps.imgObj = orig.imgObj;
if (orig.videoEl) ps.videoEl = orig.videoEl;
if (orig.thumbnails) ps.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) ps._extractingThumbs = orig._extractingThumbs;
if (orig._uploading !== undefined) ps._uploading = orig._uploading;
if (orig._decoding !== undefined) ps._decoding = orig._decoding;
if (orig._blobUrl !== undefined) ps._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) ps._audioBuffer = orig._audioBuffer;
}
return ps;
});
}
if (this._multiDragPreviewTimelines.motion) {
this.timeline.motionSegments = this._multiDragPreviewTimelines.motion.map(ps => {
const orig = this.timeline.motionSegments.find(s => s.id === ps.id);
if (orig) {
if (orig.imgObj) ps.imgObj = orig.imgObj;
if (orig.videoEl) ps.videoEl = orig.videoEl;
if (orig.thumbnails) ps.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) ps._extractingThumbs = orig._extractingThumbs;
if (orig._uploading !== undefined) ps._uploading = orig._uploading;
if (orig._decoding !== undefined) ps._decoding = orig._decoding;
if (orig._blobUrl !== undefined) ps._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) ps._audioBuffer = orig._audioBuffer;
}
return ps;
});
}
if (this._multiDragPreviewTimelines.audio) {
this.timeline.audioSegments = this._multiDragPreviewTimelines.audio.map(ps => {
const orig = this.timeline.audioSegments.find(s => s.id === ps.id);
if (orig) {
if (orig.imgObj) ps.imgObj = orig.imgObj;
if (orig.videoEl) ps.videoEl = orig.videoEl;
if (orig.thumbnails) ps.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) ps._extractingThumbs = orig._extractingThumbs;
if (orig._uploading !== undefined) ps._uploading = orig._uploading;
if (orig._decoding !== undefined) ps._decoding = orig._decoding;
if (orig._blobUrl !== undefined) ps._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) ps._audioBuffer = orig._audioBuffer;
}
return ps;
});
}
this._multiDragPreviewTimelines = null;
} else if (this._previewSegments) {
const targetArray = this.getSegmentArray(this.selectionType);
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) {
if (orig.imgObj) newPs.imgObj = orig.imgObj;
if (orig.videoEl) newPs.videoEl = orig.videoEl;
if (orig.thumbnails) newPs.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) newPs._extractingThumbs = orig._extractingThumbs;
if (orig._uploading !== undefined) newPs._uploading = orig._uploading;
if (orig._decoding !== undefined) newPs._decoding = orig._decoding;
if (orig._blobUrl !== undefined) newPs._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) newPs._audioBuffer = orig._audioBuffer;
}
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 if (this.selectionType === "motion") {
this.timeline.motionSegments = mappedArray;
if (this._dragTargetId) this.selectedIndex = this.timeline.motionSegments.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);
}
}
if (this._previewSiblingSegments) {
let siblingArray = null;
if (this.selectionType === "audio") siblingArray = this.timeline.segments;
else if (this.selectionType === "image") siblingArray = this.timeline.audioSegments;
if (siblingArray) {
const mappedSibling = this._previewSiblingSegments.map(ps => {
const orig = siblingArray.find(s => s.id === ps.id);
let finalStart = ps.resolvedStart !== undefined ? ps.resolvedStart : ps.start;
let newPs = { ...ps, start: finalStart };
if (orig) {
if (orig.imgObj) newPs.imgObj = orig.imgObj;
if (orig.videoEl) newPs.videoEl = orig.videoEl;
if (orig.thumbnails) newPs.thumbnails = orig.thumbnails;
if (orig._extractingThumbs !== undefined) newPs._extractingThumbs = orig._extractingThumbs;
if (orig._uploading !== undefined) newPs._uploading = orig._uploading;
if (orig._decoding !== undefined) newPs._decoding = orig._decoding;
if (orig._blobUrl !== undefined) newPs._blobUrl = orig._blobUrl;
if (orig._audioBuffer !== undefined) newPs._audioBuffer = orig._audioBuffer;
}
delete newPs.resolvedStart;
return newPs;
});
if (this.selectionType === "audio") this.timeline.segments = mappedSibling;
else if (this.selectionType === "image") this.timeline.audioSegments = mappedSibling;
}
}
if (this._multiDragClickPendingDeselect && !this._isMultiDraggingAndMoved) {
const clickedId = this._multiDragClickPendingDeselect;
this.selectedSegmentIds = [clickedId];
const sibId = clickedId.endsWith("_v") ? clickedId.slice(0, -2) + "_a" : (clickedId.endsWith("_a") ? clickedId.slice(0, -2) + "_v" : null);
if (sibId && !this.selectedSegmentIds.includes(sibId)) this.selectedSegmentIds.push(sibId);
let foundIdx = -1;
let foundTrack = "image";
for (const track of ["image", "motion", "audio"]) {
const arr = this.getSegmentArray(track);
const idx = arr.findIndex(s => s.id === clickedId);
if (idx !== -1) {
foundIdx = idx;
foundTrack = track;
break;
}
}
if (foundIdx !== -1) {
this.selectionType = foundTrack;
this.selectedIndex = foundIdx;
}
this.updateUIFromSelection();
}
this._isDragging = false;
this._previewSegments = null;
this._previewSiblingSegments = null;
this._ghostTrack = null;
this._isMultiDraggingAndMoved = false;
this._multiDragClickPendingDeselect = null;
this._multiDragInitialSegments = null;
this._multiDragPreviewTimelines = null;
this.canvas.style.cursor = "default";
this.commitChanges();
}
}
// --- Backend Data Sync ---
// --- Visual Character Reference Slots ---
createCharacterSlots(parent) {
const container = document.createElement("div");
container.className = "prcs-characters-container";
if (!this.timeline.characters) {
this.timeline.characters = [
{ images: [], description: "" },
{ images: [], description: "" },
{ images: [], description: "" }
];
}
this.characterSlots = [];
for (let i = 0; i < 3; i++) {
const slot = document.createElement("div");
slot.className = "prcs-character-slot";
slot.dataset.index = i;
slot.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
slot.classList.add("drag-over");
});
slot.addEventListener("dragleave", (e) => {
e.stopPropagation();
slot.classList.remove("drag-over");
});
slot.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
slot.classList.remove("drag-over");
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
Array.from(e.dataTransfer.files).forEach(f => this.handleCharacterImageUpload(f, i));
}
});
slot.addEventListener("click", (e) => {
if (e.target.closest(".prcs-character-delete") ||
e.target.closest(".prcs-character-validate-btn") ||
e.target.closest(".prcs-character-desc")) return;
const fi = document.createElement("input");
fi.type = "file";
fi.accept = "image/*";
fi.multiple = true;
fi.addEventListener("change", (ev) => {
if (ev.target.files) {
Array.from(ev.target.files).forEach(f => this.handleCharacterImageUpload(f, i));
}
});
fi.click();
});
container.appendChild(slot);
this.characterSlots.push(slot);
}
parent.appendChild(container);
this.charPanelContainer = container;
this.charPanelHeight = 150;
this.updateCharacterSlotsUI();
}
handleCharacterImageUpload(file, idx) {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => {
const imgObj = new Image();
imgObj.onload = async () => {
const maxDim = 1920;
let w = imgObj.width;
let h = imgObj.height;
if (w > maxDim || h > maxDim) {
if (w > h) { h = Math.round((h * maxDim) / w); w = maxDim; }
else { w = Math.round((w * maxDim) / h); h = maxDim; }
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(imgObj, 0, 0, w, h);
// Upload the downscaled reference to the input folder and store only its
// filename. Embedding base64 in the timeline bloats the saved workflow; the
// backend loads the file directly for both Analyze and Ghost/MSR.
let stored = null;
try {
const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.95));
const base = (file.name || "ref").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_");
const upName = `ltxref_${base}_${Date.now()}.jpg`;
const body = new FormData();
body.append("image", new File([blob], upName, { type: "image/jpeg" }));
body.append("subfolder", "whatdreamscost");
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) {
const data = await resp.json();
const sf = data.subfolder || "";
stored = { name: sf ? sf + "/" + data.name : data.name };
}
} catch (err) {
console.error("[LTXDirector] ref upload failed, embedding b64 as fallback:", err);
}
if (!stored) {
stored = { b64: canvas.toDataURL("image/jpeg", 0.95), name: file.name };
}
if (!this.timeline.characters[idx].images) {
this.timeline.characters[idx].images = [];
}
if (this.timeline.characters[idx].images.length >= 2) {
this.timeline.characters[idx].images.shift();
}
this.timeline.characters[idx].images.push(stored);
this.updateCharacterSlotsUI();
this.commitChanges();
};
imgObj.src = e.target.result;
};
reader.readAsDataURL(file);
}
_refImageSrc(imgData) {
if (!imgData) return "";
if (imgData.b64) return imgData.b64; // legacy embedded data / fallback
if (imgData.name) {
const parts = imgData.name.split("/");
const fn = parts.pop();
const sf = parts.join("/");
return api.apiURL(`/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=${encodeURIComponent(sf)}`);
}
return "";
}
async _refImageToB64(imgData) {
if (!imgData) return null;
if (imgData.b64) return imgData.b64; // legacy embedded data
const src = this._refImageSrc(imgData);
if (!src) return null;
try {
const resp = await fetch(src);
const blob = await resp.blob();
return await new Promise((res) => {
const r = new FileReader();
r.onloadend = () => res(r.result);
r.readAsDataURL(blob);
});
} catch (err) {
console.error("[LTXDirector] could not load ref image for analyze:", err);
return null;
}
}
updateCharacterSlotsUI() {
if (!this.characterSlots) return;
if (!this.timeline.characters) {
this.timeline.characters = [
{ images: [], description: "" },
{ images: [], description: "" },
{ images: [], description: "" }
];
}
for (let i = 0; i < 3; i++) {
const slot = this.characterSlots[i];
const data = this.timeline.characters[i] || { images: [], description: "" };
slot.innerHTML = "";
if (data.images && data.images.length > 0) {
const previewsRow = document.createElement("div");
previewsRow.className = "prcs-character-previews-row";
data.images.forEach((imgData, imgIdx) => {
const imgWrapper = document.createElement("div");
imgWrapper.className = "prcs-character-preview-wrapper";
const img = document.createElement("img");
img.className = "prcs-character-preview";
img.src = this._refImageSrc(imgData);
imgWrapper.appendChild(img);
const delBtn = document.createElement("button");
delBtn.className = "prcs-character-delete";
delBtn.innerHTML = ICONS.close;
delBtn.title = "Delete Image";
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.timeline.characters[i] && this.timeline.characters[i].images) {
this.timeline.characters[i].images.splice(imgIdx, 1);
this.updateCharacterSlotsUI();
this.commitChanges();
}
});
imgWrapper.appendChild(delBtn);
previewsRow.appendChild(imgWrapper);
});
const _provider = this.timeline.analyzeProvider || "ollama";
if (_provider !== "off") {
const valBtn = document.createElement("button");
valBtn.className = "prcs-character-validate-btn";
valBtn.textContent = data.description ? "Re-Analyze" : "Analyze";
valBtn.title = "Run multimodal analysis on the reference image(s)";
valBtn.style.left = "50%";
valBtn.style.transform = "translateX(-50%)";
valBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.runGemmaAnalysis(i, valBtn);
});
previewsRow.appendChild(valBtn);
}
slot.appendChild(previewsRow);
const descInput = document.createElement("textarea");
descInput.className = "prcs-character-desc";
descInput.value = data.description || "";
descInput.placeholder = "manual description...";
descInput.addEventListener("input", () => {
this.timeline.characters[i].description = descInput.value;
this.commitChanges();
});
descInput.addEventListener("click", (e) => { e.stopPropagation(); });
slot.appendChild(descInput);
} else {
const label = document.createElement("div");
label.className = "prcs-character-label";
label.textContent = `@char${i + 1}`;
const placeholder = document.createElement("div");
placeholder.className = "prcs-character-placeholder";
placeholder.innerHTML = `${ICONS.upload}
Drop Sheet`;
slot.appendChild(label);
slot.appendChild(placeholder);
}
}
}
async runGemmaAnalysis(idx, btn) {
if (btn.classList.contains("loading")) return;
btn.classList.add("loading");
btn.textContent = "Analyzing...";
let clip_name = "";
try {
const inputs = this.node.inputs || [];
const clipLink = inputs.find(i => i.name === "clip")?.link;
if (clipLink) {
const linkInfo = window.app.graph.links[clipLink];
if (linkInfo) {
const originNode = window.app.graph.getNodeById(linkInfo.origin_id);
if (originNode) {
const widgets = originNode.widgets || [];
const modelWidget = widgets.find(w =>
w.name === "clip_name" || w.name === "clip_name_1" ||
w.name === "clip_name_2" || w.name === "clip" || w.name === "model_name"
);
if (modelWidget) clip_name = modelWidget.value;
}
}
}
} catch (e) {
console.warn("[LTXDirector] Could not traverse graph to find CLIPLoader name", e);
}
const b64_images = (await Promise.all(
(this.timeline.characters[idx].images || []).map(img => this._refImageToB64(img))
)).filter(Boolean);
try {
const resp = await api.fetchApi("/ltx_director/analyze_character", {
method: "POST",
body: JSON.stringify({
clip_name: clip_name,
image_b64: b64_images,
char_index: idx,
provider: this.timeline.analyzeProvider || "ollama",
base_url: this.timeline.analyzeBaseUrl || "",
model: this.timeline.analyzeModel || "",
})
});
const result = await resp.json();
if (result.status === "success") {
this.timeline.characters[idx].description = result.description;
btn.textContent = "Success!";
setTimeout(() => { this.updateCharacterSlotsUI(); this.commitChanges(); }, 1500);
} else {
alert("Analysis Error: " + result.message);
btn.classList.remove("loading");
btn.textContent = "Analyze";
}
} catch (err) {
console.error("[LTXDirector] analysis request failed", err);
alert("Request failed. Is your server running?");
btn.classList.remove("loading");
btn.textContent = "Analyze";
}
}
// --- @char auto-complete popup (attaches to a given textarea) ---
setupAutocomplete(input) {
if (!input || input._prAutocompleteAttached) return;
input._prAutocompleteAttached = true;
const menu = document.createElement("div");
menu.className = "prcs-autocomplete-menu";
menu.style.display = "none";
document.body.appendChild(menu);
if (!this._autocompleteMenus) this._autocompleteMenus = [];
this._autocompleteMenus.push(menu);
const suggestions = [
{ tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" }
];
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 + 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 = "";
if (activeIndex >= filtered.length) activeIndex = 0;
filtered.forEach((s, idx) => {
const item = document.createElement("div");
item.className = "prcs-autocomplete-item" + (idx === activeIndex ? " active" : "");
item.innerHTML = `${s.tag}${s.label}`;
item.addEventListener("mousedown", (e) => { e.preventDefault(); 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;
input.dispatchEvent(new Event("input"));
hideMenu();
input.focus();
};
input.addEventListener("keydown", (e) => {
if (showMenu) {
const items = menu.querySelectorAll(".prcs-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) insertSuggestion(activeItem.querySelector("span").textContent);
} 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 === 0 || textBeforeCursor[lastAt - 1] === " ")) {
showMenu = true;
queryStart = lastAt;
updateMenu();
} else {
hideMenu();
}
});
input.addEventListener("blur", () => { setTimeout(hideMenu, 150); });
}
commitChanges(skipRender = false) {
if (this._suppressCommit) return;
// Deduplicate segments by ID to clean up any duplicates created by the previous onseeked bug
this.timeline.segments = this.timeline.segments.filter((seg, index, self) => index === self.findIndex((s) => s.id === seg.id));
if (this.timeline.audioSegments) {
this.timeline.audioSegments = this.timeline.audioSegments.filter((seg, index, self) => index === self.findIndex((s) => s.id === seg.id));
}
if (this.timeline.motionSegments) {
this.timeline.motionSegments = this.timeline.motionSegments.filter((seg, index, self) => index === self.findIndex((s) => s.id === seg.id));
}
let sortedSegments = [...this.timeline.segments].sort((a, b) => a.start - b.start);
let contiguousLengths = [];
let contiguousPrompts = [];
let imgStrengths = [];
const startFrames = this.getStartFrames();
const durationFrames = this.getDurationFrames();
if (!this.retakeMode) {
this.timeline.normalStartFrame = startFrames;
this.timeline.normalDurationFrames = durationFrames;
}
const endFrames = startFrames + durationFrames;
let currentCursor = startFrames;
if (this.retakeMode) {
const totalFrames = this.getVisualDurationFrames();
const retakeStart = this.timeline.retakeStart ?? 0;
const retakeLength = this.timeline.retakeLength ?? totalFrames;
const retakeEnd = retakeStart + retakeLength;
const retakePrompt = this.timeline.retakePrompt || "";
const retakeStrength = this.timeline.retakeStrength ?? 1.0;
const globalPrompt = this.globalPromptInput ? this.globalPromptInput.value : (this.node.properties?.global_prompt || "");
// 1. Preserved before
const pBeforeStart = startFrames;
const pBeforeEnd = Math.min(endFrames, retakeStart);
const pBeforeLen = pBeforeEnd - pBeforeStart;
if (pBeforeLen > 0) {
contiguousLengths.push(pBeforeLen);
contiguousPrompts.push(globalPrompt || "video");
imgStrengths.push("0.00");
}
// 2. Retake region
const rStart = Math.max(startFrames, retakeStart);
const rEnd = Math.min(endFrames, retakeEnd);
const rLen = rEnd - rStart;
if (rLen > 0) {
contiguousLengths.push(rLen);
contiguousPrompts.push(retakePrompt || "video");
imgStrengths.push(retakeStrength.toFixed(2));
}
// 3. Preserved after
const pAfterStart = Math.max(startFrames, retakeEnd);
const pAfterEnd = endFrames;
const pAfterLen = pAfterEnd - pAfterStart;
if (pAfterLen > 0) {
contiguousLengths.push(pAfterLen);
contiguousPrompts.push(globalPrompt || "video");
imgStrengths.push("0.00");
}
} else {
// 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 endFrames.
// - Segments completely before startFrames or after endFrames are excluded entirely.
// - Segments that cross the boundaries are trimmed.
let pendingGap = 0;
for (let seg of sortedSegments) {
if (seg.start + seg.length <= startFrames) continue;
if (seg.start >= endFrames) break;
const effectiveStart = Math.max(seg.start, startFrames);
if (effectiveStart > currentCursor) {
const gapLength = Math.min(effectiveStart, endFrames) - currentCursor;
if (contiguousLengths.length > 0) {
contiguousLengths[contiguousLengths.length - 1] += gapLength;
} else {
pendingGap += gapLength;
}
}
const clippedEnd = Math.min(seg.start + seg.length, endFrames);
const clippedLength = clippedEnd - effectiveStart;
contiguousLengths.push(clippedLength + pendingGap);
contiguousPrompts.push(seg.prompt || "");
pendingGap = 0;
currentCursor = Math.max(currentCursor, seg.start + seg.length);
}
const clampedCursor = Math.min(currentCursor, endFrames);
if (contiguousLengths.length > 0 && clampedCursor < endFrames) {
contiguousLengths[contiguousLengths.length - 1] += endFrames - clampedCursor;
}
}
const toSave = {
mainTrackEnabled: this.mainTrackEnabled,
audioTrackEnabled: this.audioTrackEnabled,
motionTrackEnabled: this.motionTrackEnabled,
propHeight: this.propHeight,
globalPropHeight: this.globalPropHeight,
showFilenames: !!this.node.properties.showFilenames,
overrideAudio: !!this.node.properties.overrideAudio,
inpaint_audio: !!(this.node.widgets?.find(w => w.name === "inpaint_audio")?.value),
global_prompt: this.retakeMode ? (this.timeline.global_prompt || "") : (this.globalPromptInput ? this.globalPromptInput.value : ""),
retake_global_prompt: this.retakeMode ? (this.globalPromptInput ? this.globalPromptInput.value : "") : (this.timeline.retake_global_prompt || ""),
retakeMode: this.retakeMode,
retakeStart: this.timeline.retakeStart,
retakeLength: this.timeline.retakeLength,
retakePrompt: this.timeline.retakePrompt,
retakeStrength: this.timeline.retakeStrength,
retakeVideo: this.timeline.retakeVideo ? {
fileName: this.timeline.retakeVideo.fileName,
imageFile: this.timeline.retakeVideo.imageFile,
videoDurationFrames: this.timeline.retakeVideo.videoDurationFrames,
fileSize: this.timeline.retakeVideo.fileSize,
} : null,
normalStartFrame: this.timeline.normalStartFrame,
normalDurationFrames: this.timeline.normalDurationFrames,
reference_mode: this.timeline.reference_mode || "OFF",
analyzeProvider: this.timeline.analyzeProvider || "ollama",
analyzeBaseUrl: this.timeline.analyzeBaseUrl || "",
analyzeModel: this.timeline.analyzeModel || "",
characters: (this.timeline.characters || []).map(c => ({
images: (c.images || []).map(img => img.b64 ? { b64: img.b64, name: img.name } : { name: img.name }),
description: c.description || ""
})),
segments: sortedSegments.map(s => {
const { imgObj, videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
}),
motionSegments: (this.timeline.motionSegments || []).map(s => {
const { imgObj, videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
}),
audioSegments: (this.timeline.audioSegments || []).map(s => {
const { _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _decoding, _blobUrl, _audioBuffer, ...rest } = s;
return rest;
})
};
const jsonStr = JSON.stringify(toSave);
console.log("[LTXDirector debug] commitChanges: saving timelineDataWidget value:", jsonStr);
const updateWidgetValue = (w, val) => {
if (!w) return;
const oldVal = w.value;
w.value = val;
if (this.node) {
if (this.node.properties) {
this.node.properties[w.name] = val;
}
if (this.node.onWidgetChanged) {
this.node.onWidgetChanged(w.name, val, oldVal, w);
}
}
if (w.callback) {
try {
w.callback(val);
} catch (e) {
// ignore
}
}
};
if (this.timelineDataWidget) {
updateWidgetValue(this.timelineDataWidget, jsonStr);
}
if (this.node.properties) {
this.node.properties.mainTrackEnabled = this.mainTrackEnabled;
this.node.properties.audioTrackEnabled = this.audioTrackEnabled;
this.node.properties.motionTrackEnabled = this.motionTrackEnabled;
this.node.properties.audioTrackWasEnabledBeforeOverride = !!this._audioTrackWasEnabledBeforeOverride;
if (this.node.widgets) {
for (const w of this.node.widgets) {
if (w.name && w.value !== undefined) {
this.node.properties[w.name] = w.value;
}
}
}
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget) {
this.node.properties.overrideAudio = !!overrideWidget.value;
}
}
const overrideWidget = this.node.widgets?.find(w => w.name === "override_audio");
if (overrideWidget) {
updateWidgetValue(overrideWidget, !!this.node.properties.overrideAudio);
}
if (this.localPromptsWidget) {
updateWidgetValue(this.localPromptsWidget, contiguousPrompts.join(" | "));
}
if (this.segmentLengthsWidget) {
updateWidgetValue(this.segmentLengthsWidget, contiguousLengths.join(","));
}
if (this.guideStrengthWidget) {
let val = "";
if (this.retakeMode) {
val = imgStrengths.join(",");
} else {
const strList = sortedSegments
.filter(s => s.type !== "text")
.filter(s => s.start + s.length > startFrames && s.start < endFrames)
.map(s => (s.guideStrength !== undefined ? s.guideStrength : 1.0).toFixed(2));
val = strList.join(",");
}
updateWidgetValue(this.guideStrengthWidget, val);
}
// 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);
if (app.graph.change) app.graph.change();
if (app.graph.onNodeChanged) app.graph.onNodeChanged(this.node);
if (app.graph.onStateChanged) app.graph.onStateChanged();
}
}
try {
const canvasEl = app.canvasEl || app.canvas?.canvas;
if (canvasEl) {
canvasEl.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }));
}
if (app.canvas && app.canvas.checkState) app.canvas.checkState();
if (app.canvas && app.canvas.captureCanvasState) app.canvas.captureCanvasState();
} catch (_) { }
}, 100);
// Stamp exact seconds on every live segment so FPS changes can recompute
// frame values without cumulative rounding error.
this._stampSegmentSeconds();
if (this.isPlaying) {
this.playAudio(); // Resync audio engine with new timeline data
}
if (!skipRender) this.render();
}
// Stamp _sSecs / _lSecs / _tSecs / _dSecs on every live segment
// using the current frame rate. Call this whenever segments change
// through normal timeline interactions (not FPS changes).
_stampSegmentSeconds() {
const fps = this.getFrameRate();
if (fps <= 0) return;
for (const seg of this.timeline.segments) {
seg._sSecs = seg.start / fps;
seg._lSecs = seg.length / fps;
if (seg.trimStart !== undefined) seg._tSecs = seg.trimStart / fps;
if (seg.videoDurationFrames !== undefined) seg._dSecs = seg.videoDurationFrames / fps;
}
for (const seg of this.timeline.audioSegments) {
seg._sSecs = seg.start / fps;
seg._lSecs = seg.length / fps;
if (seg.trimStart !== undefined) seg._tSecs = seg.trimStart / fps;
if (seg.audioDurationFrames !== undefined) seg._dSecs = seg.audioDurationFrames / fps;
}
}
// Recompute all segment frame values from their seconds snapshots at `newFPS`.
// If a segment has no snapshot yet (e.g. freshly added), fall back to scaling
// from the previous FPS so it still moves correctly.
_rebaseSegmentsToFPS(newFPS) {
if (newFPS <= 0) return;
const oldFPS = this._prevFrameRate || newFPS;
const fallbackRatio = oldFPS > 0 ? newFPS / oldFPS : 1;
for (const seg of this.timeline.segments) {
if (seg._sSecs !== undefined) {
seg.start = Math.round(seg._sSecs * newFPS);
seg.length = Math.max(1, Math.round(seg._lSecs * newFPS));
if (seg._tSecs !== undefined) seg.trimStart = Math.round(seg._tSecs * newFPS);
if (seg._dSecs !== undefined) seg.videoDurationFrames = Math.round(seg._dSecs * newFPS);
} else {
seg.start = Math.round(seg.start * fallbackRatio);
seg.length = Math.max(1, Math.round(seg.length * fallbackRatio));
if (seg.trimStart !== undefined) seg.trimStart = Math.round(seg.trimStart * fallbackRatio);
if (seg.videoDurationFrames !== undefined) seg.videoDurationFrames = Math.round(seg.videoDurationFrames * fallbackRatio);
}
}
for (const seg of this.timeline.audioSegments) {
if (seg._sSecs !== undefined) {
seg.start = Math.round(seg._sSecs * newFPS);
seg.length = Math.max(1, Math.round(seg._lSecs * newFPS));
if (seg._tSecs !== undefined) seg.trimStart = Math.round(seg._tSecs * newFPS);
if (seg._dSecs !== undefined) seg.audioDurationFrames = Math.round(seg._dSecs * newFPS);
} else {
seg.start = Math.round(seg.start * fallbackRatio);
seg.length = Math.max(1, Math.round(seg.length * fallbackRatio));
if (seg.trimStart !== undefined) seg.trimStart = Math.round(seg.trimStart * fallbackRatio);
if (seg.audioDurationFrames !== undefined) seg.audioDurationFrames = Math.round(seg.audioDurationFrames * fallbackRatio);
}
}
}
// --- Gap Region Calculation ---
getGapRegions() {
const totalFrames = this.getVisualDurationFrames();
const outputFrames = this.getStartFrames() + 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 });
}
// Motion gaps
cursor = 0;
const sortedMot = [...this.timeline.motionSegments].sort((a, b) => a.start - b.start);
for (const seg of sortedMot) {
if (seg.start > cursor) {
const x0 = (cursor / totalFrames) * width;
const x1 = (seg.start / totalFrames) * width;
gaps.push({ track: 'motion', frameStart: cursor, frameEnd: seg.start, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight + this.motionTrackHeight / 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: 'motion', frameStart: cursor, frameEnd: outputFrames, centerX: (x0 + x1) / 2, centerY: RULER_HEIGHT + this.blockHeight + this.audioTrackHeight + this.motionTrackHeight / 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();
}
promptAddMotionInGap(frameStart, frameEnd) {
const fi = document.createElement("input");
fi.type = "file";
fi.accept = "video/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) this.handleMotionUpload([ev.target.files[0]], frameStart);
});
fi.click();
}
// --- Context Menu ---
onContextMenu(e) {
e.preventDefault();
e.stopPropagation();
// In retake mode: suppress the normal timeline context menu entirely.
// If a retake video is loaded, show a minimal retake-specific menu instead.
if (this.retakeMode) {
if (this.timeline.retakeVideo) {
this._showRetakeContextMenu(e.clientX, e.clientY);
}
return;
}
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 isMotionTrack = mouseY >= RULER_HEIGHT + trackHeight + this.audioTrackHeight && mouseY <= RULER_HEIGHT + trackHeight + this.audioTrackHeight + this.motionTrackHeight;
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 (isMotionTrack) {
clickedSeg = this.timeline.motionSegments.find(s => cursor >= s.start && cursor <= s.start + s.length);
trackType = "motion";
} else 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 (isMotionTrack || isImageTrack || isAudioTrack) {
const gapRegions = this.getGapRegions();
const currentTrack = isMotionTrack ? "motion" : (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);
}
}
_deleteRetakeVideo() {
if (!this.timeline.retakeVideo) return;
// Clean up the video element
const vid = this.timeline.retakeVideo;
if (vid.videoEl) {
vid.videoEl.pause();
vid.videoEl.src = "";
vid.videoEl.load();
}
if (vid._blobUrl) {
URL.revokeObjectURL(vid._blobUrl);
}
this.timeline.retakeVideo = null;
this.timeline.retakeStart = 0;
this.timeline.retakeLength = this.getDurationFrames();
this.commitChanges();
this.render();
}
_showRetakeContextMenu(clientX, clientY) {
this.dismissContextMenu();
const menu = document.createElement("div");
menu.className = "prcs-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const deleteBtn = document.createElement("button");
deleteBtn.className = "prcs-gap-menu-btn";
deleteBtn.innerHTML = `${ICONS.trash} Delete`;
deleteBtn.style.color = "#ffaaaa";
deleteBtn.onclick = () => {
this.dismissContextMenu();
this._deleteRetakeVideo();
};
menu.appendChild(deleteBtn);
document.body.appendChild(menu);
this._contextMenu = menu;
setTimeout(() => {
this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); };
document.addEventListener("pointerdown", this._contextMenuDismisser, true);
document.addEventListener("wheel", this._contextMenuDismisser, true);
}, 0);
}
async _checkClipboardForImage(btn) {
try {
if (navigator.permissions && navigator.permissions.query) {
const status = await navigator.permissions.query({ name: "clipboard-read" });
if (status.state === "granted") {
const items = await navigator.clipboard.read();
let hasImg = false;
for (const item of items) {
if (item.types.some(t => t.startsWith("image/"))) {
hasImg = true;
break;
}
}
if (!hasImg) {
btn.disabled = true;
btn.style.opacity = "0.4";
btn.style.cursor = "not-allowed";
btn.title = "No image found in clipboard";
}
} else if (status.state === "denied") {
btn.disabled = true;
btn.style.opacity = "0.4";
btn.style.cursor = "not-allowed";
btn.title = "Clipboard permission denied";
}
}
} catch (e) {
console.warn("Clipboard read permission query failed:", e);
}
}
async _checkClipboardForText(btn) {
try {
if (navigator.permissions && navigator.permissions.query) {
const status = await navigator.permissions.query({ name: "clipboard-read" });
if (status.state === "granted") {
const text = await navigator.clipboard.readText();
if (!text || text.trim() === "") {
btn.disabled = true;
btn.style.opacity = "0.4";
btn.style.cursor = "not-allowed";
btn.title = "No text found in clipboard";
}
} else if (status.state === "denied") {
btn.disabled = true;
btn.style.opacity = "0.4";
btn.style.cursor = "not-allowed";
btn.title = "Clipboard permission denied";
}
}
} catch (e) {
console.warn("Clipboard read text permission query failed:", e);
}
}
showContextMenu(clientX, clientY, seg, trackType) {
this.dismissContextMenu();
const menu = document.createElement("div");
menu.className = "prcs-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const isImage = trackType === "image" && seg.imageB64;
const makeDivider = () => {
const d = document.createElement("div");
d.className = "prcs-settings-divider";
return d;
};
// ==========================================
// 1. Define Segment options (Copy, Paste, Replace Segment, Split)
// ==========================================
const copySegBtn = document.createElement("button");
copySegBtn.className = "prcs-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;
window._ltxCopiedSegmentCS = { main: { ...seg }, sibling: null };
window._ltxCopiedSegmentTypeCS = this.getCanonicalTrack(trackType);
if (seg.imgObj) window._ltxCopiedSegmentCS.main.imgObj = seg.imgObj;
if (seg.videoEl) window._ltxCopiedSegmentCS.main.videoEl = seg.videoEl;
if (seg.id && (seg.id.endsWith("_v") || seg.id.endsWith("_a"))) {
const isVid = seg.id.endsWith("_v");
const sibId = isVid ? seg.id.slice(0, -2) + "_a" : seg.id.slice(0, -2) + "_v";
const sibArr = isVid ? this.timeline.audioSegments : this.timeline.segments;
const sib = sibArr.find(s => s.id === sibId);
if (sib) {
window._ltxCopiedSegmentCS.sibling = { ...sib };
if (sib.imgObj) window._ltxCopiedSegmentCS.sibling.imgObj = sib.imgObj;
if (sib.videoEl) window._ltxCopiedSegmentCS.sibling.videoEl = sib.videoEl;
}
}
this.dismissContextMenu();
};
const hasCopied = this._copiedSegment || window._ltxCopiedSegmentCS;
const copiedTrack = this._copiedSegmentTrack || window._ltxCopiedSegmentTypeCS;
const copiedSegData = this._copiedSegment || (window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.main : null);
const copiedSibData = window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.sibling : null;
const canPaste = hasCopied && this.getCanonicalTrack(copiedTrack) === this.getCanonicalTrack(trackType) && copiedSegData;
const pasteSegBtn = document.createElement("button");
pasteSegBtn.className = "prcs-gap-menu-btn";
pasteSegBtn.innerHTML = `Paste Segment`;
if (!canPaste) {
pasteSegBtn.disabled = true;
pasteSegBtn.style.opacity = "0.4";
pasteSegBtn.style.cursor = "not-allowed";
pasteSegBtn.title = "No matching segment copied to clipboard";
} else {
pasteSegBtn.onclick = () => {
const startFrame = Math.round(this.currentFrame);
this.pasteSegmentAtFrame(copiedSegData, this.getCanonicalTrack(copiedTrack), copiedSibData, startFrame);
this.dismissContextMenu();
};
}
const currentTrack = trackType;
const canReplace = hasCopied && this.getCanonicalTrack(copiedTrack) === this.getCanonicalTrack(currentTrack) && copiedSegData;
const pasteReplaceBtn = document.createElement("button");
pasteReplaceBtn.className = "prcs-gap-menu-btn";
pasteReplaceBtn.innerHTML = `Replace Segment`;
if (!canReplace) {
pasteReplaceBtn.disabled = true;
pasteReplaceBtn.style.opacity = "0.4";
pasteReplaceBtn.style.cursor = "not-allowed";
pasteReplaceBtn.title = "No matching segment copied to clipboard";
} else {
pasteReplaceBtn.onclick = () => {
const newSeg = {
...copiedSegData,
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
start: seg.start,
length: copiedSegData.length
};
const targetArray = this.getSegmentArray(this.getCanonicalTrack(currentTrack));
const idx = targetArray.findIndex(s => s.id === seg.id);
if (idx >= 0) targetArray[idx] = newSeg;
this.commitChanges();
this.dismissContextMenu();
};
}
let splitBtn = null;
const splitFrame = Math.round(this.currentFrame);
if (splitFrame > seg.start && splitFrame < seg.start + seg.length) {
splitBtn = document.createElement("button");
splitBtn.className = "prcs-gap-menu-btn";
splitBtn.innerHTML = `Split at Playhead`;
splitBtn.onclick = () => {
this.splitSegmentAtPlayhead(seg, trackType);
this.dismissContextMenu();
};
}
// ==========================================
// 2. Define Prompt options (if not audio)
// ==========================================
let copyPromptBtn = null;
let pastePromptBtn = null;
if (trackType !== "audio") {
copyPromptBtn = document.createElement("button");
copyPromptBtn.className = "prcs-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();
};
pastePromptBtn = document.createElement("button");
pastePromptBtn.className = "prcs-gap-menu-btn";
pastePromptBtn.innerHTML = `Paste Prompt`;
this._checkClipboardForText(pastePromptBtn);
pastePromptBtn.onclick = async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
seg.prompt = text;
this.commitChanges();
this.render();
if (this.selectedIndex === this.timeline.segments.findIndex(s => s.id === seg.id)) {
this.updateUIFromSelection();
}
}
} catch (err) {
console.error("Failed to paste prompt", err);
}
this.dismissContextMenu();
};
}
// ==========================================
// 3. Define Image options (if isImage)
// ==========================================
let copyImgBtn = null;
let saveImgBtn = null;
let openImgBtn = null;
let replaceImgBtn = null;
let replaceWithFileBtn = null;
if (isImage) {
copyImgBtn = document.createElement("button");
copyImgBtn.className = "prcs-gap-menu-btn";
copyImgBtn.innerHTML = `Copy Image`;
copyImgBtn.onclick = async () => {
try {
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = seg.imageB64;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext("2d").drawImage(img, 0, 0);
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch (err) {
console.error("Failed to copy image", err);
}
this.dismissContextMenu();
};
saveImgBtn = document.createElement("button");
saveImgBtn.className = "prcs-gap-menu-btn";
saveImgBtn.innerHTML = `Save Image`;
saveImgBtn.onclick = () => {
const a = document.createElement("a");
a.href = seg.imageB64;
a.download = "timeline_image.jpg";
a.click();
this.dismissContextMenu();
};
openImgBtn = document.createElement("button");
openImgBtn.className = "prcs-gap-menu-btn";
openImgBtn.innerHTML = `Open Image in New Tab`;
openImgBtn.onclick = () => {
const win = window.open();
if (win) {
win.document.write(`
`);
win.document.close();
}
this.dismissContextMenu();
};
replaceImgBtn = document.createElement("button");
replaceImgBtn.className = "prcs-gap-menu-btn";
replaceImgBtn.innerHTML = `Replace with Copied Image`;
this._checkClipboardForImage(replaceImgBtn);
replaceImgBtn.onclick = async () => {
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageTypes = item.types.filter(type => type.startsWith("image/"));
if (imageTypes.length > 0) {
const blob = await item.getType(imageTypes[0]);
const file = new File([blob], "clipboard.png", { type: blob.type });
const body = new FormData();
body.append("image", file);
body.append("subfolder", "whatdreamscost");
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) {
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 = () => {
seg.imageFile = imageFile;
seg.imageB64 = imgUrl;
seg.imgObj = img;
this.commitChanges();
this.render();
if (this.selectedIndex === this.timeline.segments.findIndex(s => s.id === seg.id)) {
this.updateUIFromSelection();
}
};
img.src = imgUrl;
}
break;
}
}
} catch (err) {
console.error("Failed to read image from clipboard", err);
}
this.dismissContextMenu();
};
replaceWithFileBtn = document.createElement("button");
replaceWithFileBtn.className = "prcs-gap-menu-btn";
replaceWithFileBtn.innerHTML = `Replace with...`;
replaceWithFileBtn.onclick = () => {
this.dismissContextMenu();
const fi = document.createElement("input");
fi.type = "file";
fi.accept = "image/*";
fi.addEventListener("change", async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
try {
const body = new FormData();
body.append("image", file);
body.append("subfolder", "whatdreamscost");
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) {
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 = () => {
seg.imageFile = imageFile;
seg.imageB64 = imgUrl;
seg.imgObj = img;
this.commitChanges();
this.render();
if (this.selectedIndex === this.timeline.segments.findIndex(s => s.id === seg.id)) {
this.updateUIFromSelection();
}
};
img.src = imgUrl;
}
} catch (err) {
console.error("Failed to upload replacement image", err);
}
});
fi.click();
};
}
// ==========================================
// 4. Define Convert to End Frame options (only image segment with type === "image")
// ==========================================
let toggleEndFrameBtn = null;
if (trackType === "image" && seg.type === "image") {
toggleEndFrameBtn = document.createElement("button");
toggleEndFrameBtn.className = "prcs-gap-menu-btn";
if (seg.isEndFrame) {
toggleEndFrameBtn.innerHTML = `Convert to Start Frame`;
toggleEndFrameBtn.onclick = () => {
seg.isEndFrame = false;
this.commitChanges();
this.render();
this.dismissContextMenu();
};
} else {
toggleEndFrameBtn.innerHTML = `Convert to End Frame`;
toggleEndFrameBtn.onclick = () => {
seg.isEndFrame = true;
this.commitChanges();
this.render();
this.dismissContextMenu();
};
}
}
// ==========================================
// 5. Define Unlink Media & Mark Selection options
// ==========================================
const isVidLink = trackType === "video" && seg.id.endsWith("_v");
const isAudLink = trackType === "audio" && seg.id.endsWith("_a");
let siblingForUnlink = null;
if (isVidLink) {
siblingForUnlink = this.timeline.audioSegments.find(s => s.id === seg.id.slice(0, -2) + "_a");
} else if (isAudLink) {
siblingForUnlink = this.timeline.segments.find(s => s.id === seg.id.slice(0, -2) + "_v");
}
let unlinkBtn = null;
if (siblingForUnlink) {
unlinkBtn = document.createElement("button");
unlinkBtn.className = "prcs-gap-menu-btn";
unlinkBtn.innerHTML = `Unlink Media`;
unlinkBtn.onclick = () => {
seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
siblingForUnlink.id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
this.commitChanges();
this.render();
this.dismissContextMenu();
};
}
const markSelectionBtn = document.createElement("button");
markSelectionBtn.className = "prcs-gap-menu-btn";
markSelectionBtn.innerHTML = `Mark Selection`;
markSelectionBtn.onclick = () => {
if (this.selectedSegmentIds && this.selectedSegmentIds.includes(seg.id)) {
this.markCurrentSelection();
} else {
this.markSegment(seg);
}
this.dismissContextMenu();
};
// ==========================================
// 6. Define Delete Option
// ==========================================
const delBtn = document.createElement("button");
delBtn.className = "prcs-gap-menu-btn";
delBtn.innerHTML = `Delete`;
delBtn.style.color = "#ff4444";
delBtn.onclick = () => {
this.selectionType = trackType;
const list = this.getSegmentArray(trackType);
this.selectedIndex = list.findIndex(s => s.id === seg.id);
this.deleteSelectedSegment();
this.dismissContextMenu();
};
// Very top: Split at Playhead (if active/available)
if (splitBtn) {
menu.appendChild(splitBtn);
menu.appendChild(makeDivider());
}
// Group 1: Segment Options (Always present)
menu.appendChild(copySegBtn);
menu.appendChild(pasteSegBtn);
menu.appendChild(pasteReplaceBtn);
menu.appendChild(makeDivider());
// Group 2: Prompt Options (Only if not audio)
if (copyPromptBtn && pastePromptBtn) {
menu.appendChild(copyPromptBtn);
menu.appendChild(pastePromptBtn);
menu.appendChild(makeDivider());
}
// Group 3: Image Options (Only if isImage)
if (isImage) {
menu.appendChild(copyImgBtn);
menu.appendChild(saveImgBtn);
menu.appendChild(openImgBtn);
menu.appendChild(replaceImgBtn);
menu.appendChild(replaceWithFileBtn);
menu.appendChild(makeDivider());
}
// Group 4: Convert to End Frame (Only if toggleEndFrameBtn is defined)
if (toggleEndFrameBtn) {
menu.appendChild(toggleEndFrameBtn);
menu.appendChild(makeDivider());
}
// Group 5: Unlink Media & Mark Selection
if (unlinkBtn) {
menu.appendChild(unlinkBtn);
menu.appendChild(makeDivider());
}
menu.appendChild(markSelectionBtn);
menu.appendChild(makeDivider());
// Group 6: Delete Option
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 = "prcs-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const currentTrack = gap.track;
const hasCopied = this._copiedSegment || window._ltxCopiedSegmentCS;
const copiedTrack = this._copiedSegmentTrack || window._ltxCopiedSegmentTypeCS;
const copiedSegData = this._copiedSegment || (window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.main : null);
const copiedSibData = window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.sibling : null;
const canPaste = hasCopied && this.getCanonicalTrack(copiedTrack) === this.getCanonicalTrack(currentTrack) && copiedSegData;
const pasteBtn = document.createElement("button");
pasteBtn.className = "prcs-gap-menu-btn";
pasteBtn.innerHTML = `Paste Segment`;
if (!canPaste) {
pasteBtn.disabled = true;
pasteBtn.style.opacity = "0.4";
pasteBtn.style.cursor = "not-allowed";
pasteBtn.title = "No matching segment copied to clipboard";
} else {
pasteBtn.onclick = () => {
const startFrame = Math.round(gap.clickedFrame !== undefined ? gap.clickedFrame : gap.frameStart);
this.pasteSegmentAtFrame(copiedSegData, this.getCanonicalTrack(copiedTrack), copiedSibData, startFrame);
this.dismissContextMenu();
};
}
menu.appendChild(pasteBtn);
if (currentTrack === "image") {
const textBtn = document.createElement("button");
textBtn.className = "prcs-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 = "prcs-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);
const pasteImageBtn = document.createElement("button");
pasteImageBtn.className = "prcs-gap-menu-btn";
pasteImageBtn.innerHTML = `${ICONS.upload} Paste Image`;
this._checkClipboardForImage(pasteImageBtn);
pasteImageBtn.onclick = async () => {
this.dismissContextMenu();
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageTypes = item.types.filter(type => type.startsWith("image/"));
if (imageTypes.length > 0) {
const blob = await item.getType(imageTypes[0]);
const file = new File([blob], "clipboard.png", { type: blob.type });
const startFrame = Math.round(gap.clickedFrame !== undefined ? gap.clickedFrame : gap.frameStart);
const gapLength = gap.frameEnd - startFrame;
await this.handleImageUpload([file], startFrame, gapLength);
break;
}
}
} catch (err) {
console.error("Failed to paste image from clipboard", err);
}
};
const vidBtn = document.createElement("button");
vidBtn.className = "prcs-gap-menu-btn";
vidBtn.innerHTML = ` Video Segment`;
vidBtn.onclick = () => {
this.dismissContextMenu();
const fi = document.createElement("input");
fi.type = "file"; fi.accept = "video/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) this.handleVideoUpload([ev.target.files[0]], gap.frameStart);
});
fi.click();
};
menu.appendChild(vidBtn);
menu.appendChild(pasteImageBtn);
} else if (currentTrack === "motion") {
const vidBtn = document.createElement("button");
vidBtn.className = "prcs-gap-menu-btn";
vidBtn.innerHTML = ` Video Segment`;
vidBtn.onclick = () => {
this.dismissContextMenu();
this.promptAddMotionInGap(gap.frameStart, gap.frameEnd);
};
menu.appendChild(vidBtn);
} else if (currentTrack === "audio") {
const audBtn = document.createElement("button");
audBtn.className = "prcs-gap-menu-btn";
audBtn.innerHTML = `${ICONS.audio} Audio Segment`;
audBtn.onclick = () => {
this.dismissContextMenu();
this.promptAddAudioInGap(gap.frameStart, gap.frameEnd);
};
menu.appendChild(audBtn);
}
document.body.appendChild(menu);
this._contextMenu = menu;
setTimeout(() => {
this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); };
document.addEventListener("pointerdown", this._contextMenuDismisser, true);
document.addEventListener("wheel", this._contextMenuDismisser, true);
}, 0);
}
dismissContextMenu() {
if (this._contextMenu) { this._contextMenu.remove(); this._contextMenu = null; }
if (this._contextMenuDismisser) {
document.removeEventListener("pointerdown", this._contextMenuDismisser, true);
document.removeEventListener("wheel", this._contextMenuDismisser, true);
this._contextMenuDismisser = null;
}
}
// --- Gap Popup Menu ---
showGapMenu(clientX, clientY, gap) {
this.dismissGapMenu();
const menu = document.createElement("div");
menu.className = "prcs-gap-menu";
menu.style.left = `${clientX + 6}px`;
menu.style.top = `${clientY - 10}px`;
const currentTrack = gap.track;
if (currentTrack === "image") {
const textBtn = document.createElement("button");
textBtn.className = "prcs-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 = "prcs-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();
});
const pasteImageBtn = document.createElement("button");
pasteImageBtn.className = "prcs-gap-menu-btn";
pasteImageBtn.innerHTML = `${ICONS.upload} Paste Image`;
this._checkClipboardForImage(pasteImageBtn);
pasteImageBtn.addEventListener("click", async () => {
this.dismissGapMenu();
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageTypes = item.types.filter(type => type.startsWith("image/"));
if (imageTypes.length > 0) {
const blob = await item.getType(imageTypes[0]);
const file = new File([blob], "clipboard.png", { type: blob.type });
const gapLength = gap.frameEnd - gap.frameStart;
await this.handleImageUpload([file], gap.frameStart, gapLength);
break;
}
}
} catch (err) {
console.error("Failed to paste image from clipboard", err);
}
});
const vidBtn = document.createElement("button");
vidBtn.className = "prcs-gap-menu-btn";
vidBtn.innerHTML = ` Video Segment`;
vidBtn.addEventListener("click", () => {
this.dismissGapMenu();
const fi = document.createElement("input");
fi.type = "file"; fi.accept = "video/*";
fi.addEventListener("change", (ev) => {
if (ev.target.files?.[0]) {
this.handleVideoUpload([ev.target.files[0]], gap.frameStart);
}
});
fi.click();
});
menu.appendChild(textBtn);
menu.appendChild(imgBtn);
menu.appendChild(vidBtn);
menu.appendChild(pasteImageBtn);
} else if (currentTrack === "motion") {
const vidBtn = document.createElement("button");
vidBtn.className = "prcs-gap-menu-btn";
vidBtn.innerHTML = ` Video Segment`;
vidBtn.addEventListener("click", () => {
this.dismissGapMenu();
this.promptAddMotionInGap(gap.frameStart, gap.frameEnd);
});
menu.appendChild(vidBtn);
} else if (currentTrack === "audio") {
const audBtn = document.createElement("button");
audBtn.className = "prcs-gap-menu-btn";
audBtn.innerHTML = `${ICONS.audio} Audio Segment`;
audBtn.addEventListener("click", () => {
this.dismissGapMenu();
this.promptAddAudioInGap(gap.frameStart, gap.frameEnd);
});
menu.appendChild(audBtn);
}
const hasCopied = this._copiedSegment || window._ltxCopiedSegmentCS;
const copiedTrack = this._copiedSegmentTrack || window._ltxCopiedSegmentTypeCS;
const copiedSegData = this._copiedSegment || (window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.main : null);
const copiedSibData = window._ltxCopiedSegmentCS ? window._ltxCopiedSegmentCS.sibling : null;
const canPaste = hasCopied && this.getCanonicalTrack(copiedTrack) === this.getCanonicalTrack(currentTrack) && copiedSegData;
const pasteBtn = document.createElement("button");
pasteBtn.className = "prcs-gap-menu-btn";
pasteBtn.innerHTML = `Paste Segment`;
if (!canPaste) {
pasteBtn.disabled = true;
pasteBtn.style.opacity = "0.4";
pasteBtn.style.cursor = "not-allowed";
pasteBtn.title = "No matching segment copied to clipboard";
} else {
pasteBtn.onclick = () => {
const startFrame = Math.round(gap.frameStart);
this.pasteSegmentAtFrame(copiedSegData, this.getCanonicalTrack(copiedTrack), copiedSibData, startFrame);
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);
document.addEventListener("wheel", this._gapMenuDismisser, true);
}, 0);
}
dismissGapMenu() {
if (this._gapMenu) { this._gapMenu.remove(); this._gapMenu = null; }
if (this._gapMenuDismisser) {
document.removeEventListener("pointerdown", this._gapMenuDismisser, true);
document.removeEventListener("wheel", 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() {
const isLiteGraph = !window.LiteGraph || !window.LiteGraph.vueNodesMode;
// If any settings widgets have active connections, show settings widgets instead
let hasActiveSettings = false;
for (const name of this._settingsWidgetNames) {
const hasInput = this.node.inputs?.find(i => i.name === name);
if (hasInput && hasInput.link != null) {
hasActiveSettings = true;
break;
}
}
if (hasActiveSettings) {
this.showSettingsWidgets();
return;
}
for (const name of this._settingsWidgetNames) {
const w = this.node.widgets?.find(w => w.name === name);
if (w) {
hideWidget(w);
// If it was converted to an input slot but is unconnected, remove the input slot
if (isLiteGraph && this.node.inputs) {
const idx = this.node.inputs.findIndex(i => i.name === name);
if (idx !== -1 && this.node.inputs[idx].link == null) {
this.node.removeInput(idx);
}
}
}
}
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() {
const isLiteGraph = !window.LiteGraph || !window.LiteGraph.vueNodesMode;
for (const name of this._settingsWidgetNames) {
const w = this.node.widgets?.find(w => w.name === name);
if (!w) continue;
showWidget(w);
// If the widget is a converted-widget but the input slot is missing, add it back!
if (isLiteGraph && w.type === "converted-widget" && this.node.inputs) {
if (!this.node.inputs.find(i => i.name === name)) {
let type = "FLOAT";
if (name === "divisible_by" || name === "img_compression") {
type = "INT";
} else if (name === "display_mode") {
type = "COMBO";
}
const slot = this.node.addInput(name, type);
if (slot != null) {
const inp = this.node.inputs[this.node.inputs.length - 1];
if (inp) inp.widget = { name };
}
}
}
}
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);
}
}
// --- Save / Load Handlers ---
async handleLoadTimeline() {
try {
if (window.showOpenFilePicker) {
const [fileHandle] = await window.showOpenFilePicker({
types: [{ description: 'Timeline JSON', accept: { 'application/json': ['.json'] } }],
multiple: false
});
const file = await fileHandle.getFile();
const content = await file.text();
this._applyLoadedTimeline(content, fileHandle);
} else {
// Fallback for browsers without showOpenFilePicker (e.g. Firefox)
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = evt => this._applyLoadedTimeline(evt.target.result, null);
reader.readAsText(file);
};
input.click();
}
} catch (e) {
if (e.name !== "AbortError") {
console.error("Failed to load timeline:", e);
alert("Failed to load timeline. See console for details.");
}
}
}
_applyLoadedTimeline(jsonStr, fileHandle) {
try {
const data = JSON.parse(jsonStr);
// Load settings if present
if (data.global_prompt !== undefined) {
if (data.retake_global_prompt !== undefined) {
this.timeline.global_prompt = data.global_prompt;
this.timeline.retake_global_prompt = data.retake_global_prompt;
} else {
this.syncGlobalPrompt(data.global_prompt);
}
}
if (data.settings) {
for (const [key, value] of Object.entries(data.settings)) {
// Handle legacy keys for backward compatibility
if (key === "startFrames" && this.startFramesWidget) {
this.startFramesWidget.value = value;
if (this.startFramesWidget.callback) this.startFramesWidget.callback(value);
continue;
}
if (key === "durationFrames" && this.durationFramesWidget) {
this.durationFramesWidget.value = value;
if (this.durationFramesWidget.callback) this.durationFramesWidget.callback(value);
continue;
}
if (key === "frameRate" && this.frameRateWidget) {
this.frameRateWidget.value = value;
if (this.frameRateWidget.callback) this.frameRateWidget.callback(value);
continue;
}
const w = this.node.widgets?.find(x => x.name === key);
if (w) {
w.value = value;
if (w.callback) w.callback(w.value);
}
}
}
if (this.timelineDataWidget) this.timelineDataWidget.value = JSON.stringify(data.timeline || data);
this.timeline = parseInitial(this.timelineDataWidget.value);
this.mainTrackEnabled = this.timeline.mainTrackEnabled !== false;
this.audioTrackEnabled = this.timeline.audioTrackEnabled !== false;
this.motionTrackEnabled = this.timeline.motionTrackEnabled !== false;
if (this.timeline.showFilenames !== undefined) {
this.node.properties.showFilenames = this.timeline.showFilenames;
}
if (this.timeline.overrideAudio !== undefined) {
this.node.properties.overrideAudio = this.timeline.overrideAudio;
}
if (this.timeline.inpaint_audio !== undefined) {
this.node.properties.inpaint_audio = this.timeline.inpaint_audio;
}
if (this.timeline.propHeight !== undefined) {
this.node.properties.propHeight = this.timeline.propHeight;
this.propHeight = this.timeline.propHeight;
if (this.propContainer) {
this.propContainer.style.height = `${this.propHeight}px`;
}
}
if (this.timeline.globalPropHeight !== undefined) {
this.node.properties.globalPropHeight = this.timeline.globalPropHeight;
this.globalPropHeight = this.timeline.globalPropHeight;
if (this.globalPropContainer) {
this.globalPropContainer.style.height = `${this.globalPropHeight}px`;
}
}
this.currentFileHandle = fileHandle;
this.retakeMode = this.timeline.retakeMode === true;
this.loadMedia();
if (!this.retakeMode) {
this._suppressCommit = true;
if (this.timeline.normalStartFrame !== undefined && this.startFramesWidget) {
this.startFramesWidget.value = this.timeline.normalStartFrame;
if (this.startFramesWidget.callback) {
try { this.startFramesWidget.callback(this.timeline.normalStartFrame); } catch (_) {}
}
}
if (this.timeline.normalDurationFrames !== undefined && this.durationFramesWidget) {
this.durationFramesWidget.value = this.timeline.normalDurationFrames;
if (this.durationFramesWidget.callback) {
try { this.durationFramesWidget.callback(this.timeline.normalDurationFrames); } catch (_) {}
}
}
this._suppressCommit = false;
}
this.updateRetakeUIState();
this.updateUIFromSelection();
this.syncWidgetsAndUI();
this.commitChanges(true); // forces sync to UI and other widgets
if (this.updateInpaintToggleStyle) {
const inpaintWidget = this.node.widgets?.find(w => w.name === "inpaint_audio");
if (inpaintWidget) this.updateInpaintToggleStyle(inpaintWidget.value);
}
this.render();
this.dismissSettingsMenu();
// Trigger ComfyUI's change-detection pipeline the same way a real user
// interaction does: by dispatching a pointerup on the canvas. This fires
// LiteGraph's onAfterChange → ChangeTracker.captureCanvasState() →
// workflowDraftStore.saveDraft() → localStorage. This is what the user
// experiences when they "move something" and it persists correctly.
setTimeout(() => {
try {
const canvasEl = app.canvasEl || app.canvas?.canvas;
if (canvasEl) {
canvasEl.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }));
}
// Also try the direct ChangeTracker API as a backup for both frontend versions
if (app.canvas && app.canvas.checkState) app.canvas.checkState();
if (app.canvas && app.canvas.captureCanvasState) app.canvas.captureCanvasState();
} catch (_) { }
}, 100);
} catch (e) {
console.error("Invalid timeline JSON:", e);
alert("Invalid timeline file.");
}
}
_getTimelineSavePayload() {
const allSettings = {};
const skipWidgets = ["timeline_data", "local_prompts", "segment_lengths", "guide_strength", "timeline_ui", "global_prompt"];
for (const w of this.node.widgets || []) {
if (!skipWidgets.includes(w.name) && w.value !== undefined) {
allSettings[w.name] = w.value;
}
}
const normPrompt = this.retakeMode ? (this.timeline.global_prompt || "") : (this.globalPromptInput ? this.globalPromptInput.value : "");
const retPrompt = this.retakeMode ? (this.globalPromptInput ? this.globalPromptInput.value : "") : (this.timeline.retake_global_prompt || "");
return JSON.stringify({
version: 1,
settings: allSettings,
global_prompt: normPrompt,
retake_global_prompt: retPrompt,
timeline: {
mainTrackEnabled: this.mainTrackEnabled,
audioTrackEnabled: this.audioTrackEnabled,
motionTrackEnabled: this.motionTrackEnabled,
showFilenames: !!this.node.properties.showFilenames,
overrideAudio: !!this.node.properties.overrideAudio,
inpaint_audio: !!(this.node.widgets?.find(w => w.name === "inpaint_audio")?.value),
propHeight: this.propHeight,
globalPropHeight: this.globalPropHeight,
global_prompt: normPrompt,
retake_global_prompt: retPrompt,
retakeMode: this.retakeMode,
retakeStart: this.timeline.retakeStart,
retakeLength: this.timeline.retakeLength,
retakePrompt: this.timeline.retakePrompt,
retakeStrength: this.timeline.retakeStrength,
retakeVideo: this.timeline.retakeVideo ? {
fileName: this.timeline.retakeVideo.fileName,
imageFile: this.timeline.retakeVideo.imageFile,
videoDurationFrames: this.timeline.retakeVideo.videoDurationFrames,
fileSize: this.timeline.retakeVideo.fileSize,
} : null,
normalStartFrame: this.timeline.normalStartFrame,
normalDurationFrames: this.timeline.normalDurationFrames,
segments: (this.timeline.segments || []).map(s => {
const { imgObj, videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
}),
motionSegments: (this.timeline.motionSegments || []).map(s => {
const { imgObj, videoEl, _isSeeking, thumbnails, _extractingThumbs, _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _blobUrl, ...rest } = s;
return rest;
}),
audioSegments: (this.timeline.audioSegments || []).map(s => {
const { _sSecs, _lSecs, _tSecs, _dSecs, _uploading, _decoding, _blobUrl, _audioBuffer, ...rest } = s;
return rest;
})
}
}, null, 2);
}
async handleSaveTimeline() {
if (!this.currentFileHandle) {
return this.handleSaveTimelineAs();
}
try {
const payload = this._getTimelineSavePayload();
const writable = await this.currentFileHandle.createWritable();
await writable.write(payload);
await writable.close();
this.dismissSettingsMenu();
} catch (e) {
console.error("Failed to save timeline:", e);
alert("Failed to save. You may need to use Save As.");
}
}
async handleSaveTimelineAs() {
const payload = this._getTimelineSavePayload();
try {
if (window.showSaveFilePicker) {
const fileHandle = await window.showSaveFilePicker({
suggestedName: "timeline_export.json",
types: [{ description: 'Timeline JSON', accept: { 'application/json': ['.json'] } }]
});
const writable = await fileHandle.createWritable();
await writable.write(payload);
await writable.close();
this.currentFileHandle = fileHandle;
} else {
// Fallback for Firefox
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "timeline_export.json";
a.click();
URL.revokeObjectURL(url);
// Can't track file handle via download fallback
this.currentFileHandle = null;
}
this.dismissSettingsMenu();
} catch (e) {
if (e.name !== "AbortError") {
console.error("Failed to save timeline as:", e);
}
}
}
_makeSettingRow(label, inputEl) {
const row = document.createElement("div");
row.className = "prcs-settings-row";
const lbl = document.createElement("span");
lbl.className = "prcs-settings-label";
lbl.textContent = label;
row.appendChild(lbl);
row.appendChild(inputEl);
return row;
}
showSettingsMenu(anchorEl) {
this.dismissSettingsMenu();
const menu = document.createElement("div");
menu.className = "prcs-settings-menu";
// Set sizing inline so it applies even if the injected stylesheet is cached/stale.
menu.style.width = "560px";
menu.style.maxWidth = "92vw";
menu.style.maxHeight = "60vh";
menu.style.overflowY = "auto";
// Title & Close Button Container
const titleContainer = document.createElement("div");
titleContainer.className = "prcs-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 = "prcs-settings-close-btn";
closeBtn.innerHTML = ICONS.close;
closeBtn.title = "Close Settings";
closeBtn.addEventListener("click", () => this.dismissSettingsMenu());
titleContainer.appendChild(closeBtn);
menu.appendChild(titleContainer);
// --- Save / Load / Show Widgets Grid (2x2) ---
const gridContainer = document.createElement("div");
gridContainer.style.display = "grid";
gridContainer.style.gridTemplateColumns = "repeat(2, 1fr)";
gridContainer.style.gap = "6px";
gridContainer.style.marginBottom = "4px";
const btnSave = document.createElement("button");
btnSave.className = "prcs-settings-toggle-btn";
btnSave.textContent = "Save Timeline";
btnSave.addEventListener("click", () => this.handleSaveTimeline());
gridContainer.appendChild(btnSave);
const btnSaveAs = document.createElement("button");
btnSaveAs.className = "prcs-settings-toggle-btn";
btnSaveAs.textContent = "Save Timeline As";
btnSaveAs.addEventListener("click", () => this.handleSaveTimelineAs());
gridContainer.appendChild(btnSaveAs);
const btnLoad = document.createElement("button");
btnLoad.className = "prcs-settings-toggle-btn";
btnLoad.textContent = "Load Timeline";
btnLoad.addEventListener("click", () => this.handleLoadTimeline());
gridContainer.appendChild(btnLoad);
// --- Show/Hide on Node Toggle ---
const toggleBtn = document.createElement("button");
toggleBtn.className = "prcs-settings-toggle-btn";
const widgetsVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden)));
toggleBtn.textContent = widgetsVisible ? "Hide Widgets" : "Show Widgets";
toggleBtn.addEventListener("click", () => {
const nowVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden)));
if (nowVisible) {
this.hideSettingsWidgets();
const stillVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden)));
toggleBtn.textContent = stillVisible ? "Hide Widgets" : "Show Widgets";
} else {
this.showSettingsWidgets();
toggleBtn.textContent = "Hide Widgets";
}
});
gridContainer.appendChild(toggleBtn);
menu.appendChild(gridContainer);
const div2 = document.createElement("hr");
div2.className = "prcs-settings-divider";
menu.appendChild(div2);
// 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 = "prcs-segmented-control";
const framesSeg = document.createElement("div");
framesSeg.className = "prcs-segment";
framesSeg.textContent = "Frames";
const secondsSeg = document.createElement("div");
secondsSeg.className = "prcs-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));
}
// --- Show Filenames Toggle ---
const showFnameCtrl = document.createElement("div");
showFnameCtrl.className = "prcs-segmented-control";
const offSeg = document.createElement("div");
offSeg.className = "prcs-segment";
offSeg.textContent = "Off";
const onSeg = document.createElement("div");
onSeg.className = "prcs-segment";
onSeg.textContent = "On";
const updateFnameActive = (isEnabled) => {
if (isEnabled) {
onSeg.classList.add("active");
offSeg.classList.remove("active");
} else {
offSeg.classList.add("active");
onSeg.classList.remove("active");
}
};
updateFnameActive(!!this.node.properties.showFilenames);
const onFnameSegClick = (isEnabled) => {
this.node.properties.showFilenames = isEnabled;
updateFnameActive(isEnabled);
this.render();
this.commitChanges(true);
};
offSeg.addEventListener("click", () => onFnameSegClick(false));
onSeg.addEventListener("click", () => onFnameSegClick(true));
showFnameCtrl.appendChild(onSeg);
showFnameCtrl.appendChild(offSeg);
menu.appendChild(this._makeSettingRow("Show Filenames", showFnameCtrl));
const divider2 = document.createElement("div");
divider2.className = "prcs-settings-divider";
menu.appendChild(divider2);
// Helper to create scrubbable number control with horizontal buttons
const createScrubbableNumberControl = (w, step, min, max, isFloat = false) => {
const container = document.createElement("div");
container.className = "prcs-number-control";
const decBtn = document.createElement("button");
decBtn.className = "prcs-number-btn";
decBtn.textContent = "-";
const inp = document.createElement("input");
inp.type = "number";
inp.className = "prcs-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 = "prcs-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)));
}
// --- Divider ---
const folderDivider = document.createElement("div");
folderDivider.className = "prcs-settings-divider";
menu.appendChild(folderDivider);
// --- Workspace Folder Button ---
const btnOpenFolder = document.createElement("button");
btnOpenFolder.className = "prcs-settings-toggle-btn";
btnOpenFolder.textContent = "Open";
btnOpenFolder.style.width = "98px";
btnOpenFolder.style.margin = "0";
btnOpenFolder.addEventListener("click", async () => {
try {
const response = await api.fetchApi("/ltx_director_open_folder");
const data = await response.json();
if (!data.success) {
console.error("Failed to open workspace folder:", data.error || "Unknown error");
alert("Could not open workspace folder. This option is only supported when running ComfyUI locally.");
}
} catch (err) {
console.error("Error opening workspace folder:", err);
alert("Error opening workspace folder: " + err.message);
}
});
menu.appendChild(this._makeSettingRow("Workspace Folder", btnOpenFolder));
// --- Spacer + Analyze Backend section ---
const provDivider = document.createElement("div");
provDivider.className = "prcs-settings-divider";
menu.appendChild(provDivider);
const provTitle = document.createElement("div");
provTitle.className = "prcs-settings-title";
provTitle.style.marginBottom = "2px";
provTitle.textContent = "Analyze Backend";
menu.appendChild(provTitle);
const PROVIDER_DEFAULTS = {
"off": { url: "", model: "" },
"ollama": { url: "http://127.0.0.1:11434", model: "huihui_ai/qwen3.5-abliterated:2b" },
"lmstudio": { url: "http://127.0.0.1:1234", model: "" },
"custom": { url: "", model: "" },
};
if (!this.timeline.analyzeProvider) this.timeline.analyzeProvider = "ollama";
if (this.timeline.analyzeBaseUrl === undefined) this.timeline.analyzeBaseUrl = "";
if (this.timeline.analyzeModel === undefined) this.timeline.analyzeModel = "";
const provSelect = document.createElement("select");
provSelect.className = "prcs-settings-select";
[
{ v: "off", label: "Off / Manual (no Analyze)" },
{ v: "ollama", label: "Ollama" },
{ v: "lmstudio", label: "LM Studio" },
{ v: "custom", label: "Custom (OpenAI-compatible)" },
].forEach(o => {
const opt = document.createElement("option");
opt.value = o.v;
opt.textContent = o.label;
provSelect.appendChild(opt);
});
provSelect.value = this.timeline.analyzeProvider;
const urlInput = document.createElement("input");
urlInput.type = "text";
urlInput.className = "prcs-settings-input";
urlInput.style.width = "150px";
urlInput.style.textAlign = "left";
const modelInput = document.createElement("input");
modelInput.type = "text";
modelInput.className = "prcs-settings-input";
modelInput.style.width = "150px";
modelInput.style.textAlign = "left";
const urlRow = this._makeSettingRow("Base URL", urlInput);
const modelRow = this._makeSettingRow("Model", modelInput);
const refreshProviderRows = () => {
const prov = this.timeline.analyzeProvider || "ollama";
const defs = PROVIDER_DEFAULTS[prov] || PROVIDER_DEFAULTS.ollama;
urlInput.placeholder = defs.url || "http://your-server:port";
modelInput.placeholder = defs.model || "your-loaded-model-name";
urlInput.value = this.timeline.analyzeBaseUrl || "";
modelInput.value = this.timeline.analyzeModel || "";
const isOff = (prov === "off");
urlRow.style.display = isOff ? "none" : "";
modelRow.style.display = isOff ? "none" : "";
};
provSelect.addEventListener("change", (e) => {
this.timeline.analyzeProvider = e.target.value;
this.timeline.analyzeBaseUrl = "";
this.timeline.analyzeModel = "";
refreshProviderRows();
this.updateCharacterSlotsUI();
this.commitChanges(true);
});
urlInput.addEventListener("change", () => {
this.timeline.analyzeBaseUrl = urlInput.value.trim();
this.commitChanges(true);
});
modelInput.addEventListener("change", () => {
this.timeline.analyzeModel = modelInput.value.trim();
this.commitChanges(true);
});
menu.appendChild(this._makeSettingRow("Provider", provSelect));
menu.appendChild(urlRow);
menu.appendChild(modelRow);
const provNote = document.createElement("div");
provNote.style.fontSize = "9px";
provNote.style.color = "#777";
provNote.style.padding = "2px 4px 0";
provNote.style.lineHeight = "1.3";
provNote.textContent = "Off = type descriptions by hand. LM Studio / Custom: hard VRAM eviction depends on your server version; set a short JIT/auto-unload TTL there if it doesn't release.";
menu.appendChild(provNote);
refreshProviderRows();
// 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("pointerdown", this._settingsDismisser, true);
document.addEventListener("wheel", this._settingsDismisser, true);
}, 0);
}
dismissSettingsMenu() {
if (this._settingsMenu) { this._settingsMenu.remove(); this._settingsMenu = null; }
if (this._settingsDismisser) {
document.removeEventListener("pointerdown", this._settingsDismisser, true);
document.removeEventListener("wheel", this._settingsDismisser, true);
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);
if (!this.retakeMode) {
this.growTimelineIfNeeded(seg.start + seg.length);
}
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);
if (!this.retakeMode) {
this.growTimelineIfNeeded(seg.start + seg.length);
}
this.selectionType = "image";
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id);
this.updateUIFromSelection();
this.commitChanges();
}
updateSeekBarBackground() {
if (!this.seekBar) return;
const max = parseFloat(this.seekBar.max) || 1;
const val = parseFloat(this.seekBar.value) || 0;
const pct = (val / max) * 100;
this.seekBar.style.background = `linear-gradient(to right, #ff4444 0%, #ff4444 ${pct}%, #444 ${pct}%, #444 100%)`;
}
// --- 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;
this.updateSeekBarBackground();
}
if (this.timeCodeDisplay) {
this.timeCodeDisplay.textContent = this.formatTime(this.currentFrame);
}
}
togglePlay() {
if (this.isPlaying) {
this.pauseAudio();
} else {
const playMax = this.retakeMode
? (this.timeline.retakeVideo ? (this.timeline.retakeVideo.videoDurationFrames || this.getDurationFrames()) : this.getDurationFrames())
: this.getVisualDurationFrames();
if (this.currentFrame >= playMax) {
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;
// Build the list of active segments to play
const segmentsToPlay = [];
// 1. Standard Audio Segments on the audio track (only if the track is enabled and NOT in retake mode)
if (this.audioTrackEnabled && !this.retakeMode) {
if (this.timeline.audioSegments) {
for (let seg of this.timeline.audioSegments) {
segmentsToPlay.push({
type: 'audio',
originalSeg: seg,
start: seg.start,
length: seg.length,
trimStart: seg.trimStart || 0,
audioFile: seg.audioFile,
audioB64: seg.audioB64,
_blobUrl: seg._blobUrl,
fileSize: seg.fileSize
});
}
}
}
// 2. Motion Video Segments (only if overrideAudio toggle is ON and NOT in retake mode)
const isOverrideAudio = !!(this.node.properties.overrideAudio || this.timeline.overrideAudio);
if (isOverrideAudio && !this.retakeMode) {
if (this.timeline.motionSegments) {
for (let seg of this.timeline.motionSegments) {
if (seg.videoFile || seg._blobUrl) {
segmentsToPlay.push({
type: 'motion',
originalSeg: seg,
start: seg.start,
length: seg.length,
trimStart: seg.trimStart || 0,
audioFile: seg.videoFile || seg.fileName,
audioB64: null,
_blobUrl: seg._blobUrl,
fileSize: seg.fileSize
});
}
}
}
}
// Decode and schedule all scheduled segments that happen AT or AFTER currentFrame in the background
for (let item of segmentsToPlay) {
const segStartFrame = item.start;
const segEndFrame = item.start + item.length;
if (segEndFrame <= this.currentFrame) continue;
(async () => {
try {
// Build mock seg object for helper compatibility
const mockSeg = {
audioFile: item.audioFile,
audioB64: item.audioB64,
_blobUrl: item._blobUrl,
fileSize: item.fileSize,
waveformPeaks: item.originalSeg.waveformPeaks
};
await this._getOrExtractAudio(mockSeg);
if (this._currentPlayId !== playId || !this.isPlaying) return;
if (mockSeg.waveformPeaks && !item.originalSeg.waveformPeaks) {
item.originalSeg.waveformPeaks = mockSeg.waveformPeaks;
this.render();
}
if (!this._isAudioDecodingAllowed(mockSeg)) {
return;
}
// Build audio buffer
let audioBuffer = item.originalSeg._audioBuffer;
if (!audioBuffer) {
if (!mockSeg.audioFile && !mockSeg._blobUrl && !mockSeg.audioB64) {
return;
}
audioBuffer = await this._loadAudioBuffer(mockSeg);
item.originalSeg._audioBuffer = audioBuffer;
}
if (this._currentPlayId !== playId || !this.isPlaying) return;
// Determine current playback position dynamically in Web Audio time
const currentPlayTime = this.audioContext.currentTime;
const elapsedSecSincePlayStart = currentPlayTime - this.playbackStartTime;
const currentFrameCalculated = this.playbackStartFrame + elapsedSecSincePlayStart * frameRate;
// If playback has already moved beyond the segment end, skip playing it
if (currentFrameCalculated >= segEndFrame) return;
let startTime, fileOffsetSec, playDurationSec;
if (currentFrameCalculated < segStartFrame) {
// Segment starts in the future relative to current playback position
const waitFrames = segStartFrame - currentFrameCalculated;
const waitTimeSec = waitFrames / frameRate;
startTime = currentPlayTime + waitTimeSec;
fileOffsetSec = item.trimStart / frameRate;
playDurationSec = item.length / frameRate;
} else {
// Segment is already playing. Start immediately, but offset into the audio buffer
startTime = currentPlayTime;
const framesToSkip = currentFrameCalculated - segStartFrame;
fileOffsetSec = (item.trimStart + framesToSkip) / frameRate;
playDurationSec = (item.length - framesToSkip) / frameRate;
}
if (playDurationSec <= 0) return;
const bufferNode = this.audioContext.createBufferSource();
bufferNode.buffer = audioBuffer;
bufferNode["connect"](this.audioContext.destination);
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();
let loopBound, stopBound;
if (this.retakeMode) {
const retakeLimit = this.timeline.retakeVideo ? (this.timeline.retakeVideo.videoDurationFrames || durationFrames) : durationFrames;
loopBound = retakeLimit;
stopBound = retakeLimit;
} else {
loopBound = (this.playbackStartFrame >= durationFrames) ? visualDurationFrames : durationFrames;
stopBound = visualDurationFrames;
}
if (this.isLooping) {
if (this.currentFrame >= loopBound) {
this.currentFrame = 0;
this.playAudio(); // Restart playback
return;
}
} else {
if (this.currentFrame >= stopBound) {
this.currentFrame = stopBound;
this.pauseAudio();
this.render();
return;
}
}
// Sync video playback
if (this.retakeMode) {
if (this.timeline.retakeVideo) {
const retakeVid = this.timeline.retakeVideo;
this._ensureVideoEl(retakeVid);
if (retakeVid.videoEl) {
const expectedSec = this.currentFrame / frameRate;
if (retakeVid.videoEl.paused && !retakeVid.videoEl.seeking) {
retakeVid.videoEl.currentTime = expectedSec;
retakeVid.videoEl.muted = false;
retakeVid.videoEl.play().catch(e => console.warn("Retake video play prevented", e));
} else if (!retakeVid.videoEl.paused && Math.abs(retakeVid.videoEl.currentTime - expectedSec) > 0.5) {
retakeVid.videoEl.currentTime = expectedSec;
}
}
}
// Pause all other video elements
const allSegments = [...(this.timeline.segments || []), ...(this.timeline.motionSegments || [])];
for (const seg of allSegments) {
if (seg.videoEl && !seg.videoEl.paused) {
seg.videoEl.pause();
}
}
} else {
const activeSegments = (this._isDragging && this._previewSegments && this.selectionType !== "audio") ? this._previewSegments : this.timeline.segments;
const activeSeg = activeSegments.find(s => s.type === "video" && this.currentFrame >= s.start && this.currentFrame < s.start + s.length);
const activeVideoEl = activeSeg ? activeSeg.videoEl : null;
for (const seg of activeSegments) {
if (seg.type === "video" && seg.videoEl) {
if (seg === activeSeg) {
const expectedSec = (seg.trimStart + (this.currentFrame - seg.start)) / frameRate;
if (seg.videoEl.paused && !seg.videoEl.seeking) {
// Not playing and no seek in flight — start a fresh seek+play
seg.videoEl.currentTime = expectedSec;
seg.videoEl.play().catch(e => console.warn("Video play prevented", e));
} else if (!seg.videoEl.paused && Math.abs(seg.videoEl.currentTime - expectedSec) > 0.5) {
// Already playing but drifted — resync
seg.videoEl.currentTime = expectedSec;
}
// If paused && seeking: a seek+play is already in flight, let it finish
} else {
// Only pause if this segment's video element is NOT shared with the currently active segment
if (seg.videoEl !== activeVideoEl && !seg.videoEl.paused) {
seg.videoEl.pause();
}
}
}
}
}
// Sync motion playback
if (!this.retakeMode) {
const activeMotionSegments = (this._isDragging && this._previewSegments && this.selectionType === "motion") ? this._previewSegments : this.timeline.motionSegments;
const activeMotionSeg = activeMotionSegments.find(s => s.type === "motion_video" && this.currentFrame >= s.start && this.currentFrame < s.start + s.length);
const activeMotionVideoEl = activeMotionSeg ? activeMotionSeg.videoEl : null;
for (const seg of activeMotionSegments) {
if (seg.type === "motion_video" && seg.videoEl) {
if (seg === activeMotionSeg) {
const expectedSec = (seg.trimStart + (this.currentFrame - seg.start)) / frameRate;
if (seg.videoEl.paused && !seg.videoEl.seeking) {
// Not playing and no seek in flight — start a fresh seek+play
seg.videoEl.currentTime = expectedSec;
seg.videoEl.play().catch(e => console.warn("Video play prevented", e));
} else if (!seg.videoEl.paused && Math.abs(seg.videoEl.currentTime - expectedSec) > 0.5) {
// Already playing but drifted — resync
seg.videoEl.currentTime = expectedSec;
}
// If paused && seeking: a seek+play is already in flight, let it finish
} else {
// Only pause if this segment's video element is NOT shared with the currently active motion segment
if (seg.videoEl !== activeMotionVideoEl && !seg.videoEl.paused) {
seg.videoEl.pause();
}
}
}
}
}
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) { }
}
if (this.retakeMode && this.timeline.retakeVideo) {
const retakeVid = this.timeline.retakeVideo;
if (retakeVid.videoEl) {
if (!retakeVid.videoEl.paused) {
retakeVid.videoEl.pause();
}
retakeVid.videoEl.muted = true; // Mute again on pause/stop to prevent transient audio bursts
retakeVid.videoEl.currentTime = this.currentFrame / this.getFrameRate();
}
} else {
// Sync video segments on pause
for (const seg of this.timeline.segments) {
if (seg.type === "video" && seg.videoEl) {
if (!seg.videoEl.paused) {
seg.videoEl.pause();
}
if (this.currentFrame >= seg.start && this.currentFrame < seg.start + seg.length) {
seg.videoEl.currentTime = (seg.trimStart + (this.currentFrame - seg.start)) / this.getFrameRate();
}
}
}
// Sync motion segments on pause
for (const seg of this.timeline.motionSegments) {
if (seg.type === "motion_video" && seg.videoEl) {
if (!seg.videoEl.paused) {
seg.videoEl.pause();
}
if (this.currentFrame >= seg.start && this.currentFrame < seg.start + seg.length) {
seg.videoEl.currentTime = (seg.trimStart + (this.currentFrame - seg.start)) / this.getFrameRate();
}
}
}
}
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: "LTXDirectorCS",
async setup() {
// On Run, ask the chosen analyze backend to release its model from VRAM so it doesn't
// compete with LTX generation. Only fires when an LTX Director is in the graph and its
// provider isn't "off". Fully tolerant: failures are swallowed so they never block a run.
if (app._ltxDirectorUnloadHookInstalled) return;
app._ltxDirectorUnloadHookInstalled = true;
const origQueuePrompt = app.queuePrompt;
app.queuePrompt = async function (...args) {
try {
const nodes = app.graph?._nodes || [];
const director = nodes.find(n => n && (n.comfyClass === "LTXDirectorCS" || n.type === "LTXDirectorCS"));
if (director) {
// Read provider settings from the node's saved timeline_data widget.
let provider = "ollama", baseUrl = "", model = "";
try {
const tdWidget = director.widgets?.find(w => w.name === "timeline_data");
if (tdWidget && tdWidget.value) {
const td = JSON.parse(tdWidget.value);
provider = td.analyzeProvider || "ollama";
baseUrl = td.analyzeBaseUrl || "";
model = td.analyzeModel || "";
}
} catch (e) {}
if (provider !== "off") {
try {
await api.fetchApi("/ltx_director/unload_ollama", {
method: "POST",
body: JSON.stringify({ provider, base_url: baseUrl, model }),
});
} catch (e) {}
}
}
} catch (e) {}
return origQueuePrompt.apply(this, args);
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "LTXDirectorCS") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
if (!this.properties) this.properties = {};
const DEFAULTS = {
global_prompt: "",
mainTrackEnabled: true,
audioTrackEnabled: true,
motionTrackEnabled: true,
audioTrackWasEnabledBeforeOverride: false,
inpaint_audio: true,
override_audio: false,
overrideAudio: false,
showFilenames: true,
use_custom_audio: false,
use_custom_motion: true,
frame_rate: 24,
display_mode: "seconds",
custom_width: 0,
custom_height: 0,
resize_method: "maintain aspect ratio",
divisible_by: 32,
img_compression: 18,
guide_strength: "",
local_prompts: "",
segment_lengths: "",
timeline_data: "{}",
epsilon: 0.001,
start_second: 0.0,
end_second: 5.0,
duration_seconds: 5.0,
start_frame: 0,
end_frame: 120,
duration_frames: 120,
};
for (const [key, val] of Object.entries(DEFAULTS)) {
if (this.properties[key] === undefined) {
this.properties[key] = val;
}
}
for (const [name, def] of APPENDED_WIDGET_DEFAULTS) {
if (!this.widgets?.find(w => w.name === name)) {
this.addWidget("string", name, def, () => { });
}
}
const isLiteGraph = !window.LiteGraph || !window.LiteGraph.vueNodesMode;
for (const w of this.widgets) {
if (HIDDEN_WIDGET_NAMES.includes(w.name)) {
hideWidget(w);
if (isLiteGraph && this.inputs) {
const idx = this.inputs.findIndex(i => i.name === w.name);
if (idx !== -1 && this.inputs[idx].link == null) {
this.removeInput(idx);
}
}
}
}
// Set default width to be wider on creation (approx 2.5x default ~220px)
this.size[0] = 1375;
// 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;
}
const self = this;
this._syncGlobalPromptFromLink = function () {
const globalInput = self.inputs?.find(i => i.name === "global_prompt");
if (globalInput && globalInput.link !== null && globalInput.link !== undefined) {
const link = app.graph.links[globalInput.link];
if (link) {
const originNode = app.graph.getNodeById(link.origin_id);
if (originNode) {
// Usually string values are in widgets[0] for primitives
if (originNode.widgets && originNode.widgets.length > 0) {
const val = originNode.widgets[0].value;
if (self._timelineEditor && self._timelineEditor.globalPromptInput) {
const isRetake = self._timelineEditor.retakeMode;
const currentValInEditor = isRetake ? (self._timelineEditor.timeline.retake_global_prompt || "") : (self._timelineEditor.timeline.global_prompt || "");
if (val !== currentValInEditor) {
if (isRetake) {
self._timelineEditor.timeline.retake_global_prompt = val;
} else {
self._timelineEditor.timeline.global_prompt = val;
}
self._timelineEditor.globalPromptInput.value = val;
if (self._timelineEditor.selectionType === "motion") {
self._timelineEditor.promptInput.value = val;
}
if (self.properties) {
self.properties.global_prompt = val;
}
} else if (self._timelineEditor.globalPromptInput.value !== val) {
self._timelineEditor.globalPromptInput.value = val;
}
}
}
}
}
} else {
if (self.properties && self._timelineEditor && self._timelineEditor.globalPromptInput) {
const val = self.properties.global_prompt || "";
const isRetake = self._timelineEditor.retakeMode;
const currentValInEditor = isRetake ? (self._timelineEditor.timeline.retake_global_prompt || "") : (self._timelineEditor.timeline.global_prompt || "");
if (val !== currentValInEditor) {
if (isRetake) {
self._timelineEditor.timeline.retake_global_prompt = val;
} else {
self._timelineEditor.timeline.global_prompt = val;
}
self._timelineEditor.globalPromptInput.value = val;
if (self._timelineEditor.selectionType === "motion") {
self._timelineEditor.promptInput.value = val;
}
} else if (self._timelineEditor.globalPromptInput.value !== val) {
self._timelineEditor.globalPromptInput.value = val;
}
}
}
};
const origOnConnectionsChange = this.onConnectionsChange;
this.onConnectionsChange = function (type, index, connected, link_info) {
if (origOnConnectionsChange) {
origOnConnectionsChange.apply(this, arguments);
}
self._syncGlobalPromptFromLink();
};
const origOnDrawForeground = this.onDrawForeground;
this.onDrawForeground = function (ctx) {
if (origOnDrawForeground) {
origOnDrawForeground.apply(this, arguments);
}
self._syncGlobalPromptFromLink();
};
// --- LTX Director settings panel (Stage 1: Resolution | Timing/Reference) ---
const _ltxBuildSettingsPanel = (node, panelRoot) => {
const getW = (name) => (node.widgets ? node.widgets.find(w => w.name === name) : null);
const setW = (name, val) => {
const w = getW(name);
if (!w) return;
w.value = val;
if (w.callback) { try { w.callback(val); } catch (e) {} }
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
};
// Frame-rate change: render length comes from duration_frames, so we must recompute
// the frame-count widgets from the (constant) seconds values x new fps. The editor's
// own recompute is wired for the fps slider; a dropdown/number change needs this.
const applyFrameRate = (newFPS) => {
const w = getW("frame_rate");
const oldFPS = (w && parseInt(w.value) > 0) ? parseInt(w.value) : 24;
if (w) w.value = newFPS;
const syncFrm = (secName, frmName, minV) => {
const sw = getW(secName), fw = getW(frmName);
if (sw && fw) fw.value = Math.max(minV, Math.round((parseFloat(sw.value) || 0) * newFPS));
};
syncFrm("start_second", "start_frame", 0);
syncFrm("end_second", "end_frame", 1);
syncFrm("duration_seconds", "duration_frames", 1);
const ed = node._timelineEditor;
if (ed) {
try {
ed._prevFrameRate = oldFPS;
if (ed._rebaseSegmentsToFPS) ed._rebaseSegmentsToFPS(newFPS);
ed._prevFrameRate = newFPS;
if (ed.commitChanges) ed.commitChanges();
} catch (e) { console.error("[LTXDirector] fps recompute:", e); }
}
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
};
Object.assign(panelRoot.style, {
display: "flex", gap: "8px", width: "100%", boxSizing: "border-box", padding: "0 2px",
});
const mkCol = (title) => {
const col = document.createElement("div");
Object.assign(col.style, {
flex: "1", minWidth: "0", display: "flex", flexDirection: "column", gap: "5px",
background: "#1e1e1e", border: "1px solid #3a3a3a", borderRadius: "8px", padding: "8px",
});
const h = document.createElement("div");
h.textContent = title;
Object.assign(h.style, {
fontSize: "9px", fontWeight: "700", color: "#7a7a7a", letterSpacing: "0.6px",
textTransform: "uppercase", marginBottom: "1px",
});
col.appendChild(h);
return col;
};
const mkRow = (labelText) => {
const row = document.createElement("div");
Object.assign(row.style, {
display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", minHeight: "20px",
});
const lab = document.createElement("span");
lab.textContent = labelText;
Object.assign(lab.style, { color: "#9a9a9a", fontSize: "11px", flexShrink: "0" });
row.appendChild(lab);
return row;
};
const sIn = (el, w) => Object.assign(el.style, {
background: "#2b2b2b", border: "1px solid #484848", borderRadius: "4px", color: "#eaeaea",
padding: "1px 5px", fontSize: "11px", width: (w || "86px"), boxSizing: "border-box",
textAlign: "right", outline: "none",
});
const sSel = (el, w) => Object.assign(el.style, {
background: "#2b2b2b", border: "1px solid #484848", borderRadius: "4px", color: "#eaeaea",
padding: "1px 4px", fontSize: "11px", width: (w || "126px"), boxSizing: "border-box", outline: "none",
});
// ---------- LEFT: Resolution ----------
const left = mkCol("Resolution");
const RES = [
{ label: "Custom", w: 0, h: 0 },
{ label: "Full HD \u2014 1920\u00d71088", w: 1920, h: 1088 },
{ label: "Portrait \u2014 1088\u00d71920", w: 1088, h: 1920 },
{ label: "HD \u2014 1280\u00d7704", w: 1280, h: 704 },
{ label: "Square \u2014 1024\u00d71024", w: 1024, h: 1024 },
{ label: "Small \u2014 768\u00d7512", w: 768, h: 512 },
];
const presetRow = mkRow("Preset");
const presetSel = document.createElement("select"); sSel(presetSel);
RES.forEach((p, i) => { const o = document.createElement("option"); o.value = String(i); o.textContent = p.label; presetSel.appendChild(o); });
presetRow.appendChild(presetSel); left.appendChild(presetRow);
const widthRow = mkRow("Width");
const widthIn = document.createElement("input"); widthIn.type = "number"; widthIn.step = "32"; widthIn.min = "0"; sIn(widthIn);
widthRow.appendChild(widthIn); left.appendChild(widthRow);
const heightRow = mkRow("Height");
const heightIn = document.createElement("input"); heightIn.type = "number"; heightIn.step = "32"; heightIn.min = "0"; sIn(heightIn);
heightRow.appendChild(heightIn); left.appendChild(heightRow);
const wW = getW("custom_width"), hW = getW("custom_height");
widthIn.value = wW ? wW.value : 768;
heightIn.value = hW ? hW.value : 512;
const syncPreset = () => {
const cw = parseInt(widthIn.value) || 0, ch = parseInt(heightIn.value) || 0;
const idx = RES.findIndex(p => p.w === cw && p.h === ch);
presetSel.value = String(idx > 0 ? idx : 0);
};
presetSel.addEventListener("change", () => {
const p = RES[parseInt(presetSel.value)];
if (p && p.w > 0) {
widthIn.value = p.w; heightIn.value = p.h;
setW("custom_width", p.w); setW("custom_height", p.h);
}
});
widthIn.addEventListener("change", () => { let v = Math.round(parseFloat(widthIn.value)); if (isNaN(v) || v < 0) v = 0; widthIn.value = v; setW("custom_width", v); syncPreset(); });
heightIn.addEventListener("change", () => { let v = Math.round(parseFloat(heightIn.value)); if (isNaN(v) || v < 0) v = 0; heightIn.value = v; setW("custom_height", v); syncPreset(); });
const FPS = [24, 25, 30, 48, 60];
const fpsRow = mkRow("Frame rate");
const fpsWrap = document.createElement("div"); Object.assign(fpsWrap.style, { display: "flex", gap: "4px", alignItems: "center" });
const fpsSel = document.createElement("select"); sSel(fpsSel, "62px");
const oCustom = document.createElement("option"); oCustom.value = "0"; oCustom.textContent = "Custom"; fpsSel.appendChild(oCustom);
FPS.forEach(v => { const o = document.createElement("option"); o.value = String(v); o.textContent = v + " fps"; fpsSel.appendChild(o); });
const fpsIn = document.createElement("input"); fpsIn.type = "number"; fpsIn.min = "1"; sIn(fpsIn, "48px");
const frW = getW("frame_rate"); fpsIn.value = frW ? frW.value : 24;
const syncFps = () => { const v = parseInt(fpsIn.value) || 0; fpsSel.value = (FPS.indexOf(v) >= 0) ? String(v) : "0"; };
fpsSel.addEventListener("change", () => { const v = parseInt(fpsSel.value) || 0; if (v > 0) { fpsIn.value = v; applyFrameRate(v); } });
fpsIn.addEventListener("change", () => { let v = Math.round(parseFloat(fpsIn.value)); if (isNaN(v) || v < 1) v = 1; fpsIn.value = v; applyFrameRate(v); syncFps(); });
fpsWrap.appendChild(fpsSel); fpsWrap.appendChild(fpsIn);
fpsRow.appendChild(fpsWrap); left.appendChild(fpsRow);
syncPreset(); syncFps();
// ---- shared seconds<->frames timing infrastructure ----
const TIME_NAMES = ["start_second", "end_second", "duration_seconds", "start_frame", "end_frame", "duration_frames"];
const timeMode = () => { const dm = getW("display_mode"); return (dm && dm.value === "frames") ? "frames" : "seconds"; };
const hideTimingWidgets = () => {
const isLG = !window.LiteGraph || !window.LiteGraph.vueNodesMode;
for (const nm of TIME_NAMES) {
const w = getW(nm);
if (w) hideWidget(w);
if (isLG && node.inputs) {
const i = node.inputs.findIndex(sl => sl.name === nm);
if (i !== -1 && node.inputs[i].link == null) node.removeInput(i);
}
}
};
const ensureTimingHidden = () => {
const ed = node._timelineEditor;
if (ed && typeof ed.updateWidgetVisibility === "function" && !ed._ltxTimingPatched) {
const orig = ed.updateWidgetVisibility.bind(ed);
ed.updateWidgetVisibility = function () { orig(); hideTimingWidgets(); };
ed._ltxTimingPatched = true;
}
hideTimingWidgets();
};
const timeRefreshers = [];
const mkTimeRow = (parentCol, labelText, secName, frmName, minSec, minFrm) => {
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", minHeight: "20px" });
const labWrap = document.createElement("div");
Object.assign(labWrap.style, { display: "flex", alignItems: "baseline", gap: "4px" });
const lab = document.createElement("span"); lab.textContent = labelText;
Object.assign(lab.style, { color: "#9a9a9a", fontSize: "11px" });
const unit = document.createElement("span"); Object.assign(unit.style, { color: "#666", fontSize: "9px" });
labWrap.appendChild(lab); labWrap.appendChild(unit);
const input = document.createElement("input"); input.type = "number"; sIn(input, "86px");
row.appendChild(labWrap); row.appendChild(input); parentCol.appendChild(row);
const refresh = () => {
if (timeMode() === "frames") { const w = getW(frmName); if (w) input.value = w.value; input.step = "1"; unit.textContent = "fr"; }
else { const w = getW(secName); if (w) input.value = w.value; input.step = "0.01"; unit.textContent = "s"; }
};
input.addEventListener("change", () => {
let v = parseFloat(input.value);
if (isNaN(v)) v = 0;
if (timeMode() === "frames") { v = Math.max(minFrm, Math.round(v)); setW(frmName, v); }
else { v = Math.max(minSec, v); setW(secName, parseFloat(v.toFixed(3))); }
timeRefreshers.forEach(fn => fn());
});
timeRefreshers.push(refresh);
refresh();
return input;
};
mkTimeRow(left, "Duration", "duration_seconds", "duration_frames", 0.1, 1);
// ---------- RIGHT: Timing / Reference ----------
const right = mkCol("Timing / Reference");
const unitRow = mkRow("Units");
const unitSel = document.createElement("select"); sSel(unitSel, "100px");
[["seconds", "Seconds"], ["frames", "Frames"]].forEach(([v, l]) => { const o = document.createElement("option"); o.value = v; o.textContent = l; unitSel.appendChild(o); });
unitSel.value = timeMode();
unitSel.addEventListener("change", () => {
const w = getW("display_mode");
const mode = unitSel.value;
if (w) { w.value = mode; if (w.callback) { try { w.callback(mode); } catch (e) {} } }
ensureTimingHidden();
timeRefreshers.forEach(fn => fn());
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
});
unitRow.appendChild(unitSel); right.appendChild(unitRow);
mkTimeRow(right, "Start", "start_second", "start_frame", 0, 0);
mkTimeRow(right, "End", "end_second", "end_frame", 0, 1);
const rmRow = mkRow("Resize");
const rmSel = document.createElement("select"); sSel(rmSel);
const rmW = getW("resize_method");
let rmVals = (rmW && rmW.options && rmW.options.values) ? rmW.options.values : null;
if (!rmVals || !rmVals.length) rmVals = ["stretch to fit", "maintain aspect ratio", "crop to fit"];
rmVals.forEach(v => { const o = document.createElement("option"); o.value = v; o.textContent = v; rmSel.appendChild(o); });
if (rmW) rmSel.value = rmW.value;
rmSel.addEventListener("change", () => setW("resize_method", rmSel.value));
rmRow.appendChild(rmSel); right.appendChild(rmRow);
const refRow = mkRow("Ref strength");
const refIn = document.createElement("input"); refIn.type = "number"; refIn.step = "0.05"; refIn.min = "0"; sIn(refIn);
const rsW = getW("reference_strength"); refIn.value = rsW ? rsW.value : 1.0;
refIn.addEventListener("change", () => { let v = parseFloat(refIn.value); if (isNaN(v) || v < 0) v = 0; refIn.value = v; setW("reference_strength", v); });
refRow.appendChild(refIn); right.appendChild(refRow);
panelRoot.appendChild(left);
panelRoot.appendChild(right);
// Re-read widget values into the panel. Saved values are restored AFTER onNodeCreated,
// so we must refresh on load (onConfigure + a post-tick) or the panel shows defaults.
const refreshFromWidgets = () => {
const cw = getW("custom_width"), ch = getW("custom_height"), fr = getW("frame_rate"),
rm = getW("resize_method"), rs = getW("reference_strength");
if (cw) widthIn.value = cw.value;
if (ch) heightIn.value = ch.value;
if (fr) fpsIn.value = fr.value;
if (rm && rm.value != null) rmSel.value = rm.value;
if (rs) refIn.value = rs.value;
syncPreset(); syncFps();
if (typeof unitSel !== "undefined" && unitSel) unitSel.value = timeMode();
timeRefreshers.forEach(fn => fn());
ensureTimingHidden();
};
node._ltxSettingsRefresh = refreshFromWidgets;
refreshFromWidgets();
setTimeout(refreshFromWidgets, 0);
requestAnimationFrame(refreshFromWidgets);
setTimeout(refreshFromWidgets, 60);
setTimeout(refreshFromWidgets, 250);
["custom_width", "custom_height", "frame_rate", "resize_method", "reference_strength"].forEach(n => { hideWidget(getW(n)); });
ensureTimingHidden();
};
const settingsContainer = document.createElement("div");
settingsContainer.style.boxSizing = "border-box";
_ltxBuildSettingsPanel(this, settingsContainer);
const settingsWidget = this.addDOMWidget("ltx_settings_ui", "ltx_settings_ui", settingsContainer, {
getValue: () => "",
setValue: () => { },
});
settingsWidget.computeSize = function () { return [0, 184]; };
const _ltxOrigOnConfigure = this.onConfigure;
this.onConfigure = function () {
if (_ltxOrigOnConfigure) _ltxOrigOnConfigure.apply(this, arguments);
if (this._ltxSettingsRefresh) { try { this._ltxSettingsRefresh(); } catch (e) {} }
};
const container = document.createElement("div");
container.style.boxSizing = "border-box";
const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, {
getValue: () => "",
setValue: () => { },
});
widget.computeSize = function (width) {
const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT;
const propH = self._timelineEditor ? (self._timelineEditor.propHeight || 90) : 90;
const globalPropH = self._timelineEditor ? (self._timelineEditor.globalPropHeight || 60) : 60;
// Reserve room for the @char reference panel at the bottom so the node doesn't
// collapse and crop it whenever ComfyUI recomputes the node height.
const charPanelH = self._timelineEditor ? (self._timelineEditor.charPanelHeight || 150) : 150;
const nodeWidth = self.size?.[0] || width || 1375;
return [Math.max(10, nodeWidth - 30), canvasH + propH + globalPropH + charPanelH + 160];
};
setTimeout(() => {
try {
self._timelineEditor = new TimelineEditor(self, container, widget);
} catch (err) {
console.error("[PromptRelay] timeline editor init failed:", err);
}
}, 0);
};
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
const out = onResize?.apply(this, arguments);
if (this._timelineEditor) {
requestAnimationFrame(() => this._timelineEditor?.syncLayoutToNode());
}
return out;
};
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) {
// 1. Call parent/original onConfigure first, with info.widgets_values intact
const out = onConfigure ? onConfigure.apply(this, arguments) : undefined;
if (info.properties) {
this.properties = { ...this.properties, ...info.properties };
}
console.log("[LTXDirector debug] onConfigure called. info.widgets_values:", info.widgets_values ? JSON.stringify(info.widgets_values) : "undefined");
// Helper to set widget value, sync DOM element, and trigger callbacks safely
const setWidgetValue = (w, val) => {
if (!w) return;
w.value = val;
if (w.element) {
if (w.element.type === "checkbox") {
w.element.checked = !!val;
} else {
w.element.value = val;
}
}
if (w.callback) {
try {
w.callback(val);
} catch (e) {
// ignore
}
}
};
// 2. Check if we have serialized properties. If so, restore widgets from properties!
if (info.properties && info.properties.has_serialized_properties) {
console.log("[LTXDirector debug] Restoring widgets from properties");
if (this.widgets) {
for (const w of this.widgets) {
if (w.name && this.properties[w.name] !== undefined) {
setWidgetValue(w, this.properties[w.name]);
}
}
}
} else if (info.widgets_values) {
// Fallback to name-based schema mapping for older workflows
console.log("[LTXDirector debug] Restoring widgets via fallback name-based schema mapping");
const SCHEMA_19 = [
"start_frame", "end_frame", "duration_frames",
"timeline_data", "use_custom_audio", "use_custom_motion", "inpaint_audio", "local_prompts", "segment_lengths",
"epsilon", "frame_rate", "display_mode", "guide_strength", "custom_width", "custom_height",
"resize_method", "divisible_by", "img_compression", "timeline_ui"
];
const SCHEMA_21_NO_INPAINT = [
"start_second", "end_second", "duration_seconds", "start_frame", "end_frame", "duration_frames",
"timeline_data", "local_prompts", "segment_lengths", "epsilon", "guide_strength",
"use_custom_audio", "use_custom_motion", "frame_rate", "display_mode", "custom_width", "custom_height",
"resize_method", "divisible_by", "img_compression", "timeline_ui"
];
const SCHEMA_22_NO_INPAINT = [
"start_second", "end_second", "duration_seconds", "start_frame", "end_frame", "duration_frames",
"timeline_data", "local_prompts", "segment_lengths", "epsilon", "guide_strength",
"use_custom_audio", "use_custom_motion", "frame_rate", "display_mode", "custom_width", "custom_height",
"resize_method", "divisible_by", "img_compression", "override_audio", "timeline_ui"
];
const SCHEMA_22_WITH_INPAINT = [
"start_second", "end_second", "duration_seconds", "start_frame", "end_frame", "duration_frames",
"timeline_data", "use_custom_audio", "use_custom_motion", "inpaint_audio", "local_prompts", "segment_lengths",
"epsilon", "frame_rate", "display_mode", "guide_strength", "custom_width", "custom_height",
"resize_method", "divisible_by", "img_compression", "timeline_ui"
];
const SCHEMA_23 = [
"start_second", "end_second", "duration_seconds", "start_frame", "end_frame", "duration_frames",
"timeline_data", "use_custom_audio", "use_custom_motion", "inpaint_audio", "local_prompts", "segment_lengths",
"epsilon", "frame_rate", "display_mode", "guide_strength", "custom_width", "custom_height",
"resize_method", "divisible_by", "img_compression", "override_audio", "timeline_ui"
];
const ALL_WIDGET_DEFAULTS = {
inpaint_audio: true,
override_audio: false,
use_custom_audio: false,
use_custom_motion: true,
frame_rate: 24,
display_mode: "seconds",
custom_width: 0,
custom_height: 0,
resize_method: "maintain aspect ratio",
divisible_by: 32,
img_compression: 18,
guide_strength: "",
local_prompts: "",
segment_lengths: "",
timeline_data: "{}",
epsilon: 0.001,
start_second: 0.0,
end_second: 5.0,
duration_seconds: 5.0,
start_frame: 0,
end_frame: 120,
duration_frames: 120,
};
let names = SCHEMA_23;
const len = info.widgets_values.length;
if (len <= 19) {
names = SCHEMA_19;
} else if (len === 21) {
names = SCHEMA_21_NO_INPAINT;
} else if (len === 22) {
if (typeof info.widgets_values[13] === "number") {
names = SCHEMA_22_NO_INPAINT;
} else {
names = SCHEMA_22_WITH_INPAINT;
}
}
if (this.widgets) {
for (const w of this.widgets) {
const schemaIdx = names.indexOf(w.name);
if (schemaIdx !== -1 && schemaIdx < len) {
setWidgetValue(w, info.widgets_values[schemaIdx]);
} else if (ALL_WIDGET_DEFAULTS.hasOwnProperty(w.name)) {
setWidgetValue(w, ALL_WIDGET_DEFAULTS[w.name]);
}
}
}
// Populate properties with these restored values
if (this.widgets) {
for (const w of this.widgets) {
if (w.name && w.value !== undefined) {
this.properties[w.name] = w.value;
}
}
}
this.properties.has_serialized_properties = true;
}
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) {
console.log("[LTXDirector debug] setTimeout sync block called.");
console.log("[LTXDirector debug] setTimeout: timelineDataWidget value:", this._timelineEditor.timelineDataWidget?.value);
const tl = parseInitial(this._timelineEditor.timelineDataWidget?.value);
console.log("[LTXDirector debug] setTimeout: parsed timeline:", JSON.stringify(tl));
this._timelineEditor.timeline = tl;
// Sync editor states from the parsed timeline object (the absolute source of truth)
this._timelineEditor.mainTrackEnabled = tl.mainTrackEnabled !== false;
this._timelineEditor.audioTrackEnabled = tl.audioTrackEnabled !== false;
this._timelineEditor.motionTrackEnabled = tl.motionTrackEnabled !== false;
this._timelineEditor.retakeMode = tl.retakeMode === true;
this._timelineEditor._audioTrackWasEnabledBeforeOverride = !!this.properties.audioTrackWasEnabledBeforeOverride;
// Sync properties to match
this.properties.mainTrackEnabled = this._timelineEditor.mainTrackEnabled;
this.properties.audioTrackEnabled = this._timelineEditor.audioTrackEnabled;
this.properties.motionTrackEnabled = this._timelineEditor.motionTrackEnabled;
this.properties.retakeMode = this._timelineEditor.retakeMode;
if (tl.showFilenames !== undefined) {
this.properties.showFilenames = tl.showFilenames;
}
if (tl.overrideAudio !== undefined) {
this.properties.overrideAudio = tl.overrideAudio;
}
if (tl.inpaint_audio !== undefined) {
this.properties.inpaint_audio = tl.inpaint_audio;
}
// Sync widgets to match the timeline data
const inpaintWidget = this.widgets?.find(w => w.name === "inpaint_audio");
if (inpaintWidget && tl.inpaint_audio !== undefined) {
inpaintWidget.value = tl.inpaint_audio;
}
const overrideWidget = this.widgets?.find(w => w.name === "override_audio");
if (overrideWidget && tl.overrideAudio !== undefined) {
overrideWidget.value = tl.overrideAudio;
}
this._timelineEditor.loadMedia();
this._timelineEditor.selectionType = "image";
this._timelineEditor.selectedIndex = clamp(
this._timelineEditor.selectedIndex, -1,
Math.max(-1, this._timelineEditor.timeline.segments.length - 1)
);
this._timelineEditor.updateRetakeUIState();
this._timelineEditor.updateUIFromSelection();
this._timelineEditor.syncWidgetsAndUI();
this._timelineEditor.syncLayoutToNode();
this._timelineEditor.render();
}
}, 0);
return out;
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (info) {
if (onSerialize) {
onSerialize.apply(this, arguments);
}
// Sync all current widgets to properties
if (this.widgets) {
for (const w of this.widgets) {
if (w.name && w.value !== undefined) {
this.properties[w.name] = w.value;
}
}
}
// Sync timeline editor state if it exists
if (this._timelineEditor) {
this.properties.mainTrackEnabled = this._timelineEditor.mainTrackEnabled !== false;
this.properties.audioTrackEnabled = this._timelineEditor.audioTrackEnabled !== false;
this.properties.motionTrackEnabled = this._timelineEditor.motionTrackEnabled !== false;
this.properties.audioTrackWasEnabledBeforeOverride = !!this._timelineEditor._audioTrackWasEnabledBeforeOverride;
}
// Mark that properties have been serialized
this.properties.has_serialized_properties = true;
// Ensure info.properties is populated with all our properties
info.properties = { ...this.properties };
};
}
},
});