From 1fc048df618e5204725b2e4ca95335293fd0ed9d Mon Sep 17 00:00:00 2001 From: johannesjo Date: Sat, 9 May 2026 23:44:47 +0200 Subject: [PATCH] chore(video): smooth marketing reel output Note: the drag/drop reel works finally with the native app drag preview path. --- e2e/store-video/CLAUDE.md | 37 +-- e2e/store-video/build-video.ts | 31 +-- e2e/store-video/open-video.ts | 124 ++++++++++ e2e/store-video/overlays.ts | 159 ++++++++++++- e2e/store-video/scenarios/reel.spec.ts | 300 ++++++++++++++----------- package.json | 3 +- 6 files changed, 478 insertions(+), 176 deletions(-) create mode 100644 e2e/store-video/open-video.ts diff --git a/e2e/store-video/CLAUDE.md b/e2e/store-video/CLAUDE.md index 2aff062d99..ae971f8877 100644 --- a/e2e/store-video/CLAUDE.md +++ b/e2e/store-video/CLAUDE.md @@ -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=` 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 `

` rather than `

` 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`. diff --git a/e2e/store-video/build-video.ts b/e2e/store-video/build-video.ts index 5c8a20960e..4fb20d5b72 100644 --- a/e2e/store-video/build-video.ts +++ b/e2e/store-video/build-video.ts @@ -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); diff --git a/e2e/store-video/open-video.ts b/e2e/store-video/open-video.ts new file mode 100644 index 0000000000..ca2e396472 --- /dev/null +++ b/e2e/store-video/open-video.ts @@ -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 = [ + '', + '', + '', + ' ', + ' ', + ' Super Productivity reel preview', + ' ', + '', + '', + ` `, + ' ', + '', + '', + '', + ].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(); diff --git a/e2e/store-video/overlays.ts b/e2e/store-video/overlays.ts index 31fe5ef9d8..1504b483e8 100644 --- a/e2e/store-video/overlays.ts +++ b/e2e/store-video/overlays.ts @@ -68,6 +68,10 @@ export type OverlayHandle = { hide: () => Promise; }; +export type CaptionHandle = OverlayHandle & { + update: (text: string, options?: { fadeMs?: number }) => Promise; +}; + /** * 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 => { .__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 = {}, +): Promise => { + 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 => { + const textFadeMs = updateOptions.fadeMs ?? 170; + const shouldUpdate = await page.evaluate( + (args) => { + const textEl = document.querySelector( + `#${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( + `#${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 => { + 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, - options: { fadeMs?: number } = {}, + options: { fadeMs?: number; label?: string } = {}, ): Promise => { // 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, - options: { fadeMs?: number; opacity?: number } = {}, + options: { fadeMs?: number; opacity?: number; label?: string } = {}, ): Promise => { // 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 => { + 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, diff --git a/e2e/store-video/scenarios/reel.spec.ts b/e2e/store-video/scenarios/reel.spec.ts index cd5c5c1dc2..f3261deb41 100644 --- a/e2e/store-video/scenarios/reel.spec.ts +++ b/e2e/store-video/scenarios/reel.spec.ts @@ -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 * 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 diff --git a/package.json b/package.json index dbf4cd5bc1..ad2c4cb527 100644 --- a/package.json +++ b/package.json @@ -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",