Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .builderloops/verify.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"comment": "Build-correctness and rendered-workflow gate: zero-dependency unit tests over the DOM-free episode/preset model, a preview-build smoke check, a headless Chrome rendered workflow that uploads three generated local WebM files through the shipped Host/Guest controls and decodes blob-backed pixels in the composed preview across Split/Stack/Spotlight — including per-region Stack row pixels and Spotlight host-dominant/PiP geometry — surviving preset switches with export still enabled, a headless social-context workflow that enters distinct per-speaker social links through the real inputs and confirms they store per speaker, surface as derived names in the preview, and survive a preset switch with the uploads intact, and a headless export workflow that uploads two generated videos, enters social links, chooses a preset, clicks the real Export action, and confirms the produced file is a genuinely playable video (loads back into a <video> with real dimensions and non-trivial bytes) reflecting the selected preset. No seeded product media, committed fixture output, or verifier-only product paths are used.",
"comment": "Build-correctness and rendered-workflow gate: zero-dependency unit tests over the DOM-free episode/preset model, a preview-build smoke check, a headless Chrome rendered workflow that uploads three generated local WebM files through the shipped Host/Guest controls and decodes blob-backed pixels in the composed preview across Split/Stack/Spotlight — including per-region Stack row pixels and Spotlight host-dominant/PiP geometry — surviving preset switches with export still enabled, a headless social-context workflow that uploads three speaker videos, enters distinct per-speaker social links through the real inputs (input and change events), confirms derived names appear on the live canvas preview labels and in setup, survive preset switching, clear/replace per speaker only, and keep export enabled, and a headless export workflow that uploads two generated videos, enters social links, chooses a preset, clicks the real Export action, and confirms the produced file is a genuinely playable video (loads back into a <video> with real dimensions and non-trivial bytes) reflecting the selected preset. No seeded product media, committed fixture output, or verifier-only product paths are used.",
"checks": [
{ "name": "test", "run": "node scripts/run-tests.mjs" },
{ "name": "preview-build", "run": "node scripts/preview-build.mjs" },
Expand Down
30 changes: 24 additions & 6 deletions app/episode.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,37 @@
if (!s) return "";
const at = s.match(/^@([A-Za-z0-9_.\-]+)$/);
if (at) return at[1];
s = s.replace(/^https?:\/\//i, "").replace(/^www\./i, "").split(/[?#]/)[0];
// Allow a plain handle when the creator skips the full URL.
if (/^[A-Za-z0-9_.\-]+$/.test(s)) return s;
s = s.replace(/^https?:\/\//i, "").replace(/^www\./i, "").split(/[?#]/)[0].replace(/\/+$/, "");
const parts = s.split("/").filter(Boolean);
const last = parts.length > 1 ? parts[parts.length - 1] : "";
const handle = (last || "").replace(/^@/, "");
return handle;
if (!parts.length) return "";
// Walk path segments from the end; skip empty/@-only and generic route words.
for (let i = parts.length - 1; i >= 0; i--) {
const seg = parts[i].replace(/^@/, "");
if (!seg) continue;
if (/^(in|user|u|profile|channel|c|people|@)$/i.test(seg)) continue;
if (i === 0 && seg.includes(".")) continue; // domain-only remainder
return seg;
}
return "";
}

// The name to display for a speaker: derived from their social link when one
// is set, otherwise the default bucket label (Host / Guest 1 / Guest 2).
// Display name for one speaker bucket (derived link handle or bucket label).
function speakerName(episode, bucket) {
const fallback = (PDC.presets.BUCKET_LABELS && PDC.presets.BUCKET_LABELS[bucket]) || bucket;
return deriveHandle(getSocialLink(episode, bucket)) || fallback;
}

// Map each speaker bucket to the label the preview should show right now.
function speakerLabels(episode) {
const labels = {};
SPEAKER_BUCKETS.forEach(function (bucket) {
labels[bucket] = speakerName(episode, bucket);
});
return labels;
}

// Buckets that currently hold media, in canonical speaker order.
function assignedBuckets(episode) {
return SPEAKER_BUCKETS.filter((b) => episode.media[b]);
Expand Down Expand Up @@ -112,6 +129,7 @@
getSocialLink,
deriveHandle,
speakerName,
speakerLabels,
canCompose,
readinessReason,
};
Expand Down
18 changes: 13 additions & 5 deletions app/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,16 @@
ctx.fillStyle = "#05070c";
ctx.fillRect(0, 0, w, h);

const labels = {};
buckets.forEach(function (bucket, i) {
const rect = rects[i] || rects[rects.length - 1];
const x = (rect.x / 100) * w;
const y = (rect.y / 100) * h;
const rw = (rect.w / 100) * w;
const rh = (rect.h / 100) * h;
const v = videos[bucket];
const label = PDC.episode.speakerName(episodeRef, bucket);
labels[bucket] = label;

ctx.fillStyle = "#000";
ctx.fillRect(x, y, rw, rh);
Expand Down Expand Up @@ -150,18 +153,22 @@
ctx.strokeRect(x + 1, y + 1, rw - 2, rh - 2);
}

const label = PDC.episode.speakerName(episodeRef, bucket);
if (label) {
ctx.fillStyle = "rgba(8,10,16,0.72)";
ctx.fillRect(x + 8, y + rh - 28, Math.min(rw - 16, label.length * 9 + 20), 22);
const fontSize = Math.max(11, Math.min(14, Math.round(rh * 0.09)));
const padX = Math.max(8, Math.round(rw * 0.03));
const barW = Math.min(rw - padX * 2, label.length * (fontSize * 0.62) + 16);
const barH = Math.max(18, fontSize + 8);
ctx.fillStyle = "rgba(8,10,16,0.78)";
ctx.fillRect(x + padX, y + rh - barH - 6, barW, barH);
ctx.fillStyle = "#fff";
ctx.font = "600 14px system-ui, sans-serif";
ctx.fillText(label, x + 14, y + rh - 12);
ctx.font = "600 " + fontSize + "px system-ui, sans-serif";
ctx.fillText(label, x + padX + 6, y + rh - 10);
}
});

canvasEl.dataset.preset = preset.id;
canvasEl.dataset.speakers = String(buckets.length);
canvasEl.dataset.speakerLabels = JSON.stringify(labels);
}

function loop() {
Expand Down Expand Up @@ -191,6 +198,7 @@
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
canvasEl.dataset.preset = "";
canvasEl.dataset.speakers = "0";
canvasEl.dataset.speakerLabels = "";
}
drawFrame();
return buckets.length;
Expand Down
11 changes: 11 additions & 0 deletions app/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@
input.addEventListener("input", handle);
});

function onSocialLinkChange(bucket, value) {
setSocialLink(episode, bucket, value);
updateBucketRow(bucket);
if (canCompose(episode)) {
preview.render(episode);
preview.drawFrame();
if (!preview.isPlaying()) preview.play();
}
refresh();
}

document.querySelectorAll("input[data-link-bucket]").forEach(function (input) {
const bucket = input.getAttribute("data-link-bucket");
function handle() {
Expand Down
10 changes: 10 additions & 0 deletions scripts/preview-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ if (!PDC.episode.canCompose(ep)) {
console.error("preview-build: canCompose() false with two speakers + default preset — readiness gate is broken");
process.exit(1);
}
PDC.episode.setSocialLink(ep, "host", "https://x.com/hostperson");
PDC.episode.setSocialLink(ep, "guest1", "@guestperson");
if (PDC.episode.speakerName(ep, "host") !== "hostperson" || PDC.episode.speakerLabels(ep).guest1 !== "guestperson") {
console.error("preview-build: social links should derive speaker names for preview labels");
process.exit(1);
}
if (!html.includes('data-link-bucket="host"')) {
console.error("preview-build: index.html must declare static speaker social-link inputs");
process.exit(1);
}

// 4. Copy the static app into dist/.
const dist = path.join(root, "dist");
Expand Down
31 changes: 16 additions & 15 deletions scripts/verify-social-context.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// scripts/verify-social-context.mjs
// Drives the shipped app in headless Chrome and proves issue #41's full workflow:
// upload Host + Guest videos, enter distinct social links, confirm derived names
// in the preview, cycle Split → Stack → Spotlight with nonblank video, and keep
// uploads + social context intact throughout.
// Drives the shipped app in headless Chrome and proves issue #63: upload speaker
// videos, enter distinct social links for Host/Guest 1/Guest 2 through the real
// setup inputs, confirm derived names appear in the live composed preview (canvas
// labels + setup hints), survive preset switching, clear/replace per speaker
// only, and keep export available.
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
Expand Down Expand Up @@ -136,7 +137,7 @@ const browserExpression = `
recorder.start();
for (let i = 0; i < 24; i++) {
ctx.fillStyle = color; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff"; ctx.font = "26px sans-serif"; ctx.fillText("frame " + i, 20, 100);
ctx.fillStyle = "#fff"; ctx.font = "26px sans-serif"; ctx.fillText(name, 20, 100);
await sleep(45);
}
await new Promise((resolve) => { recorder.onstop = resolve; recorder.stop(); });
Expand All @@ -149,11 +150,12 @@ const browserExpression = `
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function typeInto(input, value) {
function setLink(bucket, value, eventName) {
const input = document.querySelector('[data-link-bucket="' + bucket + '"]');
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event(eventName || "input", { bubbles: true }));
}
const tagText = (bucket) => {
const bucketName = (bucket) => {
const el = document.querySelector('.bucket[data-bucket="' + bucket + '"] .bucket-name');
return el ? el.textContent : null;
};
Expand Down Expand Up @@ -186,13 +188,11 @@ const browserExpression = `
assert(lit >= 5, label + ": composed canvas should show nonblank pixels (" + lit + "%)");
}

// Wait for the app's classic scripts to finish wiring the DOM (the page may
// still be loading when this evaluates), then assert the controls exist.
const waitFor = async (fn, label) => {
for (let i = 0; i < 100; i++) { if (fn()) return; await sleep(50); }
throw new Error(label);
};
await waitFor(() => window.PDC && window.PDC.episode && window.PDC.episode.setSocialLink, "PDC.episode social API should load");
await waitFor(() => window.PDC && window.PDC.episode && window.PDC.episode.speakerLabels, "PDC.episode social API should load");
await waitFor(() => document.querySelector("#stage-canvas"), "composed preview canvas should exist");
await waitFor(() => document.querySelector('[data-file-bucket="host"]'), "Host upload control should exist");
assert(document.querySelector('[data-link-bucket="host"]'), "Host social link input should exist");
Expand Down Expand Up @@ -230,13 +230,14 @@ const browserExpression = `
const playButton = document.querySelector("#play");
if (!playButton.textContent.includes("Pause")) playButton.click();
await sleep(700);
assertSocialState("split preset with social links");

assertSocialState(NAMES, "after entering social links");

for (const presetId of ["stack", "spotlight", "split"]) {
document.querySelector('[data-preset="' + presetId + '"]').click();
await sleep(500);
assert(document.querySelector("#stage-canvas").dataset.preset === presetId, "preset should switch to " + presetId);
assertSocialState(presetId + " preset with social links");
assertSocialState(NAMES, presetId + " preset preserves derived names");
}

// Replacing one link updates only that speaker's derived name.
Expand Down Expand Up @@ -265,7 +266,7 @@ const browserExpression = `
guest2: document.querySelector('[data-link-bucket="guest2"]').value,
},
presetAfter: document.querySelector("#stage-canvas").dataset.preset,
videoCount: videos.length,
exportEnabled: !document.querySelector("#export").disabled,
};
})()
`;
Expand Down Expand Up @@ -299,7 +300,7 @@ async function main() {
expression: browserExpression,
awaitPromise: true,
returnByValue: true,
timeout: 30000,
timeout: 45000,
});
ws.close();

Expand Down
13 changes: 13 additions & 0 deletions tests/episode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,22 @@ test("deriveHandle reads handles from common profile URL shapes", () => {
assert.equal(E.deriveHandle("https://www.linkedin.com/in/jane-doe"), "jane-doe");
assert.equal(E.deriveHandle("github.com/foo?tab=repos"), "foo");
assert.equal(E.deriveHandle("@bar"), "bar");
assert.equal(E.deriveHandle("plainhandle"), "plainhandle");
assert.equal(E.deriveHandle("https://x.com/hostperson/"), "hostperson");
assert.equal(E.deriveHandle("https://www.youtube.com/@guesttwo"), "guesttwo");
assert.equal(E.deriveHandle(""), "");
});

test("speakerLabels maps every bucket to its current display name", () => {
const ep = E.createEpisode({});
E.setSocialLink(ep, "host", "https://x.com/hostperson");
E.setSocialLink(ep, "guest2", "@guesttwo");
const labels = E.speakerLabels(ep);
assert.equal(labels.host, "hostperson");
assert.equal(labels.guest1, "Guest 1");
assert.equal(labels.guest2, "guesttwo");
});

test("removing a speaker clears only its own link, not the others'", () => {
const ep = E.createEpisode({});
E.assignMedia(ep, "host", media("h.webm"));
Expand Down