chore(video): smooth marketing reel output

Note: the drag/drop reel works finally with the native app drag preview path.
This commit is contained in:
johannesjo 2026-05-09 23:44:47 +02:00
parent a448053d6b
commit 1fc048df61
6 changed files with 478 additions and 176 deletions

View file

@ -11,6 +11,7 @@ npm run video:full # full variant (~21s) → dist/video/reel-full*.{...}
# under the hood
npm run video:capture # Playwright records to .tmp/video/recordings/
npm run video:build # ffmpeg → dist/video/, picks the most recent webm
npm run video:open # opens an autoplay browser preview, skips in CI
```
`REEL_VARIANT=<name>` switches the spec branch and adds a filename suffix so multiple variants coexist in `dist/video/`. `full` is the only one wired up so far; add more by branching on `isFull`-style flags in the spec.
@ -23,22 +24,23 @@ npm run video:build # ffmpeg → dist/video/, picks the most recent webm
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `playwright.store-video.config.ts` | Single chromium project, `video: 'off'` at project level (the fixture handles `recordVideo` itself because `browser.newContext()` doesn't inherit `use.video`). |
| `store-video/fixture.ts` | Custom context with `recordVideo` enabled at 1024×1024 / DPR 2. Reuses the screenshot pipeline's seed builder. Init scripts handle: cursor highlight ring, dialog/snack/tooltip/mention suppression, app zoom. |
| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`. Plus inline brand SVGs in the `LOGOS` constant. |
| `store-video/overlays.ts` | DOM-injected overlay primitives: `showOverlay`, `showCaption`, `showIntegrationsCard`, `showEndCard`, `cutToScene`, `fadeTransition`, `loopBoundary`, `attachDragGhost`, `smoothMouseMove`. Plus inline brand SVGs in the `LOGOS` constant. |
| `store-video/scenarios/reel.spec.ts` | Six-beat choreography. `REEL_VARIANT=full` triggers the optional "No account. No tracking." beat and relaxes hold timings. |
| `store-video/build-video.ts` | Picks the most recent `.webm` under `.tmp/video/recordings/`, applies the trim sidecar (cuts the seed-import lead-in), produces mp4/webm/gif via ffmpeg, optionally `gifsicle`-optimizes. |
| `store-video/open-video.ts` | Opens an autoplay browser preview after `npm run video`. Prefers mp4, respects `REEL_VARIANT`, seeks slightly past the black first frame for preview only, and skips auto-open in CI. |
## Beat structure (current)
```
Lead-in black fades to SP UI with schedule day-panel already open
1 Capture in seconds. global add-task-bar (scaled 1.45×, max-width
520px); types "A task 1h #urgent @17" with
55ms keystroke delay. Captured task carries
through to beats 2 and 3.
1 Capture in seconds. global add-task-bar; types
"A task 1h" with 55ms keystroke delay.
Captured task carries through to beats 2 and 3.
1.5 [full only] No account. No tracking.
2 Plan your day. drags first task onto schedule panel with
`attachDragGhost` cloning the source element
under the cursor.
2 Plan your day. drags the newly captured "A task" item onto
the schedule panel with the app's native CDK
drag behavior and Playwright's stepped mouse
movement.
3 Focus on what matters. dispatches `[Task] SetCurrentTask` /
`[FocusMode] Show Overlay` /
`[FocusMode] Start Session` directly via
@ -56,17 +58,16 @@ Lead-in black fades to SP UI with schedule day-panel already open
Boundary black fades in so the gif loop seam is black-to-black
```
## Scene transitions: cutToScene
## Scene transitions
Every beat handoff uses `cutToScene(page, async () => { ... })` for a fade-to-black scene cut. The helper:
The main reel uses two transition styles:
1. Fades a max-z-index black overlay from transparent to opaque (covers everything, including any beat overlays/cards).
2. Runs the callback while the screen is fully black — this is where state changes happen (close add-task-bar, dispatch focus mode, hide previous overlay, prime next overlay/card).
3. Fades the black back out to reveal whatever the callback set up.
1. `cutToScene(page, async () => { ... })` for app state changes and bigger app-to-screen jumps. It fades a max-z-index black overlay in, runs setup while black covers the app, then fades back out. Beat 1 → 2 closes the add-task-bar by clicking the real backdrop inside this covered callback, so list reflow and cursor reset are hidden before the drag starts. Pass a `label` to log how long setup spent behind black.
2. Direct card crossfade for controlled full-screen cards. Beat 4 → 5 shows the end card above the integrations card, then hides the integrations card underneath once the end card is mostly opaque.
**Always pass `noWait: true` to the next overlay/card inside the callback.** Without it, the call awaits its own fade-in (and stagger animation, for `showIntegrationsCard`/`showEndCard`) — which would play behind the still-opaque black and be wasted. With `noWait`, the call returns as soon as the DOM is in place, so the fade-in animates concurrently with `cutToScene`'s fade-from-black. The viewer sees: scene → black → next-scene-emerging-with-its-animation.
**Always pass `noWait: true` to the next overlay/card inside a `cutToScene` callback.** Without it, the call awaits its own fade-in (and stagger animation, for `showIntegrationsCard`/`showEndCard`) — which would play behind the still-opaque black and be wasted. With `noWait`, the call returns as soon as the DOM is in place, so the fade-in animates concurrently with `cutToScene`'s fade-from-black. The viewer sees: scene → black → next-scene-emerging-with-its-animation.
`fadeTransition` (partial-dim, lower z-index) is no longer used by the main spec — it was unreliable for big scene jumps because the dim's transparent regions let the underlying state change show through. Kept in `overlays.ts` in case a future beat wants a softer mid-beat dim, but `cutToScene` is the default.
`fadeTransition` remains available in `overlays.ts`, but keep it out of the drag setup path: its transparent dim can let underlying app reflow leak through and make the first drag frames look wrong.
## Architecture decisions / gotchas
@ -80,16 +81,18 @@ Every beat handoff uses `cutToScene(page, async () => { ... })` for a fade-to-bl
**Schedule day-panel width.** Pre-seeded in `ONBOARDING_INIT` via `localStorage.setItem('SUP_RIGHT_PANEL_WIDTH', '250')` — that's `RIGHT_PANEL_CONFIG.MIN_WIDTH`, the smallest the panel allows before its 200px CLOSE_THRESHOLD kicks in. Pre-seeding through the panel's own persistence path means the inner schedule grid computes its column widths against 250 and the event blocks don't overflow. Earlier iterations forced `width !important` on `.side`, which sized the chrome but didn't propagate to the grid — events then spilled past the panel's right edge and required ugly `overflow-x: hidden` belt-and-braces clips. Don't go that route.
**Add-task-bar overlays.** The fixture only hides the *overlay surfaces* that pop on top of the bar while typing (mat-autocomplete suggestions, mention-list, loading spinner) — those would otherwise read as glitchy white boxes mid-gif. The bar itself is not styled by the fixture; it uses its real `:host` rules.
**Add-task-bar overlays.** The fixture only hides the *overlay surfaces* that pop on top of the bar while typing (mat-autocomplete suggestions, mention-list, loading spinner) — those would otherwise read as glitchy white boxes mid-gif. The bar itself is not styled by the fixture; it uses its real `:host` rules. Beat 1 → 2 closes it by clicking the real `.backdrop`, matching normal UI behavior instead of dispatching layout state directly.
**Cursor highlight.** Soft white radial-gradient ring at `z-index: 2147483640`, follows mousemove. Toggle visibility per-beat via `body.__sp-hide-cursor-highlight` — used during the capture beat where the focused input would otherwise show the ring as a stray dot.
**Main-text consistency.** `.__sp-video-overlay-text`, `.__sp-video-int-card-title`, `.__sp-video-end-card-title` share a single rule with `font-size: clamp(48px, 6.4vw, 96px) !important`. The `!important` flag is required because `.mat-typography h1` has specificity (0,1,1) which outranks our class-only selector. Card titles are `<p>` rather than `<h1>` for belt-and-braces — even with `!important`, the typography font shorthand can sneak through.
**Drag ghost.** Beat 2 uses `attachDragGhost(page, sourceLocator)` which clones the source via `outerHTML`, attaches a mousemove listener, and follows the cursor with a 2° tilt + drop shadow. Detaches on mouse-up. SP's cdkDrag may or may not show its own preview depending on the drop target's `cdkDropList` wiring; the ghost guarantees the act of dragging reads regardless.
**Drag preview.** Beat 2 intentionally does not use a synthetic video ghost. It relies on the app's real CDK drag behavior so the visual preview matches what a user sees while dragging a task onto the schedule panel. Keep the source locator tied to `CAPTURED_TASK_DISPLAY_TITLE`; using `task().first()` can accidentally drag a larger seeded task and make the preview look zoomed.
**Loop boundary.** `loopBoundary(page, 'in', ms)` shows full-black opacity 1 then fades to 0 over `ms` (lead-in). `loopBoundary(page, 'out', ms)` fades from 0 to 1 (closing). Gif seam is black-to-black, no jump cut. `z-index: 2147483647` (max safe) so it covers everything including end card.
**Output cadence.** Playwright's recorder emits 25fps webm in this pipeline. `build-video.ts` keeps MP4, WebM, and GIF at 25fps to avoid duplicate/drop-frame judder during fades and cursor movement.
**Build script picks most-recent webm.** No need to clean `.tmp/video/recordings/` between runs. Old webms accumulate but only the most recent `.mtime` is built into outputs.
**Variant filename suffix.** `build-video.ts` reads `process.env.REEL_VARIANT` and appends `-${variant}` to all output filenames when set. `npm run video:full` works because env vars propagate through `npm run`.

View file

@ -1,8 +1,8 @@
/**
* Post-process the latest Playwright video capture into shippable formats:
* - reel.mp4 1920×1080, 30fps, H.264 yuv420p landing-page fallback
* - reel.webm 1920×1080, 30fps, VP9 landing-page primary
* - reel.gif 1024 wide, 24fps, two-pass palette README embed
* - reel.mp4 1024×1024, 25fps, H.264 yuv420p landing-page fallback
* - reel.webm 1024×1024, 25fps, VP9 landing-page primary
* - reel.gif 1024 wide, 25fps, two-pass palette README embed
*
* Inputs: the most recent `.webm` under `.tmp/video/recordings/` (where the
* fixture's `recordVideo` setting writes). Outputs: `dist/video/`.
@ -29,6 +29,9 @@ const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
*/
const VARIANT = process.env.REEL_VARIANT ?? '';
const SUFFIX = VARIANT ? `-${VARIANT}` : '';
// Playwright's recorder currently emits 25fps VP8 webm. Keeping all derived
// formats on that cadence avoids duplicate/drop-frame judder in fades.
const OUTPUT_FPS = 25;
/**
* Read the trim offset (seconds) from the sidecar the fixture writes when
@ -99,9 +102,11 @@ const main = (): void => {
console.log(`[video] source: ${path.relative(REPO_ROOT, src)}`);
const trimSeconds = readTrimSeconds();
// `-ss` BEFORE `-i` is input seek (fast, frame-accurate when re-encoding).
// Empty array when no trim, so the ffmpeg arg list is clean.
const trimArgs = trimSeconds > 0 ? ['-ss', trimSeconds.toFixed(3)] : [];
// Trim in the filter graph so ffmpeg decodes to the exact trim point for
// every output. Seeking before `-i` is faster, but VP8 keyframes can be
// sparse enough to drop the opening beat from the generated reel.
const trimFilter =
trimSeconds > 0 ? `trim=start=${trimSeconds.toFixed(3)},setpts=PTS-STARTPTS,` : '';
if (trimSeconds > 0) {
console.log(
`[video] trimming first ${trimSeconds.toFixed(3)}s (seed-import lead-in)`,
@ -113,11 +118,10 @@ const main = (): void => {
const palette = path.join(OUT_DIR, `.palette${SUFFIX}.png`);
const gif = path.join(OUT_DIR, `reel${SUFFIX}.gif`);
// 1. mp4 — Playwright records VFR; force CFR 30fps for predictable playback.
// 1. mp4 — Playwright records VFR; force CFR for predictable playback.
console.log('[video] -> mp4');
run('ffmpeg', [
'-y',
...trimArgs,
'-i',
src,
'-c:v',
@ -129,7 +133,7 @@ const main = (): void => {
'-pix_fmt',
'yuv420p',
'-vf',
'fps=30',
`${trimFilter}fps=${OUTPUT_FPS}`,
'-movflags',
'+faststart',
'-an',
@ -140,7 +144,6 @@ const main = (): void => {
console.log('[video] -> webm');
run('ffmpeg', [
'-y',
...trimArgs,
'-i',
src,
'-c:v',
@ -152,7 +155,7 @@ const main = (): void => {
'-pix_fmt',
'yuv420p',
'-vf',
'fps=30',
`${trimFilter}fps=${OUTPUT_FPS}`,
'-an',
webm,
]);
@ -168,23 +171,21 @@ const main = (): void => {
console.log('[video] -> gif (palette pass)');
run('ffmpeg', [
'-y',
...trimArgs,
'-i',
src,
'-vf',
'fps=24,scale=1024:-1:flags=lanczos,palettegen=stats_mode=full',
`${trimFilter}fps=${OUTPUT_FPS},scale=1024:-1:flags=lanczos,palettegen=stats_mode=full`,
palette,
]);
console.log('[video] -> gif (paletteuse)');
run('ffmpeg', [
'-y',
...trimArgs,
'-i',
src,
'-i',
palette,
'-lavfi',
'fps=24,scale=1024:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a',
`${trimFilter}fps=${OUTPUT_FPS},scale=1024:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=sierra2_4a`,
gif,
]);
fs.unlinkSync(palette);

View file

@ -0,0 +1,124 @@
/**
* Opens the generated marketing reel after `npm run video` finishes building.
*
* Kept separate from `build-video.ts` so `npm run video:build` remains useful
* for automation and CI without launching a local media player.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const OUT_DIR = path.join(REPO_ROOT, 'dist', 'video');
const VARIANT = process.env.REEL_VARIANT ?? '';
const SUFFIX = VARIANT ? `-${VARIANT}` : '';
const PREVIEW_START_SECONDS = 0.35;
const findOutput = (): string | null => {
const candidates = [
`reel${SUFFIX}.mp4`,
`reel${SUFFIX}.webm`,
`reel${SUFFIX}.gif`,
`reel${SUFFIX}-optimized.gif`,
].map((fileName) => path.join(OUT_DIR, fileName));
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
};
const writePreview = (videoPath: string): string => {
const previewPath = path.join(OUT_DIR, `preview${SUFFIX}.html`);
const videoFileName = JSON.stringify(`./${path.basename(videoPath)}`);
const startSeconds = JSON.stringify(PREVIEW_START_SECONDS);
const html = [
'<!doctype html>',
'<html lang="en">',
'<head>',
' <meta charset="utf-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1">',
' <title>Super Productivity reel preview</title>',
' <style>',
' html, body {',
' margin: 0;',
' min-height: 100%;',
' background: #050507;',
' }',
' body {',
' display: grid;',
' place-items: center;',
' }',
' video {',
' width: min(100vw, 100vh);',
' height: min(100vw, 100vh);',
' background: #000;',
' }',
' </style>',
'</head>',
'<body>',
` <video id="reel" src=${videoFileName} controls autoplay muted loop playsinline></video>`,
' <script>',
' const video = document.getElementById("reel");',
` const startSeconds = ${startSeconds};`,
' const start = () => {',
' if (Number.isFinite(video.duration) && video.duration > startSeconds) {',
' video.currentTime = startSeconds;',
' }',
' video.play().catch(() => undefined);',
' };',
' video.addEventListener("loadedmetadata", start, { once: true });',
' </script>',
'</body>',
'</html>',
'',
].join('\n');
fs.writeFileSync(previewPath, html);
return previewPath;
};
const openFile = (filePath: string): void => {
const platform = process.platform;
const command =
platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
const args = platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
const result = spawnSync(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
if (result.error || result.status !== 0) {
const detail = result.error?.message ?? `exit status ${result.status}`;
console.warn(
`[video] could not open ${path.relative(REPO_ROOT, filePath)}: ${detail}`,
);
}
};
const main = (): void => {
if (process.env.CI) {
console.log('[video] CI detected; skipping auto-open.');
return;
}
const output = findOutput();
if (!output) {
console.warn(
`[video] no built reel found under ${path.relative(REPO_ROOT, OUT_DIR)}`,
);
return;
}
const preview = writePreview(output);
console.log(
`[video] opening ${path.relative(REPO_ROOT, preview)} for ${path.relative(
REPO_ROOT,
output,
)}`,
);
openFile(preview);
};
main();

View file

@ -68,6 +68,10 @@ export type OverlayHandle = {
hide: () => Promise<void>;
};
export type CaptionHandle = OverlayHandle & {
update: (text: string, options?: { fadeMs?: number }) => Promise<void>;
};
/**
* One line on the end card. Plain string renders as-is. The object form
* animates the `{n}` placeholder in `template` from 0 up to `to` over the
@ -115,6 +119,7 @@ export type IntegrationsCardContent = {
const STYLE_ID = '__sp-video-overlay-style';
const OVERLAY_ID_PREFIX = '__sp-video-overlay-';
const END_CARD_ID = '__sp-video-end-card';
const CAPTION_ID = '__sp-video-caption';
let overlayCounter = 0;
@ -157,6 +162,11 @@ const ensureStyleInjected = async (page: Page): Promise<void> => {
.__sp-video-overlay.visible .__sp-video-overlay-bg {
transform: translateY(0);
}
.__sp-video-caption .__sp-video-overlay-text {
transition:
opacity var(--__sp-caption-text-ms, 170ms) ease-out,
transform var(--__sp-caption-text-ms, 170ms) ease-out;
}
.__sp-video-overlay.center .__sp-video-overlay-bg {
border-radius: 14px;
max-width: 80vw;
@ -426,6 +436,89 @@ export const showOverlay = async (
};
};
export const showCaption = async (
page: Page,
text: string,
options: Pick<OverlayOptions, 'fadeMs' | 'noWait' | 'position'> = {},
): Promise<CaptionHandle> => {
const position: OverlayPosition = options.position ?? 'lower';
const fadeMs = options.fadeMs ?? 420;
await ensureStyleInjected(page);
await page.evaluate(
(args) => {
document.getElementById(args.id)?.remove();
const el = document.createElement('div');
el.id = args.id;
el.className = `__sp-video-overlay __sp-video-caption ${args.position}`;
el.style.setProperty('--__sp-fade-ms', `${args.fadeMs}ms`);
const bg = document.createElement('div');
bg.className = '__sp-video-overlay-bg';
const p = document.createElement('p');
p.className = '__sp-video-overlay-text';
p.textContent = args.text;
bg.appendChild(p);
el.appendChild(bg);
document.body.appendChild(el);
void el.offsetWidth;
el.classList.add('visible');
},
{ id: CAPTION_ID, text, position, fadeMs },
);
if (!options.noWait) {
await page.waitForTimeout(fadeMs);
}
return {
update: async (nextText, updateOptions = {}): Promise<void> => {
const textFadeMs = updateOptions.fadeMs ?? 170;
const shouldUpdate = await page.evaluate(
(args) => {
const textEl = document.querySelector<HTMLElement>(
`#${args.id} .__sp-video-overlay-text`,
);
if (!textEl || textEl.textContent === args.text) return false;
textEl.style.setProperty('--__sp-caption-text-ms', `${args.fadeMs}ms`);
textEl.style.opacity = '0';
textEl.style.transform = 'translateY(8px)';
return true;
},
{ id: CAPTION_ID, text: nextText, fadeMs: textFadeMs },
);
if (!shouldUpdate) return;
await page.waitForTimeout(textFadeMs);
await page.evaluate(
(args) => {
const textEl = document.querySelector<HTMLElement>(
`#${args.id} .__sp-video-overlay-text`,
);
if (!textEl) return;
textEl.textContent = args.text;
textEl.style.transform = 'translateY(-8px)';
void textEl.offsetWidth;
textEl.style.opacity = '1';
textEl.style.transform = 'translateY(0)';
},
{ id: CAPTION_ID, text: nextText },
);
await page.waitForTimeout(textFadeMs);
},
hide: async (): Promise<void> => {
await page.evaluate((id) => {
const el = document.getElementById(id);
if (!el) return;
el.classList.remove('visible');
}, CAPTION_ID);
await page.waitForTimeout(fadeMs);
await page.evaluate((id) => document.getElementById(id)?.remove(), CAPTION_ID);
},
};
};
const INT_CARD_ID = '__sp-video-int-card';
const TRANSITION_ID = '__sp-video-transition';
const LOOP_BOUNDARY_ID = '__sp-video-loop-boundary';
@ -520,6 +613,7 @@ export const loopBoundary = async (
await page.evaluate(
(args) => {
let el = document.getElementById(args.id);
const transition = `opacity ${args.durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
if (!el) {
el = document.createElement('div');
el.id = args.id;
@ -532,19 +626,21 @@ export const loopBoundary = async (
// Material's standard motion curve (cubic-bezier(0.4, 0, 0.2, 1)
// — slow start, fast middle, slow finish, asymmetrically biased
// toward a snappier reveal) reads smoother for scene cuts than
// a generic `ease-in-out`. At the gif's 24fps a 200ms fade is
// a generic `ease-in-out`. At the gif's 25fps a 200ms fade is
// ~5 frames; the curve concentrates the visible opacity change
// into the middle frames so it reads as a gradient rather than
// a stepped staircase.
`transition:opacity ${args.durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`,
`transition:${transition}`,
// Start opacity matches the mode: 'in' starts opaque (revealing
// SP underneath); 'out' starts transparent (covering it back up).
`opacity:${args.mode === 'in' ? 1 : 0}`,
].join(';');
document.body.appendChild(el);
// Force a paint before the opacity flip so the transition fires.
void el.offsetWidth;
}
el.style.transition = transition;
// Force a paint before the opacity flip so duration changes on the
// reused boundary element take effect for this transition.
void el.offsetWidth;
el.style.opacity = args.mode === 'in' ? '0' : '1';
},
{ id: LOOP_BOUNDARY_ID, mode, durationMs },
@ -576,10 +672,10 @@ export const loopBoundary = async (
export const cutToScene = async (
page: Page,
setupNextScene: () => Promise<void> | void,
options: { fadeMs?: number } = {},
options: { fadeMs?: number; label?: string } = {},
): Promise<void> => {
// 200ms × 2 (fade-to-black + fade-from-black) = 400ms per scene cut.
// At the gif's 24fps that's ~5 frames per fade. Combined with Material
// At the gif's 25fps that's ~5 frames per fade. Combined with Material
// motion curve and sierra2_4a dither (see build-video.ts) this reads as
// a smooth gradient at this duration. Going shorter starts to look
// stepped; much longer drags out the reel.
@ -588,7 +684,13 @@ export const cutToScene = async (
// higher than any beat overlay/card, so it covers everything).
await loopBoundary(page, 'out', fadeMs);
// Behind black: prepare next scene.
const setupStartedAt = Date.now();
await setupNextScene();
if (options.label) {
console.log(
`[video] ${options.label}: setup behind black ${Date.now() - setupStartedAt}ms`,
);
}
// Fade black away to reveal the next scene.
await loopBoundary(page, 'in', fadeMs);
};
@ -604,7 +706,7 @@ export const cutToScene = async (
export const fadeTransition = async (
page: Page,
during: () => Promise<void> | void,
options: { fadeMs?: number; opacity?: number } = {},
options: { fadeMs?: number; opacity?: number; label?: string } = {},
): Promise<void> => {
// Slightly longer fade with a softer dim — the lower-third overlay
// text rides above the dim layer (higher z-index), so reducing the
@ -634,7 +736,13 @@ export const fadeTransition = async (
{ id: TRANSITION_ID, fadeMs, opacity },
);
await page.waitForTimeout(fadeMs);
const setupStartedAt = Date.now();
await during();
if (options.label) {
console.log(
`[video] ${options.label}: setup under dim ${Date.now() - setupStartedAt}ms`,
);
}
await page.evaluate(
(args) => {
const el = document.getElementById(args.id);
@ -647,6 +755,43 @@ export const fadeTransition = async (
await page.waitForTimeout(fadeMs);
};
const easeInOutCubic = (t: number): number => {
if (t < 0.5) {
return 4 * t * t * t;
}
const scaled = -2 * t;
const shifted = scaled + 2;
const cubed = Math.pow(shifted, 3);
const halved = cubed / 2;
return 1 - halved;
};
export const smoothMouseMove = async (
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
options: { durationMs?: number; steps?: number } = {},
): Promise<void> => {
const durationMs = options.durationMs ?? 520;
const steps = options.steps ?? Math.max(10, Math.round(durationMs / 16));
const delayMs = Math.max(8, Math.round(durationMs / steps));
const dx = to.x - from.x;
const dy = to.y - from.y;
for (let i = 1; i <= steps; i += 1) {
const t = i / steps;
const eased = easeInOutCubic(t);
const xDelta = dx * eased;
const yDelta = dy * eased;
const x = from.x + xDelta;
const y = from.y + yDelta;
await page.mouse.move(x, y);
if (i < steps) {
await page.waitForTimeout(delayMs);
}
}
};
export const showIntegrationsCard = async (
page: Page,
content: IntegrationsCardContent,

View file

@ -3,13 +3,11 @@
*
* Lead-in Black fades to SP UI with schedule panel open.
* 1 Capture in seconds. type a task with short syntax
* (`A task 1h #urgent @17`).
* (`A task 1h`).
* 1.5 [full only] No account. No tracking.
* 2 Plan your day. drag the captured task onto the
* schedule panel; a synthetic ghost
* follows the cursor so the act of
* dragging reads regardless of SP's
* cdkDrag wiring.
* schedule panel using the app's real
* cdkDrag behavior.
* 3 Focus on what matters. focus-mode in progress on the
* captured task. clock.resume() lets
* the timer tick visibly.
@ -31,7 +29,6 @@ import { test } from '../fixture';
import type { OverlayHandle } from '../overlays';
import {
LOGOS,
attachDragGhost,
cutToScene,
loopBoundary,
showEndCard,
@ -56,10 +53,9 @@ const parkCursor = async (page: import('@playwright/test').Page): Promise<void>
* Short-syntax string typed into the global add-task bar in beat 1.
* "A task" title
* "1h" time estimate
* "#urgent" tag (auto-created if missing)
* "@17" due time today at 17:00
*/
const CAPTURED_TASK_TITLE = 'A task 1h #urgent @17';
const CAPTURED_TASK_TITLE = 'A task 1h';
const CAPTURED_TASK_DISPLAY_TITLE = 'A task';
test.describe('@video reel', () => {
test.use({ locale: 'en', theme: 'dark' });
@ -132,52 +128,75 @@ test.describe('@video reel', () => {
let capturedTaskId: string | null = null;
let bExtra: OverlayHandle | undefined;
let b2: OverlayHandle | undefined;
await cutToScene(page, async () => {
await page.evaluate(() => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
helper?.store?.dispatch({ type: '[Layout] Hide AddTaskBar' });
});
await page
.locator('add-task-bar.global')
.first()
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
capturedTaskId = await page
.locator('task')
.first()
.getAttribute('data-task-id')
.catch(() => null);
// Restore the cursor highlight — drag in beat 2 needs it visible.
await page.evaluate(() =>
document.body.classList.remove('__sp-hide-cursor-highlight'),
);
await parkCursor(page);
void b1.hide();
if (isFull) {
bExtra = await showOverlay(page, 'No account. No tracking.', {
noWait: true,
await cutToScene(
page,
async () => {
const backdrop = page.locator('.backdrop').first();
await backdrop.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => {
/* Add-task bar may have already closed itself after Enter. */
});
} else {
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
}
});
if (await backdrop.isVisible().catch(() => false)) {
await backdrop.click({ force: true });
await backdrop.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {
/* Non-fatal: backdrop can detach during the cut. */
});
}
await page
.locator('add-task-bar.global')
.first()
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
const capturedTask = page
.locator('task')
.filter({ hasText: CAPTURED_TASK_DISPLAY_TITLE })
.first();
await capturedTask.waitFor({ state: 'visible', timeout: 3_000 });
capturedTaskId = await capturedTask
.getAttribute('data-task-id')
.catch(() => null);
// Restore the cursor highlight — drag in beat 2 needs it visible.
await page.evaluate(() =>
document.body.classList.remove('__sp-hide-cursor-highlight'),
);
await parkCursor(page);
void b1.hide();
if (isFull) {
bExtra = await showOverlay(page, 'No account. No tracking.', {
noWait: true,
});
} else {
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
}
},
{
fadeMs: 260,
label: isFull ? 'beat 1 to 1.5' : 'beat 1 to 2',
},
);
// ── Beat 1.5 → 2 transition (full variant only) ──────────────────────
if (isFull) {
await page.waitForTimeout(1500);
await cutToScene(page, async () => {
void bExtra!.hide();
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
});
await cutToScene(
page,
async () => {
void bExtra!.hide();
b2 = await showOverlay(page, 'Plan your day.', { noWait: true });
},
{
fadeMs: 260,
label: 'beat 1.5 to 2',
},
);
}
// ── Beat 2 — Plan your day. (drag with ghost preview) ────────────────
// ── Beat 2 — Plan your day. (native app drag) ────────────────────────
const schedulePanel = page.locator('schedule-day-panel').first();
const dragSource = page.locator('task').first();
const dragSource = page
.locator('task')
.filter({ hasText: CAPTURED_TASK_DISPLAY_TITLE })
.first();
await dragSource.waitFor({ state: 'visible', timeout: 5_000 });
const taskBox = await dragSource.boundingBox();
const panelBox = await schedulePanel.boundingBox();
if (taskBox && panelBox) {
@ -192,14 +211,10 @@ test.describe('@video reel', () => {
await page.mouse.move(startX, startY);
await page.waitForTimeout(120);
await page.mouse.down();
// Ghost attaches AFTER mouse.down so the initial cursor-arrives-on-
// task moment isn't visually competing with the ghost popping in.
const ghost = await attachDragGhost(page, dragSource);
await page.waitForTimeout(120);
await page.mouse.move(endX, endY, { steps: 25 });
await page.waitForTimeout(180);
await page.mouse.up();
await ghost.detach();
await page.waitForTimeout(isFull ? 250 : 150);
await parkCursor(page);
}
@ -207,103 +222,116 @@ test.describe('@video reel', () => {
// ── Beat 2 → 3 transition: cut to black, dispatch focus mode ─────────
let b3: OverlayHandle | undefined;
await cutToScene(page, async () => {
void b2!.hide();
if (await scheduleBtn.isVisible().catch(() => false)) {
await scheduleBtn.click();
}
if (capturedTaskId) {
await page.evaluate((id) => {
await cutToScene(
page,
async () => {
void b2!.hide();
if (await scheduleBtn.isVisible().catch(() => false)) {
await scheduleBtn.click();
}
if (capturedTaskId) {
await page.evaluate((id) => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
if (!helper?.store) return;
helper.store.dispatch({ type: '[Task] SetCurrentTask', id });
helper.store.dispatch({ type: '[FocusMode] Show Overlay' });
helper.store.dispatch({
type: '[FocusMode] Start Session',
duration: 1500000,
});
}, capturedTaskId);
await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => undefined);
await page.clock.runFor(5500).catch(() => undefined);
await page
.locator('focus-mode-main .bottom-controls')
.first()
.waitFor({ state: 'visible', timeout: 5_000 })
.catch(() => undefined);
await page.clock.resume().catch(() => undefined);
}
await parkCursor(page);
b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
},
{
fadeMs: 260,
label: 'beat 2 to 3',
},
);
await page.waitForTimeout(isFull ? 1800 : 1200);
// ── Beat 3 → 4 transition: cut to black, swap to integrations card ──
let b4: OverlayHandle | undefined;
await cutToScene(
page,
async () => {
if (b3) void b3.hide();
await page.evaluate(() => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
if (!helper?.store) return;
helper.store.dispatch({ type: '[Task] SetCurrentTask', id });
helper.store.dispatch({ type: '[FocusMode] Show Overlay' });
helper.store.dispatch({
type: '[FocusMode] Start Session',
duration: 1500000,
});
}, capturedTaskId);
helper?.store?.dispatch({ type: '[FocusMode] Hide Overlay' });
helper?.store?.dispatch({ type: '[FocusMode] Cancel Session' });
});
await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
await page.clock.runFor(5500).catch(() => undefined);
await page
.locator('focus-mode-main .bottom-controls')
.first()
.waitFor({ state: 'visible', timeout: 5_000 })
.catch(() => undefined);
await page.clock.resume().catch(() => undefined);
}
await parkCursor(page);
b3 = await showOverlay(page, 'Focus on what matters.', { noWait: true });
});
await page.waitForTimeout(isFull ? 1800 : 1200);
// ── Beat 3 → 4 transition: cut to black, swap to integrations card ──
let b4: OverlayHandle | undefined;
await cutToScene(page, async () => {
if (b3) void b3.hide();
await page.evaluate(() => {
const helper = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers;
helper?.store?.dispatch({ type: '[FocusMode] Hide Overlay' });
helper?.store?.dispatch({ type: '[FocusMode] Cancel Session' });
});
await page
.locator('focus-mode-main')
.first()
.waitFor({ state: 'hidden', timeout: 3_000 })
.catch(() => undefined);
b4 = await showIntegrationsCard(
page,
{
title: 'Plays well with GitHub, Jira & many more',
logos: [
{ svg: LOGOS.github, label: 'GitHub' },
{ svg: LOGOS.gitlab, label: 'GitLab', color: '#fc6d26' },
{ svg: LOGOS.jira, label: 'Jira', color: '#2684ff' },
{ svg: LOGOS.linear, label: 'Linear', color: '#5e6ad2' },
{ svg: LOGOS.trello, label: 'Trello', color: '#0079bf' },
{ svg: LOGOS.calendar, label: 'Calendar' },
],
},
{ noWait: true },
);
});
b4 = await showIntegrationsCard(
page,
{
title: 'Plays well with GitHub, Jira & many more',
logos: [
{ svg: LOGOS.github, label: 'GitHub' },
{ svg: LOGOS.gitlab, label: 'GitLab', color: '#fc6d26' },
{ svg: LOGOS.jira, label: 'Jira', color: '#2684ff' },
{ svg: LOGOS.linear, label: 'Linear', color: '#5e6ad2' },
{ svg: LOGOS.trello, label: 'Trello', color: '#0079bf' },
{ svg: LOGOS.calendar, label: 'Calendar' },
],
},
{ noWait: true },
);
},
{
fadeMs: 260,
label: 'beat 3 to 4',
},
);
await page.waitForTimeout(isFull ? 2500 : 2000);
// ── Beat 4 → 5 transition: cut to black, swap to end card ────────────
await cutToScene(page, async () => {
if (b4) void b4.hide();
await showEndCard(
page,
{
logo: {
src: '/assets/icons/sp.svg',
alt: 'Super Productivity',
monochrome: true,
},
title: 'Free and open source.',
subtitle: 'superproductivity.com',
stats: [
{ template: '★ {n}K on GitHub', to: 19 },
{ template: '{n} ★ on Google Play', to: 4.8, decimals: 1 },
'Web · iOS · Android · macOS · Linux · Windows & many more',
],
// ── Beat 4 → 5 transition: crossfade between controlled cards ───────
await showEndCard(
page,
{
logo: {
src: '/assets/icons/sp.svg',
alt: 'Super Productivity',
monochrome: true,
},
{ noWait: true },
);
});
await page.waitForTimeout(isFull ? 3000 : 2500);
title: 'Free and open source.',
subtitle: 'superproductivity.com',
stats: [
{ template: '★ {n}K on GitHub', to: 19 },
{ template: '{n} ★ on Google Play', to: 4.8, decimals: 1 },
'Web · iOS · Android · macOS · Linux · Windows & many more',
],
},
{ fadeMs: 560, noWait: true },
);
await page.waitForTimeout(260);
if (b4) void b4.hide();
await page.waitForTimeout(isFull ? 2740 : 2240);
// ── Loop boundary ────────────────────────────────────────────────────
// Don't hide the end card — let it stay live. The loop boundary

View file

@ -77,9 +77,10 @@
"screenshots:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-screenshots/build-store-assets.ts",
"screenshots:capture:electron": "npm run electron:build && SCREENSHOT_MODE=electron npx playwright test --config e2e/playwright.store-screenshots.electron.config.ts",
"screenshots:electron": "npm run screenshots:capture:electron && npm run screenshots:build",
"video": "npm run video:capture && npm run video:build",
"video": "npm run video:capture && npm run video:build && npm run video:open",
"video:capture": "npx playwright test --config e2e/playwright.store-video.config.ts",
"video:build": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/build-video.ts",
"video:open": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"moduleResolution\":\"node\"}' npx ts-node --transpile-only e2e/store-video/open-video.ts",
"video:full": "REEL_VARIANT=full npm run video",
"electron": "NODE_ENV=PROD electron .",
"electron:build": "node ./tools/build-wayland-idle-helper.js && tsc -p electron/tsconfig.electron.json && node electron/scripts/bundle-preload.js",