feat(media): per-segment results and copy checks on every field

This commit is contained in:
Claude 2026-10-03 18:46:36 +00:00
parent 03e4961c9f
commit 7c744b98a0
No known key found for this signature in database
2 changed files with 96 additions and 10 deletions

View File

@ -23,7 +23,12 @@ import type {
import { EmulatorMediaInferenceProvider } from "./media-emulator.js"; import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js"; import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js";
import { storeMediaArtifact } from "./media-marketing-files.js"; import { storeMediaArtifact } from "./media-marketing-files.js";
import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.js"; import {
recordMetric,
refitTeaserModel,
resetTeaserModel,
segmentFunnel,
} from "./media-metrics.js";
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js"; import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
import { bundleRelativeDir } from "./media-publish.js"; import { bundleRelativeDir } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js"; import { BundleMediaPublisher } from "./media-publisher-bundle.js";
@ -934,6 +939,10 @@ describePostgres("media marketing on Postgres", () => {
conversions: 30, conversions: 30,
revenueCents: 29_970, revenueCents: 29_970,
}); });
const bySegment = await segmentFunnel(deps, actor, project.id);
expect(bySegment.map((row) => row.segmentId)).toEqual([strong.id, weak.id]);
expect(bySegment[0]).toMatchObject({ publications: 10, views: 10_000, conversions: 300 });
expect(bySegment[1]).toMatchObject({ publications: 10, conversions: 20 });
await resetTeaserModel(deps, actor); await resetTeaserModel(deps, actor);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
}); });

View File

@ -1,4 +1,4 @@
import type { Edl, MediaMetric, MediaSegment } from "@rakazo/contracts"; import type { Edl, FunnelRow, MediaMetric, MediaSegment } from "@rakazo/contracts";
import { TEASER_FEATURES, TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts"; import { TEASER_FEATURES, TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db"; import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
import { InvalidMediaInputError } from "@rakazo/db"; import { InvalidMediaInputError } from "@rakazo/db";
@ -57,17 +57,16 @@ export function planFeatures(edl: Edl, segments: MediaSegment[]): SegmentFeature
) as SegmentFeatures; ) as SegmentFeatures;
} }
/** Refit and store the space's teaser weights; null while there are too few results. */ /** Funnel rows with the plan each publication's render used, and those plans' segments. */
export async function refitTeaserModel( async function withPlans(
deps: Pick<MediaServiceDeps, "repos">, deps: Pick<MediaServiceDeps, "repos">,
spaceId: string, spaceId: string,
): Promise<TeaserModelRecord | null> { rows: FunnelRow[],
): Promise<{ samples: Array<FunnelRow & { edl: Edl }>; segments: MediaSegment[] }> {
const { repos } = deps; const { repos } = deps;
const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0);
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
const plans = new Map<string, Edl>(); const plans = new Map<string, Edl>();
const samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
const planOfRender = new Map<string, string>(); const planOfRender = new Map<string, string>();
const samples: Array<FunnelRow & { edl: Edl }> = [];
for (const row of rows) { for (const row of rows) {
const publication = await repos.getPublication(spaceId, row.publicationId); const publication = await repos.getPublication(spaceId, row.publicationId);
let planId = planOfRender.get(publication.renderId); let planId = planOfRender.get(publication.renderId);
@ -80,12 +79,90 @@ export async function refitTeaserModel(
edl = (await repos.getPlan(spaceId, planId)).edl; edl = (await repos.getPlan(spaceId, planId)).edl;
plans.set(planId, edl); plans.set(planId, edl);
} }
samples.push({ edl, views: row.views, conversions: row.conversions }); samples.push({ ...row, edl });
} }
const assetIds = [ const assetIds = [
...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))), ...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))),
]; ];
const segments = await repos.listSegments(spaceId, assetIds); return { samples, segments: await repos.listSegments(spaceId, assetIds) };
}
export interface SegmentFunnelRow {
segmentId: string;
assetId: string;
startMs: number;
endMs: number;
publications: number;
views: number;
clicks: number;
conversions: number;
revenueCents: number;
currency: string;
}
/**
* Results per teaser segment (spec 10.6): every publication counts fully for
* each segment its plan used. Rows are per currency so revenue never mixes.
*/
export async function segmentFunnel(
deps: Pick<MediaServiceDeps, "repos">,
actor: MediaActor,
projectId?: string,
): Promise<SegmentFunnelRow[]> {
const rows = await deps.repos.funnel(actor.spaceId, projectId);
const { samples, segments } = await withPlans(deps, actor.spaceId, rows);
const byId = new Map(segments.map((segment) => [segment.id, segment]));
const totals = new Map<string, SegmentFunnelRow>();
for (const sample of samples) {
const used = new Set<string>();
for (const clip of sample.edl.tracks.video) {
for (const segment of segments) {
const linked = clip.segmentId
? segment.id === clip.segmentId
: segment.assetId === clip.assetId &&
segment.startMs < clip.outMs &&
segment.endMs > clip.inMs;
if (linked) used.add(segment.id);
}
}
for (const segmentId of used) {
const segment = byId.get(segmentId)!;
const key = `${segmentId}\0${sample.currency}`;
const row = totals.get(key) ?? {
segmentId,
assetId: segment.assetId,
startMs: segment.startMs,
endMs: segment.endMs,
publications: 0,
views: 0,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: sample.currency,
};
row.publications += 1;
row.views += sample.views;
row.clicks += sample.clicks;
row.conversions += sample.conversions;
row.revenueCents += sample.revenueCents;
totals.set(key, row);
}
}
return [...totals.values()].sort(
(a, b) =>
b.conversions - a.conversions || b.views - a.views || a.segmentId.localeCompare(b.segmentId),
);
}
/** Refit and store the space's teaser weights; null while there are too few results. */
export async function refitTeaserModel(
deps: Pick<MediaServiceDeps, "repos">,
spaceId: string,
): Promise<TeaserModelRecord | null> {
const { repos } = deps;
const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0);
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
const { samples, segments } = await withPlans(deps, spaceId, rows);
const usable = samples.flatMap((sample) => { const usable = samples.flatMap((sample) => {
const features = planFeatures(sample.edl, segments); const features = planFeatures(sample.edl, segments);
return features return features