Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
aa4add0
test: scope vitest collection to this checkout
Co-Messi Sep 2, 2026
b617865
feat(schema): declare the unified clock in the event log
Co-Messi Sep 2, 2026
3a247b5
fix(render): hard capture-health gate; legacy skew comes from the sch…
Co-Messi Sep 2, 2026
7e67c9b
fix(director): never send a provider-scoped key to a custom endpoint
Co-Messi Sep 2, 2026
8ca3f02
fix(security): --block-private-network now gates every request type
Co-Messi Sep 2, 2026
77c18c5
fix(security): gate record-stage requests; align allowPrivateNetwork …
Co-Messi Sep 2, 2026
5e4b101
fix(director): treat page-derived text as data in prompts; add --dry-…
Co-Messi Sep 2, 2026
6a10223
fix(director): preserve the take when QC cuts everything; report befo…
Co-Messi Sep 2, 2026
1458cd3
fix(render): bound the ffmpeg mux with a timeout and explicit maxBuffer
Co-Messi Sep 2, 2026
a79f6f6
fix(render): catch hard tab deaths; size the render timeout from the …
Co-Messi Sep 2, 2026
be98d46
feat(director): meter usage-less providers; refuse oversized calls pr…
Co-Messi Sep 2, 2026
d9d8041
fix(director): bound the --repo walk with a file-count budget
Co-Messi Sep 2, 2026
4e30b12
fix: low-severity audit findings across CLI, director, and render
Co-Messi Sep 2, 2026
57ab54c
chore(ci): green audit via lockfile fix; verify build and package shape
Co-Messi Sep 2, 2026
460d632
chore(npm): ship only render assets; add publish metadata; v0.1.0
Co-Messi Sep 2, 2026
8f038cb
docs: lead with the AI director; npm-safe images; npx-first quick start
Co-Messi Sep 2, 2026
7930e89
test: malformed take directories and the ffmpeg mux failure path
Co-Messi Sep 2, 2026
ae87272
fix(director): per-run nonce in untrusted markers; scrub to a fixpoint
Co-Messi Sep 2, 2026
5114985
fix(capture): restore record()'s fail-closed allowPrivateNetwork default
Co-Messi Sep 2, 2026
f548478
feat(security): gate WebSocket connections when the private-network g…
Co-Messi Sep 2, 2026
c0461d4
fix(director): applyVerdicts throws a typed AllScenesCutError instead…
Co-Messi Sep 2, 2026
1a10492
fix(director): preflight tolerates 401/403 roots; add --skip-preflight
Co-Messi Sep 2, 2026
0f2cd10
fix(director): warn when SUPERCUT_ALLOW_SPARSE bypasses the generate …
Co-Messi Sep 2, 2026
55e8fb7
fix(director): size the per-image token estimate as a cross-provider …
Co-Messi Sep 2, 2026
19145c5
test: wiring coverage for the record request gate and the generate he…
Co-Messi Sep 2, 2026
ddec22e
chore(ci): run e2e by naming convention so new e2e files can't be ski…
Co-Messi Sep 2, 2026
29b41ce
fix(render): stop the watchdog timer and fatal-poll loop on every exi…
Co-Messi Sep 2, 2026
1f26566
fix(render): capture health counts cursor_path point timestamps as ta…
Co-Messi Sep 2, 2026
785c6cd
fix(director): apply --app scoping inside the repo walk, before the f…
Co-Messi Sep 2, 2026
b4ac900
fix(security): request gate fails closed on DNS failure and never cac…
Co-Messi Sep 3, 2026
057888c
fix(cli): dry-run's suggested record command keeps --block-private-ne…
Co-Messi Sep 3, 2026
bafe27b
fix(director): --dry-run no longer requires ffmpeg
Co-Messi Sep 3, 2026
b1f39e9
fix(director): wrap the full page-derived analysis in the untrusted m…
Co-Messi Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ jobs:
cache: npm
- run: npm ci
- run: npm run typecheck
# the package's distribution shape is part of the contract: tsc must
# actually EMIT, the bin entry must exist and resolve, and the tarball
# must stay lean (assets are allowlisted in package.json "files")
- run: npm run build
- name: verify bin entry resolves
run: |
test -f dist/cli/index.js
node dist/cli/index.js --help > /dev/null
- run: npm pack --dry-run
# whole unit suite (no explicit file list, so new test files aren't
# silently skipped); the e2e files run in the browser-e2e job below,
# which has chromium + ffmpeg installed.
- run: npx vitest run --exclude '**/*.e2e.test.ts'
# deliberately STRICT (no --omit=dev): dev-tree advisories must stay
# visible. This is green because the lockfile is clean, not because the
# check was weakened — keep it that way.
- run: npm audit --audit-level=moderate

browser-e2e:
Expand All @@ -41,4 +53,7 @@ jobs:
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: npm test -- --run test/record.e2e.test.ts test/generate.e2e.test.ts
# filter by the *.e2e.test.ts naming convention (not an explicit file
# list) so a new e2e file cannot be silently skipped here — the same
# rule the unit job above already follows in the other direction
- run: npm test -- --run e2e.test
53 changes: 40 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/supercut-wordmark-final.png" alt="supercut — real app footage → cinematic launch video" width="620" />
<img src="https://raw.githubusercontent.com/Co-Messi/supercut/main/assets/supercut-wordmark-final.png" alt="supercut — real app footage → cinematic launch video" width="620" />
</p>

<p align="center">
Expand All @@ -17,7 +17,7 @@
</p>

<p align="center">
<img src="assets/demo-meridian.gif" alt="supercut filming a live app: it opens the console, fills in a record, and frames the resulting audit" width="760" />
<img src="https://raw.githubusercontent.com/Co-Messi/supercut/main/assets/demo-meridian.gif" alt="supercut filming a live app: it opens the console, fills in a record, and frames the resulting audit" width="760" />
<br />
<sub><em>Generated by supercut from a live web app — zero manual editing. The cursor, the camera, the cuts: all automatic.</em></sub>
</p>
Expand All @@ -26,7 +26,7 @@

**You built something great. Now you need a launch video — and all you've got is a screen recording, iMovie, and a deadline.**

`supercut` is a launch video generator for the command line — a Screen Studio alternative that makes a product demo video straight from a URL. Point its AI director at your *running* app: it reads your source, crawls the live UI, decides the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the whole thing cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export.
`supercut` is an AI director for launch videos. Screen recorders hand you a canvas and a timeline and leave the direction to you — supercut decides what to film by itself. Point it at your *running* app: it reads your source, crawls the live UI, picks the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the result cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export.

> Not a screen recording. Not a fake UI mockup. **Your real product**, shot like a launch film — automatically.

Expand All @@ -53,17 +53,29 @@ Each stage hands off a plain-JSON artifact, so you can stop at any point, hand-e

## 🚀 Quick start

```bash
# your app running locally? one command:
npx supercut generate --url http://localhost:3000 --yes
```

`generate` needs an LLM key in a `.env` (see [provider setup](#-llm-provider-setup)),
plus Chromium and ffmpeg: `npx playwright install chromium`, an `ffmpeg` on your PATH,
and `npx supercut doctor` checks both.

> Any command accepts `--help` to print its own usage (e.g. `npx supercut generate --help`).

### From source (contributors — and the no-API-key demo)

```bash
git clone https://github.com/Co-Messi/supercut
cd supercut
npm install
npm run build

# point it at your running app — that's it
node dist/cli/index.js generate --url http://127.0.0.1:3000 --yes
```

`generate` needs an LLM key (see [provider setup](#-llm-provider-setup)). No key? The non-AI path works standalone against the bundled demo app:
No key? The non-AI path works standalone against the bundled demo app:

```bash
# 1. serve the bundled demo app on port 4173 (or: npx serve -l 4173 examples/demo-app)
Expand All @@ -76,10 +88,6 @@ node dist/cli/index.js render --take out/take --out out/final.mp4
node dist/cli/index.js doctor # check Chromium + ffmpeg are installed
```

> Browser + video need Chromium and ffmpeg: `npx playwright install chromium` and an `ffmpeg` on your PATH.

> Any command accepts `--help` to print its own usage (e.g. `node dist/cli/index.js generate --help`).

### 🤖 Or: let your coding agent set it up

Already living in **Claude Code, Codex, opencode, Cursor, or Cline**? Don't run the
Expand Down Expand Up @@ -130,16 +138,24 @@ node dist/cli/index.js generate --url https://untrusted.example --block-private-
```

(`--allow-private-network` is a deprecated no-op kept for back-compat. With the guard on,
the crawler also resolves-and-pins the target host's DNS so a rebinding hostname can't
swap in a private IP mid-run; the `record` stage's browser does not pin yet, so its
navigations are validated as URLs only.)
both the crawler and the `record` stage resolve-and-pin their target hosts' DNS so a
rebinding hostname can't swap in a private IP mid-run, and every in-flight browser
request — navigations from clicked links and submits, `fetch`/XHR, images, scripts,
and WebSocket connections — is checked against the policy before it leaves the
browser. WebSocket gating relies on Playwright's `routeWebSocket`; if you run supercut
against a Playwright older than 1.48 it prints a warning and WebSocket connections are
**not** policy-checked.)

> ⚠️ **supercut drives and may MUTATE the target app** — it performs real clicks and
> typing on whatever you point it at. Destructive controls (Delete, Remove, Pay, …)
> are excluded from filming by default, but that filter is **best-effort and
> English-only**: it matches visible labels and cannot catch icon-only buttons or
> other wording. Film against a disposable/staging environment, never production
> data or URLs/recipes you do not trust. Pass `--allow-destructive` to opt back in.
>
> `generate` prints the recipe's full action list — every selector and every typed
> string — before filming starts, and `--dry-run` stops right there: review
> `recipe.json`, then film it with `supercut record`.

## 🔌 LLM provider setup

Expand Down Expand Up @@ -253,7 +269,18 @@ take directory ──▶ render ──▶ final.mp4
Schemas reject unsupported URL schemes, malformed events, non-monotonic timelines,
oversized logs, and impossible camera boxes.

Event timestamps share the frame `t_source` clock: identical runs now produce structurally/geometrically identical events.json with timestamps agreeing within ~150ms (not byte-identical), and renders fail when events lead the footage by >250ms unless `SUPERCUT_ALLOW_SKEW=1` (legacy sparse takes only warn).
Event timestamps share the frame `t_source` clock, declared by `t_source_unified: true`
in `events.json` (the built-in recorder always writes it). Identical runs produce
structurally/geometrically identical events.json with timestamps agreeing within ~150ms
(not byte-identical). Two render-time gates protect the output:

- **Skew**: on a unified-clock take, events leading the footage by >250ms fail the render
(`SUPERCUT_ALLOW_SKEW=1` forces). Logs without the marker are treated as legacy
recorders whose clocks were never unified, and only warn.
- **Capture health**: a take whose frame count falls far below its duration × fps is
refused — that footage renders as stills with a camera gliding over them. Average
source fps is printed on every `record`/`generate`/`render` run. To render a genuinely
sparse take (e.g. from an old change-driven recorder) set `SUPERCUT_ALLOW_SPARSE=1`.

## Project principles

Expand Down
8 changes: 4 additions & 4 deletions assets/music/CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Use them in your videos — commercial or not — with no attribution required.

| track | vibe | length | bpm |
| -------------- | ----------------------------- | ------ | ---- |
| `pulse.mp3` | minimal tech-house, sleek | 95s | ~104 |
| `daybreak.mp3` | bright melodic house, upbeat | 95s | ~110 |
| `midnight.mp3` | dark synthwave/techno, premium| 100s | ~100 |
| `momentum.mp3` | driving minimal techno | 95s | ~122 |
| `pulse.mp3` | minimal tech-house, sleek | 92s | ~104 |
| `daybreak.mp3` | bright melodic house, upbeat | 92s | ~110 |
| `midnight.mp3` | dark synthwave/techno, premium| 92s | ~100 |
| `momentum.mp3` | driving minimal techno | 91s | ~122 |
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 17 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
{
"name": "supercut",
"version": "0.0.1",
"description": "AI launch-video generator: films your real web app with a cinematic camera, motion blur, and music — a CLI alternative to Screen Studio for product demos",
"version": "0.1.0",
"description": "An AI director that films your real web app by itself: it decides the money moments, drives a browser to perform them, and renders a cinematic 60s launch video — camera, motion blur, music, done",
"keywords": [
"launch-video",
"ai-director",
"product-demo",
"screen-recording",
"screen-studio-alternative",
"demo-video",
"marketing-video",
"ai-video",
Expand All @@ -15,9 +14,20 @@
"cinematic",
"cli",
"video-generator",
"screen-recording",
"screen-studio-alternative",
"product-hunt"
],
"license": "MIT",
"author": "Brayden Siew",
"repository": {
"type": "git",
"url": "git+https://github.com/Co-Messi/supercut.git"
},
"homepage": "https://supercut-five.vercel.app",
"bugs": {
"url": "https://github.com/Co-Messi/supercut/issues"
},
"type": "module",
"engines": {
"node": ">=20"
Expand All @@ -27,7 +37,8 @@
},
"files": [
"dist",
"assets",
"assets/backgrounds",
"assets/music",
"examples/demo-app",
"examples/demo.recipe.json"
],
Expand All @@ -39,7 +50,7 @@
"typecheck": "tsc --noEmit",
"dev": "tsx src/cli/index.ts",
"test:fast": "vitest run test/cursor.test.ts test/director.test.ts test/director-validation.test.ts test/schema.test.ts test/config.test.ts test/url-policy.test.ts test/redaction.test.ts test/plan.test.ts",
"test:e2e": "vitest run test/record.e2e.test.ts test/generate.e2e.test.ts"
"test:e2e": "vitest run e2e.test"
},
"dependencies": {
"playwright": "^1.53.0",
Expand Down
80 changes: 76 additions & 4 deletions src/capture/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { join } from "node:path";
import { chromium, type CDPSession, type Page } from "playwright";
import type { EventLog, KnownEvent, Recipe, Scene, Action } from "../schema/index.js";
import { cursorPath, makeRng, type CursorPoint } from "./cursor.js";
import { assertSafeNavigationUrl } from "../security/url-policy.js";
import { assertSafeNavigationUrl, createRequestGate, gateWebSockets, resolveAndPinHost } from "../security/url-policy.js";

const VIEWPORT = { width: 1920, height: 1080 };
const DPR = 2;
Expand Down Expand Up @@ -159,13 +159,23 @@ export interface RecordOptions {
seed?: number;
/** Skip screencast (faster scheduling-only tests). */
captureFrames?: boolean;
/** allow localhost/RFC1918/cloud-metadata navigation; off by default for safety */
/** Allow localhost/RFC1918/link-local navigation. Defaults to FALSE: the
* library fails closed and callers opt in. Every caller in this repo
* (generate(), the CLI) passes the value explicitly — the CLI allows by
* default and --block-private-network opts the guard in — so the default
* exists only for external embedders, and for them the safe direction is
* closed (matching crawlApp()'s default). With the guard on, the recipe's
* URLs are policy-checked, the target hosts are DNS resolve-and-pinned,
* and every in-flight request is gated. */
allowPrivateNetwork?: boolean;
}

export interface RecordResult {
eventLog: EventLog;
frameCount: number;
/** frames captured per second of take time (frame + event span). ~60 on a
* healthy beacon-era capture; near zero when the screencast starved. */
avgSourceFps: number;
failedScenes: string[];
aborted: boolean;
outDir: string;
Expand Down Expand Up @@ -219,9 +229,38 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {

mkdirSync(join(outDir, "frames"), { recursive: true });

// guard ON: resolve-and-pin every recipe host so the browser connects to the
// exact IPs the policy vetted — a DNS re-resolve mid-run can't swap in a
// private one (same defense the crawler applies).
// Note: this re-resolves hosts that assertRecipeNavigationPolicy above
// already resolved — a second lookup and a small TOCTOU window between the
// two. Deliberate: the assert is a pure yes/no policy check, the pin is the
// one whose answer the browser actually connects to, and collapsing them
// would couple the policy module to Chromium launch-arg formatting.
const launchArgs: string[] = [];
if (!allowPrivateNetwork) {
const rules: string[] = [];
const seenHosts = new Set<string>();
const recipeUrls: string[] = [];
for (const scene of recipe.scenes) {
recipeUrls.push(scene.entry.url);
for (const action of [...scene.entry.prelude, ...scene.actions]) {
if (action.kind === "goto" && action.url) recipeUrls.push(action.url);
}
}
for (const u of recipeUrls) {
const host = new URL(u).hostname;
if (seenHosts.has(host)) continue;
seenHosts.add(host);
const pinned = await resolveAndPinHost(u, { allowPrivateNetwork });
if (pinned) rules.push(pinned.hostResolverRule);
}
if (rules.length > 0) launchArgs.push(`--host-resolver-rules=${rules.join(",")}`);
}

// launch is the only setup outside try/finally; everything else (newPage,
// CDP session) lives inside so a setup failure can't leak the browser
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({ headless: true, args: launchArgs });

const events: KnownEvent[] = [];
const pathPoints: [number, number, number][] = []; // [t, x, y] global cursor track
Expand Down Expand Up @@ -487,6 +526,25 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {

try {
page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: DPR });
// guard ON: gate EVERY in-flight request (clicked links, Enter submits,
// redirects, subresources) — assertSafeNavigationUrl only covers entry/goto
// URLs known from the recipe, but a click on an a[href] or a submit
// navigates with no pre-check. Installed ONLY when the guard is engaged:
// route interception funnels every request through Node, and the default
// local-app path must not pay that tax during a 60fps capture.
if (!allowPrivateNetwork) {
const gate = createRequestGate({ allowPrivateNetwork });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate has zero coverage of its wiring, and the e2e test that claims to cover H5 does not reach it.

test/record.e2e.test.ts:203 — "refuses a private-network recipe when the guard is engaged (H5)" — calls record() with a 127.0.0.1 recipe and allowPrivateNetwork: false, and asserts it throws /private network/i. That throw comes from assertRecipeNavigationPolicy at line 227, which ran on main too. The test would pass unchanged if you deleted the entire createRequestGate block and the --host-resolver-rules pinning above it.

Worse, the assert makes the gate unreachable from that direction: with the guard on, every recipe URL must be public, so no localhost recipe can ever get far enough to install the route handler. The gate's only real coverage is test/url-policy.test.ts, which tests createRequestGate as a pure function with an injected isPrivateHost — good tests, but they say nothing about whether it is wired to the right object, whether route.abort() is reached, or whether the pinning loop above produces a valid --host-resolver-rules string.

Same shape on the generate side: assessCaptureHealth is well covered as a unit and through renderTake, but the new call site in generate.ts:346 has no test. A stubbed sparse frames-index.json after record would pin it.

Concretely, what would close it: a fixture served on a public-looking hostname pinned to loopback via the existing resolve-and-pin path, or a test that constructs a Page with the route handler installed and asserts a subresource to a private host aborts. Either one is more work than a unit test, which is exactly why the wiring is where the bug will be.

Unrelated but adjacent: the pinning loop calls resolveAndPinHost for every recipe host after assertRecipeNavigationPolicy already resolved them — a second DNS lookup and a small TOCTOU window between the two. Harmless in practice, worth a comment noting it is deliberate.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 19145c5. test/request-gate.e2e.test.ts reaches createRequestGate through record(), and I mutation-checked it the way you checked the old one: with the gate block disabled, the guard-on test fails, so it cannot re-prove the assert the way the H5 e2e did. Shape: a probe page fires a fetch() and a WebSocket at a private "internal" server that counts everything that actually leaves the browser. Guard on: zero requests and zero upgrade attempts while the vetted entry loads and the scene completes. Guard off: both probes reach the server (so the zeros measured a gate, not a broken page), and neither createRequestGate nor resolveAndPinHost is even called, pinning the no-interception-tax claim.

One honest caveat and one finding from trying your suggested route. Only the gate's DNS classifier is injected; the fixture-on-a-pinned-public-hostname version turns out not to be portable, because on a machine whose resolver hijacks unknown names to fake IPs (Clash-style TUN, which this dev machine runs) even MAP example.com 127.0.0.1 never produces a TCP connection to loopback. That experiment surfaced something worth knowing beyond the tests: on such machines the --host-resolver-rules pin is bypassed in production too, since name resolution happens in the proxy/tunnel, which leaves the request gate as the load-bearing defense there.

The generate-side call site is covered in test/health-gate.e2e.test.ts: a stubbed sparse frames-index trips the gate right after record, before any QC spend (llm.calls stays at 2), and the bypass path is covered in the same file. The double-resolve between the assert and the pin loop now carries the deliberate-TOCTOU comment you asked for. And ddec22e makes both CI and test:e2e select files by the *.e2e.test.ts convention, because the old explicit list would have silently skipped these two new suites.

await page.context().route("**/*", async (route) => {
if (!(await gate.allows(route.request().url()))) return route.abort();
return route.continue();
});
// WebSocket upgrades bypass ctx.route — gate them separately
if (!(await gateWebSockets(page.context(), gate))) {
console.error(
"warning: this Playwright build lacks routeWebSocket — WebSocket connections are NOT policy-checked",
);
}
}
if (captureFrames) await page.addInitScript(REPAINT_BEACON_SCRIPT);
await page.addInitScript(MUTATION_OBSERVER_SCRIPT);
cdp = await page.context().newCDPSession(page);
Expand Down Expand Up @@ -653,6 +711,10 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {

const eventLog: EventLog = {
version: 0,
// clock declaration (schema): event `t` shares the frame t_source timeline.
// The render stage keys its skew/health gates off this marker — never off
// the capture's frame rate — so a starved take can't pass as "legacy".
t_source_unified: true,
viewport: { width: VIEWPORT.width, height: VIEWPORT.height, dpr: DPR },
fps: FPS,
events,
Expand All @@ -665,5 +727,15 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
frameIndex.sort((a, b) => a.t_source - b.t_source);
writeFileSync(join(outDir, "frames-index.json"), JSON.stringify(frameIndex));

return { eventLog, frameCount: frameIndex.length, failedScenes, aborted, outDir };
// capture-health telemetry: frames per second of take time. The span uses
// BOTH clocks (last frame t_source and last event t) so a capture that
// stalled early — few frames, but a long event timeline — reads as sparse
// instead of hiding behind its own short frame span.
let maxEventT = 0;
for (const e of events) maxEventT = Math.max(maxEventT, e.t);
const lastFrameT = frameIndex.length ? frameIndex[frameIndex.length - 1]!.t_source : 0;
const spanMs = Math.max(lastFrameT, maxEventT);
const avgSourceFps = spanMs > 0 ? (frameIndex.length / spanMs) * 1000 : 0;

return { eventLog, frameCount: frameIndex.length, avgSourceFps, failedScenes, aborted, outDir };
}
Loading
Loading