feat(media): per-segment results and copy checks on every field
This commit is contained in:
parent
03e4961c9f
commit
7c744b98a0
@ -23,7 +23,12 @@ import type {
|
||||
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 {
|
||||
recordMetric,
|
||||
refitTeaserModel,
|
||||
resetTeaserModel,
|
||||
segmentFunnel,
|
||||
} from "./media-metrics.js";
|
||||
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
|
||||
import { bundleRelativeDir } from "./media-publish.js";
|
||||
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
|
||||
@ -934,6 +939,10 @@ describePostgres("media marketing on Postgres", () => {
|
||||
conversions: 30,
|
||||
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);
|
||||
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
|
||||
});
|
||||
|
||||
@ -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 type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
|
||||
import { InvalidMediaInputError } from "@rakazo/db";
|
||||
@ -57,17 +57,16 @@ export function planFeatures(edl: Edl, segments: MediaSegment[]): SegmentFeature
|
||||
) as SegmentFeatures;
|
||||
}
|
||||
|
||||
/** Refit and store the space's teaser weights; null while there are too few results. */
|
||||
export async function refitTeaserModel(
|
||||
/** Funnel rows with the plan each publication's render used, and those plans' segments. */
|
||||
async function withPlans(
|
||||
deps: Pick<MediaServiceDeps, "repos">,
|
||||
spaceId: string,
|
||||
): Promise<TeaserModelRecord | null> {
|
||||
rows: FunnelRow[],
|
||||
): Promise<{ samples: Array<FunnelRow & { edl: Edl }>; segments: MediaSegment[] }> {
|
||||
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 samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
|
||||
const planOfRender = new Map<string, string>();
|
||||
const samples: Array<FunnelRow & { edl: Edl }> = [];
|
||||
for (const row of rows) {
|
||||
const publication = await repos.getPublication(spaceId, row.publicationId);
|
||||
let planId = planOfRender.get(publication.renderId);
|
||||
@ -80,12 +79,90 @@ export async function refitTeaserModel(
|
||||
edl = (await repos.getPlan(spaceId, planId)).edl;
|
||||
plans.set(planId, edl);
|
||||
}
|
||||
samples.push({ edl, views: row.views, conversions: row.conversions });
|
||||
samples.push({ ...row, edl });
|
||||
}
|
||||
const assetIds = [
|
||||
...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 features = planFeatures(sample.edl, segments);
|
||||
return features
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user