Upload 2 files
Browse files- ltx_director.js +273 -6
- ltx_director.py +42 -3
ltx_director.js
CHANGED
|
@@ -528,6 +528,7 @@ if (!document.getElementById("prompt-relay-styles")) {
|
|
| 528 |
// --- Icons ---
|
| 529 |
const ICONS = {
|
| 530 |
upload: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>`,
|
|
|
|
| 531 |
audio: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
|
| 532 |
trash: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
|
| 533 |
text: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`,
|
|
@@ -792,10 +793,11 @@ class TimelineEditor {
|
|
| 792 |
|
| 793 |
loadImages() {
|
| 794 |
for (const seg of this.timeline.segments) {
|
| 795 |
-
|
|
|
|
| 796 |
seg.imgObj = new Image();
|
| 797 |
seg.imgObj.onload = () => this.render();
|
| 798 |
-
seg.imgObj.src =
|
| 799 |
}
|
| 800 |
}
|
| 801 |
}
|
|
@@ -868,11 +870,21 @@ class TimelineEditor {
|
|
| 868 |
uploadBtn.innerHTML = `${ICONS.upload} Add Image`;
|
| 869 |
uploadBtn.addEventListener("click", () => this.fileInput.click());
|
| 870 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
const uploadAudioBtn = document.createElement("button");
|
| 872 |
uploadAudioBtn.className = "pr-btn";
|
| 873 |
uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`;
|
| 874 |
uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click());
|
| 875 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
const addTextBtn = document.createElement("button");
|
| 877 |
addTextBtn.className = "pr-btn";
|
| 878 |
addTextBtn.innerHTML = `${ICONS.text} Add Text`;
|
|
@@ -886,8 +898,10 @@ class TimelineEditor {
|
|
| 886 |
actionGroup.appendChild(this.fileInput);
|
| 887 |
actionGroup.appendChild(this.audioFileInput);
|
| 888 |
actionGroup.appendChild(uploadBtn);
|
|
|
|
| 889 |
actionGroup.appendChild(addTextBtn);
|
| 890 |
actionGroup.appendChild(uploadAudioBtn);
|
|
|
|
| 891 |
actionGroup.appendChild(deleteBtn);
|
| 892 |
toolbar.appendChild(actionGroup);
|
| 893 |
|
|
@@ -1408,6 +1422,215 @@ class TimelineEditor {
|
|
| 1408 |
return { x, y };
|
| 1409 |
}
|
| 1410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1411 |
// --- Async Image Upload Logic (Handles multiple images simultaneously) ---
|
| 1412 |
async handleImageUpload(files, targetFrameStart = null, explicitLength = null) {
|
| 1413 |
const frameRate = this.getFrameRate();
|
|
@@ -2947,7 +3170,8 @@ class TimelineEditor {
|
|
| 2947 |
menu.style.left = `${clientX + 6}px`;
|
| 2948 |
menu.style.top = `${clientY - 10}px`;
|
| 2949 |
|
| 2950 |
-
const
|
|
|
|
| 2951 |
|
| 2952 |
if (isImage) {
|
| 2953 |
const copyBtn = document.createElement("button");
|
|
@@ -2955,7 +3179,7 @@ class TimelineEditor {
|
|
| 2955 |
copyBtn.innerHTML = `Copy Image`;
|
| 2956 |
copyBtn.onclick = async () => {
|
| 2957 |
try {
|
| 2958 |
-
const res = await fetch(
|
| 2959 |
const blob = await res.blob();
|
| 2960 |
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
| 2961 |
} catch (err) {
|
|
@@ -2970,7 +3194,7 @@ class TimelineEditor {
|
|
| 2970 |
saveBtn.innerHTML = `Save Image`;
|
| 2971 |
saveBtn.onclick = () => {
|
| 2972 |
const a = document.createElement("a");
|
| 2973 |
-
a.href =
|
| 2974 |
a.download = "timeline_image.jpg";
|
| 2975 |
a.click();
|
| 2976 |
this.dismissContextMenu();
|
|
@@ -2983,7 +3207,7 @@ class TimelineEditor {
|
|
| 2983 |
openBtn.onclick = () => {
|
| 2984 |
const win = window.open();
|
| 2985 |
if (win) {
|
| 2986 |
-
win.document.write(`<body style="margin:0;display:flex;justify-content:center;align-items:center;background:#0e0e0e;height:100vh;"><img style="max-width:100%;max-height:100%;" src="${
|
| 2987 |
win.document.close();
|
| 2988 |
}
|
| 2989 |
this.dismissContextMenu();
|
|
@@ -3117,6 +3341,34 @@ class TimelineEditor {
|
|
| 3117 |
fi.click();
|
| 3118 |
};
|
| 3119 |
menu.appendChild(imgBtn);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3120 |
}
|
| 3121 |
|
| 3122 |
document.body.appendChild(menu);
|
|
@@ -3165,6 +3417,17 @@ class TimelineEditor {
|
|
| 3165 |
|
| 3166 |
menu.appendChild(textBtn);
|
| 3167 |
menu.appendChild(imgBtn);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3168 |
const currentTrack = gap.track === "audio" ? "audio" : "image";
|
| 3169 |
if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) {
|
| 3170 |
const pasteBtn = document.createElement("button");
|
|
@@ -3678,6 +3941,10 @@ class TimelineEditor {
|
|
| 3678 |
const resp = await fetch(audioUrl);
|
| 3679 |
const arrayBuffer = await resp.arrayBuffer();
|
| 3680 |
audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3681 |
} else if (seg.audioB64) {
|
| 3682 |
const binaryString = window.atob(seg.audioB64);
|
| 3683 |
const len = binaryString.length;
|
|
|
|
| 528 |
// --- Icons ---
|
| 529 |
const ICONS = {
|
| 530 |
upload: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>`,
|
| 531 |
+
link: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3.54-3.54a5 5 0 0 0-7.07-7.07L11.5 4.43"></path><path d="M14 11a5 5 0 0 0-7.07 0L3.39 14.54a5 5 0 0 0 7.07 7.07l2.04-2.04"></path></svg>`,
|
| 532 |
audio: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
|
| 533 |
trash: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
|
| 534 |
text: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`,
|
|
|
|
| 793 |
|
| 794 |
loadImages() {
|
| 795 |
for (const seg of this.timeline.segments) {
|
| 796 |
+
const imageSrc = seg.imageB64 || seg.imageUrl;
|
| 797 |
+
if (imageSrc && !seg.imgObj) {
|
| 798 |
seg.imgObj = new Image();
|
| 799 |
seg.imgObj.onload = () => this.render();
|
| 800 |
+
seg.imgObj.src = imageSrc;
|
| 801 |
}
|
| 802 |
}
|
| 803 |
}
|
|
|
|
| 870 |
uploadBtn.innerHTML = `${ICONS.upload} Add Image`;
|
| 871 |
uploadBtn.addEventListener("click", () => this.fileInput.click());
|
| 872 |
|
| 873 |
+
const imageUrlBtn = document.createElement("button");
|
| 874 |
+
imageUrlBtn.className = "pr-btn";
|
| 875 |
+
imageUrlBtn.innerHTML = `${ICONS.link} Add Image URL`;
|
| 876 |
+
imageUrlBtn.addEventListener("click", () => this.promptAddImageUrl());
|
| 877 |
+
|
| 878 |
const uploadAudioBtn = document.createElement("button");
|
| 879 |
uploadAudioBtn.className = "pr-btn";
|
| 880 |
uploadAudioBtn.innerHTML = `${ICONS.audio} Add Audio`;
|
| 881 |
uploadAudioBtn.addEventListener("click", () => this.audioFileInput.click());
|
| 882 |
|
| 883 |
+
const audioUrlBtn = document.createElement("button");
|
| 884 |
+
audioUrlBtn.className = "pr-btn";
|
| 885 |
+
audioUrlBtn.innerHTML = `${ICONS.link} Add Audio URL`;
|
| 886 |
+
audioUrlBtn.addEventListener("click", () => this.promptAddAudioUrl());
|
| 887 |
+
|
| 888 |
const addTextBtn = document.createElement("button");
|
| 889 |
addTextBtn.className = "pr-btn";
|
| 890 |
addTextBtn.innerHTML = `${ICONS.text} Add Text`;
|
|
|
|
| 898 |
actionGroup.appendChild(this.fileInput);
|
| 899 |
actionGroup.appendChild(this.audioFileInput);
|
| 900 |
actionGroup.appendChild(uploadBtn);
|
| 901 |
+
actionGroup.appendChild(imageUrlBtn);
|
| 902 |
actionGroup.appendChild(addTextBtn);
|
| 903 |
actionGroup.appendChild(uploadAudioBtn);
|
| 904 |
+
actionGroup.appendChild(audioUrlBtn);
|
| 905 |
actionGroup.appendChild(deleteBtn);
|
| 906 |
toolbar.appendChild(actionGroup);
|
| 907 |
|
|
|
|
| 1422 |
return { x, y };
|
| 1423 |
}
|
| 1424 |
|
| 1425 |
+
normalizeRemoteUrl(rawUrl) {
|
| 1426 |
+
const value = (rawUrl || "").trim();
|
| 1427 |
+
if (!value) return null;
|
| 1428 |
+
try {
|
| 1429 |
+
const parsed = new URL(value);
|
| 1430 |
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
| 1431 |
+
window.alert("Only http:// and https:// URLs are supported.");
|
| 1432 |
+
return null;
|
| 1433 |
+
}
|
| 1434 |
+
return parsed.href;
|
| 1435 |
+
} catch (err) {
|
| 1436 |
+
window.alert("Please enter a valid URL.");
|
| 1437 |
+
return null;
|
| 1438 |
+
}
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
getRemoteFileName(url, fallback) {
|
| 1442 |
+
try {
|
| 1443 |
+
const parsed = new URL(url);
|
| 1444 |
+
const name = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || "");
|
| 1445 |
+
return name || fallback;
|
| 1446 |
+
} catch (err) {
|
| 1447 |
+
return fallback;
|
| 1448 |
+
}
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
promptAddImageUrl(targetFrameStart = null, explicitLength = null) {
|
| 1452 |
+
const url = this.normalizeRemoteUrl(window.prompt("Paste image URL:", ""));
|
| 1453 |
+
if (!url) return;
|
| 1454 |
+
this.handleImageUrl(url, targetFrameStart, explicitLength);
|
| 1455 |
+
}
|
| 1456 |
+
|
| 1457 |
+
promptAddAudioUrl(targetFrameStart = null) {
|
| 1458 |
+
const url = this.normalizeRemoteUrl(window.prompt("Paste audio URL:", ""));
|
| 1459 |
+
if (!url) return;
|
| 1460 |
+
this.handleAudioUrl(url, targetFrameStart);
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
_resolveNewSegmentStart(targetArray, newLength, targetFrameStart) {
|
| 1464 |
+
let newStart = targetFrameStart;
|
| 1465 |
+
|
| 1466 |
+
if (newStart === null) {
|
| 1467 |
+
newStart = 0;
|
| 1468 |
+
targetArray.sort((a, b) => a.start - b.start);
|
| 1469 |
+
for (let i = 0; i < targetArray.length; i++) {
|
| 1470 |
+
let seg = targetArray[i];
|
| 1471 |
+
if (newStart + newLength <= seg.start) break;
|
| 1472 |
+
newStart = Math.max(newStart, seg.start + seg.length);
|
| 1473 |
+
}
|
| 1474 |
+
return newStart;
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
const currentDuration = this.getVisualDurationFrames();
|
| 1478 |
+
let tempId = "TEMP_" + Date.now();
|
| 1479 |
+
targetArray.push({ id: tempId, start: newStart, length: newLength, type: "temp" });
|
| 1480 |
+
let result = this._applyCenterDragPhysics(targetArray, tempId, newStart, newStart + newLength / 2, currentDuration, currentDuration, 1);
|
| 1481 |
+
|
| 1482 |
+
for (let shiftedSeg of result) {
|
| 1483 |
+
let original = targetArray.find(s => s.id === shiftedSeg.id);
|
| 1484 |
+
if (original) {
|
| 1485 |
+
original.start = shiftedSeg.resolvedStart !== undefined ? shiftedSeg.resolvedStart : shiftedSeg.start;
|
| 1486 |
+
}
|
| 1487 |
+
}
|
| 1488 |
+
|
| 1489 |
+
let tempSeg = targetArray.find(s => s.id === tempId);
|
| 1490 |
+
newStart = tempSeg ? tempSeg.start : newStart;
|
| 1491 |
+
const tempIndex = targetArray.findIndex(s => s.id === tempId);
|
| 1492 |
+
if (tempIndex !== -1) targetArray.splice(tempIndex, 1);
|
| 1493 |
+
return newStart;
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
_buildAudioPeaks(audioBuffer) {
|
| 1497 |
+
const channelData = audioBuffer.getChannelData(0);
|
| 1498 |
+
const peaks = [];
|
| 1499 |
+
const numPeaks = 200;
|
| 1500 |
+
const step = Math.max(1, Math.floor(channelData.length / numPeaks));
|
| 1501 |
+
for (let i = 0; i < numPeaks; i++) {
|
| 1502 |
+
let max = 0;
|
| 1503 |
+
const start = i * step;
|
| 1504 |
+
const end = Math.min(channelData.length, start + step);
|
| 1505 |
+
for (let j = start; j < end; j++) {
|
| 1506 |
+
const val = Math.abs(channelData[j]);
|
| 1507 |
+
if (val > max) max = val;
|
| 1508 |
+
}
|
| 1509 |
+
peaks.push(max);
|
| 1510 |
+
}
|
| 1511 |
+
return peaks;
|
| 1512 |
+
}
|
| 1513 |
+
|
| 1514 |
+
async _loadRemoteAudioInfo(audioUrl) {
|
| 1515 |
+
try {
|
| 1516 |
+
const resp = await fetch(audioUrl);
|
| 1517 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 1518 |
+
const arrayBuffer = await resp.arrayBuffer();
|
| 1519 |
+
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 1520 |
+
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
| 1521 |
+
return {
|
| 1522 |
+
duration: audioBuffer.duration,
|
| 1523 |
+
peaks: this._buildAudioPeaks(audioBuffer),
|
| 1524 |
+
};
|
| 1525 |
+
} catch (err) {
|
| 1526 |
+
console.warn("[PromptRelay] Audio URL waveform load failed; falling back to metadata/default length.", err);
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
+
try {
|
| 1530 |
+
const duration = await new Promise((resolve, reject) => {
|
| 1531 |
+
const audio = document.createElement("audio");
|
| 1532 |
+
const timeoutId = setTimeout(() => {
|
| 1533 |
+
audio.removeAttribute("src");
|
| 1534 |
+
reject(new Error("Timed out while reading audio metadata."));
|
| 1535 |
+
}, 10000);
|
| 1536 |
+
audio.preload = "metadata";
|
| 1537 |
+
audio.onloadedmetadata = () => {
|
| 1538 |
+
clearTimeout(timeoutId);
|
| 1539 |
+
resolve(Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 1);
|
| 1540 |
+
};
|
| 1541 |
+
audio.onerror = () => {
|
| 1542 |
+
clearTimeout(timeoutId);
|
| 1543 |
+
reject(new Error("Could not read audio metadata."));
|
| 1544 |
+
};
|
| 1545 |
+
audio.src = audioUrl;
|
| 1546 |
+
});
|
| 1547 |
+
return { duration, peaks: [] };
|
| 1548 |
+
} catch (err) {
|
| 1549 |
+
console.warn("[PromptRelay] Audio URL metadata load failed; using a 1 second placeholder.", err);
|
| 1550 |
+
return { duration: 1, peaks: [] };
|
| 1551 |
+
}
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
+
async handleImageUrl(imageUrl, targetFrameStart = null, explicitLength = null) {
|
| 1555 |
+
const normalizedUrl = this.normalizeRemoteUrl(imageUrl);
|
| 1556 |
+
if (!normalizedUrl) return;
|
| 1557 |
+
|
| 1558 |
+
const frameRate = this.getFrameRate();
|
| 1559 |
+
const newLength = explicitLength !== null ? explicitLength : frameRate * 1;
|
| 1560 |
+
|
| 1561 |
+
await new Promise((resolve) => {
|
| 1562 |
+
const displayImg = new Image();
|
| 1563 |
+
let didCommit = false;
|
| 1564 |
+
|
| 1565 |
+
const commit = (imgObj = null) => {
|
| 1566 |
+
if (didCommit) return;
|
| 1567 |
+
didCommit = true;
|
| 1568 |
+
|
| 1569 |
+
let newStart = this._resolveNewSegmentStart(this.timeline.segments, newLength, targetFrameStart);
|
| 1570 |
+
const seg = {
|
| 1571 |
+
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
|
| 1572 |
+
start: newStart,
|
| 1573 |
+
length: newLength,
|
| 1574 |
+
prompt: "",
|
| 1575 |
+
type: "image",
|
| 1576 |
+
imageUrl: normalizedUrl,
|
| 1577 |
+
imageB64: normalizedUrl
|
| 1578 |
+
};
|
| 1579 |
+
|
| 1580 |
+
if (imgObj) seg.imgObj = imgObj;
|
| 1581 |
+
|
| 1582 |
+
this.timeline.segments.push(seg);
|
| 1583 |
+
this.timeline.segments.sort((a, b) => a.start - b.start);
|
| 1584 |
+
this.selectionType = "image";
|
| 1585 |
+
this.selectedIndex = this.timeline.segments.findIndex(s => s.id === seg.id);
|
| 1586 |
+
|
| 1587 |
+
this.updateUIFromSelection();
|
| 1588 |
+
this.commitChanges(true);
|
| 1589 |
+
this.render();
|
| 1590 |
+
resolve();
|
| 1591 |
+
};
|
| 1592 |
+
|
| 1593 |
+
displayImg.onload = () => commit(displayImg);
|
| 1594 |
+
displayImg.onerror = () => {
|
| 1595 |
+
console.warn("[PromptRelay] Image URL preview failed; saving URL for backend execution.", normalizedUrl);
|
| 1596 |
+
commit(null);
|
| 1597 |
+
};
|
| 1598 |
+
displayImg.src = normalizedUrl;
|
| 1599 |
+
});
|
| 1600 |
+
}
|
| 1601 |
+
|
| 1602 |
+
async handleAudioUrl(audioUrl, targetFrameStart = null) {
|
| 1603 |
+
const normalizedUrl = this.normalizeRemoteUrl(audioUrl);
|
| 1604 |
+
if (!normalizedUrl) return;
|
| 1605 |
+
|
| 1606 |
+
const frameRate = this.getFrameRate();
|
| 1607 |
+
const info = await this._loadRemoteAudioInfo(normalizedUrl);
|
| 1608 |
+
const clipFrames = Math.max(1, Math.ceil(info.duration * frameRate));
|
| 1609 |
+
const newLength = clipFrames;
|
| 1610 |
+
let newStart = this._resolveNewSegmentStart(this.timeline.audioSegments, newLength, targetFrameStart);
|
| 1611 |
+
|
| 1612 |
+
const seg = {
|
| 1613 |
+
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
|
| 1614 |
+
type: "audio",
|
| 1615 |
+
start: newStart,
|
| 1616 |
+
length: newLength,
|
| 1617 |
+
trimStart: 0,
|
| 1618 |
+
audioDurationFrames: clipFrames,
|
| 1619 |
+
audioUrl: normalizedUrl,
|
| 1620 |
+
fileName: this.getRemoteFileName(normalizedUrl, "remote-audio"),
|
| 1621 |
+
waveformPeaks: info.peaks
|
| 1622 |
+
};
|
| 1623 |
+
|
| 1624 |
+
this.timeline.audioSegments.push(seg);
|
| 1625 |
+
this.timeline.audioSegments.sort((a, b) => a.start - b.start);
|
| 1626 |
+
this.selectionType = "audio";
|
| 1627 |
+
this.selectedIndex = this.timeline.audioSegments.findIndex(s => s.id === seg.id);
|
| 1628 |
+
|
| 1629 |
+
this.updateUIFromSelection();
|
| 1630 |
+
this.commitChanges(true);
|
| 1631 |
+
this.render();
|
| 1632 |
+
}
|
| 1633 |
+
|
| 1634 |
// --- Async Image Upload Logic (Handles multiple images simultaneously) ---
|
| 1635 |
async handleImageUpload(files, targetFrameStart = null, explicitLength = null) {
|
| 1636 |
const frameRate = this.getFrameRate();
|
|
|
|
| 3170 |
menu.style.left = `${clientX + 6}px`;
|
| 3171 |
menu.style.top = `${clientY - 10}px`;
|
| 3172 |
|
| 3173 |
+
const imageSrc = seg.imageB64 || seg.imageUrl;
|
| 3174 |
+
const isImage = trackType !== "audio" && trackType !== "text" && imageSrc;
|
| 3175 |
|
| 3176 |
if (isImage) {
|
| 3177 |
const copyBtn = document.createElement("button");
|
|
|
|
| 3179 |
copyBtn.innerHTML = `Copy Image`;
|
| 3180 |
copyBtn.onclick = async () => {
|
| 3181 |
try {
|
| 3182 |
+
const res = await fetch(imageSrc);
|
| 3183 |
const blob = await res.blob();
|
| 3184 |
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
| 3185 |
} catch (err) {
|
|
|
|
| 3194 |
saveBtn.innerHTML = `Save Image`;
|
| 3195 |
saveBtn.onclick = () => {
|
| 3196 |
const a = document.createElement("a");
|
| 3197 |
+
a.href = imageSrc;
|
| 3198 |
a.download = "timeline_image.jpg";
|
| 3199 |
a.click();
|
| 3200 |
this.dismissContextMenu();
|
|
|
|
| 3207 |
openBtn.onclick = () => {
|
| 3208 |
const win = window.open();
|
| 3209 |
if (win) {
|
| 3210 |
+
win.document.write(`<body style="margin:0;display:flex;justify-content:center;align-items:center;background:#0e0e0e;height:100vh;"><img style="max-width:100%;max-height:100%;" src="${imageSrc}" /></body>`);
|
| 3211 |
win.document.close();
|
| 3212 |
}
|
| 3213 |
this.dismissContextMenu();
|
|
|
|
| 3341 |
fi.click();
|
| 3342 |
};
|
| 3343 |
menu.appendChild(imgBtn);
|
| 3344 |
+
|
| 3345 |
+
const imgUrlBtn = document.createElement("button");
|
| 3346 |
+
imgUrlBtn.className = "pr-gap-menu-btn";
|
| 3347 |
+
imgUrlBtn.innerHTML = `${ICONS.link} Image URL Segment`;
|
| 3348 |
+
imgUrlBtn.onclick = () => {
|
| 3349 |
+
this.dismissContextMenu();
|
| 3350 |
+
const gapLength = gap.frameEnd - gap.frameStart;
|
| 3351 |
+
this.promptAddImageUrl(gap.frameStart, gapLength);
|
| 3352 |
+
};
|
| 3353 |
+
menu.appendChild(imgUrlBtn);
|
| 3354 |
+
} else {
|
| 3355 |
+
const audioBtn = document.createElement("button");
|
| 3356 |
+
audioBtn.className = "pr-gap-menu-btn";
|
| 3357 |
+
audioBtn.innerHTML = `${ICONS.audio} Audio Segment`;
|
| 3358 |
+
audioBtn.onclick = () => {
|
| 3359 |
+
this.dismissContextMenu();
|
| 3360 |
+
this.promptAddAudioInGap(gap.frameStart, gap.frameEnd);
|
| 3361 |
+
};
|
| 3362 |
+
menu.appendChild(audioBtn);
|
| 3363 |
+
|
| 3364 |
+
const audioUrlBtn = document.createElement("button");
|
| 3365 |
+
audioUrlBtn.className = "pr-gap-menu-btn";
|
| 3366 |
+
audioUrlBtn.innerHTML = `${ICONS.link} Audio URL Segment`;
|
| 3367 |
+
audioUrlBtn.onclick = () => {
|
| 3368 |
+
this.dismissContextMenu();
|
| 3369 |
+
this.promptAddAudioUrl(gap.frameStart);
|
| 3370 |
+
};
|
| 3371 |
+
menu.appendChild(audioUrlBtn);
|
| 3372 |
}
|
| 3373 |
|
| 3374 |
document.body.appendChild(menu);
|
|
|
|
| 3417 |
|
| 3418 |
menu.appendChild(textBtn);
|
| 3419 |
menu.appendChild(imgBtn);
|
| 3420 |
+
|
| 3421 |
+
const imgUrlBtn = document.createElement("button");
|
| 3422 |
+
imgUrlBtn.className = "pr-gap-menu-btn";
|
| 3423 |
+
imgUrlBtn.innerHTML = `${ICONS.link} Image URL Segment`;
|
| 3424 |
+
imgUrlBtn.addEventListener("click", () => {
|
| 3425 |
+
this.dismissGapMenu();
|
| 3426 |
+
const gapLength = gap.frameEnd - gap.frameStart;
|
| 3427 |
+
this.promptAddImageUrl(gap.frameStart, gapLength);
|
| 3428 |
+
});
|
| 3429 |
+
menu.appendChild(imgUrlBtn);
|
| 3430 |
+
|
| 3431 |
const currentTrack = gap.track === "audio" ? "audio" : "image";
|
| 3432 |
if (this._copiedSegment && this._copiedSegmentTrack === currentTrack) {
|
| 3433 |
const pasteBtn = document.createElement("button");
|
|
|
|
| 3941 |
const resp = await fetch(audioUrl);
|
| 3942 |
const arrayBuffer = await resp.arrayBuffer();
|
| 3943 |
audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
| 3944 |
+
} else if (seg.audioUrl) {
|
| 3945 |
+
const resp = await fetch(seg.audioUrl);
|
| 3946 |
+
const arrayBuffer = await resp.arrayBuffer();
|
| 3947 |
+
audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
| 3948 |
} else if (seg.audioB64) {
|
| 3949 |
const binaryString = window.atob(seg.audioB64);
|
| 3950 |
const len = binaryString.length;
|
ltx_director.py
CHANGED
|
@@ -3,6 +3,8 @@ import json
|
|
| 3 |
import base64
|
| 4 |
import io as _io
|
| 5 |
import math
|
|
|
|
|
|
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
import torch
|
|
@@ -31,8 +33,27 @@ log = logging.getLogger(__name__)
|
|
| 31 |
GuideData = io.Custom("GUIDE_DATA")
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def _load_image_tensor(seg: dict) -> torch.Tensor:
|
| 35 |
-
"""Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
|
| 36 |
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
|
| 37 |
if seg.get("imageFile"):
|
| 38 |
file_path = os.path.join(folder_paths.get_input_directory(), seg["imageFile"])
|
|
@@ -41,6 +62,16 @@ def _load_image_tensor(seg: dict) -> torch.Tensor:
|
|
| 41 |
arr = np.array(img, dtype=np.float32) / 255.0
|
| 42 |
return torch.from_numpy(arr).unsqueeze(0)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
b64_str = seg.get("imageB64", "")
|
| 45 |
if not b64_str or b64_str.startswith("/view?"):
|
| 46 |
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
@@ -183,6 +214,14 @@ def _build_combined_audio(timeline_data_str: str, duration_frames: int, frame_ra
|
|
| 183 |
if os.path.exists(file_path):
|
| 184 |
with open(file_path, "rb") as f:
|
| 185 |
buffer = _io.BytesIO(f.read())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
if not buffer and seg.get("audioB64"):
|
| 188 |
b64 = seg.get("audioB64")
|
|
@@ -484,7 +523,7 @@ class LTXDirector(io.ComfyNode):
|
|
| 484 |
img_segs = [
|
| 485 |
s for s in tdata.get("segments", [])
|
| 486 |
if s.get("type", "image") == "image"
|
| 487 |
-
and (s.get("imageFile") or s.get("imageB64"))
|
| 488 |
and int(s.get("start", 0)) < duration_frames # exclude segments fully outside duration
|
| 489 |
]
|
| 490 |
img_segs.sort(key=lambda s: s["start"])
|
|
@@ -658,4 +697,4 @@ NODE_CLASS_MAPPINGS = {
|
|
| 658 |
|
| 659 |
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 660 |
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
| 661 |
-
}
|
|
|
|
| 3 |
import base64
|
| 4 |
import io as _io
|
| 5 |
import math
|
| 6 |
+
import urllib.parse
|
| 7 |
+
import urllib.request
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
import torch
|
|
|
|
| 33 |
GuideData = io.Custom("GUIDE_DATA")
|
| 34 |
|
| 35 |
|
| 36 |
+
def _is_http_url(value: str) -> bool:
|
| 37 |
+
if not isinstance(value, str):
|
| 38 |
+
return False
|
| 39 |
+
try:
|
| 40 |
+
parsed = urllib.parse.urlparse(value.strip())
|
| 41 |
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
| 42 |
+
except Exception:
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _download_url_bytes(url: str, timeout: int = 30) -> bytes:
|
| 47 |
+
req = urllib.request.Request(
|
| 48 |
+
url.strip(),
|
| 49 |
+
headers={"User-Agent": "ComfyUI-LTXDirector/1.0"},
|
| 50 |
+
)
|
| 51 |
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
| 52 |
+
return response.read()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def _load_image_tensor(seg: dict) -> torch.Tensor:
|
| 56 |
+
"""Decode an image from the ComfyUI input folder (if imageFile provided), URL, or fallback to base64
|
| 57 |
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
|
| 58 |
if seg.get("imageFile"):
|
| 59 |
file_path = os.path.join(folder_paths.get_input_directory(), seg["imageFile"])
|
|
|
|
| 62 |
arr = np.array(img, dtype=np.float32) / 255.0
|
| 63 |
return torch.from_numpy(arr).unsqueeze(0)
|
| 64 |
|
| 65 |
+
image_url = seg.get("imageUrl", "")
|
| 66 |
+
if _is_http_url(image_url):
|
| 67 |
+
try:
|
| 68 |
+
img_bytes = _download_url_bytes(image_url)
|
| 69 |
+
img = Image.open(_io.BytesIO(img_bytes)).convert("RGB")
|
| 70 |
+
arr = np.array(img, dtype=np.float32) / 255.0
|
| 71 |
+
return torch.from_numpy(arr).unsqueeze(0)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
log.warning("[PromptRelay] Image URL load failed for %s: %s", image_url, e)
|
| 74 |
+
|
| 75 |
b64_str = seg.get("imageB64", "")
|
| 76 |
if not b64_str or b64_str.startswith("/view?"):
|
| 77 |
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
|
|
|
| 214 |
if os.path.exists(file_path):
|
| 215 |
with open(file_path, "rb") as f:
|
| 216 |
buffer = _io.BytesIO(f.read())
|
| 217 |
+
|
| 218 |
+
if not buffer and seg.get("audioUrl"):
|
| 219 |
+
audio_url = seg.get("audioUrl")
|
| 220 |
+
if _is_http_url(audio_url):
|
| 221 |
+
try:
|
| 222 |
+
buffer = _io.BytesIO(_download_url_bytes(audio_url))
|
| 223 |
+
except Exception as e:
|
| 224 |
+
log.warning("[PromptRelay] Audio URL load failed for %s: %s", audio_url, e)
|
| 225 |
|
| 226 |
if not buffer and seg.get("audioB64"):
|
| 227 |
b64 = seg.get("audioB64")
|
|
|
|
| 523 |
img_segs = [
|
| 524 |
s for s in tdata.get("segments", [])
|
| 525 |
if s.get("type", "image") == "image"
|
| 526 |
+
and (s.get("imageFile") or s.get("imageUrl") or s.get("imageB64"))
|
| 527 |
and int(s.get("start", 0)) < duration_frames # exclude segments fully outside duration
|
| 528 |
]
|
| 529 |
img_segs.sort(key=lambda s: s["start"])
|
|
|
|
| 697 |
|
| 698 |
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 699 |
"PromptRelayEncodeTimeline": "Prompt Relay Encode (Timeline)",
|
| 700 |
+
}
|