diff --git a/preview/index.html b/preview/index.html index 77267ad..177a946 100644 --- a/preview/index.html +++ b/preview/index.html @@ -663,17 +663,17 @@

Example podcast layout canvas

Host video slot Drop the host video file here, or choose one. Keep room for captions and chapter callouts. - +
Guest video slot Drop the guest video file here, or choose one, matched to framing and safe-area rules. - +
@@ -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(/