# Vidmoat developer documentation Everything the public API exposes, in one document. Generated from the same catalogue the human-facing reference renders from, so the two cannot disagree. Base URL: `https://api.vidmoat.com/v1` --- ## Runnable editor starter Guide: https://developer.vidmoat.com/developer/docs/editor-starter Source ZIP: https://developer.vidmoat.com/docs/starters/vidmoat-video-editor.zip React frontend plus a local Node server with a server-held scoped API key. Supports media uploads/imports, editable titles and clip timing, frame previews, and MP4 export. Single-user local example; add authentication and per-user authorization before public hosting. The Vidmoat rendering engine remains hosted. Test-key exports return sample fixtures. ## Authentication Two models, and which one you want depends on whose account the work happens in. 1. **API key** — you act as yourself. `Authorization: Bearer vmk_live_…`. Credits come out of your balance. Right when Vidmoat is an implementation detail of your product. Test keys (`vmk_test_…`) are free on every plan, spend nothing and never queue a real render. 2. **Sign in with Vidmoat** — OAuth 2.0 + PKCE, and you act on behalf of a user who has their own Vidmoat account and their own credits. Authorize at `https://www.vidmoat.com/oauth/authorize`, exchange the code, then use the access token exactly like a key. A key can never mint another key: key management is session-authenticated only. --- ## Scopes Additive, enforced on every request, and never sufficient on their own — the account's plan and credit balance still apply. A missing scope returns 403 and names the scope it wanted. | Scope | Grants | Spends credits | |---|---|---| | `account.read` | See your plan and remaining credits | no | | `projects.read` | Read your projects and their edit documents | no | | `projects.write` | Create, edit and delete projects | no | | `media.read` | List the media you have uploaded | no | | `media.write` | Upload media and import it from a URL | no | | `render.read` | Check render progress and fetch preview frames | no | | `render.write` | Start renders, using your monthly export allowance | yes | | `ai.transcribe` | Transcribe audio, spending your credits | yes | | `ai.speech` | Generate speech, spending your credits | yes | | `ai.image` | Generate images and stickers, spending your credits | yes | | `ai.video` | Generate video, spending your credits | yes | | `ai.agent` | Run the AI editing agent, spending your credits | yes | | `ai.analyze` | Analyse footage for scenes, faces and speech | yes | | `stock.read` | Search the stock media library | no | | `webhooks.manage` | Manage webhook endpoints for this app | no | | `plugins.invoke` | Call third-party plugins you have installed, sending them the arguments of each call | no | Read-only preset: `account.read`, `projects.read`, `media.read`, `render.read`, `stock.read`. Named bundles offered in the console: - **Read only** — Look at projects, media and render status. Cannot change or spend anything. (`account.read`, `projects.read`, `media.read`, `render.read`, `stock.read`) - **Build & render** — Everything above, plus creating and editing projects, uploading media and starting renders. (`account.read`, `projects.read`, `media.read`, `render.read`, `stock.read`, `projects.write`, `media.write`, `render.write`) - **Build & render + AI** — Everything above, plus transcription, generation and the editing agent. Spends credits. (`account.read`, `projects.read`, `media.read`, `render.read`, `stock.read`, `projects.write`, `media.write`, `render.write`, `ai.transcribe`, `ai.speech`, `ai.image`, `ai.video`, `ai.agent`, `ai.analyze`) --- ## Endpoints ### Account Who the credential belongs to, what it may do, and what it has spent. #### GET /v1/me The owner of this credential: plan, credit balance, and the scopes this key actually carries. The first call to make when a request is 403-ing and you are not sure why. - Scope: `account.read` - Cost: — ```bash curl https://api.vidmoat.com/v1/me \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### GET /v1/usage Request counts (with the 4xx/5xx split) and credits spent, for building your own dashboard or a spend alarm. - Scope: `account.read` - Cost: — ```bash curl "https://api.vidmoat.com/v1/usage?days=30" \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### GET /v1/schema/commands The full command vocabulary the editor accepts, verbatim from COMMAND_SCHEMA. A valid bearer token is required, but no additional scope is needed. - Scope: none - Cost: — - Note: This is the same schema the editor and the MCP server validate against. If an op is not in here, it does not exist. ```bash curl https://api.vidmoat.com/v1/schema/commands \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` ### Projects A project is a timeline document. Every mutation goes through the same command reducer the editor uses, so there is exactly one definition of what an edit means. #### GET /v1/projects List your projects, newest first. Add ?workspace= to list one folder, or ?workspace=unfiled for the ones in none. - Scope: `projects.read` - Cost: — ```bash curl "https://api.vidmoat.com/v1/projects?limit=20" \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/projects Create an empty project. Counts against the plan project limit. Pass workspaceId to file it as it is created. - Scope: `projects.write` - Cost: project quota ```bash curl -X POST https://api.vidmoat.com/v1/projects \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"My first API edit"}' ``` #### GET /v1/projects/{id} One project including its full edit document — clips, tracks, settings. - Scope: `projects.read` - Cost: — ```bash curl https://api.vidmoat.com/v1/projects/$ID \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### PATCH /v1/projects/{id} Rename, or change output settings such as resolution and frame rate. - Scope: `projects.write` - Cost: — ```bash curl -X PATCH https://api.vidmoat.com/v1/projects/$ID \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Renamed"}' ``` #### DELETE /v1/projects/{id} Delete a project and its render jobs. - Scope: `projects.write` - Cost: — ```bash curl -X DELETE https://api.vidmoat.com/v1/projects/$ID \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/projects/{id}/commands Apply an ordered list of edit commands. This is the only way to change a timeline, and it is the same reducer the editor calls — anything you can do by hand you can do here. - Scope: `projects.write` - Cost: plan-gated ops - Note: Commands apply atomically: if one fails validation, none are written. Individual ops can still be plan-gated — a pro effect on a free plan returns 402 while the rest of the batch is rolled back. ```bash curl -X POST https://api.vidmoat.com/v1/projects/$ID/commands \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"commands":[ {"op":"addTextClip","text":"Hello","start":0,"duration":3} ]}' ``` #### POST /v1/projects/{id}/validate Dry-run a command batch and get back the lint warnings without persisting anything. Overlapping text, off-canvas elements, unreadable font sizes. - Scope: `projects.read` - Cost: — - Note: Read the lint. Numeric x/y/fontSize choices routinely overlap on the real canvas, and this is cheaper than a render to find out. ```bash curl -X POST https://api.vidmoat.com/v1/projects/$ID/validate \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"commands":[{"op":"addTextClip","text":"Hi","start":0,"duration":2}]}' ``` ### Workspaces A workspace is a folder for projects — a client, a series, a campaign. It is NOT a team: a team decides who can see a project, a workspace decides what body of work it belongs to, and a project carries both. Workspaces ride on the projects scopes rather than having their own, so keys you have already minted can use them. #### GET /v1/workspaces Every workspace you can see, with how many projects are in each. - Scope: `projects.read` - Cost: — ```bash curl https://api.vidmoat.com/v1/workspaces \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/workspaces Create a workspace. Pass teamId to make it a shared one. - Scope: `projects.write` - Cost: — ```bash curl -X POST https://api.vidmoat.com/v1/workspaces \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Q3","icon":"📦"}' ``` #### PATCH /v1/workspaces/{id} Rename, recolour or reorder a workspace. - Scope: `projects.write` - Cost: — ```bash curl -X PATCH https://api.vidmoat.com/v1/workspaces/$ID \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Q4"}' ``` #### DELETE /v1/workspaces/{id} Delete a workspace. The projects inside are KEPT and become unfiled; the response returns how many. - Scope: `projects.write` - Cost: — - Note: Deleting a folder never deletes work. `freed` in the response is the number of projects that came out of it. ```bash curl -X DELETE https://api.vidmoat.com/v1/workspaces/$ID \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### PUT /v1/projects/{id}/workspace File a project into a workspace, or send workspaceId: null to take it out. - Scope: `projects.write` - Cost: — - Note: A workspace and the project filed in it must be in the same scope: a team project goes in that team's workspace, a personal project in a personal one. ```bash curl -X PUT https://api.vidmoat.com/v1/projects/$ID/workspace \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"workspaceId":"'$WS'"}' ``` ### Rendering Renders are queued, not synchronous. A 5-second clip takes seconds; a three-minute one takes minutes. #### POST /v1/renders Queue an export. Resolution cap, watermark and concurrency come from the owner’s plan. - Scope: `render.write` - Cost: export quota ```bash curl -X POST https://api.vidmoat.com/v1/renders \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId":"'$ID'","format":"mp4","quality":"standard"}' ``` #### GET /v1/renders List your render jobs, newest first. Filter by `projectId` or `status`, and page with `cursor`. - Scope: `render.read` - Cost: — ```bash curl "https://api.vidmoat.com/v1/renders?limit=20&status=completed" \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### GET /v1/renders/{id} Poll a job: PENDING → PROCESSING → COMPLETED or FAILED, with progress 0-100 and the download URL on completion. - Scope: `render.read` - Cost: — - Note: Poll every few seconds, not every few hundred milliseconds — the rate limiter does not distinguish an eager poller from an attack. Configure render.completed and render.failed notifications in the Webhooks console. ```bash curl https://api.vidmoat.com/v1/renders/$JOB_ID \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### GET /v1/projects/{id}/preview Metadata (default), approximate composition HTML, or an exact rendered frame. For live playback with the app compositor, use the embedded player described in the video-preview guide. - Scope: `render.read` - Cost: — - Note: format=json is free and starts no browser. format=image launches Chromium and carries its own 10/min limit on top of your plan rate. ```bash curl "https://api.vidmoat.com/v1/projects/$ID/preview" \ -H "Authorization: Bearer $VIDMOAT_KEY" # a real frame, as JPEG bytes curl "https://api.vidmoat.com/v1/projects/$ID/preview?format=image&at=2.5" \ -H "Authorization: Bearer $VIDMOAT_KEY" -o frame.jpg ``` ### Media Bring footage in. Everything you upload is attributed to the app owner’s account, and the usual moderation applies. #### GET /v1/media List the media on the account, with the URLs a clip src can point at. - Scope: `media.read` - Cost: — ```bash curl https://api.vidmoat.com/v1/media \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/media/uploads Upload a file directly, multipart. - Scope: `media.write` - Cost: upload cap ```bash curl -X POST https://api.vidmoat.com/v1/media/uploads \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -F "file=@clip.mp4" ``` #### POST /v1/media/import Fetch a public http(s) URL server-side. Destinations pass through the SSRF guard, so private ranges and metadata endpoints are refused. - Scope: `media.write` - Cost: upload cap ```bash curl -X POST https://api.vidmoat.com/v1/media/import \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/clip.mp4"}' ``` #### GET /v1/stock/search Search the stock library by keyword, then import a result’s downloadUrl with /v1/media/import. - Scope: `stock.read` - Cost: — ```bash curl "https://api.vidmoat.com/v1/stock/search?query=city+at+night&type=video" \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` ### Generation These spend credits, and they are the reason scopes exist. Every one runs the prompt-safety check; refusals are a 422 with a reason, not a silent empty result. #### POST /v1/ai/transcriptions Word-level timings for captions. Feed words[] straight into an addCaptions command. - Scope: `ai.transcribe` - Cost: 10 cr / 5 min - Note: Priced per 5 minutes of media, rounded up, and the length is measured before any work starts — anything over 30 minutes is refused with a 400 rather than transcribed and billed. Passing `script` + `duration` instead of `src` needs no provider and is free. ```bash curl -X POST https://api.vidmoat.com/v1/ai/transcriptions \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"src":"https://example.com/voice.mp3","language":"en"}' ``` #### POST /v1/ai/speech Text to speech. Input is capped at 1,000 characters, which is what keeps a flat price honest. - Scope: `ai.speech` - Cost: 15 credits ```bash curl -X POST https://api.vidmoat.com/v1/ai/speech \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"Ship it.","voice":"nova"}' ``` #### POST /v1/ai/images Generate a still. Optional referenceUrls (up to 3 owned images) guide appearance; omit them for unconstrained generation. - Scope: `ai.image` - Cost: 60 credits ```bash curl -X POST https://api.vidmoat.com/v1/ai/images \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt":"a lighthouse in fog, cinematic"}' ``` #### POST /v1/ai/stickers Generate a cut-out sticker with a transparent background. - Scope: `ai.image` - Cost: 20 credits ```bash curl -X POST https://api.vidmoat.com/v1/ai/stickers \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt":"a winking crab"}' ``` #### POST /v1/ai/videos Generate a clip. The dearest action on the platform, and Studio-only. - Scope: `ai.video` - Cost: 80 credits/second (400 for the 5s default) - Note: Optional consistency: pass referenceUrls (up to 7 owned images, 720p only), or imageUrl for a starting frame. These modes are mutually exclusive. withAudio opts into supported generated audio. Priced per second, not flat: 80 credits/second on grok-imagine-video (128 on 1.5), so 5s costs 400 and the 15s maximum costs 1,200. `durationSec` is clamped to 1-15 and the SAME clamped number is both sent to the provider and billed. ```bash curl -X POST https://api.vidmoat.com/v1/ai/videos \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt":"drone over a harbour at dawn","durationSec":5}' ``` #### GET /v1/ai/jobs/{id} Poll a generation job. Same shape for every generator. - Scope: `matching ai.*` - Cost: — ```bash curl https://api.vidmoat.com/v1/ai/jobs/$JOB_ID \ -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/ai/agent Describe an edit in `message` and let the agent emit and apply the commands. One turn per call, charged once — you drive the loop. - Scope: `ai.agent` - Cost: 5 credits - Note: Add `dryRun: true` to get the plan back without applying it — the credit is still spent, because the model call is what it pays for. ```bash curl -X POST https://api.vidmoat.com/v1/ai/agent \ -H "Authorization: Bearer $VIDMOAT_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId":"'$ID'","message":"cut the silences and add captions"}' ``` ### Plugins Discover installed or owned plugins, then call a tool by its published schema. #### GET /v1/plugins List installed and owned plugins and their available tools. - Scope: `account.read` - Cost: — ```bash curl https://api.vidmoat.com/v1/plugins -H "Authorization: Bearer $VIDMOAT_KEY" ``` #### POST /v1/plugins/{slug}/{tool} Invoke an available plugin tool with its arguments. Test keys return a fixture without contacting the plugin. - Scope: `plugins.invoke` - Cost: provider-dependent - Note: Replace SLUG and TOOL with an installed tool and supply arguments matching its schema. Live calls can have external effects or provider costs. ```bash curl -X POST https://api.vidmoat.com/v1/plugins/$SLUG/$TOOL -H "Authorization: Bearer $VIDMOAT_KEY" -H "Content-Type: application/json" -d '{"arguments":{}}' ``` --- ## Guide: build your own platform A guide for a developer who wants to ship a video product without building a renderer. Everything below was checked against the live API before it was written. Field names, credit costs, scope names and status strings are what the server actually returns, not what a spec says it should. Where something is not built yet, it says so. Base URL: `https://api.vidmoat.com/v1`. (`/api/v1` resolves to the same place, but use `/v1` — it is the supported path, and it avoids a redirect, which is where `Authorization` headers get dropped.) --- ## 1. What Vidmoat gives you, and what it does not ### You get - **A document format.** A project is one JSON document: settings, tracks, clips. Every clip field that affects the output is in it — transforms, crops, keyframes, colour grade, effects, masks, audio DSP, transitions. It is readable, diffable, and you can hold it in your own database if you want to. - **A command reducer.** ~80 named operations (`addClip`, `sliceClip`, `addCaptions`, `cutRanges`, `autoDuck`, `addKeyframe`, `applyMotionPreset`, `addAdjustmentLayer`, …) that take a document and produce a new one. Published verbatim at `GET /v1/schema/commands`. - **A render farm.** `POST /v1/renders` queues a job; a worker composites the document in Chromium, mixes audio through ffmpeg, and hands you an MP4/WebM/GIF URL. You never install ffmpeg. - **Generative media.** Text-to-image, text-to-video, text-to-speech, AI stickers — xAI/Grok behind a credit meter, with refunds when the provider fails. - **Transcription.** Word-level timings in exactly the shape the `addCaptions` command consumes. Plus a free mode that distributes a script you already have across a known duration, with no provider call at all. - **Preview.** Three representations: cheap JSON metadata + layout lint, a single rendered JPEG frame, and the whole interactive composition as HTML you can scrub client-side at zero server cost. - **A planner.** `POST /v1/ai/agent` turns plain English into a validated command program and applies it. ### You bring The product. The UI. The users. The onboarding, the pricing page, the retention loop, the reason anyone opens your app instead of CapCut. ### What is not there Say these out loud before you design around them: - **Render webhooks are available in the developer console.** Subscribe to `render.completed` and `render.failed`, verify signatures and inspect delivery logs at `/developer/webhooks`. Public `/v1/webhooks` management is not shipped. Poll `/v1/ai/jobs/{id}` for generation jobs and keep bounded render polling as a recovery path. See the [webhook guide](https://developer.vidmoat.com/developer/docs/webhooks). - **`ai.analyze` is not exposed.** The scope exists. The analysis (shot boundaries, loudness, voice activity) runs internally. There is no `POST /v1/ai/analyze` route. Do not build a cut-decision engine that assumes you can ask Vidmoat what is in a frame. - **No idempotency keys.** There is no `Idempotency-Key` header. Commands are not idempotent — posting `addClip` twice gives you two clips. §8 explains what to do instead. - **No chunked upload.** `POST /v1/media/uploads` buffers the whole file in memory and refuses anything over 32 MB. Bigger files go through `POST /v1/media/import`, which streams from a URL you host. - **No browser-facing API.** An API key is a server credential. It must never reach a browser, and CORS on `api.vidmoat.com` is configured for Vidmoat's own hosts, not for arbitrary third-party origins. Your web UI talks to your server; your server talks to Vidmoat. §4 shows the one place this matters architecturally. - **No frame-exact local scrubbing.** The interactive preview is a real browser playing your media over the network, driven by the page's own clock. It is excellent for composition and timing decisions. It is not a broadcast monitor. If your users are colour-grading 4K ProRes and need guaranteed frame accuracy with no network, **Vidmoat is the wrong choice** — you want a local NLE, and no amount of API is going to fix that. - **No social publishing.** No scope grants posting to Vidmoat Social, and none will. - **Browser recorder, inpainting and Flows are deliberately not in v1.** Each would let one API caller starve everyone else on the box. --- ## 2. The mental model This is the one section worth reading twice, because everything else follows from it. **A project is a JSON document.** ```jsonc { "version": 2, "settings": { "name": "…", "aspectRatio": "9:16", "width": 1080, "height": 1920, "fps": 30, "background": "#000000" }, "tracks": [ { "id": 0, "label": "V1", "type": "video", … }, … ], "clips": [ { "id": "…", "type": "video", "start": 0, "duration": 6, "trackIndex": 0, "src": "…", … }, … ], "markers": [ … ] } ``` Nothing is derived and stored. The timeline's duration is `max(clip.start + clip.duration)`, computed on read. Compositing order is the position of a clip's track in the `tracks` **array** — a clip whose track is listed later draws on top. That is a real gotcha: `trackIndex` is a track *id*, not a layer number, and the two only coincide in the default document. **Every edit is a command through one reducer.** ``` { "op": "addClip", "type": "video", "src": "…", "start": 0, "duration": 6, "trackIndex": 0 } ``` One reducer is the only thing in the system that mutates a document. It generates ids, allowlists patch fields, normalises clips, resolves collisions, and applies the auto-fixes that model output actually needs. **The same reducer serves the editor, the agent and the API.** The editor's `window.vidmoat.dispatch` calls it. The chat agent calls it. The MCP server's `edit_project` calls it. `POST /v1/projects/{id}/commands` calls it — directly, as a library import, not over HTTP. `PATCH /v1/projects/{id}` calls it, even for a rename, because `Project.name` on the database row and `document.settings.name` are two copies of one fact and `setProjectSettings` is the only thing that keeps them equal. **Why this matters to you.** An API edit and a hand edit cannot diverge. If your user opens the same project in Vidmoat's own editor, they see exactly what your API calls produced, with the same ids, the same normalisation and the same semantics. There is no "API version of the document" and no import/export step where fidelity leaks. That is the guarantee you are buying, and it is the reason a third party can build a real editor on this at all: you are not driving a sanitised subset, you are driving the actual thing. Two consequences to design around: 1. **Read the document you are about to mutate.** `GET /v1/projects/{id}?view=document` returns it verbatim — the same JSON the reducer consumes. Nothing is reshaped, so your read and your write agree about field names. 2. **The reducer may not do exactly what you asked.** An overlapping clip is pushed to the next track index, and the track is created if it is missing. `addClip` defaults `mediaDuration` to `duration` if you omit it, which quietly breaks waveform windowing and looping. Always read the returned `results[].data.clipId` rather than assuming, and pass `mediaDuration` explicitly when you pass `mediaStart`. --- ## 3. Hello, render ### 3.1 Get a key At `https://developer.vidmoat.com/apps`: create an app, accept the developer agreement, mint a key with the scopes you need. - Key format: `vmk_live_<48 hex>` or `vmk_test_<48 hex>`. Older unprefixed `vmk_` keys still work. - **Live keys need the Studio plan** (`api_access`). **Test keys work on any plan**, including free. Max 10 active keys per app. - Scopes are chosen at mint time and clamped to the app's approved ceiling — and clamped again on every request, so narrowing an app's ceiling immediately narrows every key it already issued. - Keys cannot mint keys. `/api/developer/**` is session-authenticated only. Everything is `Authorization: Bearer `. ### 3.2 Who am I ```bash export VM=https://api.vidmoat.com/v1 export KEY=vmk_live_… curl -s $VM/me -H "Authorization: Bearer $KEY" ``` ```jsonc { "account": { "id": "…", "email": "…", "name": "…", "plan": { "id": "studio", "name": "Studio", "features": ["ai_console", "no_watermark", …] }, "credits": { "remaining": 5840, "perMonth": 6000, "resetsAt": "2026-09-01T…" }, "exports": { "used": 12, "limit": null, "resetsAt": "…" }, "limits": { "maxProjects": null, "maxExportHeight": 2160, "maxUploadMb": 8192, "maxConcurrentRenders": 5 } }, "credential": { "type": "api_key", "keyId": "…", "appId": "…", "environment": "live", "scopes": ["account.read", "projects.read", "projects.write", "render.read", "render.write"] } } ``` `credential.scopes` is the answer to every 403 you are about to get. Read it first. ### 3.3 Create a project, already edited `POST /v1/projects` accepts a `commands` array, so a whole video can be one call. Returns `201`. ```bash PROJECT=$(curl -s -X POST $VM/projects \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{ "name": "Hello render", "commands": [ { "op": "setProjectSettings", "aspectRatio": "9:16", "fps": 30, "background": "#0b0b0f" }, { "op": "addTextClip", "text": "Hello, render", "start": 0.2, "duration": 3, "style": { "fontSize": 96, "fontWeight": 800, "color": "#ffffff" } }, { "op": "addShapeClip", "kind": "capsule", "start": 0, "duration": 3.5, "trackIndex": 0, "style": { "fill": "#FF4D2E", "width": 900, "height": 260 } } ] }' | jq -r '.project.id') ``` Response: ```jsonc { "project": { "id": "clx…", "name": "Hello render", "createdAt": "…", "updatedAt": "…", "durationSec": 3.5, "clipCount": 2, "settings": { "aspectRatio": "9:16", "width": 1080, "height": 1920, "fps": 30, "background": "#0b0b0f" } }, "results": [ { "op": "setProjectSettings", "ok": true }, { "op": "addTextClip", "ok": true, "data": { "clipId": "…" } }, … ], "blocked": [], "lint": [ { "severity": "note", "time": 0.2, "message": "…" } ], "suggestedPreviewTimes": [0.2, 1.75] } ``` If a command in the program fails, the project is **not** rolled back. You get an empty-ish project plus a `results` array telling you which command was wrong — a debuggable state, rather than burning a project-quota slot on every retry. ### 3.4 Apply more commands ```bash curl -s -X POST $VM/projects/$PROJECT/commands \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{ "commands": [ { "op": "applyMotionPreset", "clipId": "CLIP_ID", "preset": "bounce-in" }, { "op": "setTransition", "clipId": "CLIP_ID", "type": "dissolve", "duration": 0.4 } ] }' ``` ```jsonc { "projectId": "clx…", "ok": true, "dryRun": false, "results": [ { "op": "applyMotionPreset", "ok": true }, … ], "blocked": [], "lint": [ { "severity": "warn", "time": 1.2, "message": "Text \"Hello, render\" runs off the right at ~1.2s. Reduce fontSize/scale or move it inward." } ], "suggestedPreviewTimes": [1.2, 0.2], "timeline": { "clipCount": 3, "durationSec": 3.5 }, "hint": "1 layout warning(s) — see lint[] …" } ``` Notes that will save you an afternoon: - **Max 200 commands per request.** Split larger programs; they apply in order. - **`blocked` is not an error.** Plan-gated ops (Pro effects/filters/ transitions, `runScript`) are *dropped* from the batch and named in `blocked`; everything else applies. A Creator-plan caller with one Studio-only effect in a fifty-command program gets forty-nine applied. - **`lint` comes back on every call and you should read it.** Overlapping text, off-canvas elements, font sizes unreadable at output resolution. `severity` is `"warn"` or `"note"`, capped at 10 entries. Numeric x/y/fontSize choices routinely look wrong on the real canvas, and this is the only cheap way to find out. - **`dryRun: true`** runs the whole reducer and returns results + lint without persisting. ### 3.5 Render and poll ```bash RENDER=$(curl -s -X POST $VM/renders \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d "{\"projectId\":\"$PROJECT\",\"format\":\"mp4\",\"quality\":\"standard\"}" \ | jq -r '.render.id') ``` `202`, and note what is *not* in that request body: the document. You name a project you own and the server reads it. The internal `/api/render` takes the whole timeline inline; v1 deliberately does not, because that would weld the clip format into a public contract. ```jsonc { "render": { "id": "clx…", "projectId": "clx…", "status": "PENDING", "progress": 0, "format": "mp4", "resolution": "full", "quality": "standard", "url": null, "error": null, "createdAt": "…", "updatedAt": "…" }, "applied": { "resolution": "full", "watermark": false, "priority": true }, "poll": "/api/v1/renders/clx…" } ``` `applied` tells you the two things that would otherwise be silent surprises: a plan-capped resolution (`maxHeight` + `cappedByPlan: true`) and a baked-in watermark (free plans lack `no_watermark`). ```bash while :; do sleep 4 S=$(curl -s $VM/renders/$RENDER -H "Authorization: Bearer $KEY") echo "$S" | jq -r '.render | "\(.status) \(.progress)%"' case $(echo "$S" | jq -r '.render.status') in COMPLETED) echo "$S" | jq -r '.render.url'; break ;; FAILED) echo "$S" | jq -r '.render.error'; exit 1 ;; esac done ``` **Status strings are uppercase**: `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`. The failure reason is on `render.error` (not `errorMsg` — that is the database column name, and the serializer renames it). Request-shape validation happens for accepted bodies: `format ∈ {mp4, webm, gif}`, `quality ∈ {draft, standard, high}`, `resolution` is `"full"` or anything else (which means half). A project with no clips is a `400`. --- ## 4. Building an editor UI This is the section that decides whether you have a product or a script. The answer is yes, you can build a real editor UI on this, and here is exactly how. ### 4.0 The architecture constraint, first Your API key is a server credential. It cannot go in a browser, and `api.vidmoat.com`'s CORS configuration is not written for arbitrary third-party origins. So: ``` browser (your UI) ──► your server ──► api.vidmoat.com/v1 │ │ └──────── media bytes, directly ─────────┘ /uploads/* /exports/* ACAO: * ``` The **control plane** goes through your server. The **media plane** does not: `/uploads/`, `/exports/` and `/fixtures/` on `api.vidmoat.com` are all served with `Access-Control-Allow-Origin: *`. That single header is what makes a genuine third-party editor possible, because it means your browser can `fetch()` the raw media, decode it, and read pixels and samples out of it without tainting a canvas or proxying gigabytes through your own box. Give yourself a thin proxy and never think about it again: ```ts // server: app/api/vm/[...path]/route.ts (or the equivalent in your stack) export async function POST(req: Request, { params }: { params: Promise<{ path: string[] }> }) { const { path } = await params; const res = await fetch(`https://api.vidmoat.com/v1/${path.join('/')}`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.VIDMOAT_KEY!}`, 'Content-Type': 'application/json' }, body: await req.text(), }); // Pass the body and status through untouched: the error envelope's `code` is // what your client branches on, and flattening it to "request failed" throws // away the only stable machine-readable part. return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' }, }); } ``` ### 4.1 Read the document, draw a timeline ```ts type Clip = { id: string; type: 'video' | 'audio' | 'text' | 'image' | 'shape' | 'sticker' | 'html' | 'adjustment'; name: string; src: string; start: number; // timeline seconds duration: number; // timeline seconds trackIndex: number; // a TRACK ID, not a layer index mediaStart: number; // in-point in the source media mediaDuration: number; // length of the source media speed: number; reverse: boolean; freezeAt: number | null; loop: boolean; x: number; y: number; scale: number; rotation: number; opacity: number; textStyle?: { content: string; fontSize: number; /* … */ }; isCaption?: boolean; kfX: { time: number; value: number; easing?: string }[]; // … one array per keyframable prop volume: number; muted: boolean; fadeIn: number; fadeOut: number; }; type TrackDef = { id: number; label: string; type: 'video' | 'audio' | 'text'; color: string; bg: string; muted: boolean; locked: boolean; visible: boolean }; type EditorDocument = { version: 2; settings: { name: string; aspectRatio: string; width: number; height: number; fps: number; background: string }; tracks: TrackDef[]; clips: Clip[]; markers?: { id: string; time: number; label?: string; color?: string }[]; }; const { project } = await vm(`/projects/${id}?view=document`); const doc: EditorDocument = project.document; ``` Three views exist and the difference is size, not taste: | `view` | Returns | Use for | |---|---|---| | `summary` | id, name, timestamps, `durationSec`, `clipCount`, `settings` | project lists | | `clips` (default on a single GET) | + `tracks`, + per-clip summaries (id, type, start, duration, trackIndex, src, first 120 chars of text, effect types, `keyframedProps`) | a timeline overview | | `document` | + the whole document verbatim | anything you intend to edit | `view=document` is refused on the *list* endpoint (`GET /v1/projects`) — a page of 20 full documents is megabytes. You get `view=clips` and a `note` saying so. Drawing the timeline is then arithmetic. Pixels-per-second is your zoom; the Vidmoat editor uses 10–300 px/s with a 46 px track height: ```tsx const PX_PER_SEC = 60; function Timeline({ doc }: { doc: EditorDocument }) { const total = Math.max(...doc.clips.map(c => c.start + c.duration), 1); return (
{/* tracks in ARRAY order — index 0 is the bottom layer */} {doc.tracks.map((track, layer) => (
{doc.clips .filter(c => c.trackIndex === track.id) .map(c => (
{c.type === 'text' ? c.textStyle?.content : c.name} {(c.type === 'audio' || c.type === 'video') && ( )}
))}
))}
); } ``` ### 4.2 Waveforms, client-side This is the part people assume needs a server. It does not, and Vidmoat's own editor does not use one either — it decodes the media in the browser with `AudioContext.decodeAudioData` and reduces it to peaks. Because `/uploads/` sends `Access-Control-Allow-Origin: *`, **your** origin can do the identical thing. ```ts const peakCache = new Map(); /** ~300 MB ceiling: decodeAudioData needs the ENTIRE file in memory. Past that, * the tab OOMs — so fail fast and fall back to a flat waveform instead. */ const MAX_DECODE_BYTES = 300 * 1024 * 1024; export async function computePeaks(url: string, count = 400): Promise { const hit = peakCache.get(url); if (hit) return hit; try { const res = await fetch(url); // cross-origin, allowed by ACAO: * if (!res.ok) throw new Error(`fetch ${res.status}`); if (Number(res.headers.get('content-length') || 0) > MAX_DECODE_BYTES) { res.body?.cancel(); throw new Error('too large to decode'); } const buf = await res.arrayBuffer(); const ctx = new AudioContext(); const audio = await ctx.decodeAudioData(buf.slice(0)); // slice(0): decodeAudioData detaches the buffer const chan = audio.getChannelData(0); const block = Math.floor(chan.length / count) || 1; const peaks: number[] = []; let max = 0.0001; for (let i = 0; i < count; i++) { let peak = 0; const start = i * block; for (let j = 0; j < block; j += 8) { // stride: 8x faster, visually identical const v = Math.abs(chan[start + j] || 0); if (v > peak) peak = v; } peaks.push(peak); if (peak > max) max = peak; } const norm = peaks.map(p => p / max); peakCache.set(url, norm); return norm; } catch { // A non-audio file or a decode failure is normal, not exceptional. Draw // something and keep the editor usable. const flat = Array.from({ length: count }, (_, i) => 0.2 + 0.12 * Math.abs(Math.sin(i * 0.4))); peakCache.set(url, flat); return flat; } } ``` A trimmed clip must show *its* region of the file, not the whole file's. The window is a pair of fractions derived from `mediaStart`, `duration` and `speed`: ```ts function mediaWindow(clip: Clip): { from: number; to: number } | undefined { const len = clip.mediaDuration; if (!len || len <= 0) return undefined; // this is why you always pass mediaDuration const from = Math.max(0, Math.min(1, clip.mediaStart / len)); const to = Math.max(from, Math.min(1, (clip.mediaStart + clip.duration * clip.speed) / len)); return { from, to }; } function Waveform({ src, clip, width, color, height = 26 }: { src: string; clip: Clip; width: number; color: string; height?: number; }) { const [peaks, setPeaks] = useState(null); useEffect(() => { let alive = true; if (src) computePeaks(src, 400).then(p => { if (alive) setPeaks(p); }).catch(() => {}); return () => { alive = false; }; }, [src]); if (!peaks) return null; const win = mediaWindow(clip); const view = win ? peaks.slice( Math.floor(win.from * peaks.length), Math.max(Math.floor(win.from * peaks.length) + 1, Math.ceil(win.to * peaks.length)), ) : peaks; const bars = Math.max(12, Math.min(400, Math.floor(width / 2))); const step = view.length / bars; const mid = height / 2; return ( {Array.from({ length: bars }, (_, i) => { const h = Math.max(1.2, (view[Math.floor(i * step)] ?? 0) * (height - 2)); return ; })} ); } ``` The same technique gives you the other analyses Vidmoat runs client-side, off the same decoded buffer: silence detection (RMS per 20 ms window, merge, drop regions under 0.4 s) and energy-flux beat detection. Both are a few dozen lines over the same peaks array — the point here is that **none of it costs you a server round-trip or a credit**, and it is the input to the `cutRanges`, `trimSilence`, `addMarkers` and `sliceClip` commands. ### 4.3 The composition preview `GET /v1/projects/{id}/preview?format=html` returns the whole composition: a self-contained HTML document with every clip as a positioned element, driven by **one paused GSAP timeline** registered at `window.__timelines.main`. This is the exact HTML the render worker drives — not an approximation of it. ``` GET /v1/projects/{id}/preview?format=html → text/html, Cache-Control: private, no-store X-Vidmoat-Media-Count: 7 ``` Two things stop you from putting that URL straight in an `