From c0f9993e0b562c68414ab728adb78bb2ee4f8036 Mon Sep 17 00:00:00 2001 From: FenjuFu Date: Thu, 1 Oct 2026 16:39:59 +0800 Subject: [PATCH] feat(cli): caption capture images through any OpenAI-compatible vision endpoint Capture descriptions could only use OpenRouter, Vertex or the Gemini API, none of which is reliably reachable from mainland China or able to target a self-hosted model. Setting HYPERFRAMES_VISION_BASE_URL, HYPERFRAMES_VISION_API_KEY and HYPERFRAMES_VISION_MODEL together now routes captioning through that endpoint ahead of the other providers. Setting only some of them warns with the missing names, skips captioning and reports the phase as degraded instead of falling back to another provider. OpenRouter goes through the same request helper with its URL, auth header and error text unchanged. Signed-off-by: FenjuFu --- docs/guides/authentication.mdx | 3 +- docs/packages/cli.mdx | 8 +- .../cli/src/capture/contentExtractor.test.ts | 169 ++++++++++++++++ packages/cli/src/capture/contentExtractor.ts | 182 ++++++++++++------ .../cli/src/capture/postExtractionPhase.ts | 6 +- 5 files changed, 304 insertions(+), 64 deletions(-) diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index 93564714ce..28a945c636 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -68,7 +68,7 @@ vision key is available. | **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` | | **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` | | **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps | -| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) | +| **Capture descriptions** | Your OpenAI-compatible endpoint → OpenRouter → Gemini | `HYPERFRAMES_VISION_*` → `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) | Run `npx hyperframes doctor` to check which local dependencies are installed. The media workflows run `hyperframes auth status` before generation and tell @@ -144,6 +144,7 @@ to a shared space with `--space`. | `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. | | `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT_ID` | Gemini OAuth quota project; first set value wins, then the service account project. | | `OPENROUTER_API_KEY` | Capture descriptions; takes priority over Gemini for that step. | +| `HYPERFRAMES_VISION_BASE_URL` / `HYPERFRAMES_VISION_API_KEY` / `HYPERFRAMES_VISION_MODEL` | Capture descriptions through any OpenAI-compatible vision endpoint; set all three. Takes priority over OpenRouter and Gemini. | ## Related topics diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 74abd1a044..b9b0b97b29 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -252,7 +252,13 @@ you build. For AI image descriptions, set `GEMINI_API_KEY` in a `.env` file (~$0.001/image), or `OPENROUTER_API_KEY` to route any vision model through [OpenRouter](https://openrouter.ai) — it wins if both are set, and -`HYPERFRAMES_OPENROUTER_MODEL` overrides the model. +`HYPERFRAMES_OPENROUTER_MODEL` overrides the model. To use any other +OpenAI-compatible endpoint (a regional provider, or a local server such as +Ollama or vLLM), set `HYPERFRAMES_VISION_BASE_URL`, `HYPERFRAMES_VISION_API_KEY` +and `HYPERFRAMES_VISION_MODEL` together; this endpoint wins over both. Pick a +model that accepts image input, and give servers that ignore auth any non-empty +key. If only some of the three are set, capture warns and skips descriptions +instead of falling back to another provider. ### `transcribe` diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index 328dbca1bd..c55771d471 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { captionImagesWithGemini, + resolveCustomVisionEndpoint, resolveVisionPhaseCompletion, type VisionCaptionOutcome, } from "./contentExtractor.js"; @@ -410,6 +411,174 @@ describe("captionImagesWithGemini — OpenRouter provider", () => { }); }); +describe("captionImagesWithGemini — custom OpenAI-compatible endpoint", () => { + const dirs: string[] = []; + afterEach(() => { + generateContentMock.mockReset(); + clientOptions.length = 0; + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + function captionResponse(content: string): Response { + return new Response(JSON.stringify({ choices: [{ message: { content } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + it("captions through the configured endpoint, model and key", async () => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2/"); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key"); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model"); + + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + capturedUrl = url; + capturedInit = init; + return captionResponse("A white pricing table with green buttons."); + }); + vi.stubGlobal("fetch", fetchMock); + + const warnings: string[] = []; + const captions = await captionImagesWithGemini(dir, () => {}, warnings); + + expect(captions).toEqual({ "hero.png": "A white pricing table with green buttons." }); + expect(warnings).toEqual([]); + expect(capturedUrl).toBe("https://vision.example.com/v2/chat/completions"); + expect(new Headers(capturedInit?.headers).get("authorization")).toBe("Bearer custom-test-key"); + const body = JSON.parse(typeof capturedInit?.body === "string" ? capturedInit.body : "{}"); + expect(body.model).toBe("example-vl-model"); + expect(body.max_tokens).toBe(500); + const image = body.messages[0].content.find((p: { type: string }) => p.type === "image_url"); + expect(image?.image_url?.url).toMatch(/^data:image\/png;base64,/); + }); + + it("takes priority over OpenRouter and Gemini when both are also configured", async () => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2"); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key"); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model"); + vi.stubEnv("OPENROUTER_API_KEY", "or-test-key"); + vi.stubEnv("GEMINI_API_KEY", "gemini-test-key"); + + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + urls.push(url); + return captionResponse("A caption."); + }), + ); + + await captionImagesWithGemini(dir, () => {}, []); + + expect(urls).toEqual(["https://vision.example.com/v2/chat/completions"]); + expect(generateContentMock).not.toHaveBeenCalled(); + }); + + it("counts a rejected request as a provider failure", async () => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "https://vision.example.com/v2"); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-test-key"); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("bad request", { status: 400 })), + ); + + let outcome: VisionCaptionOutcome | undefined; + const captions = await captionImagesWithGemini(dir, () => {}, [], { + onOutcome: (value) => { + outcome = value; + }, + }); + + expect(captions).toEqual({}); + expect(outcome?.failedRequests).toBe(1); + }); + + it("refuses a half-configured endpoint instead of falling back to Gemini", async () => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", ""); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", "custom-secret-key"); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model"); + vi.stubEnv("OPENROUTER_API_KEY", ""); + vi.stubEnv("GEMINI_API_KEY", "gemini-test-key"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const warnings: string[] = []; + let outcome: VisionCaptionOutcome | undefined; + const captions = await captionImagesWithGemini(dir, () => {}, warnings, { + onOutcome: (value) => { + outcome = value; + }, + }); + + expect(captions).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + expect(generateContentMock).not.toHaveBeenCalled(); + expect(clientOptions).toHaveLength(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("missing HYPERFRAMES_VISION_BASE_URL."); + expect(warnings[0]).not.toContain("custom-secret-key"); + if (!outcome) throw new Error("Expected vision caption outcome"); + expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({ + status: "degraded", + reason: "internal-error", + }); + }); + + it("names every variable a base URL alone still needs", async () => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", "http://localhost:11434/v1"); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", ""); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", ""); + vi.stubEnv("OPENROUTER_API_KEY", "or-test-key"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const warnings: string[] = []; + await captionImagesWithGemini(dir, () => {}, warnings); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(warnings[0]).toContain("missing HYPERFRAMES_VISION_API_KEY, HYPERFRAMES_VISION_MODEL."); + }); +}); + +describe("resolveCustomVisionEndpoint", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is unset when none of the three variables is set", () => { + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", ""); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", ""); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", ""); + expect(resolveCustomVisionEndpoint()).toEqual({ kind: "unset" }); + }); + + it("lists exactly the variables that are missing", () => { + vi.stubEnv("HYPERFRAMES_VISION_BASE_URL", ""); + vi.stubEnv("HYPERFRAMES_VISION_API_KEY", ""); + vi.stubEnv("HYPERFRAMES_VISION_MODEL", "example-vl-model"); + expect(resolveCustomVisionEndpoint()).toEqual({ + kind: "incomplete", + missing: ["HYPERFRAMES_VISION_BASE_URL", "HYPERFRAMES_VISION_API_KEY"], + }); + }); +}); + describe("captionImagesWithGemini — Gemini provider", () => { const dirs: string[] = []; diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 87474c85b7..8f36191656 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -98,6 +98,88 @@ async function runBoundedVisionRequest( } } +interface OpenAiCompatibleEndpoint { + baseUrl: string; + apiKey: string; + model: string; +} + +export type CustomVisionEndpointConfig = + | { kind: "unset" } + | { kind: "incomplete"; missing: string[] } + | { kind: "ready"; endpoint: OpenAiCompatibleEndpoint }; + +/** + * Reads the user's own OpenAI-compatible vision endpoint from + * HYPERFRAMES_VISION_BASE_URL, HYPERFRAMES_VISION_API_KEY and HYPERFRAMES_VISION_MODEL. + * Setting only some of them is reported as incomplete, so a half-configured endpoint is + * never silently replaced by another provider the user may be configuring their way around. + */ +export function resolveCustomVisionEndpoint(): CustomVisionEndpointConfig { + const baseUrl = process.env.HYPERFRAMES_VISION_BASE_URL; + const apiKey = process.env.HYPERFRAMES_VISION_API_KEY; + const model = process.env.HYPERFRAMES_VISION_MODEL; + if (baseUrl && apiKey && model) return { kind: "ready", endpoint: { baseUrl, apiKey, model } }; + const vars: Array<[string, string | undefined]> = [ + ["HYPERFRAMES_VISION_BASE_URL", baseUrl], + ["HYPERFRAMES_VISION_API_KEY", apiKey], + ["HYPERFRAMES_VISION_MODEL", model], + ]; + const missing = vars.filter(([, value]) => !value).map(([name]) => name); + return missing.length === vars.length ? { kind: "unset" } : { kind: "incomplete", missing }; +} + +type CaptionOne = (args: { + mimeType: string; + base64: string; + prompt: string; + maxTokens: number; + timeoutMs: number; +}) => Promise; + +function openAiCompatibleCaptionOne( + providerName: string, + endpoint: OpenAiCompatibleEndpoint, +): CaptionOne { + const url = `${endpoint.baseUrl.replace(/\/+$/, "")}/chat/completions`; + return async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => { + return runBoundedVisionRequest(async (signal) => { + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${endpoint.apiKey}`, + "Content-Type": "application/json", + }, + signal, + body: JSON.stringify({ + model: endpoint.model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt }, + { + type: "image_url", + image_url: { url: `data:${mimeType};base64,${base64}` }, + }, + ], + }, + ], + max_tokens: maxTokens, + }), + }); + if (!res.ok) { + await res.text(); + throw new Error(`${providerName} request failed with HTTP ${res.status}`); + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + return data.choices?.[0]?.message?.content?.trim() || ""; + }, timeoutMs); + }; +} + /** * Detect JS libraries via window globals, DOM fingerprints, script URLs, * and WebGL shader analysis. @@ -241,9 +323,10 @@ export async function extractVisibleText(page: Page): Promise { /** * Caption downloaded images using a vision model. * - * Provider is chosen by which API key is present: OPENROUTER_API_KEY → OpenRouter - * (any vision model via its OpenAI-style API), else GEMINI_API_KEY/GOOGLE_API_KEY - * → Google Gemini, else no captioning. OpenRouter wins if both are set. + * Provider is chosen by which credentials are present: HYPERFRAMES_VISION_BASE_URL, + * HYPERFRAMES_VISION_API_KEY and HYPERFRAMES_VISION_MODEL → that OpenAI-compatible + * endpoint, else OPENROUTER_API_KEY → OpenRouter (any vision model via its OpenAI-style + * API), else Vertex, else GEMINI_API_KEY/GOOGLE_API_KEY → Google Gemini, else no captioning. * * Batches requests to stay under free-tier rate limits. * Returns a map of filename -> caption string. @@ -273,6 +356,16 @@ export async function captionImagesWithGemini( reportOutcome(); return geminiCaptions; } + const customVision = resolveCustomVisionEndpoint(); + if (customVision.kind === "incomplete") { + warnings.push( + `Skipped vision captioning: HYPERFRAMES_VISION_BASE_URL, HYPERFRAMES_VISION_API_KEY and HYPERFRAMES_VISION_MODEL must be set together; missing ${customVision.missing.join(", ")}.`, + ); + internalError = true; + reportOutcome(); + return geminiCaptions; + } + const customEndpoint = customVision.kind === "ready" ? customVision.endpoint : undefined; const openRouterKey = process.env.OPENROUTER_API_KEY; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; // Vertex authenticates with a service account and a project instead of an API key. Server @@ -282,27 +375,33 @@ export async function captionImagesWithGemini( const vertexProject = process.env.HYPERFRAMES_VERTEX_PROJECT_ID; const vertexServiceAccount = process.env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT; const useVertex = Boolean(vertexProject && vertexServiceAccount); - if (!openRouterKey && !useVertex && !geminiKey) { + if (!customEndpoint && !openRouterKey && !useVertex && !geminiKey) { reportOutcome(); return geminiCaptions; } - // OpenRouter takes priority — it's the explicit opt-in for users without Google access. - // Vertex outranks the bare API key because it is the credential a deployment actually - // holds. All three satisfy the same single-image → one-line-caption contract - // (`captionOne`), so the batching and SVG-rasterization loops stay provider-agnostic. - const provider: "openrouter" | "vertex" | "gemini" = openRouterKey - ? "openrouter" - : useVertex - ? "vertex" - : "gemini"; - const providerName = { openrouter: "OpenRouter", vertex: "Vertex AI", gemini: "Gemini" }[ - provider - ]; + // A custom endpoint and OpenRouter are explicit opt-ins for users without Google access, + // so they come first. Vertex outranks the bare API key because it is the credential a + // deployment actually holds. All of them satisfy the same single-image → one-line-caption + // contract (`captionOne`), so the batching and SVG-rasterization loops stay provider-agnostic. + const provider: "custom" | "openrouter" | "vertex" | "gemini" = customEndpoint + ? "custom" + : openRouterKey + ? "openrouter" + : useVertex + ? "vertex" + : "gemini"; + const providerName = { + custom: "OpenAI-compatible endpoint", + openrouter: "OpenRouter", + vertex: "Vertex AI", + gemini: "Gemini", + }[provider]; // Override per provider via HYPERFRAMES_OPENROUTER_MODEL / HYPERFRAMES_VERTEX_MODEL / // HYPERFRAMES_GEMINI_MODEL. Vertex publishes a different model set than the Gemini API — // the API's flash-lite preview id is not resolvable there — so it carries its own default. const model = { + custom: customEndpoint?.model ?? "", openrouter: process.env.HYPERFRAMES_OPENROUTER_MODEL || "google/gemini-3.1-flash-lite", vertex: process.env.HYPERFRAMES_VERTEX_MODEL || "gemini-2.5-flash", gemini: process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview", @@ -313,52 +412,15 @@ export async function captionImagesWithGemini( try { // One image → one short caption. Each provider implements this contract; // everything below is provider-agnostic. - type CaptionOne = (args: { - mimeType: string; - base64: string; - prompt: string; - maxTokens: number; - timeoutMs: number; - }) => Promise; + const openAiCompatibleEndpoint = + customEndpoint ?? + (openRouterKey + ? { baseUrl: "https://openrouter.ai/api/v1", apiKey: openRouterKey, model } + : undefined); let captionOne: CaptionOne; - if (provider === "openrouter") { - captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => { - return runBoundedVisionRequest(async (signal) => { - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${openRouterKey}`, - "Content-Type": "application/json", - }, - signal, - body: JSON.stringify({ - model, - messages: [ - { - role: "user", - content: [ - { type: "text", text: prompt }, - { - type: "image_url", - image_url: { url: `data:${mimeType};base64,${base64}` }, - }, - ], - }, - ], - max_tokens: maxTokens, - }), - }); - if (!res.ok) { - await res.text(); - throw new Error(`OpenRouter request failed with HTTP ${res.status}`); - } - const data = (await res.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - return data.choices?.[0]?.message?.content?.trim() || ""; - }, timeoutMs); - }; + if (openAiCompatibleEndpoint) { + captionOne = openAiCompatibleCaptionOne(providerName, openAiCompatibleEndpoint); } else { let GoogleGenAI: OptionalPackageModules["@google/genai"]["GoogleGenAI"]; try { diff --git a/packages/cli/src/capture/postExtractionPhase.ts b/packages/cli/src/capture/postExtractionPhase.ts index 1163e1f18d..b1e1609235 100644 --- a/packages/cli/src/capture/postExtractionPhase.ts +++ b/packages/cli/src/capture/postExtractionPhase.ts @@ -26,6 +26,7 @@ import { createCaptureDownloadBudget } from "./readBoundedResponse.js"; import { captionImagesWithGemini, generateAssetDescriptions, + resolveCustomVisionEndpoint, resolveVisionPhaseCompletion, } from "./contentExtractor.js"; import type { VisionCaptionOutcome } from "./contentExtractor.js"; @@ -33,7 +34,8 @@ import type { VisionCaptionOutcome } from "./contentExtractor.js"; function hasVisionCredentials(skipVision: boolean): boolean { return Boolean( !skipVision && - (process.env.OPENROUTER_API_KEY || + (resolveCustomVisionEndpoint().kind === "ready" || + process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || (process.env.HYPERFRAMES_VERTEX_PROJECT_ID && @@ -343,7 +345,7 @@ export async function runPostExtraction(input: PostExtractionInput): Promise.svg` filename prefix is a cheap structural hint (DOM said this SVG was inside a `
`, home-link ``, or had an aria-label matching the page brand). It is NOT a content claim — many `logo-*` files are nav icons or decorative shapes. Trust the captions, not the filename prefix.\n\n" - : "# Asset Descriptions\n\n⚠️ No vision credentials — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY), or HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, and re-run.\n\nThe `logo-.svg` filename prefix is a structural hint (DOM said this SVG was inside a `
`, home-link ``, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n"; + : "# Asset Descriptions\n\n⚠️ No vision credentials — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY), HYPERFRAMES_VERTEX_PROJECT_ID plus HYPERFRAMES_VERTEX_SERVICE_ACCOUNT for Vertex service-account auth, or HYPERFRAMES_VISION_BASE_URL, HYPERFRAMES_VISION_API_KEY and HYPERFRAMES_VISION_MODEL for any OpenAI-compatible vision endpoint, and re-run.\n\nThe `logo-.svg` filename prefix is a structural hint (DOM said this SVG was inside a `
`, home-link ``, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.\n\n"; writeAssetDescriptionsFile(outputDir, lines, header); progress( "design",