mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cd832f01ac
commit
f0c4dee705
@@ -174,7 +174,7 @@ function buildPresentPage(projectName: string, islandJson: string): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${projectName} — Presenter</title>
|
||||
<title>${escHtml(projectName)} — Presenter</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; background: #0a0a0a; overflow: hidden; }
|
||||
@@ -193,7 +193,7 @@ function buildPresentPage(projectName: string, islandJson: string): string {
|
||||
</head>
|
||||
<body>
|
||||
<hyperframes-slideshow tabindex="0" sound>
|
||||
<hyperframes-player src="/composition/index.html"></hyperframes-player>
|
||||
<hyperframes-player interactive src="/composition/index.html"></hyperframes-player>
|
||||
<script type="application/hyperframes-slideshow+json">
|
||||
${islandJson}
|
||||
</script>
|
||||
@@ -241,10 +241,16 @@ ${islandJson}
|
||||
|
||||
// Mute state is owned by <hyperframes-slideshow sound>; mirror it.
|
||||
var muted = false;
|
||||
function setClipsMuted(nextMuted) {
|
||||
Object.keys(clips).forEach(function (name) {
|
||||
clips[name].muted = nextMuted;
|
||||
});
|
||||
}
|
||||
var ss = document.querySelector("hyperframes-slideshow");
|
||||
if (ss) {
|
||||
ss.addEventListener("hf-sound", function (e) {
|
||||
muted = e.detail && e.detail.muted === true;
|
||||
setClipsMuted(muted);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,3 +301,11 @@ ${islandJson}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,26 @@ describe("shouldIgnoreRequestFailure", () => {
|
||||
expect(shouldIgnoreRequestFailure("http://127.0.0.1:3000/video.mp4", "net::ERR_ABORTED")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldIgnoreRequestFailure(
|
||||
"https://www.heygenverse.com/s/50f13ccf-9002-4d80-b567-9d4c0eac30d8/raw",
|
||||
"net::ERR_ABORTED",
|
||||
"media",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-media and non-aborted failures reportable", () => {
|
||||
expect(
|
||||
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/map.png", "net::ERR_ABORTED"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreRequestFailure(
|
||||
"https://www.heygenverse.com/s/50f13ccf-9002-4d80-b567-9d4c0eac30d8/raw",
|
||||
"net::ERR_ABORTED",
|
||||
"xhr",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_FAILED"),
|
||||
).toBe(false);
|
||||
|
||||
@@ -32,8 +32,13 @@ const CONTRAST_SAMPLES = 5;
|
||||
const SEEK_SETTLE_MS = 150;
|
||||
const MEDIA_EXTENSIONS = /\.(aac|flac|m4a|mov|mp3|mp4|oga|ogg|wav|webm)$/i;
|
||||
|
||||
export function shouldIgnoreRequestFailure(url: string, errorText: string | undefined): boolean {
|
||||
export function shouldIgnoreRequestFailure(
|
||||
url: string,
|
||||
errorText: string | undefined,
|
||||
resourceType?: string,
|
||||
): boolean {
|
||||
if (errorText !== "net::ERR_ABORTED") return false;
|
||||
if (resourceType === "media") return true;
|
||||
try {
|
||||
return MEDIA_EXTENSIONS.test(new URL(url).pathname);
|
||||
} catch {
|
||||
@@ -166,7 +171,7 @@ async function validateInBrowser(
|
||||
const url = req.url();
|
||||
if (url.includes("favicon") || url.startsWith("data:")) return;
|
||||
const failureText = req.failure()?.errorText;
|
||||
if (shouldIgnoreRequestFailure(url, failureText)) return;
|
||||
if (shouldIgnoreRequestFailure(url, failureText, req.resourceType())) return;
|
||||
const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
|
||||
errors.push({
|
||||
level: "error",
|
||||
@@ -210,6 +215,74 @@ function printContrastFailures(failures: ContrastEntry[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitJsonReport(
|
||||
errors: ConsoleEntry[],
|
||||
warnings: ConsoleEntry[],
|
||||
contrast: ContrastEntry[] | undefined,
|
||||
contrastFailures: ContrastEntry[],
|
||||
): void {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
contrast,
|
||||
contrastFailures: contrastFailures.length,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function formatConsoleEntry(prefix: string, e: ConsoleEntry): string {
|
||||
return ` ${prefix} ${e.text}${e.line ? c.dim(` (line ${e.line})`) : ""}`;
|
||||
}
|
||||
|
||||
function formatTotals(
|
||||
errors: ConsoleEntry[],
|
||||
warnings: ConsoleEntry[],
|
||||
contrastFailures: ContrastEntry[],
|
||||
): string {
|
||||
const parts = [`${errors.length} error(s)`, `${warnings.length} warning(s)`];
|
||||
if (contrastFailures.length > 0) parts.push(`${contrastFailures.length} contrast warning(s)`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function emitTextReport(
|
||||
errors: ConsoleEntry[],
|
||||
warnings: ConsoleEntry[],
|
||||
contrastFailures: ContrastEntry[],
|
||||
contrastPassed: ContrastEntry[],
|
||||
): void {
|
||||
const hasIssues = errors.length > 0 || warnings.length > 0 || contrastFailures.length > 0;
|
||||
if (!hasIssues) {
|
||||
const suffix =
|
||||
contrastPassed.length > 0 ? ` · ${contrastPassed.length} text elements pass WCAG AA` : "";
|
||||
console.log(`${c.success("◇")} No console errors${suffix}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const e of errors) console.log(formatConsoleEntry(c.error("✗"), e));
|
||||
for (const w of warnings) console.log(formatConsoleEntry(c.warn("⚠"), w));
|
||||
if (contrastFailures.length > 0) printContrastFailures(contrastFailures);
|
||||
|
||||
console.log();
|
||||
console.log(`${c.accent("◇")} ${formatTotals(errors, warnings, contrastFailures)}`);
|
||||
}
|
||||
|
||||
function emitFailureReport(message: string, asJson: boolean): void {
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`${c.error("✗")} ${message}`);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "validate",
|
||||
@@ -239,73 +312,36 @@ Examples:
|
||||
const project = resolveProject(args.dir);
|
||||
const timeout = parseInt(args.timeout as string, 10) || 3000;
|
||||
const useContrast = args.contrast ?? true;
|
||||
const asJson = Boolean(args.json);
|
||||
|
||||
if (!args.json) {
|
||||
if (!asJson) {
|
||||
console.log(`${c.accent("◆")} Validating ${c.accent(project.name)} in headless Chrome`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { errors, warnings, contrast } = await validateInBrowser(project.dir, {
|
||||
timeout,
|
||||
contrast: useContrast,
|
||||
});
|
||||
|
||||
const contrastFailures = (contrast ?? []).filter((e) => !e.wcagAA);
|
||||
const contrastPassed = (contrast ?? []).filter((e) => e.wcagAA);
|
||||
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
contrast,
|
||||
contrastFailures: contrastFailures.length,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (errors.length === 0 && warnings.length === 0 && contrastFailures.length === 0) {
|
||||
const suffix =
|
||||
contrastPassed.length > 0 ? ` · ${contrastPassed.length} text elements pass WCAG AA` : "";
|
||||
console.log(`${c.success("◇")} No console errors${suffix}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const e of errors) {
|
||||
console.log(` ${c.error("✗")} ${e.text}${e.line ? c.dim(` (line ${e.line})`) : ""}`);
|
||||
}
|
||||
for (const w of warnings) {
|
||||
console.log(` ${c.warn("⚠")} ${w.text}${w.line ? c.dim(` (line ${w.line})`) : ""}`);
|
||||
}
|
||||
if (contrastFailures.length > 0) printContrastFailures(contrastFailures);
|
||||
|
||||
console.log();
|
||||
const parts = [`${errors.length} error(s)`, `${warnings.length} warning(s)`];
|
||||
if (contrastFailures.length > 0) parts.push(`${contrastFailures.length} contrast warning(s)`);
|
||||
console.log(`${c.accent("◇")} ${parts.join(", ")}`);
|
||||
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
const result = await validateInBrowser(project.dir, { timeout, contrast: useContrast });
|
||||
const exitCode = printValidationResult(result, asJson);
|
||||
process.exit(exitCode);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({ ok: false, error: message, errors: [], warnings: [] }),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`${c.error("✗")} ${message}`);
|
||||
emitFailureReport(message, asJson);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function printValidationResult(
|
||||
result: { errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] },
|
||||
asJson: boolean,
|
||||
): number {
|
||||
const { errors, warnings, contrast } = result;
|
||||
const contrastFailures = (contrast ?? []).filter((e) => !e.wcagAA);
|
||||
const contrastPassed = (contrast ?? []).filter((e) => e.wcagAA);
|
||||
|
||||
if (asJson) {
|
||||
emitJsonReport(errors, warnings, contrast, contrastFailures);
|
||||
} else {
|
||||
emitTextReport(errors, warnings, contrastFailures, contrastPassed);
|
||||
}
|
||||
return errors.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user