LGTM
This commit is contained in:
parent
a8165249a4
commit
a4103b0896
64 changed files with 5376 additions and 3832 deletions
|
|
@ -231,6 +231,19 @@ poster_time_for() {
|
|||
' "$1"
|
||||
}
|
||||
|
||||
# Resolve the FINAL published video dimensions for a storyboard. The
|
||||
# recording happens at the CSS viewport, but the encode pass upscales to
|
||||
# `captureScale x viewport` via lanczos so the published mp4 is true
|
||||
# 1080x1920 on mobile rather than a soft 540x960. Returns "WxH".
|
||||
published_size_for() {
|
||||
node -e '
|
||||
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
|
||||
const sb = idx.storyboards.find(s => s.name === process.argv[1]);
|
||||
if (!sb || !sb.publishedSize) { process.exit(1); }
|
||||
process.stdout.write(`${sb.publishedSize.width}x${sb.publishedSize.height}`);
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# -- per-storyboard wipe of leaking artefacts --------------------------------
|
||||
# output/<sb>/audio/ is preserved; tts/synth.py decides whether the cached
|
||||
# WAVs still match the script and skips generation when they do. In resume
|
||||
|
|
@ -273,13 +286,36 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
say "Synchronising tts/ Python deps"
|
||||
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
|
||||
|
||||
# Voice consistency: every ad in this set declares the same AD_VOICE
|
||||
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
|
||||
# VoiceDesign, independent invocations across processes can produce
|
||||
# mildly different reference waveforms — different enough that a
|
||||
# listener notices the timbre shift across ads. To avoid that, we
|
||||
# mint the reference WAV ONCE (from the first storyboard) and reuse
|
||||
# it across the rest of the storyboards by copying _reference.wav +
|
||||
# _reference.meta.json into their audio dirs before their synth runs.
|
||||
# synth.py's _resolve_reference() reuses a matching cached reference
|
||||
# as long as the meta block (instruct/language/seed/etc.) matches —
|
||||
# which it always does, because every ad shares AD_VOICE.
|
||||
shared_ref_wav=""
|
||||
shared_ref_meta=""
|
||||
for sb in "${STORYBOARDS[@]}"; do
|
||||
say "Synthesising narration for [$sb] — one batched call"
|
||||
if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
|
||||
mkdir -p "output/$sb/audio"
|
||||
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
|
||||
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
|
||||
fi
|
||||
say "Synthesising narration for [$sb]"
|
||||
uv run --project tts python tts/synth.py --storyboard "$sb" \
|
||||
|| fail "tts/synth.py failed for $sb"
|
||||
if [ ! -s "output/$sb/audio/index.json" ]; then
|
||||
fail "synth did not produce output/$sb/audio/index.json"
|
||||
fi
|
||||
if [ -z "$shared_ref_wav" ] && [ -f "output/$sb/audio/_reference.wav" ]; then
|
||||
shared_ref_wav="output/$sb/audio/_reference.wav"
|
||||
shared_ref_meta="output/$sb/audio/_reference.meta.json"
|
||||
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
|
@ -305,7 +341,16 @@ fi
|
|||
for sb in "${STORYBOARDS[@]}"; do
|
||||
if [ "$DO_ENCODE" = "1" ]; then
|
||||
say "[$sb] Encoding to MP4"
|
||||
# Lanczos upscale the recording to its published dimensions
|
||||
# (captureScale × viewport). For captureScale=1 the filter is a
|
||||
# no-op and ffmpeg copies the size through; for captureScale=2
|
||||
# mobile cuts go 540x960 → 1080x1920 sharply because Chromium
|
||||
# already rasterised internally at DPR=2.
|
||||
pub_size="$(published_size_for "$sb")"
|
||||
pub_w="${pub_size%x*}"
|
||||
pub_h="${pub_size#*x}"
|
||||
ffmpeg -y -loglevel warning -i "output/$sb/recording.webm" \
|
||||
-vf "scale=${pub_w}:${pub_h}:flags=lanczos" \
|
||||
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
|
||||
-movflags +faststart \
|
||||
"output/$sb/recording.mp4"
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export class DashboardRecorder {
|
|||
throw new Error('No recorded hexagon response is available for map clicking');
|
||||
}
|
||||
|
||||
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
|
||||
const mapBox = await this.mapBoundingBox();
|
||||
if (!mapBox) throw new Error('Map container has no bounding box');
|
||||
const clear = await this.clickableBox(mapBox);
|
||||
|
||||
|
|
@ -152,6 +152,23 @@ export class DashboardRecorder {
|
|||
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the map container's bounding box. DesktopMapPage tags it with
|
||||
* `[data-tutorial="map"]`, but MobileMapPage does not, so on mobile we
|
||||
* fall back to the maplibre-gl canvas element. The canvas is always
|
||||
* present once the map has loaded (we already wait for `canvas` in
|
||||
* prepareTimeline). The first canvas in the document IS the map.
|
||||
*/
|
||||
private async mapBoundingBox(): Promise<
|
||||
{ x: number; y: number; width: number; height: number } | null
|
||||
> {
|
||||
const desktopAnchor = this.page.locator('[data-tutorial="map"]').first();
|
||||
if ((await desktopAnchor.count()) > 0) {
|
||||
return desktopAnchor.boundingBox();
|
||||
}
|
||||
return this.page.locator('canvas').first().boundingBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pixel rect inside `mapBox` that's safe to click — i.e. not under
|
||||
* the dashboard's left filters pane, right details pane, or (on mobile)
|
||||
|
|
@ -279,7 +296,7 @@ export class DashboardRecorder {
|
|||
const snapshot = this.lastPostcodes;
|
||||
if (!snapshot || snapshot.features.length === 0) return [];
|
||||
|
||||
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
|
||||
const mapBox = await this.mapBoundingBox();
|
||||
if (!mapBox) throw new Error('Map container has no bounding box');
|
||||
const clear = await this.clickableBox(mapBox);
|
||||
|
||||
|
|
|
|||
277
video/src/dom.ts
277
video/src/dom.ts
|
|
@ -1,5 +1,5 @@
|
|||
import type { Page } from 'playwright';
|
||||
import type { AdScene } from './script.js';
|
||||
import type { AdScene, AdScenePanel } from './script.js';
|
||||
|
||||
/**
|
||||
* Inject a visible cursor that mirrors the real mouse position. The browser's
|
||||
|
|
@ -71,30 +71,67 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
}
|
||||
#__demo-vignette.gone { opacity: 0; }
|
||||
|
||||
/*
|
||||
* Caption positioning rules of thumb:
|
||||
* Vertical (9:16) cuts MUST keep the caption inside the top ~62% of
|
||||
* the viewport. TikTok, Reels, and Shorts overlay their own chrome
|
||||
* across the bottom ~30%, so anything below y=68% gets eaten by
|
||||
* the platform UI. Mobile dashboard captures also have a sheet
|
||||
* covering the bottom half, so a low caption sits over filter
|
||||
* controls rather than over the map.
|
||||
* Horizontal (16:9) cuts can use the classic lower-third instead.
|
||||
* The body class is set once at recorder setup (setAspectClass) so
|
||||
* every cue inherits the right positioning.
|
||||
*/
|
||||
#__demo-caption {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 6.5%;
|
||||
transform: translate(-50%, 28px);
|
||||
width: max-content;
|
||||
max-width: min(1160px, 78vw);
|
||||
padding: 18px 28px;
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.84);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
color: #f0fdfa;
|
||||
font: 650 32px/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
max-width: min(1160px, 86vw);
|
||||
padding: 22px 30px;
|
||||
border-radius: 22px;
|
||||
background: rgba(2, 6, 23, 0.92);
|
||||
backdrop-filter: blur(20px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||
color: #ffffff;
|
||||
font:
|
||||
800 36px/1.22 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||
sans-serif;
|
||||
letter-spacing: -0.005em;
|
||||
text-align: center;
|
||||
box-shadow: 0 18px 54px rgba(0,0,0,0.38), inset 0 0 0 1px rgba(255,255,255,0.1);
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 0 1.5px rgba(255, 255, 255, 0.16);
|
||||
z-index: 2147483641;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition:
|
||||
opacity 280ms ease-out,
|
||||
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
white-space: normal;
|
||||
}
|
||||
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
|
||||
/* Horizontal default: classic lower-third. */
|
||||
body.__demo-aspect-horizontal #__demo-caption {
|
||||
bottom: 7%;
|
||||
}
|
||||
/* Vertical default: upper-third. Sized DOWN deliberately — the
|
||||
caption must not occlude the map below it. ~30px on a 540-wide
|
||||
viewport sits at ~5.6% of viewport width per character; a single
|
||||
line typically wraps in 2 rows of large bold text. */
|
||||
body.__demo-aspect-vertical #__demo-caption {
|
||||
top: 9%;
|
||||
max-width: min(880px, 84vw);
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
padding: 14px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
#__demo-caption.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
#__demo-outro {
|
||||
position: fixed; inset: 0;
|
||||
|
|
@ -128,32 +165,42 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
#__demo-outro-brand {
|
||||
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
|
||||
margin: 0 0 16px;
|
||||
font: 850 72px/1.05 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
margin: 0 0 18px;
|
||||
background: linear-gradient(90deg, #5eead4, #14b8a6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
#__demo-outro-tagline {
|
||||
font: 500 28px/1.4 ui-sans-serif, system-ui, sans-serif;
|
||||
color: #cbd5e1;
|
||||
margin: 0 0 28px;
|
||||
font: 600 28px/1.36 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 30px;
|
||||
max-width: 28ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
#__demo-outro-url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 24px;
|
||||
padding: 18px 26px;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border: 1px solid rgba(94, 234, 212, 0.36);
|
||||
box-shadow: 0 22px 60px rgba(0,0,0,0.32);
|
||||
font: 700 34px/1 ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: 0;
|
||||
color: #99f6e4;
|
||||
background: rgba(2, 6, 23, 0.92);
|
||||
border: 1.5px solid rgba(45, 212, 191, 0.6);
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 36px rgba(45, 212, 191, 0.18);
|
||||
font: 800 36px/1 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
color: #5eead4;
|
||||
}
|
||||
/* Tighter outro for vertical 9:16 — the brand/url stack must fit
|
||||
comfortably inside the platform-safe centre column. */
|
||||
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
|
||||
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
|
||||
body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; }
|
||||
|
||||
.__ad-scene {
|
||||
position: fixed;
|
||||
|
|
@ -177,21 +224,39 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0.86) 0%, rgba(2, 6, 23, 0.46) 44%, rgba(2, 6, 23, 0.88) 100%),
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0.9) 0%, rgba(2, 6, 23, 0.66) 50%, rgba(2, 6, 23, 0.94) 100%),
|
||||
linear-gradient(135deg, var(--ad-accent-soft), transparent 38%, rgba(15, 23, 42, 0.38));
|
||||
backdrop-filter: blur(2px) saturate(0.92);
|
||||
-webkit-backdrop-filter: blur(2px) saturate(0.92);
|
||||
backdrop-filter: blur(3px) saturate(0.92);
|
||||
-webkit-backdrop-filter: blur(3px) saturate(0.92);
|
||||
}
|
||||
/*
|
||||
* Transparent mode: no scrim, no blur — the product stays fully
|
||||
* visible behind a floating kicker + title. Used by mid-cue hook
|
||||
* stings ("Postcode polygraph") that should not occlude the demo.
|
||||
*/
|
||||
.__ad-scene.transparent .__ad-scrim { display: none; }
|
||||
.__ad-scene.transparent .__ad-title,
|
||||
.__ad-scene.transparent .__ad-body {
|
||||
text-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.75),
|
||||
0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.__ad-scene.transparent .__ad-frame {
|
||||
background: linear-gradient(180deg, rgba(2, 6, 23, 0.78), rgba(2, 6, 23, 0.18) 60%, transparent);
|
||||
padding-bottom: 60px;
|
||||
bottom: auto;
|
||||
height: 60%;
|
||||
}
|
||||
.__ad-frame {
|
||||
position: absolute;
|
||||
top: 118px;
|
||||
left: 58px;
|
||||
right: 58px;
|
||||
bottom: 330px;
|
||||
top: 6%;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
bottom: 22%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
gap: 22px;
|
||||
}
|
||||
.__ad-kicker {
|
||||
align-self: flex-start;
|
||||
|
|
@ -215,16 +280,58 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
margin: 0;
|
||||
max-width: 940px;
|
||||
color: #fff;
|
||||
font: 850 82px/1.02 ui-sans-serif, system-ui, sans-serif;
|
||||
font: 850 64px/1.04 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: -0.012em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.__ad-body {
|
||||
max-width: 890px;
|
||||
margin: 0;
|
||||
color: #dbeafe;
|
||||
font: 560 38px/1.24 ui-sans-serif, system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
font: 580 32px/1.26 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.__ad-image {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.__ad-image-caption {
|
||||
margin-top: -6px;
|
||||
color: #cbd5e1;
|
||||
font: 600 22px/1.3 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
.__ad-image-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.__ad-image-split .__ad-image {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
.__ad-image-split-meta {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(2, 6, 23, 0.86);
|
||||
color: #f8fafc;
|
||||
font: 700 22px/1.15 "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
.__ad-image-split-cell {
|
||||
position: relative;
|
||||
}
|
||||
.__ad-image-split-cell.good .__ad-image-split-meta { color: #86efac; }
|
||||
.__ad-image-split-cell.warn .__ad-image-split-meta { color: #fde68a; }
|
||||
.__ad-image-split-cell.bad .__ad-image-split-meta { color: #fda4af; }
|
||||
.__ad-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -323,7 +430,24 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
justify-content: center;
|
||||
}
|
||||
.__ad-scene.mode-title .__ad-title {
|
||||
font-size: 94px;
|
||||
font-size: 78px;
|
||||
}
|
||||
/*
|
||||
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
|
||||
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
|
||||
* a 64px title is ~12% of viewport width per line — still big and bold,
|
||||
* but no longer overflows the available space the way 82px did on the
|
||||
* old 1080-wide ad config. Captions also re-anchor to the upper third
|
||||
* via body.__demo-aspect-vertical.
|
||||
*/
|
||||
body.__demo-aspect-vertical .__ad-scene .__ad-title {
|
||||
font-size: 58px;
|
||||
}
|
||||
body.__demo-aspect-vertical .__ad-scene.mode-title .__ad-title {
|
||||
font-size: 68px;
|
||||
}
|
||||
body.__demo-aspect-vertical .__ad-scene .__ad-body {
|
||||
font-size: 28px;
|
||||
}
|
||||
.__ad-scene.mode-comment .__ad-comment {
|
||||
margin-bottom: 12px;
|
||||
|
|
@ -421,6 +545,28 @@ export async function clearVignette(page: Page): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the document body with the aspect class the caption / overlay CSS keys
|
||||
* off. Run once during recorder setup so every cue inherits the right
|
||||
* positioning without per-call overrides. The body class is the cheapest
|
||||
* stable signal — the storyboard's `video.aspect` knows the truth and we
|
||||
* surface it once into the DOM.
|
||||
*/
|
||||
export async function setAspectClass(
|
||||
page: Page,
|
||||
aspect: '16x9' | '9x16'
|
||||
): Promise<void> {
|
||||
await page.evaluate((aspect) => {
|
||||
document.body.classList.remove(
|
||||
'__demo-aspect-horizontal',
|
||||
'__demo-aspect-vertical'
|
||||
);
|
||||
document.body.classList.add(
|
||||
aspect === '9x16' ? '__demo-aspect-vertical' : '__demo-aspect-horizontal'
|
||||
);
|
||||
}, aspect);
|
||||
}
|
||||
|
||||
export async function showCaption(page: Page, text: string): Promise<void> {
|
||||
await page.evaluate((t) => {
|
||||
const el = document.getElementById('__demo-caption');
|
||||
|
|
@ -514,7 +660,9 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
|
|||
const root =
|
||||
document.getElementById('__ad-scene') ?? document.createElement('div');
|
||||
root.id = '__ad-scene';
|
||||
root.className = `__ad-scene mode-${mode} accent-${accent}`;
|
||||
root.className =
|
||||
`__ad-scene mode-${mode} accent-${accent}` +
|
||||
(s.transparent ? ' transparent' : '');
|
||||
root.replaceChildren();
|
||||
|
||||
const scrim = make('div', '__ad-scrim');
|
||||
|
|
@ -522,10 +670,54 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
|
|||
|
||||
if (s.comment) frame.appendChild(make('div', '__ad-comment', s.comment));
|
||||
if (s.kicker) frame.appendChild(make('div', '__ad-kicker', s.kicker));
|
||||
|
||||
// Side-by-side photos (used by ads like "two streets apart"). When the
|
||||
// ad has split images, render them in place of the panel grid: the
|
||||
// photos themselves are the comparison, not just text labels.
|
||||
if (s.images) {
|
||||
const split = make('div', '__ad-image-split');
|
||||
const [leftSrc, rightSrc] = s.images;
|
||||
const buildCell = (src: string, panel?: AdScenePanel): HTMLElement => {
|
||||
const cell = make('div', `__ad-image-split-cell ${panel?.tone ?? 'neutral'}`);
|
||||
const img = document.createElement('img');
|
||||
img.className = '__ad-image';
|
||||
// Skip crossOrigin so the request goes through as a vanilla
|
||||
// image fetch and CORS headers on the Unsplash CDN are not
|
||||
// required. We never read pixels back out — display only.
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onerror = () => img.remove();
|
||||
img.src = src;
|
||||
cell.appendChild(img);
|
||||
if (panel?.title || panel?.meta) {
|
||||
const meta = make(
|
||||
'div',
|
||||
'__ad-image-split-meta',
|
||||
[panel?.title, panel?.meta].filter(Boolean).join(' · ')
|
||||
);
|
||||
cell.appendChild(meta);
|
||||
}
|
||||
return cell;
|
||||
};
|
||||
split.appendChild(buildCell(leftSrc, s.left));
|
||||
split.appendChild(buildCell(rightSrc, s.right));
|
||||
frame.appendChild(split);
|
||||
} else if (s.image) {
|
||||
const img = document.createElement('img');
|
||||
img.className = '__ad-image';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onerror = () => img.remove();
|
||||
img.src = s.image;
|
||||
frame.appendChild(img);
|
||||
if (s.imageCaption) {
|
||||
frame.appendChild(make('div', '__ad-image-caption', s.imageCaption));
|
||||
}
|
||||
}
|
||||
|
||||
frame.appendChild(make('h1', '__ad-title', s.title));
|
||||
if (s.body) frame.appendChild(make('p', '__ad-body', s.body));
|
||||
|
||||
if (s.left || s.right) {
|
||||
// Text-only side panels are only used when there are no photos.
|
||||
if (!s.images && (s.left || s.right)) {
|
||||
const split = make('div', '__ad-split');
|
||||
for (const panel of [s.left, s.right]) {
|
||||
if (!panel) continue;
|
||||
|
|
@ -595,7 +787,8 @@ export async function showOutro(
|
|||
taglineEl.textContent = tagline;
|
||||
const urlEl = document.createElement('div');
|
||||
urlEl.id = '__demo-outro-url';
|
||||
urlEl.textContent = url;
|
||||
// Drop the protocol so the CTA reads as a bare domain.
|
||||
urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
card.append(brandEl, taglineEl, urlEl);
|
||||
el.appendChild(card);
|
||||
document.body.appendChild(el);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OUTPUT_DIR } from './config.js';
|
||||
import type { Storyboard } from './script.js';
|
||||
import { publishedSizeFor, type Storyboard } from './script.js';
|
||||
import { storyboards } from './storyboard.js';
|
||||
|
||||
/**
|
||||
|
|
@ -75,6 +75,8 @@ function main(): void {
|
|||
minDurationS: sb.video.minDurationS,
|
||||
maxDurationS: sb.video.maxDurationS,
|
||||
posterTimeS: sb.video.posterTimeS,
|
||||
// Final mp4 dimensions after render.sh's lanczos upscale.
|
||||
publishedSize: publishedSizeFor(sb.video),
|
||||
})),
|
||||
};
|
||||
const indexPath = join(OUTPUT_DIR, 'storyboards.json');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
clearVignette,
|
||||
hideAdScene,
|
||||
hideCaption,
|
||||
scrollPaneTo,
|
||||
setCursorScale,
|
||||
showAdScene,
|
||||
showCaption,
|
||||
|
|
@ -228,6 +229,21 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
case 'hideAdScene':
|
||||
await hideAdScene(ctx.page);
|
||||
return;
|
||||
case 'scrollPane':
|
||||
await scrollPaneTo(ctx.page, step.selector, step.top);
|
||||
return;
|
||||
case 'openFilterGroup':
|
||||
// Click is idempotent: if the group is already expanded, the click
|
||||
// would collapse it — which we don't want. Detect via aria-expanded
|
||||
// (Radix Accordion sets it on the trigger) and skip the click when
|
||||
// the group is already open.
|
||||
await ctx.page.evaluate((selector) => {
|
||||
const trigger = document.querySelector<HTMLElement>(selector);
|
||||
if (!trigger) return;
|
||||
if (trigger.getAttribute('aria-expanded') === 'true') return;
|
||||
trigger.click();
|
||||
}, step.selector);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ export interface AdScene {
|
|||
left?: AdScenePanel;
|
||||
right?: AdScenePanel;
|
||||
items?: AdSceneItem[];
|
||||
/** Optional single hero photo (URL) shown above the title. */
|
||||
image?: string;
|
||||
/** Optional [left, right] photos for split mode — used by "two streets apart" style ads. */
|
||||
images?: [string, string];
|
||||
/** Optional caption shown under the image for attribution / context. */
|
||||
imageCaption?: string;
|
||||
/**
|
||||
* If true, render the scene with a transparent background so the dashboard
|
||||
* stays visible behind. Useful for hooks where you want a floating kicker
|
||||
* + title without occluding the live product. Defaults to false (full
|
||||
* scrim, used by the closing "title" cards).
|
||||
*/
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
/** A point on screen, resolved at runtime to viewport pixels. */
|
||||
|
|
@ -140,7 +153,19 @@ export type Activity =
|
|||
/** Fade the ad overlay away. */
|
||||
| { kind: 'hideAdScene'; durationMs: number }
|
||||
/** Fade away the opening vignette. */
|
||||
| { kind: 'clearVignette'; durationMs: number };
|
||||
| { kind: 'clearVignette'; durationMs: number }
|
||||
/**
|
||||
* Smoothly scroll the closest scrollable ancestor of `selector` to
|
||||
* absolute pixel `top`. Used to surface a specific filter card or to
|
||||
* scroll through the property-stats drawer after a postcode click.
|
||||
*/
|
||||
| { kind: 'scrollPane'; selector: string; top: number; durationMs: number }
|
||||
/**
|
||||
* Click the header of a collapsible filter group (e.g. "Transport",
|
||||
* "Education") so the cards beneath it become visible. Idempotent —
|
||||
* if the group is already open this is a no-op click.
|
||||
*/
|
||||
| { kind: 'openFilterGroup'; selector: string; durationMs: number };
|
||||
|
||||
/**
|
||||
* A narration cue + the activities that play alongside it.
|
||||
|
|
@ -281,12 +306,27 @@ export function viewportFor(video: VideoConfig): { width: number; height: number
|
|||
}
|
||||
|
||||
/**
|
||||
* Recorded video resolution. Equal to the CSS viewport because
|
||||
* Playwright's recordVideo writes frames at CSS pixel size regardless
|
||||
* of `deviceScaleFactor`. Kept as a separate function so future
|
||||
* supersample + post-encode flows (e.g. ffmpeg lanczos upscale) can
|
||||
* plug in here without touching verify.ts.
|
||||
* Recorded video resolution. Equal to the CSS viewport.
|
||||
*
|
||||
* Playwright's recordVideo captures the page at its CSS-pixel surface, so
|
||||
* passing a size larger than the viewport just letterboxes the content
|
||||
* into the top-left of an empty frame — not a true high-DPR raster.
|
||||
* Final-resolution upscale (e.g. mobile 540x960 → 1080x1920) is done in
|
||||
* render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a
|
||||
* sharp upscale because Chromium rasterises internally at DPR=captureScale.
|
||||
*/
|
||||
export function recordedSizeFor(video: VideoConfig): { width: number; height: number } {
|
||||
return viewportFor(video);
|
||||
}
|
||||
|
||||
/**
|
||||
* The final mp4's resolution (after the lanczos upscale pass in render.sh).
|
||||
* Storyboards drive their on-screen typography from CSS viewport sizes, but
|
||||
* social platforms care about the file resolution — so we expose a
|
||||
* separate getter for the published dimensions.
|
||||
*/
|
||||
export function publishedSizeFor(video: VideoConfig): { width: number; height: number } {
|
||||
const viewport = viewportFor(video);
|
||||
const scale = Math.max(1, Math.round(video.captureScale));
|
||||
return { width: viewport.width * scale, height: viewport.height * scale };
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
import type { Page } from 'playwright';
|
||||
import { DashboardRecorder } from './dashboard.js';
|
||||
import { installCursor, installZoomWrapper } from './dom.js';
|
||||
import { installCursor, installZoomWrapper, setAspectClass } from './dom.js';
|
||||
import { sleep } from './motion.js';
|
||||
import { dashboardUrl } from './routes.js';
|
||||
import { runStoryboard, type RunnerResult } from './runner.js';
|
||||
|
|
@ -30,6 +30,7 @@ export async function prepareTimeline(
|
|||
await sleep(400);
|
||||
await installZoomWrapper(page);
|
||||
await installCursor(page);
|
||||
await setAspectClass(page, storyboard.video.aspect);
|
||||
|
||||
const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } };
|
||||
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { OUTPUT_DIR } from './config.js';
|
||||
import { recordedSizeFor, type Storyboard } from './script.js';
|
||||
import { publishedSizeFor, recordedSizeFor, type Storyboard } from './script.js';
|
||||
import { getStoryboard } from './storyboard.js';
|
||||
|
||||
interface Probe {
|
||||
|
|
@ -58,7 +58,10 @@ function verifyVideo(path: string, storyboard: Storyboard) {
|
|||
const stream = data.streams?.[0];
|
||||
if (!stream) fail(`${path} has no video stream`);
|
||||
|
||||
const expectedSize = recordedSizeFor(storyboard.video);
|
||||
// .webm is at CSS-pixel size; .mp4 is upscaled to publishedSize.
|
||||
const expectedSize = path.endsWith('.webm')
|
||||
? recordedSizeFor(storyboard.video)
|
||||
: publishedSizeFor(storyboard.video);
|
||||
const { minDurationS, maxDurationS, outputFps } = storyboard.video;
|
||||
|
||||
const duration = Number(data.format?.duration ?? 0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue