Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,70 @@

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 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 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.

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. 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.

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"
Expand Down
14 changes: 14 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
18 changes: 17 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import path from "node:path";

import {
ApiError,
isVideoPrompt,
InputValidationError,
RecoveryRequiredError,
KNOWN_OPERATIONS,
Expand All @@ -43,6 +44,11 @@ const USAGE = `dreamlayer - generate and edit images from your terminal

USAGE
dreamlayer generate <prompt> [--aspect <ratio>] [--out <file>]
dreamlayer video quote --prompt <text> [--image <file-or-asset-id>] [--model auto|flux-3|seedance-2.5] [--duration <seconds>]
Duration defaults to 10 seconds only when unspecified in both prompt and flags; issued quotes are unchanged.
dreamlayer video execute --quote <quote-id> --max-credits <ceiling> --idempotency-key <saved-key>
dreamlayer video status|wait <execution-id> [--wait-timeout <seconds>]
dreamlayer video download <execution-id> --output <new-file.mp4>
dreamlayer edit <image> <prompt> [--out <file>]
dreamlayer cutout <image> [--out <file>]
dreamlayer upscale <image> [--out <file>]
Expand Down Expand Up @@ -72,7 +78,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.
Expand Down Expand Up @@ -383,6 +389,7 @@ function exitCodeFor(error: ApiError): number {

async function main(argv: string[]): Promise<number> {
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;
Expand Down Expand Up @@ -417,6 +424,10 @@ async function main(argv: string[]): Promise<number> {
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 },
Expand All @@ -426,6 +437,11 @@ async function main(argv: string[]): Promise<number> {
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": {
Expand Down
59 changes: 59 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -148,6 +149,7 @@ export type ManagedExecution = {
conversation_id: string;
status: string;
image_job: Record<string, unknown> | null;
video_job?: Record<string, unknown> | null;
};

export const PUBLIC_ERROR_REASONS = [
Expand Down Expand Up @@ -672,7 +674,64 @@ 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|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]*”|(?<!\w)'[^'\n]+'(?!\w)/g, " ")
.replace(/’/g, "'").replace(/\b(?:but|however|instead)\b/g, ";")
.replace(negation, " ");
if (/\b(sprite|spritesheet|sprite sheet|gif)\b/.test(p)) return false;
if (/\b(?:static|still|print) (?:image|ad|advertisement|poster)\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);
if (target) return ["video","movie","footage"].includes(target[0]) && !/^video[- ]games?\b/.test(p.slice(target.index));
return /\b(?:\d+(?:\.\d+)?|five|ten|fifteen|twenty|thirty)[ -]*(?:seconds?|secs?|minutes?)\b/.test(p)
&& /\b(cinematic|camera|dolly|pan|talking[- ]head|speaks?|says|narration|voiceover|scene|shot|sequence|motion|animation)\b/.test(p)
&& /\b(ad|advertisement|commercial|film|clip)\b/.test(p);
}

export class ManagedClient {
// Existing image request/stream contracts remain unchanged.
async quoteVideo(input: Record<string, unknown>): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<ManagedExecution> {
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<Response> {
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<Record<string, unknown>> | null = null;

Expand Down
Loading
Loading