From 4d5566ca7810baa52fa1e078938e948ec94ebf9d Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Fri, 9 Oct 2026 21:43:29 -0400 Subject: [PATCH 1/4] feat(video): add quoted video execution and recovery commands --- README.md | 57 +++++++++++++++ RELEASE.md | 14 ++++ package.json | 4 +- src/cli.ts | 17 ++++- src/client.ts | 49 +++++++++++++ src/video.ts | 122 +++++++++++++++++++++++++++++++ test/packaged-install.test.mjs | 6 +- test/video.test.mjs | 126 +++++++++++++++++++++++++++++++++ 8 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 src/video.ts create mode 100644 test/video.test.mjs diff --git a/README.md b/README.md index 7f48ed9..429c855 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,63 @@ Generate and edit images from your terminal, over local files, with one API key. +This working tree also contains the **unpublished 0.5.0-beta.0 video candidate**. +The published-image installation below does not enable video. To review the +candidate, use Node >=22.12, `pnpm install --frozen-lockfile`, `pnpm build`, then +`node dist/cli.js video ...` against a video-enabled test endpoint. No registry +publication or production activation is implied. + +## Video candidate + +Both text-to-video and one-image-to-video use quote-first admission. Quotes cost +no generation credits and return the selected model, resolved settings and fixed +API-credit price. `pricing_pending` is not executable. Execution requires an +explicit spending ceiling and a key saved before the request; no interactive +confirmation is required for automation. + +```bash +# Replace dreamlayer below with node dist/cli.js when reviewing the local candidate. +dreamlayer video quote --prompt "A 15-second silent video of a forest" --operation txt2vid +dreamlayer video quote --prompt "Show this product on a clean white background" \ + --image ./product.png --operation img2vid --duration 15 --audio off +dreamlayer video quote --prompt "A slow camera pan through a furnished room" \ + --image ./room.png --model flux-3 --duration 20 +dreamlayer video execute --quote QUOTE_ID --max-credits 40 --idempotency-key SAVED_UNIQUE_KEY +dreamlayer video status EXECUTION_ID +dreamlayer video wait EXECUTION_ID --wait-timeout 120 +dreamlayer video download EXECUTION_ID --output ./result.mp4 +``` + +`--image` accepts an owned uploaded asset UUID or a regular local image file +(up to 200 MB before existing server normalization; normalized video inputs must +fit the backend's 4096px-per-side / 20 MB limits). Multiple references are not in +V1. No supplied image is dropped. Default duration is 15s, resolution is the native +720p profile, and audio is off unless requested. Text aspect defaults to 16:9; +`--aspect 16:9|9:16|1:1` is text-only. Image video preserves the source-driven +native aspect and rejects fixed-aspect requests. Use `--context product` and +`--background white|non_white|unknown` for explicit product/background context. + +Automatic image routing uses Seedance for requested white backgrounds and unknown +product backgrounds, FLUX for known non-white/lifestyle scenes. Above 20s it uses +Seedance; above 30s it rejects. Explicit FLUX is limited to 5–20s and is never +silently overridden. Seedance supports 4–30s. Source pixels are not classified. +Text routing is separate and provisionally uses FLUX up to 20s (Seedance at 4s or +21–30s), not a claim about text/audio quality. + +Ordinary `generate "Make a 15-second video ..."` and `edit image.png "Make a video ..."` +return a quote rather than submitting paid video. Explicit image operations retain +their old behavior. Simple numeric prompt settings are validated; prefer structured +video flags for unambiguous settings. + +Wait expiry exits **7** and leaves the job running. Retain its execution ID and +resume status/wait/download. After a lost submission response, repeat the same +quote, ceiling and idempotency key—even if the quote has since expired. Do not +create a replacement job. A failed download retries delivery, not generation. +Downloads stream with size/hash checks and never overwrite an existing path. +Outputs remain available for 30 days; fresh download requests renew signed links. +There is no automatic second model/provider attempt. API credits are separate +from Studio credits; a later delivery after a restored hold is not charged again. + ```bash npm install -g dreamlayer@0.4.0-beta.4 export DREAMLAYER_API_KEY="dlr_live_your_key" diff --git a/RELEASE.md b/RELEASE.md index e3d7f74..f6b8960 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,19 @@ # Agent readiness release +## Video candidate — not published + +`0.5.0-beta.0` adds `video quote/execute/status/wait/download` and ordinary +video-prompt routing into the quote flow. Quotes do not spend; execution requires +an explicit ceiling and persisted idempotency key. A client wait timeout does not +cancel or replace the job. + +Do not publish until the compatible backend is live and the exact tested tarball +has release approval. The backend `VIDEO_V1_RELEASE.md` lists paid qualification, +protected migration, cloud verification and rollback gates. README install pins +remain on the previously published version; this candidate is a local build only. + +## Previous image/sprite release record + Prepared version: `0.4.0-beta.4`. Keep `latest` on `0.3.0` until a stable release is deliberately selected. beta.4 adds `--background keep`: sprite frames that keep their generated background, priced at the flat diff --git a/package.json b/package.json index 7a1fcd4..daf6eb3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dreamlayer", - "version": "0.4.0-beta.4", - "description": "Generate and edit images from your terminal, over local files, with one API key.", + "version": "0.5.0-beta.0", + "description": "Generate and edit images, and quote and run durable video jobs, with one API key.", "license": "MIT", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 72382f0..45c830f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import path from "node:path"; import { ApiError, + isVideoPrompt, InputValidationError, RecoveryRequiredError, KNOWN_OPERATIONS, @@ -43,6 +44,10 @@ const USAGE = `dreamlayer - generate and edit images from your terminal USAGE dreamlayer generate [--aspect ] [--out ] + dreamlayer video quote --prompt [--image ] [--model auto|flux-3|seedance-2.5] [--duration ] + dreamlayer video execute --quote --max-credits --idempotency-key + dreamlayer video status|wait [--wait-timeout ] + dreamlayer video download --output dreamlayer edit [--out ] dreamlayer cutout [--out ] dreamlayer upscale [--out ] @@ -72,7 +77,7 @@ OPTIONS EXIT CODES 0 success, 1 usage/local error, 2 authentication/access, 3 credits/quota, - 4 permanent API failure, 5 temporary failure, 6 input required + 4 permanent API failure, 5 temporary failure, 6 input required, 7 video still running AUTOMATION Commands never prompt. Save a unique --idempotency-key before paid work. @@ -383,6 +388,7 @@ function exitCodeFor(error: ApiError): number { async function main(argv: string[]): Promise { const [command, ...rest] = argv; + if (command === "video") return (await import("./video.js")).videoMain(rest); if (!command || command === "--help" || command === "-h" || command === "help") { process.stdout.write(USAGE); return command ? 0 : 1; @@ -417,6 +423,10 @@ async function main(argv: string[]): Promise { case "generate": { const prompt = positional[0]; if (!prompt) throw new UsageError("generate needs a prompt"); + if (isVideoPrompt(prompt)) { + process.stdout.write(JSON.stringify(await client().planVideoPrompt(prompt, undefined, rest.includes("--aspect") ? options.aspect : undefined)) + "\n"); + return 0; + } return run( client(), { prompt, operation: "text_to_image", aspect_ratio: options.aspect }, @@ -426,6 +436,11 @@ async function main(argv: string[]): Promise { case "edit": { const [file, prompt] = positional; if (!file || !prompt) throw new UsageError("edit needs an image and a prompt"); + if (isVideoPrompt(prompt)) { + const api = client(); + process.stdout.write(JSON.stringify(await api.planVideoPrompt(prompt, await upload(api, file), rest.includes("--aspect") ? options.aspect : undefined)) + "\n"); + return 0; + } return imageCommand("image_to_image", prompt, file, options); } case "cutout": { diff --git a/src/client.ts b/src/client.ts index 19ae58c..5e52c3d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -77,6 +77,7 @@ export type ManagedExecuteInput = { export class InputValidationError extends Error {} export class RecoveryRequiredError extends Error {} +export class ResponseContractError extends Error {} /** * Transparent frames are tiered; plain frames are a flat rate with no tier, because the @@ -148,6 +149,7 @@ export type ManagedExecution = { conversation_id: string; status: string; image_job: Record | null; + video_job?: Record | null; }; export const PUBLIC_ERROR_REASONS = [ @@ -672,7 +674,54 @@ function requireEventStream(response: Response): void { } } +export function isVideoPrompt(prompt: string): boolean { + const p = prompt.toLowerCase(); + if (/\b(sprite|spritesheet|sprite sheet|gif)\b/.test(p)) return false; + if (/\b(txt2vid|img2vid)\b|\b(?:image|text)[- ]to[- ]video\b|\banimate (?:this|the|my) (?:image|photo|product)\b|\b(?:turn|convert)\b.+\binto\b.+\b(?:video|movie)\b/.test(p)) return true; + const target = /\b(video|movie|footage|image|photo|poster|logo|illustration|thumbnail|cover|icon|screenshot|artwork)\b/.exec(p); + return !!target && ["video","movie","footage"].includes(target[0]) && !/^video[- ]games?\b/.test(p.slice(target.index)); +} + export class ManagedClient { + // Existing image request/stream contracts remain unchanged. +async quoteVideo(input: Record): Promise> { + return this.request("/v1/video/quotes", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), + }); + } + + async planVideoPrompt(prompt: string, inputAssetId?: string, aspectRatio?: string): Promise> { + return this.request("/v1/execute", { + method: "POST", headers: { "Content-Type": "application/json", "DreamLayer-Features": "video-quotes-v1", "Idempotency-Key": crypto.randomUUID() }, + body: JSON.stringify({ prompt, ...(inputAssetId ? { input_asset_id: inputAssetId } : {}), ...(aspectRatio ? {aspect_ratio: aspectRatio} : {}) }), + }); + } + + async executeVideo(quoteId: string, maxCredits: number, idempotencyKey: string): Promise { + if (!quoteId || !idempotencyKey || idempotencyKey.length > 200 || !Number.isFinite(maxCredits) || maxCredits <= 0 || maxCredits > 100) + throw new InputValidationError("A quote, explicit spending ceiling and stable idempotency key are required"); + return this.request("/v1/execute", { + method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey }, + body: JSON.stringify({ quote_id: quoteId, max_credits: maxCredits }), + }); + } + + async videoDownloadResponse(executionId: string): Promise { + const response = await fetch(`${this.baseUrl}/v1/video/executions/${encodeURIComponent(executionId)}/asset`, { + headers: { Authorization: `Bearer ${this.apiKey}`, "DreamLayer-Version": "1" }, + redirect: "manual", signal: AbortSignal.timeout(30_000), + }); + if (response.status !== 302) { + if (!response.ok) throw await apiError(response); + throw new ResponseContractError("Expected owned video redirect"); + } + const url = new URL(response.headers.get("Location") ?? ""); + if (url.protocol !== "https:" || url.hostname !== "storage.googleapis.com" || url.username || url.password || url.port || url.hash) + throw new ResponseContractError("Unapproved video download host"); + const download = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(120_000) }); + if (!download.ok) throw new RecoveryRequiredError("Video download unavailable; retry delivery, not generation"); + return download; + } private readonly baseUrl: string; private capabilitiesPromise: Promise> | null = null; diff --git a/src/video.ts b/src/video.ts new file mode 100644 index 0000000..57c95d7 --- /dev/null +++ b/src/video.ts @@ -0,0 +1,122 @@ +import { createHash, randomUUID } from "node:crypto"; +import { open, link, unlink, lstat } from "node:fs/promises"; +import { openAsBlob } from "node:fs"; +import path from "node:path"; +import { ManagedClient, ApiError, InputValidationError, RecoveryRequiredError } from "./client.js"; + +export async function downloadVideo(client: ManagedClient, id: string, target: string): Promise { + const state = await client.getExecution(id); + if (state.status !== "completed" || !state.video_job) throw new InputValidationError("Video is not ready; retain the execution ID"); + const metadata = state.video_job.metadata as { sha256?: string; bytes?: number } | undefined; + if (!metadata?.sha256 || !metadata.bytes) throw new InputValidationError("Video metadata is missing"); + try { await lstat(target); throw new InputValidationError("Destination exists; choose a new path"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const temp = path.join(path.dirname(target), `.dreamlayer-video-${randomUUID()}.part`); + const file = await open(temp, "wx", 0o600); + let bytes = 0; + try { + const response = await client.videoDownloadResponse(id); + if (!response.body) throw new RecoveryRequiredError("Missing video body; retry the same download"); + const reader = response.body.getReader(); + const hash = createHash("sha256"); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.length; + if (bytes > 256 * 1024 * 1024 || bytes > metadata.bytes) throw new InputValidationError("Video exceeds declared size"); + hash.update(chunk.value); + await file.writeFile(chunk.value); + } + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + if (bytes !== metadata.bytes || hash.digest("hex") !== metadata.sha256) throw new RecoveryRequiredError("Download checksum mismatch; retry delivery only"); + await file.sync(); + await file.close(); + await link(temp, target); // Atomic publish without overwriting an existing destination. + return bytes; + } finally { await file.close().catch(() => {}); await unlink(temp).catch(() => {}); } +} + +export async function videoMain(argv: string[]): Promise { + const action = argv[0]; + const flags: Record = {}; + const positional: string[] = []; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--json") continue; + if (arg.startsWith("--")) { + if (!["prompt","image","operation","model","duration","resolution","audio","aspect","context","background","quote","max-credits","idempotency-key","wait-timeout","output"].includes(arg.slice(2))) throw new InputValidationError(`Unknown video flag ${arg}`); + const value = argv[++i]; + if (!value || value.startsWith("--")) throw new InputValidationError(`Missing ${arg} value`); + if (arg.slice(2) in flags) throw new InputValidationError(`Duplicate video flag ${arg}`); + flags[arg.slice(2)] = value; + } else positional.push(arg); + } + const allowed: Record = { + quote: ["prompt","image","operation","model","duration","resolution","audio","aspect","context","background"], + execute: ["quote","max-credits","idempotency-key"], + status: [], wait: ["wait-timeout"], download: ["output"], + }; + if (!action || !allowed[action]) throw new InputValidationError("Use video quote|execute|status|wait|download"); + for (const key of Object.keys(flags)) if (!allowed[action]!.includes(key)) throw new InputValidationError(`--${key} is not valid for video ${action}`); + if (positional.length > (["status","wait","download"].includes(action) ? 1 : 0)) throw new InputValidationError("Unexpected positional video arguments"); + const client = new ManagedClient(process.env.DREAMLAYER_API_KEY ?? "", process.env.DREAMLAYER_API_URL); + let result: unknown; + if (action === "quote") { + if (!flags.prompt) throw new InputValidationError("--prompt is required"); + if (flags.audio && !["on","off"].includes(flags.audio)) throw new InputValidationError("--audio must be on or off"); + const options: Record = {}; + if (flags.model) options.model = flags.model; + if (flags.duration) { + if (!Number.isInteger(Number(flags.duration))) throw new InputValidationError("--duration must be whole seconds"); + options.duration_seconds = Number(flags.duration); + } + if (flags.resolution) options.resolution = flags.resolution; + if (flags.aspect) options.aspect_ratio = flags.aspect; + if (flags.audio) options.generate_audio = flags.audio === "on"; + const context: Record = {}; + if (flags.context) context.use_case = flags.context === "product" ? "product_showcase" : flags.context; + if (flags.background) context.requested_background = flags.background === "white" ? "clean_white" : flags.background; + const operation = flags.operation === "txt2vid" ? "text_to_video" : flags.operation === "img2vid" ? "image_to_video" : flags.operation; + let image = flags.image; + if (image && !/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(image)) { + const source = path.resolve(image); + const info = await lstat(source); + if (!info.isFile() || info.size > 200 * 1024 * 1024) throw new InputValidationError("--image must be an owned asset UUID or a regular image file up to 200 MB"); + image = (await client.uploadInput(await openAsBlob(source), path.basename(source))).input_asset_id; + } + result = await client.quoteVideo({ prompt: flags.prompt, ...(image ? {input_asset_id: image} : {}), ...(operation ? {operation} : {}), options, context }); + } else if (action === "execute") { + try { + result = await client.executeVideo(flags.quote ?? "", Number(flags["max-credits"]), flags["idempotency-key"] ?? ""); + } catch (error) { + if (error instanceof ApiError || error instanceof InputValidationError) throw error; + process.stderr.write(JSON.stringify({error: "submission_outcome_unknown", quote_id: flags.quote, idempotency_key: flags["idempotency-key"], guidance: "Repeat the same execute command, quote and key to recover. Do not request replacement generation."}) + "\n"); + return 5; + } + } else if (action === "download") { + if (!positional[0] || !flags.output) throw new InputValidationError("execution ID and --output are required"); + result = {execution_id: positional[0], path: flags.output, bytes: await downloadVideo(client, positional[0], flags.output)}; + } else if (action === "wait" || action === "status") { + if (!positional[0]) throw new InputValidationError("execution ID is required"); + const seconds = Number(flags["wait-timeout"] ?? 120); + if (!Number.isFinite(seconds) || seconds < 0 || seconds > 3600) throw new InputValidationError("wait timeout must be between 0 and 3600 seconds"); + const end = Date.now() + seconds * 1000; + while (true) { + const state = await client.getExecution(positional[0]); + result = state; + if (["failed","cancelled","expired"].includes(state.status)) { + process.stdout.write(JSON.stringify(state) + "\n"); + return 1; + } + if (action === "status" || state.status === "completed") break; + if (Date.now() >= end) { + process.stdout.write(JSON.stringify({...state, wait_expired: true, resume: `dreamlayer video wait ${positional[0]}`}) + "\n"); + return 7; + } + await new Promise(resolve => setTimeout(resolve, Math.min(2000, end-Date.now()))); + } + } else throw new InputValidationError("Use video quote|execute|status|wait|download"); + process.stdout.write(JSON.stringify(result) + "\n"); + return 0; +} diff --git a/test/packaged-install.test.mjs b/test/packaged-install.test.mjs index 69dc7f7..b943fc7 100644 --- a/test/packaged-install.test.mjs +++ b/test/packaged-install.test.mjs @@ -25,7 +25,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: assert.equal(packed.status, 0, packed.stderr); const metadata = JSON.parse(packed.stdout)[0]; assert.equal(metadata.name, "dreamlayer"); - assert.equal(metadata.version, "0.4.0-beta.4"); + assert.equal(metadata.version, "0.5.0-beta.0"); assert.ok(metadata.integrity.startsWith("sha512-")); assert.deepEqual( metadata.files.map(({ path: file }) => file).sort(), @@ -39,6 +39,8 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: "dist/client.js", "dist/render.d.ts", "dist/render.js", + "dist/video.d.ts", + "dist/video.js", "package.json", ], ); @@ -58,7 +60,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"), ); assert.equal(packageJson.publishConfig.tag, "beta"); - assert.equal(packageJson.version, "0.4.0-beta.4"); + assert.equal(packageJson.version, "0.5.0-beta.0"); assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js"); const env = { ...process.env, DREAMLAYER_API_KEY: "" }; diff --git a/test/video.test.mjs b/test/video.test.mjs new file mode 100644 index 0000000..e37324a --- /dev/null +++ b/test/video.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ManagedClient, isVideoPrompt } from "../dist/client.js"; +import { downloadVideo } from "../dist/video.js"; + +test("video intent does not hijack image or sprite requests", () => { + assert.equal(isVideoPrompt("Create a video game character"), false); + assert.equal(isVideoPrompt("Make a poster for a video"), false); + assert.equal(isVideoPrompt("Create a sprite sheet from video"), false); + assert.equal(isVideoPrompt("Turn this image into a video"), true); + assert.equal(isVideoPrompt("Make a 15-second video"), true); +}); + +test("video CLI quotes, admits once, and wait expiry never cancels or resubmits", async (t) => { + const calls=[]; + const server=createServer((req,res) => { + let raw=""; + req.on("data",chunk => raw+=chunk); + req.on("end",() => { + calls.push({method:req.method,url:req.url,body:raw?JSON.parse(raw):null,headers:req.headers}); + res.setHeader("content-type","application/json"); + res.end(JSON.stringify(req.url.endsWith("quotes")?{quote_id:"quote",status:"ready"}:{execution_id:"job",status:"generating",video_job:{stage:"generating"}})); + }); + }); + await new Promise(resolve => server.listen(0,"127.0.0.1",resolve)); + t.after(() => server.close()); + async function run(args) { + const child=spawn(process.execPath,[fileURLToPath(new URL("../dist/cli.js",import.meta.url)),"video",...args],{env:{...process.env,DREAMLAYER_API_KEY:"fixture",DREAMLAYER_API_URL:`http://127.0.0.1:${server.address().port}`}}); + let stdout="",stderr=""; + child.stdout.on("data",chunk=>stdout+=chunk);child.stderr.on("data",chunk=>stderr+=chunk); + const code=await new Promise(resolve=>child.on("close",resolve)); + return {code,stdout,stderr}; + } + assert.equal((await run(["quote","--prompt","Make a video","--duration","25","--model","seedance-2.5"])).code,0); + assert.equal(calls[0].body.options.duration_seconds,25); + assert.equal((await run(["execute","--quote","quote","--max-credits","40","--idempotency-key","stable-key"])).code,0); + assert.equal(calls[1].headers["idempotency-key"],"stable-key"); + const wait=await run(["wait","job","--wait-timeout","0"]); + assert.equal(wait.code,7);assert.equal(JSON.parse(wait.stdout).wait_expired,true); + assert.equal(calls.filter(call=>call.method==="POST").length,2); + assert.equal((await run(["quote","--prompt","video","--duration","banana"])).code,1); + assert.equal(calls.length,3); +}); + +test("video quote and execution keep ceiling/key, never auto-retry an uncertain POST", async (t) => { + const calls = []; + const original = globalThis.fetch; + t.after(() => {globalThis.fetch = original;}); + globalThis.fetch = async (url, init) => { + calls.push({url: String(url), init, body: JSON.parse(init.body)}); + if (String(url).endsWith("/quotes")) return Response.json({status: "ready", quote_id: "quote", selected_model: "seedance-2.5"}); + throw new TypeError("simulated response loss"); + }; + const client = new ManagedClient("fixture", "http://127.0.0.1:8090"); + const quote = await client.quoteVideo({prompt: "Make a video", options: {duration_seconds: 25}}); + assert.equal(quote.selected_model, "seedance-2.5"); + await assert.rejects(client.executeVideo("quote", 40, "stable-key")); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1].body, {quote_id: "quote", max_credits: 40}); + assert.equal(calls[1].init.headers.get("Idempotency-Key"), "stable-key"); + for (const cap of [0, -1, NaN, Infinity, 101]) await assert.rejects(client.executeVideo("quote", cap, "key")); + assert.equal(calls.length, 2); +}); + +test("ordinary video prompt requests a quote without spend authorization", async (t) => { + const original = globalThis.fetch; + t.after(() => {globalThis.fetch = original;}); + globalThis.fetch = async (url, init) => { + assert.equal(init.headers.get("DreamLayer-Features"), "video-quotes-v1"); + assert.equal(init.headers.get("DreamLayer-Version"), "1"); + assert.deepEqual(JSON.parse(init.body), {prompt: "Make a 15-second video", input_asset_id: "asset"}); + return Response.json({status: "quote_required", quote: {quote_id: "quote"}}); + }; + const result = await new ManagedClient("fixture", "http://127.0.0.1:8090").planVideoPrompt("Make a 15-second video", "asset"); + assert.equal(result.status, "quote_required"); +}); + +test("owned video download streams, checks hash, never forwards key or overwrites", async (t) => { + const original = globalThis.fetch; + t.after(() => {globalThis.fetch = original;}); + const dir = await mkdtemp(path.join(tmpdir(), "dreamlayer-video-test-")); + t.after(() => rm(dir, {recursive: true, force: true})); + const payload = Buffer.from("video fixture"); + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push(String(url)); + if (String(url).endsWith("/asset")) return new Response(null, {status: 302, headers: {Location: "https://storage.googleapis.com/fixture/result.mp4?signature=fixture"}}); + if (String(url).startsWith("https://storage.googleapis.com/")) { + assert.equal(init.headers, undefined); + assert.equal(init.redirect, "error"); + return new Response(payload); + } + return Response.json({status: "completed", video_job: {metadata: {bytes: payload.length, sha256: createHash("sha256").update(payload).digest("hex")}}}); + }; + const client = new ManagedClient("fixture", "http://127.0.0.1:8090"); + const target = path.join(dir, "video.mp4"); + assert.equal(await downloadVideo(client, "execution", target), payload.length); + assert.deepEqual(await readFile(target), payload); + await assert.rejects(downloadVideo(client, "execution", target), /Destination exists/); + assert.deepEqual(await readdir(dir), ["video.mp4"]); + assert.equal(calls.filter(url => url.endsWith("/asset")).length, 1); +}); + +test("video redirect refuses arbitrary hosts and checksum failure leaves no file", async (t) => { + const original = globalThis.fetch; + t.after(() => {globalThis.fetch = original;}); + const client = new ManagedClient("fixture", "http://127.0.0.1:8090"); + globalThis.fetch = async () => new Response(null, {status: 302, headers: {Location: "https://evil.example/result.mp4"}}); + await assert.rejects(client.videoDownloadResponse("execution"), /Unapproved/); + const dir = await mkdtemp(path.join(tmpdir(), "dreamlayer-video-test-")); + t.after(() => rm(dir, {recursive: true, force: true})); + globalThis.fetch = async (url) => { + if (String(url).endsWith("/asset")) return new Response(null, {status: 302, headers: {Location: "https://storage.googleapis.com/fixture/result.mp4"}}); + if (String(url).startsWith("https://storage.googleapis.com/")) return new Response("bad"); + return Response.json({status: "completed", video_job: {metadata: {bytes: 3, sha256: "0".repeat(64)}}}); + }; + await assert.rejects(downloadVideo(client, "execution", path.join(dir, "video.mp4")), /checksum/); + assert.deepEqual(await readdir(dir), []); +}); From 09ea504fb74f033c4cdeed9b5c72508238e22573 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sat, 10 Oct 2026 08:01:49 -0400 Subject: [PATCH 2/4] Align video intent and document ten-second defaults without changing issued quotes --- README.md | 21 +++++++++++++-------- src/cli.ts | 1 + src/client.ts | 12 ++++++++++-- test/video.test.mjs | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 429c855..0542897 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ confirmation is required for automation. ```bash # Replace dreamlayer below with node dist/cli.js when reviewing the local candidate. -dreamlayer video quote --prompt "A 15-second silent video of a forest" --operation txt2vid +dreamlayer video quote --prompt "A silent video of a forest" --operation txt2vid dreamlayer video quote --prompt "Show this product on a clean white background" \ --image ./product.png --operation img2vid --duration 15 --audio off dreamlayer video quote --prompt "A slow camera pan through a furnished room" \ @@ -32,18 +32,23 @@ dreamlayer video download EXECUTION_ID --output ./result.mp4 `--image` accepts an owned uploaded asset UUID or a regular local image file (up to 200 MB before existing server normalization; normalized video inputs must fit the backend's 4096px-per-side / 20 MB limits). Multiple references are not in -V1. No supplied image is dropped. Default duration is 15s, resolution is the native +V1. No supplied image is dropped. Default duration is 10s when omitted from both prompt and flags; explicit durations and issued quotes are unchanged. Resolution is the native 720p profile, and audio is off unless requested. Text aspect defaults to 16:9; `--aspect 16:9|9:16|1:1` is text-only. Image video preserves the source-driven native aspect and rejects fixed-aspect requests. Use `--context product` and `--background white|non_white|unknown` for explicit product/background context. -Automatic image routing uses Seedance for requested white backgrounds and unknown -product backgrounds, FLUX for known non-white/lifestyle scenes. Above 20s it uses -Seedance; above 30s it rejects. Explicit FLUX is limited to 5–20s and is never -silently overridden. Seedance supports 4–30s. Source pixels are not classified. -Text routing is separate and provisionally uses FLUX up to 20s (Seedance at 4s or -21–30s), not a claim about text/audio quality. +Policy `video-v2-creative-10s-20261010` applies only to new quotes. +Explicit compatible model first; automatic 4s or 21–30s selects Seedance. +Complex action, demanding audio, continuity, white/unknown product backgrounds +and uncertain classification prefer Seedance. Clearly simple non-white product, +one-speaker talking-head, retro or controlled cinematic-lighting scenes prefer +FLUX. Complexity beats style; these are preferences, not quality guarantees. +FLUX accepts 5–20s; Seedance 4–30s. Source pixels are not analyzed. +Spoken dialogue enables unspecified audio; captions/labels do not. Shot timestamps +are not duration/aspect settings. Overlong timelines fail before spending rather +than being compressed; dialogue is never truncated. Live-test defaults remain 5s. +Existing quotes retain their model, prompt, settings and price. Ordinary `generate "Make a 15-second video ..."` and `edit image.png "Make a video ..."` return a quote rather than submitting paid video. Explicit image operations retain diff --git a/src/cli.ts b/src/cli.ts index 45c830f..5f98281 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,7 @@ const USAGE = `dreamlayer - generate and edit images from your terminal USAGE dreamlayer generate [--aspect ] [--out ] dreamlayer video quote --prompt [--image ] [--model auto|flux-3|seedance-2.5] [--duration ] + Duration defaults to 10 seconds only when unspecified in both prompt and flags; issued quotes are unchanged. dreamlayer video execute --quote --max-credits --idempotency-key dreamlayer video status|wait [--wait-timeout ] dreamlayer video download --output diff --git a/src/client.ts b/src/client.ts index 5e52c3d..49c4969 100644 --- a/src/client.ts +++ b/src/client.ts @@ -675,11 +675,19 @@ function requireEventStream(response: Response): void { } export function isVideoPrompt(prompt: string): boolean { - const p = prompt.toLowerCase(); + // Analysis only; submit the original prompt unchanged. Selection stays on the server. + const p = prompt.toLowerCase() + .replace(/"[^"\n]*"|“[^”\n]*”|(? { + assert.equal(isVideoPrompt("Create a 10-second cinematic ad with one slow camera move"), true); + assert.equal(isVideoPrompt('Create a 15-second ad. She says: "Hello"'), true); + assert.equal(isVideoPrompt("Create a static ad with the caption '15-second video'"), false); + assert.equal(isVideoPrompt("Do not make a video. Create a static ad"), false); + assert.equal(isVideoPrompt("Create a cinematic ad"), false); assert.equal(isVideoPrompt("Create a video game character"), false); assert.equal(isVideoPrompt("Make a poster for a video"), false); assert.equal(isVideoPrompt("Create a sprite sheet from video"), false); @@ -18,6 +23,20 @@ test("video intent does not hijack image or sprite requests", () => { assert.equal(isVideoPrompt("Make a 15-second video"), true); }); +test("omitted duration is resolved by the server; quote acceptance never sends new defaults", async (t) => { + const original = globalThis.fetch; + t.after(() => { globalThis.fetch = original; }); + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push(JSON.parse(init.body)); + return Response.json({quote_id: "old-fifteen-second-quote", resolved_settings: {duration_seconds: 15}}); + }; + const client = new ManagedClient("fixture", "http://127.0.0.1:8090"); + await client.quoteVideo({prompt: "Make a video"}); + await client.executeVideo("old-fifteen-second-quote", 40, "stable"); + assert.deepEqual(calls, [{prompt: "Make a video"}, {quote_id: "old-fifteen-second-quote", max_credits: 40}]); +}); + test("video CLI quotes, admits once, and wait expiry never cancels or resubmits", async (t) => { const calls=[]; const server=createServer((req,res) => { From 2db117d026d913cc3686e3916802e6b095d51ba8 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sat, 10 Oct 2026 08:21:46 -0400 Subject: [PATCH 3/4] Preserve positive video intent after local negation clauses --- README.md | 6 ++++-- src/client.ts | 4 +++- test/video.test.mjs | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0542897..da9e63b 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,16 @@ V1. No supplied image is dropped. Default duration is 10s when omitted from both native aspect and rejects fixed-aspect requests. Use `--context product` and `--background white|non_white|unknown` for explicit product/background context. -Policy `video-v2-creative-10s-20261010` applies only to new quotes. +Policy `video-v3-scoped-intent-10s-20261010` applies only to new quotes. Explicit compatible model first; automatic 4s or 21–30s selects Seedance. Complex action, demanding audio, continuity, white/unknown product backgrounds and uncertain classification prefer Seedance. Clearly simple non-white product, one-speaker talking-head, retro or controlled cinematic-lighting scenes prefer FLUX. Complexity beats style; these are preferences, not quality guarantees. FLUX accepts 5–20s; Seedance 4–30s. Source pixels are not analyzed. -Spoken dialogue enables unspecified audio; captions/labels do not. Shot timestamps +Spoken dialogue enables unspecified audio; captions/labels do not. Speech instructions +are not discarded by a preceding "no music" clause. Non-white is not also white; +a speaker alone does not establish simple action. Shot timestamps are not duration/aspect settings. Overlong timelines fail before spending rather than being compressed; dialogue is never truncated. Live-test defaults remain 5s. Existing quotes retain their model, prompt, settings and price. diff --git a/src/client.ts b/src/client.ts index 49c4969..324928d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -676,10 +676,12 @@ function requireEventStream(response: Response): void { export function isVideoPrompt(prompt: string): boolean { // Analysis only; submit the original prompt unchanged. Selection stays on the server. + const affirmative = "(?:(?:the|a|an)\\s+)?(?:she|he|they|woman|man|person|speaker|actor|actress|presenter|narrator|character|show|create|make|use|add|include|keep|place|with)\\b"; + const negation = new RegExp("\\b(?:do not|don't|does not|doesn't|must not|without|avoid|no|not)\\b[^.;\\n]*?(?=[.;\\n]|,\\s*(?:then\\s+)?" + affirmative + "|\\b(?:and|while|then)\\s+" + affirmative + "|$)", "g"); const p = prompt.toLowerCase() .replace(/"[^"\n]*"|“[^”\n]*”|(? { + assert.equal(isVideoPrompt('Create a 10-second ad, no music, she says: "Hello"'), true); + assert.equal(isVideoPrompt('Create a 10-second ad, no music and the woman speaks'), true); + assert.equal(isVideoPrompt('Create an ad with no video, motion or narration'), false); assert.equal(isVideoPrompt("Create a 10-second cinematic ad with one slow camera move"), true); assert.equal(isVideoPrompt('Create a 15-second ad. She says: "Hello"'), true); assert.equal(isVideoPrompt("Create a static ad with the caption '15-second video'"), false); From cd26015f70bde2573e4c002ce087e469be24e93f Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sat, 10 Oct 2026 08:26:04 -0400 Subject: [PATCH 4/4] Recognize affirmative speech-only clauses after exclusions --- src/client.ts | 2 +- test/video.test.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 324928d..b6e7a52 100644 --- a/src/client.ts +++ b/src/client.ts @@ -676,7 +676,7 @@ function requireEventStream(response: Response): void { export function isVideoPrompt(prompt: string): boolean { // Analysis only; submit the original prompt unchanged. Selection stays on the server. - const affirmative = "(?:(?:the|a|an)\\s+)?(?:she|he|they|woman|man|person|speaker|actor|actress|presenter|narrator|character|show|create|make|use|add|include|keep|place|with)\\b"; + const affirmative = "(?:(?:the|a|an)\\s+)?(?:she|he|they|woman|man|person|speaker|actor|actress|presenter|narrator|character|show|create|make|use|add|include|keep|place|with|only|just)\\b"; const negation = new RegExp("\\b(?:do not|don't|does not|doesn't|must not|without|avoid|no|not)\\b[^.;\\n]*?(?=[.;\\n]|,\\s*(?:then\\s+)?" + affirmative + "|\\b(?:and|while|then)\\s+" + affirmative + "|$)", "g"); const p = prompt.toLowerCase() .replace(/"[^"\n]*"|“[^”\n]*”|(? { assert.equal(isVideoPrompt('Create a 10-second ad, no music, she says: "Hello"'), true); assert.equal(isVideoPrompt('Create a 10-second ad, no music and the woman speaks'), true); + assert.equal(isVideoPrompt('Create a 10-second ad, no music, only narration'), true); assert.equal(isVideoPrompt('Create an ad with no video, motion or narration'), false); assert.equal(isVideoPrompt("Create a 10-second cinematic ad with one slow camera move"), true); assert.equal(isVideoPrompt('Create a 15-second ad. She says: "Hello"'), true);