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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,62 @@
Image generation and editing tools for MCP-compatible AI clients, backed by the
[DreamLayer Agent API](https://docs.dreamlayer.io/agent-api).

This working tree includes the **unpublished 0.5.0-beta.0 video candidate**.
The published version in the installation section below remains image/sprite-only.
For local review use Node >=22.12, `pnpm install --frozen-lockfile`, `pnpm build`,
and configure the MCP client to run `node /absolute/path/to/dist/index.js` with an
authorized video-enabled API endpoint. This does not publish or deploy anything.

## Video candidate tools

`dreamlayer_video_quote` performs no paid generation. `dreamlayer_video_execute`
admits only a ready owned quote under an explicit user-authorized credit ceiling.
Never invent that authorization. Save the idempotency key before execution.

```json
{"tool":"dreamlayer_video_quote","arguments":{"operation":"text_to_video","prompt":"A silent forest video","options":{"resolution":"720p","generate_audio":false,"aspect_ratio":"16:9"}}}
```

For an image, first use `dreamlayer_upload_image` with an absolute local path,
then use the returned owned asset ID:

```json
{"tool":"dreamlayer_video_quote","arguments":{"operation":"image_to_video","prompt":"Restrained product showcase on a clean white background","input_asset_id":"OWNED_ASSET_UUID","context":{"use_case":"product_showcase","requested_background":"clean_white"},"options":{"model":"auto","duration_seconds":15,"generate_audio":false}}}
{"tool":"dreamlayer_video_execute","arguments":{"quote_id":"READY_QUOTE_UUID","max_credits":40,"idempotency_key":"SAVED_UNIQUE_KEY"}}
{"tool":"dreamlayer_execution","arguments":{"execution_id":"EXECUTION_UUID"}}
{"tool":"dreamlayer_events","arguments":{"execution_id":"EXECUTION_UUID"}}
{"tool":"dreamlayer_download","arguments":{"execution_id":"EXECUTION_UUID","path":"/absolute/new/result.mp4"}}
```

The execute result includes `video_job`, not an inline/base64 video. Status and
events are read-only and never start work. Progress is actual execution stages,
not an invented percentage. A bounded wait or lost response does not cancel a
job; recover its identity with the same quote/key. Already consumed quotes remain
recoverable after expiry. Retry downloads without generating again.

Default: 10s when absent from both prompt and options, native 720p, no audio unless requested; text aspect 16:9. Explicit durations and issued quotes are unchanged. One image
uses native/source-driven aspect. Explicit incompatible settings and extra images
are rejected before charging.

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.

No automatic cross-provider retry, fidelity scoring, UI change, extension,
upscaling, keyframes or multiple-reference support is included. Downloads use
owned 30-day storage, bounded streaming, SHA-256 verification and no overwrite.
`pricing_pending`/disabled access must be reported honestly, not bypassed.

Use it for raster logo concepts, product imagery, marketing visuals, print artwork
concepts and game assets. Inspect output quality and task-specific requirements.
See the [installable agent workflows](https://github.com/TheDesignFounder/dreamlayer-agent-plugin)
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 quotes, explicit ceiling-bound execution, status recovery
and owned downloads. `server.json` identifies this candidate under the existing
server namespace. Do not publish it until the compatible video backend is live,
paid route qualification and protected release approvals are complete, and the
exact tarball is approved. README installation pins below remain on the previously
published version intentionally. The candidate is available only as a local build.

See the backend `VIDEO_V1_RELEASE.md` for qualification and rollback gates. Local
fixture tests do not establish production access, actual provider bills or quality.

## Previous image/sprite release record

Prepared version: `0.4.0-beta.5`. Keep `latest` on the stable image release.

beta.5 lets an agent order a sprite sheet that keeps its background. The `options` schema previously set
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/mcp",
"version": "0.4.0-beta.5",
"description": "Image generation and editing tools for MCP-compatible AI clients, backed by the DreamLayer Agent API.",
"version": "0.5.0-beta.0",
"description": "Image generation, editing and durable video tools for MCP-compatible AI clients, backed by the DreamLayer Agent API.",
"license": "MIT",
"type": "module",
"bin": {
Expand Down
6 changes: 3 additions & 3 deletions server.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.TheDesignFounder/dreamlayer",
"description": "Generate and edit images, remove backgrounds, upscale, and create sprite sheets through DreamLayer.",
"description": "Generate and edit images, create sprite sheets, and quote and recover video jobs through DreamLayer.",
"repository": {
"url": "https://github.com/TheDesignFounder/dreamlayer-mcp",
"source": "github"
},
"version": "0.4.0-beta.5",
"version": "0.5.0-beta.0",
"packages": [
{
"registryType": "npm",
"identifier": "@dreamlayer/mcp",
"version": "0.4.0-beta.5",
"version": "0.5.0-beta.0",
"transport": {
"type": "stdio"
},
Expand Down
58 changes: 58 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,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 @@ -687,7 +688,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
46 changes: 44 additions & 2 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* runtime any more.
*/
import { randomUUID } from "node:crypto";
import { downloadVideo } from "./video.js";
import { openAsBlob } from "node:fs";
import { stat, writeFile } from "node:fs/promises";
import path from "node:path";
Expand All @@ -15,6 +16,7 @@ import { KNOWN_OPERATIONS } from "./client.js";
import type { ManagedClient, ManagedEvent, ManagedOperation } from "./client.js";
import {
ApiError,
isVideoPrompt,
InputValidationError,
RecoveryRequiredError,
ResponseContractError,
Expand Down Expand Up @@ -309,6 +311,8 @@ export async function toolDefinitionsFor(client: ManagedClient): Promise<ListedT
dreamlayer_upload_image: { input_asset_id: { type: "string" }, expires_at: { type: "string" } },
dreamlayer_execution: { execution_id: { type: "string" }, status: { type: "string" }, image_job: { type: ["object", "null"] } },
dreamlayer_download: { path: { type: "string" }, bytes: { type: "number" } },
dreamlayer_video_quote: {quote_id: {type: "string"}, status: {type: "string"}, selected_model: {type: "string"}, customer_price: {type: "object"}, resolved_settings: {type: "object"}},
dreamlayer_video_execute: {execution_id: {type: "string"}, status: {type: "string"}, video_job: {type: "object"}},
};
const outputSchema = { type: "object", properties: {
...(shapes[tool.name] ?? { execution_id: { type: ["string", "null"] }, status: { type: "string" }, last_event_id: { type: ["string", "null"] }, asset: { type: ["object", "null"] }, question: { type: ["object", "null"] }, events: { type: "array", items: { type: "object" } } }),
Expand All @@ -319,7 +323,7 @@ export async function toolDefinitionsFor(client: ManagedClient): Promise<ListedT
const property = (tool.inputSchema.properties as Record<string, unknown>).operation as
| { enum?: unknown }
| undefined;
if (!property) return { ...tool, annotations, outputSchema };
if (!property || tool.name === "dreamlayer_video_quote") return { ...tool, annotations, outputSchema };
return {
name: tool.name,
annotations,
Expand All @@ -337,6 +341,35 @@ export async function toolDefinitionsFor(client: ManagedClient): Promise<ListedT
}

export const TOOL_DEFINITIONS = [
{
name: "dreamlayer_video_quote",
description: "Plan text-to-video or one-image-to-video without generating. Returns selected model, settings, fixed API-credit price and expiry. No extra images, hidden fallback or fidelity scoring. Execution requires a separate explicit spending ceiling.",
inputSchema: {type: "object", additionalProperties: false, required: ["prompt"], properties: {
prompt: {type: "string", minLength: 1, maxLength: 4000, description: "Describe the video, preserving requested movement, framing and audio."},
operation: {type: "string", enum: ["text_to_video","image_to_video"], description: "Optional explicit operation; otherwise inferred from the image."},
input_asset_id: {type: "string", description: "Owned uploaded first-frame image ID; omit for text-to-video."},
options: {type: "object", description: "Explicit video settings, validated before any paid submission.", additionalProperties: false, properties: {
model: {type: "string", enum: ["auto","flux-3","seedance-2.5"], description: "Explicit model overrides background preferences, not duration limits."},
duration_seconds: {type: "integer", minimum: 4, maximum: 30, description: "Default 10 when unspecified in both options and prompt; explicit durations and issued quotes are preserved. FLUX supports only 5–20. Timelines are never compressed."},
resolution: {type: "string", enum: ["720p"], description: "Native provider 720p profile, not an exact 720px height guarantee."},
generate_audio: {type: "boolean", description: "Off unless requested; must not contradict the prompt."},
aspect_ratio: {type: "string", enum: ["16:9","9:16","1:1"], description: "Text-to-video only; image input uses native aspect."},
}},
context: {type: "object", description: "Requested scene and product context; source pixels are not analyzed.", additionalProperties: false, properties: {
use_case: {type: "string", enum: ["product_showcase","general"], description: "Product intent, never an assertion of detected image contents."},
requested_background: {type: "string", enum: ["clean_white","non_white","unknown"], description: "Requested output background, not guessed source content."},
}},
}},
},
{
name: "dreamlayer_video_execute",
description: "Accept a ready owned video quote. Spend only within an explicitly user-authorized ceiling. Reuse the SAME idempotency_key after uncertainty; never start replacement generation because a wait ends. A consumed quote recovers the original execution even after expiry.",
inputSchema: {type: "object", additionalProperties: false, required: ["quote_id","max_credits","idempotency_key"], properties: {
quote_id: {type: "string", description: "Ready quote returned by dreamlayer_video_quote."},
max_credits: {type: "number", exclusiveMinimum: 0, maximum: 100, description: "Explicit caller-authorized API-credit ceiling. Never invent spending permission."},
idempotency_key: {type: "string", minLength: 1, maxLength: 200, description: "Save before submitting and reuse for all retries of this execution."},
}},
},
{
name: "dreamlayer_capabilities",
description:
Expand Down Expand Up @@ -447,7 +480,7 @@ export const TOOL_DEFINITIONS = [
},
{
name: "dreamlayer_download",
description: "Use when an existing execution completed or a previous download failed. Requires execution_id and an absolute new local path. Saves the finished image or sprite ZIP and returns path/bytes; starts no generation and spends no new credits. Never overwrites. For output_not_ready check status; for local_output_failed repair the path and download the same execution.",
description: "Use when an existing execution completed or a previous download failed. Requires execution_id and an absolute new local path. Saves the finished image, sprite ZIP or video and returns path/bytes; starts no generation and spends no new credits. Never overwrites. For output_not_ready check status; for local_output_failed repair the path and download the same execution.",
inputSchema: { type: "object", properties: { execution_id: { type: "string", description: "UUID of a completed execution, as returned by dreamlayer_generate." }, path: { type: "string", description: "Absolute destination path; an existing file is never overwritten." } }, required: ["execution_id", "path"], additionalProperties: false },
},

Expand Down Expand Up @@ -548,6 +581,11 @@ async function callToolInternal(
let recoveryKey: string | undefined;
try {
switch (name) {
case "dreamlayer_video_quote":
return ok(await client.quoteVideo(args));
case "dreamlayer_video_execute":
recoveryKey = String(args.idempotency_key ?? "");
return ok(await client.executeVideo(String(args.quote_id ?? ""), Number(args.max_credits), recoveryKey));
case "dreamlayer_capabilities":
return ok(await client.getCapabilities());

Expand Down Expand Up @@ -576,6 +614,9 @@ async function callToolInternal(
}

case "dreamlayer_generate": {
if (!args.operation && isVideoPrompt(String(args.prompt ?? ""))) {
return ok(await client.planVideoPrompt(String(args.prompt), typeof args.input_asset_id === "string" ? args.input_asset_id : undefined, typeof args.aspect_ratio === "string" ? args.aspect_ratio : undefined));
}
const { idempotency_key: supplied, ...rest } = args;
// Generated here rather than demanded from the model. The Python server made
// it required, so a model had to invent one, and an invented key is not a
Expand Down Expand Up @@ -607,6 +648,7 @@ async function callToolInternal(
const target = String(args.path ?? "");
if (!path.isAbsolute(target)) throw new ToolInputError("The destination must be an absolute path.");
const state = await client.getExecution(String(args.execution_id));
if (state.video_job) return ok({path: target, bytes: await downloadVideo(client, String(args.execution_id), target)});
const assets = state.image_job?.finished_assets;
if (state.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new ToolInputError("This execution has no finished asset yet.");
const bytes = await client.download(assets[0].download_url);
Expand Down
Loading
Loading