test(media): cover marketing plans, brand kits, bundles, publishing and learning
This commit is contained in:
parent
872fdd2194
commit
03e4961c9f
@ -149,16 +149,30 @@ export async function generateCopy(
|
||||
.filter((chapter) => chapter.atMs < durationMs && chapter.title.trim())
|
||||
.sort((a, b) => a.atMs - b.atMs)
|
||||
: [];
|
||||
const hook = truncateText(draft.hook, 200);
|
||||
const description = draft.description ? truncateText(draft.description, 5000) : undefined;
|
||||
const altText = draft.altText?.trim() ? truncateText(draft.altText, 500) : undefined;
|
||||
// The other fields get the same banned-pattern and link checks as the caption.
|
||||
const extraIssues = checkCopy(profile, {
|
||||
title: null,
|
||||
caption: [hook, description, altText].filter(Boolean).join("\n"),
|
||||
tags: [],
|
||||
})
|
||||
.filter((issue) => issue.code === "banned_pattern" || issue.code.startsWith("link_"))
|
||||
.map((issue) => ({
|
||||
...issue,
|
||||
message: issue.message.replace(/^caption/, "hook or description"),
|
||||
}));
|
||||
return MarketingCopySchema.parse({
|
||||
profileId: profile.id,
|
||||
language,
|
||||
title,
|
||||
hook: truncateText(draft.hook, 200),
|
||||
hook,
|
||||
caption,
|
||||
hashtags,
|
||||
...(draft.description ? { description: truncateText(draft.description, 5000) } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(chapterList.length > 0 ? { chapters: chapterList } : {}),
|
||||
...(draft.altText?.trim() ? { altText: truncateText(draft.altText, 500) } : {}),
|
||||
issues: checkCopy(profile, { title, caption, tags: hashtags }),
|
||||
...(altText ? { altText } : {}),
|
||||
issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues],
|
||||
});
|
||||
}
|
||||
|
||||
941
packages/adapters/src/media-marketing.postgres.test.ts
Normal file
941
packages/adapters/src/media-marketing.postgres.test.ts
Normal file
@ -0,0 +1,941 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { BackgroundJob, JobPublisher, MediaRenderer } from "@rakazo/adapter-kit";
|
||||
import type { BrandKit, FfprobeReport, PlatformProfile, TeaserWeights } from "@rakazo/contracts";
|
||||
import { PlatformProfileSchema, TEASER_FEATURES } from "@rakazo/contracts";
|
||||
import type { MediaRepos, PrismaClient } from "@rakazo/db";
|
||||
import { bootstrapUserSpace, createDb, createMediaRepos } from "@rakazo/db";
|
||||
import type { ProfileSet } from "@rakazo/media";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { LocalArtifactStore } from "./artifacts.js";
|
||||
import { isCubeLut, saveBrandKit } from "./media-brand.js";
|
||||
import type {
|
||||
MediaActor,
|
||||
MediaModelClient,
|
||||
MediaRealtimeEvent,
|
||||
MediaServiceDeps,
|
||||
} from "./media-context.js";
|
||||
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
|
||||
import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js";
|
||||
import { storeMediaArtifact } from "./media-marketing-files.js";
|
||||
import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.js";
|
||||
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
|
||||
import { bundleRelativeDir } from "./media-publish.js";
|
||||
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
|
||||
|
||||
/*
|
||||
* Marketing slice against real repositories: plans with the teaser judge,
|
||||
* brand kits and intro variants, thumbnails, publications, bundles, the
|
||||
* scheduled publish job, and results import with teaser learning. All media is
|
||||
* synthetic (ffmpeg testsrc and sine); the model is a recording fake.
|
||||
*/
|
||||
|
||||
const run = promisify(execFile);
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
function onPath(bin: string): boolean {
|
||||
try {
|
||||
execFileSync(bin, ["-version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const hasFfmpeg = onPath("ffmpeg") && onPath("ffprobe");
|
||||
|
||||
async function ffmpeg(args: string[]): Promise<void> {
|
||||
await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]);
|
||||
}
|
||||
|
||||
function profile(overrides: Record<string, unknown>): PlatformProfile {
|
||||
return PlatformProfileSchema.parse({
|
||||
id: "example-short-vertical",
|
||||
name: "Example short vertical",
|
||||
kind: "free_social",
|
||||
reviewedAt: "2026-09-01",
|
||||
video: {
|
||||
aspectRatios: ["9:16"],
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
codec: "h264",
|
||||
maxBitrateKbps: 8000,
|
||||
maxFileSizeMb: 500,
|
||||
},
|
||||
duration: { hardMinMs: 1000, hardMaxMs: 60_000, recommended: [7000, 15_000, 30_000] },
|
||||
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
|
||||
captions: { recommended: true, maxLines: 2 },
|
||||
audio: {},
|
||||
text: { titleMaxChars: 100, captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only" },
|
||||
content: { thresholds: { explicit_nudity: 0.2 } },
|
||||
disclosure: { aiAlteredLabelRequired: true },
|
||||
consent: { requirePerformerConsent: false, requireAgeVerification: false },
|
||||
export: {},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const SHORT = profile({});
|
||||
const LANDSCAPE = profile({
|
||||
id: "example-landscape",
|
||||
name: "Example landscape",
|
||||
video: {
|
||||
aspectRatios: ["16:9"],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
codec: "h264",
|
||||
maxBitrateKbps: 12_000,
|
||||
maxFileSizeMb: 2000,
|
||||
},
|
||||
duration: { hardMinMs: 1000, hardMaxMs: 600_000, recommended: [30_000], hookWindowMs: 5000 },
|
||||
text: { titleMaxChars: 100, captionMaxChars: 5000, hashtagLimit: 15, linkPolicy: "allowed" },
|
||||
});
|
||||
const PAID = profile({
|
||||
id: "example-paid-video",
|
||||
name: "Example paid video",
|
||||
kind: "paid_video",
|
||||
video: LANDSCAPE.video,
|
||||
duration: { hardMinMs: 1000, hardMaxMs: 3_600_000, recommended: [] },
|
||||
text: { captionMaxChars: 2000, hashtagLimit: 5, linkPolicy: "forbidden" },
|
||||
disclosure: { aiAlteredLabelRequired: true, ageRestrictedFlag: true },
|
||||
consent: { requirePerformerConsent: true, requireAgeVerification: true },
|
||||
export: { previewClipMs: 2000 },
|
||||
});
|
||||
|
||||
const profileSet: ProfileSet = {
|
||||
profiles: new Map(
|
||||
[SHORT, LANDSCAPE, PAID].map((p) => [p.id, { profile: p, origin: "builtin" as const }]),
|
||||
),
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const KIT: BrandKit = {
|
||||
name: "Lake Studio",
|
||||
colors: {
|
||||
primary: "#1a1a1a",
|
||||
secondary: "#333333",
|
||||
accent: "#777777",
|
||||
text: "#f5f5f5",
|
||||
background: "#0a0a0a",
|
||||
},
|
||||
fonts: [],
|
||||
logoArtifactIds: [],
|
||||
captionPresets: [],
|
||||
musicBeds: [],
|
||||
tone: "calm and premium",
|
||||
intro: { templateId: "intro_logo_reveal", params: {}, durationMs: 2500 },
|
||||
};
|
||||
|
||||
function gatePassed(profileId: string) {
|
||||
return {
|
||||
profileId,
|
||||
passed: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
sampledFrames: 8,
|
||||
issues: [],
|
||||
autoFixRound: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describePostgres("media marketing on Postgres", () => {
|
||||
let prisma: PrismaClient;
|
||||
let pool: { end(): Promise<void> };
|
||||
let repos: MediaRepos;
|
||||
let root: string;
|
||||
let mediaRoot: string;
|
||||
const userIds: string[] = [];
|
||||
const jobs: BackgroundJob[] = [];
|
||||
const canceledJobs: string[] = [];
|
||||
const events: MediaRealtimeEvent[] = [];
|
||||
let modelReply: ((input: Parameters<MediaModelClient["generate"]>[0]) => unknown) | null = null;
|
||||
const modelCalls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
|
||||
let deps: MediaServiceDeps;
|
||||
|
||||
async function newActor(): Promise<MediaActor> {
|
||||
const id = `mk${randomUUID().replaceAll("-", "")}`;
|
||||
userIds.push(id);
|
||||
await prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
|
||||
const { spaceId } = await bootstrapUserSpace(
|
||||
prisma,
|
||||
{ id },
|
||||
{ signupsEnabled: "true", signupAllowlist: "" },
|
||||
{ claimDeploymentOwner: false },
|
||||
);
|
||||
return { spaceId, userId: id };
|
||||
}
|
||||
|
||||
const renderer: MediaRenderer = {
|
||||
id: "test-ffmpeg",
|
||||
hardwareEncoders: async () => [],
|
||||
render: async () => {
|
||||
throw new Error("not used by the marketing slice");
|
||||
},
|
||||
probe: async () => ({ durationMs: 4000 }) as FfprobeReport,
|
||||
async extractFrame(input) {
|
||||
await ffmpeg([
|
||||
"-ss",
|
||||
(input.tMs / 1000).toFixed(3),
|
||||
"-i",
|
||||
input.path,
|
||||
"-frames:v",
|
||||
"1",
|
||||
...(input.width ? ["-vf", `scale=${input.width}:-2`] : []),
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
input.outPath,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = createDb(databaseUrl as string, { poolMax: 4 });
|
||||
prisma = db.prisma;
|
||||
pool = db.pool;
|
||||
repos = createMediaRepos(prisma);
|
||||
root = await mkdtemp(path.join(tmpdir(), "media-marketing-"));
|
||||
mediaRoot = path.join(root, "media");
|
||||
await mkdir(mediaRoot, { recursive: true });
|
||||
const jobPublisher: JobPublisher = {
|
||||
enqueue: async (job) => {
|
||||
jobs.push(job);
|
||||
},
|
||||
cancel: async (key) => {
|
||||
canceledJobs.push(key);
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
deps = {
|
||||
prisma,
|
||||
repos,
|
||||
jobs: jobPublisher,
|
||||
inference: new EmulatorMediaInferenceProvider({ root: mediaRoot }),
|
||||
renderer,
|
||||
motion: new ResvgMotionGraphicsRenderer({ root: mediaRoot }),
|
||||
publisher: new BundleMediaPublisher(),
|
||||
shortener: null,
|
||||
sources: {},
|
||||
mediaRoot,
|
||||
profiles: async () => profileSet,
|
||||
artifacts: new LocalArtifactStore(path.join(root, "artifacts")),
|
||||
model: {
|
||||
async generate(input) {
|
||||
modelCalls.push(input);
|
||||
return modelReply ? input.schema.parse(modelReply(input)) : null;
|
||||
},
|
||||
},
|
||||
publish: async (_spaceId, event) => {
|
||||
events.push(event);
|
||||
},
|
||||
now: () => new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!prisma) return;
|
||||
const memberships = await prisma.member.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await prisma.organization.deleteMany({
|
||||
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A space with two 20 s landscape clips, scored segments, and a project. */
|
||||
async function seedLibrary(actor: MediaActor, options: { kit?: BrandKit } = {}) {
|
||||
const source = await repos.createSource(actor.spaceId, {
|
||||
kind: "folder",
|
||||
label: "Footage",
|
||||
ownerUserId: actor.userId,
|
||||
config: { path: "/media/footage" },
|
||||
});
|
||||
const item = (index: number) => ({
|
||||
externalId: `clip-${index}.mp4`,
|
||||
kind: "video" as const,
|
||||
capturedAt: new Date(Date.UTC(2026, 0, 1, 8, index)),
|
||||
durationMs: 20_000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
gpsLat: null,
|
||||
gpsLon: null,
|
||||
deviceModel: null,
|
||||
albumNames: [],
|
||||
isFavorite: false,
|
||||
fileName: `clip-${index}.mp4`,
|
||||
sizeBytes: 1000,
|
||||
revision: "r1",
|
||||
});
|
||||
const synced = await repos.upsertSourceItems(actor.spaceId, source.id, [item(1), item(2)]);
|
||||
const [assetA, assetB] = synced.changedAssetIds as [string, string];
|
||||
const scores = (motion: number, speechHook = 0.2) => ({
|
||||
motion,
|
||||
sharpness: 0.7,
|
||||
faces: 0.2,
|
||||
audioPeak: 0.4,
|
||||
speechHook,
|
||||
novelty: 0.5,
|
||||
aesthetics: 0.6,
|
||||
});
|
||||
await repos.replaceSegments(actor.spaceId, assetA, 1, [
|
||||
{
|
||||
kind: "shot",
|
||||
startMs: 0,
|
||||
endMs: 3000,
|
||||
scores: scores(0.9, 0.9),
|
||||
transcriptExcerpt: "Did you know these 3 tricks?",
|
||||
},
|
||||
{ kind: "shot", startMs: 3000, endMs: 8000, scores: scores(0.6) },
|
||||
{ kind: "shot", startMs: 8000, endMs: 12_000, scores: scores(1, 1), paidOnly: true },
|
||||
{ kind: "shot", startMs: 12_000, endMs: 16_000, scores: scores(0.95) },
|
||||
{ kind: "shot", startMs: 16_000, endMs: 20_000, scores: scores(0.5) },
|
||||
]);
|
||||
await repos.replaceSegments(actor.spaceId, assetB, 1, [
|
||||
{ kind: "shot", startMs: 0, endMs: 5000, scores: scores(0.85) },
|
||||
{ kind: "shot", startMs: 5000, endMs: 10_000, scores: scores(0.4) },
|
||||
]);
|
||||
// Frame 12.5 s of clip A exceeds the short profile's nudity threshold.
|
||||
const frames = Array.from({ length: 20 }, (_, i) => ({
|
||||
tMs: i * 1000 + 500,
|
||||
scores: { explicit_nudity: i === 12 ? 0.9 : 0.01 },
|
||||
}));
|
||||
for (const assetId of [assetA, assetB]) {
|
||||
await repos.upsertAnalysis(actor.spaceId, assetId, {
|
||||
kind: "classifier",
|
||||
modelId: "emulator",
|
||||
modelVersion: "1",
|
||||
data: { frames },
|
||||
});
|
||||
}
|
||||
// A treated tattoo on clip B keeps every frame of it away from the model.
|
||||
await repos.createMark(actor.spaceId, {
|
||||
kind: "tattoo",
|
||||
label: "arm",
|
||||
personId: null,
|
||||
assetId: assetB,
|
||||
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
|
||||
treatment: "blur",
|
||||
});
|
||||
if (hasFfmpeg) {
|
||||
for (const assetId of [assetA, assetB]) {
|
||||
const relative = path.join(actor.spaceId, assetId, "proxy.mp4");
|
||||
await mkdir(path.join(mediaRoot, path.dirname(relative)), { recursive: true });
|
||||
await ffmpeg([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=160x90:r=10:d=20",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
path.join(mediaRoot, relative),
|
||||
]);
|
||||
await repos.recordProxy(actor.spaceId, assetId, {
|
||||
contentHash: assetId.padEnd(64, "0").slice(0, 64),
|
||||
proxyPath: relative,
|
||||
thumbPath: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const kit = options.kit ? (await saveBrandKit(deps, actor, { kit: options.kit })).record : null;
|
||||
const project = await repos.createProject(actor.spaceId, actor.userId, {
|
||||
title: "Lake Day Launch",
|
||||
goal: "teaser",
|
||||
profileIds: [SHORT.id, LANDSCAPE.id],
|
||||
assetIds: [assetA, assetB],
|
||||
brandKitId: kit?.id ?? null,
|
||||
});
|
||||
const segments = await repos.listSegments(actor.spaceId, [assetA, assetB]);
|
||||
return { assetA, assetB, project, segments };
|
||||
}
|
||||
|
||||
describe("edit plans", () => {
|
||||
it("puts the hook first on shorts, keeps paid-only segments out, and ends on a CTA", async () => {
|
||||
const actor = await newActor();
|
||||
const { project, segments, assetA } = await seedLibrary(actor, { kit: KIT });
|
||||
modelReply = null;
|
||||
const marketing = createMediaMarketingService(deps);
|
||||
const plans = await marketing.autoPlan(actor, { projectId: project.id });
|
||||
expect(plans.map((plan) => plan.profileId).sort()).toEqual([LANDSCAPE.id, SHORT.id]);
|
||||
const short = plans.find((plan) => plan.profileId === SHORT.id)!;
|
||||
const landscape = plans.find((plan) => plan.profileId === LANDSCAPE.id)!;
|
||||
|
||||
// Shorts: hook before branding; the long-form profile may open with the intro.
|
||||
expect(short.edl.intro).toBeUndefined();
|
||||
expect(landscape.edl.intro?.templateId).toBe("intro_logo_reveal");
|
||||
const firstShort = [...short.edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0]!;
|
||||
expect(firstShort.atMs).toBeLessThan(SHORT.duration.hookWindowMs);
|
||||
expect(short.rationale).toContain("Brand intro left out so the hook comes first.");
|
||||
|
||||
const paid = segments.find((segment) => segment.paidOnly)!;
|
||||
for (const plan of plans) {
|
||||
for (const clip of plan.edl.tracks.video) {
|
||||
expect(clip.segmentId).not.toBe(paid.id);
|
||||
if (clip.assetId === assetA) {
|
||||
expect(clip.outMs <= paid.startMs || clip.inMs >= paid.endMs).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cta = short.edl.overlays.find((overlay) => overlay.kind === "cta_end_card");
|
||||
expect(cta && "text" in cta ? cta.text : null).toBe("Link in bio");
|
||||
expect(short.rationale).toContain("Default teaser weights.");
|
||||
});
|
||||
|
||||
it("sends only gate-safe candidates to the model and applies its ranking", async () => {
|
||||
if (!hasFfmpeg) return;
|
||||
const actor = await newActor();
|
||||
const { project, segments, assetB } = await seedLibrary(actor);
|
||||
modelCalls.length = 0;
|
||||
modelReply = (input) => {
|
||||
const ids = [...input.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
|
||||
return {
|
||||
ranking: ids.reverse().map((segmentId) => ({ segmentId, reason: "clear opener" })),
|
||||
};
|
||||
};
|
||||
const plans = await createMediaMarketingService(deps).autoPlan(actor, {
|
||||
projectId: project.id,
|
||||
profileIds: [SHORT.id],
|
||||
});
|
||||
modelReply = null;
|
||||
expect(modelCalls).toHaveLength(1);
|
||||
const call = modelCalls[0]!;
|
||||
const sentIds = [...call.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
|
||||
const byId = new Map(segments.map((segment) => [segment.id, segment]));
|
||||
expect(sentIds.length).toBeGreaterThanOrEqual(2);
|
||||
expect(call.images?.map((image) => image.name)).toEqual(sentIds.map((id) => `${id}.png`));
|
||||
for (const id of sentIds) {
|
||||
const segment = byId.get(id)!;
|
||||
expect(segment.paidOnly).toBe(false);
|
||||
expect(segment.assetId).not.toBe(assetB);
|
||||
expect(segment.startMs).not.toBe(12_000);
|
||||
}
|
||||
expect(call.prompt).not.toContain(actor.userId);
|
||||
expect(plans[0]?.rationale).toContain("Model ranking applied");
|
||||
});
|
||||
|
||||
it("uses learned weights only from 20 publications and validates submitted plans", async () => {
|
||||
const actor = await newActor();
|
||||
const { project, assetA } = await seedLibrary(actor);
|
||||
const marketing = createMediaMarketingService(deps);
|
||||
const weights = Object.fromEntries(TEASER_FEATURES.map((f) => [f, 1])) as TeaserWeights;
|
||||
await repos.putTeaserModel(actor.spaceId, weights, 5);
|
||||
const [few] = await marketing.autoPlan(actor, {
|
||||
projectId: project.id,
|
||||
profileIds: [SHORT.id],
|
||||
});
|
||||
expect(few?.rationale).toContain("Default teaser weights.");
|
||||
await repos.putTeaserModel(actor.spaceId, weights, 20);
|
||||
const [many] = await marketing.autoPlan(actor, {
|
||||
projectId: project.id,
|
||||
profileIds: [SHORT.id],
|
||||
});
|
||||
expect(many?.rationale).toContain("Weights learned");
|
||||
await resetTeaserModel(deps, actor);
|
||||
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
|
||||
|
||||
const wrongProfile = await marketing.submitPlan(actor, {
|
||||
projectId: project.id,
|
||||
edl: { ...many!.edl, output: { ...many!.edl.output, profileId: PAID.id } },
|
||||
rationale: null,
|
||||
createdBy: "agent",
|
||||
});
|
||||
expect(wrongProfile.ok).toBe(false);
|
||||
const outOfRange = await marketing.submitPlan(actor, {
|
||||
projectId: project.id,
|
||||
edl: {
|
||||
...many!.edl,
|
||||
tracks: {
|
||||
...many!.edl.tracks,
|
||||
video: [
|
||||
{ id: "c1", assetId: assetA, inMs: 0, outMs: 90_000, atMs: 0, reframe: "none" },
|
||||
],
|
||||
},
|
||||
},
|
||||
rationale: null,
|
||||
createdBy: "user",
|
||||
});
|
||||
expect(outOfRange.ok).toBe(false);
|
||||
const resubmitted = await marketing.submitPlan(actor, {
|
||||
projectId: project.id,
|
||||
edl: many!.edl,
|
||||
rationale: "kept",
|
||||
createdBy: "user",
|
||||
});
|
||||
expect(resubmitted.ok && resubmitted.plan.version).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brand kits and intros", () => {
|
||||
it("accepts only files of the space with usable types and real templates", async () => {
|
||||
const actor = await newActor();
|
||||
const other = await newActor();
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const logo = await storeMediaArtifact(deps, actor, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
bytes: new Uint8Array(png),
|
||||
});
|
||||
const lutText = `LUT_3D_SIZE 2\n${Array.from({ length: 8 }, () => "0 0.5 1").join("\n")}\n`;
|
||||
expect(isCubeLut(lutText)).toBe(true);
|
||||
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 0\n")).toBe(false);
|
||||
const lut = await storeMediaArtifact(deps, actor, {
|
||||
name: "look.cube",
|
||||
mimeType: "text/plain",
|
||||
bytes: new TextEncoder().encode(lutText),
|
||||
});
|
||||
const foreign = await storeMediaArtifact(deps, other, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
bytes: new Uint8Array(png),
|
||||
});
|
||||
|
||||
const saved = await saveBrandKit(deps, actor, {
|
||||
kit: {
|
||||
...KIT,
|
||||
logoArtifactIds: [logo],
|
||||
lutArtifactId: lut,
|
||||
colors: { ...KIT.colors, text: "#111111" },
|
||||
},
|
||||
});
|
||||
expect(saved.record.kit.logoArtifactIds).toEqual([logo]);
|
||||
expect(saved.warnings.some((warning) => warning.includes("contrast"))).toBe(true);
|
||||
|
||||
await expect(
|
||||
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [foreign] } }),
|
||||
).rejects.toThrow(/not found in this space/);
|
||||
await expect(
|
||||
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [lut] } }),
|
||||
).rejects.toThrow(/PNG or SVG/);
|
||||
await expect(
|
||||
saveBrandKit(deps, actor, {
|
||||
kit: { ...KIT, intro: { templateId: "endcard_cta", params: {}, durationMs: 3000 } },
|
||||
}),
|
||||
).rejects.toThrow(/not a intro template/);
|
||||
await expect(
|
||||
saveBrandKit(deps, actor, {
|
||||
kit: { ...KIT, intro: { templateId: "intro_glitch", params: {}, durationMs: 5000 } },
|
||||
}),
|
||||
).rejects.toThrow(/duration/);
|
||||
});
|
||||
|
||||
it("proposes three intro variants with previews", async () => {
|
||||
const actor = await newActor();
|
||||
const { record } = await saveBrandKit(deps, actor, { kit: KIT });
|
||||
const variants = await createMediaMarketingService(deps).introVariants(actor, {
|
||||
brandKitId: record.id,
|
||||
count: 3,
|
||||
});
|
||||
expect(variants).toHaveLength(3);
|
||||
expect(new Set(variants.map((v) => v.template.templateId)).size).toBe(3);
|
||||
for (const variant of variants) {
|
||||
expect(variant.template.durationMs).toBeGreaterThanOrEqual(1500);
|
||||
expect(variant.template.durationMs).toBeLessThanOrEqual(4000);
|
||||
if (hasFfmpeg) {
|
||||
const artifact = await prisma.artifact.findFirst({
|
||||
where: { id: variant.previewArtifactId ?? "", spaceId: actor.spaceId },
|
||||
});
|
||||
expect(artifact?.mimeType).toBe("video/mp4");
|
||||
}
|
||||
}
|
||||
expect(variants[0]?.template.templateId).toBe("intro_elegant_fade");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasFfmpeg)("thumbnails, publications and bundles", () => {
|
||||
async function finishedRender(
|
||||
actor: MediaActor,
|
||||
input: { profile: PlatformProfile; quality?: "draft" | "final"; projectId?: string },
|
||||
) {
|
||||
const assetIds = input.projectId ? [] : [(await seedLibrary(actor)).assetA];
|
||||
const projectId =
|
||||
input.projectId ??
|
||||
(
|
||||
await repos.createProject(actor.spaceId, actor.userId, {
|
||||
title: "Lake Day Launch",
|
||||
goal: "teaser",
|
||||
profileIds: [input.profile.id],
|
||||
assetIds,
|
||||
})
|
||||
).id;
|
||||
const vertical = input.profile.video.aspectRatios.includes("9:16");
|
||||
const plan = await repos.createPlanVersion(actor.spaceId, {
|
||||
projectId,
|
||||
profileId: input.profile.id,
|
||||
edl: {
|
||||
edlVersion: 1,
|
||||
output: {
|
||||
profileId: input.profile.id,
|
||||
width: input.profile.video.width,
|
||||
height: input.profile.video.height,
|
||||
fps: 30,
|
||||
durationTargetMs: 4000,
|
||||
durationHardMinMs: 1000,
|
||||
durationHardMaxMs: 60_000,
|
||||
},
|
||||
tracks: {
|
||||
video: [
|
||||
{
|
||||
id: "c1",
|
||||
assetId: assetIds[0] ?? "a",
|
||||
inMs: 0,
|
||||
outMs: 4000,
|
||||
atMs: 0,
|
||||
reframe: "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: { title: "Lake day", aiAltered: true, aiAlterations: ["inpaint"] },
|
||||
},
|
||||
createdBy: "agent",
|
||||
rationale: null,
|
||||
});
|
||||
const render = await repos.createRender(actor.spaceId, {
|
||||
planId: plan.id,
|
||||
quality: input.quality ?? "final",
|
||||
createdBy: actor.userId,
|
||||
});
|
||||
const dir = path.join(mediaRoot, actor.spaceId, "renders", render.id);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await ffmpeg([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc2=s=${vertical ? "180x320" : "320x180"}:r=30:d=4`,
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=4",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-g",
|
||||
"30",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
path.join(dir, "video.mp4"),
|
||||
]);
|
||||
await ffmpeg([
|
||||
"-i",
|
||||
path.join(dir, "video.mp4"),
|
||||
"-frames:v",
|
||||
"1",
|
||||
path.join(dir, "poster.png"),
|
||||
]);
|
||||
const srt = "1\n00:00:00,000 --> 00:00:02,000\nHello\n";
|
||||
await writeFile(path.join(dir, "subtitles.srt"), srt);
|
||||
await repos.claimRender(actor.spaceId, render.id);
|
||||
await repos.updateRender(actor.spaceId, render.id, {
|
||||
status: "done",
|
||||
progress: 1,
|
||||
gateReport: gatePassed(input.profile.id),
|
||||
outputs: [
|
||||
{
|
||||
kind: "video",
|
||||
artifactId: null,
|
||||
fileName: "video.mp4",
|
||||
sizeBytes: (await stat(path.join(dir, "video.mp4"))).size,
|
||||
},
|
||||
{ kind: "poster", artifactId: null, fileName: "poster.png", sizeBytes: 1 },
|
||||
{
|
||||
kind: "subtitles_srt",
|
||||
artifactId: null,
|
||||
fileName: "subtitles.srt",
|
||||
sizeBytes: srt.length,
|
||||
},
|
||||
],
|
||||
});
|
||||
return { render, projectId };
|
||||
}
|
||||
|
||||
it("creates three EXIF-free thumbnail variants", async () => {
|
||||
const actor = await newActor();
|
||||
const { render } = await finishedRender(actor, { profile: SHORT });
|
||||
const thumbs = await createMediaMarketingService(deps).thumbnails(actor, {
|
||||
renderId: render.id,
|
||||
count: 3,
|
||||
});
|
||||
expect(thumbs.map((thumb) => thumb.fileName)).toEqual([
|
||||
"thumb-a.png",
|
||||
"thumb-b.png",
|
||||
"thumb-c.png",
|
||||
]);
|
||||
const stored = await repos.getRender(actor.spaceId, render.id);
|
||||
expect(stored.outputs.filter((output) => output.kind === "thumbnail")).toHaveLength(3);
|
||||
const file = await readFile(
|
||||
path.join(mediaRoot, actor.spaceId, "renders", render.id, "thumbs", "thumb-a.png"),
|
||||
);
|
||||
for (const chunk of ["eXIf", "tEXt", "iTXt", "zTXt"]) {
|
||||
expect(file.includes(Buffer.from(chunk, "ascii"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses drafts and builds a complete bundle with tracking and checklist", async () => {
|
||||
const actor = await newActor();
|
||||
const marketing = createMediaMarketingService(deps);
|
||||
const draft = await finishedRender(actor, { profile: SHORT, quality: "draft" });
|
||||
const base = {
|
||||
destination: "bundle",
|
||||
targetUrl: "https://example.com/full-video",
|
||||
utm: null,
|
||||
title: "Lake day",
|
||||
caption: "A calm morning at the lake",
|
||||
tags: ["lake", "#morning", "calm", "extra"],
|
||||
thumbnailVariant: null,
|
||||
scheduledAt: null,
|
||||
};
|
||||
await expect(
|
||||
marketing.createPublication(actor, { ...base, renderId: draft.render.id }),
|
||||
).rejects.toThrow(/final render/);
|
||||
|
||||
const { render } = await finishedRender(actor, {
|
||||
profile: SHORT,
|
||||
projectId: draft.projectId,
|
||||
});
|
||||
await marketing.thumbnails(actor, { renderId: render.id, count: 3 });
|
||||
const publication = await marketing.createPublication(actor, {
|
||||
...base,
|
||||
renderId: render.id,
|
||||
thumbnailVariant: "thumb-b",
|
||||
});
|
||||
const tracking = new URL(publication.trackingUrl ?? "");
|
||||
expect(tracking.searchParams.get("utm_source")).toBe(SHORT.id);
|
||||
expect(tracking.searchParams.get("utm_medium")).toBe("video-9x16");
|
||||
expect(tracking.searchParams.get("utm_campaign")).toBe("lake-day-launch");
|
||||
expect(tracking.searchParams.get("utm_content")).toBe("thumb-b");
|
||||
|
||||
const bundled = await marketing.bundle(actor, publication.id);
|
||||
expect(bundled.status).toBe("bundled");
|
||||
expect(bundled.bundleArtifactId).not.toBeNull();
|
||||
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
|
||||
expect((await readdir(dir)).sort()).toEqual(
|
||||
[
|
||||
"AI-DISCLOSURE.txt",
|
||||
"UPLOAD-CHECKLIST.md",
|
||||
"bundle.zip",
|
||||
"caption.txt",
|
||||
"metadata.json",
|
||||
"poster.png",
|
||||
"subtitles.srt",
|
||||
"thumb-a.png",
|
||||
"thumb-b.png",
|
||||
"thumb-c.png",
|
||||
"video.mp4",
|
||||
].sort(),
|
||||
);
|
||||
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
|
||||
expect(checklist).toContain("AI-generated or altered content label");
|
||||
expect(checklist).toContain("profile bio");
|
||||
const caption = await readFile(path.join(dir, "caption.txt"), "utf8");
|
||||
expect(caption).not.toContain("https://");
|
||||
expect(caption.match(/#/g)).toHaveLength(3);
|
||||
const metadata = JSON.parse(await readFile(path.join(dir, "metadata.json"), "utf8"));
|
||||
expect(metadata).toMatchObject({ aiAltered: true, aiAlterations: ["inpaint"] });
|
||||
expect(
|
||||
(await readFile(path.join(dir, "poster.png"))).equals(
|
||||
await readFile(path.join(dir, "thumb-b.png")),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("schedules the publish job, bundles at the time, and reminds instead of uploading paid videos", async () => {
|
||||
const actor = await newActor();
|
||||
const marketing = createMediaMarketingService(deps);
|
||||
const { render } = await finishedRender(actor, { profile: PAID });
|
||||
const at = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const publication = await marketing.createPublication(actor, {
|
||||
renderId: render.id,
|
||||
destination: "bundle",
|
||||
targetUrl: null,
|
||||
utm: null,
|
||||
title: "Full release",
|
||||
caption: null,
|
||||
tags: [],
|
||||
thumbnailVariant: null,
|
||||
scheduledAt: at,
|
||||
});
|
||||
expect(publication.status).toBe("scheduled");
|
||||
const job = jobs.find((entry) => entry.replaceKey === `media.publish:${publication.id}`);
|
||||
expect(job?.availableAt?.toISOString()).toBe(at);
|
||||
|
||||
const publish = vi.spyOn(deps.publisher, "publish");
|
||||
const remind = vi.fn(async () => {});
|
||||
const handlers = createMediaMarketingJobHandlers(deps, marketing, { remind });
|
||||
await handlers["media.publish"]({ publicationId: publication.id });
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(remind).toHaveBeenCalledTimes(1);
|
||||
const after = await repos.getPublication(actor.spaceId, publication.id);
|
||||
expect(after.status).toBe("bundled");
|
||||
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
|
||||
const files = await readdir(dir);
|
||||
expect(files).toContain("preview.mp4");
|
||||
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
|
||||
expect(checklist).toContain("age-restricted");
|
||||
|
||||
const canceled = await marketing.cancelPublication(actor, publication.id);
|
||||
expect(canceled.status).toBe("canceled");
|
||||
expect(canceledJobs).toContain(`media.publish:${publication.id}`);
|
||||
await handlers["media.publish"]({ publicationId: publication.id });
|
||||
expect(remind).toHaveBeenCalledTimes(1);
|
||||
publish.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("results", () => {
|
||||
it("imports CSV through a saved mapping without double counting and learns from 20 publications", async () => {
|
||||
const actor = await newActor();
|
||||
const { project, segments, assetA } = await seedLibrary(actor);
|
||||
const marketing = createMediaMarketingService(deps);
|
||||
const strong = segments.find((s) => s.assetId === assetA && s.startMs === 0)!;
|
||||
const weak = segments.find((s) => s.assetId === assetA && s.startMs === 16_000)!;
|
||||
const renders: string[] = [];
|
||||
for (const segment of [strong, weak]) {
|
||||
const plan = await repos.createPlanVersion(actor.spaceId, {
|
||||
projectId: project.id,
|
||||
profileId: SHORT.id,
|
||||
edl: {
|
||||
edlVersion: 1,
|
||||
output: {
|
||||
profileId: SHORT.id,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
durationTargetMs: 3000,
|
||||
durationHardMinMs: 1000,
|
||||
durationHardMaxMs: 60_000,
|
||||
},
|
||||
tracks: {
|
||||
video: [
|
||||
{
|
||||
id: "c1",
|
||||
assetId: assetA,
|
||||
inMs: segment.startMs,
|
||||
outMs: segment.startMs + 3000,
|
||||
atMs: 0,
|
||||
reframe: "none",
|
||||
segmentId: segment.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
createdBy: "agent",
|
||||
rationale: null,
|
||||
});
|
||||
const render = await repos.createRender(actor.spaceId, {
|
||||
planId: plan.id,
|
||||
quality: "final",
|
||||
createdBy: actor.userId,
|
||||
});
|
||||
await repos.claimRender(actor.spaceId, render.id);
|
||||
await repos.updateRender(actor.spaceId, render.id, { status: "done", progress: 1 });
|
||||
renders.push(render.id);
|
||||
}
|
||||
const publicationIds: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const publication = await repos.createPublication(actor.spaceId, actor.userId, {
|
||||
renderId: renders[i % 2]!,
|
||||
destination: "bundle",
|
||||
targetUrl: null,
|
||||
trackingUrl: null,
|
||||
utm: null,
|
||||
title: null,
|
||||
caption: null,
|
||||
tags: [],
|
||||
thumbnailVariant: null,
|
||||
scheduledAt: null,
|
||||
});
|
||||
publicationIds.push(publication.id);
|
||||
}
|
||||
const mapping = await repos.saveMetricMapping(actor.spaceId, {
|
||||
name: "Shorts export",
|
||||
delimiter: ";",
|
||||
columns: {
|
||||
publication: "Post",
|
||||
observedAt: "Date",
|
||||
views: "Views",
|
||||
clicks: "Clicks",
|
||||
conversions: "Sales",
|
||||
revenue: "Revenue",
|
||||
},
|
||||
publicationKey: "id",
|
||||
revenueUnit: "major",
|
||||
defaultCurrency: "EUR",
|
||||
});
|
||||
const csv = (ids: string[]) =>
|
||||
[
|
||||
"Post;Date;Views;Clicks;Sales;Revenue",
|
||||
...ids.map((id, i) => {
|
||||
const strongPost = i % 2 === 0;
|
||||
return `${id};2026-09-0${(i % 9) + 1};1000;${strongPost ? 120 : 40};${strongPost ? 30 : 2};${strongPost ? "299,70" : "19,98"}`;
|
||||
}),
|
||||
"unknown-post;2026-09-01;10;1;0;0",
|
||||
].join("\n");
|
||||
|
||||
const first19 = await marketing.importMetricsCsv(actor, {
|
||||
mappingId: mapping.id as string,
|
||||
csv: csv(publicationIds.slice(0, 19)),
|
||||
});
|
||||
expect(first19.imported).toBe(19);
|
||||
expect(first19.skipped).toEqual([{ row: 21, reason: "publication not found" }]);
|
||||
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
|
||||
|
||||
const again = await marketing.importMetricsCsv(actor, {
|
||||
mappingId: mapping.id as string,
|
||||
csv: csv(publicationIds.slice(0, 19)),
|
||||
});
|
||||
expect(again.imported).toBe(19);
|
||||
const funnel = await repos.funnel(actor.spaceId, project.id);
|
||||
expect(funnel.reduce((sum, row) => sum + row.views, 0)).toBe(19_000);
|
||||
expect(await refitTeaserModel(deps, actor.spaceId)).toBeNull();
|
||||
|
||||
await recordMetric(deps, actor, {
|
||||
publicationId: publicationIds[19]!,
|
||||
observedAt: "2026-09-10T00:00:00.000Z",
|
||||
views: 1000,
|
||||
clicks: 40,
|
||||
conversions: 2,
|
||||
revenueCents: 1998,
|
||||
currency: "EUR",
|
||||
});
|
||||
const model = await repos.getTeaserModel(actor.spaceId);
|
||||
expect(model?.sampleCount).toBe(20);
|
||||
const totals = funnel.find((row) => row.publicationId === publicationIds[0]);
|
||||
expect(totals).toMatchObject({
|
||||
views: 1000,
|
||||
clicks: 120,
|
||||
conversions: 30,
|
||||
revenueCents: 29_970,
|
||||
});
|
||||
await resetTeaserModel(deps, actor);
|
||||
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
350
packages/adapters/src/media-marketing.test.ts
Normal file
350
packages/adapters/src/media-marketing.test.ts
Normal file
@ -0,0 +1,350 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Edl, MediaSegment, PlatformProfile } from "@rakazo/contracts";
|
||||
import { EdlSchema, PlatformProfileSchema } from "@rakazo/contracts";
|
||||
import type { MediaRepos } from "@rakazo/db";
|
||||
import type { ProfileSet, ScoredSegmentInput } from "@rakazo/media";
|
||||
import { escapeFilterText } from "@rakazo/media";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCubeLut } from "./media-brand.js";
|
||||
import type { MediaModelClient, MediaServiceDeps } from "./media-context.js";
|
||||
import { generateCopy, truncateText } from "./media-marketing-copy.js";
|
||||
import { applyRanking, defaultCtaText, showsProtectedDetail } from "./media-marketing-plan.js";
|
||||
import {
|
||||
measureGrayFrame,
|
||||
thumbnailCandidateTimes,
|
||||
titleOverlayFilter,
|
||||
} from "./media-marketing-thumbnails.js";
|
||||
import { planFeatures } from "./media-metrics.js";
|
||||
import { assertPublishable } from "./media-publish.js";
|
||||
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
|
||||
|
||||
function profile(text: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
|
||||
return PlatformProfileSchema.parse({
|
||||
id: "example-short-vertical",
|
||||
name: "Example short vertical",
|
||||
kind: "free_social",
|
||||
reviewedAt: "2026-09-01",
|
||||
video: {
|
||||
aspectRatios: ["9:16"],
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
codec: "h264",
|
||||
maxBitrateKbps: 8000,
|
||||
maxFileSizeMb: 500,
|
||||
},
|
||||
duration: { hardMinMs: 1000, hardMaxMs: 60_000 },
|
||||
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
|
||||
captions: { recommended: true },
|
||||
audio: {},
|
||||
text: { captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only", ...text },
|
||||
content: { thresholds: {} },
|
||||
disclosure: { aiAlteredLabelRequired: true },
|
||||
consent: { requirePerformerConsent: false, requireAgeVerification: false },
|
||||
export: {},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function edl(overrides: Partial<Edl> = {}): Edl {
|
||||
return EdlSchema.parse({
|
||||
edlVersion: 1,
|
||||
output: {
|
||||
profileId: "example-short-vertical",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
durationTargetMs: 9000,
|
||||
durationHardMinMs: 1000,
|
||||
durationHardMaxMs: 60_000,
|
||||
},
|
||||
tracks: {
|
||||
video: [
|
||||
{
|
||||
id: "c1",
|
||||
assetId: "a1",
|
||||
inMs: 0,
|
||||
outMs: 3000,
|
||||
atMs: 0,
|
||||
reframe: "none",
|
||||
segmentId: "s1",
|
||||
},
|
||||
{ id: "c2", assetId: "a1", inMs: 6000, outMs: 12_000, atMs: 3000, reframe: "none" },
|
||||
],
|
||||
},
|
||||
metadata: { title: "Lake day", tags: ["lake"] },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function segment(id: string, startMs: number, endMs: number, extra: Partial<MediaSegment> = {}) {
|
||||
return {
|
||||
id,
|
||||
assetId: "a1",
|
||||
kind: "shot",
|
||||
startMs,
|
||||
endMs,
|
||||
scores: {},
|
||||
teaserScore: null,
|
||||
transcriptExcerpt: null,
|
||||
labels: [],
|
||||
paidOnly: false,
|
||||
...extra,
|
||||
} as MediaSegment;
|
||||
}
|
||||
|
||||
describe("teaser plans", () => {
|
||||
it("ends teasers on a call to action that follows the link policy", () => {
|
||||
expect(defaultCtaText("teaser", profile({ linkPolicy: "bio_only" }))).toBe("Link in bio");
|
||||
expect(defaultCtaText("teaser", profile({ linkPolicy: "allowed" }))).toBe("Link below");
|
||||
expect(defaultCtaText("teaser", profile({ linkPolicy: "forbidden" }))).not.toMatch(/link/i);
|
||||
expect(defaultCtaText("full_release", profile({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("re-weights by the model order and leaves paid-only segments alone", () => {
|
||||
const input = (id: string, paidOnly = false): ScoredSegmentInput => ({
|
||||
id,
|
||||
assetId: "a1",
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
capturedAt: null,
|
||||
features: { motion: 1, sharpness: 0.5 },
|
||||
paidOnly,
|
||||
transcript: null,
|
||||
});
|
||||
const ranked = applyRanking([input("a"), input("b"), input("c"), input("p", true)], ["b", "a"]);
|
||||
const byId = new Map(ranked.map((s) => [s.id, s.features]));
|
||||
expect(byId.get("b")).toEqual({ motion: 1, sharpness: 0.5 });
|
||||
expect(byId.get("a")).toEqual({ motion: 0.75, sharpness: 0.375 });
|
||||
expect(byId.get("c")).toEqual({ motion: 0.5, sharpness: 0.25 });
|
||||
expect(byId.get("p")).toEqual({ motion: 1, sharpness: 0.5 });
|
||||
});
|
||||
|
||||
it("keeps protected faces and treated marks away from the model", () => {
|
||||
const track = (personId: string | null) => ({
|
||||
assetId: "a1",
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
personId,
|
||||
});
|
||||
const ctx = (tracks: unknown[], marks: unknown[] = []) =>
|
||||
({ faceTracks: tracks, marks }) as Parameters<typeof showsProtectedDetail>[0];
|
||||
const span = { assetId: "a1", startMs: 0, endMs: 1500 };
|
||||
expect(showsProtectedDetail(ctx([track("p1")]), new Set(), span)).toBe(false);
|
||||
expect(showsProtectedDetail(ctx([track("p1")]), new Set(["p1"]), span)).toBe(true);
|
||||
expect(showsProtectedDetail(ctx([track(null)]), new Set(["p2"]), span)).toBe(true);
|
||||
expect(
|
||||
showsProtectedDetail(ctx([track("p1")]), new Set(), { ...span, startMs: 2000, endMs: 3000 }),
|
||||
).toBe(false);
|
||||
const tattoo = { treatment: "blur", assetId: null, personId: "p1" };
|
||||
expect(showsProtectedDetail(ctx([track("p1")], [tattoo]), new Set(), span)).toBe(true);
|
||||
expect(
|
||||
showsProtectedDetail(ctx([track("p1")], [{ ...tattoo, treatment: "none" }]), new Set(), span),
|
||||
).toBe(false);
|
||||
const plate = { treatment: "cover", assetId: "a1", personId: null };
|
||||
expect(showsProtectedDetail(ctx([], [plate]), new Set(), span)).toBe(true);
|
||||
});
|
||||
|
||||
it("learns from the clip-length-weighted features of the segments a plan used", () => {
|
||||
const features = planFeatures(edl(), [
|
||||
segment("s1", 0, 3000, { scores: { motion: 1 } }),
|
||||
segment("s2", 6000, 12_000, { scores: { motion: 0.25 }, transcriptExcerpt: "Wow!" }),
|
||||
segment("s3", 15_000, 18_000, { scores: { motion: 0 } }),
|
||||
]);
|
||||
expect(features?.motion).toBeCloseTo((1 * 3000 + 0.25 * 6000) / 9000);
|
||||
expect(features?.speechHook).toBeGreaterThan(0);
|
||||
expect(planFeatures(edl(), [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("thumbnails", () => {
|
||||
it("measures sharpness, contrast and thirds of a grey frame", () => {
|
||||
const width = 30;
|
||||
const height = 30;
|
||||
const flat = new Uint8Array(width * height).fill(128);
|
||||
const checker = Uint8Array.from({ length: width * height }, (_, i) =>
|
||||
(i % width) % 2 === Math.floor(i / width) % 2 ? 255 : 0,
|
||||
);
|
||||
expect(measureGrayFrame(flat, width, height)).toEqual({
|
||||
laplacianVariance: 0,
|
||||
contrast: 0,
|
||||
thirds: 0,
|
||||
});
|
||||
const busy = measureGrayFrame(checker, width, height);
|
||||
expect(busy.laplacianVariance).toBeGreaterThan(1000);
|
||||
expect(busy.contrast).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("samples the main program after the intro and just after each cut", () => {
|
||||
const withIntro = edl({ intro: { templateId: "intro_glitch", params: {}, durationMs: 2000 } });
|
||||
const times = thumbnailCandidateTimes(withIntro, 11_000);
|
||||
expect(times.length).toBeGreaterThanOrEqual(24);
|
||||
expect(Math.min(...times)).toBeGreaterThan(2000);
|
||||
expect(Math.max(...times)).toBeLessThan(11_000);
|
||||
expect(times).toContain(2000 + 3000 + 200);
|
||||
});
|
||||
|
||||
it("escapes the title and falls back to neutral colours", () => {
|
||||
const hostile = "Hi': drawtext=text='x' ;[out] %{pts}";
|
||||
const filter = titleOverlayFilter({
|
||||
text: hostile,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
profile: profile({}),
|
||||
kit: null,
|
||||
fontFile: "/fonts/My Font:Bold.ttf",
|
||||
});
|
||||
expect(filter).toContain(`text=${escapeFilterText(hostile)}`);
|
||||
expect(filter).not.toContain(hostile);
|
||||
expect(filter).toContain("fontcolor=white");
|
||||
expect(filter).toContain(`y=${Math.round(1920 * 0.14)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy", () => {
|
||||
const render = {
|
||||
id: "r1",
|
||||
planId: "p1",
|
||||
projectId: "j1",
|
||||
profileId: "example-short-vertical",
|
||||
};
|
||||
|
||||
function copyDeps(reply: unknown, text: Record<string, unknown> = {}) {
|
||||
const short = profile({
|
||||
titleMaxChars: 20,
|
||||
bannedPatterns: [{ pattern: "guaranteed", reason: "false promise" }],
|
||||
...text,
|
||||
});
|
||||
const profiles: ProfileSet = {
|
||||
profiles: new Map([[short.id, { profile: short, origin: "builtin" as const }]]),
|
||||
errors: [],
|
||||
};
|
||||
const calls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
|
||||
const deps = {
|
||||
repos: {
|
||||
getRender: async () => render,
|
||||
getPlan: async () => ({ edl: edl() }),
|
||||
getProjectRecord: async () => ({ title: "Lake Day Launch" }),
|
||||
listSegments: async () => [
|
||||
segment("s1", 0, 3000, { transcriptExcerpt: "Welcome back. Today we film at the lake." }),
|
||||
],
|
||||
} as unknown as MediaRepos,
|
||||
profiles: async () => profiles,
|
||||
model: {
|
||||
async generate(input) {
|
||||
calls.push(input);
|
||||
return reply === null ? null : input.schema.parse(reply);
|
||||
},
|
||||
} as MediaModelClient,
|
||||
} as unknown as MediaServiceDeps;
|
||||
return { deps, calls };
|
||||
}
|
||||
const actor = { spaceId: "s", userId: "u" };
|
||||
|
||||
it("falls back to the plan metadata without a model", async () => {
|
||||
const { deps, calls } = copyDeps(null);
|
||||
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "de" });
|
||||
expect(calls[0]?.instructions).toContain('language "de"');
|
||||
expect(calls[0]?.prompt).toContain("Today we film at the lake");
|
||||
expect(copy).toMatchObject({
|
||||
title: "Lake day",
|
||||
hook: "Welcome back.",
|
||||
caption: "Lake day",
|
||||
hashtags: ["#lake"],
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("cuts to the profile limits and reports banned patterns and links in every field", async () => {
|
||||
const { deps } = copyDeps({
|
||||
title: "The calmest lake morning you will ever see",
|
||||
hook: "Guaranteed calm",
|
||||
caption: `See https://example.com/video now ${"x".repeat(200)}`,
|
||||
hashtags: ["lake", "calm", "morning", "travel", "nature"],
|
||||
description: "More at https://example.com",
|
||||
altText: "A lake at sunrise",
|
||||
});
|
||||
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "en" });
|
||||
expect([...copy.title].length).toBeLessThanOrEqual(20);
|
||||
expect([...copy.caption].length).toBeLessThanOrEqual(150);
|
||||
expect(copy.hashtags).toEqual(["#lake", "#calm", "#morning"]);
|
||||
const codes = copy.issues.map((issue) => `${issue.code}:${issue.message}`);
|
||||
expect(codes.some((code) => code.startsWith("link_bio_only:caption"))).toBe(true);
|
||||
expect(codes.some((code) => code.startsWith("link_bio_only:hook or description"))).toBe(true);
|
||||
expect(codes.some((code) => code.startsWith("banned_pattern:hook or description"))).toBe(true);
|
||||
expect(copy.chapters).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects malformed language codes", async () => {
|
||||
const { deps } = copyDeps(null);
|
||||
await expect(
|
||||
generateCopy(deps, actor, { renderId: "r1", language: "en; drop" }),
|
||||
).rejects.toThrow(/language/);
|
||||
});
|
||||
|
||||
it("cuts long text at a word boundary", () => {
|
||||
expect(truncateText("one two three four", 15)).toBe("one two three");
|
||||
expect(truncateText("one two three four", 12)).toBe("one two thre");
|
||||
expect(truncateText("short", 12)).toBe("short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishing", () => {
|
||||
it("publishes only final renders whose gate passed", () => {
|
||||
const gate = { passed: true } as never;
|
||||
expect(() =>
|
||||
assertPublishable({ status: "done", quality: "final", gateReport: gate }),
|
||||
).not.toThrow();
|
||||
expect(() => assertPublishable({ status: "done", quality: "draft", gateReport: gate })).toThrow(
|
||||
/final/,
|
||||
);
|
||||
expect(() =>
|
||||
assertPublishable({ status: "blocked", quality: "final", gateReport: gate }),
|
||||
).toThrow(/finished/);
|
||||
expect(() => assertPublishable({ status: "done", quality: "final", gateReport: null })).toThrow(
|
||||
/gate/,
|
||||
);
|
||||
expect(() =>
|
||||
assertPublishable({
|
||||
status: "done",
|
||||
quality: "final",
|
||||
gateReport: { passed: false } as never,
|
||||
}),
|
||||
).toThrow(/gate/);
|
||||
});
|
||||
|
||||
it("reports a bundle only when the files are really there", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "media-bundle-"));
|
||||
try {
|
||||
const publisher = new BundleMediaPublisher();
|
||||
const request = {
|
||||
bundleDir: dir,
|
||||
profile: profile({}) as PlatformProfile,
|
||||
publicationId: "pub1",
|
||||
title: null,
|
||||
caption: null,
|
||||
tags: [],
|
||||
};
|
||||
await expect(publisher.publish(request)).rejects.toThrow(/missing video.mp4/);
|
||||
await writeFile(path.join(dir, "video.mp4"), "v");
|
||||
await expect(publisher.publish(request)).rejects.toThrow(/missing bundle.zip/);
|
||||
await writeFile(path.join(dir, "bundle.zip"), "z");
|
||||
expect(await publisher.publish(request)).toEqual({ status: "bundled", externalUrl: null });
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("brand kits", () => {
|
||||
it("accepts only complete 3D .cube LUTs", () => {
|
||||
const rows = (n: number) => Array.from({ length: n }, () => "0.1 0.2 0.3").join("\n");
|
||||
expect(isCubeLut(`TITLE "look"\nLUT_3D_SIZE 2\n${rows(8)}\n`)).toBe(true);
|
||||
expect(isCubeLut(`LUT_3D_SIZE 2\n${rows(7)}\n`)).toBe(false);
|
||||
expect(isCubeLut(`LUT_1D_SIZE 2\n${rows(2)}\n`)).toBe(false);
|
||||
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 zero\n")).toBe(false);
|
||||
expect(isCubeLut("not a lut")).toBe(false);
|
||||
});
|
||||
});
|
||||
108
packages/adapters/src/media-zip.test.ts
Normal file
108
packages/adapters/src/media-zip.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { crc32 } from "node:zlib";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isSafeZipEntryName, writeStoreZip } from "./media-zip.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "media-zip-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Read a store-only archive through its central directory, checking every CRC. */
|
||||
function readStoreZip(bytes: Buffer): Array<{ name: string; data: Buffer; mode: number }> {
|
||||
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
||||
expect(end).toBeGreaterThan(0);
|
||||
const count = bytes.readUInt16LE(end + 10);
|
||||
let at = bytes.readUInt32LE(end + 16);
|
||||
const entries: Array<{ name: string; data: Buffer; mode: number }> = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
expect(bytes.readUInt32LE(at)).toBe(0x02014b50);
|
||||
const crc = bytes.readUInt32LE(at + 16);
|
||||
const size = bytes.readUInt32LE(at + 20);
|
||||
const nameLength = bytes.readUInt16LE(at + 28);
|
||||
const mode = bytes.readUInt32LE(at + 38) >>> 16;
|
||||
const offset = bytes.readUInt32LE(at + 42);
|
||||
const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("ascii");
|
||||
expect(bytes.readUInt32LE(offset)).toBe(0x04034b50);
|
||||
expect(bytes.readUInt16LE(offset + 8)).toBe(0);
|
||||
const localName = bytes.readUInt16LE(offset + 26);
|
||||
const start = offset + 30 + localName + bytes.readUInt16LE(offset + 28);
|
||||
const data = bytes.subarray(start, start + size);
|
||||
expect(crc32(data) >>> 0).toBe(crc);
|
||||
entries.push({ name, data, mode });
|
||||
at += 46 + nameLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe("writeStoreZip", () => {
|
||||
it("stores flat entries byte for byte and deterministically", async () => {
|
||||
const video = path.join(dir, "v");
|
||||
const notes = path.join(dir, "n");
|
||||
await writeFile(video, Buffer.alloc(200_000, 7));
|
||||
await writeFile(notes, "# Upload checklist\n");
|
||||
const entries = [
|
||||
{ name: "video.mp4", path: video },
|
||||
{ name: "UPLOAD-CHECKLIST.md", path: notes },
|
||||
];
|
||||
const first = path.join(dir, "a.zip");
|
||||
const size = await writeStoreZip(first, entries);
|
||||
expect((await stat(first)).size).toBe(size);
|
||||
const read = readStoreZip(await readFile(first));
|
||||
expect(read.map((entry) => entry.name)).toEqual(["video.mp4", "UPLOAD-CHECKLIST.md"]);
|
||||
expect(read[0]?.data.equals(Buffer.alloc(200_000, 7))).toBe(true);
|
||||
expect(read[1]?.data.toString()).toBe("# Upload checklist\n");
|
||||
expect(read[0]?.mode).toBe(0o100644);
|
||||
|
||||
const second = path.join(dir, "b.zip");
|
||||
await writeStoreZip(second, entries);
|
||||
expect((await readFile(second)).equals(await readFile(first))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses names that could escape the extraction folder", async () => {
|
||||
const file = path.join(dir, "x");
|
||||
await writeFile(file, "x");
|
||||
for (const name of [
|
||||
"../evil.sh",
|
||||
"..",
|
||||
"/etc/passwd",
|
||||
"a/b.txt",
|
||||
"a\\b.txt",
|
||||
".hidden",
|
||||
"C:evil",
|
||||
"name\0.txt",
|
||||
"",
|
||||
`${"a".repeat(101)}`,
|
||||
]) {
|
||||
expect(isSafeZipEntryName(name), name).toBe(false);
|
||||
await expect(writeStoreZip(path.join(dir, "out.zip"), [{ name, path: file }])).rejects.toThrow(
|
||||
/unsafe zip entry/,
|
||||
);
|
||||
}
|
||||
expect(isSafeZipEntryName("thumb-a.png")).toBe(true);
|
||||
expect(isSafeZipEntryName("UPLOAD-CHECKLIST.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate names and leaves no partial archive behind", async () => {
|
||||
const file = path.join(dir, "x");
|
||||
await writeFile(file, "x");
|
||||
const out = path.join(dir, "dup.zip");
|
||||
await expect(
|
||||
writeStoreZip(out, [
|
||||
{ name: "caption.txt", path: file },
|
||||
{ name: "caption.txt", path: file },
|
||||
]),
|
||||
).rejects.toThrow(/duplicate/);
|
||||
await expect(
|
||||
writeStoreZip(out, [{ name: "missing.txt", path: path.join(dir, "missing") }]),
|
||||
).rejects.toThrow();
|
||||
await expect(stat(out)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user