caching , first gen ok, broken sound, adding interfaces from gemini

This commit is contained in:
Enrico
2026-01-19 19:19:59 +01:00
parent 7ff543fd70
commit a772d9bbd5
6 changed files with 3315 additions and 407 deletions
+786
View File
@@ -0,0 +1,786 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Timeline Editor</title>
<style>
:root {
--bg-body: #121212;
--bg-node: #1e1e1e;
--bg-overlay: #252525;
--text-main: #e0e0e0;
--text-dim: #a0a0a0;
--timeline-track: #333;
--timeline-filled: #444;
--accent-blue: #3b82f6;
--accent-orange: #f97316;
--border-color: #404040;
--input-bg: #2d2d2d;
}
body {
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
}
/* --- Node Container (The "Small" View) --- */
.node-container {
background: var(--bg-node);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 400px;
padding: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.node-title {
font-size: 1.1rem;
font-weight: 600;
}
.control-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 0.9rem;
color: var(--text-dim);
}
input[type="number"] {
background: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 4px 8px;
border-radius: 4px;
width: 60px;
}
/* --- Timeline Base Styles --- */
.timeline-scroll-container {
width: 100%;
overflow-x: auto;
background: var(--timeline-track);
border: 1px solid var(--border-color);
border-radius: 4px;
position: relative;
margin-bottom: 12px;
height: 60px;
/* Custom Scrollbar */
scrollbar-width: thin;
scrollbar-color: #555 #333;
}
.timeline-scroll-container::-webkit-scrollbar {
height: 6px;
}
.timeline-scroll-container::-webkit-scrollbar-track {
background: #333;
}
.timeline-scroll-container::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
.timeline-track {
height: 100%;
position: relative;
min-width: 100%;
/* Ensures it fills container at minimum */
cursor: crosshair;
}
/* Grid lines for the track */
.timeline-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background-image: linear-gradient(90deg, #444 1px, transparent 1px);
background-size: 50px 100%;
/* Arbitrary grid spacing */
opacity: 0.3;
}
.marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
/* Hit area width handled by pseudo element usually, but here simple */
background-color: var(--accent-blue);
cursor: ew-resize;
z-index: 10;
transition: background-color 0.2s;
}
/* Hit area expansion */
.marker::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -4px;
right: -4px;
cursor: ew-resize;
}
.marker.orange {
background-color: var(--accent-orange);
border-bottom: 4px solid #fff;
/* Visual indicator */
}
.marker:hover {
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
}
.marker.selected {
background-color: #fff;
z-index: 20;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
}
.marker-label {
position: absolute;
bottom: 2px;
left: 4px;
font-size: 10px;
color: #fff;
pointer-events: none;
background: rgba(0, 0, 0, 0.5);
padding: 1px 3px;
border-radius: 2px;
}
.btn-primary {
background-color: #404040;
color: white;
border: 1px solid #555;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
width: 100%;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-primary:hover {
background-color: #505050;
}
/* --- Overlay Editor --- */
.overlay-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
justify-content: center;
align-items: center;
backdrop-filter: blur(2px);
}
.editor-modal {
background: var(--bg-overlay);
width: 90%;
max-width: 900px;
height: 80vh;
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
}
.modal-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.5rem;
cursor: pointer;
}
.modal-close:hover {
color: var(--text-main);
}
.modal-body {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.instructions {
font-size: 0.85rem;
color: var(--text-dim);
background: #333;
padding: 8px;
border-radius: 4px;
border-left: 3px solid var(--accent-blue);
}
/* --- Editor Specific Components --- */
.editor-timeline-area {
height: 120px;
/* Taller timeline in editor */
}
.properties-panel {
background: #1a1a1a;
border: 1px solid var(--border-color);
padding: 16px;
border-radius: 6px;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
opacity: 0.5;
pointer-events: none;
transition: opacity 0.2s;
}
.properties-panel.active {
opacity: 1;
pointer-events: all;
}
.image-row {
display: flex;
align-items: center;
gap: 20px;
}
.image-uploader {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.uploader-label {
font-size: 0.8rem;
color: var(--text-dim);
}
.upload-box {
width: 100px;
height: 100px;
border: 2px dashed #555;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
position: relative;
overflow: hidden;
background: #222;
transition: border-color 0.2s;
}
.upload-box:hover {
border-color: #777;
}
.upload-box span {
font-size: 2rem;
color: #555;
}
.uploaded-img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
}
.transition-toggle {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
color: #555;
}
.transition-toggle.active {
color: var(--accent-blue);
}
.transition-icon {
font-size: 1.5rem;
border: 1px solid transparent;
padding: 4px;
border-radius: 4px;
}
.transition-toggle.active .transition-icon {
border-color: var(--accent-blue);
}
textarea {
background: var(--input-bg);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 12px;
border-radius: 4px;
resize: none;
height: 80px;
font-family: inherit;
}
textarea:focus {
outline: 1px solid var(--accent-blue);
}
</style>
</head>
<body>
<!-- --- Node View --- -->
<div class="node-container">
<div class="node-header">
<span class="node-title">Video Timeline</span>
</div>
<div class="control-row">
<label>Duration (s):</label>
<input type="number" id="durationInput" value="30" min="5">
</div>
<!-- Mini Timeline Preview -->
<div class="timeline-scroll-container" style="height: 40px; overflow:hidden;">
<div id="miniTrack" class="timeline-track">
<!-- Markers go here -->
</div>
</div>
<button class="btn-primary" id="openEditorBtn">Open Editor</button>
</div>
<!-- --- Overlay Editor --- -->
<div class="overlay-backdrop" id="editorOverlay">
<div class="editor-modal">
<div class="modal-header">
<h2 class="modal-title">Timeline Editor</h2>
<button class="modal-close" id="closeEditorBtn">&times;</button>
</div>
<div class="modal-body">
<div class="instructions">
<strong>Controls:</strong> Click to add marker. <strong>Alt+Click</strong> for orange frame. Drag to
move. <strong>Ctrl+Click</strong> to remove.
</div>
<!-- Main Editor Timeline -->
<div class="timeline-scroll-container editor-timeline-area" id="mainScrollContainer">
<div id="mainTrack" class="timeline-track">
<div class="timeline-grid"></div>
<!-- Markers go here -->
</div>
</div>
<!-- Selected Marker Properties -->
<div class="properties-panel" id="propertiesPanel">
<div class="image-row">
<!-- Start Image (or Frame Image) -->
<div class="image-uploader">
<span class="uploader-label" id="labelImg1">Start Image</span>
<div class="upload-box" id="boxImg1">
<span>+</span>
</div>
<input type="file" id="fileImg1" hidden accept="image/*">
</div>
<!-- Transition Control -->
<div class="transition-toggle" id="cutTransition">
<div class="transition-icon">✂️</div>
<span style="font-size:0.7rem; margin-top:4px;">CUT</span>
</div>
<!-- End Image (Hidden for orange markers) -->
<div class="image-uploader" id="wrapperImg2">
<span class="uploader-label">End Image</span>
<div class="upload-box" id="boxImg2">
<span>+</span>
</div>
<input type="file" id="fileImg2" hidden accept="image/*">
</div>
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:0.9rem; color:var(--text-dim);">Prompt / Notes</label>
<textarea id="promptInput" placeholder="Enter prompt or details for this segment..."></textarea>
</div>
</div>
</div>
</div>
</div>
<script>
// --- State Management ---
const state = {
duration: 30, // seconds
markers: [],
selectedId: null,
pixelsPerSec: 20 // Zoom level in editor
};
// --- DOM Elements ---
const durationInput = document.getElementById('durationInput');
const openEditorBtn = document.getElementById('openEditorBtn');
const closeEditorBtn = document.getElementById('closeEditorBtn');
const editorOverlay = document.getElementById('editorOverlay');
const mainScrollContainer = document.getElementById('mainScrollContainer');
const mainTrack = document.getElementById('mainTrack');
const miniTrack = document.getElementById('miniTrack');
const propertiesPanel = document.getElementById('propertiesPanel');
const labelImg1 = document.getElementById('labelImg1');
const wrapperImg2 = document.getElementById('wrapperImg2');
const cutTransition = document.getElementById('cutTransition');
const promptInput = document.getElementById('promptInput');
const uploaders = [
{ box: document.getElementById('boxImg1'), input: document.getElementById('fileImg1'), key: 'img1' },
{ box: document.getElementById('boxImg2'), input: document.getElementById('fileImg2'), key: 'img2' }
];
// --- Initialization ---
function init() {
durationInput.addEventListener('change', (e) => updateDuration(e.target.value));
openEditorBtn.addEventListener('click', () => {
editorOverlay.style.display = 'flex';
render();
});
closeEditorBtn.addEventListener('click', () => {
editorOverlay.style.display = 'none';
render(); // Re-render mini view
});
// Track Interactions
mainTrack.addEventListener('mousedown', handleTrackClick);
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', endDrag);
// Property Inputs
promptInput.addEventListener('input', (e) => updateMarkerData('prompt', e.target.value));
cutTransition.addEventListener('click', () => toggleTransition());
// Image Uploaders
uploaders.forEach((u, index) => setupUploader(u, index));
// Initial Render
render();
}
// --- Core Logic ---
function updateDuration(val) {
let v = parseFloat(val);
if (v < 1) v = 1;
state.duration = v;
durationInput.value = v;
// Remove markers out of bounds
state.markers = state.markers.filter(m => m.time <= state.duration);
render();
}
function createMarker(time, type) {
return {
id: 'm_' + Date.now() + Math.random().toString(36).substr(2, 5),
time: time,
type: type, // 'standard' or 'orange'
prompt: '',
img1: null,
img2: null,
cut: false
};
}
// --- Interaction Handlers ---
function handleTrackClick(e) {
if (e.target.closest('.marker')) return; // handled by marker mousedown
const rect = mainTrack.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = (x / rect.width) * state.duration;
if (time < 0 || time > state.duration) return;
const type = e.altKey ? 'orange' : 'standard';
const newMarker = createMarker(time, type);
state.markers.push(newMarker);
selectMarker(newMarker.id);
render();
}
let dragTargetId = null;
function startDrag(e, id) {
e.stopPropagation(); // prevent track creation
if (e.ctrlKey) {
// Delete
state.markers = state.markers.filter(m => m.id !== id);
if (state.selectedId === id) selectMarker(null);
render();
return;
}
selectMarker(id);
dragTargetId = id;
mainTrack.style.cursor = 'ew-resize';
}
function handleDrag(e) {
if (!dragTargetId) return;
const rect = mainTrack.getBoundingClientRect();
let x = e.clientX - rect.left;
// Clamp visual bounds
if (x < 0) x = 0;
if (x > rect.width) x = rect.width;
const newTime = (x / rect.width) * state.duration;
const m = state.markers.find(m => m.id === dragTargetId);
if (m) {
m.time = newTime;
render(); // This is efficient enough for this scale
}
}
function endDrag() {
dragTargetId = null;
mainTrack.style.cursor = 'crosshair';
}
function selectMarker(id) {
state.selectedId = id;
renderProperties();
render(); // to update visual selection class
}
function updateMarkerData(key, value) {
if (!state.selectedId) return;
const m = state.markers.find(m => m.id === state.selectedId);
if (m) m[key] = value;
}
function toggleTransition() {
if (!state.selectedId) return;
const m = state.markers.find(m => m.id === state.selectedId);
if (m) {
m.cut = !m.cut;
renderProperties();
}
}
// --- Rendering ---
function render() {
// 1. Calculate Widths
// Editor: fixed pixels per second
const editorWidth = state.duration * state.pixelsPerSec;
mainTrack.style.width = `${editorWidth}px`;
// Mini: 100% width
miniTrack.style.width = '100%';
// 2. Clear
mainTrack.innerHTML = '<div class="timeline-grid"></div>'; // keep grid
miniTrack.innerHTML = '';
// 3. Render Markers
state.markers.forEach(m => {
// Editor Marker
const elMain = createMarkerElement(m, true);
mainTrack.appendChild(elMain);
// Mini Marker
const elMini = createMarkerElement(m, false);
miniTrack.appendChild(elMini);
});
}
function createMarkerElement(m, isEditor) {
const el = document.createElement('div');
el.className = `marker ${m.type}`;
if (isEditor && m.id === state.selectedId) el.classList.add('selected');
// Position percentage
const pct = (m.time / state.duration) * 100;
el.style.left = `${pct}%`;
if (isEditor) {
// Add timestamp label
const label = document.createElement('div');
label.className = 'marker-label';
label.innerText = m.time.toFixed(1) + 's';
el.appendChild(label);
// Event
el.addEventListener('mousedown', (e) => startDrag(e, m.id));
}
return el;
}
function renderProperties() {
if (!state.selectedId) {
propertiesPanel.classList.remove('active');
return;
}
const m = state.markers.find(m => m.id === state.selectedId);
if (!m) return; // Should not happen
propertiesPanel.classList.add('active');
promptInput.value = m.prompt;
// Type specific UI
if (m.type === 'orange') {
labelImg1.innerText = "Frame Image";
wrapperImg2.style.display = 'none';
} else {
labelImg1.innerText = "Start Image";
wrapperImg2.style.display = 'flex';
}
// Transition State
if (m.cut) cutTransition.classList.add('active');
else cutTransition.classList.remove('active');
// Images
updateUploaderBox(0, m.img1);
updateUploaderBox(1, m.img2);
}
// --- Image Handling ---
function setupUploader(u, index) {
// Click box to trigger file or delete
u.box.addEventListener('click', (e) => {
const m = state.markers.find(m => m.id === state.selectedId);
if (!m) return;
if (m[u.key]) {
// Remove existing
m[u.key] = null;
renderProperties();
} else {
u.input.click();
}
});
// File Change
u.input.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
handleFileUpload(e.target.files[0], u.key);
e.target.value = ''; // reset
}
});
// Drag Drop
u.box.addEventListener('dragover', (e) => {
e.preventDefault();
u.box.style.borderColor = '#fff';
});
u.box.addEventListener('dragleave', (e) => {
e.preventDefault();
u.box.style.borderColor = '';
});
u.box.addEventListener('drop', (e) => {
e.preventDefault();
u.box.style.borderColor = '';
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0], u.key);
}
});
}
function handleFileUpload(file, key) {
if (!state.selectedId) return;
// Mock Upload to ComfyUI (Local URL)
const url = URL.createObjectURL(file);
const m = state.markers.find(m => m.id === state.selectedId);
if (m) {
m[key] = url;
renderProperties();
}
}
function updateUploaderBox(index, url) {
const u = uploaders[index];
u.box.innerHTML = '';
if (url) {
const img = document.createElement('img');
img.src = url;
img.className = 'uploaded-img';
u.box.appendChild(img);
} else {
const span = document.createElement('span');
span.innerText = '+';
u.box.appendChild(span);
}
}
// Start
init();
</script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff
+1098
View File
File diff suppressed because it is too large Load Diff
+265 -359
View File
@@ -1,409 +1,315 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Styles
// --- STYLES ---
const STYLE = `
.ltxv-timeline-editor {
background: #222;
padding: 10px;
border-radius: 8px;
color: #ddd;
font-family: sans-serif;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 600px;
overflow-y: auto;
.ltxv-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.8); z-index: 10000; display: none;
align-items: center; justify-content: center;
}
.ltxv-chunk {
background: #333;
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
.ltxv-modal {
width: 90%; height: 80%; background: #1e1e1e; border: 1px solid #444;
border-radius: 8px; display: flex; flex-direction: column;
box-shadow: 0 0 20px rgba(0,0,0,0.5); font-family: sans-serif; color: #ddd;
}
.ltxv-chunk-header {
display: flex;
justify-content: space-between;
align-items: center;
.ltxv-header {
padding: 10px; background: #2a2a2a; border-bottom: 1px solid #444;
display: flex; justify-content: space-between; align-items: center;
}
.ltxv-chunk-title {
font-weight: bold;
color: #aaddff;
.ltxv-body {
flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative;
}
.ltxv-row {
display: flex;
gap: 8px;
align-items: center;
.ltxv-timeline-area {
flex: 1; overflow-x: auto; overflow-y: hidden; position: relative;
background: #111; padding: 20px 0;
cursor: text; /* Click to add marker */
}
.ltxv-input {
background: #111;
border: 1px solid #555;
color: #eee;
padding: 4px;
border-radius: 4px;
.ltxv-track {
height: 100%; position: relative; border-bottom: 1px solid #333;
min-width: 100%;
}
.ltxv-input-sm {
width: 60px;
.ltxv-chunk-block {
position: absolute; top: 10px; height: 60px;
background: #2b3a42; border: 1px solid #4a6fa5;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #fff; overflow: hidden;
white-space: nowrap; cursor: pointer;
}
.ltxv-btn {
background: #444;
border: none;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
}
.ltxv-btn:hover { background: #555; }
.ltxv-btn-danger { background: #844; }
.ltxv-btn-danger:hover { background: #a55; }
.ltxv-btn-primary { background: #268; width: 100%; padding: 8px;}
.ltxv-btn-primary:hover { background: #37a; }
.ltxv-chunk-block.selected { background: #3a4b55; border-color: #61afef; }
.ltxv-guides {
border-top: 1px solid #444;
padding-top: 5px;
display: flex;
flex-direction: column;
gap: 4px;
.ltxv-marker {
position: absolute; top: 0; bottom: 0; width: 2px; background: #fff;
cursor: col-resize; z-index: 10;
}
.ltxv-guide-item {
background: #2a2a2a;
padding: 4px;
border-radius: 4px;
display: flex;
gap: 6px;
align-items: center;
.ltxv-marker-handle {
position: absolute; top: -15px; left: -8px; width: 16px; height: 16px;
background: #fff; border-radius: 50%; border: 2px solid #555;
cursor: pointer; z-index: 11;
}
.ltxv-textarea {
width: 100%;
min-height: 60px;
background: #111;
border: 1px solid #555;
color: #eee;
resize: vertical;
.ltxv-marker-label {
position: absolute; bottom: 5px; left: 5px; font-size: 10px; color: #aaa;
pointer-events: none;
}
.ltxv-marker-img-slot {
position: absolute; top: 80px; left: -20px; width: 40px; height: 40px;
background: #000; border: 1px dashed #555; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; color: #555; cursor: pointer; z-index: 12;
overflow: hidden;
}
.ltxv-marker-img-slot:hover { border-color: #fff; color: #fff; }
.ltxv-marker-img-slot img { width: 100%; height: 100%; object-fit: cover; }
.ltxv-guide-id {
position: absolute; top: -5px; right: -5px; background: #61afef;
color: #000; font-size: 8px; padding: 1px 3px; border-radius: 4px;
}
.ltxv-prop-panel {
height: 150px; background: #222; border-top: 1px solid #444;
padding: 10px; display: grid; grid-template-columns: 200px 1fr; gap: 10px;
}
.ltxv-prop-group { display: flex; flex-direction: column; gap: 5px; }
.ltxv-input { background: #111; border: 1px solid #555; color: #eee; padding: 4px; }
.ltxv-textarea { flex: 1; resize: none; background: #111; border: 1px solid #555; color: #eee; }
`;
function injectStyle() {
if (!document.getElementById("ltxv-editor-style")) {
const style = document.createElement("style");
style.id = "ltxv-editor-style";
style.innerHTML = STYLE;
document.head.appendChild(style);
if (!document.getElementById("ltxv-v2-style")) {
const s = document.createElement("style");
s.id = "ltxv-v2-style";
s.innerHTML = STYLE;
document.head.appendChild(s);
}
}
// State Management
class TimelineState {
constructor(initialString, onChange) {
this.chunks = [];
// --- LOGIC ---
class TimelineEditorV2 {
constructor(widget, onChange) {
this.widget = widget;
this.onChange = onChange;
this.parse(initialString);
this.chunks = []; // { duration, prompt, guides: [] }
this.zoom = 50; // pixels per second
this.selectedChunkIdx = -1;
this.parse(widget.value);
this.buildUI();
}
parse(str) {
this.chunks = [];
// Very basic parsing for demo. Robust parsing would be complex.
// We will assume if it starts with '(', we try to match regex.
// Or we just default to one empty chunk if empty.
// Regex to match chunks: (duration: "prompt" { ... })
// This is hard. We'll start fresh if parsing fails, or try simple split.
// For MVP, we might overwrite existing value if structure is too complex?
// Let's try to be non-destructive: If empty, add default.
if (!str || str.trim() === "") {
this.addChunk();
// Basic parser. If empty, create default.
if (!str || str.trim().length < 5) {
this.chunks = [{ duration: 2.0, prompt: "Scene 1", guides: [] }];
return;
}
// Simple Regex for chunks? (?: *\()([^)]+)(?:\))
// This breaks on nested parens in prompt.
// We will just initialize with one chunk if parse invalid.
// Or just let user build from scratch.
}
addChunk() {
this.chunks.push({
id: Date.now(),
duration: 2.0,
transition: "blend",
prompt: "",
guides: []
});
this.notify();
}
removeChunk(index) {
this.chunks.splice(index, 1);
this.notify();
}
updateChunk(index, key, value) {
this.chunks[index][key] = value;
this.notify();
}
addGuide(chunkIndex) {
this.chunks[chunkIndex].guides.push({
timeType: "start", // start, end, custom
timeVal: 0.0,
imageIdx: 0,
strength: 1.0
});
this.notify();
}
removeGuide(chunkIndex, guideIndex) {
this.chunks[chunkIndex].guides.splice(guideIndex, 1);
this.notify();
}
updateGuide(chunkIndex, guideIndex, key, value) {
this.chunks[chunkIndex].guides[guideIndex][key] = value;
this.notify();
}
notify() {
this.onChange(this.serialize());
// TODO: Implement robust parser. For now, reset if invalid or try very basic split.
// We will assume state is cleared for this "Prototype" rewrite.
// Or keep existing string if parse fails.
}
serialize() {
// (2.0s: "Prompt" { guides }) + ( ... )
let t = 0.0;
return this.chunks.map(c => {
let guideStr = "";
if (c.guides.length > 0) {
const gParts = c.guides.map(g => {
let time = "0.0s";
if (g.timeType === "start") time = "0.0s";
else if (g.timeType === "end") time = "end";
else time = `${g.timeVal}s`;
return `${time}: $${g.imageIdx}:${g.strength}`;
});
guideStr = ` { ${gParts.join(", ")} }`;
if (c.guides && c.guides.length) {
guideStr = " { " + c.guides.map(g => {
// Determine relative time
// Simplified: Just output image refs for now, assume Start/End logic
// Or full syntax: 0.0s: $0:1.0
return `${g.time.toFixed(1)}s: $${g.imgIdx}:${g.strength}`;
}).join(", ") + " }";
}
// Transition format: 2.0s -> "cut" syntax supported?
// Existing parser: (duration: "prompt")
// Transition is separate?
// My Parser: "+ (2.0s ...)" means Blend.
// "|" means Cut.
// So we need to control the joining character.
const s = `(${c.duration.toFixed(1)}s: "${c.prompt.replace(/"/g, '\\"')}"${guideStr})`;
t += c.duration;
return s;
}).join(" + ");
}
// Wait, my parser expects joining characters BETWEEN chunks.
// But chunks array is linear.
// First chunk joins nothing.
// Second chunk joins First?
// We should store 'transitionToNext' or 'transitionFromPrev'?
// Parser: `Chunk1 + Chunk2`. The '+' belongs to the join.
// Let's store `transition` on the chunk as "how it joins PREVIOUS".
// Chunk 0 has no transition.
buildUI() {
this.overlay = document.createElement("div");
this.overlay.className = "ltxv-overlay";
const content = `(${c.duration}s: "${c.prompt.replace(/"/g, '\\"')}"${guideStr})`;
return { content, transition: c.transition };
}).reduce((acc, curr, idx) => {
if (idx === 0) return curr.content;
const op = curr.transition === "cut" ? " | " : " + ";
return acc + op + curr.content;
}, "");
const modal = document.createElement("div");
modal.className = "ltxv-modal";
// Header
const header = document.createElement("div");
header.className = "ltxv-header";
header.innerHTML = `<span>LTXV Timeline Editor</span>`;
const closeBtn = document.createElement("button");
closeBtn.innerText = "Close & Save";
closeBtn.onclick = () => {
this.onChange(this.serialize());
this.overlay.style.display = "none";
};
header.appendChild(closeBtn);
modal.appendChild(header);
// Body
const body = document.createElement("div");
body.className = "ltxv-body";
// Timeline
const area = document.createElement("div");
area.className = "ltxv-timeline-area";
this.track = document.createElement("div");
this.track.className = "ltxv-track";
area.appendChild(this.track);
body.appendChild(area);
// Properties
this.props = document.createElement("div");
this.props.className = "ltxv-prop-panel";
body.appendChild(this.props);
modal.appendChild(body);
this.overlay.appendChild(modal);
document.body.appendChild(this.overlay);
this.renderTimeline();
this.renderProps();
}
open() {
this.overlay.style.display = "flex";
this.renderTimeline();
}
renderTimeline() {
this.track.innerHTML = "";
let x = 0;
this.chunks.forEach((chunk, i) => {
const width = chunk.duration * this.zoom;
// Block
const el = document.createElement("div");
el.className = "ltxv-chunk-block";
if (i === this.selectedChunkIdx) el.classList.add("selected");
el.style.left = x + "px";
el.style.width = width + "px";
el.innerText = chunk.prompt.substring(0, 20) + "...";
el.onclick = (e) => {
e.stopPropagation();
this.selectedChunkIdx = i;
this.renderTimeline();
this.renderProps();
};
// Drag Handle (Right Edge)
const handle = document.createElement("div");
handle.className = "ltxv-marker-handle";
handle.style.left = (width - 8) + "px";
handle.style.top = "50%";
handle.onmousedown = (e) => this.startDrag(e, i);
el.appendChild(handle);
this.track.appendChild(el);
x += width;
});
this.track.style.width = (x + 200) + "px"; // Extra space
}
startDrag(e, idx) {
e.stopPropagation();
const startX = e.clientX;
const startDur = this.chunks[idx].duration;
const onMove = (mv) => {
const diffPx = mv.clientX - startX;
const diffSec = diffPx / this.zoom;
let newDur = Math.max(0.5, startDur + diffSec); // Min 0.5s
this.chunks[idx].duration = newDur;
this.renderTimeline();
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
this.renderProps(); // Update duration input
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
renderProps() {
this.props.innerHTML = "";
if (this.selectedChunkIdx < 0) {
this.props.innerText = "Select a chunk to edit.";
return;
}
const c = this.chunks[this.selectedChunkIdx];
// Duration
const grp1 = document.createElement("div");
grp1.className = "ltxv-prop-group";
grp1.innerHTML = `<label>Duration (s)</label>`;
const durIn = document.createElement("input");
durIn.type = "number"; durIn.step = "0.1"; durIn.className = "ltxv-input";
durIn.value = c.duration.toFixed(1);
durIn.onchange = (e) => {
c.duration = parseFloat(e.target.value);
this.renderTimeline();
};
grp1.appendChild(durIn);
this.props.appendChild(grp1);
// Prompt
const grp2 = document.createElement("div");
grp2.className = "ltxv-prop-group";
grp2.style.flex = "1";
grp2.innerHTML = `<label>Prompt</label>`;
const pIn = document.createElement("textarea");
pIn.className = "ltxv-textarea";
pIn.value = c.prompt;
pIn.onchange = (e) => {
c.prompt = e.target.value;
this.renderTimeline();
};
grp2.appendChild(pIn);
this.props.appendChild(grp2);
// Add Chunk Btn
const grp3 = document.createElement("div");
grp3.className = "ltxv-prop-group";
const addBtn = document.createElement("button");
addBtn.innerText = "Add After";
addBtn.onclick = () => {
this.chunks.splice(this.selectedChunkIdx + 1, 0, {
duration: 2.0, prompt: "New Scene", guides: []
});
this.renderTimeline();
};
grp3.appendChild(addBtn);
this.props.appendChild(grp3);
}
}
app.registerExtension({
name: "ErosDiffusion.LTXVTimelineEditor",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "LTXVTimelineEditor") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
const node = this;
injectStyle();
const node = this;
const widget = this.widgets.find(w => w.name === "script");
// Find script widget
const scriptWidget = this.widgets.find(w => w.name === "script");
if (scriptWidget) {
scriptWidget.inputEl.style.display = "none"; // Hide default textarea
// Keep it for serialization
}
// Create Container
const container = document.createElement("div");
container.className = "ltxv-timeline-editor";
// Add to DOMWidget
const domWidget = this.addDOMWidget("timeline_ui", "ui", container, {
getValue() { return scriptWidget.value; },
setValue(v) { scriptWidget.value = v; },
});
// Logic
const state = new TimelineState(scriptWidget.value, (newStr) => {
scriptWidget.value = newStr;
// Trigger graph update if needed?
});
// Render Function
function render() {
container.innerHTML = "";
state.chunks.forEach((chunk, cIdx) => {
const card = document.createElement("div");
card.className = "ltxv-chunk";
// Header
const header = document.createElement("div");
header.className = "ltxv-chunk-header";
const title = document.createElement("span");
title.className = "ltxv-chunk-title";
title.innerText = `Chunk ${cIdx + 1}`;
header.appendChild(title);
// Delete Button
const delBtn = document.createElement("button");
delBtn.className = "ltxv-btn ltxv-btn-danger";
delBtn.innerText = "X";
delBtn.onclick = () => { state.removeChunk(cIdx); render(); };
header.appendChild(delBtn);
card.appendChild(header);
// Controls Row
const row = document.createElement("div");
row.className = "ltxv-row";
// Duration
const durLabel = document.createElement("label");
durLabel.innerText = "Dur(s):";
const durIn = document.createElement("input");
durIn.type = "number";
durIn.step = "0.1";
durIn.className = "ltxv-input ltxv-input-sm";
durIn.value = chunk.duration;
durIn.onchange = (e) => state.updateChunk(cIdx, "duration", parseFloat(e.target.value));
row.appendChild(durLabel);
row.appendChild(durIn);
// Transition (If not first)
if (cIdx > 0) {
const transLabel = document.createElement("label");
transLabel.innerText = "Trans:";
const transSel = document.createElement("select");
transSel.className = "ltxv-input";
["blend", "cut"].forEach(opt => {
const o = document.createElement("option");
o.value = opt;
o.innerText = opt;
if (opt === chunk.transition) o.selected = true;
transSel.appendChild(o);
});
transSel.onchange = (e) => state.updateChunk(cIdx, "transition", e.target.value);
row.appendChild(transLabel);
row.appendChild(transSel);
}
card.appendChild(row);
// Prompt
const promptArea = document.createElement("textarea");
promptArea.className = "ltxv-textarea";
promptArea.placeholder = "Prompt...";
promptArea.value = chunk.prompt;
promptArea.onchange = (e) => state.updateChunk(cIdx, "prompt", e.target.value);
card.appendChild(promptArea);
// Guides
const guidesDiv = document.createElement("div");
guidesDiv.className = "ltxv-guides";
chunk.guides.forEach((g, gIdx) => {
const gRow = document.createElement("div");
gRow.className = "ltxv-guide-item";
// Type
const tSel = document.createElement("select");
tSel.className = "ltxv-input";
["start", "end", "time"].forEach(o => {
const op = document.createElement("option");
op.value = o;
op.innerText = o;
if (o === g.timeType) op.selected = true;
tSel.appendChild(op);
});
tSel.onchange = (e) => {
state.updateGuide(cIdx, gIdx, "timeType", e.target.value);
render(); // Re-render to show/hide timeVal
};
gRow.appendChild(tSel);
// Time Value
if (g.timeType === "time") {
const tVal = document.createElement("input");
tVal.type = "number";
tVal.step = "0.1";
tVal.className = "ltxv-input ltxv-input-sm";
tVal.value = g.timeVal;
tVal.onchange = (e) => state.updateGuide(cIdx, gIdx, "timeVal", parseFloat(e.target.value));
gRow.appendChild(tVal);
}
// Image Index
const imgLabel = document.createElement("span");
imgLabel.innerText = "Img $";
gRow.appendChild(imgLabel);
const imgIn = document.createElement("input");
imgIn.type = "number";
imgIn.min = "0";
imgIn.className = "ltxv-input ltxv-input-sm";
imgIn.value = g.imageIdx;
imgIn.style.width = "40px";
imgIn.onchange = (e) => state.updateGuide(cIdx, gIdx, "imageIdx", parseInt(e.target.value));
gRow.appendChild(imgIn);
// Strength
const strIn = document.createElement("input");
strIn.type = "range";
strIn.min = "0";
strIn.max = "1";
strIn.step = "0.05";
strIn.value = g.strength;
strIn.onchange = (e) => state.updateGuide(cIdx, gIdx, "strength", parseFloat(e.target.value));
gRow.appendChild(strIn);
// Remove Guide
const remG = document.createElement("button");
remG.className = "ltxv-btn ltxv-btn-danger";
remG.innerText = "x";
remG.onclick = () => { state.removeGuide(cIdx, gIdx); render(); };
gRow.appendChild(remG);
guidesDiv.appendChild(gRow);
// Create Button to Open Editor
this.addWidget("button", "Open Visual Editor", null, () => {
if (!node.editor) {
node.editor = new TimelineEditorV2(widget, (v) => {
widget.value = v;
});
// Add Guide Btn
const addGBtn = document.createElement("button");
addGBtn.className = "ltxv-btn";
addGBtn.innerText = "+ Add Guide";
addGBtn.onclick = () => { state.addGuide(cIdx); render(); };
guidesDiv.appendChild(addGBtn);
card.appendChild(guidesDiv);
container.appendChild(card);
});
// Add Chunk Btn
const addCBtn = document.createElement("button");
addCBtn.className = "ltxv-btn ltxv-btn-primary";
addCBtn.innerText = "+ Add Scene Chunk";
addCBtn.onclick = () => { state.addChunk(); render(); };
container.appendChild(addCBtn);
}
render();
}
node.editor.open();
});
};
}
}
+47 -23
View File
@@ -49,6 +49,9 @@ from .script_parser import (
)
from .audio_blender import AudioOverlapBlender
# Global Cache to avoid Locked Class issues
_CACHE = {}
def get_noise_mask(latent):
"""Helper to extract noise mask, handling nested tensors."""
@@ -109,22 +112,6 @@ class LTXVSceneExtender(io.ComfyNode):
),
# === Extension Configuration ===
io.Float.Input(
"extension_duration",
default=5.0,
min=0.1,
max=300.0,
step=0.1,
tooltip="How many seconds to extend (handled internally)"
),
io.Float.Input(
"tile_duration",
default=3.0,
min=1.0,
max=10.0,
step=0.5,
tooltip="Duration of each temporal chunk in seconds"
),
io.Float.Input(
"overlap_duration",
default=1.0,
@@ -161,7 +148,10 @@ class LTXVSceneExtender(io.ComfyNode):
# === Scene Script ===
io.String.Input(
"scene_script",
default="",
default="""[00:00-00:01] A woman with short hair speaks to camera | audio:"Hello" | first:$0 | end:$0
[00:01-00:03] A woman with short hair turns around and speaks to camera , camera orbits around | audio:"I like this camera" | first:$0 | end:$1
[00:03-00:06] A woman with short hair opens her arms and tilts head backwards happy | audio: "I feel free" | first:$1 | end:$2
[00:06-00:08] A woman with short hair puts her hands in her hair | first:$2""",
multiline=True,
tooltip="""Timestamped scene script. Format:
[MM:SS-MM:SS] Scene description | audio:spec | first:$0 | MM:SS:$1 | end:$2
@@ -213,7 +203,7 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
io.Int.Input("audio_overlap_frames", default=32, min=0, max=256),
io.Float.Input(
"temporal_cond_strength",
default=0.5,
default=1.0,
min=0.0,
max=1.0,
step=0.05,
@@ -247,8 +237,6 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
noise,
guider,
clip,
extension_duration: float,
tile_duration: float,
overlap_duration: float,
video_fps: float,
width: int,
@@ -269,6 +257,14 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
batch_size = 1 # Hardcoded
"""Execute the scene extension."""
# Initialize Cache
global _CACHE
# Clear cache if script fundamentally changed structure?
# But maybe limit cache size?
if len(_CACHE) > 50: # Arbitrary limit
_CACHE.clear()
# Check if we have an audio-video model
is_av_model = cls._is_av_model(model)
@@ -336,9 +332,34 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
for i, (chunk, c_data) in enumerate(zip(chunks, chunk_data)):
chunk_duration = chunk.end_sec - chunk.start_sec
latent_length = time_mgr.calculate_video_latent_count(chunk_duration)
latent_length = max(1, time_mgr.calculate_video_latent_count(chunk_duration)) # Ensure at least 1
print(f"Processing chunk {i+1}/{len(chunks)}: [{chunk.start_sec:.1f}s - {chunk.end_sec:.1f}s] ({latent_length} latents / ~{latent_length*8} frames)")
# === CACHING CHECK ===
prev_context_hash = 0.0
if prev_latent is not None:
if isinstance(prev_latent["samples"], NestedTensor):
v_tens = prev_latent["samples"].tensors[0]
else:
v_tens = prev_latent["samples"]
prev_context_hash = float(v_tens.mean().cpu()) # Simple hash
cache_key = (
i,
chunk.prompt,
chunk.duration,
chunk.transition_type,
prev_context_hash,
width, height
)
if cache_key in _CACHE:
print(f"Using Cached Chunk {i}")
chunk_res = _CACHE[cache_key]
final_video_list.append(chunk_res)
prev_latent = chunk_res
continue
# --- Allocation & Extension Logic ---
is_extension = (prev_latent is not None) and (chunk.transition_type != "cut")
@@ -357,7 +378,7 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
# Handle AV Model
if is_av_model and NestedTensor is not None and audio_vae is not None:
audio_len = time_mgr.calculate_audio_latent_count(chunk_duration)
audio_len = max(1, time_mgr.calculate_audio_latent_count(chunk_duration))
# Get correct dimensions from VAE
a_ch = getattr(audio_vae, "latent_channels", 128)
@@ -410,7 +431,7 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
# AV Logic
if is_av_model and NestedTensor is not None and audio_vae is not None:
audio_len = time_mgr.calculate_audio_latent_count(chunk_duration)
audio_len = max(1, time_mgr.calculate_audio_latent_count(chunk_duration))
a_ch = getattr(audio_vae, "latent_channels", 128)
a_freq = getattr(audio_vae, "latent_frequency_bins", 1)
@@ -556,6 +577,9 @@ Guide refs: $0, $1, etc. reference guide_images batch by index"""
final_video_list.append(denoised)
prev_latent = denoised
# Cache it
_CACHE[cache_key] = denoised
# Keep refs for return
positive = c_pos
negative = c_neg
+21 -25
View File
@@ -1,27 +1,23 @@
from comfy_api.latest import io, ComfyNode
class LTXVTimelineEditor(ComfyNode):
class LTXVTimelineEditor:
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="LTXVTimelineEditor",
display_name="💜 LTXV Timeline Editor",
category="ErosDiffusion/LTXV",
description="Visual editor for constructing Scene Extender scripts.",
inputs=[
io.String.Input(
"script",
default="(2.0s: \"Example Prompt\" { 0.0s: $0:1.0 })",
multiline=True,
display_mode=io.StringDisplay.textarea,
tooltip="The generated script string. Use the Custom UI to edit this."
)
],
outputs=[
io.String.Output(display_name="script_string")
]
)
def INPUT_TYPES(s):
return {
"required": {
"script": ("STRING", {
"multiline": True,
"default": '(2.0s: "Describe your scene here" { 0.0s: $0:1.0 })',
"tooltip": "The generated script string. Use the Custom UI (below or hidden) to visually edit chunks, prompts, and guides."
})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("script_string",)
FUNCTION = "execute"
CATEGORY = "ErosDiffusion/LTXV"
OUTPUT_NODE = False
@classmethod
def execute(cls, script: str) -> io.NodeOutput:
return io.NodeOutput(script)
DESCRIPTION = "A visual editor for constructing LTXV Scene Extender scripts. \nUse the Custom UI to add Timeline Chunks, set durations, transitions, and place Image Guides ($0, $1) at specific timestamps."
def execute(self, script):
return (script,)