FN-8289: add feature-video review artifacts

Add gated local feature-video capture to completed user-facing deliverables.

- Capture loopback scenario WebM recordings through the existing artifact registry.
- Keep recording failures non-blocking and cover gated, unsafe, and failed capture paths.
- Document the scenario contract and package the Playwright runtime dependency.

Files changed:
 .changeset/fn-8289-feature-video.md                |   7 +
 docs/workflow-steps.md                             |   8 +
 packages/cli/package.json                          |   3 +-
 packages/cli/tsup.config.ts                        |   7 +
 packages/engine/package.json                       |   3 +-
 .../__tests__/executor-review-artifacts.test.ts    |  44 ++++
 packages/engine/src/executor.ts                    |  33 +++
 .../src/review-artifacts/feature-video.test.ts     |  75 +++++++
 .../engine/src/review-artifacts/feature-video.ts   | 226 +++++++++++++++++++++
 packages/engine/src/review-artifacts/index.ts      |  10 +
 pnpm-lock.yaml                                     |   6 +
 11 files changed, 420 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8289

Fusion-Task-Lineage: 8a34675a-2417-4669-a14a-b74c4aa99331

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 13:52:31 -07:00
parent 02ee8a4fd5
commit bcdad279d7
11 changed files with 420 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Auto-generate a short feature-video artifact for user-facing task deliverables.
category: feature
dev: Gated by the reviewArtifacts mode; best-effort playwright-core WebM capture at executor completion is linked by taskId. Loopback-only scenarios; oversize output is rejected and capture failure never fails the task.

View File

@@ -932,3 +932,11 @@ Authoring surfaces:
![Workflow quality-gate results](./screenshots/workflow-steps.png)
See also: [Task Management](./task-management.md) and [Settings Reference](./settings-reference.md).
## Best-effort Feature-Video Review Artifacts
When the project or task-level [`reviewArtifacts`](./settings-reference.md) policy permits a completed task, the executor attempts a short feature-video immediately before its clean handoff to review. It is an optional deliverable: browser discovery, navigation, recording, and artifact-registration failures are logged and never block completion or review handoff.
The MVP requires a persisted `review-artifact-scenario` task document containing JSON such as `{ "baseUrl": "http://127.0.0.1:5173", "targetRoute": "/settings" }`. The URL must be `http` or `https` on `127.0.0.1`, `localhost`, or `::1`; missing, malformed, remote, or unreachable scenarios are skipped. Fusion does not start or manage this server. An optional `flowScript` identifier is accepted for future registered flows; unknown identifiers use the default navigate-and-settle recording.
Capture uses local Chromium through `playwright-core` and records WebM. Recording is capped at 15 seconds (normally three seconds); output over the size cap is rejected without artifact registration rather than trimmed or re-encoded. A successful recording is registered through the normal artifact registry as `type="video"`, `mimeType="video/webm"`, and linked to its task, so existing review-artifact galleries display it.

View File

@@ -79,7 +79,8 @@
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.2.0",
"react-i18next": "^17.0.8",
"ws": "^8.18.0"
"ws": "^8.18.0",
"playwright-core": "^1.60.0"
},
"peerDependencies": {
"typebox": "*"

View File

@@ -412,6 +412,13 @@ const cliBuildConfig = {
"cpu-features",
"embedded-postgres",
/^@embedded-postgres\//,
/*
FNXC:ReviewArtifacts 2026-07-19-10:00:
The engine lazy-loads playwright-core only for a gated local feature-video.
Keep it external because Playwright has optional Chromium BiDi internals that
esbuild cannot resolve, while the published CLI installs this direct runtime dep.
*/
"playwright-core",
],
splitting: false,
// Keep clean disabled so the dedicated plugin-sdk tsup config can emit into

View File

@@ -47,7 +47,8 @@
"esbuild": "^0.25.12",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"proper-lockfile": "^4.1.2",
"typebox": "^1.0.0"
"typebox": "^1.0.0",
"playwright-core": "^1.60.0"
},
"devDependencies": {
"@types/node": "^25.5.0",

View File

@@ -0,0 +1,44 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "../review-artifacts/feature-video.js";
import { TaskExecutor } from "../executor.js";
const cleanup: string[] = [];
afterEach(async () => { await Promise.all(cleanup.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
describe("TaskExecutor feature-video completion handoff", () => {
const task = { id: "FN-video", description: "user-facing", column: "in-progress", dependencies: [], steps: [], currentStep: 0 } as any;
function makeStore() {
return {
on: vi.fn(), getSettings: vi.fn().mockResolvedValue({ reviewArtifacts: "on" }), getTask: vi.fn().mockResolvedValue({ ...task, prompt: "**Review Artifacts:** on" }),
getTaskDocument: vi.fn().mockResolvedValue({ content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/" }) }),
registerArtifact: vi.fn().mockResolvedValue({ id: "video-1" }),
handoffToReview: vi.fn().mockResolvedValue({ ...task, column: "in-review" }),
} as any;
}
it("runs the injectable capture seam before handing completed work to review", async () => {
const store = makeStore();
const dir = await mkdtemp(join(tmpdir(), "executor-video-test-"));
cleanup.push(dir);
const videoPath = join(dir, "recording.webm");
await writeFile(videoPath, "webm");
const client: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => undefined, video: () => ({ path: async () => videoPath }) }), close: async () => undefined }), close: async () => undefined }) };
const capture = vi.fn((options) => generateFeatureVideo({ ...options, client, sleep: async () => undefined }));
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: capture });
await (executor as any).handoffTaskToReview(task, "fn_task_done");
expect(capture).toHaveBeenCalledWith(expect.objectContaining({ task: expect.objectContaining({ id: task.id }), settings: { reviewArtifacts: "on" } }));
expect(store.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id }));
expect(store.handoffToReview).toHaveBeenCalledWith(task.id, expect.any(Object));
});
it("preserves completion handoff when capture rejects", async () => {
const store = makeStore();
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: vi.fn().mockRejectedValue(new Error("browser crashed")) });
await expect((executor as any).handoffTaskToReview(task, "fn_task_done")).resolves.toMatchObject({ column: "in-review" });
expect(store.handoffToReview).toHaveBeenCalledTimes(1);
});
});

View File

@@ -17,6 +17,7 @@ import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js";
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
import {
@@ -1634,6 +1635,8 @@ export interface TaskExecutorOptions {
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
onError?: (task: Task, error: Error) => void;
/** Testable, best-effort completion-deliverable seam; production uses generateFeatureVideo. */
reviewArtifactGenerator?: (options: GenerateFeatureVideoOptions) => Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>;
/** Optional runtime-owned dispatch seam that lets a flag-gated workflow
* interpreter own the authoritative lifecycle for default coding tasks.
* Return true when the task was fully handled and legacy execute() should stop. */
@@ -2235,6 +2238,7 @@ export class TaskExecutor {
*/
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
const agentId = this.getRunContextFor(task.id)?.agentId;
await this.generateCompletionFeatureVideo(task);
if (reason.startsWith("workflow-")) {
await ensureWorkflowCompletionSummary(this.store, task as TaskDetail, {
reason,
@@ -2269,6 +2273,35 @@ export class TaskExecutor {
return handedOff;
}
/*
FNXC:ReviewArtifacts 2026-07-19-10:00:
A successful executor handoff may offer reviewers a short local feature-video, but
capture is strictly best-effort. Bound and swallow this optional work before the
review transition so browser, scenario, and artifact failures never delay or fail it.
*/
private async generateCompletionFeatureVideo(task: Task): Promise<void> {
try {
const [settings, detail] = await Promise.all([this.store.getSettings(), this.store.getTask(task.id)]);
const generator = this.options.reviewArtifactGenerator ?? generateFeatureVideo;
const result = await this.awaitFeatureVideoBounded(generator({ store: this.store, task: detail ?? task, settings }));
executorLog.log(`${task.id}: feature-video ${result.status}${"reason" in result ? ` (${result.reason})` : ""}`);
} catch (error) {
executorLog.warn(`${task.id}: feature-video capture ignored: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async awaitFeatureVideoBounded(result: Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>): Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
result,
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("feature-video timeout")), 20_000); }),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
private getModelRegistry(): Promise<ModelRegistry> {
if (!this._modelRegistry) {
const authStorage = createFusionAuthStorage();

View File

@@ -0,0 +1,75 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "./feature-video.js";
const task = { id: "FN-8289", title: "Feature demo", prompt: "**Review Artifacts:** on" } as const;
const scenario = { id: "doc", taskId: task.id, key: "review-artifact-scenario", content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/settings" }), revision: 1, author: "agent", createdAt: "", updatedAt: "" };
const tempDirs: string[] = [];
afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
async function videoClient(bytes = Buffer.from("webm")): Promise<FeatureVideoBrowserClient> {
const dir = await mkdtemp(join(tmpdir(), "feature-video-test-"));
tempDirs.push(dir);
const path = join(dir, "video.webm");
await writeFile(path, bytes);
return {
launch: vi.fn().mockResolvedValue({
newContext: vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ goto: vi.fn().mockResolvedValue(undefined), video: () => ({ path: async () => path }) }),
close: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
};
}
function store(document: typeof scenario | null = scenario) {
return {
getTaskDocument: vi.fn().mockResolvedValue(document),
registerArtifact: vi.fn().mockResolvedValue({ id: "artifact-1" }),
};
}
describe("generateFeatureVideo", () => {
it("registers a WebM video linked to its task through the existing registry", async () => {
const artifactStore = store();
const result = await generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(), sleep: async () => undefined });
expect(result).toEqual({ status: "captured", artifactId: "artifact-1" });
expect(artifactStore.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id, mimeType: "video/webm", data: Buffer.from("webm") }));
});
it("short-circuits before reading a scenario or browser when policy is off", async () => {
const artifactStore = store();
const client = await videoClient();
await expect(generateFeatureVideo({ store: artifactStore, task: { ...task, prompt: undefined }, settings: { reviewArtifacts: "off" }, client })).resolves.toEqual({ status: "skipped", reason: "gated-off" });
expect(artifactStore.getTaskDocument).not.toHaveBeenCalled();
expect(client.launch).not.toHaveBeenCalled();
});
it.each([
[null, "no-scenario"],
[{ ...scenario, content: "not json" }, "no-scenario"],
[{ ...scenario, content: JSON.stringify({ baseUrl: "https://example.com", targetRoute: "/" }) }, "scenario-url-not-local"],
])("skips missing or unsafe scenario contracts (%s)", async (document, reason) => {
const artifactStore = store(document as typeof scenario | null);
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient() })).resolves.toEqual({ status: "skipped", reason });
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
});
it("swallows browser and navigation failures without registering partial artifacts", async () => {
const artifactStore = store();
const unavailable: FeatureVideoBrowserClient = { launch: vi.fn().mockRejectedValue(new Error("missing chromium")) };
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: unavailable })).resolves.toEqual({ status: "skipped", reason: "browser-unavailable" });
const navigation: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => { throw new Error("offline"); }, video: () => null }), close: async () => undefined }), close: async () => undefined }) };
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: navigation })).resolves.toEqual({ status: "skipped", reason: "navigation-failed" });
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
});
it("rejects oversize output without registration", async () => {
const artifactStore = store();
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(Buffer.alloc(11)), maxBytes: 10, sleep: async () => undefined })).resolves.toEqual({ status: "failed", reason: "size-cap-exceeded" });
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,226 @@
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { constants } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
isReviewArtifactGenerationEligible,
type Artifact,
type ProjectSettings,
type Task,
type TaskDocument,
} from "@fusion/core";
const SCENARIO_DOCUMENT_KEY = "review-artifact-scenario";
const MAX_DURATION_MS = 15_000;
const DEFAULT_DURATION_MS = 3_000;
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
const NAVIGATION_TIMEOUT_MS = 10_000;
export interface FeatureVideoPage {
goto(url: string, options: { timeout: number; waitUntil: "load" }): Promise<unknown>;
video(): { path(): Promise<string> } | null;
}
export interface FeatureVideoContext {
newPage(): Promise<FeatureVideoPage>;
close(): Promise<void>;
}
export interface FeatureVideoBrowser {
newContext(options: { recordVideo: { dir: string; size: { width: number; height: number } } }): Promise<FeatureVideoContext>;
close(): Promise<void>;
}
/** Injectable seam so unit tests never launch a real browser. */
export interface FeatureVideoBrowserClient {
launch(options: { executablePath?: string; headless: boolean }): Promise<FeatureVideoBrowser>;
}
export interface FeatureVideoStore {
getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null>;
registerArtifact(input: {
type: "video";
taskId: string;
title: string;
description: string;
mimeType: "video/webm";
data: Buffer;
authorId: string;
authorType: "system";
}): Promise<Artifact>;
}
export type FeatureVideoResult =
| { status: "skipped"; reason: "gated-off" | "no-scenario" | "scenario-url-not-local" | "browser-unavailable" | "navigation-failed" }
| { status: "failed"; reason: "size-cap-exceeded" | "capture-failed" }
| { status: "captured"; artifactId: string };
export interface GenerateFeatureVideoOptions {
store: FeatureVideoStore;
task: Pick<Task, "id" | "title"> & { prompt?: string };
settings: Pick<ProjectSettings, "reviewArtifacts">;
client?: FeatureVideoBrowserClient;
executablePath?: string;
durationMs?: number;
maxBytes?: number;
sleep?: (ms: number) => Promise<void>;
}
interface Scenario {
baseUrl: string;
targetRoute: string;
flowScript?: string;
}
/*
FNXC:ReviewArtifacts 2026-07-19-10:00:
Feature-video generation is a gated, best-effort completion deliverable. It uses the
existing artifact registry (not a parallel media store), accepts only loopback scenario
URLs, and returns a result for every failure so recording can never fail task completion.
*/
export function shouldGenerateReviewArtifacts(
task: Pick<Task, "id"> & { prompt?: string },
settings: Pick<ProjectSettings, "reviewArtifacts">,
): boolean {
return isReviewArtifactGenerationEligible(settings, task.prompt);
}
/**
* Captures a short WebM from the persisted local scenario contract and registers its
* bytes through TaskStore. This boundary intentionally swallows all capture failures.
*/
export async function generateFeatureVideo(options: GenerateFeatureVideoOptions): Promise<FeatureVideoResult> {
if (!shouldGenerateReviewArtifacts(options.task, options.settings)) {
return { status: "skipped", reason: "gated-off" };
}
let scenarioDocument: TaskDocument | null;
try {
scenarioDocument = await options.store.getTaskDocument(options.task.id, SCENARIO_DOCUMENT_KEY);
} catch {
return { status: "skipped", reason: "no-scenario" };
}
const scenario = parseScenario(scenarioDocument?.content);
if (!scenario) return { status: "skipped", reason: "no-scenario" };
const url = resolveLoopbackScenarioUrl(scenario);
if (!url) return { status: "skipped", reason: "scenario-url-not-local" };
const executablePath = options.client ? options.executablePath : await probeBrowserExecutable(options.executablePath);
if (!options.client && !executablePath) return { status: "skipped", reason: "browser-unavailable" };
const client = options.client ?? await createPlaywrightFeatureVideoClient();
if (!client) return { status: "skipped", reason: "browser-unavailable" };
let recordingDir: string | undefined;
let browser: FeatureVideoBrowser | undefined;
let context: FeatureVideoContext | undefined;
try {
recordingDir = await mkdtemp(join(tmpdir(), "fusion-feature-video-"));
try {
browser = await client.launch({ executablePath, headless: true });
} catch {
return { status: "skipped", reason: "browser-unavailable" };
}
context = await browser.newContext({ recordVideo: { dir: recordingDir, size: { width: 1280, height: 720 } } });
const page = await context.newPage();
try {
await page.goto(url, { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "load" });
} catch {
return { status: "skipped", reason: "navigation-failed" };
}
const video = page.video();
if (!video) return { status: "failed", reason: "capture-failed" };
await (options.sleep ?? defaultSleep)(Math.min(Math.max(options.durationMs ?? DEFAULT_DURATION_MS, 0), MAX_DURATION_MS));
await context.close();
context = undefined;
const data = await readFile(await video.path());
if (data.byteLength > (options.maxBytes ?? DEFAULT_MAX_BYTES)) {
return { status: "failed", reason: "size-cap-exceeded" };
}
const artifact = await options.store.registerArtifact({
type: "video",
taskId: options.task.id,
title: `Feature video: ${options.task.title ?? options.task.id}`,
description: `Best-effort feature recording for ${scenario.targetRoute}.`,
mimeType: "video/webm",
data,
authorId: "executor",
authorType: "system",
});
return { status: "captured", artifactId: artifact.id };
} catch {
return { status: "failed", reason: "capture-failed" };
} finally {
await safeClose(context);
await safeClose(browser);
if (recordingDir) await rm(recordingDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function parseScenario(content: string | undefined): Scenario | undefined {
if (!content) return undefined;
try {
const value = JSON.parse(content) as Partial<Scenario>;
return typeof value.baseUrl === "string" && typeof value.targetRoute === "string" && value.targetRoute.startsWith("/")
? { baseUrl: value.baseUrl, targetRoute: value.targetRoute, ...(typeof value.flowScript === "string" ? { flowScript: value.flowScript } : {}) }
: undefined;
} catch {
return undefined;
}
}
function resolveLoopbackScenarioUrl(scenario: Scenario): string | undefined {
try {
const base = new URL(scenario.baseUrl);
if (!/^https?:$/.test(base.protocol) || !["127.0.0.1", "localhost", "::1"].includes(base.hostname)) return undefined;
return new URL(scenario.targetRoute, base).toString();
} catch {
return undefined;
}
}
/*
FNXC:ReviewArtifacts 2026-07-19-10:00:
playwright-core does not download Chromium. Probe only known local executable locations
before launch so an unavailable browser is a clean skip, never a completion failure.
*/
async function probeBrowserExecutable(explicit?: string): Promise<string | undefined> {
const candidates = [
explicit,
process.env.FUSION_BROWSER_EXECUTABLE,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
process.env.CHROME_PATH,
...(process.platform === "darwin" ? [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
] : process.platform === "win32" ? [] : [
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
]),
].filter((candidate): candidate is string => Boolean(candidate?.trim()));
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
// Continue with the next local candidate; no subprocess or download is used.
}
}
return undefined;
}
async function createPlaywrightFeatureVideoClient(): Promise<FeatureVideoBrowserClient | undefined> {
try {
const playwright = await import("playwright-core") as unknown as { chromium: FeatureVideoBrowserClient };
return playwright.chromium;
} catch {
return undefined;
}
}
async function safeClose(value: { close(): Promise<void> } | undefined): Promise<void> {
await value?.close().catch(() => undefined);
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,10 @@
export {
generateFeatureVideo,
shouldGenerateReviewArtifacts,
type FeatureVideoBrowser,
type FeatureVideoBrowserClient,
type FeatureVideoContext,
type FeatureVideoPage,
type FeatureVideoResult,
type GenerateFeatureVideoOptions,
} from "./feature-video.js";

6
pnpm-lock.yaml generated
View File

@@ -92,6 +92,9 @@ importers:
node-pty:
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
playwright-core:
specifier: ^1.60.0
version: 1.60.0
react:
specifier: ^19.2.0
version: 19.2.4
@@ -560,6 +563,9 @@ importers:
node-pty:
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
playwright-core:
specifier: ^1.60.0
version: 1.60.0
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2