From a909d1eb3edacacdc4757efedfd6a1a4cf43e485 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sat, 4 Apr 2026 03:51:38 +0800 Subject: [PATCH] feat: add browser artifact transformer pipeline --- packages/vishot-runner-browser/src/index.ts | 11 +++- .../src/runtime/artifacts.test.ts | 50 +++++++++++++++++++ .../src/runtime/artifacts.ts | 37 ++++++++++++++ .../src/runtime/capture.ts | 19 ++++--- .../src/runtime/files.ts | 6 ++- .../src/runtime/integration.test.ts | 2 +- .../src/runtime/types.ts | 16 +++++- 7 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 packages/vishot-runner-browser/src/runtime/artifacts.test.ts create mode 100644 packages/vishot-runner-browser/src/runtime/artifacts.ts diff --git a/packages/vishot-runner-browser/src/index.ts b/packages/vishot-runner-browser/src/index.ts index c39481f54..332106c1a 100644 --- a/packages/vishot-runner-browser/src/index.ts +++ b/packages/vishot-runner-browser/src/index.ts @@ -1,6 +1,13 @@ export { parseCaptureBrowserCliArguments } from './cli/capture' +export { applyArtifactTransformers, createImageArtifact } from './runtime/artifacts' export { captureBrowserRoots } from './runtime/capture' -export { assertUniqueCaptureFilePaths, captureFilePath, sanitizeOutputName } from './runtime/files' +export { artifactFilePath, assertUniqueCaptureFilePaths, captureFilePath, sanitizeOutputName } from './runtime/files' export { captureRootSelector } from './runtime/selectors' export type { CaptureBrowserCliArguments } from './runtime/types' -export type { BrowserCaptureRequest, CapturedRootArtifact } from './runtime/types' +export type { + ArtifactTransformer, + BrowserCaptureRequest, + VishotArtifact, + VishotArtifactKind, + VishotArtifactStage, +} from './runtime/types' diff --git a/packages/vishot-runner-browser/src/runtime/artifacts.test.ts b/packages/vishot-runner-browser/src/runtime/artifacts.test.ts new file mode 100644 index 000000000..d72b46d5d --- /dev/null +++ b/packages/vishot-runner-browser/src/runtime/artifacts.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + applyArtifactTransformers, + createImageArtifact, +} from './artifacts' + +describe('applyArtifactTransformers', () => { + it('returns the original artifact when no transformers are configured', async () => { + const artifact = createImageArtifact({ + artifactName: 'intro-chat-window', + filePath: '/tmp/intro-chat-window.png', + stage: 'browser-final', + }) + + expect(artifact).not.toHaveProperty('rootName') + await expect(applyArtifactTransformers(artifact, [])).resolves.toEqual([artifact]) + }) + + it('passes the generated artifact through each transformer in order', async () => { + const first = vi.fn(async artifact => ({ + ...artifact, + filePath: '/tmp/intro-chat-window.avif', + format: 'avif', + })) + const second = vi.fn(async artifact => ({ + ...artifact, + metadata: { optimized: true }, + })) + + const artifact = createImageArtifact({ + artifactName: 'intro-chat-window', + filePath: '/tmp/intro-chat-window.png', + stage: 'browser-final', + }) + + expect(artifact).not.toHaveProperty('rootName') + const result = await applyArtifactTransformers(artifact, [first, second]) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(result).toEqual([ + expect.objectContaining({ + filePath: '/tmp/intro-chat-window.avif', + format: 'avif', + metadata: { optimized: true }, + }), + ]) + }) +}) diff --git a/packages/vishot-runner-browser/src/runtime/artifacts.ts b/packages/vishot-runner-browser/src/runtime/artifacts.ts new file mode 100644 index 000000000..ade096b2b --- /dev/null +++ b/packages/vishot-runner-browser/src/runtime/artifacts.ts @@ -0,0 +1,37 @@ +import type { ArtifactTransformer, VishotArtifact, VishotArtifactStage } from './types' + +export function createImageArtifact(options: { + artifactName: string + filePath: string + stage: VishotArtifactStage + metadata?: Record +}): VishotArtifact { + return { + artifactName: options.artifactName, + filePath: options.filePath, + format: 'png', + kind: 'image', + metadata: options.metadata, + stage: options.stage, + } +} + +export async function applyArtifactTransformers( + artifact: VishotArtifact, + transformers: ArtifactTransformer[] | undefined, +): Promise { + let currentArtifacts: VishotArtifact[] = [artifact] + + for (const transformer of transformers ?? []) { + const nextArtifacts: VishotArtifact[] = [] + + for (const currentArtifact of currentArtifacts) { + const transformed = await transformer(currentArtifact) + nextArtifacts.push(...(Array.isArray(transformed) ? transformed : [transformed])) + } + + currentArtifacts = nextArtifacts + } + + return currentArtifacts +} diff --git a/packages/vishot-runner-browser/src/runtime/capture.ts b/packages/vishot-runner-browser/src/runtime/capture.ts index a405afad5..281b2560d 100644 --- a/packages/vishot-runner-browser/src/runtime/capture.ts +++ b/packages/vishot-runner-browser/src/runtime/capture.ts @@ -1,6 +1,6 @@ import type { Page } from 'playwright' -import type { BrowserCaptureRequest, CapturedRootArtifact } from './types' +import type { BrowserCaptureRequest, VishotArtifact } from './types' import path from 'node:path' @@ -8,6 +8,7 @@ import { mkdir } from 'node:fs/promises' import { chromium } from 'playwright' +import { applyArtifactTransformers, createImageArtifact } from './artifacts' import { assertUniqueCaptureFilePaths, captureFilePath } from './files' import { captureRootSelector } from './selectors' import { startSceneViteServer } from './vite-server' @@ -34,7 +35,7 @@ async function waitForScenarioReady(page: Page): Promise { }) } -async function captureRoot(page: Page, outputDir: string, rootName: string): Promise { +async function captureRoot(page: Page, outputDir: string, rootName: string): Promise { const filePath = captureFilePath(outputDir, rootName) const locator = page.locator(captureRootSelector(rootName)) @@ -44,10 +45,11 @@ async function captureRoot(page: Page, outputDir: string, rootName: string): Pro path: filePath, }) - return { - rootName, + return createImageArtifact({ + artifactName: rootName, filePath, - } + stage: 'browser-final', + }) } async function resolveBaseUrl(request: BrowserCaptureRequest): Promise<{ baseUrl: string, closeServer?: () => Promise }> { @@ -67,7 +69,7 @@ async function resolveBaseUrl(request: BrowserCaptureRequest): Promise<{ baseUrl throw new Error('Browser capture requires either "baseUrl" or "sceneAppRoot"') } -export async function captureBrowserRoots(request: BrowserCaptureRequest): Promise { +export async function captureBrowserRoots(request: BrowserCaptureRequest): Promise { const { baseUrl, closeServer } = await resolveBaseUrl(request) let browser: Awaited> | undefined @@ -95,10 +97,11 @@ export async function captureBrowserRoots(request: BrowserCaptureRequest): Promi assertUniqueCaptureFilePaths(rootNames) - const artifacts: CapturedRootArtifact[] = [] + const artifacts: VishotArtifact[] = [] for (const rootName of rootNames) { - artifacts.push(await captureRoot(page, request.outputDir, rootName)) + const artifact = await captureRoot(page, request.outputDir, rootName) + artifacts.push(...await applyArtifactTransformers(artifact, request.imageTransformers)) } return artifacts diff --git a/packages/vishot-runner-browser/src/runtime/files.ts b/packages/vishot-runner-browser/src/runtime/files.ts index 1ee539683..71bac57e9 100644 --- a/packages/vishot-runner-browser/src/runtime/files.ts +++ b/packages/vishot-runner-browser/src/runtime/files.ts @@ -13,8 +13,12 @@ export function sanitizeOutputName(name: string): string { return sanitized.length > 0 ? sanitized : 'capture' } +export function artifactFilePath(outputDir: string, artifactName: string, format: string): string { + return path.resolve(outputDir, `${sanitizeOutputName(artifactName)}.${format}`) +} + export function captureFilePath(outputDir: string, rootName: string): string { - return path.resolve(outputDir, `${sanitizeOutputName(rootName)}.png`) + return artifactFilePath(outputDir, rootName, 'png') } export function assertUniqueCaptureFilePaths(rootNames: string[]): void { diff --git a/packages/vishot-runner-browser/src/runtime/integration.test.ts b/packages/vishot-runner-browser/src/runtime/integration.test.ts index c93bcf561..317e49edc 100644 --- a/packages/vishot-runner-browser/src/runtime/integration.test.ts +++ b/packages/vishot-runner-browser/src/runtime/integration.test.ts @@ -193,7 +193,7 @@ describe('captureBrowserRoots', () => { outputDir, }) - expect(artifacts.map(item => item.rootName)).toEqual([ + expect(artifacts.map(item => item.artifactName)).toEqual([ 'intro-chat-window', 'intro-websocket-settings', ]) diff --git a/packages/vishot-runner-browser/src/runtime/types.ts b/packages/vishot-runner-browser/src/runtime/types.ts index a504745dd..7e38be24e 100644 --- a/packages/vishot-runner-browser/src/runtime/types.ts +++ b/packages/vishot-runner-browser/src/runtime/types.ts @@ -10,6 +10,7 @@ export interface BrowserCaptureRequest { routePath: string outputDir: string rootNames?: string[] + imageTransformers?: ArtifactTransformer[] viewport?: { width: number height: number @@ -17,7 +18,18 @@ export interface BrowserCaptureRequest { } } -export interface CapturedRootArtifact { - rootName: string +export type VishotArtifactKind = 'image' | 'video' +export type VishotArtifactStage = 'browser-final' | 'electron-raw' + +export interface VishotArtifact { + kind: VishotArtifactKind + stage: VishotArtifactStage + artifactName: string filePath: string + format: string + metadata?: Record } + +export type ArtifactTransformer = ( + artifact: VishotArtifact, +) => Promise