@mevdragon/vidfarm-devcli 0.20.1 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.director.md +79 -7
- package/SKILL.platform.md +2 -2
- package/dist/src/app.js +306 -18
- package/dist/src/cli.js +247 -23
- package/dist/src/devcli/sync.js +66 -9
- package/dist/src/services/hyperframes.js +2 -2
- package/package.json +1 -1
package/SKILL.director.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vidfarm-director
|
|
3
|
-
description: Use Vidfarm as a director. Browse/add inspiration videos, fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, render to MP4, approve into a shareable post, and schedule it. Includes login, provider keys, discovery, versioning, uploads/downloads, and billing. Every step is available as
|
|
3
|
+
description: Use Vidfarm as a director. Browse/add inspiration videos, fork a template into a composition, edit it in the Trackpad Editor (timeline-based like Premiere/DaVinci), auto-decompose source video into scenes, render to MP4, approve into a shareable post, and schedule it. Includes login, provider keys, discovery, versioning, uploads/downloads, and billing. Every step is available as raw REST; `vidfarm-devcli` wraps those routes and composes the file-backed scripting flows.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Vidfarm Director
|
|
@@ -136,6 +136,19 @@ Content-Type: application/json
|
|
|
136
136
|
|
|
137
137
|
Returns the same fork metadata shape as `POST /api/v1/compositions` (201). devcli: `vidfarm clone <forkId> [--from current|default] [--version N] [--title …]`. In the in-editor AI chat this is the `editor_action` `fork_composition` action (`fork_from: current|default`).
|
|
138
138
|
|
|
139
|
+
## Automate a template via REST
|
|
140
|
+
|
|
141
|
+
Templates now have the same job-backed REST pattern as primitives, so a script can run them repeatably without the editor UI:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
POST /api/v1/templates/:templateId/operations/:operationName
|
|
145
|
+
Content-Type: application/json
|
|
146
|
+
|
|
147
|
+
{ "tracer": "my-run", "payload": { ... }, "webhook_url": "https://..." }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The route returns `202` with a `job_id`, and you can poll the job with `GET /api/v1/user/me/jobs/:jobId` or the template-scoped `GET /api/v1/templates/:templateId/jobs/:jobId`. devcli wraps the same flow as `vidfarm template run <templateId> <operationName> --payload-file payload.json --wait`.
|
|
151
|
+
|
|
139
152
|
## Agentic editing: the three axes (SWAP ↔ REPLACE)
|
|
140
153
|
|
|
141
154
|
Almost every editor session is a director taking a template / fork / project and **re-working** it, and a re-work only ever touches three independent axes — **SCENES** (the video/image clips carrying the visuals), **AUDIO** (narration/voiceover, music, SFX), and **TEXT** (captions, titles, overlays). On each axis the intent sits on a **SWAP ↔ REPLACE** spectrum, and the three axes in one request can sit at different points — decide each before you act:
|
|
@@ -439,7 +452,7 @@ Beyond the Ken Burns / transition / animated-caption presets, the copilot can ha
|
|
|
439
452
|
|
|
440
453
|
## Render (publish to MP4)
|
|
441
454
|
|
|
442
|
-
Rendering publishes the fork's current working state to an MP4 using HyperFrames (cloud Lambda fan-out on the deployed host, or the free in-process renderer on a local `vidfarm serve` box). In the Trackpad Editor this is the **Render** button. The REST route is `/render`; the older `/export` path is kept as a deprecated alias for already-published devcli clients (treat "Render", "publish", `/render`, and `/export` as the same operation). The devcli exposes it as `vidfarm render <forkId> [--wait]`.
|
|
455
|
+
Rendering publishes the fork's current working state to an MP4 using HyperFrames (cloud Lambda fan-out on the deployed host, or the free in-process renderer on a local `vidfarm serve` box). In the Trackpad Editor this is the **Render** button. The REST route is `/render`; the older `/export` path is kept as a deprecated alias for already-published devcli clients (treat "Render", "publish", `/render`, and `/export` as the same operation). The devcli exposes it as `vidfarm render <forkId> [--wait]`; scripted local dirs can use `vidfarm render <forkId> --dir ./work --wait`.
|
|
443
456
|
|
|
444
457
|
```
|
|
445
458
|
POST /api/v1/compositions/:forkId/render
|
|
@@ -448,16 +461,18 @@ Content-Type: application/json
|
|
|
448
461
|
{ "title": "optional", "version": null, "tracer": "optional-trace-id", "html": "optional HTML to save to working state before rendering" }
|
|
449
462
|
```
|
|
450
463
|
|
|
451
|
-
Returns 202 with `{ ok, renderId, status, progress, outputUrl, cost, title, version, ... }` where `version` is the snapshot version created for this publish. Failure modes: `402` if the fork owner is not on a paid plan, `409` while GhostCut subtitle removal is still `pending` (retry after `/remove-video-captions-poll` reports done/failed), `412` if the provider-key preflight fails. Poll:
|
|
464
|
+
Returns 202 with `{ ok, renderId, status, progress, expectedOutputPublicUrl, outputUrl, cost, title, version, ... }` where `expectedOutputPublicUrl` is the deterministic public MP4 URL and `version` is the snapshot version created for this publish. Failure modes: `402` if the fork owner is not on a paid plan, `409` while GhostCut subtitle removal is still `pending` (retry after `/remove-video-captions-poll` reports done/failed), `412` if the provider-key preflight fails. Poll:
|
|
452
465
|
|
|
453
466
|
```
|
|
454
467
|
GET /api/v1/compositions/:forkId/renders/:renderId
|
|
455
468
|
```
|
|
456
469
|
|
|
457
|
-
Response includes `{ ok, renderId, status: "RUNNING" | "SUCCEEDED" | "FAILED", progress, framesRendered, totalFrames, cost, outputUrl, outputS3Uri, errors }` (`progress` is 0..1). On success, `
|
|
470
|
+
Response includes `{ ok, renderId, status: "RUNNING" | "SUCCEEDED" | "FAILED", progress, framesRendered, totalFrames, cost, expectedOutputPublicUrl, outputUrl, outputS3Uri, errors }` (`progress` is 0..1). On success, `expectedOutputPublicUrl` is the durable public MP4 URL and `outputUrl` remains the completion-time field.
|
|
458
471
|
|
|
459
472
|
Every publish creates an immutable version snapshot at `versions/<N>/composition.html` and `versions/<N>/composition.json`.
|
|
460
473
|
|
|
474
|
+
The Web UI **Render** button and devcli render both use this same endpoint. The fast `202` response includes the deterministic `expectedOutputPublicUrl` so a caller can store or pass along the final public S3 URL before the render has completed, then poll by `renderId` until `status` settles.
|
|
475
|
+
|
|
461
476
|
## Approve a finished post
|
|
462
477
|
|
|
463
478
|
A render produces a bare MP4 URL. **Approving** wraps that MP4 (plus caption, title, pinned comment, and any carousel slides) into a shareable preview page — the phone-mockup page a human opens to review and copy the post.
|
|
@@ -600,16 +615,73 @@ For agents that operate Vidfarm headlessly, the typical loop is:
|
|
|
600
615
|
3. `PATCH /api/v1/compositions/:forkId/composition.json` → update metadata
|
|
601
616
|
4. `POST /api/v1/compositions/:forkId/render { tracer }` → render
|
|
602
617
|
5. Poll `GET /api/v1/compositions/:forkId/renders/:renderId` until `SUCCEEDED`
|
|
603
|
-
6. Use `
|
|
618
|
+
6. Use `expectedOutputPublicUrl` in the next stage when you need the stable public URL immediately; otherwise fall back to `outputUrl` after completion
|
|
604
619
|
|
|
605
620
|
Send a stable `tracer` on export so retries are traceable and filterable in job history — but note that submission is **not idempotent**: every POST creates a new job (and a new charge) even with the same tracer. Do not blind-retry expensive submissions; check the render status first.
|
|
606
621
|
|
|
622
|
+
## Scripting mode
|
|
623
|
+
|
|
624
|
+
**Scripting mode** is the recommended posture for repeatable template automation. Use it when a director wants to take a template they like, agree on a base fork, and then drive bulk or one-off edits entirely through REST or `vidfarm api` from a script, Lambda, or local machine.
|
|
625
|
+
|
|
626
|
+
Contract:
|
|
627
|
+
|
|
628
|
+
1. Pick the template once, then fork once and treat the resulting `forkId` as the stable base.
|
|
629
|
+
2. Read the working state with `GET /api/v1/compositions/:forkId/composition.html` and `GET /api/v1/compositions/:forkId/composition.json`.
|
|
630
|
+
3. Modify the composition deterministically in your script.
|
|
631
|
+
4. Write back with `PUT /api/v1/compositions/:forkId/composition.html` and `PATCH /api/v1/compositions/:forkId/composition.json`.
|
|
632
|
+
5. Snapshot with `POST /api/v1/compositions/:forkId/versions` before or after render, depending on whether you want the version to capture the exact render input or the post-render state.
|
|
633
|
+
6. Render with `POST /api/v1/compositions/:forkId/render`, choosing local or cloud based on the host and runtime.
|
|
634
|
+
|
|
635
|
+
Devcli shortcut:
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
vidfarm render "$FORK_ID" --dir ./work --tracer "batch-2026-07-09-row-42" --wait
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
That command validates `./work/composition.html`, shallow-patches `./work/composition.json` if present, then submits the same `POST /api/v1/compositions/:forkId/render` call used by the Web UI. `--dir` may also point directly at `composition.html`.
|
|
642
|
+
|
|
643
|
+
Best practices:
|
|
644
|
+
|
|
645
|
+
- Prefer one canonical base fork per automation run, then branch from that fork if you need variants.
|
|
646
|
+
- Treat `composition.json` as a shallow merged metadata document. The server merges the posted JSON object into the existing object; it is not RFC 6902 JSON Patch.
|
|
647
|
+
- Keep edits idempotent in your script. Re-run the script against the same fork only if it computes the same desired state.
|
|
648
|
+
- Use a stable `tracer` on render jobs so logs and job history stay searchable.
|
|
649
|
+
- Use the same REST sequence in Lambda, CI, or a local shell. The only thing that changes is the host and auth.
|
|
650
|
+
|
|
651
|
+
Canonical shell shape:
|
|
652
|
+
|
|
653
|
+
```bash
|
|
654
|
+
BASE_TEMPLATE_ID="template_..."
|
|
655
|
+
FORK_ID="$(vidfarm api POST /api/v1/compositions --data "{\"template_id\":\"${BASE_TEMPLATE_ID}\"}" --json | jq -r '.fork_id')"
|
|
656
|
+
|
|
657
|
+
vidfarm api GET "/api/v1/compositions/${FORK_ID}/composition.html" --raw > /tmp/composition.html
|
|
658
|
+
vidfarm api GET "/api/v1/compositions/${FORK_ID}/composition.json" --raw > /tmp/composition.json
|
|
659
|
+
|
|
660
|
+
# edit /tmp/composition.html and /tmp/composition.json in your script
|
|
661
|
+
|
|
662
|
+
vidfarm api PUT "/api/v1/compositions/${FORK_ID}/composition.html" --body-file /tmp/composition.html --content-type "text/html; charset=utf-8"
|
|
663
|
+
vidfarm api PATCH "/api/v1/compositions/${FORK_ID}/composition.json" --data-file /tmp/composition.json
|
|
664
|
+
vidfarm api POST "/api/v1/compositions/${FORK_ID}/versions" --data '{"message":"scripting mode snapshot"}'
|
|
665
|
+
vidfarm api POST "/api/v1/compositions/${FORK_ID}/render" --data '{"tracer":"scripting-mode"}'
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
If you need many variants, keep the base fork fixed and fan out by cloning that fork or by reapplying the same edit function to multiple fork ids. Use the raw REST routes directly when you want maximum control; use `vidfarm-devcli` when you want auth, polling, and file helpers without writing the plumbing yourself.
|
|
669
|
+
|
|
607
670
|
## `vidfarm-devcli` — full command surface
|
|
608
671
|
|
|
609
|
-
`@mevdragon/vidfarm-devcli` wraps the **entire director REST flow** as CLI commands. It is a thin shell over the REST API, not a second implementation:
|
|
672
|
+
`@mevdragon/vidfarm-devcli` wraps the **entire director REST flow** as CLI commands. It is a thin shell over the REST API, not a second implementation: most named commands map 1:1 to one REST route, and the file-backed commands compose the documented routes for upload/download + render-polling. Auth via `--api-key <key>` or `VIDFARM_API_KEY`.
|
|
610
673
|
|
|
611
674
|
**Raw REST vs devcli — your choice.** If you want full control, call the REST API directly (or use the raw passthrough `vidfarm api <METHOD> <path> [--data <json>] [--query k=v]`). If you want ergonomics + openable frontend URLs, use the named commands. They are interchangeable — pick per call.
|
|
612
675
|
|
|
676
|
+
**Scripting mode recommendation.** For automation, prefer:
|
|
677
|
+
|
|
678
|
+
- `vidfarm api` for the actual REST calls
|
|
679
|
+
- `--data-file` for file-backed payloads
|
|
680
|
+
- a single pinned base fork id per template family
|
|
681
|
+
- `POST /api/v1/compositions/:forkId/versions` as the versioning boundary
|
|
682
|
+
- `POST /api/v1/compositions/:forkId/render` with a stable `tracer`, or `vidfarm render <forkId> --dir ./work --wait` for the same composed flow
|
|
683
|
+
- `render_target: "cloud"` when you want cloud handoff from a local `vidfarm serve` box, or the default local renderer when you want free in-process output
|
|
684
|
+
|
|
613
685
|
| Command | REST route | Flow step |
|
|
614
686
|
|---|---|---|
|
|
615
687
|
| `vidfarm discover [query]` | `GET /discover/feed[?q=]` | browse/search templates |
|
|
@@ -636,7 +708,7 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
|
|
|
636
708
|
| `vidfarm remove-video-captions <forkId>` (alias: `ghostcut`) | `GET .../compositions/:forkId/remove-video-captions` | subtitle-removal status |
|
|
637
709
|
| `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
|
|
638
710
|
| `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
|
|
639
|
-
| `vidfarm render <forkId> [--wait]` | `POST .../compositions/:forkId/render` | render
|
|
711
|
+
| `vidfarm render <forkId> [--dir <dir\|composition.html>] [--wait] [--target local\|cloud]` | `PUT/PATCH working files, then POST .../compositions/:forkId/render` | script-friendly render to MP4; no `--dir` uses the fork's current working state |
|
|
640
712
|
| `vidfarm render-status <forkId> <renderId>` | `GET .../compositions/:forkId/renders/:renderId` | poll a render |
|
|
641
713
|
| `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
|
|
642
714
|
| `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
|
package/SKILL.platform.md
CHANGED
|
@@ -205,8 +205,8 @@ Publish flow:
|
|
|
205
205
|
3. API resolves the caller's provider keys (preflight check for required models)
|
|
206
206
|
4. API creates a `job` record and invokes the `hyperframes_render` primitive Lambda
|
|
207
207
|
5. The renderer runs HyperFrames: on the deployed host it stages the composition and dispatches to the shared HyperFrames render state machine (chunked Lambda fan-out); on a local `vidfarm serve` box with no cloud stack it renders in-process (headless Chromium → ffmpeg) for free. See `HyperframesService` in `src/services/hyperframes.ts`.
|
|
208
|
-
6. On success, the primitive returns `{ primary_file_url: "s3://.../output.mp4" }`, the API creates a `ForkVersionRecord` with `reason: "publish"`, writes the snapshot to `versions/<N>/`,
|
|
209
|
-
7. Client polls `GET /api/v1/compositions/:forkId/renders/:renderId` for `{ status: "RUNNING" | "SUCCEEDED" | "FAILED", progress, outputUrl, cost }` (see `adaptJobToPublishStatus` in `src/app.ts`)
|
|
208
|
+
6. On success, the primitive returns `{ primary_file_url: "s3://.../output.mp4" }`, the API creates a `ForkVersionRecord` with `reason: "publish"`, writes the snapshot to `versions/<N>/`, updates the fork's `latestVersion` and `currentRenderJobId`, and now also exposes the deterministic public artifact URL immediately as `expected_output_public_url`
|
|
209
|
+
7. Client polls `GET /api/v1/compositions/:forkId/renders/:renderId` for `{ status: "RUNNING" | "SUCCEEDED" | "FAILED", progress, expectedOutputPublicUrl, outputUrl, cost }` (see `adaptJobToPublishStatus` in `src/app.ts`); `expectedOutputPublicUrl` is the stable public S3 URL, while `outputUrl` remains the completion-time field
|
|
210
210
|
|
|
211
211
|
The HyperFrames render state machine is **shared across all templates and forks** — it's an infrastructure primitive, not a per-template deployment. Its ARN/bucket are passed to the Vidfarm stack as env vars (`HYPERFRAMES_RENDER_*`). Remotion has been fully removed.
|
|
212
212
|
|
package/dist/src/app.js
CHANGED
|
@@ -24,6 +24,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
|
|
|
24
24
|
import { renderHelpPage } from "./help-page.js";
|
|
25
25
|
import { renderHomepage } from "./homepage.js";
|
|
26
26
|
import { renderLandingPage } from "./landing-page.js";
|
|
27
|
+
import { createTemplateJobContext } from "./context.js";
|
|
27
28
|
import { renderReskinIndex } from "./reskin/index-page.js";
|
|
28
29
|
import { renderReskinSettings } from "./reskin/settings-page.js";
|
|
29
30
|
import { renderReskinLibrary } from "./reskin/library-page.js";
|
|
@@ -7805,7 +7806,7 @@ async function publishCompositionToRenderer(input) {
|
|
|
7805
7806
|
fps: 30,
|
|
7806
7807
|
chunk_size: 240,
|
|
7807
7808
|
max_parallel_chunks: 8,
|
|
7808
|
-
output_key:
|
|
7809
|
+
output_key: "output.mp4",
|
|
7809
7810
|
project_files: projectFiles,
|
|
7810
7811
|
composition_data: compositionData ?? undefined
|
|
7811
7812
|
});
|
|
@@ -7894,6 +7895,48 @@ async function runPrimitiveJobInProcess(jobId) {
|
|
|
7894
7895
|
throw error;
|
|
7895
7896
|
}
|
|
7896
7897
|
}
|
|
7898
|
+
async function runTemplateJobInProcess(jobId) {
|
|
7899
|
+
const job = await jobs.getJobAsync(jobId);
|
|
7900
|
+
if (!job)
|
|
7901
|
+
throw new Error(`Job not found: ${jobId}`);
|
|
7902
|
+
const failContract = async (message) => {
|
|
7903
|
+
await jobs.failJob(job.id, { message, type: "local_runner_contract_error" });
|
|
7904
|
+
throw new Error(message);
|
|
7905
|
+
};
|
|
7906
|
+
const customer = await serverlessRecords.getCustomerById(job.customerId);
|
|
7907
|
+
if (!customer)
|
|
7908
|
+
return failContract(`Customer not found: ${job.customerId}`);
|
|
7909
|
+
const template = await templateRegistry.getExecutable(job.templateId);
|
|
7910
|
+
if (!template)
|
|
7911
|
+
return failContract(`Template not found: ${job.templateId}`);
|
|
7912
|
+
const workflow = template.jobs[job.workflowName];
|
|
7913
|
+
if (!workflow)
|
|
7914
|
+
return failContract(`Template workflow not found: ${job.workflowName}`);
|
|
7915
|
+
const workerId = createId("local_worker");
|
|
7916
|
+
try {
|
|
7917
|
+
await jobs.markRunning(job.id);
|
|
7918
|
+
const ctx = createTemplateJobContext({
|
|
7919
|
+
customer,
|
|
7920
|
+
job,
|
|
7921
|
+
template,
|
|
7922
|
+
workerId,
|
|
7923
|
+
storage,
|
|
7924
|
+
jobs,
|
|
7925
|
+
billing,
|
|
7926
|
+
providers,
|
|
7927
|
+
hyperframes
|
|
7928
|
+
});
|
|
7929
|
+
const result = await workflow(ctx, job.payload);
|
|
7930
|
+
await jobs.succeedJob(job.id, result.output ?? {}, result.progress ?? 1);
|
|
7931
|
+
}
|
|
7932
|
+
catch (error) {
|
|
7933
|
+
await jobs.failJob(job.id, {
|
|
7934
|
+
message: error instanceof Error ? error.message : "Local template job failed",
|
|
7935
|
+
type: "local_render_failed"
|
|
7936
|
+
});
|
|
7937
|
+
throw error;
|
|
7938
|
+
}
|
|
7939
|
+
}
|
|
7897
7940
|
function mapJobStatusToPublishPhase(status) {
|
|
7898
7941
|
const normalized = String(status ?? "").toLowerCase();
|
|
7899
7942
|
if (normalized === "completed" || normalized === "succeeded")
|
|
@@ -7977,6 +8020,7 @@ function adaptJobToPublishStatus(job, fallbackTitle) {
|
|
|
7977
8020
|
cost,
|
|
7978
8021
|
outputS3Uri: readNonEmptyString(asRecord(result.output)?.s3_uri) ?? readNonEmptyString(renderBlock.output_s3_uri),
|
|
7979
8022
|
outputUrl,
|
|
8023
|
+
expectedOutputPublicUrl: readNonEmptyString(record.expected_output_public_url) ?? expectedHyperframesRenderOutputPublicUrl(job),
|
|
7980
8024
|
title: readNonEmptyString(asRecord(record.payload)?.title) ?? fallbackTitle,
|
|
7981
8025
|
fatalErrorEncountered: Boolean(publishErrorMessage),
|
|
7982
8026
|
errors: publishErrorMessage ? [{ error: publishErrorMessage }] : []
|
|
@@ -16449,8 +16493,10 @@ async function serializeJob(job) {
|
|
|
16449
16493
|
customerId: job.customerId,
|
|
16450
16494
|
templateId: job.templateId
|
|
16451
16495
|
});
|
|
16496
|
+
const expectedOutputPublicUrl = expectedHyperframesRenderOutputPublicUrl(job);
|
|
16452
16497
|
return {
|
|
16453
16498
|
job_id: job.id,
|
|
16499
|
+
customer_id: job.customerId,
|
|
16454
16500
|
template_id: job.templateId,
|
|
16455
16501
|
operation_name: job.operationName,
|
|
16456
16502
|
workflow_name: job.workflowName,
|
|
@@ -16466,6 +16512,7 @@ async function serializeJob(job) {
|
|
|
16466
16512
|
event_count: jobBilling.eventCount,
|
|
16467
16513
|
consumed_usd: jobBilling.totalChargeUsd
|
|
16468
16514
|
},
|
|
16515
|
+
expected_output_public_url: expectedOutputPublicUrl,
|
|
16469
16516
|
parent_job_id: job.parentJobId,
|
|
16470
16517
|
created_at: job.createdAt,
|
|
16471
16518
|
updated_at: job.updatedAt,
|
|
@@ -16485,6 +16532,19 @@ function serializeArtifact(artifact) {
|
|
|
16485
16532
|
created_at: artifact.createdAt
|
|
16486
16533
|
};
|
|
16487
16534
|
}
|
|
16535
|
+
function expectedHyperframesRenderOutputPublicUrl(job) {
|
|
16536
|
+
const record = asRecord(job);
|
|
16537
|
+
if (!record)
|
|
16538
|
+
return null;
|
|
16539
|
+
const templateId = readNonEmptyString(record.templateId) ?? readNonEmptyString(record.template_id);
|
|
16540
|
+
const customerId = readNonEmptyString(record.customerId) ?? readNonEmptyString(record.customer_id);
|
|
16541
|
+
const jobId = readNonEmptyString(record.id) ?? readNonEmptyString(record.job_id);
|
|
16542
|
+
if (templateId !== "primitive:hyperframes_render" || !customerId || !jobId)
|
|
16543
|
+
return null;
|
|
16544
|
+
const outputKey = readNonEmptyString(asRecord(record.payload)?.output_key) ?? "output.mp4";
|
|
16545
|
+
const storageKey = storage.primitiveJobKey(templateId, customerId, jobId, outputKey);
|
|
16546
|
+
return storage.getPublicUrl(storageKey);
|
|
16547
|
+
}
|
|
16488
16548
|
async function buildJobDebugBundle(input) {
|
|
16489
16549
|
const job = input.job;
|
|
16490
16550
|
const startTime = input.startTime ?? offsetIso(job.createdAt, -60_000);
|
|
@@ -17020,7 +17080,7 @@ async function deriveProviderHint(input) {
|
|
|
17020
17080
|
];
|
|
17021
17081
|
return useHintIfAvailable(configCandidates.find(isProviderType));
|
|
17022
17082
|
}
|
|
17023
|
-
async function
|
|
17083
|
+
async function listJobsForCustomerByTemplateId(input) {
|
|
17024
17084
|
const target = clampPageLimit(input.limit, 100) + 1;
|
|
17025
17085
|
const collected = [];
|
|
17026
17086
|
let cursor = input.cursor ?? null;
|
|
@@ -17037,7 +17097,8 @@ async function listPrimitiveJobsForCustomer(input) {
|
|
|
17037
17097
|
break;
|
|
17038
17098
|
}
|
|
17039
17099
|
for (const job of batch) {
|
|
17040
|
-
if (
|
|
17100
|
+
if ((!input.templateId || job.templateId === input.templateId) &&
|
|
17101
|
+
(!input.templateIdPrefix || job.templateId.startsWith(input.templateIdPrefix))) {
|
|
17041
17102
|
collected.push(job);
|
|
17042
17103
|
if (collected.length >= target) {
|
|
17043
17104
|
break;
|
|
@@ -17058,6 +17119,16 @@ async function listPrimitiveJobsForCustomer(input) {
|
|
|
17058
17119
|
}
|
|
17059
17120
|
return collected;
|
|
17060
17121
|
}
|
|
17122
|
+
async function getJobForCustomerByTemplateId(input) {
|
|
17123
|
+
const job = await jobs.getJobAsync(input.jobId);
|
|
17124
|
+
if (!job || job.customerId !== input.customerId) {
|
|
17125
|
+
return null;
|
|
17126
|
+
}
|
|
17127
|
+
if (input.templateId && job.templateId !== input.templateId) {
|
|
17128
|
+
return null;
|
|
17129
|
+
}
|
|
17130
|
+
return job;
|
|
17131
|
+
}
|
|
17061
17132
|
async function createPrimitiveJob(c, input) {
|
|
17062
17133
|
const customer = requireCustomer(c);
|
|
17063
17134
|
const primitive = primitiveRegistry.get(input.primitiveId);
|
|
@@ -17145,6 +17216,90 @@ async function createPrimitiveJob(c, input) {
|
|
|
17145
17216
|
operation_name: input.operationName
|
|
17146
17217
|
}, 202);
|
|
17147
17218
|
}
|
|
17219
|
+
async function createTemplateJob(c, input) {
|
|
17220
|
+
const customer = requireCustomer(c);
|
|
17221
|
+
const template = await templateRegistry.getExecutable(input.templateId);
|
|
17222
|
+
if (!template) {
|
|
17223
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17224
|
+
}
|
|
17225
|
+
const operation = template.operations[input.operationName];
|
|
17226
|
+
if (!operation) {
|
|
17227
|
+
return c.json({ error: "Template operation not found" }, 404);
|
|
17228
|
+
}
|
|
17229
|
+
let body;
|
|
17230
|
+
let payload;
|
|
17231
|
+
try {
|
|
17232
|
+
body = primitiveEnvelopeSchema.parse(await c.req.json());
|
|
17233
|
+
payload = operation.inputSchema.parse(body.payload);
|
|
17234
|
+
}
|
|
17235
|
+
catch (error) {
|
|
17236
|
+
if (error instanceof z.ZodError) {
|
|
17237
|
+
return c.json({
|
|
17238
|
+
error: "Invalid template request.",
|
|
17239
|
+
issues: error.issues
|
|
17240
|
+
}, 400);
|
|
17241
|
+
}
|
|
17242
|
+
if (isJsonBodyParseError(error)) {
|
|
17243
|
+
return c.json({
|
|
17244
|
+
error: "Invalid template request.",
|
|
17245
|
+
detail: "Request body must be valid JSON with top-level tracer and payload fields."
|
|
17246
|
+
}, 400);
|
|
17247
|
+
}
|
|
17248
|
+
throw error;
|
|
17249
|
+
}
|
|
17250
|
+
const providerPreflight = await preflightAiProviderKeys({
|
|
17251
|
+
customerId: customer.id,
|
|
17252
|
+
templateId: template.id,
|
|
17253
|
+
operationName: input.operationName,
|
|
17254
|
+
operationHint: operation.providerHint,
|
|
17255
|
+
payload
|
|
17256
|
+
});
|
|
17257
|
+
if (providerPreflight) {
|
|
17258
|
+
return c.json(providerPreflight, 412);
|
|
17259
|
+
}
|
|
17260
|
+
let job;
|
|
17261
|
+
try {
|
|
17262
|
+
await assertWalletCanQueueJobs(customer.id);
|
|
17263
|
+
job = await jobs.createRootJob({
|
|
17264
|
+
templateId: template.id,
|
|
17265
|
+
operationName: input.operationName,
|
|
17266
|
+
workflowName: operation.workflow,
|
|
17267
|
+
tracer: body.tracer,
|
|
17268
|
+
payload,
|
|
17269
|
+
webhookUrl: body.webhook_url,
|
|
17270
|
+
customer,
|
|
17271
|
+
providerHint: await deriveProviderHint({
|
|
17272
|
+
customerId: customer.id,
|
|
17273
|
+
templateId: template.id,
|
|
17274
|
+
operationName: input.operationName,
|
|
17275
|
+
operationHint: operation.providerHint,
|
|
17276
|
+
payload
|
|
17277
|
+
})
|
|
17278
|
+
});
|
|
17279
|
+
}
|
|
17280
|
+
catch (error) {
|
|
17281
|
+
if (error instanceof InsufficientWalletFundsError) {
|
|
17282
|
+
return insufficientFundsResponse(c, error);
|
|
17283
|
+
}
|
|
17284
|
+
if (error instanceof ActiveJobLimitExceededError) {
|
|
17285
|
+
return activeJobLimitExceededResponse(c, error);
|
|
17286
|
+
}
|
|
17287
|
+
throw error;
|
|
17288
|
+
}
|
|
17289
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN) {
|
|
17290
|
+
void runTemplateJobInProcess(job.id).catch((error) => {
|
|
17291
|
+
console.error("local template job failed", job.id, error instanceof Error ? error.message : error);
|
|
17292
|
+
});
|
|
17293
|
+
}
|
|
17294
|
+
return c.json({
|
|
17295
|
+
job_id: job.id,
|
|
17296
|
+
tracer: job.tracer,
|
|
17297
|
+
status: job.status,
|
|
17298
|
+
template_id: template.id,
|
|
17299
|
+
template_type: "template",
|
|
17300
|
+
operation_name: input.operationName
|
|
17301
|
+
}, 202);
|
|
17302
|
+
}
|
|
17148
17303
|
function resolvePrimitiveRouteTarget(primitiveParam) {
|
|
17149
17304
|
const normalized = primitiveParam.trim();
|
|
17150
17305
|
if (!normalized) {
|
|
@@ -17185,6 +17340,12 @@ app.post(`${PRIMITIVES_PREFIX}/:primitiveKind/operations/:operationName`, async
|
|
|
17185
17340
|
operationName: c.req.param("operationName")
|
|
17186
17341
|
});
|
|
17187
17342
|
});
|
|
17343
|
+
app.post(`${TEMPLATES_PREFIX}/:templateId/operations/:operationName`, async (c) => {
|
|
17344
|
+
return createTemplateJob(c, {
|
|
17345
|
+
templateId: c.req.param("templateId"),
|
|
17346
|
+
operationName: c.req.param("operationName")
|
|
17347
|
+
});
|
|
17348
|
+
});
|
|
17188
17349
|
app.get(`${PRIMITIVES_PREFIX}/jobs`, async (c) => {
|
|
17189
17350
|
const customer = requireCustomer(c);
|
|
17190
17351
|
const query = listJobsQuerySchema.parse({
|
|
@@ -17195,8 +17356,9 @@ app.get(`${PRIMITIVES_PREFIX}/jobs`, async (c) => {
|
|
|
17195
17356
|
limit: c.req.query("limit")
|
|
17196
17357
|
});
|
|
17197
17358
|
const limit = clampPageLimit(query.limit, 100);
|
|
17198
|
-
const listedJobs = await
|
|
17359
|
+
const listedJobs = await listJobsForCustomerByTemplateId({
|
|
17199
17360
|
customerId: customer.id,
|
|
17361
|
+
templateIdPrefix: "primitive:",
|
|
17200
17362
|
tracer: query.tracer,
|
|
17201
17363
|
startTime: query.start_time,
|
|
17202
17364
|
endTime: query.end_time,
|
|
@@ -17206,9 +17368,40 @@ app.get(`${PRIMITIVES_PREFIX}/jobs`, async (c) => {
|
|
|
17206
17368
|
const page = paginateDescendingByCreatedAt(listedJobs, limit);
|
|
17207
17369
|
return c.json({ primitive_jobs: await Promise.all(page.items.map(serializePrimitiveJob)), next_cursor: page.nextCursor });
|
|
17208
17370
|
});
|
|
17371
|
+
app.get(`${TEMPLATES_PREFIX}/:templateId/jobs`, async (c) => {
|
|
17372
|
+
const customer = requireCustomer(c);
|
|
17373
|
+
const template = await templateRegistry.getExecutable(c.req.param("templateId"));
|
|
17374
|
+
if (!template) {
|
|
17375
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17376
|
+
}
|
|
17377
|
+
const query = listJobsQuerySchema.parse({
|
|
17378
|
+
tracer: c.req.query("tracer"),
|
|
17379
|
+
start_time: c.req.query("start_time"),
|
|
17380
|
+
end_time: c.req.query("end_time"),
|
|
17381
|
+
cursor: c.req.query("cursor"),
|
|
17382
|
+
limit: c.req.query("limit")
|
|
17383
|
+
});
|
|
17384
|
+
const limit = clampPageLimit(query.limit, 100);
|
|
17385
|
+
const listedJobs = await listJobsForCustomerByTemplateId({
|
|
17386
|
+
customerId: customer.id,
|
|
17387
|
+
templateId: template.id,
|
|
17388
|
+
tracer: query.tracer,
|
|
17389
|
+
startTime: query.start_time,
|
|
17390
|
+
endTime: query.end_time,
|
|
17391
|
+
limit,
|
|
17392
|
+
cursor: parseCreatedAtIdCursor(query.cursor)
|
|
17393
|
+
});
|
|
17394
|
+
const page = paginateDescendingByCreatedAt(listedJobs, limit);
|
|
17395
|
+
return c.json({
|
|
17396
|
+
template_id: template.id,
|
|
17397
|
+
template_type: "template",
|
|
17398
|
+
jobs: await Promise.all(page.items.map(serializeJob)),
|
|
17399
|
+
next_cursor: page.nextCursor
|
|
17400
|
+
});
|
|
17401
|
+
});
|
|
17209
17402
|
app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId`, async (c) => {
|
|
17210
17403
|
const customer = requireCustomer(c);
|
|
17211
|
-
const job = await
|
|
17404
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17212
17405
|
jobId: c.req.param("jobId"),
|
|
17213
17406
|
customerId: customer.id
|
|
17214
17407
|
});
|
|
@@ -17217,9 +17410,25 @@ app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId`, async (c) => {
|
|
|
17217
17410
|
}
|
|
17218
17411
|
return c.json(await serializePrimitiveJob(job));
|
|
17219
17412
|
});
|
|
17413
|
+
app.get(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId`, async (c) => {
|
|
17414
|
+
const customer = requireCustomer(c);
|
|
17415
|
+
const template = await templateRegistry.getExecutable(c.req.param("templateId"));
|
|
17416
|
+
if (!template) {
|
|
17417
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17418
|
+
}
|
|
17419
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17420
|
+
jobId: c.req.param("jobId"),
|
|
17421
|
+
customerId: customer.id,
|
|
17422
|
+
templateId: template.id
|
|
17423
|
+
});
|
|
17424
|
+
if (!job) {
|
|
17425
|
+
return c.json({ error: "Template job not found" }, 404);
|
|
17426
|
+
}
|
|
17427
|
+
return c.json(await serializeJob(job));
|
|
17428
|
+
});
|
|
17220
17429
|
app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId/artifacts`, async (c) => {
|
|
17221
17430
|
const customer = requireCustomer(c);
|
|
17222
|
-
const job = await
|
|
17431
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17223
17432
|
jobId: c.req.param("jobId"),
|
|
17224
17433
|
customerId: customer.id
|
|
17225
17434
|
});
|
|
@@ -17242,9 +17451,40 @@ app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId/artifacts`, async (c) => {
|
|
|
17242
17451
|
next_cursor: page.nextCursor
|
|
17243
17452
|
});
|
|
17244
17453
|
});
|
|
17454
|
+
app.get(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId/artifacts`, async (c) => {
|
|
17455
|
+
const customer = requireCustomer(c);
|
|
17456
|
+
const template = await templateRegistry.getExecutable(c.req.param("templateId"));
|
|
17457
|
+
if (!template) {
|
|
17458
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17459
|
+
}
|
|
17460
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17461
|
+
jobId: c.req.param("jobId"),
|
|
17462
|
+
customerId: customer.id,
|
|
17463
|
+
templateId: template.id
|
|
17464
|
+
});
|
|
17465
|
+
if (!job) {
|
|
17466
|
+
return c.json({ error: "Template job not found" }, 404);
|
|
17467
|
+
}
|
|
17468
|
+
const limit = clampPageLimit(Number(c.req.query("limit") || "100"), 100);
|
|
17469
|
+
const page = await listArtifactsForJobPage({
|
|
17470
|
+
jobId: job.id,
|
|
17471
|
+
customerId: customer.id,
|
|
17472
|
+
templateId: job.templateId,
|
|
17473
|
+
limit,
|
|
17474
|
+
cursor: parseCreatedAtIdCursor(c.req.query("cursor"))
|
|
17475
|
+
});
|
|
17476
|
+
return c.json({
|
|
17477
|
+
job_id: job.id,
|
|
17478
|
+
tracer: job.tracer,
|
|
17479
|
+
template_id: job.templateId,
|
|
17480
|
+
template_type: "template",
|
|
17481
|
+
artifacts: page.items.map(serializeArtifact),
|
|
17482
|
+
next_cursor: page.nextCursor
|
|
17483
|
+
});
|
|
17484
|
+
});
|
|
17245
17485
|
app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId/logs`, async (c) => {
|
|
17246
17486
|
const customer = requireCustomer(c);
|
|
17247
|
-
const job = await
|
|
17487
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17248
17488
|
jobId: c.req.param("jobId"),
|
|
17249
17489
|
customerId: customer.id
|
|
17250
17490
|
});
|
|
@@ -17268,9 +17508,40 @@ app.get(`${PRIMITIVES_PREFIX}/jobs/:jobId/logs`, async (c) => {
|
|
|
17268
17508
|
const page = paginateDescendingByCreatedAt(logs, limit);
|
|
17269
17509
|
return c.json({ job_id: job.id, tracer: job.tracer, logs: page.items, next_cursor: page.nextCursor });
|
|
17270
17510
|
});
|
|
17511
|
+
app.get(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId/logs`, async (c) => {
|
|
17512
|
+
const customer = requireCustomer(c);
|
|
17513
|
+
const template = await templateRegistry.getExecutable(c.req.param("templateId"));
|
|
17514
|
+
if (!template) {
|
|
17515
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17516
|
+
}
|
|
17517
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17518
|
+
jobId: c.req.param("jobId"),
|
|
17519
|
+
customerId: customer.id,
|
|
17520
|
+
templateId: template.id
|
|
17521
|
+
});
|
|
17522
|
+
if (!job) {
|
|
17523
|
+
return c.json({ error: "Template job not found" }, 404);
|
|
17524
|
+
}
|
|
17525
|
+
const query = listJobsQuerySchema.parse({
|
|
17526
|
+
start_time: c.req.query("start_time"),
|
|
17527
|
+
end_time: c.req.query("end_time"),
|
|
17528
|
+
cursor: c.req.query("cursor"),
|
|
17529
|
+
limit: c.req.query("limit")
|
|
17530
|
+
});
|
|
17531
|
+
const limit = clampPageLimit(query.limit, 100);
|
|
17532
|
+
const logs = await jobs.listLogs({
|
|
17533
|
+
jobId: job.id,
|
|
17534
|
+
startTime: query.start_time,
|
|
17535
|
+
endTime: query.end_time,
|
|
17536
|
+
limit: limit + 1,
|
|
17537
|
+
cursor: parseCreatedAtIdCursor(query.cursor)
|
|
17538
|
+
});
|
|
17539
|
+
const page = paginateDescendingByCreatedAt(logs, limit);
|
|
17540
|
+
return c.json({ job_id: job.id, tracer: job.tracer, logs: page.items, next_cursor: page.nextCursor });
|
|
17541
|
+
});
|
|
17271
17542
|
app.post(`${PRIMITIVES_PREFIX}/jobs/:jobId/cancel`, async (c) => {
|
|
17272
17543
|
const customer = requireCustomer(c);
|
|
17273
|
-
const job = await
|
|
17544
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17274
17545
|
jobId: c.req.param("jobId"),
|
|
17275
17546
|
customerId: customer.id
|
|
17276
17547
|
});
|
|
@@ -17280,6 +17551,23 @@ app.post(`${PRIMITIVES_PREFIX}/jobs/:jobId/cancel`, async (c) => {
|
|
|
17280
17551
|
await jobs.cancelJobAsync(job.id);
|
|
17281
17552
|
return c.json({ ok: true, job_id: job.id, status: "cancelled" });
|
|
17282
17553
|
});
|
|
17554
|
+
app.post(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId/cancel`, async (c) => {
|
|
17555
|
+
const customer = requireCustomer(c);
|
|
17556
|
+
const template = await templateRegistry.getExecutable(c.req.param("templateId"));
|
|
17557
|
+
if (!template) {
|
|
17558
|
+
return c.json({ error: "Template not found" }, 404);
|
|
17559
|
+
}
|
|
17560
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17561
|
+
jobId: c.req.param("jobId"),
|
|
17562
|
+
customerId: customer.id,
|
|
17563
|
+
templateId: template.id
|
|
17564
|
+
});
|
|
17565
|
+
if (!job) {
|
|
17566
|
+
return c.json({ error: "Template job not found" }, 404);
|
|
17567
|
+
}
|
|
17568
|
+
await jobs.cancelJobAsync(job.id);
|
|
17569
|
+
return c.json({ ok: true, job_id: job.id, status: "cancelled" });
|
|
17570
|
+
});
|
|
17283
17571
|
app.get(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs`, async (c) => {
|
|
17284
17572
|
const customer = requireCustomer(c);
|
|
17285
17573
|
const primitive = resolvePrimitiveRouteTarget(c.req.param("primitiveKind"));
|
|
@@ -17294,9 +17582,9 @@ app.get(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs`, async (c) => {
|
|
|
17294
17582
|
limit: c.req.query("limit")
|
|
17295
17583
|
});
|
|
17296
17584
|
const limit = clampPageLimit(query.limit, 100);
|
|
17297
|
-
const listedJobs = await
|
|
17585
|
+
const listedJobs = await listJobsForCustomerByTemplateId({
|
|
17298
17586
|
customerId: customer.id,
|
|
17299
|
-
|
|
17587
|
+
templateId: primitive.id,
|
|
17300
17588
|
tracer: query.tracer,
|
|
17301
17589
|
startTime: query.start_time,
|
|
17302
17590
|
endTime: query.end_time,
|
|
@@ -17317,10 +17605,10 @@ app.get(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs/:jobId`, async (c) => {
|
|
|
17317
17605
|
if (!primitive) {
|
|
17318
17606
|
return c.json({ error: "Primitive not found" }, 404);
|
|
17319
17607
|
}
|
|
17320
|
-
const job = await
|
|
17608
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17321
17609
|
jobId: c.req.param("jobId"),
|
|
17322
17610
|
customerId: customer.id,
|
|
17323
|
-
|
|
17611
|
+
templateId: primitive.id
|
|
17324
17612
|
});
|
|
17325
17613
|
if (!job) {
|
|
17326
17614
|
return c.json({ error: "Primitive job not found" }, 404);
|
|
@@ -17333,10 +17621,10 @@ app.get(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs/:jobId/artifacts`, async (c) =
|
|
|
17333
17621
|
if (!primitive) {
|
|
17334
17622
|
return c.json({ error: "Primitive not found" }, 404);
|
|
17335
17623
|
}
|
|
17336
|
-
const job = await
|
|
17624
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17337
17625
|
jobId: c.req.param("jobId"),
|
|
17338
17626
|
customerId: customer.id,
|
|
17339
|
-
|
|
17627
|
+
templateId: primitive.id
|
|
17340
17628
|
});
|
|
17341
17629
|
if (!job) {
|
|
17342
17630
|
return c.json({ error: "Primitive job not found" }, 404);
|
|
@@ -17364,10 +17652,10 @@ app.get(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs/:jobId/logs`, async (c) => {
|
|
|
17364
17652
|
if (!primitive) {
|
|
17365
17653
|
return c.json({ error: "Primitive not found" }, 404);
|
|
17366
17654
|
}
|
|
17367
|
-
const job = await
|
|
17655
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17368
17656
|
jobId: c.req.param("jobId"),
|
|
17369
17657
|
customerId: customer.id,
|
|
17370
|
-
|
|
17658
|
+
templateId: primitive.id
|
|
17371
17659
|
});
|
|
17372
17660
|
if (!job) {
|
|
17373
17661
|
return c.json({ error: "Primitive job not found" }, 404);
|
|
@@ -17402,10 +17690,10 @@ app.post(`${PRIMITIVES_PREFIX}/:primitiveKind/jobs/:jobId/cancel`, async (c) =>
|
|
|
17402
17690
|
if (!primitive) {
|
|
17403
17691
|
return c.json({ error: "Primitive not found" }, 404);
|
|
17404
17692
|
}
|
|
17405
|
-
const job = await
|
|
17693
|
+
const job = await getJobForCustomerByTemplateId({
|
|
17406
17694
|
jobId: c.req.param("jobId"),
|
|
17407
17695
|
customerId: customer.id,
|
|
17408
|
-
|
|
17696
|
+
templateId: primitive.id
|
|
17409
17697
|
});
|
|
17410
17698
|
if (!job) {
|
|
17411
17699
|
return c.json({ error: "Primitive job not found" }, 404);
|
package/dist/src/cli.js
CHANGED
|
@@ -25,8 +25,8 @@ import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
|
|
|
25
25
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
26
26
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
27
27
|
// records + storage) so power users edit compositions on disk while a browser
|
|
28
|
-
// editor and a coding agent share one source of truth
|
|
29
|
-
//
|
|
28
|
+
// editor and a coding agent share one source of truth. Render can run locally
|
|
29
|
+
// in-process or hand off to cloud; the remaining commands wrap the REST API.
|
|
30
30
|
const DEFAULT_HOST = "https://vidfarm.cc";
|
|
31
31
|
const DEFAULT_PORT = 4321;
|
|
32
32
|
// Agent skill files the `update-skill` command can install from the live host
|
|
@@ -35,18 +35,21 @@ const SKILL_TARGETS = {
|
|
|
35
35
|
"vidfarm-director": { route: "/skill/vidfarm-director", bundled: "SKILL.director.md" },
|
|
36
36
|
"vidfarm-platform": { route: "/skill/vidfarm-platform", bundled: "SKILL.platform.md" }
|
|
37
37
|
};
|
|
38
|
-
//
|
|
39
|
-
// line documents the exact route).
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// the
|
|
38
|
+
// Most commands below are thin wrappers over ONE Vidfarm REST call (the `→`
|
|
39
|
+
// line documents the exact route). A few convenience commands compose the raw
|
|
40
|
+
// API for you when that is the ergonomic default. The devcli adds nothing the
|
|
41
|
+
// raw API can't do — it just resolves your fork, sets the `vidfarm-api-key`
|
|
42
|
+
// header, prints the prod frontend URL to open, and handles the multi-step
|
|
43
|
+
// upload/download + render-polling flows that are awkward to do by hand.
|
|
44
|
+
// Prefer the raw REST API (or `vidfarm api <METHOD> <path>`) whenever you want
|
|
45
|
+
// full control; reach for the named commands when you want the ergonomics and
|
|
46
|
+
// the frontend links.
|
|
45
47
|
const HELP = `vidfarm-devcli — command-line bridge for the Vidfarm video studio
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
raw REST (or \`vidfarm api\`) when
|
|
49
|
-
ergonomics + prod frontend links.
|
|
49
|
+
Most named commands map 1:1 to a REST route (shown as → below). A few compose
|
|
50
|
+
the same routes for file-backed scripting. Use raw REST (or \`vidfarm api\`) when
|
|
51
|
+
you want control; use named commands for ergonomics + prod frontend links.
|
|
52
|
+
Auth: --api-key <key> or VIDFARM_API_KEY.
|
|
50
53
|
|
|
51
54
|
Usage:
|
|
52
55
|
vidfarm <template_id> [opts] Start local editor session (default)
|
|
@@ -118,7 +121,7 @@ Composition lifecycle (fork → decompose → snapshot → render):
|
|
|
118
121
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
119
122
|
|
|
120
123
|
Generate AI media and drop it on the timeline (for local coding agents):
|
|
121
|
-
generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
|
|
124
|
+
generate <image|video> Generate AI media, poll to the finished public URL → POST /api/v1/primitives/{images,videos}/generate
|
|
122
125
|
--prompt <text> What to generate (required)
|
|
123
126
|
--ref <url|@file> Reference image (repeatable): input_references
|
|
124
127
|
for video, prompt_attachments for image. @file
|
|
@@ -372,9 +375,17 @@ Fine timeline control (trackpad-level verbs on a pulled/served composition — l
|
|
|
372
375
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
373
376
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
374
377
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
378
|
+
--dir <dir|composition.html> pushes local
|
|
379
|
+
composition.html + composition.json first
|
|
380
|
+
--target local|cloud chooses in-process or
|
|
381
|
+
cloud handoff when the backend supports both
|
|
375
382
|
Cloud render costs ~$0.01-$0.10; against a local
|
|
376
383
|
'serve' host it renders in-process for FREE.
|
|
377
384
|
render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
|
|
385
|
+
template run <template_id> <operation_name>
|
|
386
|
+
Run one template operation via REST → POST /api/v1/templates/:templateId/operations/:operationName
|
|
387
|
+
--payload-file / --payload supply the input
|
|
388
|
+
--wait polls GET /api/v1/user/me/jobs/:jobId
|
|
378
389
|
visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
|
|
379
390
|
clone <forkId> [--from current|default] [--version N]
|
|
380
391
|
New fork: --from current (default) branches this
|
|
@@ -495,7 +506,10 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
|
|
|
495
506
|
Escape hatch — call ANY route directly:
|
|
496
507
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
497
508
|
--data <json> JSON request body (or --data-file <path>)
|
|
509
|
+
--body-file <path> Literal request body file (for HTML/text payloads)
|
|
510
|
+
--content-type <mime> Content-Type for --body-file (default text/plain)
|
|
498
511
|
--query k=v Repeatable query param
|
|
512
|
+
--raw Print the exact response body, not pretty JSON
|
|
499
513
|
Examples:
|
|
500
514
|
vidfarm api GET /discover/feed
|
|
501
515
|
vidfarm api POST /api/v1/compositions --data '{"template_id":"template_..."}'
|
|
@@ -643,6 +657,9 @@ async function main() {
|
|
|
643
657
|
case "render-status":
|
|
644
658
|
await runRenderStatusCommand(rest);
|
|
645
659
|
return;
|
|
660
|
+
case "template":
|
|
661
|
+
await runTemplateCommand(rest);
|
|
662
|
+
return;
|
|
646
663
|
case "visibility":
|
|
647
664
|
await runVisibilityCommand(rest);
|
|
648
665
|
return;
|
|
@@ -818,8 +835,14 @@ async function apiRequest(input) {
|
|
|
818
835
|
const headers = { ...buildAuthHeaders(input.auth), accept: "application/json" };
|
|
819
836
|
let body;
|
|
820
837
|
if (input.body !== undefined) {
|
|
821
|
-
|
|
822
|
-
|
|
838
|
+
if (typeof input.body === "string") {
|
|
839
|
+
body = input.body;
|
|
840
|
+
headers["content-type"] = input.contentType ?? "text/plain; charset=utf-8";
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
headers["content-type"] = input.contentType ?? "application/json";
|
|
844
|
+
body = JSON.stringify(input.body);
|
|
845
|
+
}
|
|
823
846
|
}
|
|
824
847
|
const res = await fetch(url, { method: input.method.toUpperCase(), headers, body });
|
|
825
848
|
const text = await res.text();
|
|
@@ -1631,7 +1654,15 @@ async function runApiCommand(argv) {
|
|
|
1631
1654
|
const parsed = parseArgs({
|
|
1632
1655
|
args: argv,
|
|
1633
1656
|
allowPositionals: true,
|
|
1634
|
-
options: {
|
|
1657
|
+
options: {
|
|
1658
|
+
...commonOptions(),
|
|
1659
|
+
data: { type: "string" },
|
|
1660
|
+
"data-file": { type: "string" },
|
|
1661
|
+
"body-file": { type: "string" },
|
|
1662
|
+
"content-type": { type: "string" },
|
|
1663
|
+
query: { type: "string", multiple: true },
|
|
1664
|
+
raw: { type: "boolean", default: false }
|
|
1665
|
+
}
|
|
1635
1666
|
});
|
|
1636
1667
|
const method = parsed.positionals[0];
|
|
1637
1668
|
const routePath = parsed.positionals[1];
|
|
@@ -1639,10 +1670,16 @@ async function runApiCommand(argv) {
|
|
|
1639
1670
|
throw new Error(`api requires <METHOD> <path>.\n\nExample: vidfarm api GET /discover/feed`);
|
|
1640
1671
|
}
|
|
1641
1672
|
const ctx = commonContext(parsed.values);
|
|
1673
|
+
if (parsed.values["data-file"] && parsed.values["body-file"]) {
|
|
1674
|
+
throw new Error("api accepts either --data-file (JSON) or --body-file (literal), not both.");
|
|
1675
|
+
}
|
|
1642
1676
|
let body;
|
|
1643
1677
|
if (parsed.values["data-file"]) {
|
|
1644
1678
|
body = JSON.parse(readFileSync(path.resolve(process.cwd(), String(parsed.values["data-file"])), "utf8"));
|
|
1645
1679
|
}
|
|
1680
|
+
else if (parsed.values["body-file"]) {
|
|
1681
|
+
body = readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8");
|
|
1682
|
+
}
|
|
1646
1683
|
else if (parsed.values.data) {
|
|
1647
1684
|
body = JSON.parse(String(parsed.values.data));
|
|
1648
1685
|
}
|
|
@@ -1652,16 +1689,139 @@ async function runApiCommand(argv) {
|
|
|
1652
1689
|
path: routePath,
|
|
1653
1690
|
auth: ctx.auth,
|
|
1654
1691
|
query: collectKeyValues(parsed.values.query),
|
|
1655
|
-
body
|
|
1692
|
+
body,
|
|
1693
|
+
contentType: parsed.values["body-file"] ? parsed.values["content-type"] : undefined
|
|
1656
1694
|
});
|
|
1657
1695
|
if (!ctx.json) {
|
|
1658
1696
|
const color = result.ok ? GREEN : RED;
|
|
1659
1697
|
console.log(`${color}${method.toUpperCase()} ${routePath} → ${result.status}${RESET}`);
|
|
1660
1698
|
}
|
|
1661
|
-
|
|
1699
|
+
if (parsed.values.raw)
|
|
1700
|
+
process.stdout.write(result.text);
|
|
1701
|
+
else
|
|
1702
|
+
printJson(result.json ?? result.text);
|
|
1662
1703
|
if (!result.ok)
|
|
1663
1704
|
process.exitCode = 1;
|
|
1664
1705
|
}
|
|
1706
|
+
function parseJsonInput(raw, label) {
|
|
1707
|
+
if (raw === undefined) {
|
|
1708
|
+
return undefined;
|
|
1709
|
+
}
|
|
1710
|
+
try {
|
|
1711
|
+
return JSON.parse(raw);
|
|
1712
|
+
}
|
|
1713
|
+
catch {
|
|
1714
|
+
throw new Error(`${label} must be valid JSON.`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
async function waitForJobCompletion(ctx, jobId, pollMs) {
|
|
1718
|
+
const intervalMs = Number.isFinite(pollMs) && pollMs > 0 ? pollMs : 2000;
|
|
1719
|
+
while (true) {
|
|
1720
|
+
const result = await dispatch(ctx, {
|
|
1721
|
+
method: "GET",
|
|
1722
|
+
path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`
|
|
1723
|
+
}, ctx.target === "cloud" ? "cloud" : "local");
|
|
1724
|
+
if (!result.ok) {
|
|
1725
|
+
return result;
|
|
1726
|
+
}
|
|
1727
|
+
const status = String(result.json?.status ?? "").toLowerCase();
|
|
1728
|
+
if (!["queued", "running", "waiting_for_provider", "waiting_for_render", "waiting_for_child", "waiting_for_human"].includes(status)) {
|
|
1729
|
+
return result;
|
|
1730
|
+
}
|
|
1731
|
+
await sleep(intervalMs);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
async function runTemplateCommand(argv) {
|
|
1735
|
+
const parsed = parseArgs({
|
|
1736
|
+
args: argv,
|
|
1737
|
+
allowPositionals: true,
|
|
1738
|
+
options: {
|
|
1739
|
+
...commonOptions(),
|
|
1740
|
+
tracer: { type: "string" },
|
|
1741
|
+
wait: { type: "boolean", default: false },
|
|
1742
|
+
"poll-ms": { type: "string" },
|
|
1743
|
+
body: { type: "string" },
|
|
1744
|
+
"body-file": { type: "string" },
|
|
1745
|
+
payload: { type: "string" },
|
|
1746
|
+
"payload-file": { type: "string" },
|
|
1747
|
+
"webhook-url": { type: "string" }
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
const subcommand = parsed.positionals[0];
|
|
1751
|
+
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h" || !subcommand) {
|
|
1752
|
+
console.log(`vidfarm template — run template operations via REST
|
|
1753
|
+
template run <template_id> <operation_name> [--payload-file payload.json | --payload '{...}']
|
|
1754
|
+
[--tracer <id>] [--webhook-url <url>] [--wait] [--poll-ms 2000]
|
|
1755
|
+
--body-file / --body can send the exact REST envelope instead of auto-wrapping
|
|
1756
|
+
`);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (subcommand !== "run") {
|
|
1760
|
+
throw new Error(`Unknown template subcommand: ${subcommand}`);
|
|
1761
|
+
}
|
|
1762
|
+
const templateId = parsed.positionals[1];
|
|
1763
|
+
const operationName = parsed.positionals[2];
|
|
1764
|
+
if (!templateId || !operationName) {
|
|
1765
|
+
throw new Error("template run requires <template_id> <operation_name>.");
|
|
1766
|
+
}
|
|
1767
|
+
const ctx = commonContext(parsed.values);
|
|
1768
|
+
if (ctx.target === "both") {
|
|
1769
|
+
throw new Error("template run only supports one backend at a time. Use --cloud or omit it for the local backend.");
|
|
1770
|
+
}
|
|
1771
|
+
const rawBody = parsed.values["body-file"]
|
|
1772
|
+
? readFileSync(path.resolve(process.cwd(), String(parsed.values["body-file"])), "utf8")
|
|
1773
|
+
: typeof parsed.values.body === "string"
|
|
1774
|
+
? parsed.values.body
|
|
1775
|
+
: null;
|
|
1776
|
+
const payloadSource = parsed.values["payload-file"]
|
|
1777
|
+
? readFileSync(path.resolve(process.cwd(), String(parsed.values["payload-file"])), "utf8")
|
|
1778
|
+
: typeof parsed.values.payload === "string"
|
|
1779
|
+
? parsed.values.payload
|
|
1780
|
+
: null;
|
|
1781
|
+
let body;
|
|
1782
|
+
if (rawBody !== null) {
|
|
1783
|
+
body = parseJsonInput(rawBody, "template body");
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
const payload = payloadSource === null ? {} : parseJsonInput(payloadSource, "template payload");
|
|
1787
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1788
|
+
throw new Error("template payload must be a JSON object.");
|
|
1789
|
+
}
|
|
1790
|
+
body = {
|
|
1791
|
+
tracer: typeof parsed.values.tracer === "string" && parsed.values.tracer.trim()
|
|
1792
|
+
? parsed.values.tracer.trim()
|
|
1793
|
+
: `template_run_${templateId}_${operationName}_${Date.now()}`,
|
|
1794
|
+
payload,
|
|
1795
|
+
...(typeof parsed.values["webhook-url"] === "string" && parsed.values["webhook-url"].trim()
|
|
1796
|
+
? { webhook_url: parsed.values["webhook-url"].trim() }
|
|
1797
|
+
: {})
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
const result = await dispatch(ctx, {
|
|
1801
|
+
method: "POST",
|
|
1802
|
+
path: `/api/v1/templates/${encodeURIComponent(templateId)}/operations/${encodeURIComponent(operationName)}`,
|
|
1803
|
+
body
|
|
1804
|
+
}, ctx.target);
|
|
1805
|
+
if (!result.ok && result.status !== 202) {
|
|
1806
|
+
assertApiOk(result, `template run ${templateId}:${operationName}`);
|
|
1807
|
+
}
|
|
1808
|
+
if (!parsed.values.wait) {
|
|
1809
|
+
emitResult(result, ctx.json);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const jobId = result.json?.job_id;
|
|
1813
|
+
if (typeof jobId !== "string" || !jobId.trim()) {
|
|
1814
|
+
emitResult(result, ctx.json);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const pollMs = Number(parsed.values["poll-ms"] ?? 2000);
|
|
1818
|
+
const final = await waitForJobCompletion(ctx, jobId, pollMs);
|
|
1819
|
+
emitResult(final, ctx.json);
|
|
1820
|
+
const finalStatus = String(final.json?.status ?? "").toLowerCase();
|
|
1821
|
+
if (!final.ok || !["succeeded", "completed"].includes(finalStatus)) {
|
|
1822
|
+
process.exitCode = 1;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1665
1825
|
// ── Discover & inspiration ──────────────────────────────────────────────────
|
|
1666
1826
|
// `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
|
|
1667
1827
|
// CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
|
|
@@ -2168,18 +2328,40 @@ async function runRenderCommand(argv) {
|
|
|
2168
2328
|
const parsed = parseArgs({
|
|
2169
2329
|
args: argv,
|
|
2170
2330
|
allowPositionals: true,
|
|
2171
|
-
options: {
|
|
2331
|
+
options: {
|
|
2332
|
+
...commonOptions(),
|
|
2333
|
+
dir: { type: "string" },
|
|
2334
|
+
title: { type: "string" },
|
|
2335
|
+
tracer: { type: "string" },
|
|
2336
|
+
target: { type: "string", default: "local" },
|
|
2337
|
+
wait: { type: "boolean", default: false }
|
|
2338
|
+
}
|
|
2172
2339
|
});
|
|
2173
2340
|
const forkId = parsed.positionals[0];
|
|
2174
2341
|
if (!forkId)
|
|
2175
2342
|
throw new Error("render requires a fork id.");
|
|
2176
2343
|
const ctx = commonContext(parsed.values);
|
|
2344
|
+
const renderTarget = String(parsed.values.target ?? "local");
|
|
2345
|
+
if (renderTarget !== "local" && renderTarget !== "cloud") {
|
|
2346
|
+
throw new Error(`render --target must be local or cloud, got: ${renderTarget}`);
|
|
2347
|
+
}
|
|
2348
|
+
const sourceDir = parsed.values.dir ? resolveCompositionSourceDir(String(parsed.values.dir)) : null;
|
|
2349
|
+
if (sourceDir) {
|
|
2350
|
+
await pushCompositionFromDir({
|
|
2351
|
+
forkId,
|
|
2352
|
+
rootDir: sourceDir,
|
|
2353
|
+
host: ctx.host,
|
|
2354
|
+
auth: ctx.auth,
|
|
2355
|
+
json: ctx.json,
|
|
2356
|
+
action: "render"
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2177
2359
|
const result = await apiRequest({
|
|
2178
2360
|
method: "POST",
|
|
2179
2361
|
host: ctx.host,
|
|
2180
2362
|
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
|
|
2181
2363
|
auth: ctx.auth,
|
|
2182
|
-
body: { title: parsed.values.title, tracer: parsed.values.tracer }
|
|
2364
|
+
body: { title: parsed.values.title, tracer: parsed.values.tracer, render_target: renderTarget }
|
|
2183
2365
|
});
|
|
2184
2366
|
assertApiOk(result, "render");
|
|
2185
2367
|
const renderId = result.json?.renderId;
|
|
@@ -2187,7 +2369,7 @@ async function runRenderCommand(argv) {
|
|
|
2187
2369
|
if (!ctx.json && renderId) {
|
|
2188
2370
|
console.log(`${DIM}Poll with: vidfarm render-status ${forkId} ${renderId} (or add --wait next time).${RESET}`);
|
|
2189
2371
|
}
|
|
2190
|
-
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
|
|
2372
|
+
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
|
|
2191
2373
|
return;
|
|
2192
2374
|
}
|
|
2193
2375
|
// --wait: poll the render job until it settles.
|
|
@@ -2206,10 +2388,52 @@ async function runRenderCommand(argv) {
|
|
|
2206
2388
|
if (status === "SUCCEEDED" || status === "FAILED")
|
|
2207
2389
|
break;
|
|
2208
2390
|
}
|
|
2209
|
-
emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.outputUrl]]);
|
|
2391
|
+
emitResult(last, ctx.json, [["Rendered MP4 ", last.json?.expectedOutputPublicUrl ?? last.json?.outputUrl]]);
|
|
2210
2392
|
if (String(last.json?.status) === "FAILED")
|
|
2211
2393
|
process.exitCode = 1;
|
|
2212
2394
|
}
|
|
2395
|
+
function resolveCompositionSourceDir(inputPath) {
|
|
2396
|
+
const absolute = path.resolve(process.cwd(), inputPath);
|
|
2397
|
+
if (!existsSync(absolute)) {
|
|
2398
|
+
throw new Error(`render --dir path does not exist: ${absolute}`);
|
|
2399
|
+
}
|
|
2400
|
+
const stat = statSync(absolute);
|
|
2401
|
+
if (stat.isDirectory())
|
|
2402
|
+
return absolute;
|
|
2403
|
+
if (stat.isFile() && path.basename(absolute) === "composition.html")
|
|
2404
|
+
return path.dirname(absolute);
|
|
2405
|
+
throw new Error("render --dir must point to a directory or composition.html.");
|
|
2406
|
+
}
|
|
2407
|
+
async function pushCompositionFromDir(input) {
|
|
2408
|
+
const htmlPath = path.join(input.rootDir, "composition.html");
|
|
2409
|
+
if (!existsSync(htmlPath)) {
|
|
2410
|
+
throw new Error(`No composition.html at ${htmlPath}.`);
|
|
2411
|
+
}
|
|
2412
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
2413
|
+
if (!html.includes("data-composition-id=")) {
|
|
2414
|
+
throw new Error("composition.html is missing data-composition-id — refusing to render a malformed composition.");
|
|
2415
|
+
}
|
|
2416
|
+
const jsonPath = path.join(input.rootDir, "composition.json");
|
|
2417
|
+
const compositionJson = existsSync(jsonPath) ? readFileSync(jsonPath, "utf8") : null;
|
|
2418
|
+
if (compositionJson !== null) {
|
|
2419
|
+
try {
|
|
2420
|
+
JSON.parse(compositionJson || "{}");
|
|
2421
|
+
}
|
|
2422
|
+
catch (error) {
|
|
2423
|
+
throw new Error(`composition.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
if (!input.json)
|
|
2427
|
+
console.log(`[vidfarm] ${input.action}: pushing ${input.rootDir} → ${input.forkId}`);
|
|
2428
|
+
await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.html`, "PUT", html, "text/html; charset=utf-8", input.auth);
|
|
2429
|
+
if (!input.json)
|
|
2430
|
+
console.log(`[vidfarm] pushed composition.html (${Buffer.byteLength(html)} bytes)`);
|
|
2431
|
+
if (compositionJson !== null) {
|
|
2432
|
+
await putComposition(`${input.host}/api/v1/compositions/${encodeURIComponent(input.forkId)}/composition.json`, "PATCH", compositionJson, "application/json", input.auth);
|
|
2433
|
+
if (!input.json)
|
|
2434
|
+
console.log(`[vidfarm] pushed composition.json (${Buffer.byteLength(compositionJson)} bytes)`);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2213
2437
|
async function runRenderStatusCommand(argv) {
|
|
2214
2438
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
2215
2439
|
const forkId = parsed.positionals[0];
|
|
@@ -2219,7 +2443,7 @@ async function runRenderStatusCommand(argv) {
|
|
|
2219
2443
|
const ctx = commonContext(parsed.values);
|
|
2220
2444
|
const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/renders/${encodeURIComponent(renderId)}`, auth: ctx.auth });
|
|
2221
2445
|
assertApiOk(result, "render-status");
|
|
2222
|
-
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
|
|
2446
|
+
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.expectedOutputPublicUrl ?? result.json?.outputUrl]]);
|
|
2223
2447
|
}
|
|
2224
2448
|
// ── AI generation + timeline placement (for local coding agents) ────────────
|
|
2225
2449
|
// `generate` submits an AI image/video primitive job and (by default) polls it
|
package/dist/src/devcli/sync.js
CHANGED
|
@@ -66,16 +66,42 @@ async function readBytes(ctx, space, item) {
|
|
|
66
66
|
const { withLocalBackend } = await import("./local-backend.js");
|
|
67
67
|
const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.apiKey });
|
|
68
68
|
const u = new URL(item.viewUrl, "http://vidfarm.local");
|
|
69
|
+
if (!isLocalFileViewPath(u.pathname)) {
|
|
70
|
+
throw new Error(`Refusing local sync read outside Vidfarm file storage: ${item.path}`);
|
|
71
|
+
}
|
|
69
72
|
const res = await backend.app.request(u.pathname + u.search, { headers: authHeaders(ctx.apiKey) });
|
|
70
73
|
if (!res.ok)
|
|
71
74
|
throw new Error(`local read ${item.path} → HTTP ${res.status}`);
|
|
72
75
|
return Buffer.from(await res.arrayBuffer());
|
|
73
76
|
}
|
|
74
|
-
const
|
|
77
|
+
const url = validateCloudFileViewUrl(ctx, item);
|
|
78
|
+
const res = await fetch(url, { headers: authHeaders(ctx.apiKey) });
|
|
75
79
|
if (!res.ok)
|
|
76
80
|
throw new Error(`cloud read ${item.path} → HTTP ${res.status}`);
|
|
77
81
|
return Buffer.from(await res.arrayBuffer());
|
|
78
82
|
}
|
|
83
|
+
function isLocalFileViewPath(pathname) {
|
|
84
|
+
return pathname === "/api/v1/user/me/files/view" || pathname === "/template-media" || pathname.startsWith("/storage/");
|
|
85
|
+
}
|
|
86
|
+
function validateCloudFileViewUrl(ctx, item) {
|
|
87
|
+
let url;
|
|
88
|
+
try {
|
|
89
|
+
url = new URL(item.viewUrl || "", ctx.host);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Invalid cloud view URL for ${item.path}`);
|
|
93
|
+
}
|
|
94
|
+
const host = new URL(ctx.host);
|
|
95
|
+
const isSameOrigin = url.origin === host.origin;
|
|
96
|
+
const isS3 = /^https:\/\/[^/]+\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
|
|
97
|
+
|| /^https:\/\/s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.origin)
|
|
98
|
+
|| /\.s3[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname);
|
|
99
|
+
if (isSameOrigin && isLocalFileViewPath(url.pathname))
|
|
100
|
+
return url;
|
|
101
|
+
if (isS3)
|
|
102
|
+
return url;
|
|
103
|
+
throw new Error(`Refusing cloud sync read from non-Vidfarm URL for ${item.path}`);
|
|
104
|
+
}
|
|
79
105
|
/** Upload bytes into a space's My Files at folderPath/fileName (multipart — the
|
|
80
106
|
* one transport that works on both S3 and local storage). */
|
|
81
107
|
async function uploadBytes(ctx, space, input) {
|
|
@@ -213,9 +239,10 @@ export async function runSyncCommand(argv) {
|
|
|
213
239
|
home: values.home
|
|
214
240
|
};
|
|
215
241
|
// Scope: a /files subtree (default the whole root).
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
242
|
+
const rawRootPath = positionals[0] ?? "/files";
|
|
243
|
+
const rootPath = normalizeSyncRoot(rawRootPath);
|
|
244
|
+
if (!rootPath) {
|
|
245
|
+
throw new Error(`sync currently covers the /files (My Files) root — got "${rawRootPath}". Raws sync is a follow-up.`);
|
|
219
246
|
}
|
|
220
247
|
const srcSpace = direction === "push" ? "local" : "cloud";
|
|
221
248
|
const dstSpace = direction === "push" ? "cloud" : "local";
|
|
@@ -235,7 +262,7 @@ export async function runSyncCommand(argv) {
|
|
|
235
262
|
console.log(` ${GREEN}+ new${RESET} ${src.path}`);
|
|
236
263
|
}
|
|
237
264
|
else {
|
|
238
|
-
await transfer(ctx, srcSpace, dstSpace, src, src.
|
|
265
|
+
await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
|
|
239
266
|
console.log(` ${GREEN}✓ sent${RESET} ${src.path}`);
|
|
240
267
|
}
|
|
241
268
|
transferred += 1;
|
|
@@ -260,14 +287,15 @@ export async function runSyncCommand(argv) {
|
|
|
260
287
|
continue;
|
|
261
288
|
}
|
|
262
289
|
if (resolution === "keep-both") {
|
|
263
|
-
|
|
264
|
-
|
|
290
|
+
const keepBothName = suffixName(fileNameFromSyncPath(src.path), srcSpace);
|
|
291
|
+
await transfer(ctx, srcSpace, dstSpace, src, keepBothName);
|
|
292
|
+
console.log(` ${YELLOW}✓ both${RESET} ${src.path} ${DIM}→ ${keepBothName}${RESET}`);
|
|
265
293
|
}
|
|
266
294
|
else {
|
|
267
295
|
// Overwrite: drop the target copy first so we replace rather than dup.
|
|
268
296
|
if (dst.id)
|
|
269
297
|
await deleteAttachment(ctx, dstSpace, dst.id);
|
|
270
|
-
await transfer(ctx, srcSpace, dstSpace, src, src.
|
|
298
|
+
await transfer(ctx, srcSpace, dstSpace, src, fileNameFromSyncPath(src.path));
|
|
271
299
|
console.log(` ${GREEN}✓ over${RESET} ${src.path}`);
|
|
272
300
|
}
|
|
273
301
|
transferred += 1;
|
|
@@ -277,15 +305,44 @@ export async function runSyncCommand(argv) {
|
|
|
277
305
|
console.log(` ${DIM}(dry run — nothing was written)${RESET}`);
|
|
278
306
|
}
|
|
279
307
|
async function transfer(ctx, from, to, item, fileName) {
|
|
308
|
+
if (!item.path.startsWith("/files/")) {
|
|
309
|
+
throw new Error(`Refusing to sync item outside /files: ${item.path}`);
|
|
310
|
+
}
|
|
311
|
+
const folderPath = folderPathFromSyncPath(item.path);
|
|
280
312
|
const bytes = await readBytes(ctx, from, item);
|
|
281
313
|
await uploadBytes(ctx, to, {
|
|
282
314
|
fileName,
|
|
283
|
-
folderPath
|
|
315
|
+
folderPath,
|
|
284
316
|
contentType: item.contentType,
|
|
285
317
|
notes: item.notes,
|
|
286
318
|
bytes
|
|
287
319
|
});
|
|
288
320
|
}
|
|
321
|
+
function normalizeSyncRoot(value) {
|
|
322
|
+
const raw = value.trim();
|
|
323
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
324
|
+
const normalized = path.posix.normalize(withSlash).replace(/\/+$/, "") || "/files";
|
|
325
|
+
if (normalized === "/files" || normalized.startsWith("/files/"))
|
|
326
|
+
return normalized;
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
function fileNameFromSyncPath(value) {
|
|
330
|
+
const name = path.posix.basename(value);
|
|
331
|
+
if (!name || name === "." || name === ".." || name.includes("/")) {
|
|
332
|
+
throw new Error(`Invalid sync file path: ${value}`);
|
|
333
|
+
}
|
|
334
|
+
return name;
|
|
335
|
+
}
|
|
336
|
+
function folderPathFromSyncPath(value) {
|
|
337
|
+
if (!value.startsWith("/files/"))
|
|
338
|
+
throw new Error(`Invalid sync file path: ${value}`);
|
|
339
|
+
const dir = path.posix.dirname(value);
|
|
340
|
+
if (dir === "/files")
|
|
341
|
+
return "";
|
|
342
|
+
if (!dir.startsWith("/files/"))
|
|
343
|
+
throw new Error(`Invalid sync file path: ${value}`);
|
|
344
|
+
return dir.slice("/files/".length);
|
|
345
|
+
}
|
|
289
346
|
function normalizePolicy(v) {
|
|
290
347
|
const s = String(v ?? "").trim().toLowerCase();
|
|
291
348
|
if (["newest", "skip", "keep-both", "local", "cloud"].includes(s))
|
|
@@ -469,7 +469,7 @@ export class HyperframesService {
|
|
|
469
469
|
});
|
|
470
470
|
const timestamp = Date.now().toString(36);
|
|
471
471
|
const renderId = `vfimg-${metadata.compositionId.slice(0, 40)}-${timestamp}-${randomUUID().slice(0, 8)}`;
|
|
472
|
-
const framesDir = path.join(config.VIDFARM_DATA_DIR, "
|
|
472
|
+
const framesDir = path.join(config.VIDFARM_DATA_DIR, "vidfarm-projects", `${renderId}-frames`);
|
|
473
473
|
await mkdir(framesDir, { recursive: true });
|
|
474
474
|
devLog("hyperframes.render_still.start", {
|
|
475
475
|
composition_id: metadata.compositionId,
|
|
@@ -3113,7 +3113,7 @@ async function resolveHyperframesStack() {
|
|
|
3113
3113
|
return value;
|
|
3114
3114
|
}
|
|
3115
3115
|
async function stageProject(input) {
|
|
3116
|
-
const root = path.join(config.VIDFARM_DATA_DIR, "
|
|
3116
|
+
const root = path.join(config.VIDFARM_DATA_DIR, "vidfarm-projects");
|
|
3117
3117
|
const projectDir = path.join(root, `${Date.now().toString(36)}-${safeIdentifier(input.compositionId)}-${randomUUID().slice(0, 8)}`);
|
|
3118
3118
|
await rm(projectDir, { recursive: true, force: true });
|
|
3119
3119
|
await mkdir(projectDir, { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|