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 { 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user