Skip to content

Commit 76ec5e0

Browse files
committed
feat: add preview command to open Shotstack Edit JSON in browser editor
1 parent 7bdbaa1 commit 76ec5e0

8 files changed

Lines changed: 202 additions & 30 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ shotstack status 01ja7-x8m2k-... --watch
6767
shotstack status 01ja7-x8m2k-... --output json
6868
```
6969

70+
### `shotstack preview <file>`
71+
72+
Opens a `shotstack.studio` URL that loads the Edit JSON directly into the browser-based editor. No API call, no key, no charge — pure client-side encoding via the URL hash. Use it to hand a generated edit off to a human for review or quick tweaks before rendering.
73+
74+
```sh
75+
shotstack preview my-template.json
76+
# → opens browser silently
77+
78+
shotstack preview my-template.json --copy # also copies URL to clipboard
79+
shotstack preview my-template.json --no-open # print URL, don't open browser
80+
shotstack preview my-template.json --output json # emit {"url":"..."} on stdout
81+
```
82+
83+
When a browser can be launched, the command is silent — the URL only opens in the browser. On a headless server (no `$DISPLAY`, no `xdg-open`), the URL is printed to stdout instead so you can copy it elsewhere.
84+
85+
Templates whose encoded URL exceeds ~6KB print a stderr warning. Browser URL limits vary; if you regularly exceed it, host the JSON publicly and link to it via `https://shotstack.studio/#src=<https-url>`.
86+
7087
### `shotstack feedback`
7188

7289
Opens a pre-filled GitHub issue with a sanitised dossier of your last 5 CLI invocations (render IDs, errors, exit codes). API keys and signed URLs are stripped at write time. You review and submit in your browser; nothing is transmitted automatically. Inspect the log at `~/.shotstack/log.jsonl`.

SKILL.md

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,25 @@ description: |
44
Render video and poll render status via the Shotstack API.
55
Use when generating clips, building automated video pipelines, or orchestrating
66
cloud video renders from a script, agent, or CI workflow.
7-
NOT for: real-time streaming, live broadcasting, or in-browser editing UIs.
7+
NOT for: real-time streaming, or live broadcasting.
88
license: Apache-2.0
99
---
1010

1111
# Shotstack CLI
1212

13-
Two commands for the Shotstack video rendering API. `render` submits a timeline JSON and returns a render ID; `status` polls a render until done.
13+
Two commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done.
1414

1515
## Authentication
1616

1717
```sh
18-
export SHOTSTACK_API_KEY=sk_...
18+
export SHOTSTACK_API_KEY=...
1919
```
2020

21-
Get a key at <https://dashboard.shotstack.io>. Without a key, every command exits with code 1.
21+
Get a key at <https://app.shotstack.io>. Without a key, every command exits with code 1.
2222

2323
## Environments
2424

2525
```
26-
--env dev → https://api.shotstack.io/edit/dev
2726
--env stage → https://api.shotstack.io/edit/stage (test credits, free)
2827
--env v1 → https://api.shotstack.io/edit/v1 (production, default)
2928
```
@@ -39,26 +38,42 @@ shotstack render template.json --output json --env stage
3938

4039
# Poll until terminal state.
4140
shotstack status 01ja7-x8m2k-39rzv-cmvxve --watch --env stage
42-
# → done https://shotstack-api-stage-output.s3.amazonaws.com/.../out.mp4
41+
# → done https://shotstack-api-stage-output.s3.amazonaws.com/.../01ja7-x8m2k-39rzv-cmvxve.mp4
4342
```
4443

45-
## Three CLI rules
44+
## Hand-off to a human before rendering
45+
46+
When a human is in the loop and may want to tweak the result, prefer **`shotstack preview <file>`** over `shotstack render`. By default it opens the browser to `https://shotstack.studio/#json=<base64url>` and prints the URL — the timeline loads directly into the browser-based editor. No API call, no key, no charge. The human can play, edit, and decide whether to render — saving credits when the AI's first attempt isn't quite right.
47+
48+
```sh
49+
shotstack preview template.json # opens browser + prints URL
50+
shotstack preview template.json --no-open # headless: just print the URL
51+
shotstack preview template.json --output json # piping: {"url":"..."}, no browser
52+
```
53+
54+
On headless systems (no `xdg-open`, no `$DISPLAY`) the browser launch silently no-ops; the URL is still printed. Safe to run anywhere.
55+
56+
Use `render` only when you're confident the JSON is final, or there's no human to review.
57+
58+
## Four CLI rules
4659

4760
1. **Pipe → `--output json`.** Default output is human-readable. When parsing programmatically or piping to another command, always pass `--output json`. The text format is not stable across versions.
4861

4962
2. **Use `--watch`, not a polling loop.** `shotstack status <id> --watch` exits when the render reaches `done` (exit 0) or `failed` (exit 1). Don't write `while true; do ...; sleep 3; done`.
5063

51-
3. **Fetch the current docs before generating timeline JSON.** The Shotstack API evolves; LLM training data is often stale. Pull <https://shotstack.io/docs/guide/llms-full.txt> for the current schema and examples before composing a timeline from scratch.
64+
3. **Fetch the current docs before generating Edit JSON.** The Shotstack API evolves; LLM training data is often stale. Pull <https://shotstack.io/docs/guide/llms-full.txt> for the current schema and examples before composing an Edit from scratch.
65+
66+
4. **Hand off to a human via `preview` when uncertain.** Don't burn render credits iterating. Generate JSON → `shotstack preview` → human reviews/tweaks → render only when right.
5267

53-
## Authoring timeline JSON
68+
## Authoring Edit JSON
5469

55-
These are the conventions agents most often get wrong. Read this section before generating any timeline JSON.
70+
These are the conventions agents most often get wrong. Read this section before generating any Edit JSON.
5671

5772
### Before composing JSON: check the schema
5873

5974
Don't invent property names or enum values. The Shotstack schema is published — fetch one of these before composing JSON from scratch:
6075

61-
- <https://shotstack.io/docs/api/api.bundled.json> — single-file JSON Schema. Machine-validatable; load it once and validate locally instead of round-tripping the API.
76+
- <https://shotstack.io/docs/api/api.edit.json> — single-file OpenAPI Schema. Machine-validatable; load it once and validate locally instead of round-tripping the API.
6277
- <https://shotstack.io/docs/api/> — interactive HTML reference. Fastest for human scanning.
6378
- <https://shotstack.io/docs/guide/llms-full.txt> — single-file LLM-friendly version of the full guide + reference.
6479
- <https://github.com/shotstack/oas-api-definition/tree/main/schemas> — raw OpenAPI YAML, source of truth.
@@ -167,7 +182,7 @@ The render API supports many asset types. Use only the **current** ones; the dep
167182

168183
### AI-generated assets
169184

170-
`image-to-video` and `text-to-image` are billed per generation **even when invoked through the sandbox stage endpoint** (which is otherwise free). They are async — the render submits the AI job and waits. Renders containing AI assets take longer.
185+
`image-to-video`, `text-to-speech`, and `text-to-image` are billed per generation **even when invoked through the sandbox stage endpoint** (which is otherwise free). They are async — the render submits the AI job and waits. Renders containing AI assets take longer.
171186

172187
## Fonts
173188

@@ -220,10 +235,11 @@ The `font.family` value is the **font file basename** (without `.ttf`/`.otf`).
220235
Authoritative sources, in order of preference:
221236

222237
- `shotstack --help` and `shotstack <command> --help` — current CLI flag listing
223-
- <https://shotstack.io/docs/api/api.bundled.json>JSON Schema for the render API (machine-validatable)
238+
- <https://shotstack.io/docs/api/api.edit.json>OpenAPI Schema for the render API (machine-validatable)
224239
- <https://shotstack.io/docs/guide/llms-full.txt> — full API docs in LLM-friendly single file
225240
- <https://github.com/shotstack/oas-api-definition/tree/main/schemas> — raw OpenAPI YAML, source of truth for property names and enums
226-
- <https://shotstack.io/docs/api/> — interactive HTML reference
241+
- <https://shotstack.io/docs/guide/> — interactive HTML docs and guides
242+
- <https://shotstack.io/docs/api/> — interactive HTML API reference
227243
- <https://github.com/shotstack/shotstack-cli> — CLI source
228244

229245
This skill ships sub-references for the gnarly bits:

references/timeline.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Timeline conventions
22

3-
Detailed guide to track layering, the soundtrack vs audio distinction, and `timeline.fonts[]`. Read this when building any non-trivial timeline JSON.
3+
Detailed guide to track layering, the soundtrack vs audio distinction, and `timeline.fonts[]`. Read this when building any non-trivial Edit JSON.
44

55
## Contents
66

@@ -74,13 +74,7 @@ If you put the video first and the captions last, the video covers the captions
7474

7575
## Soundtrack vs audio asset
7676

77-
There are two ways to add audio. Pick the right one.
78-
79-
| Use `timeline.soundtrack` when | Use `audio` asset when |
80-
|---|---|
81-
| One background music track for the entire timeline | Sound effects at specific times |
82-
| Loop or fade the music | Voiceover synced to specific clips |
83-
| You don't need to control timing | Multiple audio sources at different times |
77+
Prefer `Audio` assets — they support custom timing, keyframes, and effects, and can do everything `timeline.soundtrack` can. Use `timeline.soundtrack` only when you want a single background track spanning the whole edit.
8478

8579
```json
8680
{
@@ -94,7 +88,9 @@ There are two ways to add audio. Pick the right one.
9488
}
9589
```
9690

97-
Soundtrack supported `effect` values: `fadeIn`, `fadeOut`, `fadeInFadeOut`.
91+
`soundtrack.effect` accepts `fadeIn`, `fadeOut`, or `fadeInFadeOut`.
92+
93+
An `audio` clip with `length: "end"` is functionally identical to `timeline.soundtrack`.
9894

9995
## `timeline.fonts[]` for custom fonts
10096

references/troubleshooting.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Common errors and the fix for each. If your error isn't here, run `shotstack fee
1313
- "Invalid asset URL"
1414
- "Render failed" with no other detail
1515
- Render takes much longer than expected
16-
- Stage credits exhausted
16+
- Credits exhausted on stage environment
1717

1818
## "Unknown property: alignment" (and similar)
1919

@@ -28,7 +28,7 @@ You used a property name from CSS or HTML instinct — `alignment`, `font.name`,
2828
| `duration` | `length` |
2929
| `transitions: [...]` (array) | `transition: { in, out }` (object) |
3030

31-
Always check <https://shotstack.io/docs/api/api.bundled.json> (JSON Schema) or <https://shotstack.io/docs/guide/llms-full.txt> before composing timeline JSON. Don't guess from CSS conventions.
31+
Always check <https://shotstack.io/docs/api/api.edit.json> (OpenAPI Schema) or <https://shotstack.io/docs/guide/llms-full.txt> before composing Edit JSON. Don't guess from CSS conventions.
3232

3333
## "Invalid option: expected one of top|middle|bottom"
3434

@@ -91,11 +91,11 @@ Renders containing AI-generation assets (`text-to-image`, `image-to-video`) take
9191

9292
**Fix:** if you're iterating quickly, render with `output.resolution: "preview"` first (512×288 @ 15fps) to validate the timeline shape before spending credits on full-resolution output.
9393

94-
## Stage credits exhausted
94+
## Credits exhausted on stage environment
9595

96-
The `stage` environment uses test credits. They reset on a regular cadence but can be exhausted if you run many renders.
96+
The `stage` environment uses no credits, but you do require a positive credit balance to make use of the sandbox.
9797

98-
**Fix:** wait for the reset, or run against `--env v1` (production credits, real money). Check current credit balance at <https://dashboard.shotstack.io>.
98+
**Fix:** add more credits to your account. Check current credit balance at <https://app.shotstack.io>.
9999

100100
## Filing a useful bug report
101101

src/commands/preview.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { readFile } from "node:fs/promises";
2+
import { resolve } from "node:path";
3+
import { spawn, execSync } from "node:child_process";
4+
import { Command } from "commander";
5+
import { emit, parseOutputFormat } from "../output.ts";
6+
7+
const STUDIO_URL = "https://shotstack.studio";
8+
const URL_WARN_THRESHOLD = 6000;
9+
10+
export const previewCommand = new Command("preview")
11+
.description("Open a shotstack.studio URL that loads the Edit JSON in the browser editor")
12+
.argument("<file>", "Path to a Shotstack Edit JSON file")
13+
.option("--copy", "Copy the URL to the clipboard")
14+
.option("--no-open", "Do not try to open the URL in a browser")
15+
.option("--output <format>", "Output format: text | json", "text")
16+
.action(async (file: string, options: { copy?: boolean; open: boolean; output: string }) => {
17+
const format = parseOutputFormat(options.output);
18+
19+
const path = resolve(process.cwd(), file);
20+
const raw = await readFile(path, "utf8");
21+
const template = JSON.parse(raw) as unknown;
22+
assertTemplateShape(template);
23+
24+
const url = buildPreviewUrl(template);
25+
26+
if (url.length > URL_WARN_THRESHOLD) {
27+
console.error(`warning: encoded URL is ${url.length} characters; large templates may exceed browser URL limits.`);
28+
}
29+
30+
if (options.copy) await copyToClipboard(url);
31+
32+
const opened = options.open && browserAvailable() && openInBrowser(url);
33+
34+
if (format === "json" || !opened) {
35+
emit(format, { url }, url);
36+
}
37+
});
38+
39+
export function buildPreviewUrl(template: unknown): string {
40+
const encoded = Buffer.from(JSON.stringify(template), "utf8").toString("base64url");
41+
return `${STUDIO_URL}/#json=${encoded}`;
42+
}
43+
44+
function assertTemplateShape(t: unknown): asserts t is { timeline: unknown; output: unknown } {
45+
if (!t || typeof t !== "object") throw new Error("Template must be a JSON object.");
46+
if (!("timeline" in t)) throw new Error("Template missing required field: timeline.");
47+
if (!("output" in t)) throw new Error("Template missing required field: output.");
48+
}
49+
50+
async function copyToClipboard(text: string): Promise<void> {
51+
const { cmd, args } = clipboardCommand();
52+
await new Promise<void>((res, rej) => {
53+
const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
54+
proc.on("error", rej);
55+
proc.on("exit", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}`))));
56+
proc.stdin.end(text);
57+
});
58+
}
59+
60+
function clipboardCommand(): { cmd: string; args: string[] } {
61+
if (process.platform === "darwin") return { cmd: "pbcopy", args: [] };
62+
if (process.platform === "win32") return { cmd: "clip", args: [] };
63+
return { cmd: "xclip", args: ["-selection", "clipboard"] };
64+
}
65+
66+
function openInBrowser(url: string): boolean {
67+
const { cmd, args } = browserCommand(url);
68+
try {
69+
const proc = spawn(cmd, args, { detached: true, stdio: "ignore" });
70+
proc.on("error", () => {});
71+
proc.unref();
72+
return true;
73+
} catch {
74+
return false;
75+
}
76+
}
77+
78+
function browserCommand(url: string): { cmd: string; args: string[] } {
79+
if (process.platform === "win32") return { cmd: "cmd", args: ["/c", "start", "", url] };
80+
if (process.platform === "darwin") return { cmd: "open", args: [url] };
81+
return { cmd: "xdg-open", args: [url] };
82+
}
83+
84+
function browserAvailable(): boolean {
85+
if (process.platform === "darwin" || process.platform === "win32") return true;
86+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
87+
try {
88+
execSync("command -v xdg-open", { stdio: "ignore" });
89+
return true;
90+
} catch {
91+
return false;
92+
}
93+
}

src/commands/render.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ interface RenderResponse {
1414
}
1515

1616
export const renderCommand = new Command("render")
17-
.description("Submit a Shotstack timeline JSON to the render API")
18-
.argument("<file>", "Path to a Shotstack timeline JSON file")
17+
.description("Submit a Shotstack Edit JSON to the render API")
18+
.argument("<file>", "Path to a Shotstack Edit JSON file")
1919
.option(`--env <name>`, `Environment: ${ENV_NAMES.join(" | ")}`)
2020
.option("--output <format>", "Output format: text | json", "text")
2121
.action(async (file: string, options: { env?: string; output: string }) => {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from "commander";
22
import { renderCommand } from "./commands/render.ts";
33
import { statusCommand } from "./commands/status.ts";
44
import { feedbackCommand } from "./commands/feedback.ts";
5+
import { previewCommand } from "./commands/preview.ts";
56
import { ApiError } from "./http/client.ts";
67
import { MissingApiKeyError } from "./http/auth.ts";
78
import { InvalidEnvError } from "./http/env.ts";
@@ -13,6 +14,7 @@ const program = new Command()
1314
.version(version)
1415
.addCommand(renderCommand)
1516
.addCommand(statusCommand)
17+
.addCommand(previewCommand)
1618
.addCommand(feedbackCommand);
1719

1820
try {

tests/preview.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { buildPreviewUrl } from "../src/commands/preview.ts";
3+
4+
const sample = {
5+
timeline: {
6+
background: "#000000",
7+
tracks: [
8+
{
9+
clips: [
10+
{
11+
asset: { type: "rich-text", text: "hello", font: { family: "Roboto", size: 22 } },
12+
start: 0,
13+
length: 5,
14+
},
15+
],
16+
},
17+
],
18+
},
19+
output: { size: { width: 1920, height: 1080 }, format: "mp4" },
20+
};
21+
22+
describe("buildPreviewUrl", () => {
23+
test("emits a shotstack.studio URL with #json= hash", () => {
24+
const url = buildPreviewUrl(sample);
25+
expect(url.startsWith("https://shotstack.studio/#json=")).toBe(true);
26+
});
27+
28+
test("encoded payload uses base64url alphabet (no +, /, =)", () => {
29+
const url = buildPreviewUrl(sample);
30+
const encoded = url.split("#json=")[1]!;
31+
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
32+
});
33+
34+
test("decoding the hash returns identical JSON", () => {
35+
const url = buildPreviewUrl(sample);
36+
const encoded = url.split("#json=")[1]!;
37+
const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
38+
expect(decoded).toEqual(sample);
39+
});
40+
41+
test("handles unicode in text fields", () => {
42+
const tpl = { ...sample, timeline: { ...sample.timeline, tracks: [{ clips: [{ asset: { type: "rich-text", text: "héllo 🌏 中文" }, start: 0, length: 1 }] }] } };
43+
const url = buildPreviewUrl(tpl);
44+
const encoded = url.split("#json=")[1]!;
45+
const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
46+
expect(decoded).toEqual(tpl);
47+
});
48+
});

0 commit comments

Comments
 (0)