| const { app } = window.comfyAPI.app; |
| const { api } = window.comfyAPI.api; |
|
|
| |
| const RULER_HEIGHT = 24; |
| const BLOCK_HEIGHT = 160; |
| const AUDIO_TRACK_HEIGHT = 80; |
| const CANVAS_HEIGHT = RULER_HEIGHT + BLOCK_HEIGHT + AUDIO_TRACK_HEIGHT; |
| const HANDLE_HIT_PX = 14; |
| const MIN_SEGMENT_LENGTH = 6; |
| const MAX_THUMBNAIL_DIM = 512; |
|
|
| const HIDDEN_WIDGET_NAMES = ["timeline_data", "local_prompts", "segment_lengths", "guide_strength", "audio_data", "use_custom_audio"]; |
|
|
| function hideWidget(w) { |
| if (!w) return; |
| if (!w._origType && w.type !== "hidden") w._origType = w.type; |
| |
| |
| w.hidden = true; |
| if (!w.options) w.options = {}; |
| w.options.hidden = true; |
| w.computeSize = () => [0, 0]; |
| if (w.element) w.element.style.display = "none"; |
| } |
|
|
| function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } |
|
|
| |
| const STYLES = ` |
| .pr-wrapper { |
| font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| width: 100%; |
| height: 100%; |
| box-sizing: border-box; |
| padding-bottom: 4px; |
| } |
| .pr-wrapper.drag-active { |
| outline: 2px dashed #888; |
| background: rgba(255, 255, 255, 0.05); |
| border-radius: 6px; |
| } |
| .pr-toolbar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 2px 0px; |
| flex-wrap: wrap; |
| gap: 6px; |
| } |
| .pr-actions { |
| display: flex; |
| gap: 6px; |
| flex-wrap: wrap; |
| } |
| .pr-btn { |
| background: #222; |
| color: #e0e0e0; |
| border: 1px solid #111; |
| border-radius: 4px; |
| padding: 6px 12px; |
| font-size: 11px; |
| font-weight: 500; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| transition: all 0.2s ease; |
| } |
| .pr-btn:hover { |
| background: #333; |
| border-color: #555; |
| } |
| .pr-btn-danger:hover { |
| background: #4a1515; |
| border-color: #cc4444; |
| color: #ffaaaa; |
| } |
| .pr-canvas { |
| border-radius: 6px; |
| border: 1px solid #111; |
| background: #2a2a2a; |
| cursor: pointer; |
| width: 100%; |
| outline: none; |
| display: block; /* Ensure no inline baseline gaps */ |
| } |
| .pr-prop-container { |
| display: flex; |
| flex-direction: column; |
| width: 100%; |
| flex-grow: 1; /* Automatically scales to fill node height */ |
| min-height: 40px; |
| } |
| .pr-prompt-area { |
| width: 100%; |
| height: 100%; |
| background: #222; |
| color: #e0e0e0; |
| border: 1px solid #111; |
| border-radius: 6px; |
| padding: 8px; |
| resize: none; /* Removed the manual resize corner handle */ |
| font-size: 12px; |
| line-height: 1.4; |
| box-sizing: border-box; |
| outline: none; |
| transition: border-color 0.2s ease; |
| } |
| .pr-prompt-area:focus { |
| border-color: #888; |
| } |
| .pr-audio-info { |
| width: 100%; |
| height: 100%; |
| background: #181818; |
| color: #aaa; |
| border: 1px solid #111; |
| border-radius: 6px; |
| padding: 10px; |
| font-size: 12px; |
| line-height: 1.6; |
| box-sizing: border-box; |
| display: none; |
| } |
| .pr-audio-info span { color: #fff; font-weight: 500; } |
| .pr-controls-group { |
| background: #1e1e1e; |
| border: 1px solid #333; |
| border-radius: 6px; |
| padding: 6px 10px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| margin-bottom: 4px; |
| box-sizing: border-box; |
| width: 100%; |
| } |
| .pr-strength-row { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| .pr-height-resizer { |
| height: 6px; |
| background: #2a2a2a; |
| cursor: ns-resize; |
| border-radius: 3px; |
| margin: 2px 0; |
| transition: background 0.15s; |
| border: 1px solid #1e1e1e; |
| } |
| .pr-height-resizer:hover { |
| background: #444; |
| border-color: #555; |
| } |
| .pr-strength-label { |
| font-size: 11px; |
| font-weight: 600; |
| color: #fff; |
| white-space: nowrap; |
| margin-left: auto; |
| } |
| .pr-strength-slider { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 80px; |
| height: 4px; |
| background: #444; |
| border-radius: 2px; |
| outline: none; |
| cursor: pointer; |
| border: 1px solid #222; |
| } |
| .pr-strength-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| background: #aaa; |
| cursor: pointer; |
| } |
| .pr-strength-slider:disabled { |
| opacity: 0.3; |
| cursor: not-allowed; |
| } |
| .pr-strength-input { |
| font-size: 12px; |
| color: #fff; |
| background: #222; |
| border: 1px solid #444; |
| border-radius: 4px; |
| width: 52px; |
| text-align: center; |
| padding: 3px; |
| } |
| .pr-strength-input::-webkit-outer-spin-button, |
| .pr-strength-input::-webkit-inner-spin-button { |
| -webkit-appearance: none; |
| margin: 0; |
| } |
| .pr-strength-input[type=number] { |
| -moz-appearance: textfield; |
| } |
| .pr-strength-input:disabled { |
| opacity: 0.35; |
| cursor: not-allowed; |
| } |
| .pr-gap-menu { |
| position: fixed; |
| background: #1e1e1e; |
| border: 1px solid #444; |
| border-radius: 6px; |
| padding: 4px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| z-index: 9999; |
| box-shadow: 0 4px 16px rgba(0,0,0,0.6); |
| } |
| .pr-gap-menu-btn { |
| background: #2a2a2a; |
| color: #e0e0e0; |
| border: 1px solid #333; |
| border-radius: 4px; |
| padding: 6px 14px; |
| font-size: 11px; |
| font-family: inherit; |
| cursor: pointer; |
| text-align: left; |
| white-space: nowrap; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| transition: background 0.15s ease; |
| } |
| .pr-gap-menu-btn:hover { |
| background: #3a3a3a; |
| border-color: #666; |
| } |
| .pr-player-controls { |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| gap: 12px; |
| padding: 2px 0; |
| flex-wrap: wrap; |
| width: 100%; |
| } |
| .pr-icon-btn { |
| background: #2a2a2a; |
| border: 1px solid #444; |
| color: #eee; |
| cursor: pointer; |
| padding: 6px 12px; |
| border-radius: 4px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: all 0.2s; |
| } |
| .pr-icon-btn * { |
| pointer-events: none; |
| } |
| .pr-icon-btn:hover { |
| color: #fff; |
| background: #3a3a3a; |
| border-color: #666; |
| } |
| .pr-icon-btn.active { |
| color: #4fff8f; |
| border-color: #4fff8f; |
| background: #1a3a2a; |
| } |
| .pr-seek-bar { |
| -webkit-appearance: none; |
| appearance: none; |
| height: 6px; |
| background: #444; |
| border-radius: 3px; |
| outline: none; |
| cursor: pointer; |
| border: 1px solid #222; |
| } |
| .pr-seek-bar::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 14px; |
| height: 14px; |
| border-radius: 50%; |
| background: #ff4444; |
| cursor: pointer; |
| border: 2px solid #222; |
| } |
| .pr-timeline-viewport { |
| width: 100%; |
| overflow-x: auto; |
| overflow-y: hidden; |
| } |
| .pr-timeline-viewport::-webkit-scrollbar { |
| height: 10px; |
| } |
| .pr-timeline-viewport::-webkit-scrollbar-track { |
| background: #151515; |
| border-radius: 5px; |
| } |
| .pr-timeline-viewport::-webkit-scrollbar-thumb { |
| background: #444 |
| border-radius: 5px; |
| border: 1px solid #000; |
| } |
| .pr-timeline-viewport::-webkit-scrollbar-thumb:hover { |
| background: #666 |
| border-color: #000; |
| } |
| .pr-zoom-controls { |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| margin-left: 12px; |
| } |
| .pr-zoom-slider { |
| width: 80px; |
| -webkit-appearance: none; |
| appearance: none; |
| height: 4px; |
| background: #444; |
| border-radius: 2px; |
| outline: none; |
| cursor: pointer; |
| } |
| .pr-zoom-slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| background: #aaa; |
| cursor: pointer; |
| } |
| .pr-right-group { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| .pr-segment-bounds { |
| font-size: 12px; |
| color: #aaa; |
| font-family: monospace; |
| } |
| .pr-timecode { |
| font-size: 14px; |
| font-weight: bold; |
| color: #e0e0e0; |
| font-family: monospace; |
| } |
| .pr-settings-menu { |
| position: fixed; |
| background: #1e1e1e; |
| border: 1px solid #444; |
| border-radius: 6px; |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| z-index: 9999; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.7); |
| min-width: 220px; |
| } |
| .pr-settings-title { |
| font-size: 11px; |
| font-weight: 600; |
| color: #888; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| padding-bottom: 4px; |
| border-bottom: 1px solid #333; |
| margin-bottom: 2px; |
| } |
| .pr-settings-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 8px; |
| } |
| .pr-settings-label { |
| font-size: 12px; |
| color: #bbb; |
| flex: 1; |
| white-space: nowrap; |
| } |
| .pr-number-control { |
| display: flex; |
| align-items: center; |
| border: 1px solid #444; |
| border-radius: 4px; |
| background: #2a2a2a; |
| overflow: hidden; |
| } |
| .pr-number-btn { |
| background: #333; |
| color: #aaa; |
| border: none; |
| width: 20px; |
| height: 22px; |
| cursor: pointer; |
| font-size: 12px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: background 0.15s; |
| user-select: none; |
| } |
| .pr-number-btn:hover { |
| background: #444; |
| color: #fff; |
| } |
| .pr-settings-input { |
| background: transparent; |
| color: #e0e0e0; |
| border: none; |
| padding: 0 4px; |
| font-size: 12px; |
| width: 50px; |
| height: 22px; |
| text-align: center; |
| font-family: monospace; |
| outline: none; |
| -moz-appearance: textfield; |
| } |
| .pr-settings-input::-webkit-outer-spin-button, |
| .pr-settings-input::-webkit-inner-spin-button { |
| -webkit-appearance: none; |
| margin: 0; |
| } |
| .pr-settings-select { |
| background: #2a2a2a; |
| color: #e0e0e0; |
| border: 1px solid #444; |
| border-radius: 4px; |
| padding: 3px 4px; |
| font-size: 12px; |
| width: 98px; |
| cursor: pointer; |
| } |
| .pr-settings-divider { |
| border: none; |
| border-top: 1px solid #2a2a2a; |
| margin: 2px 0; |
| } |
| .pr-settings-toggle-btn { |
| width: 100%; |
| background: #252525; |
| color: #aaa; |
| border: 1px solid #333; |
| border-radius: 4px; |
| padding: 5px 8px; |
| font-size: 11px; |
| cursor: pointer; |
| text-align: center; |
| transition: all 0.15s; |
| } |
| .pr-settings-toggle-btn:hover { |
| background: #2e2e2e; |
| color: #ccc; |
| border-color: #555; |
| } |
| .pr-settings-close-btn { |
| background: transparent; |
| color: #888; |
| border: none; |
| cursor: pointer; |
| padding: 2px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-radius: 4px; |
| transition: all 0.15s; |
| } |
| .pr-settings-close-btn:hover { |
| color: #fff; |
| background: rgba(255,255,255,0.1); |
| } |
| .pr-segmented-control { |
| display: flex; |
| background: #1e1e1e; |
| border: 1px solid #333; |
| border-radius: 6px; |
| padding: 2px; |
| width: 110px; |
| height: 22px; |
| align-items: center; |
| box-sizing: border-box; |
| } |
| .pr-segment { |
| flex: 1; |
| text-align: center; |
| font-size: 10px; |
| font-weight: 500; |
| line-height: 18px; |
| cursor: pointer; |
| border-radius: 4px; |
| color: #888; |
| transition: all 0.15s ease; |
| } |
| .pr-segment.active { |
| background: #333; |
| color: #fff; |
| } |
| .pr-segment:hover:not(.active) { |
| color: #ccc; |
| } |
| `; |
|
|
| if (!document.getElementById("prompt-relay-styles")) { |
| const styleEl = document.createElement("style"); |
| styleEl.id = "prompt-relay-styles"; |
| styleEl.textContent = STYLES; |
| document.head.appendChild(styleEl); |
| } |
|
|
| |
| const ICONS = { |
| upload: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>`, |
| link: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3.54-3.54a5 5 0 0 0-7.07-7.07L11.5 4.43"></path><path d="M14 11a5 5 0 0 0-7.07 0L3.39 14.54a5 5 0 0 0 7.07 7.07l2.04-2.04"></path></svg>`, |
| audio: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`, |
| trash: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`, |
| text: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`, |
| play: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>`, |
| pause: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>`, |
| loop: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12A9 9 0 0 0 6 5.3L3 8"></path><polyline points="3 3 3 8 8 8"></polyline><path d="M3 12a9 9 0 0 0 15 6.7l3-2.7"></path><polyline points="21 21 21 16 16 16"></polyline></svg>`, |
| minus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`, |
| plus: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`, |
| fit: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><polyline points="8 7 3 12 8 17"></polyline><polyline points="16 7 21 12 16 17"></polyline></svg>`, |
| gear: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`, |
| close: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>` |
| }; |
|
|
| |
| function parseInitial(jsonStr) { |
| let parsed = { segments: [], audioSegments: [] }; |
| try { |
| if (jsonStr) { |
| const p = JSON.parse(jsonStr); |
| if (Array.isArray(p.segments)) parsed.segments = p.segments; |
| if (Array.isArray(p.audioSegments)) parsed.audioSegments = p.audioSegments; |
| } |
| } catch (e) { } |
|
|
| let currentStart = 0; |
| for (let seg of parsed.segments) { |
| if (seg.start === undefined) { |
| seg.start = currentStart; |
| currentStart += seg.length; |
| } |
| |
| if (!seg.id) { |
| seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5); |
| } |
| } |
|
|
| for (let seg of parsed.audioSegments) { |
| if (!seg.id) { |
| seg.id = Date.now().toString() + Math.random().toString(36).substr(2, 5); |
| } |
| if (seg.trimStart === undefined) seg.trimStart = 0; |
| } |
|
|
| return parsed; |
| } |
|
|
| class TimelineEditor { |
| constructor(node, container, domWidget) { |
| this.node = node; |
| this.container = container; |
| this.domWidget = domWidget; |
|
|
| |
| this.rulerHeight = RULER_HEIGHT; |
| this.blockHeight = BLOCK_HEIGHT; |
| this.audioTrackHeight = AUDIO_TRACK_HEIGHT; |
| this.canvasHeight = CANVAS_HEIGHT; |
|
|
| |
| this.timeline = { segments: [], audioSegments: [] }; |
| this.selectionType = "image"; |
| this.selectedIndex = -1; |
|
|
| |
| 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; |
|
|
| |
| this.currentFrame = 0; |
| this.isPlaying = false; |
| this.isLooping = false; |
| this.audioContext = null; |
| this.activeAudioNodes = []; |
| this.playbackStartTime = 0; |
| this.playbackStartFrame = 0; |
| this._playLoopId = null; |
|
|
| |
| this._ghostSegmentId = null; |
| this._ghostTrack = null; |
| this._ghostInitialTimeline = null; |
|
|
| |
| this._gapMenu = null; |
| this._gapMenuDismisser = null; |
|
|
| |
| this.durationFramesWidget = this.node.widgets.find(w => w.name === "duration_frames"); |
| this.durationSecondsWidget = this.node.widgets.find(w => w.name === "duration_seconds"); |
| this.frameRateWidget = this.node.widgets.find(w => w.name === "frame_rate"); |
| this.timelineDataWidget = this.node.widgets.find(w => w.name === "timeline_data"); |
| this.localPromptsWidget = this.node.widgets.find(w => w.name === "local_prompts"); |
| this.segmentLengthsWidget = this.node.widgets.find(w => w.name === "segment_lengths"); |
| this.guideStrengthWidget = this.node.widgets.find(w => w.name === "guide_strength"); |
| this.displayModeWidget = this.node.widgets.find(w => w.name === "display_mode"); |
|
|
| this.timeline = parseInitial(this.timelineDataWidget?.value); |
| this.loadImages(); |
|
|
| this.createDOM(); |
| if (this.timeline.segments.length > 0) { |
| this.selectedIndex = 0; |
| } |
| this.updateUIFromSelection(); |
| this.commitChanges(true); |
| |
| |
| setTimeout(() => this.hideSettingsWidgets(), 0); |
|
|
| let isSyncing = false; |
|
|
| const origDurationFramesCallback = this.durationFramesWidget?.callback; |
| if (this.durationFramesWidget) { |
| this.durationFramesWidget.callback = (...args) => { |
| if (origDurationFramesCallback) origDurationFramesCallback.apply(this.durationFramesWidget, args); |
|
|
| if (!isSyncing && this.durationSecondsWidget) { |
| isSyncing = true; |
| this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3)); |
| isSyncing = false; |
| } |
|
|
| this.commitChanges(); |
| }; |
| } |
|
|
| const origDurationSecondsCallback = this.durationSecondsWidget?.callback; |
| if (this.durationSecondsWidget) { |
| this.durationSecondsWidget.callback = (...args) => { |
| if (origDurationSecondsCallback) origDurationSecondsCallback.apply(this.durationSecondsWidget, args); |
|
|
| if (!isSyncing && this.durationFramesWidget) { |
| isSyncing = true; |
| const newFrames = Math.max(1, Math.round(this.durationSecondsWidget.value * this.getFrameRate())); |
| this.durationFramesWidget.value = newFrames; |
| if (this.durationFramesWidget.callback) this.durationFramesWidget.callback(newFrames); |
| isSyncing = false; |
| } |
| }; |
| } |
|
|
| const origFrameRateCallback = this.frameRateWidget?.callback; |
| if (this.frameRateWidget) { |
| this.frameRateWidget.callback = (...args) => { |
| if (origFrameRateCallback) origFrameRateCallback.apply(this.frameRateWidget, args); |
| if (!isSyncing && this.durationSecondsWidget) { |
| isSyncing = true; |
| this.durationSecondsWidget.value = parseFloat((this.getDurationFrames() / this.getFrameRate()).toFixed(3)); |
| isSyncing = false; |
| } |
| }; |
| } |
|
|
| const origDisplayModeCallback = this.displayModeWidget?.callback; |
| if (this.displayModeWidget) { |
| this.displayModeWidget.callback = (...args) => { |
| if (origDisplayModeCallback) origDisplayModeCallback.apply(this.displayModeWidget, args); |
| this.updateWidgetVisibility(); |
| this.updateUIFromSelection(); |
| this.render(); |
| }; |
| this.updateWidgetVisibility(); |
| } |
|
|
| |
| this._renderLoop = requestAnimationFrame(() => this.checkResize()); |
| } |
|
|
| destroy() { |
| cancelAnimationFrame(this._renderLoop); |
| this.pauseAudio(); |
| window.removeEventListener("keydown", this.handleKeyDown, true); |
| window.removeEventListener("paste", this.handlePaste, true); |
| } |
|
|
| 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); |
| } |
|
|
| |
| |
| growTimelineIfNeeded(requiredFrames) { |
| const current = this.getDurationFrames(); |
| if (requiredFrames <= current) return; |
|
|
| const newFrames = Math.ceil(requiredFrames); |
| if (this.durationFramesWidget) { |
| this.durationFramesWidget.value = newFrames; |
| } |
| if (this.durationSecondsWidget) { |
| this.durationSecondsWidget.value = parseFloat((newFrames / this.getFrameRate()).toFixed(3)); |
| } |
| |
| if (window.app && window.app.graph) { |
| window.app.graph.setDirtyCanvas(true, true); |
| } |
| } |
|
|
| |
| |
| getMaxZoom() { |
| const visualDurationSecs = this.getVisualDurationFrames() / this.getFrameRate(); |
| const baseMaxZoom = Math.max(1, visualDurationSecs / 4); |
|
|
| |
| const viewportWidth = this.viewport ? this.viewport.clientWidth : 1000; |
| const MAX_CANVAS_WIDTH = 32768; |
| const limitMaxZoom = MAX_CANVAS_WIDTH / Math.max(1, viewportWidth); |
|
|
| return Math.max(1, Math.min(baseMaxZoom, limitMaxZoom)); |
| } |
|
|
| |
| |
| |
| getVisualDurationFrames() { |
| let furthest = 0; |
| for (const seg of this.timeline.segments) { |
| furthest = Math.max(furthest, seg.start + seg.length); |
| } |
| for (const seg of this.timeline.audioSegments) { |
| furthest = Math.max(furthest, seg.start + seg.length); |
| } |
| const outputDuration = this.getDurationFrames(); |
| if (furthest <= 0) return outputDuration; |
| return Math.max(outputDuration, Math.ceil(furthest * 1.30)); |
| } |
|
|
| |
| |
| 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; |
| |
| const viewportWidth = this.viewport ? this.viewport.clientWidth : 0; |
| if (viewportWidth > 0) { |
| const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel); |
| this.canvas.style.width = newCanvasWidth + "px"; |
| this.resizeCanvas(newCanvasWidth); |
| } |
| } |
| } |
|
|
| loadImages() { |
| for (const seg of this.timeline.segments) { |
| const imageSrc = seg.imageB64 || seg.imageUrl; |
| if (imageSrc && !seg.imgObj) { |
| seg.imgObj = new Image(); |
| seg.imgObj.onload = () => this.render(); |
| seg.imgObj.src = imageSrc; |
| } |
| } |
| } |
|
|
| createDOM() { |
| this.wrapper = document.createElement("div"); |
| this.wrapper.className = "pr-wrapper"; |
|
|
| this.wrapper.addEventListener("mouseenter", () => { this._isHovering = true; }); |
| this.wrapper.addEventListener("mouseleave", () => { this._isHovering = false; }); |
|
|
| this.handleKeyDown = (e) => { |
| const activeTag = document.activeElement ? document.activeElement.tagName : ""; |
| if (activeTag === "INPUT" || activeTag === "TEXTAREA") return; |
|
|
| if ((e.key === "Delete" || e.key === "Backspace") && this.selectedIndex !== -1 && this._isHovering) { |
| this.deleteSelectedSegment(); |
| e.stopPropagation(); |
| e.stopImmediatePropagation(); |
| e.preventDefault(); |
| } else if ((e.key === " " || e.code === "Space") && this._isHovering) { |
| this.togglePlay(); |
| e.stopPropagation(); |
| e.stopImmediatePropagation(); |
| e.preventDefault(); |
| } |
| }; |
| window.addEventListener("keydown", this.handleKeyDown, true); |
|
|
| this.handlePaste = (e) => { |
| if (this._isHovering) { |
| const activeTag = document.activeElement ? document.activeElement.tagName : ""; |
| if (activeTag === "INPUT" || activeTag === "TEXTAREA") return; |
|
|
| if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) { |
| const imageFiles = Array.from(e.clipboardData.files).filter(f => f.type.startsWith("image/")); |
| if (imageFiles.length > 0) { |
| this.handleImageUpload(imageFiles, this.currentFrame); |
| e.preventDefault(); |
| e.stopPropagation(); |
| } |
| } |
| } |
| }; |
| window.addEventListener("paste", this.handlePaste, true); |
|
|
| |
| const toolbar = document.createElement("div"); |
| toolbar.className = "pr-toolbar"; |
|
|
| const actionGroup = document.createElement("div"); |
| actionGroup.className = "pr-actions"; |
|
|
| this.fileInput = document.createElement("input"); |
| this.fileInput.type = "file"; |
| this.fileInput.accept = "image/*"; |
| this.fileInput.multiple = true; |
| this.fileInput.style.display = "none"; |
| this.fileInput.addEventListener("change", (e) => this.handleImageUpload(e.target.files)); |
|
|
| this.audioFileInput = document.createElement("input"); |
| this.audioFileInput.type = "file"; |
| this.audioFileInput.accept = "audio/*"; |
| this.audioFileInput.multiple = true; |
| this.audioFileInput.style.display = "none"; |
| this.audioFileInput.addEventListener("change", (e) => this.handleAudioUpload(e.target.files)); |
|
|
| const uploadBtn = document.createElement("button"); |
| uploadBtn.className = "pr-btn"; |
| uploadBtn.innerHTML = `${ICONS.upload} Add Image`; |
| uploadBtn.addEventListener("click", () => this.fileInput.click()); |
|
|
| const imageUrlBtn = document.createElement("button"); |
| imageUrlBtn.className = "pr-btn"; |
| imageUrlBtn.innerHTML = `${ICONS.link} Add Image URL`; |
| imageUrlBtn.addEventListener("click", () => this.promptAddImageUrl()); |
|
|
| const uploadAudioBtn = document.createElement("button"); |
| uploadAudioBtn.className = "pr-btn"; |
| uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`; |
| uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click()); |
|
|
| const audioUrlBtn = document.createElement("button"); |
| audioUrlBtn.className = "pr-btn"; |
| audioUrlBtn.innerHTML = `${ICONS.link} Add Audio URL`; |
| audioUrlBtn.addEventListener("click", () => this.promptAddAudioUrl()); |
|
|
| const addTextBtn = document.createElement("button"); |
| addTextBtn.className = "pr-btn"; |
| addTextBtn.innerHTML = `${ICONS.text} Add Text`; |
| addTextBtn.addEventListener("click", () => this.addTextSegmentFreeSpace()); |
|
|
| const deleteBtn = document.createElement("button"); |
| deleteBtn.className = "pr-btn pr-btn-danger"; |
| deleteBtn.innerHTML = `${ICONS.trash} Delete`; |
| deleteBtn.addEventListener("click", () => this.deleteSelectedSegment()); |
|
|
| actionGroup.appendChild(this.fileInput); |
| actionGroup.appendChild(this.audioFileInput); |
| actionGroup.appendChild(uploadBtn); |
| actionGroup.appendChild(imageUrlBtn); |
| actionGroup.appendChild(addTextBtn); |
| actionGroup.appendChild(uploadAudioBtn); |
| actionGroup.appendChild(audioUrlBtn); |
| actionGroup.appendChild(deleteBtn); |
| toolbar.appendChild(actionGroup); |
|
|
| const rightGroup = document.createElement("div"); |
| rightGroup.className = "pr-right-group"; |
|
|
| this.segmentBoundsDisplay = document.createElement("div"); |
| this.segmentBoundsDisplay.className = "pr-segment-bounds"; |
| this.segmentBoundsDisplay.textContent = "Start: - | End: -"; |
|
|
| this.timeCodeDisplay = document.createElement("div"); |
| this.timeCodeDisplay.className = "pr-timecode"; |
| this.timeCodeDisplay.textContent = this.formatTime(0); |
|
|
| const settingsBtn = document.createElement("button"); |
| settingsBtn.className = "pr-btn"; |
| settingsBtn.style.padding = "6px"; |
| settingsBtn.style.justifyContent = "center"; |
| settingsBtn.style.width = "28px"; |
| settingsBtn.style.height = "28px"; |
| settingsBtn.style.boxSizing = "border-box"; |
| settingsBtn.innerHTML = ICONS.gear; |
| settingsBtn.title = "Settings"; |
| settingsBtn.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| if (this._settingsMenu) { |
| this.dismissSettingsMenu(); |
| } else { |
| this.showSettingsMenu(settingsBtn); |
| } |
| }); |
|
|
| const toggleBtn = document.createElement("button"); |
| toggleBtn.className = "pr-btn"; |
| toggleBtn.style.padding = "6px 8px"; |
| toggleBtn.style.fontSize = "11px"; |
| toggleBtn.style.marginRight = "0px"; |
| toggleBtn.textContent = "Custom Audio: OFF"; |
| toggleBtn.title = "Toggle Custom Audio Output"; |
|
|
| const updateToggleStyle = (isOn) => { |
| toggleBtn.textContent = isOn ? "Custom Audio: ON" : "Custom Audio: OFF"; |
| if (isOn) { |
| toggleBtn.style.background = "#1c222d"; |
| toggleBtn.style.borderColor = "#283142"; |
| toggleBtn.style.color = "#e0e0e0"; |
| } else { |
| toggleBtn.style.background = "#222"; |
| toggleBtn.style.borderColor = "#111"; |
| toggleBtn.style.color = "#e0e0e0"; |
| } |
| }; |
|
|
| toggleBtn.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| const widget = this.node.widgets?.find(w => w.name === "use_custom_audio"); |
| if (widget) { |
| widget.value = !widget.value; |
| updateToggleStyle(widget.value); |
| this.node.setDirtyCanvas(true, true); |
| } |
| }); |
|
|
| |
| setTimeout(() => { |
| const widget = this.node.widgets?.find(w => w.name === "use_custom_audio"); |
| if (widget) { |
| updateToggleStyle(widget.value); |
| } |
| }, 100); |
|
|
| const helpBtn = document.createElement("button"); |
| helpBtn.className = "pr-btn"; |
| helpBtn.style.padding = "6px"; |
| helpBtn.style.justifyContent = "center"; |
| helpBtn.style.width = "28px"; |
| helpBtn.style.height = "28px"; |
| helpBtn.style.boxSizing = "border-box"; |
| helpBtn.innerHTML = "?"; |
| helpBtn.title = "Help / Documentation"; |
| helpBtn.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| window.open("https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI", "_blank"); |
| }); |
|
|
| const btnGroup = document.createElement("div"); |
| btnGroup.style.display = "flex"; |
| btnGroup.style.gap = "6px"; |
| btnGroup.style.alignItems = "center"; |
| btnGroup.appendChild(toggleBtn); |
| btnGroup.appendChild(helpBtn); |
| btnGroup.appendChild(settingsBtn); |
| rightGroup.appendChild(btnGroup); |
|
|
| toolbar.appendChild(rightGroup); |
|
|
| |
| this.viewport = document.createElement("div"); |
| this.viewport.className = "pr-timeline-viewport"; |
|
|
| this.viewport.addEventListener("wheel", (e) => { |
| if (e.ctrlKey || e.metaKey) { |
| e.preventDefault(); |
| e.stopPropagation(); |
|
|
| let zoomDelta = e.deltaY > 0 ? -0.5 : 0.5; |
| this.zoomLevel = Math.max(1, Math.min(this.getMaxZoom(), this.zoomLevel + zoomDelta)); |
| if (this.zoomSlider) this.zoomSlider.value = this.zoomLevel; |
|
|
| const oldWidth = this.canvas.offsetWidth; |
| const newWidth = this.viewport.clientWidth * this.zoomLevel; |
| const mouseX = e.clientX - this.viewport.getBoundingClientRect().left; |
| const scrollRatio = (this.viewport.scrollLeft + mouseX) / oldWidth; |
|
|
| this.canvas.style.width = newWidth + "px"; |
| this.viewport.scrollLeft = scrollRatio * newWidth - mouseX; |
| } |
| }, { passive: false, capture: true }); |
|
|
| this.canvas = document.createElement("canvas"); |
| this.canvas.className = "pr-canvas"; |
| this.ctx = this.canvas.getContext("2d"); |
| this.canvas.style.width = "100%"; |
|
|
| this.viewport.appendChild(this.canvas); |
|
|
| this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e)); |
| this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e)); |
| this.canvas.style.height = `${CANVAS_HEIGHT}px`; |
|
|
| |
| const propContainer = document.createElement("div"); |
| propContainer.className = "pr-prop-container"; |
|
|
| |
| this.promptInput = document.createElement("textarea"); |
| this.promptInput.className = "pr-prompt-area"; |
| this.promptInput.placeholder = "Enter prompt for selected segment..."; |
| this.promptInput.addEventListener("input", () => { |
| if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { |
| this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value; |
| this.commitChanges(); |
| } |
| }); |
|
|
| |
| this.audioInfoArea = document.createElement("div"); |
| this.audioInfoArea.className = "pr-audio-info"; |
|
|
| propContainer.appendChild(this.promptInput); |
| propContainer.appendChild(this.audioInfoArea); |
|
|
| this.wrapper.addEventListener("dragover", (e) => { |
| e.preventDefault(); |
| this.wrapper.classList.add("drag-active"); |
|
|
| const { x, y } = this.getMousePos(e); |
| const logicalWidth = this.canvas.offsetWidth; |
| const totalFrames = this.getVisualDurationFrames(); |
| if (!logicalWidth || totalFrames <= 0) return; |
|
|
| const isAudioTrack = y > RULER_HEIGHT + this.blockHeight; |
| const trackType = isAudioTrack ? "audio" : "image"; |
| const arrToModify = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments; |
|
|
| if (!this._ghostSegmentId || this._ghostTrack !== trackType) { |
| this._ghostSegmentId = "GHOST_" + Date.now(); |
| this._ghostTrack = trackType; |
| this._ghostInitialTimeline = JSON.parse(JSON.stringify(arrToModify)); |
|
|
| const frameRate = this.getFrameRate(); |
| const newLength = Math.max(1, frameRate * 1); |
|
|
| let mouseFrameX = x * (totalFrames / logicalWidth); |
| let startFrame = clamp(Math.round(mouseFrameX - newLength / 2), 0, totalFrames - newLength); |
|
|
| this._ghostInitialTimeline.push({ |
| id: this._ghostSegmentId, |
| start: startFrame, |
| length: newLength, |
| type: "ghost" |
| }); |
| } |
|
|
| let mouseFrameX = x * (totalFrames / logicalWidth); |
| const ghost = this._ghostInitialTimeline.find(s => s.id === this._ghostSegmentId); |
| let D_mouse_start = mouseFrameX - ghost.length / 2; |
|
|
| this._previewSegments = this._applyCenterDragPhysics( |
| this._ghostInitialTimeline, |
| this._ghostSegmentId, |
| D_mouse_start, |
| mouseFrameX, |
| totalFrames, |
| totalFrames, |
| logicalWidth |
| ); |
| this.render(); |
| }); |
|
|
| this.wrapper.addEventListener("dragleave", (e) => { |
| const rect = this.wrapper.getBoundingClientRect(); |
| if (e.clientX < rect.left || e.clientX >= rect.right || |
| e.clientY < rect.top || e.clientY >= rect.bottom) { |
| this.wrapper.classList.remove("drag-active"); |
| this._ghostSegmentId = null; |
| this._ghostTrack = null; |
| this._ghostInitialTimeline = null; |
| this._previewSegments = null; |
| this.render(); |
| } |
| }); |
|
|
| this.wrapper.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| this.wrapper.classList.remove("drag-active"); |
|
|
| let targetFrameStart = null; |
| let targetTrack = this._ghostTrack || "image"; |
|
|
| if (this._ghostSegmentId && this._previewSegments) { |
| const ghost = this._previewSegments.find(s => s.id === this._ghostSegmentId); |
| if (ghost) { |
| targetFrameStart = ghost.resolvedStart !== undefined ? ghost.resolvedStart : ghost.start; |
| } |
| } |
| this._ghostSegmentId = null; |
| this._ghostTrack = null; |
| this._ghostInitialTimeline = null; |
| this._previewSegments = null; |
| this.render(); |
|
|
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { |
| const imageFiles = []; |
| const audioFiles = []; |
| for (let file of e.dataTransfer.files) { |
| if (file.type.startsWith("audio/")) audioFiles.push(file); |
| if (file.type.startsWith("image/")) imageFiles.push(file); |
| } |
|
|
| |
| |
| 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)); |
|
|
| |
| const playerControls = document.createElement("div"); |
| playerControls.className = "pr-player-controls"; |
|
|
| this.playBtn = document.createElement("button"); |
| this.playBtn.className = "pr-icon-btn"; |
| this.playBtn.style.padding = "4px"; |
| this.playBtn.innerHTML = ICONS.play; |
| this.playBtn.title = "Play/Pause Audio"; |
| this.playBtn.addEventListener("click", () => this.togglePlay()); |
|
|
| this.loopBtn = document.createElement("button"); |
| this.loopBtn.className = "pr-icon-btn"; |
| this.loopBtn.style.padding = "4px"; |
| this.loopBtn.innerHTML = ICONS.loop; |
| this.loopBtn.title = "Toggle Loop"; |
| this.loopBtn.addEventListener("click", () => this.toggleLoop()); |
|
|
| this.seekBar = document.createElement("input"); |
| this.seekBar.type = "range"; |
| this.seekBar.className = "pr-seek-bar"; |
| this.seekBar.min = "0"; |
| this.seekBar.value = "0"; |
| this.seekBar.style.flex = "1"; |
| this.seekBar.addEventListener("input", (e) => { |
| this.currentFrame = parseInt(e.target.value, 10); |
| this.render(); |
| if (this.isPlaying) { |
| this.playAudio(); |
| } |
| }); |
|
|
| |
| const zoomControls = document.createElement("div"); |
| zoomControls.className = "pr-zoom-controls"; |
|
|
| const zoomOutBtn = document.createElement("button"); |
| zoomOutBtn.className = "pr-icon-btn"; |
| zoomOutBtn.style.padding = "4px"; |
| zoomOutBtn.innerHTML = ICONS.minus; |
| zoomOutBtn.title = "Zoom Out"; |
| zoomOutBtn.addEventListener("click", () => { |
| const currentZoom = parseFloat(this.zoomSlider.value); |
| this.zoomSlider.value = Math.max(1, currentZoom - 0.5); |
| this.zoomSlider.dispatchEvent(new Event("input")); |
| }); |
|
|
| this.zoomSlider = document.createElement("input"); |
| this.zoomSlider.type = "range"; |
| this.zoomSlider.className = "pr-zoom-slider"; |
| this.zoomSlider.min = "1"; |
| this.zoomSlider.max = "1"; |
| 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; |
|
|
| |
| const totalFrames = this.getVisualDurationFrames(); |
| const playheadRatio = this.currentFrame / totalFrames; |
| const newPlayheadX = playheadRatio * newCanvasWidth; |
| this.viewport.scrollLeft = newPlayheadX - (viewportWidth / 2); |
| }); |
|
|
| const zoomInBtn = document.createElement("button"); |
| zoomInBtn.className = "pr-icon-btn"; |
| zoomInBtn.style.padding = "4px"; |
| zoomInBtn.innerHTML = ICONS.plus; |
| zoomInBtn.title = "Zoom In"; |
| zoomInBtn.addEventListener("click", () => { |
| const currentZoom = parseFloat(this.zoomSlider.value); |
| this.zoomSlider.value = Math.min(this.getMaxZoom(), currentZoom + 0.5); |
| this.zoomSlider.dispatchEvent(new Event("input")); |
| }); |
|
|
| const zoomFitBtn = document.createElement("button"); |
| zoomFitBtn.className = "pr-icon-btn"; |
| zoomFitBtn.style.padding = "4px"; |
| zoomFitBtn.style.marginLeft = "4px"; |
| zoomFitBtn.innerHTML = ICONS.fit; |
| zoomFitBtn.title = "Zoom to Fit (show full timeline)"; |
| zoomFitBtn.addEventListener("click", () => { |
| this.zoomLevel = 1; |
| this.zoomSlider.value = 1; |
| const viewportWidth = this.viewport.clientWidth; |
| this.canvas.style.width = viewportWidth + "px"; |
| this.resizeCanvas(viewportWidth); |
| this._lastWidth = viewportWidth; |
| this._lastZoom = 1; |
| this.viewport.scrollLeft = 0; |
| }); |
|
|
| zoomControls.appendChild(zoomOutBtn); |
| zoomControls.appendChild(this.zoomSlider); |
| zoomControls.appendChild(zoomInBtn); |
| zoomControls.appendChild(zoomFitBtn); |
|
|
| playerControls.appendChild(this.playBtn); |
| playerControls.appendChild(this.loopBtn); |
| playerControls.appendChild(this.seekBar); |
| playerControls.appendChild(zoomControls); |
|
|
|
|
|
|
| |
| this.strengthRow = document.createElement("div"); |
| this.strengthRow.className = "pr-strength-row"; |
|
|
| const strengthLabel = document.createElement("span"); |
| strengthLabel.className = "pr-strength-label"; |
| strengthLabel.textContent = "Guide Strength:"; |
|
|
| this.strengthValue = document.createElement("input"); |
| this.strengthValue.type = "text"; |
| this.strengthValue.className = "pr-strength-input"; |
| this.strengthValue.value = "1.00"; |
| this.strengthValue.disabled = true; |
| this.strengthValue.style.cursor = "ew-resize"; |
|
|
| |
| let isDragging = false; |
| let startX = 0; |
| let startVal = 0; |
| let hasMoved = false; |
|
|
| this.strengthValue.addEventListener("mousedown", (e) => { |
| if (this.strengthValue.disabled) return; |
| startX = e.clientX; |
| startVal = parseFloat(this.strengthValue.value) || 1.0; |
| hasMoved = false; |
|
|
| const onMouseMove = (moveEvent) => { |
| const deltaX = moveEvent.clientX - startX; |
| if (Math.abs(deltaX) > 3) { |
| hasMoved = true; |
| isDragging = true; |
| } |
|
|
| if (isDragging) { |
| moveEvent.preventDefault(); |
| const sensitivity = 0.002; |
| let newVal = startVal + deltaX * sensitivity; |
|
|
| if (newVal < 0) newVal = 0; |
| if (newVal > 1) newVal = 1; |
|
|
| this.strengthValue.value = newVal.toFixed(2); |
|
|
| if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { |
| const seg = this.timeline.segments[this.selectedIndex]; |
| if (seg.type !== "text") { |
| seg.guideStrength = newVal; |
| this.commitChanges(); |
| } |
| } |
| } |
| }; |
|
|
| const onMouseUp = () => { |
| document.removeEventListener("mousemove", onMouseMove); |
| document.removeEventListener("mouseup", onMouseUp); |
|
|
| if (!hasMoved) { |
| this.strengthValue.focus(); |
| this.strengthValue.select(); |
| } |
| isDragging = false; |
| }; |
|
|
| document.addEventListener("mousemove", onMouseMove); |
| document.addEventListener("mouseup", onMouseUp); |
| }); |
|
|
| this.strengthValue.addEventListener("change", (e) => { |
| let val = parseFloat(e.target.value); |
| if (isNaN(val)) val = 1; |
| val = Math.max(0, Math.min(1, val)); |
| this.strengthValue.value = val.toFixed(2); |
| if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { |
| const seg = this.timeline.segments[this.selectedIndex]; |
| if (seg.type !== "text") { |
| seg.guideStrength = val; |
| this.commitChanges(); |
| } |
| } |
| }); |
|
|
| this.strengthRow.appendChild(this.timeCodeDisplay); |
| this.strengthRow.appendChild(this.segmentBoundsDisplay); |
| this.strengthRow.appendChild(strengthLabel); |
| this.strengthRow.appendChild(this.strengthValue); |
|
|
|
|
| this.wrapper.appendChild(toolbar); |
| this.wrapper.appendChild(this.viewport); |
|
|
| const controlsGroup = document.createElement("div"); |
| controlsGroup.className = "pr-controls-group"; |
| controlsGroup.appendChild(this.strengthRow); |
| controlsGroup.appendChild(playerControls); |
| this.wrapper.appendChild(controlsGroup); |
| this.wrapper.appendChild(propContainer); |
|
|
| this.container.appendChild(this.wrapper); |
| } |
|
|
| checkResize() { |
| const viewportWidth = this.viewport.clientWidth; |
| const currentScale = this.getRenderScale(); |
|
|
| if (viewportWidth > 0 && (this._lastWidth !== viewportWidth || this._lastZoom !== this.zoomLevel || this._lastScale !== currentScale)) { |
| this._lastWidth = viewportWidth; |
| this._lastZoom = this.zoomLevel; |
| this._lastScale = currentScale; |
|
|
| const newCanvasWidth = Math.max(viewportWidth, viewportWidth * this.zoomLevel); |
| this.canvas.style.width = newCanvasWidth + "px"; |
| this.resizeCanvas(newCanvasWidth); |
| } |
| this._renderLoop = requestAnimationFrame(() => this.checkResize()); |
| } |
|
|
| getRenderScale() { |
| const dpr = window.devicePixelRatio || 1; |
| let graphScale = 1; |
| try { |
| if (window.app && window.app.canvas && window.app.canvas.ds && window.app.canvas.ds.scale) { |
| graphScale = window.app.canvas.ds.scale; |
| } |
| } catch (e) { } |
| |
| 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(); |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| normalizeRemoteUrl(rawUrl) { |
| const value = (rawUrl || "").trim(); |
| if (!value) return null; |
| try { |
| const parsed = new URL(value); |
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { |
| window.alert("Only http:// and https:// URLs are supported."); |
| return null; |
| } |
| return parsed.href; |
| } catch (err) { |
| window.alert("Please enter a valid URL."); |
| return null; |
| } |
| } |
|
|
| getRemoteFileName(url, fallback) { |
| try { |
| const parsed = new URL(url); |
| const name = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || ""); |
| return name || fallback; |
| } catch (err) { |
| return fallback; |
| } |
| } |
|
|
| promptAddImageUrl(targetFrameStart = null, explicitLength = null) { |
| const url = this.normalizeRemoteUrl(window.prompt("Paste image URL:", "")); |
| if (!url) return; |
| this.handleImageUrl(url, targetFrameStart, explicitLength); |
| } |
|
|
| promptAddAudioUrl(targetFrameStart = null) { |
| const url = this.normalizeRemoteUrl(window.prompt("Paste audio URL:", "")); |
| if (!url) return; |
| this.handleAudioUrl(url, targetFrameStart); |
| } |
|
|
| _resolveNewSegmentStart(targetArray, newLength, targetFrameStart) { |
| let newStart = targetFrameStart; |
|
|
| if (newStart === null) { |
| newStart = 0; |
| targetArray.sort((a, b) => a.start - b.start); |
| for (let i = 0; i < targetArray.length; i++) { |
| let seg = targetArray[i]; |
| if (newStart + newLength <= seg.start) break; |
| newStart = Math.max(newStart, seg.start + seg.length); |
| } |
| return newStart; |
| } |
|
|
| const currentDuration = this.getVisualDurationFrames(); |
| let tempId = "TEMP_" + Date.now(); |
| targetArray.push({ id: tempId, start: newStart, length: newLength, type: "temp" }); |
| let result = this._applyCenterDragPhysics(targetArray, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1); |
|
|
| for (let shiftedSeg of result) { |
| let original = targetArray.find(s => s.id === shiftedSeg.id); |
| if (original) { |
| original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start; |
| } |
| } |
|
|
| let tempSeg = targetArray.find(s => s.id === tempId); |
| newStart = tempSeg ? tempSeg.start : newStart; |
| const tempIndex = targetArray.findIndex(s => s.id === tempId); |
| if (tempIndex !== -1) targetArray.splice(tempIndex, 1); |
| return newStart; |
| } |
|
|
| _buildAudioPeaks(audioBuffer) { |
| const channelData = audioBuffer.getChannelData(0); |
| const peaks = []; |
| const numPeaks = 200; |
| const step = Math.max(1, Math.floor(channelData.length / numPeaks)); |
| for (let i = 0; i < numPeaks; i++) { |
| let max = 0; |
| const start = i * step; |
| const end = Math.min(channelData.length, start + step); |
| for (let j = start; j < end; j++) { |
| const val = Math.abs(channelData[j]); |
| if (val > max) max = val; |
| } |
| peaks.push(max); |
| } |
| return peaks; |
| } |
|
|
| async _loadRemoteAudioInfo(audioUrl) { |
| try { |
| const resp = await fetch(audioUrl); |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); |
| const arrayBuffer = await resp.arrayBuffer(); |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); |
| return { |
| duration: audioBuffer.duration, |
| peaks: this._buildAudioPeaks(audioBuffer), |
| }; |
| } catch (err) { |
| console.warn("[PromptRelay] Audio URL waveform load failed; falling back to metadata/default length.", err); |
| } |
|
|
| try { |
| const duration = await new Promise((resolve, reject) => { |
| const audio = document.createElement("audio"); |
| const timeoutId = setTimeout(() => { |
| audio.removeAttribute("src"); |
| reject(new Error("Timed out while reading audio metadata.")); |
| }, 10000); |
| audio.preload = "metadata"; |
| audio.onloadedmetadata = () => { |
| clearTimeout(timeoutId); |
| resolve(Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 1); |
| }; |
| audio.onerror = () => { |
| clearTimeout(timeoutId); |
| reject(new Error("Could not read audio metadata.")); |
| }; |
| audio.src = audioUrl; |
| }); |
| return { duration, peaks: [] }; |
| } catch (err) { |
| console.warn("[PromptRelay] Audio URL metadata load failed; using a 1 second placeholder.", err); |
| return { duration: 1, peaks: [] }; |
| } |
| } |
|
|
| async handleImageUrl(imageUrl, targetFrameStart = null, explicitLength = null) { |
| const normalizedUrl = this.normalizeRemoteUrl(imageUrl); |
| if (!normalizedUrl) return; |
|
|
| const frameRate = this.getFrameRate(); |
| const newLength = explicitLength !== null ? explicitLength : frameRate * 1; |
|
|
| await new Promise((resolve) => { |
| const displayImg = new Image(); |
| let didCommit = false; |
|
|
| const commit = (imgObj = null) => { |
| if (didCommit) return; |
| didCommit = true; |
|
|
| let newStart = this._resolveNewSegmentStart(this.timeline.segments, newLength, targetFrameStart); |
| const seg = { |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: newStart, |
| length: newLength, |
| prompt: "", |
| type: "image", |
| imageUrl: normalizedUrl, |
| imageB64: normalizedUrl |
| }; |
|
|
| if (imgObj) seg.imgObj = imgObj; |
|
|
| this.timeline.segments.push(seg); |
| this.timeline.segments.sort((a, b) => a.start - b.start); |
| this.selectionType = "image"; |
| this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); |
|
|
| this.updateUIFromSelection(); |
| this.commitChanges(true); |
| this.render(); |
| resolve(); |
| }; |
|
|
| displayImg.onload = () => commit(displayImg); |
| displayImg.onerror = () => { |
| console.warn("[PromptRelay] Image URL preview failed; saving URL for backend execution.", normalizedUrl); |
| commit(null); |
| }; |
| displayImg.src = normalizedUrl; |
| }); |
| } |
|
|
| async handleAudioUrl(audioUrl, targetFrameStart = null) { |
| const normalizedUrl = this.normalizeRemoteUrl(audioUrl); |
| if (!normalizedUrl) return; |
|
|
| const frameRate = this.getFrameRate(); |
| const info = await this._loadRemoteAudioInfo(normalizedUrl); |
| const clipFrames = Math.max(1, Math.ceil(info.duration * frameRate)); |
| const newLength = clipFrames; |
| let newStart = this._resolveNewSegmentStart(this.timeline.audioSegments, newLength, targetFrameStart); |
|
|
| const seg = { |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| type: "audio", |
| start: newStart, |
| length: newLength, |
| trimStart: 0, |
| audioDurationFrames: clipFrames, |
| audioUrl: normalizedUrl, |
| fileName: this.getRemoteFileName(normalizedUrl, "remote-audio"), |
| waveformPeaks: info.peaks |
| }; |
|
|
| this.timeline.audioSegments.push(seg); |
| this.timeline.audioSegments.sort((a, b) => a.start - b.start); |
| this.selectionType = "audio"; |
| this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === seg.id); |
|
|
| this.updateUIFromSelection(); |
| this.commitChanges(true); |
| this.render(); |
| } |
|
|
| |
| async handleImageUpload(files, targetFrameStart = null, explicitLength = null) { |
| const frameRate = this.getFrameRate(); |
| const durationFrames = this.getDurationFrames(); |
| const newLength = explicitLength !== null ? explicitLength : frameRate * 1; |
|
|
| for (let file of files) { |
| if (!file.type.startsWith("image/")) continue; |
|
|
| await new Promise(async (resolve) => { |
| try { |
| const body = new FormData(); |
| body.append("image", file); |
| const resp = await api.fetchApi("/upload/image", { method: "POST", body }); |
| if (resp.status !== 200) { resolve(); return; } |
|
|
| const data = await resp.json(); |
| const filename = data.name; |
| const subfolder = data.subfolder || ""; |
| const imageFile = subfolder ? subfolder + "/" + filename : filename; |
| const imgUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`); |
|
|
| const img = new Image(); |
| img.onload = () => { |
|
|
| let newStart = targetFrameStart; |
| if (newStart === null) { |
| |
| 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(); |
| this.timeline.segments.push({ id: tempId, start: newStart, length: newLength, type: "temp" }); |
| let result = this._applyCenterDragPhysics(this.timeline.segments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1); |
|
|
| |
| for (let shiftedSeg of result) { |
| let original = this.timeline.segments.find(s => s.id === shiftedSeg.id); |
| if (original) { |
| original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start; |
| } |
| } |
|
|
| let tempSeg = this.timeline.segments.find(s => s.id === tempId); |
| newStart = tempSeg.start; |
| this.timeline.segments = this.timeline.segments.filter(s => s.id !== tempId); |
| targetFrameStart = newStart + newLength; |
| } |
|
|
| |
| 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(); |
| }; |
| displayImg.src = imgUrl; |
|
|
| this.timeline.segments.push(seg); |
| this.timeline.segments.sort((a, b) => a.start - b.start); |
| this.selectionType = "image"; |
| this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); |
|
|
| this.updateUIFromSelection(); |
| this.commitChanges(true); |
| }; |
| img.src = imgUrl; |
| } catch (err) { |
| console.error("[PromptRelay] Image upload failed", err); |
| resolve(); |
| } |
| }); |
| } |
| this.fileInput.value = ""; |
| } |
|
|
| |
| async handleAudioUpload(files, targetFrameStart = null) { |
| const frameRate = this.getFrameRate(); |
| const durationFrames = this.getDurationFrames(); |
|
|
| for (let file of files) { |
| if (!file.type.startsWith("audio/")) continue; |
|
|
| await new Promise(async (resolve) => { |
| try { |
| const body = new FormData(); |
| body.append("image", file); |
| const resp = await api.fetchApi("/upload/image", { method: "POST", body }); |
| if (resp.status !== 200) { resolve(); return; } |
|
|
| const data = await resp.json(); |
| const filename = data.name; |
| const subfolder = data.subfolder || ""; |
| const audioFile = subfolder ? subfolder + "/" + filename : filename; |
|
|
| const arrayBuffer = await file.arrayBuffer(); |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); |
| const clipDurationSecs = audioBuffer.duration; |
| const clipFrames = Math.max(1, Math.ceil(clipDurationSecs * frameRate)); |
|
|
| const channelData = audioBuffer.getChannelData(0); |
| const peaks = []; |
| const numPeaks = 200; |
| const step = Math.floor(channelData.length / numPeaks); |
| for (let i = 0; i < numPeaks; i++) { |
| let max = 0; |
| for (let j = 0; j < step; j++) { |
| const val = Math.abs(channelData[i * step + j]); |
| if (val > max) max = val; |
| } |
| peaks.push(max); |
| } |
|
|
| let newLength = clipFrames; |
| let newStart = targetFrameStart; |
|
|
| if (newStart === null) { |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| const currentDuration = this.getVisualDurationFrames(); |
|
|
| if (targetFrameStart !== null) { |
| let tempId = "TEMP_" + Date.now(); |
| this.timeline.audioSegments.push({ id: tempId, start: newStart, length: newLength, type: "temp" }); |
| let result = this._applyCenterDragPhysics(this.timeline.audioSegments, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1); |
|
|
| for (let shiftedSeg of result) { |
| let original = this.timeline.audioSegments.find(s => s.id === shiftedSeg.id); |
| if (original) original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start; |
| } |
|
|
| let tempSeg = this.timeline.audioSegments.find(s => s.id === tempId); |
| newStart = tempSeg.start; |
| this.timeline.audioSegments = this.timeline.audioSegments.filter(s => s.id !== tempId); |
| targetFrameStart = newStart + newLength; |
| } |
|
|
| |
| let constrainedLength = newLength; |
|
|
| const seg = { |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| type: "audio", |
| start: newStart, |
| length: constrainedLength, |
| trimStart: 0, |
| audioDurationFrames: clipFrames, |
| audioFile: audioFile, |
| fileName: file.name, |
| waveformPeaks: peaks |
| }; |
|
|
| this.timeline.audioSegments.push(seg); |
| this.timeline.audioSegments.sort((a, b) => a.start - b.start); |
| this.selectionType = "audio"; |
| this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === seg.id); |
|
|
| this.updateUIFromSelection(); |
| this.commitChanges(true); |
| this.render(); |
| resolve(); |
| } catch (err) { |
| console.error("[PromptRelay] Audio processing failed", err); |
| resolve(); |
| } |
| }); |
| } |
| this.audioFileInput.value = ""; |
| } |
|
|
| deleteSelectedSegment() { |
| if (this.selectionType === "audio") { |
| if (this.timeline.audioSegments.length === 0 || this.selectedIndex === -1) return; |
| this.timeline.audioSegments.splice(this.selectedIndex, 1); |
| this.selectedIndex = Math.max(-1, this.selectedIndex - 1); |
| } else { |
| if (this.timeline.segments.length === 0 || this.selectedIndex === -1) return; |
| this.timeline.segments.splice(this.selectedIndex, 1); |
| this.selectedIndex = Math.max(-1, this.selectedIndex - 1); |
| } |
| this.updateUIFromSelection(); |
| this.commitChanges(); |
| this.render(); |
| } |
|
|
| formatTime(frames, dropSuffix = false) { |
| const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds"; |
| if (mode === "seconds") { |
| const secs = frames / this.getFrameRate(); |
| return dropSuffix ? secs.toFixed(2) : secs.toFixed(2) + "s"; |
| } |
| return dropSuffix ? Math.round(frames).toString() : Math.round(frames) + " frames"; |
| } |
|
|
| updateWidgetVisibility() { |
| const mode = this.displayModeWidget ? this.displayModeWidget.value : "seconds"; |
|
|
| if (this.durationFramesWidget) { |
| |
| this.durationFramesWidget.type = "INT"; |
| if (!this.durationFramesWidget.options) this.durationFramesWidget.options = {}; |
| this.durationFramesWidget.options.hidden = false; |
| this.durationFramesWidget.hidden = false; |
| delete this.durationFramesWidget.computeSize; |
| } |
| if (this.durationSecondsWidget) { |
| |
| this.durationSecondsWidget.type = "FLOAT"; |
| if (!this.durationSecondsWidget.options) this.durationSecondsWidget.options = {}; |
| this.durationSecondsWidget.options.hidden = false; |
| this.durationSecondsWidget.hidden = false; |
| delete this.durationSecondsWidget.computeSize; |
| } |
|
|
| |
| setTimeout(() => { |
| if (this.node && this.node.computeSize) { |
| const sz = this.node.computeSize(); |
| this.node.size[1] = sz[1]; |
| if (window.app && window.app.graph) { |
| window.app.graph.setDirtyCanvas(true, true); |
| } |
| } |
| }, 0); |
| } |
|
|
| updateUIFromSelection() { |
| let seg = null; |
| if (this.selectedIndex >= 0) { |
| if (this.selectionType === "audio") { |
| const origSeg = this.timeline.audioSegments[this.selectedIndex]; |
| if (origSeg) { |
| const previewIsAudio = this._ghostTrack === 'audio' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio'); |
| const arr = (this._previewSegments && previewIsAudio) ? this._previewSegments : this.timeline.audioSegments; |
| seg = arr.find(s => s.id === origSeg.id) || origSeg; |
| } |
| } else { |
| const origSeg = this.timeline.segments[this.selectedIndex]; |
| if (origSeg) { |
| const previewIsImage = this._ghostTrack === 'image' || (this._previewSegments && this._ghostTrack === null && this.selectionType === 'image'); |
| const arr = (this._previewSegments && previewIsImage) ? this._previewSegments : this.timeline.segments; |
| seg = arr.find(s => s.id === origSeg.id) || origSeg; |
| } |
| } |
| } |
|
|
| if (this.selectionType === "audio" && seg) { |
| this.promptInput.style.display = "none"; |
| this.strengthRow.style.display = "flex"; |
| this.audioInfoArea.style.display = "block"; |
| this.audioInfoArea.innerHTML = ` |
| File: <span>${seg.fileName || "Unknown"}</span><br> |
| Length: <span>${this.formatTime(seg.audioDurationFrames)}</span> Output Length: <span>${this.formatTime(seg.length)}</span><br> |
| Trim-in: <span>${this.formatTime(Math.round(seg.trimStart))}</span> Trim-Out: <span>${this.formatTime(Math.round(seg.audioDurationFrames - (seg.trimStart + seg.length)))}</span> |
| `; |
| this.strengthValue.value = "1.00"; |
| this.strengthValue.disabled = true; |
| } else { |
| this.audioInfoArea.style.display = "none"; |
| this.promptInput.style.display = "block"; |
| this.strengthRow.style.display = "flex"; |
|
|
| if (seg) { |
| this.promptInput.value = seg.prompt || ""; |
| this.promptInput.disabled = false; |
|
|
| const isImage = seg.type !== "text"; |
| const strength = isImage ? (seg.guideStrength ?? 1.0) : 1.0; |
| this.strengthValue.value = strength.toFixed(2); |
| this.strengthValue.disabled = !isImage; |
| } else { |
| this.promptInput.value = ""; |
| this.promptInput.disabled = true; |
| this.strengthValue.value = "1.00"; |
| this.strengthValue.disabled = true; |
| } |
| } |
|
|
| if (this.segmentBoundsDisplay) { |
| if (seg) { |
| const startStr = this.formatTime(seg.start, true); |
| const endStr = this.formatTime(seg.start + seg.length, true); |
| this.segmentBoundsDisplay.textContent = `Start: ${startStr} | End: ${endStr}`; |
| } else { |
| this.segmentBoundsDisplay.textContent = "Start: - | End: -"; |
| } |
| } |
| } |
|
|
| |
| render() { |
| const width = this.canvas.offsetWidth || this._lastWidth; |
| const height = this.canvasHeight; |
| const totalFrames = this.getVisualDurationFrames(); |
|
|
| if (!width || width <= 0) return; |
|
|
| this.ctx.clearRect(0, 0, width, height); |
|
|
|
|
|
|
| |
| this.ctx.fillStyle = "#111"; |
| this.ctx.fillRect(0, RULER_HEIGHT, width, this.blockHeight); |
| this.ctx.fillStyle = "#111"; |
| this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, this.audioTrackHeight); |
|
|
|
|
|
|
| |
| |
| |
| const previewIsAudio = this._ghostTrack === 'audio' || |
| (this._previewSegments && this._ghostTrack === null && this.selectionType === 'audio'); |
|
|
| let renderSegments = (this._previewSegments && !previewIsAudio) |
| ? this._previewSegments : this.timeline.segments; |
|
|
| let renderAudioSegments = (this._previewSegments && previewIsAudio) |
| ? this._previewSegments : this.timeline.audioSegments; |
|
|
|
|
|
|
| const activeSegId = this.timeline.segments[this.selectedIndex]?.id; |
| const activeAudioSegId = this.timeline.audioSegments[this.selectedIndex]?.id; |
|
|
| |
| const isImageSelection = this.selectionType === "image"; |
| const sortedSegments = [...renderSegments].sort((a, b) => { |
| const aSel = isImageSelection && a.id === activeSegId; |
| const bSel = isImageSelection && b.id === activeSegId; |
| return aSel - bSel; |
| }); |
|
|
| const isAudioSelection = this.selectionType === "audio"; |
| const sortedAudioSegments = [...renderAudioSegments].sort((a, b) => { |
| const aSel = isAudioSelection && a.id === activeAudioSegId; |
| const bSel = isAudioSelection && b.id === activeAudioSegId; |
| return aSel - bSel; |
| }); |
|
|
| |
| for (let i = 0; i < sortedSegments.length; i++) { |
| const seg = sortedSegments[i]; |
| const startX = (seg.start / totalFrames) * width; |
| const pxWidth = (seg.length / totalFrames) * width; |
| const isSelected = (this.selectionType === "image" && seg.id === activeSegId); |
|
|
| const originalSeg = this.timeline.segments.find(s => s.id === seg.id); |
| const imgObj = originalSeg ? originalSeg.imgObj : seg.imgObj; |
|
|
| if ((this._isDragging && this.selectionType === "image" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) { |
| this.ctx.globalAlpha = 0.65; |
| } else { |
| this.ctx.globalAlpha = 1.0; |
| } |
|
|
| if (seg.type === "ghost") { |
| this.ctx.fillStyle = "#2a2a2a"; |
| this.ctx.fillRect(startX, RULER_HEIGHT, pxWidth, this.blockHeight); |
|
|
| this.ctx.strokeStyle = "#777"; |
| this.ctx.lineWidth = 2; |
| this.ctx.setLineDash([5, 5]); |
| this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); |
| this.ctx.setLineDash([]); |
|
|
| this.ctx.fillStyle = "#aaa"; |
| this.ctx.textAlign = "center"; |
| this.ctx.textBaseline = "middle"; |
| this.ctx.font = "bold 12px sans-serif"; |
| this.ctx.fillText("Drop to Place", startX + pxWidth / 2, RULER_HEIGHT + this.blockHeight / 2); |
| } else { |
| this.ctx.fillStyle = seg.type === "text" ? "#000b12" : "#000"; |
| this.ctx.fillRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); |
| } |
|
|
| if (imgObj && imgObj.complete && imgObj.naturalWidth > 0 && seg.type !== "ghost") { |
| const imgRatio = imgObj.naturalWidth / imgObj.naturalHeight; |
| const boxRatio = pxWidth / this.blockHeight; |
| let drawW, drawH, drawX, drawY; |
| if (imgRatio > boxRatio) { |
| drawW = pxWidth; drawH = pxWidth / imgRatio; |
| drawX = startX; drawY = RULER_HEIGHT + (this.blockHeight - drawH) / 2; |
| } else { |
| drawH = this.blockHeight; drawW = this.blockHeight * imgRatio; |
| drawY = RULER_HEIGHT; drawX = startX + (pxWidth - drawW) / 2; |
| } |
|
|
| |
| this.ctx.save(); |
| this.ctx.beginPath(); |
| this.ctx.rect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); |
| this.ctx.clip(); |
|
|
| if (imgRatio > boxRatio) { |
| |
| this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH); |
| } else { |
| |
| this.ctx.drawImage(imgObj, drawX, drawY, drawW, drawH); |
|
|
| |
| let leftX = drawX - drawW; |
| while (leftX + drawW > startX) { |
| this.ctx.drawImage(imgObj, leftX, drawY, drawW, drawH); |
| leftX -= drawW; |
| } |
|
|
| |
| let rightX = drawX + drawW; |
| while (rightX < startX + pxWidth) { |
| this.ctx.drawImage(imgObj, rightX, drawY, drawW, drawH); |
| rightX += drawW; |
| } |
| } |
| this.ctx.restore(); |
|
|
| |
| 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(); |
|
|
| |
| this.ctx.fillStyle = "rgba(0, 0, 0, 0.60)"; |
| this.ctx.fillRect(startX, overlayY, pxWidth, overlayH); |
|
|
| |
| 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"; |
|
|
| |
| const maxTextW = pxWidth - 10; |
| let label = seg.prompt; |
| if (this.ctx.measureText(label).width > maxTextW) { |
| while (label.length > 0 && this.ctx.measureText(label + "…").width > maxTextW) { |
| label = label.slice(0, -1); |
| } |
| label += "…"; |
| } |
|
|
| this.ctx.fillText(label, startX + pxWidth / 2, overlayY + overlayH / 2); |
| this.ctx.restore(); |
| } |
| } else if (seg.type === "text") { |
| const pad = 8; |
| const boxW = pxWidth - pad * 2; |
| if (boxW > 12) { |
| this.ctx.save(); |
| this.ctx.beginPath(); |
| this.ctx.rect(startX + pad, RULER_HEIGHT + pad, boxW, this.blockHeight - pad * 2); |
| this.ctx.clip(); |
| this.ctx.fillStyle = "#e0e3ed"; |
| this.ctx.font = "11px sans-serif"; |
| this.ctx.textAlign = "center"; |
| this.ctx.textBaseline = "top"; |
| const label = seg.prompt || "(no prompt)"; |
| const words = label.split(" "); |
| const lineH = 15; |
| let line = ""; |
| let lines = []; |
| for (const word of words) { |
| const test = line ? line + " " + word : word; |
| if (this.ctx.measureText(test).width > boxW && line) { |
| lines.push(line); |
| line = word; |
| } else { |
| line = test; |
| } |
| } |
| if (line) lines.push(line); |
|
|
| const maxLines = Math.max(1, Math.floor((this.blockHeight - pad * 2) / lineH)); |
| if (lines.length > maxLines) { |
| lines = lines.slice(0, maxLines); |
| lines[lines.length - 1] += "…"; |
| } |
|
|
| const totalTextHeight = lines.length * lineH; |
| let ty = RULER_HEIGHT + (this.blockHeight - totalTextHeight) / 2 + 2; |
|
|
| for (const l of lines) { |
| this.ctx.fillText(l, startX + pxWidth / 2, ty); |
| ty += lineH; |
| } |
| this.ctx.restore(); |
| } |
| } |
|
|
| if (isSelected) { |
| this.ctx.strokeStyle = "#fff"; |
| this.ctx.lineWidth = 2; |
| this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); |
| this.ctx.fillStyle = "#fff"; |
| this.ctx.beginPath(); |
| this.ctx.roundRect(startX, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2); |
| this.ctx.fill(); |
| this.ctx.beginPath(); |
| this.ctx.roundRect(startX + pxWidth - 4, RULER_HEIGHT + this.blockHeight / 2 - 12, 4, 24, 2); |
| this.ctx.fill(); |
| } else { |
| this.ctx.strokeStyle = "#000"; |
| this.ctx.lineWidth = 1.5; |
| this.ctx.strokeRect(startX, RULER_HEIGHT + 1, pxWidth, this.blockHeight - 2); |
| } |
| this.ctx.globalAlpha = 1.0; |
| } |
|
|
| |
| for (let i = 0; i < sortedAudioSegments.length; i++) { |
| const seg = sortedAudioSegments[i]; |
| const startX = (seg.start / totalFrames) * width; |
| const pxWidth = (seg.length / totalFrames) * width; |
| const isSelected = (this.selectionType === "audio" && seg.id === activeAudioSegId); |
| const trackY = RULER_HEIGHT + this.blockHeight; |
|
|
| if ((this._isDragging && this.selectionType === "audio" && seg.id === this._dragTargetId) || (this._ghostSegmentId && seg.id === this._ghostSegmentId)) { |
| this.ctx.globalAlpha = 0.65; |
| } else { |
| this.ctx.globalAlpha = 1.0; |
| } |
|
|
| if (seg.type === "ghost") { |
| this.ctx.fillStyle = "#1a1a1a"; |
| this.ctx.fillRect(startX, trackY, pxWidth, this.audioTrackHeight); |
| this.ctx.strokeStyle = "#555"; |
| this.ctx.lineWidth = 2; |
| this.ctx.setLineDash([5, 5]); |
| this.ctx.strokeRect(startX, trackY, pxWidth, this.audioTrackHeight); |
| this.ctx.setLineDash([]); |
| this.ctx.fillStyle = "#888"; |
| this.ctx.textAlign = "center"; |
| this.ctx.textBaseline = "middle"; |
| this.ctx.font = "bold 12px sans-serif"; |
| this.ctx.fillText("Drop Audio", startX + pxWidth / 2, trackY + this.audioTrackHeight / 2); |
| } else { |
| this.drawAudioSegmentVisuals(this.ctx, seg, isSelected, trackY, this.audioTrackHeight, startX, pxWidth); |
| } |
| this.ctx.globalAlpha = 1.0; |
| } |
|
|
| |
| |
| this.ctx.fillStyle = "#1e1e1e"; |
| this.ctx.fillRect(0, 0, width, RULER_HEIGHT); |
|
|
| |
| 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"; |
|
|
| |
| 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); |
|
|
| |
| this.ctx.fillStyle = "#333"; |
| this.ctx.fillRect(0, RULER_HEIGHT + this.blockHeight, width, 1); |
|
|
| |
| if (!this._isDragging) { |
| const BTN_R = 12; |
| const gapRegions = this.getGapRegions(); |
| for (let i = 0; i < gapRegions.length; i++) { |
| const gap = gapRegions[i]; |
| if (gap.widthPx < BTN_R * 2 + 8) continue; |
| const hov = this._hoveredGapIdx === i; |
| const BTN_W = 18; |
| const BTN_H = 18; |
| this.ctx.beginPath(); |
| this.ctx.roundRect(gap.centerX - BTN_W / 2, gap.centerY - BTN_H / 2, BTN_W, BTN_H, 4); |
| this.ctx.fillStyle = hov ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.05)"; |
| this.ctx.fill(); |
| this.ctx.fillStyle = hov ? "#fff" : "#888"; |
| this.ctx.font = "14px sans-serif"; |
| this.ctx.textAlign = "center"; |
| this.ctx.textBaseline = "middle"; |
| this.ctx.fillText("+", gap.centerX, gap.centerY + 1); |
| } |
| } |
|
|
| |
| |
| |
| const outputFrames = this.getDurationFrames(); |
| if (outputFrames < totalFrames) { |
| const cutoffX = (outputFrames / totalFrames) * width; |
| |
| this.ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; |
| this.ctx.fillRect(cutoffX, RULER_HEIGHT, width - cutoffX, this.blockHeight + this.audioTrackHeight); |
| |
| this.ctx.fillStyle = "rgba(0, 0, 0, 0.25)"; |
| this.ctx.fillRect(cutoffX, 0, width - cutoffX, RULER_HEIGHT); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
|
|
| |
| const playheadX = (this.currentFrame / totalFrames) * width; |
|
|
| |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| const grabBarW = 4; |
| const grabBarH = 50; |
| const grabBarX = this.viewport.scrollLeft + this.viewport.clientWidth - grabBarW - 3; |
| const grabBarY = RULER_HEIGHT + (this.blockHeight + this.audioTrackHeight - grabBarH) / 2; |
| |
| this.ctx.fillStyle = "rgba(40, 40, 40, 0.6)"; |
| this.ctx.beginPath(); |
| this.ctx.roundRect(grabBarX, grabBarY, grabBarW, grabBarH, 2); |
| this.ctx.fill(); |
|
|
| |
| const hBarW = 50; |
| const hBarH = 4; |
| const hBarX = this.viewport.scrollLeft + (this.viewport.clientWidth - hBarW) / 2; |
| const hBarY = this.canvasHeight - hBarH - 3; |
| |
| this.ctx.fillStyle = "rgba(20, 20, 20, 0.8)"; |
| this.ctx.beginPath(); |
| this.ctx.roundRect(hBarX, hBarY, hBarW, hBarH, 2); |
| this.ctx.fill(); |
|
|
| this.updatePlayerUI(); |
| } |
|
|
| drawAudioSegmentVisuals(ctx, seg, isSelected, yOffset, trackHeight, startX, pxWidth) { |
| ctx.fillStyle = isSelected ? "#2a4a3a" : "#1a2a1a"; |
| ctx.fillRect(startX, yOffset + 2, pxWidth, trackHeight - 3); |
|
|
| if (seg.waveformPeaks && pxWidth > 0) { |
| ctx.fillStyle = isSelected ? "rgba(100, 255, 100, 0.6)" : "rgba(100, 255, 100, 0.3)"; |
| const startRatio = seg.trimStart / seg.audioDurationFrames; |
| const endRatio = (seg.trimStart + seg.length) / seg.audioDurationFrames; |
| const peakCount = seg.waveformPeaks.length; |
| const centerY = yOffset + trackHeight / 2; |
|
|
| ctx.beginPath(); |
| for (let i = 0; i < pxWidth; i++) { |
| const pixelRatio = i / pxWidth; |
| const globalRatio = startRatio + pixelRatio * (endRatio - startRatio); |
| const peakIdx = Math.floor(globalRatio * peakCount); |
|
|
| if (peakIdx >= 0 && peakIdx < peakCount) { |
| const val = seg.waveformPeaks[peakIdx]; |
| const amp = (val * (trackHeight - 12) / 2) * 0.9; |
| ctx.fillRect(startX + i, centerY - amp, 1, amp * 2); |
| } |
| } |
| } |
|
|
| ctx.strokeStyle = isSelected ? "#4fff8f" : "#000"; |
| ctx.lineWidth = 1.5; |
| ctx.strokeRect(startX, yOffset + 2, pxWidth, trackHeight - 3); |
|
|
| if (isSelected) { |
| ctx.fillStyle = "#4fff8f"; |
| ctx.beginPath(); |
| ctx.roundRect(startX, yOffset + trackHeight / 2 - 12, 4, 24, 2); |
| ctx.fill(); |
| ctx.beginPath(); |
| ctx.roundRect(startX + pxWidth - 4, yOffset + trackHeight / 2 - 12, 4, 24, 2); |
| ctx.fill(); |
| } |
|
|
| ctx.fillStyle = "#ccc"; |
| ctx.font = "11px sans-serif"; |
| ctx.textBaseline = "top"; |
| ctx.textAlign = "left"; |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.rect(startX, yOffset + 2, pxWidth, trackHeight - 3); |
| ctx.clip(); |
|
|
| let text = seg.fileName || "Audio Track"; |
| const maxWidth = pxWidth - 12; |
| if (ctx.measureText(text).width > maxWidth && maxWidth > 0) { |
| while (text.length > 0 && ctx.measureText(text + "...").width > maxWidth) { |
| text = text.slice(0, -1); |
| } |
| text = text + "..."; |
| } |
|
|
| ctx.fillText(text, startX + 6, yOffset + 8); |
| ctx.restore(); |
| } |
|
|
|
|
| |
| getHitTest(mouseX, mouseY) { |
| const width = this.canvas.offsetWidth; |
| const totalFrames = this.getVisualDurationFrames(); |
|
|
| |
| const playheadX = (this.currentFrame / totalFrames) * width; |
| if (mouseY <= 24 && Math.abs(mouseX - playheadX) <= 12) { |
| return { type: "playhead" }; |
| } |
|
|
| if (mouseY <= RULER_HEIGHT) { |
| return { type: "ruler" }; |
| } |
|
|
| if (mouseY < RULER_HEIGHT || mouseY > this.canvasHeight) return null; |
|
|
| const isAudioTrack = mouseY > RULER_HEIGHT + this.blockHeight; |
| const trackSegments = isAudioTrack ? this.timeline.audioSegments : this.timeline.segments; |
| const trackType = isAudioTrack ? "audio" : "image"; |
|
|
| if (trackSegments.length === 0) return null; |
|
|
| |
|
|
| let sortedSegments = [...trackSegments] |
| .map((s, i) => ({ ...s, originalIndex: i })) |
| .sort((a, b) => a.start - b.start); |
|
|
| const HANDLE_CORE = 4; |
|
|
| for (let i = 0; i < sortedSegments.length; i++) { |
| const seg = sortedSegments[i]; |
| const startX = (seg.start / totalFrames) * width; |
| const pxWidth = (seg.length / totalFrames) * width; |
| const endX = startX + pxWidth; |
|
|
| const prevSeg = sortedSegments[i - 1]; |
| const nextSeg = sortedSegments[i + 1]; |
|
|
| const isLeftJoint = prevSeg && prevSeg.start + prevSeg.length === seg.start; |
| if (!isLeftJoint) { |
| if (Math.abs(mouseX - startX) <= HANDLE_HIT_PX) { |
| return { type: "edge", index: seg.originalIndex, dir: "left", track: trackType }; |
| } |
| } |
|
|
| const isRightJoint = nextSeg && nextSeg.start === seg.start + seg.length; |
| if (isRightJoint) { |
| const dx = mouseX - endX; |
| if (Math.abs(dx) <= HANDLE_HIT_PX) { |
| if (dx < -HANDLE_CORE) { |
| return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType }; |
| } else if (dx > HANDLE_CORE) { |
| return { type: "edge", index: nextSeg.originalIndex, dir: "left", track: trackType }; |
| } else { |
| return { type: "joint", leftIndex: seg.originalIndex, rightIndex: nextSeg.originalIndex, track: trackType }; |
| } |
| } |
| } else { |
| if (Math.abs(mouseX - endX) <= HANDLE_HIT_PX) { |
| return { type: "edge", index: seg.originalIndex, dir: "right", track: trackType }; |
| } |
| } |
| } |
|
|
| for (let i = 0; i < sortedSegments.length; i++) { |
| const seg = sortedSegments[i]; |
| const startX = (seg.start / totalFrames) * width; |
| const pxWidth = (seg.length / totalFrames) * width; |
| const endX = startX + pxWidth; |
|
|
| if (mouseX >= startX && mouseX < endX) { |
| return { type: "center", index: seg.originalIndex, track: trackType }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| onMouseDown(e) { |
| if (e.button !== 0) return; |
| const { x, y } = this.getMousePos(e); |
|
|
| const isOverDivider = Math.abs(y - (RULER_HEIGHT + this.blockHeight)) <= 4; |
| if (isOverDivider) { |
| this._isDragging = true; |
| this._dragType = "divider"; |
| this._startBlockHeight = this.blockHeight; |
| this._startAudioTrackHeight = this.audioTrackHeight; |
| this._startY = y; |
| return; |
| } |
|
|
| const isAtBottom = Math.abs(y - this.canvasHeight) <= 15; |
| if (isAtBottom) { |
| this._isDragging = true; |
| this._dragType = "height_resize"; |
| this._startBlockHeight = this.blockHeight; |
| this._startY = y; |
| document.body.style.userSelect = "none"; |
| return; |
| } |
|
|
| const viewRect = this.viewport.getBoundingClientRect(); |
| const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20; |
| if (isAtRightEdge) { |
| this._isDragging = true; |
| this._dragType = "width_resize"; |
| this._startNodeWidth = this.node.size[0]; |
| this._startX = e.clientX; |
| document.body.style.userSelect = "none"; |
| return; |
| } |
|
|
| if (y >= RULER_HEIGHT && y <= this.canvasHeight) { |
| const BTN_R = 12; |
| const gapRegions = this.getGapRegions(); |
| for (let i = 0; i < gapRegions.length; i++) { |
| const gap = gapRegions[i]; |
| if (gap.widthPx < BTN_R * 2 + 8) continue; |
| const dx = x - gap.centerX, dy2 = y - gap.centerY; |
| if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) { |
| if (gap.track === "audio") { |
| |
| this.promptAddAudioInGap(gap.frameStart, gap.frameEnd); |
| } else { |
| this.showGapMenu(e.clientX, e.clientY, gap); |
| } |
| return; |
| } |
| } |
| } |
|
|
| const hit = this.getHitTest(x, y); |
| if (!hit) { |
| |
| const clickedTrack = y > RULER_HEIGHT + this.blockHeight ? "audio" : "image"; |
| if (this.selectionType === clickedTrack) { |
| this.selectedIndex = -1; |
| this.updateUIFromSelection(); |
| } |
| this.render(); |
| return; |
| } |
|
|
| if (hit.type === "playhead" || hit.type === "ruler") { |
| this._isDragging = true; |
| this._dragType = "playhead"; |
| const logicalWidth = this.canvas.offsetWidth; |
| const totalFrames = this.getVisualDurationFrames(); |
| let mouseFrameX = x * (totalFrames / logicalWidth); |
| this.currentFrame = clamp(mouseFrameX, 0, totalFrames); |
| this.render(); |
| if (this.isPlaying) { |
| this.playAudio(); |
| } |
| return; |
| } |
|
|
| this.selectionType = hit.track; |
| const targetArray = hit.track === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
|
|
| if (hit.type === "joint") { |
| this.selectedIndex = hit.leftIndex; |
| this.updateUIFromSelection(); |
| this._dragType = "joint"; |
| this._dragTargetId = targetArray[hit.leftIndex].id; |
| this._dragTargetIdRight = targetArray[hit.rightIndex].id; |
| } else if (hit.type === "center") { |
| this.selectedIndex = hit.index; |
| this.updateUIFromSelection(); |
| this._dragType = "center"; |
| } else { |
| if (this.selectedIndex !== hit.index) { |
| this.selectedIndex = hit.index; |
| this.updateUIFromSelection(); |
| } |
| this._dragType = hit.dir; |
| } |
|
|
| this._isDragging = true; |
| this._previewSegments = null; |
| this._dragStartX = x; |
| this._dragInitialTimeline = JSON.parse(JSON.stringify(targetArray)); |
|
|
| if (hit.type !== "joint") { |
| this._dragTargetId = targetArray[hit.index].id; |
| } |
| this.render(); |
| } |
|
|
| onMouseMove(e) { |
| const { x: mouseX, y: mouseY } = this.getMousePos(e); |
|
|
| if (!this._isDragging) { |
| let newHoveredGapIdx = -1; |
| const BTN_R = 12; |
| const gapRegions = this.getGapRegions(); |
| for (let i = 0; i < gapRegions.length; i++) { |
| const gap = gapRegions[i]; |
| if (gap.widthPx < BTN_R * 2 + 8) continue; |
| const dx = mouseX - gap.centerX, dy2 = mouseY - gap.centerY; |
| if (dx * dx + dy2 * dy2 <= BTN_R * BTN_R) { newHoveredGapIdx = i; break; } |
| } |
| if (this._hoveredGapIdx !== newHoveredGapIdx) { |
| this._hoveredGapIdx = newHoveredGapIdx; |
| this.render(); |
| } |
|
|
| const isOverDivider = Math.abs(mouseY - (RULER_HEIGHT + this.blockHeight)) <= 4; |
| const isAtBottom = Math.abs(mouseY - this.canvasHeight) <= 15; |
| const viewRect = this.viewport.getBoundingClientRect(); |
| const isAtRightEdge = Math.abs(e.clientX - viewRect.right) <= 20; |
| const hit = this.getHitTest(mouseX, mouseY); |
| if (isOverDivider || isAtBottom) { |
| this.canvas.style.cursor = "ns-resize"; |
| } else if (isAtRightEdge) { |
| this.canvas.style.cursor = "ew-resize"; |
| } else if (newHoveredGapIdx >= 0) { |
| this.canvas.style.cursor = "pointer"; |
| } else if (hit?.type === "edge") { |
| this.canvas.style.cursor = "ew-resize"; |
| } else if (hit?.type === "joint") { |
| this.canvas.style.cursor = "col-resize"; |
| } else if (hit?.type === "center") { |
| this.canvas.style.cursor = "grab"; |
| } else if (hit?.type === "playhead") { |
| this.canvas.style.cursor = "ew-resize"; |
| } else { |
| this.canvas.style.cursor = "default"; |
| } |
| return; |
| } |
|
|
| if (this._dragType === "divider") { |
| this.canvas.style.cursor = "ns-resize"; |
| const deltaY = mouseY - this._startY; |
|
|
| const minBlockH = 50; |
| const minAudioH = 50; |
|
|
| let newBlockHeight = this._startBlockHeight + deltaY; |
| let newAudioTrackHeight = this._startAudioTrackHeight - deltaY; |
|
|
| if (newBlockHeight < minBlockH) { |
| newBlockHeight = minBlockH; |
| newAudioTrackHeight = this._startBlockHeight + this._startAudioTrackHeight - minBlockH; |
| } |
| if (newAudioTrackHeight < minAudioH) { |
| newAudioTrackHeight = minAudioH; |
| newBlockHeight = this._startBlockHeight + this._startAudioTrackHeight - minAudioH; |
| } |
|
|
| this.blockHeight = newBlockHeight; |
| this.audioTrackHeight = newAudioTrackHeight; |
|
|
| this.render(); |
| return; |
| } |
|
|
| if (this._dragType === "height_resize") { |
| this.canvas.style.cursor = "ns-resize"; |
| const deltaY = mouseY - this._startY; |
|
|
| this.blockHeight = Math.max(100, this._startBlockHeight + deltaY); |
| this.canvasHeight = this.rulerHeight + this.blockHeight + this.audioTrackHeight; |
|
|
| this.canvas.style.height = `${this.canvasHeight}px`; |
|
|
| this.resizeCanvas(this.canvas.offsetWidth); |
| this.render(); |
|
|
| if (this.node && this.node.computeSize) { |
| const sz = this.node.computeSize(); |
| this.node.size[1] = sz[1]; |
| if (window.app && window.app.graph) { |
| window.app.graph.setDirtyCanvas(true, true); |
| } |
| } |
| return; |
| } |
|
|
| if (this._dragType === "width_resize") { |
| this.canvas.style.cursor = "ew-resize"; |
| const deltaX = e.clientX - this._startX; |
|
|
| this.node.size[0] = Math.max(300, this._startNodeWidth + deltaX); |
|
|
| if (window.app && window.app.graph) { |
| window.app.graph.setDirtyCanvas(true, true); |
| } |
| return; |
| } |
|
|
| if (this._dragType === "playhead") { |
| this.canvas.style.cursor = "ew-resize"; |
| const logicalWidth = this.canvas.offsetWidth; |
| const totalFrames = this.getVisualDurationFrames(); |
| let mouseFrameX = mouseX * (totalFrames / logicalWidth); |
| this.currentFrame = clamp(mouseFrameX, 0, totalFrames); |
| this.render(); |
| if (this.isPlaying) { |
| this.playAudio(); |
| } |
| return; |
| } |
|
|
| this.canvas.style.cursor = this._dragType === "center" ? "grabbing" : |
| this._dragType === "joint" ? "col-resize" : "ew-resize"; |
|
|
| const logicalWidth = this.canvas.offsetWidth; |
| const totalFrames = this.getVisualDurationFrames(); |
| const durationFrames = totalFrames; |
| const dragDelta = Math.round((mouseX - this._dragStartX) * (totalFrames / logicalWidth)); |
|
|
| let t = JSON.parse(JSON.stringify(this._dragInitialTimeline)); |
|
|
| |
| 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") { |
| |
| |
| maxDeltaLeft = Math.min(maxDeltaLeft, origRight.trimStart || 0); |
| |
| |
| let availLeftTail = (origLeft.audioDurationFrames || origLeft.length) - ((origLeft.trimStart || 0) + origLeft.length); |
| maxDeltaRight = Math.min(maxDeltaRight, availLeftTail); |
| } |
|
|
| let safeDelta = clamp(dragDelta, -maxDeltaLeft, maxDeltaRight); |
|
|
| t[leftIdx].length = origLeft.length + safeDelta; |
| t[rightIdx].start = origRight.start + safeDelta; |
| t[rightIdx].length = origRight.length - safeDelta; |
|
|
| if (this.selectionType === "audio") { |
| t[rightIdx].trimStart = origRight.trimStart + safeDelta; |
| } |
| } |
| } |
| |
| else { |
| const targetIdx = t.findIndex((s) => s.id === this._dragTargetId); |
| if (targetIdx < 0) return; |
|
|
| if (this._dragType === "right") { |
| let newLen = t[targetIdx].length + dragDelta; |
| let maxPossibleLength = totalFrames - t[targetIdx].start; |
| let nextSeg = t.find(s => s.start >= t[targetIdx].start + t[targetIdx].length && s.id !== t[targetIdx].id); |
| if (nextSeg) { |
| maxPossibleLength = nextSeg.start - t[targetIdx].start; |
| } |
|
|
| if (this.selectionType === "audio") { |
| maxPossibleLength = Math.min(maxPossibleLength, (t[targetIdx].audioDurationFrames || t[targetIdx].length) - (t[targetIdx].trimStart || 0)); |
| } |
|
|
| t[targetIdx].length = Math.max(MIN_SEGMENT_LENGTH, Math.min(newLen, maxPossibleLength)); |
|
|
| } else if (this._dragType === "left") { |
| let newStart = t[targetIdx].start + dragDelta; |
| let minPossibleStart = 0; |
| let prevSeg = t.slice().reverse().find(s => s.start + s.length <= t[targetIdx].start && s.id !== t[targetIdx].id); |
| if (prevSeg) { |
| minPossibleStart = prevSeg.start + prevSeg.length; |
| } |
|
|
| if (this.selectionType === "audio") { |
| minPossibleStart = Math.max(minPossibleStart, t[targetIdx].start - (t[targetIdx].trimStart || 0)); |
| } |
|
|
| let maxStart = t[targetIdx].start + t[targetIdx].length - MIN_SEGMENT_LENGTH; |
| newStart = Math.max(minPossibleStart, Math.min(newStart, maxStart)); |
|
|
| let diff = newStart - t[targetIdx].start; |
| t[targetIdx].start = newStart; |
| t[targetIdx].length -= diff; |
| if (this.selectionType === "audio") { |
| t[targetIdx].trimStart += diff; |
| } |
|
|
| } else if (this._dragType === "center") { |
| let initT = this._dragInitialTimeline; |
| let dIdx = initT.findIndex(s => s.id === this._dragTargetId); |
| if (dIdx < 0) return; |
| let D = JSON.parse(JSON.stringify(initT[dIdx])); |
|
|
| let D_mouse_start = D.start + dragDelta; |
| let mouseFrameX = mouseX * (totalFrames / logicalWidth); |
|
|
| t = this._applyCenterDragPhysics(initT, D.id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth); |
| } |
| } |
|
|
| this._previewSegments = t; |
| this.updateUIFromSelection(); |
| this.render(); |
| } |
|
|
| _applyCenterDragPhysics(initT, D_id, D_mouse_start, mouseFrameX, durationFrames, totalFrames, logicalWidth) { |
| let t_copy = JSON.parse(JSON.stringify(initT)); |
| let dIdx = t_copy.findIndex(s => s.id === D_id); |
| if (dIdx < 0) return t_copy; |
|
|
| let D = t_copy[dIdx]; |
| let D_clamped_start = clamp(D_mouse_start, 0, durationFrames - D.length); |
|
|
| let baseSegments = t_copy.filter(s => s.id !== D.id); |
|
|
| let insertIdx = baseSegments.length; |
| for (let i = 0; i < baseSegments.length; i++) { |
| let centerBase = baseSegments[i].start + baseSegments[i].length / 2; |
| if (mouseFrameX < centerBase) { |
| insertIdx = i; |
| break; |
| } |
| } |
|
|
| let leftBound = insertIdx > 0 ? baseSegments[insertIdx - 1].start + baseSegments[insertIdx - 1].length : 0; |
| let rightBound = insertIdx < baseSegments.length ? baseSegments[insertIdx].start : durationFrames; |
|
|
| if (rightBound - leftBound >= D.length) { |
| D_clamped_start = clamp(D_clamped_start, leftBound, rightBound - D.length); |
| } else { |
| let gapCenter = (leftBound + rightBound) / 2; |
| D_clamped_start = gapCenter - D.length / 2; |
| } |
|
|
| let t_test = []; |
| for (let i = 0; i < insertIdx; i++) { |
| t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start }); |
| } |
| t_test.push({ ...D, start: D_clamped_start, original_start: D_clamped_start }); |
| let D_index = insertIdx; |
|
|
| for (let i = insertIdx; i < baseSegments.length; i++) { |
| t_test.push({ ...baseSegments[i], original_start: baseSegments[i].start }); |
| } |
|
|
| for (let i = D_index + 1; i < t_test.length; i++) { |
| let prev = t_test[i - 1]; |
| t_test[i].start = Math.max(t_test[i].original_start, prev.start + prev.length); |
| } |
|
|
| for (let i = D_index - 1; i >= 0; i--) { |
| let next = t_test[i + 1]; |
| t_test[i].start = Math.min(t_test[i].original_start, next.start - t_test[i].length); |
| } |
|
|
| let rightCursor = durationFrames; |
| for (let i = t_test.length - 1; i >= 0; i--) { |
| if (t_test[i].start + t_test[i].length > rightCursor) { |
| t_test[i].start = rightCursor - t_test[i].length; |
| } |
| rightCursor = t_test[i].start; |
| } |
| let leftCursor = 0; |
| for (let i = 0; i < t_test.length; i++) { |
| if (t_test[i].start < leftCursor) { |
| t_test[i].start = leftCursor; |
| } |
| leftCursor = t_test[i].start + t_test[i].length; |
| } |
|
|
| let result = t_test.map(s => { |
| let clean = { ...s }; |
| delete clean.original_start; |
| return clean; |
| }); |
|
|
| let draggedPreview = result.find(s => s.id === D.id); |
| if (draggedPreview) { |
| draggedPreview.resolvedStart = draggedPreview.start; |
| } |
|
|
| return result; |
| } |
|
|
| onMouseUp(e) { |
| document.body.style.userSelect = ""; |
| if (this._isDragging) { |
| if (this._previewSegments) { |
| const targetArray = this.selectionType === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
|
|
| const mappedArray = this._previewSegments.map(ps => { |
| const orig = targetArray.find(s => s.id === ps.id); |
| let finalStart = ps.resolvedStart !== undefined ? ps.resolvedStart : ps.start; |
| let newPs = { ...ps, start: finalStart }; |
| if (orig && orig.imgObj) newPs.imgObj = orig.imgObj; |
| delete newPs.resolvedStart; |
| return newPs; |
| }); |
|
|
| if (this.selectionType === "audio") { |
| this.timeline.audioSegments = mappedArray; |
| if (this._dragTargetId) this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === this._dragTargetId); |
| } else { |
| this.timeline.segments = mappedArray; |
| if (this._dragTargetId) this.selectedIndex = this.timeline.segments.findIndex(s => s.id === this._dragTargetId); |
| } |
| } |
|
|
| this._isDragging = false; |
| this._previewSegments = null; |
| this._ghostTrack = null; |
| this.canvas.style.cursor = "default"; |
| this.commitChanges(); |
| } |
| } |
|
|
| |
| commitChanges(skipRender = false) { |
| let sortedSegments = [...this.timeline.segments].sort((a, b) => a.start - b.start); |
| let contiguousLengths = []; |
| let contiguousPrompts = []; |
| let currentCursor = 0; |
| const durationFrames = this.getDurationFrames(); |
|
|
| |
| |
| |
| |
| |
| let pendingGap = 0; |
| for (let seg of sortedSegments) { |
| |
| if (seg.start >= durationFrames) break; |
|
|
| if (seg.start > currentCursor) { |
| |
| const gapLength = Math.min(seg.start, durationFrames) - currentCursor; |
| if (contiguousLengths.length > 0) { |
| contiguousLengths[contiguousLengths.length - 1] += gapLength; |
| } else { |
| pendingGap += gapLength; |
| } |
| } |
|
|
| |
| const clippedEnd = Math.min(seg.start + seg.length, durationFrames); |
| const clippedLength = clippedEnd - seg.start; |
|
|
| contiguousLengths.push(clippedLength + pendingGap); |
| contiguousPrompts.push(seg.prompt || ""); |
| pendingGap = 0; |
| currentCursor = seg.start + seg.length; |
| } |
|
|
| |
| const clampedCursor = Math.min(currentCursor, durationFrames); |
| if (contiguousLengths.length > 0 && clampedCursor < durationFrames) { |
| contiguousLengths[contiguousLengths.length - 1] += durationFrames - clampedCursor; |
| } |
|
|
| const toSave = { |
| segments: sortedSegments.map(s => { |
| const { imgObj, ...rest } = s; |
| return rest; |
| }), |
| audioSegments: (this.timeline.audioSegments || []).map(s => ({ ...s })) |
| }; |
|
|
| const jsonStr = JSON.stringify(toSave); |
| if (this.timelineDataWidget) this.timelineDataWidget.value = jsonStr; |
|
|
| if (this.localPromptsWidget) { |
| this.localPromptsWidget.value = contiguousPrompts.join(" | "); |
| } |
| if (this.segmentLengthsWidget) { |
| this.segmentLengthsWidget.value = contiguousLengths.join(","); |
| } |
|
|
| if (this.guideStrengthWidget) { |
| const imgStrengths = sortedSegments |
| .filter(s => s.type !== "text") |
| .map(s => (s.guideStrength !== undefined ? s.guideStrength : 1.0).toFixed(2)); |
| this.guideStrengthWidget.value = imgStrengths.join(","); |
| } |
|
|
| |
| this.updateZoomSliderMax(); |
|
|
| setTimeout(() => { |
| if (this.node && this.node.computeSize) { |
| const sz = this.node.computeSize(); |
| this.node.size[1] = sz[1]; |
| if (app.graph) app.graph.setDirtyCanvas(true, true); |
| } |
| }, 0); |
|
|
| if (!skipRender) this.render(); |
| } |
|
|
| |
| getGapRegions() { |
| const totalFrames = this.getVisualDurationFrames(); |
| const outputFrames = this.getDurationFrames(); |
| const width = this.canvas.offsetWidth || this._lastWidth || 0; |
| const gaps = []; |
| if (!width) return 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 }); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| onContextMenu(e) { |
| e.preventDefault(); |
| const { x: mouseX, y: mouseY } = this.getMousePos(e); |
|
|
| const trackHeight = this.blockHeight; |
| const isAudioTrack = mouseY >= RULER_HEIGHT + trackHeight && mouseY <= RULER_HEIGHT + trackHeight + this.audioTrackHeight; |
| const isImageTrack = mouseY >= RULER_HEIGHT && mouseY <= RULER_HEIGHT + trackHeight; |
|
|
| const logicalWidth = this.canvas.offsetWidth || 1; |
| const totalFrames = this.getVisualDurationFrames(); |
| const cursor = mouseX * (totalFrames / logicalWidth); |
|
|
| let clickedSeg = null; |
| let trackType = ""; |
|
|
| if (isAudioTrack) { |
| clickedSeg = this.timeline.audioSegments.find(s => cursor >= s.start && cursor <= s.start + s.length); |
| trackType = "audio"; |
| } else if (isImageTrack) { |
| clickedSeg = this.timeline.segments.find(s => cursor >= s.start && cursor <= s.start + s.length); |
| trackType = clickedSeg ? clickedSeg.type : ""; |
| } |
|
|
| if (clickedSeg) { |
| this.showContextMenu(e.clientX, e.clientY, clickedSeg, trackType); |
| } else if (isAudioTrack || isImageTrack) { |
| const gapRegions = this.getGapRegions(); |
| const currentTrack = isAudioTrack ? "audio" : "image"; |
| let gap = gapRegions.find(g => cursor >= g.frameStart && cursor <= g.frameEnd && g.track === currentTrack); |
|
|
| if (!gap) { |
| const startFrame = Math.round(cursor); |
| gap = { |
| track: currentTrack, |
| frameStart: startFrame, |
| frameEnd: startFrame + Math.max(1, this.getFrameRate()) |
| }; |
| } |
| gap.clickedFrame = cursor; |
|
|
| this.showGapContextMenu(e.clientX, e.clientY, gap); |
| } |
| } |
|
|
| showContextMenu(clientX, clientY, seg, trackType) { |
| this.dismissContextMenu(); |
| const menu = document.createElement("div"); |
| menu.className = "pr-gap-menu"; |
| menu.style.left = `${clientX + 6}px`; |
| menu.style.top = `${clientY - 10}px`; |
|
|
| const imageSrc = seg.imageB64 || seg.imageUrl; |
| const isImage = trackType !== "audio" && trackType !== "text" && imageSrc; |
|
|
| if (isImage) { |
| const copyBtn = document.createElement("button"); |
| copyBtn.className = "pr-gap-menu-btn"; |
| copyBtn.innerHTML = `Copy Image`; |
| copyBtn.onclick = async () => { |
| try { |
| const res = await fetch(imageSrc); |
| const blob = await res.blob(); |
| await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); |
| } catch (err) { |
| console.error("Failed to copy image", err); |
| } |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(copyBtn); |
|
|
| const saveBtn = document.createElement("button"); |
| saveBtn.className = "pr-gap-menu-btn"; |
| saveBtn.innerHTML = `Save Image`; |
| saveBtn.onclick = () => { |
| const a = document.createElement("a"); |
| a.href = imageSrc; |
| a.download = "timeline_image.jpg"; |
| a.click(); |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(saveBtn); |
|
|
| const openBtn = document.createElement("button"); |
| openBtn.className = "pr-gap-menu-btn"; |
| openBtn.innerHTML = `Open Image in New Tab`; |
| openBtn.onclick = () => { |
| const win = window.open(); |
| if (win) { |
| win.document.write(`<body style="margin:0;display:flex;justify-content:center;align-items:center;background:#0e0e0e;height:100vh;"><img style="max-width:100%;max-height:100%;" src="${imageSrc}" /></body>`); |
| win.document.close(); |
| } |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(openBtn); |
| } |
|
|
| if (trackType !== "audio") { |
| const copyPromptBtn = document.createElement("button"); |
| copyPromptBtn.className = "pr-gap-menu-btn"; |
| copyPromptBtn.innerHTML = `Copy Prompt`; |
| copyPromptBtn.onclick = async () => { |
| try { |
| await navigator.clipboard.writeText(seg.prompt || ""); |
| } catch (err) { |
| console.error("Failed to copy prompt", err); |
| } |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(copyPromptBtn); |
| } |
|
|
| const copySegBtn = document.createElement("button"); |
| copySegBtn.className = "pr-gap-menu-btn"; |
| copySegBtn.innerHTML = `Copy Segment`; |
| copySegBtn.onclick = () => { |
| this._copiedSegment = { ...seg, id: Date.now().toString() + Math.random().toString(36).substr(2, 5) }; |
| this._copiedSegmentTrack = trackType === "audio" ? "audio" : "image"; |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(copySegBtn); |
|
|
| const currentTrack = trackType === "audio" ? "audio" : "image"; |
| if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { |
| const pasteReplaceBtn = document.createElement("button"); |
| pasteReplaceBtn.className = "pr-gap-menu-btn"; |
| pasteReplaceBtn.innerHTML = `Paste & Replace`; |
| pasteReplaceBtn.onclick = () => { |
| const newSeg = { |
| ...this._copiedSegment, |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: seg.start, |
| length: this._copiedSegment.length |
| }; |
| const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
| const idx = targetArray.findIndex(s => s.id === seg.id); |
| if (idx >= 0) targetArray[idx] = newSeg; |
| this.commitChanges(); |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(pasteReplaceBtn); |
| } |
|
|
| const delBtn = document.createElement("button"); |
| delBtn.className = "pr-gap-menu-btn"; |
| delBtn.innerHTML = `Delete`; |
| delBtn.style.color = "#ff4444"; |
| delBtn.onclick = () => { |
| this.selectionType = trackType === "audio" ? "audio" : "image"; |
| const list = trackType === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
| this.selectedIndex = list.findIndex(s => s.id === seg.id); |
| this.deleteSelectedSegment(); |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(delBtn); |
|
|
| document.body.appendChild(menu); |
| this._contextMenu = menu; |
|
|
| setTimeout(() => { |
| this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); }; |
| document.addEventListener("pointerdown", this._contextMenuDismisser, true); |
| }, 0); |
| } |
|
|
| showGapContextMenu(clientX, clientY, gap) { |
| this.dismissContextMenu(); |
| const menu = document.createElement("div"); |
| menu.className = "pr-gap-menu"; |
| menu.style.left = `${clientX + 6}px`; |
| menu.style.top = `${clientY - 10}px`; |
|
|
| const currentTrack = gap.track === "audio" ? "audio" : "image"; |
|
|
| if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { |
| const pasteBtn = document.createElement("button"); |
| pasteBtn.className = "pr-gap-menu-btn"; |
| pasteBtn.innerHTML = `Paste Segment`; |
| pasteBtn.onclick = () => { |
| const startFrame = Math.round(gap.clickedFrame !== undefined ? gap.clickedFrame : gap.frameStart); |
| const gapLength = gap.frameEnd - startFrame; |
|
|
| const newSeg = { |
| ...this._copiedSegment, |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: startFrame, |
| length: Math.min(this._copiedSegment.length, gapLength) |
| }; |
| const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
| targetArray.push(newSeg); |
| targetArray.sort((a, b) => a.start - b.start); |
| this.commitChanges(); |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(pasteBtn); |
| } |
|
|
| if (currentTrack === "image") { |
| const textBtn = document.createElement("button"); |
| textBtn.className = "pr-gap-menu-btn"; |
| textBtn.innerHTML = `${ICONS.text} Text Segment`; |
| textBtn.onclick = () => { |
| this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text"); |
| this.dismissContextMenu(); |
| }; |
| menu.appendChild(textBtn); |
|
|
| const imgBtn = document.createElement("button"); |
| imgBtn.className = "pr-gap-menu-btn"; |
| imgBtn.innerHTML = `${ICONS.upload} Image Segment`; |
| imgBtn.onclick = () => { |
| this.dismissContextMenu(); |
| const fi = document.createElement("input"); |
| fi.type = "file"; fi.accept = "image/*"; |
| fi.addEventListener("change", (ev) => { |
| if (ev.target.files?.[0]) { |
| const gapLength = gap.frameEnd - gap.frameStart; |
| this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength); |
| } |
| }); |
| fi.click(); |
| }; |
| menu.appendChild(imgBtn); |
|
|
| const imgUrlBtn = document.createElement("button"); |
| imgUrlBtn.className = "pr-gap-menu-btn"; |
| imgUrlBtn.innerHTML = `${ICONS.link} Image URL Segment`; |
| imgUrlBtn.onclick = () => { |
| this.dismissContextMenu(); |
| const gapLength = gap.frameEnd - gap.frameStart; |
| this.promptAddImageUrl(gap.frameStart, gapLength); |
| }; |
| menu.appendChild(imgUrlBtn); |
| } else { |
| const audioBtn = document.createElement("button"); |
| audioBtn.className = "pr-gap-menu-btn"; |
| audioBtn.innerHTML = `${ICONS.audio} Audio Segment`; |
| audioBtn.onclick = () => { |
| this.dismissContextMenu(); |
| this.promptAddAudioInGap(gap.frameStart, gap.frameEnd); |
| }; |
| menu.appendChild(audioBtn); |
|
|
| const audioUrlBtn = document.createElement("button"); |
| audioUrlBtn.className = "pr-gap-menu-btn"; |
| audioUrlBtn.innerHTML = `${ICONS.link} Audio URL Segment`; |
| audioUrlBtn.onclick = () => { |
| this.dismissContextMenu(); |
| this.promptAddAudioUrl(gap.frameStart); |
| }; |
| menu.appendChild(audioUrlBtn); |
| } |
|
|
| document.body.appendChild(menu); |
| this._contextMenu = menu; |
| setTimeout(() => { |
| this._contextMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissContextMenu(); }; |
| document.addEventListener("pointerdown", this._contextMenuDismisser, true); |
| }, 0); |
| } |
| dismissContextMenu() { |
| if (this._contextMenu) { this._contextMenu.remove(); this._contextMenu = null; } |
| if (this._contextMenuDismisser) { document.removeEventListener("pointerdown", this._contextMenuDismisser, true); this._contextMenuDismisser = null; } |
| } |
|
|
| |
| showGapMenu(clientX, clientY, gap) { |
| this.dismissGapMenu(); |
| const menu = document.createElement("div"); |
| menu.className = "pr-gap-menu"; |
| menu.style.left = `${clientX + 6}px`; |
| menu.style.top = `${clientY - 10}px`; |
|
|
| const textBtn = document.createElement("button"); |
| textBtn.className = "pr-gap-menu-btn"; |
| textBtn.innerHTML = `${ICONS.text} Text Segment`; |
| textBtn.addEventListener("click", () => { |
| this.addSegmentInGap(gap.frameStart, gap.frameEnd, "text"); |
| this.dismissGapMenu(); |
| }); |
|
|
| const imgBtn = document.createElement("button"); |
| imgBtn.className = "pr-gap-menu-btn"; |
| imgBtn.innerHTML = `${ICONS.upload} Image Segment`; |
| imgBtn.addEventListener("click", () => { |
| this.dismissGapMenu(); |
| const fi = document.createElement("input"); |
| fi.type = "file"; fi.accept = "image/*"; |
| fi.addEventListener("change", (ev) => { |
| if (ev.target.files?.[0]) { |
| const gapLength = gap.frameEnd - gap.frameStart; |
| this.handleImageUpload([ev.target.files[0]], gap.frameStart, gapLength); |
| } |
| }); |
| fi.click(); |
| }); |
|
|
| menu.appendChild(textBtn); |
| menu.appendChild(imgBtn); |
|
|
| const imgUrlBtn = document.createElement("button"); |
| imgUrlBtn.className = "pr-gap-menu-btn"; |
| imgUrlBtn.innerHTML = `${ICONS.link} Image URL Segment`; |
| imgUrlBtn.addEventListener("click", () => { |
| this.dismissGapMenu(); |
| const gapLength = gap.frameEnd - gap.frameStart; |
| this.promptAddImageUrl(gap.frameStart, gapLength); |
| }); |
| menu.appendChild(imgUrlBtn); |
|
|
| const currentTrack = gap.track === "audio" ? "audio" : "image"; |
| if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) { |
| const pasteBtn = document.createElement("button"); |
| pasteBtn.className = "pr-gap-menu-btn"; |
| pasteBtn.innerHTML = `Paste Segment`; |
| pasteBtn.onclick = () => { |
| const gapLength = gap.frameEnd - gap.frameStart; |
|
|
| let finalLength = Math.min(this._copiedSegment.length, gapLength); |
| if (currentTrack === "image") { |
| finalLength = gapLength; |
| } |
|
|
| const newSeg = { |
| ...this._copiedSegment, |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: gap.frameStart, |
| length: finalLength |
| }; |
| const targetArray = currentTrack === "audio" ? this.timeline.audioSegments : this.timeline.segments; |
| targetArray.push(newSeg); |
| targetArray.sort((a, b) => a.start - b.start); |
| this.commitChanges(); |
| this.dismissGapMenu(); |
| }; |
| menu.appendChild(pasteBtn); |
| } |
|
|
| document.body.appendChild(menu); |
| this._gapMenu = menu; |
| setTimeout(() => { |
| this._gapMenuDismisser = (ev) => { if (!menu.contains(ev.target)) this.dismissGapMenu(); }; |
| document.addEventListener("pointerdown", this._gapMenuDismisser, true); |
| }, 0); |
| } |
|
|
| dismissGapMenu() { |
| if (this._gapMenu) { this._gapMenu.remove(); this._gapMenu = null; } |
| if (this._gapMenuDismisser) { document.removeEventListener("pointerdown", this._gapMenuDismisser, true); this._gapMenuDismisser = null; } |
| } |
|
|
| |
| |
| get _settingsWidgetNames() { |
| return ["display_mode", "epsilon", "divisible_by", "img_compression"]; |
| } |
|
|
| |
| hideSettingsWidgets() { |
| for (const name of this._settingsWidgetNames) { |
| const w = this.node.widgets?.find(w => w.name === name); |
| if (w) hideWidget(w); |
|
|
| |
| |
| if (this.node.inputs) { |
| const inputIdx = this.node.inputs.findIndex(i => i.name === name); |
| if (inputIdx !== -1) { |
| const input = this.node.inputs[inputIdx]; |
| if (input.link == null) { |
| this.node.removeInput(inputIdx); |
| } |
| } |
| } |
| } |
| this.updateWidgetVisibility(); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| showSettingsWidgets() { |
| for (const name of this._settingsWidgetNames) { |
| const w = this.node.widgets?.find(w => w.name === name); |
| if (!w) continue; |
| |
| const typeMap = { |
| display_mode: "combo", epsilon: "FLOAT", divisible_by: "INT", |
| img_compression: "INT", |
| }; |
| w.type = typeMap[name] || w._origType || "number"; |
| w.hidden = false; |
| if (w.options) w.options.hidden = false; |
| delete w.computeSize; |
| if (w.element) w.element.style.display = ""; |
| } |
| this.updateWidgetVisibility(); |
|
|
| |
| if (this.displayModeWidget) { |
| const origVal = this.displayModeWidget.value; |
| const otherVal = origVal === "frames" ? "seconds" : "frames"; |
|
|
| this.displayModeWidget.value = otherVal; |
| if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal); |
|
|
| this.displayModeWidget.value = origVal; |
| if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal); |
| } |
| } |
|
|
| _makeSettingRow(label, inputEl) { |
| const row = document.createElement("div"); |
| row.className = "pr-settings-row"; |
| const lbl = document.createElement("span"); |
| lbl.className = "pr-settings-label"; |
| lbl.textContent = label; |
| row.appendChild(lbl); |
| row.appendChild(inputEl); |
| return row; |
| } |
|
|
| showSettingsMenu(anchorEl) { |
| this.dismissSettingsMenu(); |
| const menu = document.createElement("div"); |
| menu.className = "pr-settings-menu"; |
|
|
| |
| const titleContainer = document.createElement("div"); |
| titleContainer.className = "pr-settings-title"; |
| titleContainer.style.display = "flex"; |
| titleContainer.style.justifyContent = "space-between"; |
| titleContainer.style.alignItems = "center"; |
|
|
| const titleText = document.createElement("span"); |
| titleText.textContent = "Timeline Settings"; |
| titleContainer.appendChild(titleText); |
|
|
| const closeBtn = document.createElement("button"); |
| closeBtn.className = "pr-settings-close-btn"; |
| closeBtn.innerHTML = ICONS.close; |
| closeBtn.title = "Close Settings"; |
| closeBtn.addEventListener("click", () => this.dismissSettingsMenu()); |
| titleContainer.appendChild(closeBtn); |
|
|
| menu.appendChild(titleContainer); |
|
|
| |
| 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); |
| }; |
|
|
| |
| const dmWidget = this.node.widgets?.find(w => w.name === "display_mode"); |
| if (dmWidget) { |
| const ctrl = document.createElement("div"); |
| ctrl.className = "pr-segmented-control"; |
|
|
| const framesSeg = document.createElement("div"); |
| framesSeg.className = "pr-segment"; |
| framesSeg.textContent = "Frames"; |
|
|
| const secondsSeg = document.createElement("div"); |
| secondsSeg.className = "pr-segment"; |
| secondsSeg.textContent = "Seconds"; |
|
|
| const updateActive = (val) => { |
| if (val === "frames") { |
| framesSeg.classList.add("active"); |
| secondsSeg.classList.remove("active"); |
| } else { |
| secondsSeg.classList.add("active"); |
| framesSeg.classList.remove("active"); |
| } |
| }; |
|
|
| updateActive(dmWidget.value); |
|
|
| const onSegClick = (val) => { |
| fireCallback(dmWidget, val); |
| updateActive(val); |
| |
| if (this.updateWidgetVisibility) this.updateWidgetVisibility(); |
| if (this.updateUIFromSelection) this.updateUIFromSelection(); |
| this.render(); |
| }; |
|
|
| framesSeg.addEventListener("click", () => onSegClick("frames")); |
| secondsSeg.addEventListener("click", () => onSegClick("seconds")); |
|
|
| ctrl.appendChild(secondsSeg); |
| ctrl.appendChild(framesSeg); |
|
|
| menu.appendChild(this._makeSettingRow("Display Mode", ctrl)); |
| } |
|
|
| const divider1 = document.createElement("hr"); |
| divider1.className = "pr-settings-divider"; |
| menu.appendChild(divider1); |
|
|
| |
| const createScrubbableNumberControl = (w, step, min, max, isFloat = false) => { |
| const container = document.createElement("div"); |
| container.className = "pr-number-control"; |
|
|
| const decBtn = document.createElement("button"); |
| decBtn.className = "pr-number-btn"; |
| decBtn.textContent = "-"; |
|
|
| const inp = document.createElement("input"); |
| inp.type = "number"; |
| inp.className = "pr-settings-input"; |
| inp.value = w.value; |
| inp.step = step.toString(); |
| inp.min = min.toString(); |
| inp.max = max.toString(); |
|
|
| const incBtn = document.createElement("button"); |
| incBtn.className = "pr-number-btn"; |
| incBtn.textContent = "+"; |
|
|
| decBtn.addEventListener("click", () => { |
| let val = parseFloat(inp.value) - step; |
| if (val < min) val = min; |
| inp.value = isFloat ? val.toFixed(4) : Math.round(val); |
| fireCallback(w, parseFloat(inp.value)); |
| }); |
|
|
| incBtn.addEventListener("click", () => { |
| let val = parseFloat(inp.value) + step; |
| if (val > max) val = max; |
| inp.value = isFloat ? val.toFixed(4) : Math.round(val); |
| fireCallback(w, parseFloat(inp.value)); |
| }); |
|
|
| inp.addEventListener("change", () => { |
| let val = parseFloat(inp.value); |
| if (isNaN(val)) val = w.value; |
| if (val < min) val = min; |
| if (val > max) val = max; |
| inp.value = isFloat ? val.toFixed(4) : Math.round(val); |
| fireCallback(w, parseFloat(inp.value)); |
| }); |
|
|
| |
| 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; |
| }; |
|
|
| |
| 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))); |
| } |
|
|
| |
| 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))); |
| } |
|
|
| |
| 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))); |
| } |
|
|
| |
| const globalPromptWidget = this.node.widgets?.find(w => w.name === "global_prompt"); |
| if (globalPromptWidget) { |
| const cb = document.createElement("input"); |
| cb.type = "checkbox"; |
| cb.checked = !(globalPromptWidget.options && globalPromptWidget.options.hidden); |
| cb.style.cursor = "pointer"; |
| cb.addEventListener("change", () => { |
| const isVisible = cb.checked; |
| if (!globalPromptWidget.options) globalPromptWidget.options = {}; |
| globalPromptWidget.options.hidden = !isVisible; |
|
|
| if (isVisible) { |
| delete globalPromptWidget.computeSize; |
| globalPromptWidget.hidden = false; |
| if (globalPromptWidget.element) globalPromptWidget.element.style.display = ""; |
| } else { |
| globalPromptWidget.computeSize = () => [0, 0]; |
| globalPromptWidget.hidden = true; |
| if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none"; |
| } |
|
|
| |
| if (this.displayModeWidget) { |
| const origVal = this.displayModeWidget.value; |
| const otherVal = origVal === "frames" ? "seconds" : "frames"; |
| this.displayModeWidget.value = otherVal; |
| if (this.displayModeWidget.callback) this.displayModeWidget.callback(otherVal); |
| this.displayModeWidget.value = origVal; |
| if (this.displayModeWidget.callback) this.displayModeWidget.callback(origVal); |
| } |
| }); |
| menu.appendChild(this._makeSettingRow("Use Global Prompt", cb)); |
| } |
|
|
|
|
| |
| const toggleBtn = document.createElement("button"); |
| toggleBtn.className = "pr-settings-toggle-btn"; |
| const widgetsVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden))); |
| toggleBtn.textContent = widgetsVisible ? "Hide Widgets on Node" : "Show Widgets on Node"; |
| toggleBtn.addEventListener("click", () => { |
| const nowVisible = !!(this.node.widgets?.find(w => w.name === "display_mode" && !(w.options && w.options.hidden))); |
| if (nowVisible) { |
| this.hideSettingsWidgets(); |
| toggleBtn.textContent = "Show Widgets on Node"; |
| } else { |
| this.showSettingsWidgets(); |
| toggleBtn.textContent = "Hide Widgets on Node"; |
| } |
| }); |
| menu.appendChild(toggleBtn); |
|
|
| |
| 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; |
| |
| if (top + menuH > window.innerHeight - 4) { |
| top = rect.top - menuH - 6; |
| } |
| menu.style.left = `${left}px`; |
| menu.style.top = `${top}px`; |
|
|
| this._settingsMenu = menu; |
| setTimeout(() => { |
| this._settingsDismisser = (ev) => { |
| if (!menu.contains(ev.target) && !anchorEl.contains(ev.target)) this.dismissSettingsMenu(); |
| }; |
| document.addEventListener("mousedown", this._settingsDismisser); |
| }, 0); |
| } |
|
|
| dismissSettingsMenu() { |
| if (this._settingsMenu) { this._settingsMenu.remove(); this._settingsMenu = null; } |
| if (this._settingsDismisser) { document.removeEventListener("mousedown", this._settingsDismisser); this._settingsDismisser = null; } |
| } |
|
|
|
|
| addSegmentInGap(frameStart, frameEnd, type = "text") { |
| const seg = { |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: frameStart, length: frameEnd - frameStart, |
| prompt: "", type, |
| }; |
| this.timeline.segments.push(seg); |
| this.timeline.segments.sort((a, b) => a.start - b.start); |
| this.selectionType = "image"; |
| this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); |
| this.updateUIFromSelection(); |
| this.commitChanges(); |
| } |
|
|
| addTextSegmentFreeSpace() { |
| const frameRate = this.getFrameRate(); |
| const newLength = Math.max(1, frameRate); |
| 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); |
| } |
| |
| const durationFrames = this.getVisualDurationFrames(); |
| const seg = { |
| id: Date.now().toString() + Math.random().toString(36).substr(2, 5), |
| start: newStart, length: Math.min(newLength, Math.max(newLength, durationFrames - newStart)), |
| prompt: "", type: "text", |
| }; |
| this.timeline.segments.push(seg); |
| this.timeline.segments.sort((a, b) => a.start - b.start); |
| this.selectionType = "image"; |
| this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id); |
| this.updateUIFromSelection(); |
| this.commitChanges(); |
| } |
|
|
| |
| updatePlayerUI() { |
| if (!this.playBtn || !this.loopBtn) return; |
| this.playBtn.innerHTML = this.isPlaying ? ICONS.pause : ICONS.play; |
| if (this.isLooping) { |
| this.loopBtn.classList.add("active"); |
| } else { |
| this.loopBtn.classList.remove("active"); |
| } |
| if (this.seekBar) { |
| this.seekBar.max = this.getVisualDurationFrames(); |
| this.seekBar.value = this.currentFrame; |
| } |
| if (this.timeCodeDisplay) { |
| this.timeCodeDisplay.textContent = this.formatTime(this.currentFrame); |
| } |
| } |
|
|
| togglePlay() { |
| if (this.isPlaying) { |
| this.pauseAudio(); |
| } else { |
| if (this.currentFrame >= this.getVisualDurationFrames()) { |
| this.currentFrame = 0; |
| } |
| this.playAudio(); |
| } |
| } |
|
|
| toggleLoop() { |
| this.isLooping = !this.isLooping; |
| this.updatePlayerUI(); |
| } |
|
|
| async playAudio() { |
| this.pauseAudio(true); |
|
|
| 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; |
|
|
| |
| for (let seg of this.timeline.audioSegments) { |
| const segStartFrame = seg.start; |
| const segEndFrame = seg.start + seg.length; |
|
|
| if (segEndFrame <= this.currentFrame) continue; |
|
|
| try { |
| |
| let audioBuffer; |
| if (seg.audioFile) { |
| const audioUrl = api.apiURL(`/view?filename=${encodeURIComponent(seg.audioFile.split("/").pop())}&type=input&subfolder=${encodeURIComponent(seg.audioFile.includes("/") ? seg.audioFile.split("/").slice(0, -1).join("/") : "")}`); |
| const resp = await fetch(audioUrl); |
| const arrayBuffer = await resp.arrayBuffer(); |
| audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); |
| } else if (seg.audioUrl) { |
| const resp = await fetch(seg.audioUrl); |
| const arrayBuffer = await resp.arrayBuffer(); |
| audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); |
| } else if (seg.audioB64) { |
| const binaryString = window.atob(seg.audioB64); |
| const len = binaryString.length; |
| const bytes = new Uint8Array(len); |
| for (let i = 0; i < len; i++) { |
| bytes[i] = binaryString.charCodeAt(i); |
| } |
| audioBuffer = await this.audioContext.decodeAudioData(bytes.buffer); |
| } else { |
| continue; |
| } |
| if (this._currentPlayId !== playId || !this.isPlaying) return; |
|
|
| const framesToSkipInSegment = Math.max(0, this.currentFrame - segStartFrame); |
| const waitFrames = Math.max(0, segStartFrame - this.currentFrame); |
| const waitTimeSec = waitFrames / frameRate; |
|
|
| const fileOffsetFrames = seg.trimStart + framesToSkipInSegment; |
| const fileOffsetSec = fileOffsetFrames / frameRate; |
|
|
| const playDurationFrames = seg.length - framesToSkipInSegment; |
| const playDurationSec = playDurationFrames / frameRate; |
|
|
| if (playDurationSec <= 0) continue; |
|
|
| const bufferNode = this.audioContext.createBufferSource(); |
| bufferNode.buffer = audioBuffer; |
| bufferNode["connect"](this.audioContext.destination); |
|
|
| const startTime = this.audioContext.currentTime + waitTimeSec; |
| bufferNode.start(startTime, fileOffsetSec, playDurationSec); |
|
|
| this.activeAudioNodes.push(bufferNode); |
| } catch (err) { |
| console.error("Playback decode error for segment:", err); |
| } |
| } |
|
|
| if (this._currentPlayId !== playId || !this.isPlaying) return; |
|
|
| const loop = () => { |
| if (!this.isPlaying || this._currentPlayId !== playId) return; |
|
|
| const elapsedSec = this.audioContext.currentTime - this.playbackStartTime; |
| const elapsedFrames = elapsedSec * frameRate; |
|
|
| this.currentFrame = this.playbackStartFrame + elapsedFrames; |
|
|
| const visualDurationFrames = this.getVisualDurationFrames(); |
| const durationFrames = this.getDurationFrames(); |
|
|
| if (this.isLooping) { |
| const loopBound = (this.playbackStartFrame >= durationFrames) ? visualDurationFrames : durationFrames; |
| if (this.currentFrame >= loopBound) { |
| this.currentFrame = 0; |
| this.playAudio(); |
| return; |
| } |
| } else { |
| if (this.currentFrame >= visualDurationFrames) { |
| this.currentFrame = visualDurationFrames; |
| this.pauseAudio(); |
| this.render(); |
| return; |
| } |
| } |
|
|
| this.render(); |
| this._playLoopId = requestAnimationFrame(loop); |
| }; |
|
|
| this._playLoopId = requestAnimationFrame(loop); |
| } |
|
|
| pauseAudio(isScrubbing = false) { |
| this.isPlaying = false; |
| this._currentPlayId = null; |
|
|
| if (!isScrubbing && this.audioContext && this.audioContext.state === 'running') { |
| try { this.audioContext.suspend(); } catch (e) { } |
| } |
|
|
| for (let node of this.activeAudioNodes) { |
| try { node.stop(); } catch (e) { } |
| try { node.disconnect(); } catch (e) { } |
| } |
| this.activeAudioNodes = []; |
|
|
| if (this._playLoopId) { |
| cancelAnimationFrame(this._playLoopId); |
| this._playLoopId = null; |
| } |
| this.updatePlayerUI(); |
| } |
| } |
|
|
| |
| const APPENDED_WIDGET_DEFAULTS = [ |
| ["timeline_data", "{}"], |
| ["local_prompts", ""], |
| ["segment_lengths", ""], |
| ]; |
|
|
| app.registerExtension({ |
| name: "LTXDirector", |
| async beforeRegisterNodeDef(nodeType, nodeData, app) { |
| if (nodeData.name === "LTXDirector") { |
|
|
| const onNodeCreated = nodeType.prototype.onNodeCreated; |
| nodeType.prototype.onNodeCreated = function () { |
| if (onNodeCreated) onNodeCreated.apply(this, arguments); |
|
|
| for (const [name, def] of APPENDED_WIDGET_DEFAULTS) { |
| if (!this.widgets?.find(w => w.name === name)) { |
| this.addWidget("string", name, def, () => { }); |
| } |
| } |
| for (const w of this.widgets) { |
| if (HIDDEN_WIDGET_NAMES.includes(w.name)) hideWidget(w); |
| } |
|
|
| |
| this.size[0] = 1000; |
|
|
| |
| 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 globalPromptWidget = this.widgets?.find(w => w.name === "global_prompt"); |
| if (globalPromptWidget) { |
| if (!globalPromptWidget.options) globalPromptWidget.options = {}; |
| globalPromptWidget.options.hidden = true; |
| globalPromptWidget.hidden = true; |
| globalPromptWidget.computeSize = () => [0, 0]; |
| setTimeout(() => { |
| if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none"; |
| }, 0); |
| } |
|
|
| const container = document.createElement("div"); |
| const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, { |
| getValue: () => "", |
| setValue: () => { }, |
| }); |
|
|
| widget.computeSize = function (width) { |
| const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT; |
| return [width, canvasH + 235]; |
| }; |
|
|
| const self = this; |
| setTimeout(() => { |
| try { |
| self._timelineEditor = new TimelineEditor(self, container, widget); |
| } catch (err) { |
| console.error("[PromptRelay] timeline editor init failed:", err); |
| } |
| }, 0); |
| }; |
|
|
| const onRemoved = nodeType.prototype.onRemoved; |
| nodeType.prototype.onRemoved = function () { |
| this._timelineEditor?.destroy(); |
| return onRemoved?.apply(this, arguments); |
| }; |
|
|
| const onConfigure = nodeType.prototype.onConfigure; |
| nodeType.prototype.onConfigure = function (info) { |
| const out = onConfigure?.apply(this, arguments); |
| for (const [name, def] of APPENDED_WIDGET_DEFAULTS) { |
| const w = this.widgets.find(x => x.name === name); |
| if (w && (w.value == null || w.value === "")) w.value = def; |
| } |
|
|
| setTimeout(() => { |
| if (this._timelineEditor) { |
| this._timelineEditor.timeline = parseInitial(this._timelineEditor.timelineDataWidget?.value); |
| this._timelineEditor.loadImages(); |
| this._timelineEditor.selectionType = "image"; |
| this._timelineEditor.selectedIndex = clamp( |
| this._timelineEditor.selectedIndex, -1, |
| Math.max(-1, this._timelineEditor.timeline.segments.length - 1) |
| ); |
| this._timelineEditor.updateUIFromSelection(); |
| this._timelineEditor.render(); |
| } |
| }, 0); |
| return out; |
| }; |
| } |
| }, |
| }); |
|
|