Seashellsen commited on
Commit
b67f466
·
verified ·
1 Parent(s): 9fc1e29

Upload 2 files

Browse files
Files changed (2) hide show
  1. ltx_director.js +273 -6
  2. 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
- if (seg.imageB64 && !seg.imgObj) {
 
796
  seg.imgObj = new Image();
797
  seg.imgObj.onload = () => this.render();
798
- seg.imgObj.src = seg.imageB64;
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 isImage = trackType !== "audio" && trackType !== "text" && seg.imageB64;
 
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(seg.imageB64);
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 = seg.imageB64;
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="${seg.imageB64}" /></body>`);
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
+ }