Skip to content

Commit 3f52953

Browse files
authored
Merge pull request #24 from shotstack/derk/generate-command
[2/5] Add a generate command for one asset from a prompt
2 parents 6ecde7f + e6e3fb2 commit 3f52953

5 files changed

Lines changed: 232 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ shotstack models seedance-2.0-text-to-video # price and the JSON Schema for i
152152
shotstack models --output json
153153
```
154154

155+
### `shotstack generate <model> <prompt>`
156+
157+
Generates one image, video or audio asset from a prompt and prints its URL, without rendering an edit. `--options` takes the model's options as a JSON object (see `shotstack models <model>`). `--length` sets the clip length in seconds for models that generate to a duration. Generation is billed in credits on stage as well as v1. Identical requests share one job and its cached result, so repeating a request returns the same job instead of generating and charging again.
158+
159+
```sh
160+
shotstack generate nano-banana-2 "A ceramic mug on a walnut desk, soft window light" --watch
161+
# → processing 8a1f2c3d-…
162+
# → done https://…/8a1f2c3d.png
163+
shotstack generate seedance-2.0-text-to-video "Waves break over black sand at dusk" \
164+
--options '{"resolution":"480p"}' --length 5 --watch --output json
165+
```
166+
155167
### `shotstack login` / `shotstack logout`
156168

157169
Saves (or removes) your API key so it persists across shell sessions. Keys are stored per environment in `~/.shotstack/credentials.json`.

‎skills/shotstack/SKILL.md‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ This skill loads in **terminal-based AI agents** (Claude Code, Cursor, Codex CLI
1515

1616
**Open-ended first render? Ask before composing.** If the ask doesn't state what the video is (subject, format or destination) — e.g. "render my first video", "make me a video", "set up Shotstack" — read [`references/onboarding.md`](references/onboarding.md) and ask its discovery questions, then WAIT for answers. Do not write an Edit first. An open-ended ask is not a specification, and a stray JSON file in the project doesn't make the user experienced. Compose without asking only when the user has stated what the video is, or explicitly says to just show anything.
1717

18-
Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets with each one's price and whether this key can use it; `models <id>` prints the options a model accepts.
18+
Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets with each one's price and whether this key can use it; `models <id>` prints the options a model accepts. `generate <model> "<prompt>"` generates one asset on its own and prints its URL.
1919

20-
**Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`, `models`) need a key. Reach for `render` when you specifically need a final MP4 exported in the cloud; reach for `studio` for everything else — it is the cheapest way to see an edit and the right default for any creative a human will review.
20+
**Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`, `models`, `generate`) need a key. Reach for `render` when you specifically need a final MP4 exported in the cloud; reach for `studio` for everything else — it is the cheapest way to see an edit and the right default for any creative a human will review.
2121

2222
## Authentication
2323

@@ -36,7 +36,7 @@ Or set an environment variable (overrides the saved key — best for CI):
3636
export SHOTSTACK_API_KEY=...
3737
```
3838

39-
Resolution precedence: **`SHOTSTACK_API_KEY` env var → saved key for the target `--env` → error.** Get a key at <https://app.shotstack.io>. **`validate` and `studio` need no key** — only the API-backed commands (`render`, `status`, `ingest`, `models`) require one; without a key *those* exit 1, while compose → validate → studio keeps working.
39+
Resolution precedence: **`SHOTSTACK_API_KEY` env var → saved key for the target `--env` → error.** Get a key at <https://app.shotstack.io>. **`validate` and `studio` need no key** — only the API-backed commands (`render`, `status`, `ingest`, `models`, `generate`) require one; without a key *those* exit 1, while compose → validate → studio keeps working.
4040

4141
## Environments
4242

@@ -85,7 +85,7 @@ The bare command (no `--watch`) returns only an `id`; the hosted `source` URL do
8585

8686
Other ingest subcommands: `ingest fetch <url>` (host a copy of a remote URL), `ingest status <id>`, `ingest list`, `ingest delete <id>`. **Read [`references/ingest.md`](references/ingest.md) for the full upload→render workflow, status values, and supported file types.**
8787

88-
## Generation models
88+
## Generating assets
8989

9090
An `image`, `video` or `audio` asset with a `prompt` and a `model` is generated by AI. `shotstack models` lists the models, whether this API key can use each one, and its price in credits. `shotstack models <id>` adds the JSON Schema for that model's `options`. Read it before setting `options`, because values outside it are rejected.
9191

@@ -96,6 +96,18 @@ shotstack models seedance-2.0-text-to-video --output json
9696

9797
Offer only models marked available. `unavailable: AiCapabilityNotIncluded` means the plan doesn't include that kind of generation, `AiDisabled` means AI is turned off for the account, and `AiAccessUnavailable` means access couldn't be confirmed, so try again later. "availability unknown" means the check didn't run. It is not a yes.
9898

99+
`shotstack generate <model> "<prompt>"` generates one asset without rendering an edit and prints its URL. Use it to try a prompt, a voice or a still before building it into a timeline. Inside an edit, set `prompt` and `model` on the asset instead and it is generated at render time.
100+
101+
```sh
102+
shotstack generate nano-banana-2 "A ceramic mug on a walnut desk, soft window light" \
103+
--options '{"resolution":"2K","aspectRatio":"16:9"}' --watch --output json | tail -n1 | jq -r .url
104+
shotstack generate seedance-2.0-text-to-video "Waves break over black sand at dusk" --length 5 --watch
105+
```
106+
107+
`--length` is the clip length in seconds. Models that generate to a duration use it in place of their own duration option.
108+
109+
**Generation costs credits on stage as well as v1.** Before generating, tell the user the model and its price from `shotstack models <id>`, and wait for a yes. Identical requests share one job and its cached result: repeating a request while it runs, or after it finishes, returns the same job without charging again. That is also how to pick up a job started without `--watch`. Sending a failed generation again retries it, and that is charged.
110+
99111
## Default workflow: preview in Studio (no key, no credits)
100112

101113
**`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No API key, no render credits charged; it previews client-side, so it works with no key. The human plays, edits, and decides whether to spend credits on a render.

‎src/commands/generate.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Command, InvalidArgumentError } from "commander";
2+
import type { components } from "@shotstack/schemas";
3+
import { createClient, type Client } from "../http/client.ts";
4+
import { requireApiKey } from "../http/auth.ts";
5+
import { resolveEnv, ENV_NAMES } from "../http/env.ts";
6+
import { emit, type OutputFormat, parseOutputFormat } from "../output.ts";
7+
import { withRecording, commandArgv, type CommandResult } from "../recorder.ts";
8+
import { fetchModel, formatAvailability, type GenerationModel } from "./models.ts";
9+
10+
export type Generation = components["schemas"]["GenerationResponse"];
11+
12+
export interface GenerateInput {
13+
model: string;
14+
prompt: string;
15+
options?: Record<string, unknown>;
16+
length?: number;
17+
}
18+
19+
const TERMINAL_STATES = new Set(["done", "failed"]);
20+
const POLL_INTERVAL_MS = 3000;
21+
22+
export const generateCommand = new Command("generate")
23+
.description("Generate one image, video or audio asset from a prompt")
24+
.argument("<model>", "Model id from `shotstack models`")
25+
.argument("<prompt>", "Text prompt; for a speech model, the words to speak")
26+
.option("--options <json>", "Model options as a JSON object (see `shotstack models <model>`)", parseOptionsJson)
27+
.option("--length <seconds>", "Length of the clip the asset fills; models that generate to a duration use it", parseSeconds)
28+
.option("--watch", "Poll until the asset is ready, then print its URL")
29+
.option(`--env <name>`, `Environment: ${ENV_NAMES.join(" | ")}`)
30+
.option("--output <format>", "Output format: text | json", "text")
31+
.action(
32+
async (
33+
model: string,
34+
prompt: string,
35+
options: { options?: Record<string, unknown>; length?: number; watch?: boolean; env?: string; output: string },
36+
) => {
37+
await withRecording("generate", commandArgv("generate"), async () => {
38+
const format = parseOutputFormat(options.output);
39+
const env = resolveEnv(options.env);
40+
const apiKey = requireApiKey(env.name);
41+
const client = createClient({ apiKey, env });
42+
const input = { model, prompt, options: options.options, length: options.length };
43+
return runGenerate(client, input, format, options.watch === true);
44+
});
45+
},
46+
);
47+
48+
export async function runGenerate(
49+
client: Client,
50+
input: GenerateInput,
51+
format: OutputFormat,
52+
watch: boolean,
53+
intervalMs: number = POLL_INTERVAL_MS,
54+
): Promise<CommandResult> {
55+
// The model's type is needed for the asset, and an unavailable model is refused before anything is billed.
56+
const model = await fetchModel(client, input.model);
57+
if (model.available === false) {
58+
const refusal = { model: model.model, available: false, unavailableReason: model.unavailableReason };
59+
if (format === "json") console.log(JSON.stringify(refusal));
60+
else console.error(`✗ ${model.model} is ${formatAvailability(model)}`);
61+
return { response: refusal, exitCode: 1 };
62+
}
63+
64+
// No Idempotency-Key: without one, identical requests share a job and its cached result.
65+
const submitted = await client.post<Generation>("/generate", buildRequest(model, input));
66+
emit(format, submitted, formatHuman(submitted));
67+
68+
let final = submitted;
69+
if (!TERMINAL_STATES.has(submitted.status)) {
70+
if (watch) final = await pollGeneration(client, submitted.id, format, intervalMs);
71+
else if (format !== "json") {
72+
console.error("Still generating. Run the same command with --watch to wait; the same request returns this job instead of starting another.");
73+
}
74+
}
75+
return { renderId: final.id, response: final, exitCode: final.status === "failed" ? 1 : 0 };
76+
}
77+
78+
export function buildRequest(model: GenerationModel, input: GenerateInput) {
79+
return {
80+
asset: {
81+
type: model.type,
82+
prompt: input.prompt,
83+
model: model.model,
84+
...(input.options && { options: input.options }),
85+
},
86+
...(input.length !== undefined && { length: input.length }),
87+
};
88+
}
89+
90+
export async function pollGeneration(
91+
client: Client,
92+
id: string,
93+
format: OutputFormat,
94+
intervalMs: number = POLL_INTERVAL_MS,
95+
): Promise<Generation> {
96+
while (true) {
97+
await sleep(intervalMs);
98+
const generation = await client.get<Generation>(`/generate/${encodeURIComponent(id)}`);
99+
emit(format, generation, formatHuman(generation));
100+
if (TERMINAL_STATES.has(generation.status)) return generation;
101+
}
102+
}
103+
104+
export function parseOptionsJson(value: string): Record<string, unknown> {
105+
let parsed: unknown;
106+
try {
107+
parsed = JSON.parse(value);
108+
} catch {
109+
throw new InvalidArgumentError("Not valid JSON.");
110+
}
111+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
112+
throw new InvalidArgumentError("Expected a JSON object.");
113+
}
114+
return parsed as Record<string, unknown>;
115+
}
116+
117+
// Checked here because NaN serialises to null, which the API reads as no length and bills the model's default.
118+
export function parseSeconds(value: string): number {
119+
const seconds = Number(value);
120+
if (!Number.isFinite(seconds) || seconds <= 0) throw new InvalidArgumentError("Expected a positive number of seconds.");
121+
return seconds;
122+
}
123+
124+
function formatHuman(g: Generation): string {
125+
if (g.status === "done") return `done ${g.url ?? ""}`.trimEnd();
126+
if (g.status === "failed") return `failed error: ${g.error ?? "unknown error"}`;
127+
return `${g.status} ${g.id}`;
128+
}
129+
130+
function sleep(ms: number): Promise<void> {
131+
return new Promise((resolve) => setTimeout(resolve, ms));
132+
}

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ingestCommand } from "./commands/ingest.ts";
77
import { templateCommand } from "./commands/template.ts";
88
import { validateCommand } from "./commands/validate.ts";
99
import { modelsCommand } from "./commands/models.ts";
10+
import { generateCommand } from "./commands/generate.ts";
1011
import { loginCommand, logoutCommand } from "./commands/auth.ts";
1112
import { ApiError } from "./http/client.ts";
1213
import { MissingApiKeyError } from "./http/auth.ts";
@@ -24,6 +25,7 @@ const program = new Command()
2425
.addCommand(templateCommand)
2526
.addCommand(validateCommand)
2627
.addCommand(modelsCommand)
28+
.addCommand(generateCommand)
2729
.addCommand(loginCommand)
2830
.addCommand(logoutCommand)
2931
.addCommand(feedbackCommand);

‎tests/generate.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, test, expect, mock } from "bun:test";
2+
import { InvalidArgumentError } from "commander";
3+
import { buildRequest, parseOptionsJson, parseSeconds, runGenerate, type Generation } from "../src/commands/generate.ts";
4+
import type { GenerationModel } from "../src/commands/models.ts";
5+
import type { Client } from "../src/http/client.ts";
6+
7+
const video: GenerationModel = { model: "seedance-2.0-text-to-video", type: "video", available: true };
8+
9+
function fakeClient(model: GenerationModel, submitted: Generation, ...polls: Generation[]) {
10+
let i = 0;
11+
const post = mock(async () => submitted);
12+
const get = mock(async (path: string) => (path.startsWith("/models/") ? model : polls[Math.min(i++, polls.length - 1)]));
13+
return { client: { get, post } as unknown as Client, post, get };
14+
}
15+
16+
describe("buildRequest", () => {
17+
test("takes the asset type from the model and omits unset fields", () => {
18+
expect(buildRequest(video, { model: "x", prompt: "waves" })).toEqual({
19+
asset: { type: "video", prompt: "waves", model: "seedance-2.0-text-to-video" },
20+
});
21+
expect(buildRequest(video, { model: "x", prompt: "waves", options: { resolution: "480p" }, length: 5 })).toEqual({
22+
asset: { type: "video", prompt: "waves", model: "seedance-2.0-text-to-video", options: { resolution: "480p" } },
23+
length: 5,
24+
});
25+
});
26+
});
27+
28+
describe("option parsers", () => {
29+
test("options must be a JSON object", () => {
30+
expect(parseOptionsJson('{"resolution":"720p"}')).toEqual({ resolution: "720p" });
31+
expect(() => parseOptionsJson("[1]")).toThrow(InvalidArgumentError);
32+
expect(() => parseOptionsJson("{resolution")).toThrow(InvalidArgumentError);
33+
});
34+
35+
test("length must be a positive number", () => {
36+
expect(parseSeconds("5")).toBe(5);
37+
for (const bad of ["abc", "0", "-2"]) expect(() => parseSeconds(bad)).toThrow(InvalidArgumentError);
38+
});
39+
});
40+
41+
describe("runGenerate", () => {
42+
test("refuses an unavailable model without submitting", async () => {
43+
const { client, post } = fakeClient(
44+
{ ...video, available: false, unavailableReason: "AiCapabilityNotIncluded" },
45+
{ id: "g", status: "queued" },
46+
);
47+
const result = await runGenerate(client, { model: video.model, prompt: "waves" }, "json", true, 0);
48+
expect(post).not.toHaveBeenCalled();
49+
expect(result.exitCode).toBe(1);
50+
});
51+
52+
test("returns a cached result without polling", async () => {
53+
const { client, get } = fakeClient(video, { id: "g", status: "done", url: "https://cdn.example.com/g.mp4" });
54+
const result = await runGenerate(client, { model: video.model, prompt: "waves" }, "json", true, 0);
55+
expect(get).toHaveBeenCalledTimes(1);
56+
expect(result.exitCode).toBe(0);
57+
});
58+
59+
test("polls a queued job until it fails", async () => {
60+
const { client } = fakeClient(
61+
video,
62+
{ id: "g", status: "queued" },
63+
{ id: "g", status: "processing" },
64+
{ id: "g", status: "failed", error: "refused" },
65+
);
66+
const result = await runGenerate(client, { model: video.model, prompt: "waves" }, "json", true, 0);
67+
expect(result.response).toEqual({ id: "g", status: "failed", error: "refused" });
68+
expect(result.exitCode).toBe(1);
69+
});
70+
});

0 commit comments

Comments
 (0)