diff --git a/packages/adapters/src/media-motion-renderer.ts b/packages/adapters/src/media-motion-renderer.ts new file mode 100644 index 0000000..f43b1d9 --- /dev/null +++ b/packages/adapters/src/media-motion-renderer.ts @@ -0,0 +1,10 @@ +export type { + MotionFrameContext, + MotionTemplate, + ResvgMotionGraphicsRendererOptions, +} from "./media-motion/index.js"; +export { + MOTION_TEMPLATES, + MotionTemplateError, + ResvgMotionGraphicsRenderer, +} from "./media-motion/index.js"; diff --git a/packages/adapters/src/media-motion/easing.ts b/packages/adapters/src/media-motion/easing.ts new file mode 100644 index 0000000..95c42bc --- /dev/null +++ b/packages/adapters/src/media-motion/easing.ts @@ -0,0 +1,56 @@ +/** Easing and timing helpers shared by every motion template. All pure. */ + +export function clamp(value: number, min = 0, max = 1): number { + return Math.min(max, Math.max(min, value)); +} + +export function lerp(from: number, to: number, amount: number): number { + return from + (to - from) * amount; +} + +/** Linear progress 0..1 of `t` through the window [start, end]. */ +export function progress(t: number, start: number, end: number): number { + if (end <= start) return t >= end ? 1 : 0; + return clamp((t - start) / (end - start)); +} + +export function easeInCubic(x: number): number { + return x * x * x; +} + +export function easeOutCubic(x: number): number { + return 1 - (1 - x) ** 3; +} + +export function easeInOutCubic(x: number): number { + return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; +} + +export function easeOutExpo(x: number): number { + return x >= 1 ? 1 : 1 - 2 ** (-10 * x); +} + +/** Overshoots slightly before settling; for pop-in reveals. */ +export function easeOutBack(x: number): number { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2; +} + +/** + * Opacity envelope for a clip of `durationMs`: fades in over `inMs` and out + * over the last `outMs`. + */ +export function fadeEnvelope(t: number, durationMs: number, inMs: number, outMs: number): number { + const fadeIn = inMs > 0 ? easeOutCubic(progress(t, 0, inMs)) : 1; + const fadeOut = outMs > 0 ? 1 - easeInCubic(progress(t, durationMs - outMs, durationMs)) : 1; + return clamp(Math.min(fadeIn, fadeOut)); +} + +/** Deterministic pseudo-random value in [0, 1) for integer seeds. */ +export function hash01(a: number, b = 0): number { + let h = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x632be5ab, 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d); + h = Math.imul(h ^ (h >>> 12), 0x297a2d39); + return ((h ^ (h >>> 15)) >>> 0) / 4294967296; +} diff --git a/packages/adapters/src/media-motion/index.ts b/packages/adapters/src/media-motion/index.ts new file mode 100644 index 0000000..1161c6b --- /dev/null +++ b/packages/adapters/src/media-motion/index.ts @@ -0,0 +1,308 @@ +/* + * Motion graphics renderer for Media Studio (intros, outros, lower thirds, + * caption previews, end cards). + * + * Technology decision: no Remotion and no headless Chromium. Remotion needs a + * paid company license above a team size and Chromium is a heavy, hard-to- + * sandbox runtime. Instead every template is a pure TypeScript function that + * returns an SVG document for a point in time. Frames are rasterized with + * @resvg/resvg-js (MPL-2.0; resvg itself is MPL-2.0, used unmodified as a + * library, commercially usable) and piped as PNGs into ffmpeg (LGPL/GPL + * depending on the build, invoked as a separate process with an argv array), + * which encodes a QuickTime Animation (qtrle, argb) MOV with alpha. Nothing + * here needs a network or a hosted service. + */ +import type { ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { readFile, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import type { MotionGraphicsRenderer, MotionRenderRequest } from "@rakazo/adapter-kit"; +import type { BrandKit, MotionTemplateInfo, MotionTemplateRef } from "@rakazo/contracts"; +import { BrandKitSchema, MotionTemplateRefSchema } from "@rakazo/contracts"; +import { assertInside } from "@rakazo/media/node"; +import type { RenderedImage, ResvgRenderOptions } from "@resvg/resvg-js"; +import { renderAsync } from "@resvg/resvg-js"; +import type { MotionFrameContext, MotionTemplate } from "./template.js"; +import { brandFonts, MotionTemplateError, validateDuration, validateParams } from "./template.js"; +import { captionBoldSocial, captionClean, captionKaraoke } from "./templates/captions.js"; +import { endCardCta, endCardMinimal } from "./templates/end-cards.js"; +import { + introElegantFade, + introGlitch, + introKineticType, + introLogoReveal, +} from "./templates/intros.js"; +import { lowerThirdBar, lowerThirdClean, lowerThirdPill } from "./templates/lower-thirds.js"; +import { outroLogo, outroSubscribe } from "./templates/outros.js"; + +export type { MotionFrameContext, MotionTemplate } from "./template.js"; +export { MotionTemplateError } from "./template.js"; + +export const MOTION_TEMPLATES: readonly MotionTemplate[] = [ + introLogoReveal, + introKineticType, + introGlitch, + introElegantFade, + outroLogo, + outroSubscribe, + lowerThirdClean, + lowerThirdBar, + lowerThirdPill, + captionClean, + captionKaraoke, + captionBoldSocial, + endCardCta, + endCardMinimal, +]; + +export function motionTemplate(id: string): MotionTemplate { + const template = MOTION_TEMPLATES.find((entry) => entry.info.id === id); + if (!template) throw new MotionTemplateError("unknown_template", `unknown template "${id}"`); + return template; +} + +export const MIN_FRAME_SIDE = 16; +export const MAX_FRAME_SIDE = 3840; +export const MAX_FPS = 60; +/** Default cap on width × height × frames: ten seconds of 1080p60. */ +export const DEFAULT_MAX_PIXEL_FRAMES = 1920 * 1080 * 60 * 10; +const MAX_LOGO_BYTES = 5 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc"]); + +/** + * Read a logo as a data URI. Only PNG and SVG are accepted, and an SVG may not + * declare entities or reference anything outside itself. + */ +export async function loadLogoDataUri(file: string): Promise { + const extension = path.extname(file).toLowerCase(); + if (extension !== ".png" && extension !== ".svg") + throw new MotionTemplateError("invalid_asset", "logos must be PNG or SVG"); + const info = await stat(file); + if (!info.isFile() || info.size > MAX_LOGO_BYTES) + throw new MotionTemplateError("invalid_asset", "logo is not a file or is too large"); + const bytes = await readFile(file); + if (extension === ".png") { + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) + throw new MotionTemplateError("invalid_asset", "logo is not a PNG"); + return `data:image/png;base64,${bytes.toString("base64")}`; + } + const text = bytes.toString("utf8"); + if (!/]/i.test(text)) + throw new MotionTemplateError("invalid_asset", "logo is not an SVG"); + if (/ MAX_FRAME_SIDE) + throw new MotionTemplateError( + "invalid_size", + `frame sides must be ${MIN_FRAME_SIDE}..${MAX_FRAME_SIDE} px`, + ); + } + return { + template, + ctx: { + width: input.width, + height: input.height, + durationMs: ref.durationMs, + kit, + params: validateParams(template.info, ref.params), + logos: input.logos, + fonts: brandFonts(kit), + }, + }; +} + +export interface RasterizeOptions { + fontFiles: string[]; + defaultFontFamily?: string | null; + /** Fall back to system fonts for missing families and glyphs. */ + loadSystemFonts: boolean; +} + +export function rasterizeFrame(svg: string, options: RasterizeOptions): Promise { + const resvgOptions: ResvgRenderOptions = { + font: { + loadSystemFonts: options.loadSystemFonts, + fontFiles: options.fontFiles, + ...(options.defaultFontFamily ? { defaultFontFamily: options.defaultFontFamily } : {}), + }, + logLevel: "off", + }; + return renderAsync(svg, resvgOptions); +} + +export function frameCount(durationMs: number, fps: number): number { + return Math.max(1, Math.round((durationMs * fps) / 1000)); +} + +function abortError(): Error { + const error = new Error("motion render aborted"); + error.name = "AbortError"; + return error; +} + +export interface ResvgMotionGraphicsRendererOptions { + ffmpegPath?: string; + /** When set, every asset path and the output path must lie inside it. */ + root?: string; + maxPixelFrames?: number; +} + +export class ResvgMotionGraphicsRenderer implements MotionGraphicsRenderer { + readonly id = "resvg"; + private readonly ffmpegPath: string; + private readonly maxPixelFrames: number; + + constructor(private readonly options: ResvgMotionGraphicsRendererOptions = {}) { + this.ffmpegPath = options.ffmpegPath ?? "ffmpeg"; + this.maxPixelFrames = options.maxPixelFrames ?? DEFAULT_MAX_PIXEL_FRAMES; + } + + templates(): MotionTemplateInfo[] { + return MOTION_TEMPLATES.map((template) => structuredClone(template.info)); + } + + async render(request: MotionRenderRequest): Promise<{ path: string; hasAlpha: boolean }> { + const { signal } = request; + if (signal?.aborted) throw abortError(); + if (!Number.isFinite(request.fps) || request.fps < 1 || request.fps > MAX_FPS) + throw new MotionTemplateError("invalid_size", `fps must be 1..${MAX_FPS}`); + if (!path.isAbsolute(request.outPath) || path.extname(request.outPath).toLowerCase() !== ".mov") + throw new MotionTemplateError("invalid_asset", "outPath must be an absolute .mov path"); + + const outPath = await this.checkedPath(request.outPath); + const fontFiles: string[] = []; + for (const font of request.assets.fonts) { + const file = await this.checkedPath(font.path); + if (!FONT_EXTENSIONS.has(path.extname(file).toLowerCase()) || !(await stat(file)).isFile()) + throw new MotionTemplateError("invalid_asset", "fonts must be TTF, OTF, or TTC files"); + fontFiles.push(file); + } + const logos: string[] = []; + for (const logo of request.assets.logoPaths) { + logos.push(await loadLogoDataUri(await this.checkedPath(logo))); + } + + const { template, ctx } = buildFrameContext({ + template: request.template, + kit: request.brandKit, + width: request.width, + height: request.height, + logos, + }); + const frames = frameCount(ctx.durationMs, request.fps); + if (ctx.width * ctx.height * frames > this.maxPixelFrames) + throw new MotionTemplateError("invalid_size", "motion clip exceeds the pixel budget"); + + const raster: RasterizeOptions = { + fontFiles, + defaultFontFamily: ctx.fonts.body, + loadSystemFonts: true, + }; + const partial = path.join( + path.dirname(outPath), + `.${path.basename(outPath, ".mov")}.${randomUUID()}.partial.mov`, + ); + const child = spawn( + this.ffmpegPath, + [ + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-f", + "image2pipe", + "-framerate", + String(request.fps), + "-c:v", + "png", + "-i", + "pipe:0", + "-an", + "-c:v", + "qtrle", + "-pix_fmt", + "argb", + "-f", + "mov", + partial, + ], + { stdio: ["pipe", "ignore", "pipe"], shell: false }, + ); + const exited = waitForExit(child); + const kill = () => child.kill("SIGKILL"); + signal?.addEventListener("abort", kill, { once: true }); + try { + const stdin = child.stdin!; + stdin.on("error", () => {}); + for (let index = 0; index < frames; index += 1) { + if (signal?.aborted) throw abortError(); + const svg = template.frame((index * 1000) / request.fps, ctx); + const png = (await rasterizeFrame(svg, raster)).asPng(); + if (signal?.aborted) throw abortError(); + if (!stdin.write(png)) { + const result = await Promise.race([once(stdin, "drain").then(() => null), exited]); + if (result) throw ffmpegError(result); + } + } + stdin.end(); + const result = await exited; + if (signal?.aborted) throw abortError(); + if (result.code !== 0) throw ffmpegError(result); + await rename(partial, outPath); + return { path: outPath, hasAlpha: true }; + } catch (error) { + kill(); + await exited; + await rm(partial, { force: true }); + throw signal?.aborted ? abortError() : error; + } finally { + signal?.removeEventListener("abort", kill); + } + } + + private async checkedPath(file: string): Promise { + if (!path.isAbsolute(file) || file.includes("\0")) + throw new MotionTemplateError("invalid_asset", "asset paths must be absolute"); + return this.options.root ? assertInside(this.options.root, file) : path.resolve(file); + } +} + +interface ExitResult { + code: number | null; + stderr: string; +} + +function waitForExit(child: ChildProcess): Promise { + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr = (stderr + chunk).slice(-4000); + }); + return new Promise((resolve) => { + child.once("error", (error) => resolve({ code: -1, stderr: error.message })); + child.once("close", (code) => resolve({ code, stderr })); + }); +} + +function ffmpegError(result: ExitResult): Error { + return new Error(`ffmpeg failed (${result.code}): ${result.stderr.trim()}`); +} diff --git a/packages/adapters/src/media-motion/media-motion.test.ts b/packages/adapters/src/media-motion/media-motion.test.ts new file mode 100644 index 0000000..edb530a --- /dev/null +++ b/packages/adapters/src/media-motion/media-motion.test.ts @@ -0,0 +1,441 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { BrandKit, MotionTemplateRef } from "@rakazo/contracts"; +import { MotionTemplateInfoSchema } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { MotionTemplate } from "./index.js"; +import { + buildFrameContext, + loadLogoDataUri, + MOTION_TEMPLATES, + MotionTemplateError, + motionTemplate, + ResvgMotionGraphicsRenderer, + rasterizeFrame, +} from "./index.js"; +import { GLITCH_BURST_INTERVAL_MS } from "./templates/intros.js"; + +const DEJAVU_DIR = "/usr/share/fonts/truetype/dejavu"; +const DEJAVU = ["DejaVuSans.ttf", "DejaVuSans-Bold.ttf", "DejaVuSerif.ttf"].map((name) => + path.join(DEJAVU_DIR, name), +); +const hasDejaVu = DEJAVU.every((file) => existsSync(file)); +const hasFfmpeg = (() => { + try { + execFileSync("ffmpeg", ["-hide_banner", "-encoders"], { stdio: "pipe" }); + execFileSync("ffprobe", ["-version"], { stdio: "pipe" }); + return true; + } catch { + return false; + } +})(); + +const LOGO_SVG = + '' + + '' + + ''; + +const kit: BrandKit = { + name: "Test Brand", + colors: { + primary: "#2255cc", + secondary: "#9999aa", + accent: "#ff8800", + text: "#ffffff", + background: "#101820", + }, + fonts: [{ family: "DejaVu Sans", artifactId: "font-1", role: "heading" }], + logoArtifactIds: ["logo-1"], + captionPresets: [], + musicBeds: [], +}; + +/** Parameters that exercise every template with all its text slots filled. */ +const SAMPLE_PARAMS: Record = { + intro_logo_reveal: { tagline: "Make it simple" }, + intro_kinetic_type: { title: "Bold ideas move fast", tagline: "Since 2026" }, + intro_glitch: { tagline: "Energy" }, + intro_elegant_fade: { tagline: "Crafted slowly" }, + outro_logo: { message: "Thanks" }, + outro_subscribe: { cta: "Subscribe", handle: "@example" }, + lt_clean: { title: "Alex Example", subtitle: "Host" }, + lt_bar: { title: "Alex Example", subtitle: "Host", barColor: "accent" }, + lt_pill: { title: "Alex Example", subtitle: "Host" }, + caption_clean: { text: "A caption in the clean style" }, + caption_karaoke: { text: "A caption in the karaoke style" }, + caption_bold_social: { text: "Bold social caption" }, + endcard_cta: { headline: "Get the full video", cta: "Watch now", price: "Only 9 today" }, + endcard_minimal: { url: "example.com" }, +}; + +let dir: string; +let logoPath: string; +let logo: string; + +beforeAll(async () => { + dir = await mkdtemp(path.join(tmpdir(), "rakazo-motion-")); + logoPath = path.join(dir, "logo.svg"); + await writeFile(logoPath, LOGO_SVG); + logo = await loadLogoDataUri(logoPath); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function context( + template: MotionTemplate, + overrides: { + params?: MotionTemplateRef["params"]; + durationMs?: number; + size?: [number, number]; + } = {}, +) { + const [width, height] = overrides.size ?? [640, 360]; + return buildFrameContext({ + template: { + templateId: template.info.id, + params: overrides.params ?? SAMPLE_PARAMS[template.info.id] ?? {}, + durationMs: overrides.durationMs ?? template.info.minDurationMs, + }, + kit, + width, + height, + logos: [logo], + }).ctx; +} + +const raster = { fontFiles: DEJAVU, defaultFontFamily: "DejaVu Sans", loadSystemFonts: false }; + +/** Mean relative luminance of an RGBA frame composited over a solid backdrop. */ +function meanLuminance(pixels: Buffer, backdrop: 0 | 1): number { + let sum = 0; + const count = pixels.length / 4; + for (let index = 0; index < pixels.length; index += 4) { + const alpha = pixels[index + 3]! / 255; + const channel = (offset: number) => { + const value = (pixels[index + offset]! / 255) * alpha + backdrop * (1 - alpha); + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + sum += 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2); + } + return sum / count; +} + +/** + * Count brightness reversals of at least 10% relative luminance (the WCAG 2.3.1 + * flash threshold) per one-second window and return the worst window. A flash + * is a pair of opposing changes, so a steady fade counts as none; counting each + * reversal on its own is stricter than WCAG's three flashes per second. + */ +function maxReversalsPerSecond(series: number[], fps: number): number { + const transitions: number[] = []; + let direction = 0; + let extreme = series[0] ?? 0; + series.forEach((value, index) => { + const delta = value - extreme; + if (direction !== 0 && Math.sign(delta) === direction) { + extreme = value; + } else if (Math.abs(delta) >= 0.1) { + if (direction !== 0) transitions.push(index / fps); + direction = Math.sign(delta); + extreme = value; + } + }); + let worst = 0; + for (const start of transitions) { + worst = Math.max(worst, transitions.filter((at) => at >= start && at < start + 1).length); + } + return worst; +} + +describe("motion templates", () => { + it("ships the required set with valid, unique descriptors", () => { + const ids = MOTION_TEMPLATES.map((template) => template.info.id); + expect(new Set(ids).size).toBe(ids.length); + for (const template of MOTION_TEMPLATES) MotionTemplateInfoSchema.parse(template.info); + const count = (kind: string) => + MOTION_TEMPLATES.filter((template) => template.info.kind === kind).length; + expect([ + count("intro"), + count("outro"), + count("lower_third"), + count("caption"), + count("end_card"), + ]).toEqual([4, 2, 3, 3, 2]); + }); + + it.each(MOTION_TEMPLATES.map((template) => [template.info.id, template] as const))( + "%s renders its first, middle and last frame", + async (_id, template) => { + const ctx = context(template); + for (const t of [0, ctx.durationMs / 2, ctx.durationMs]) { + const svg = template.frame(t, ctx); + expect(svg.startsWith(" { + const allowed = new Set(Object.values(kit.colors).map((color) => color.toLowerCase())); + for (const template of MOTION_TEMPLATES) { + const ctx = context(template); + const svg = template.frame(ctx.durationMs / 2, ctx); + const colors = [...svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)].map((match) => match[1]!); + for (const color of colors) { + if (color === "none" || color.startsWith("url(")) continue; + expect(allowed.has(color.toLowerCase()), `${template.info.id} uses ${color}`).toBe(true); + } + } + }); + + it.skipIf(!hasDejaVu)("renders deterministic middle frames", async () => { + const hashes: Record = {}; + for (const template of MOTION_TEMPLATES) { + const ctx = context(template); + const image = await rasterizeFrame(template.frame(ctx.durationMs / 2, ctx), raster); + hashes[template.info.id] = createHash("sha256") + .update(image.pixels) + .digest("hex") + .slice(0, 16); + } + expect(hashes).toMatchInlineSnapshot(` + { + "caption_bold_social": "4a132766b59cdf12", + "caption_clean": "6194d5224d8e97ec", + "caption_karaoke": "f2db9bc313e4ccea", + "endcard_cta": "943fa754c9a30803", + "endcard_minimal": "be2b9f833895bc36", + "intro_elegant_fade": "8f8a98930994243f", + "intro_glitch": "35074c7ad30d26bb", + "intro_kinetic_type": "52129b6c4f5581e3", + "intro_logo_reveal": "2baf101db044941b", + "lt_bar": "a13184996d2ffc2e", + "lt_clean": "ca6a4239283e111e", + "lt_pill": "30a7342e49735d69", + "outro_logo": "84b1cd23213f8a2e", + "outro_subscribe": "18733abc1b0f1296", + } + `); + }); + + it("never changes full-frame brightness more than three times a second in the glitch intro", async () => { + const template = motionTemplate("intro_glitch"); + const ctx = context(template, { durationMs: 4000, size: [160, 90] }); + const fps = 25; + const dark: number[] = []; + const light: number[] = []; + for (let index = 0; index <= (ctx.durationMs * fps) / 1000; index += 1) { + const image = await rasterizeFrame(template.frame((index * 1000) / fps, ctx), { + fontFiles: [], + loadSystemFonts: true, + }); + dark.push(meanLuminance(image.pixels, 0)); + light.push(meanLuminance(image.pixels, 1)); + } + expect(1000 / GLITCH_BURST_INTERVAL_MS).toBeLessThanOrEqual(3); + expect(maxReversalsPerSecond(dark, fps)).toBeLessThanOrEqual(3); + expect(maxReversalsPerSecond(light, fps)).toBeLessThanOrEqual(3); + }); + + it("detects a frame that does flash too fast", () => { + const flashing = Array.from({ length: 50 }, (_, index) => (index % 5 < 2 ? 0.9 : 0.05)); + expect(maxReversalsPerSecond(flashing, 50)).toBeGreaterThan(3); + }); +}); + +describe("motion params and escaping", () => { + const lowerThird = motionTemplate("lt_clean"); + + it.each([ + [{ title: "x", extra: "y" }, "unknown_param"], + [{ title: 3 }, "invalid_param"], + [{ title: "a\u0007b" }, "invalid_param"], + [{ subtitle: "only" }, "missing_param"], + ] as const)("rejects %j", (params, code) => { + expect(() => context(lowerThird, { params })).toThrow( + expect.objectContaining({ code }) as unknown as Error, + ); + }); + + it("rejects oversized strings, unknown colors, durations, and frame sizes", () => { + expect(() => context(lowerThird, { params: { title: "x".repeat(201) } })).toThrow(); + expect(() => + context(motionTemplate("lt_bar"), { params: { title: "x", barColor: "#ff0000" } }), + ).toThrow(MotionTemplateError); + expect(() => context(lowerThird, { durationMs: 60_000 })).toThrow(MotionTemplateError); + expect(() => context(lowerThird, { size: [8000, 360] })).toThrow(MotionTemplateError); + expect(() => + buildFrameContext({ + template: { templateId: "nope", params: {}, durationMs: 2000 }, + kit, + width: 640, + height: 360, + logos: [], + }), + ).toThrow(MotionTemplateError); + }); + + it("escapes text and keeps hostile input out of the markup", async () => { + const hostile = ` & 'quotes' ]]>