Host video slotDrop the host video file here, or choose one. Keep room for captions and chapter callouts.
-
+
Guest video slotDrop the guest video file here, or choose one, matched to framing and safe-area rules.
-
+
Guest 2 video slotDrop the second guest video file here, or choose one, when the panel grid is selected.
-
+
@@ -1498,6 +1498,67 @@
Review & publish
updateSlotStatus();
}
+ // A file the placement path will actually accept into a slot: a video that carries real
+ // bytes. A non-video or a 0-byte export passes neither, so it never consumes a slot.
+ function isPlaceableVideo(file) {
+ return isVideoFile(file) && !isEmptyExport(file);
+ }
+
+ // The visible slots, in layout order, that are still empty — where extra recordings from a
+ // multi-file drop spill (required speaker slots first, optional b-roll last). Optionally
+ // excludes the slot the drop landed on, which already took the first file.
+ function openVisibleSlots(exceptZone) {
+ return currentDefinition().visibleSlots
+ .map(function (slot) { return document.querySelector(slotSelector(slot)); })
+ .filter(function (zone) {
+ return zone && zone !== exceptZone && !zone.classList.contains("filled");
+ });
+ }
+
+ // Place several recordings from one drop or file pick: fill the slot they landed on, then
+ // spill the remaining videos into the next empty visible slots in order. This lets a
+ // creator drop all of their synced speaker recordings onto the layout at once instead of
+ // one slot at a time, matching the layout-first start. Duplicate copies of the same source
+ // in a single drop are dropped (one source can't fill two speaker slots), and the creator
+ // is told when videos overflow the open slots or a non-video file was skipped.
+ function placeVideoFiles(zone, fileList) {
+ var all = Array.prototype.slice.call(fileList || []).filter(Boolean);
+ var seen = {};
+ var files = all.filter(function (file) {
+ var sig = fileSignature(file);
+ if (!sig) return true;
+ if (seen[sig]) return false;
+ seen[sig] = true;
+ return true;
+ });
+ if (!files.length) return;
+ placeVideoFile(zone, files[0]);
+ if (!zone.classList.contains("filled")) {
+ // The target slot rejected the first file — do not spill the rest into other slots.
+ return;
+ }
+ var extras = files.slice(1).filter(isPlaceableVideo);
+ var skippedNonVideo = files.slice(1).length - extras.length;
+ var open = openVisibleSlots(zone);
+ var overflow = 0;
+ extras.forEach(function (file, index) {
+ if (open[index]) {
+ placeVideoFile(open[index], file);
+ } else {
+ overflow += 1;
+ }
+ });
+ if (overflow > 0) {
+ updateSlotStatus("There's no open slot left, so " + overflow + " extra "
+ + (overflow === 1 ? "video wasn't" : "videos weren't")
+ + " placed. Remove a video to make room for another.");
+ } else if (skippedNonVideo > 0) {
+ updateSlotStatus(skippedNonVideo + " "
+ + (skippedNonVideo === 1 ? "file in that drop wasn't a video, so it was" : "files in that drop weren't videos, so they were")
+ + " skipped. Only video files can fill a slot.");
+ }
+ }
+
function placeTrack(zone, track) {
if (!zone || track !== zone.dataset.slot) {
updateSlotStatus("That track belongs in a different slot.");
@@ -1602,9 +1663,9 @@
Review & publish
zone.addEventListener("drop", function (event) {
event.preventDefault();
zone.classList.remove("drag-over");
- var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
- if (file) {
- placeVideoFile(zone, file);
+ var files = event.dataTransfer && event.dataTransfer.files;
+ if (files && files.length) {
+ placeVideoFiles(zone, files);
return;
}
var track = event.dataTransfer.getData("text/plain");
@@ -1660,7 +1721,7 @@
Review & publish
document.querySelectorAll(".slot-file").forEach(function (input) {
input.addEventListener("change", function () {
var zone = document.querySelector('.drop-zone[data-slot="' + input.dataset.fileInput + '"]');
- placeVideoFile(zone, input.files && input.files[0]);
+ placeVideoFiles(zone, input.files);
});
});
diff --git a/preview/layout-first-canvas-handoff.test.js b/preview/layout-first-canvas-handoff.test.js
index a13a586..b6764bc 100644
--- a/preview/layout-first-canvas-handoff.test.js
+++ b/preview/layout-first-canvas-handoff.test.js
@@ -403,4 +403,41 @@ assert.equal(panelButton.getAttribute("aria-checked"), "true", "End applies the
pressKey(panelButton, "Home");
assert.equal(interviewButton.getAttribute("aria-checked"), "true", "Home applies the first layout (interview)");
+// Multi-file placement: dropping several recordings at once fills the slot they land on and
+// spills the rest into the next empty visible slots, like the layout-first start.
+function vid(name, overrides) {
+ return Object.assign({ name: name, type: "video/mp4", size: 2048, lastModified: 1 }, overrides || {});
+}
+function dropFiles(slot, files) {
+ const zone = zones.find((entry) => entry.dataset.slot === slot);
+ zone.listeners.drop({ preventDefault() {}, dataTransfer: { files: files, getData() { return ""; } } });
+}
+function filled(slot) {
+ return zones.find((zone) => zone.dataset.slot === slot).classList.contains("filled");
+}
+
+resetButton.click();
+dropFiles("host", [vid("host.mp4", { lastModified: 1 }), vid("guest.mp4", { lastModified: 2 })]);
+assert.equal(filled("host"), true, "a multi-file drop fills the slot it landed on");
+assert.equal(filled("guest"), true, "the extra recording spills into the next empty slot");
+
+// A non-video in the batch is skipped (the videos still place) and the creator is told.
+resetButton.click();
+dropFiles("host", [vid("host.mp4", { lastModified: 1 }), { name: "poster.txt", type: "text/plain", size: 10 }, vid("guest.mp4", { lastModified: 2 })]);
+assert.equal(filled("host"), true, "the first video fills the target slot even with a non-video in the batch");
+assert.equal(filled("guest"), true, "the other video still spills past the skipped non-video");
+assert.match(slotStatus.textContent, /wasn't a video, so it was skipped/, "the skipped non-video is reported");
+
+// Overflow: more videos than open slots (host, guest, broll = 3 in interview) reports the surplus.
+resetButton.click();
+dropFiles("host", [vid("a.mp4", { lastModified: 1 }), vid("b.mp4", { lastModified: 2 }), vid("c.mp4", { lastModified: 3 }), vid("d.mp4", { lastModified: 4 })]);
+assert.match(slotStatus.textContent, /1 extra video wasn't placed/, "an overflowing multi-file drop reports the surplus");
+assert.equal(filled("broll"), true, "the drop fills every available slot before overflowing");
+
+// De-duplicate: the same recording twice fills only the slot it landed on.
+resetButton.click();
+dropFiles("host", [vid("dupe.mp4", { size: 5, lastModified: 9 }), vid("dupe.mp4", { size: 5, lastModified: 9 })]);
+assert.equal(filled("host"), true, "a duplicate batch keeps the recording in the slot it landed on");
+assert.equal(filled("guest"), false, "a duplicate of the same recording does not spill into another slot");
+
console.log("layout-first canvas handoff: keyboard, click, and drag placement all unlock continue correctly");
diff --git a/preview/preview-canvas-video.test.js b/preview/preview-canvas-video.test.js
index 732cd8a..84764af 100644
--- a/preview/preview-canvas-video.test.js
+++ b/preview/preview-canvas-video.test.js
@@ -16,9 +16,9 @@ const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
const script = html.match(/