This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

View file

@ -101,7 +101,7 @@ async function runCue(
videoTimeMs: cursor.ms + leadInMs,
durationMs: measuredAudioMs,
});
await showCaption(ctx.page, cue.text);
await showCaption(ctx.page, cue.text, cue.captionPlacement);
const during = cue.during ?? [];
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
@ -184,7 +184,36 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
return;
}
case 'click': {
const to = await resolveTarget(ctx, step.target);
const selectionVersion = ctx.dashboard.getSelectionStatsVersion();
const candidates =
step.target.kind === 'hexagon' && step.waitForSelectionReady
? await ctx.dashboard.visibleHexagonTargets(4)
: [await resolveTarget(ctx, step.target)];
let lastError: unknown = null;
for (let i = 0; i < candidates.length; i++) {
const to = candidates[i];
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
ctx.cursor = to;
await ctx.page.mouse.click(to.x, to.y);
if (!step.waitForSelectionReady) return;
try {
await ctx.dashboard.waitForSelectionReady(
selectionVersion,
Math.min(step.timeoutMs ?? 12000, i === candidates.length - 1 ? 12000 : 4000)
);
return;
} catch (err) {
lastError = err;
}
}
throw lastError ?? new Error('Click did not open the selection pane');
}
case 'clickIfVisible': {
const to = await tryResolveTarget(ctx, step.target, step.timeoutMs ?? 700);
if (!to) return;
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
ctx.cursor = to;
@ -196,16 +225,109 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
return;
case 'mapZoom': {
const point = await resolveTarget(ctx, step.target);
await ctx.page.mouse.move(point.x, point.y);
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
const mapVersion = ctx.dashboard.getMapDataVersion();
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA;
for (let i = 0; i < step.steps; i++) {
await ctx.page.mouse.wheel(0, delta);
if (perStepMs > 0) await sleep(perStepMs);
const handled = await ctx.page.evaluate(
async ({ x, y, steps, durationMs, direction }) => {
const root = document.querySelector('.maplibregl-map') as HTMLElement | null;
const fiberKey = root
? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$'))
: undefined;
let fiber = fiberKey ? (root as unknown as Record<string, unknown>)[fiberKey] : null;
let mapRef: unknown = null;
while (fiber && typeof fiber === 'object') {
const maybeFiber = fiber as {
ref?: { current?: unknown };
return?: unknown;
};
const current = maybeFiber.ref?.current;
if (
current &&
typeof current === 'object' &&
typeof (current as { getMap?: unknown }).getMap === 'function'
) {
mapRef = current;
break;
}
fiber = maybeFiber.return ?? null;
}
const map = (mapRef as { getMap?: () => unknown } | null)?.getMap?.();
if (!map || typeof map !== 'object') return false;
const mapApi = map as {
getCanvas: () => HTMLCanvasElement;
getZoom: () => number;
getMinZoom?: () => number;
getMaxZoom?: () => number;
unproject: (point: [number, number]) => unknown;
zoomTo: (
zoom: number,
options: { around?: unknown; duration?: number; essential?: boolean }
) => void;
};
if (
typeof mapApi.getCanvas !== 'function' ||
typeof mapApi.getZoom !== 'function' ||
typeof mapApi.unproject !== 'function' ||
typeof mapApi.zoomTo !== 'function'
) {
return false;
}
const rect = mapApi.getCanvas().getBoundingClientRect();
const around = mapApi.unproject([x - rect.left, y - rect.top]);
const sign = direction === 'out' ? -1 : 1;
const zoomDelta = Math.max(0.25, Math.min(5.2, steps * 0.28)) * sign;
const minZoom = mapApi.getMinZoom?.() ?? 0;
const maxZoom = mapApi.getMaxZoom?.() ?? 22;
const targetZoom = Math.max(minZoom, Math.min(maxZoom, mapApi.getZoom() + zoomDelta));
mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true });
await new Promise((resolve) => window.setTimeout(resolve, durationMs));
return true;
},
{
x: point.x,
y: point.y,
steps: step.steps,
durationMs: step.durationMs,
direction: step.direction,
}
);
if (!handled) {
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
await ctx.page.evaluate(
async ({ x, y, steps, durationMs, delta }) => {
const wait = (ms: number) =>
new Promise<void>((resolve) => window.setTimeout(resolve, ms));
const perStep = Math.floor(durationMs / Math.max(1, steps));
for (let i = 0; i < steps; i++) {
const target = document.elementFromPoint(x, y) ?? document.querySelector('canvas');
target?.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
deltaY: delta,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
view: window,
})
);
if (perStep > 0) await wait(perStep);
}
},
{ x: point.x, y: point.y, steps: step.steps, durationMs: step.durationMs, delta }
);
if (perStepMs > 0) await sleep(0);
}
if (step.waitForMapSettled) {
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
}
return;
}
case 'dragSlider':
case 'dragSlider': {
const mapVersion = ctx.dashboard.getMapDataVersion();
ctx.cursor = await smoothDragSliderThumb(
ctx.page,
step.thumbSelector,
@ -214,12 +336,21 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
step.toFraction,
step.durationMs
);
if (step.waitForMapSettled) {
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
}
return;
case 'submitForm':
}
case 'submitForm': {
const mapVersion = ctx.dashboard.getMapDataVersion();
await ctx.page.evaluate((selector) => {
document.querySelector<HTMLFormElement>(selector)?.requestSubmit();
}, step.formSelector);
if (step.waitForMapSettled) {
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
}
return;
}
case 'showOutro':
await showOutro(ctx.page, step.brand, step.tagline, step.url);
return;
@ -269,6 +400,30 @@ async function resolveTarget(
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function tryResolveTarget(
ctx: ScriptCtx,
target: Target,
timeoutMs: number
): Promise<{ x: number; y: number } | null> {
if (target.kind !== 'element') {
try {
return await resolveTarget(ctx, target);
} catch {
return null;
}
}
const locator = ctx.page.locator(target.selector).first();
try {
await locator.waitFor({ state: 'visible', timeout: timeoutMs });
const box = await locator.boundingBox({ timeout: timeoutMs });
if (!box) return null;
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
} catch {
return null;
}
}
/**
* Load synth's measured cue durations. Falls back to a worst-case estimate
* if the manifest is missing that path is only used for ``--no-audio``