caching , first gen ok, broken sound, adding interfaces from gemini
This commit is contained in:
+786
@@ -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">×</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
File diff suppressed because it is too large
Load Diff
+1098
File diff suppressed because it is too large
Load Diff
+265
-359
@@ -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
@@ -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
@@ -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,)
|
||||
|
||||
Reference in New Issue
Block a user