@mevdragon/vidfarm-devcli 0.1.0
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/.env.example +41 -0
- package/AWS_REMOTION_HANDOFF.md +311 -0
- package/PLATFORM_SPEC.md +898 -0
- package/README.md +151 -0
- package/dist/src/app.js +224 -0
- package/dist/src/cli.js +187 -0
- package/dist/src/config.js +52 -0
- package/dist/src/context.js +198 -0
- package/dist/src/db.js +588 -0
- package/dist/src/dev-app.js +859 -0
- package/dist/src/domain.js +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/lib/crypto.js +30 -0
- package/dist/src/lib/ids.js +4 -0
- package/dist/src/lib/images.js +18 -0
- package/dist/src/lib/json.js +14 -0
- package/dist/src/lib/time.js +6 -0
- package/dist/src/registry.js +10 -0
- package/dist/src/runtime.js +19 -0
- package/dist/src/services/auth.js +80 -0
- package/dist/src/services/billing.js +16 -0
- package/dist/src/services/jobs.js +97 -0
- package/dist/src/services/providers.js +529 -0
- package/dist/src/services/remotion.js +158 -0
- package/dist/src/services/storage.js +93 -0
- package/dist/src/services/webhooks.js +61 -0
- package/dist/src/template-sdk.js +3 -0
- package/dist/src/worker.js +122 -0
- package/dist/templates/template_0000/demo-template.js +196 -0
- package/dist/templates/template_0000/remotion/Root.js +66 -0
- package/dist/templates/template_0000/remotion/index.js +3 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Vidfarm
|
|
2
|
+
|
|
3
|
+
Vidfarm is a single-container Node.js control plane for async video template pipelines. It follows the architecture in [video-pipeline-architecture-draft.md](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/video-pipeline-architecture-draft.md) and [PLATFORM_SPEC.md](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/PLATFORM_SPEC.md): Hono API, SQLite-backed jobs and provider-key leasing, S3-compatible storage, and an in-process worker suitable for Docker on EC2.
|
|
4
|
+
|
|
5
|
+
## What Is Included
|
|
6
|
+
|
|
7
|
+
- Async template API under `/templates/:templateId/*`
|
|
8
|
+
- Email OTP login with API key issuance
|
|
9
|
+
- Customer-scoped provider key vault with encryption at rest
|
|
10
|
+
- SQLite-backed job queue and provider-key lease coordination
|
|
11
|
+
- Structured job logs, artifacts, billing events, and webhook delivery queue
|
|
12
|
+
- Local storage mode plus S3-compatible mode for AWS
|
|
13
|
+
- Remotion adapter seam and a sample `demo-template` slideshow pipeline
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
cp .env.example .env
|
|
20
|
+
npm run dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The service starts on `http://localhost:3000` by default and creates SQLite plus local artifact storage under `./data`.
|
|
24
|
+
|
|
25
|
+
## Template Developer CLI
|
|
26
|
+
|
|
27
|
+
For local template development, use the bundled CLI instead of manually wiring auth, provider keys, SQLite, and Remotion:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npx @officexapp/vidfarm-devcli dev --port 3310 --reset
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
What it does:
|
|
35
|
+
|
|
36
|
+
- starts the same REST API and in-process worker as production
|
|
37
|
+
- provisions a local SQLite database automatically
|
|
38
|
+
- uses local filesystem storage as an S3 stand-in
|
|
39
|
+
- seeds a dev customer and API key
|
|
40
|
+
- seeds provider keys from local `.env` variables like `OPENAI_API_KEY`, `GEMINI_API_KEY`, and `OPENROUTER_API_KEY`
|
|
41
|
+
- falls back to mock provider keys plus mocked AI responses when no env keys are present
|
|
42
|
+
- renders Remotion compositions locally instead of using Lambda
|
|
43
|
+
|
|
44
|
+
Useful commands:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx @officexapp/vidfarm-devcli dev --port 3310 --reset
|
|
48
|
+
npx @officexapp/vidfarm-devcli session
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
After install, the package also exposes `vidfarm` and `vidfarm-devcli` as local binaries.
|
|
52
|
+
|
|
53
|
+
`vidfarm session` prints reusable auth headers and a sample `curl` request for the local REST API.
|
|
54
|
+
|
|
55
|
+
## Environment
|
|
56
|
+
|
|
57
|
+
Primary variables:
|
|
58
|
+
|
|
59
|
+
- `PORT`
|
|
60
|
+
- `VIDFARM_DB_PATH`
|
|
61
|
+
- `VIDFARM_DATA_DIR`
|
|
62
|
+
- `ENCRYPTION_SECRET`
|
|
63
|
+
- `API_KEY_SALT`
|
|
64
|
+
- `STORAGE_DRIVER=local|s3`
|
|
65
|
+
- `AWS_REGION`
|
|
66
|
+
- `AWS_S3_BUCKET`
|
|
67
|
+
- `PUBLIC_BASE_URL`
|
|
68
|
+
- `RESEND_API_KEY`
|
|
69
|
+
- `WEBHOOK_SECRET`
|
|
70
|
+
- `MOCK_PROVIDER_RESPONSES=true|false`
|
|
71
|
+
|
|
72
|
+
Remotion-specific variables:
|
|
73
|
+
|
|
74
|
+
- `REMOTION_REGION`
|
|
75
|
+
- `REMOTION_FUNCTION_NAME`
|
|
76
|
+
- `REMOTION_BUCKET_NAME`
|
|
77
|
+
- `REMOTION_SITE_NAME`
|
|
78
|
+
- `REMOTION_SERVE_URL`
|
|
79
|
+
- `REMOTION_COMPOSITION_ID`
|
|
80
|
+
- `REMOTION_AWS_ACCESS_KEY_ID`
|
|
81
|
+
- `REMOTION_AWS_SECRET_ACCESS_KEY`
|
|
82
|
+
|
|
83
|
+
When `MOCK_PROVIDER_RESPONSES=true`, AI provider calls use the real key-leasing flow but return mocked content. This makes local end-to-end testing possible without burning customer API calls.
|
|
84
|
+
|
|
85
|
+
When using `npx @officexapp/vidfarm-devcli dev`, the CLI automatically chooses local-friendly defaults:
|
|
86
|
+
|
|
87
|
+
- `STORAGE_DRIVER=local`
|
|
88
|
+
- `REMOTION_MODE=local`
|
|
89
|
+
- a local SQLite path under `.vidfarm/local`
|
|
90
|
+
- `MOCK_PROVIDER_RESPONSES=false` if real provider env keys are present, otherwise `true`
|
|
91
|
+
|
|
92
|
+
## Remotion AWS
|
|
93
|
+
|
|
94
|
+
For shared Remotion AWS infra, use [AWS_REMOTION_HANDOFF.md](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/AWS_REMOTION_HANDOFF.md) as the operating contract.
|
|
95
|
+
|
|
96
|
+
The key points verified on `2026-05-16` are:
|
|
97
|
+
|
|
98
|
+
- region: `us-east-1`
|
|
99
|
+
- shared Lambda function: `remotion-render-4-0-355-mem2048mb-disk2048mb-180sec`
|
|
100
|
+
- shared bucket: `remotionlambda-useast1-ujg7c0h43q`
|
|
101
|
+
- Remotion package version to pin: `4.0.355`
|
|
102
|
+
|
|
103
|
+
This repo also has separate Remotion AWS runner credentials available in local production env config via:
|
|
104
|
+
|
|
105
|
+
- `REMOTION_AWS_ACCESS_KEY_ID`
|
|
106
|
+
- `REMOTION_AWS_SECRET_ACCESS_KEY`
|
|
107
|
+
|
|
108
|
+
Use those for Remotion site publish/render work rather than assuming the generic app AWS credentials are the right identity.
|
|
109
|
+
|
|
110
|
+
Recommended repo-level config for real Remotion work:
|
|
111
|
+
|
|
112
|
+
```env
|
|
113
|
+
REMOTION_REGION=us-east-1
|
|
114
|
+
REMOTION_FUNCTION_NAME=remotion-render-4-0-355-mem2048mb-disk2048mb-180sec
|
|
115
|
+
REMOTION_BUCKET_NAME=remotionlambda-useast1-ujg7c0h43q
|
|
116
|
+
REMOTION_SITE_NAME=your-site-name
|
|
117
|
+
REMOTION_SERVE_URL=https://remotionlambda-useast1-ujg7c0h43q.s3.us-east-1.amazonaws.com/sites/your-site-name/index.html
|
|
118
|
+
REMOTION_COMPOSITION_ID=YourComposition
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If you wire real Lambda rendering into `src/services/remotion.ts`, preserve the current platform contract and make the service read these env vars rather than hard-coding one-off values.
|
|
122
|
+
|
|
123
|
+
## API Flow
|
|
124
|
+
|
|
125
|
+
1. `POST /auth/request-otp`
|
|
126
|
+
2. `POST /auth/verify-otp`
|
|
127
|
+
3. `POST /me/provider-keys`
|
|
128
|
+
4. `POST /templates/demo-template/config`
|
|
129
|
+
5. `POST /templates/demo-template/operations/generate`
|
|
130
|
+
6. Poll `GET /templates/demo-template/jobs/:jobId`
|
|
131
|
+
7. Stream timeline data from `GET /templates/demo-template/jobs/:jobId/logs`
|
|
132
|
+
|
|
133
|
+
## Docker
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
docker compose up --build
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For AWS/EC2, mount `/app/data` onto durable storage if SQLite is local, or switch artifacts to S3 with:
|
|
140
|
+
|
|
141
|
+
```env
|
|
142
|
+
STORAGE_DRIVER=s3
|
|
143
|
+
AWS_REGION=us-east-1
|
|
144
|
+
AWS_S3_BUCKET=your-vidfarm-bucket
|
|
145
|
+
PUBLIC_BASE_URL=https://your-domain.example
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Notes
|
|
149
|
+
|
|
150
|
+
- `demo-template` is the canonical sample internal template. Add more templates by registering them in [src/registry.ts](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/src/registry.ts).
|
|
151
|
+
- Remotion is still stubbed behind [src/services/remotion.ts](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/src/services/remotion.ts). When upgrading it to real Lambda submission, follow [AWS_REMOTION_HANDOFF.md](/Users/localghost/Projects/OfficeX/OfficeX/ZoomGTM/vidfarm/AWS_REMOTION_HANDOFF.md) and use the Remotion-specific AWS credentials already available in local production env config.
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { config } from "./config.js";
|
|
6
|
+
import { database } from "./db.js";
|
|
7
|
+
import { renderDevApp } from "./dev-app.js";
|
|
8
|
+
import { encryptString } from "./lib/crypto.js";
|
|
9
|
+
import { createId } from "./lib/ids.js";
|
|
10
|
+
import { templateRegistry } from "./registry.js";
|
|
11
|
+
import { AuthService } from "./services/auth.js";
|
|
12
|
+
import { JobsService } from "./services/jobs.js";
|
|
13
|
+
import { StorageService } from "./services/storage.js";
|
|
14
|
+
const auth = new AuthService();
|
|
15
|
+
const jobs = new JobsService();
|
|
16
|
+
const storage = new StorageService();
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
const otpRequestSchema = z.object({
|
|
19
|
+
email: z.string().email()
|
|
20
|
+
});
|
|
21
|
+
const otpVerifySchema = z.object({
|
|
22
|
+
email: z.string().email(),
|
|
23
|
+
code: z.string().min(6).max(6),
|
|
24
|
+
name: z.string().optional()
|
|
25
|
+
});
|
|
26
|
+
const providerKeySchema = z.object({
|
|
27
|
+
provider: z.enum(["openai", "gemini", "openrouter", "perplexity"]),
|
|
28
|
+
label: z.string().optional(),
|
|
29
|
+
secret: z.string().min(8),
|
|
30
|
+
weight: z.number().int().min(1).max(100).default(1)
|
|
31
|
+
});
|
|
32
|
+
const workspacePresignSchema = z.object({
|
|
33
|
+
path: z.string().min(1),
|
|
34
|
+
contentType: z.string().min(3).default("application/octet-stream")
|
|
35
|
+
});
|
|
36
|
+
app.use("*", async (c, next) => {
|
|
37
|
+
c.header("x-powered-by", "vidfarm");
|
|
38
|
+
await next();
|
|
39
|
+
});
|
|
40
|
+
app.get("/", (c) => c.json({ name: "vidfarm", version: "0.1.0", environment: config.NODE_ENV }));
|
|
41
|
+
app.get("/health", (c) => c.json({ ok: true, time: new Date().toISOString() }));
|
|
42
|
+
app.get("/dev", (c) => c.html(renderDevApp({
|
|
43
|
+
environment: config.NODE_ENV,
|
|
44
|
+
templates: templateRegistry.list().map((template) => ({
|
|
45
|
+
id: template.id,
|
|
46
|
+
version: template.version,
|
|
47
|
+
description: template.description,
|
|
48
|
+
operations: Object.entries(template.operations).map(([name, operation]) => ({
|
|
49
|
+
name,
|
|
50
|
+
description: operation.description,
|
|
51
|
+
workflow: operation.workflow,
|
|
52
|
+
providerHint: operation.providerHint ?? null
|
|
53
|
+
}))
|
|
54
|
+
}))
|
|
55
|
+
})));
|
|
56
|
+
app.post("/auth/request-otp", async (c) => {
|
|
57
|
+
const body = otpRequestSchema.parse(await c.req.json());
|
|
58
|
+
await auth.requestOtp(body.email);
|
|
59
|
+
return c.json({ ok: true });
|
|
60
|
+
});
|
|
61
|
+
app.post("/auth/verify-otp", async (c) => {
|
|
62
|
+
const body = otpVerifySchema.parse(await c.req.json());
|
|
63
|
+
return c.json(auth.verifyOtp(body.email, body.code, body.name));
|
|
64
|
+
});
|
|
65
|
+
function requireCustomer(c) {
|
|
66
|
+
return c.get("customer");
|
|
67
|
+
}
|
|
68
|
+
const requireAuth = async (c, next) => {
|
|
69
|
+
try {
|
|
70
|
+
const customer = auth.authenticate(c.req.header("vidfarm-user-id"), c.req.header("vidfarm-api-key"));
|
|
71
|
+
c.set("customer", customer);
|
|
72
|
+
await next();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return c.json({ error: error instanceof Error ? error.message : "Unauthorized" }, 401);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
app.use("/me/*", requireAuth);
|
|
79
|
+
app.use("/templates/*", requireAuth);
|
|
80
|
+
app.get("/templates", (c) => c.json({
|
|
81
|
+
templates: templateRegistry.list().map((template) => ({
|
|
82
|
+
id: template.id,
|
|
83
|
+
version: template.version,
|
|
84
|
+
description: template.description,
|
|
85
|
+
operations: Object.entries(template.operations).map(([name, operation]) => ({
|
|
86
|
+
name,
|
|
87
|
+
description: operation.description,
|
|
88
|
+
workflow: operation.workflow,
|
|
89
|
+
providerHint: operation.providerHint ?? null
|
|
90
|
+
}))
|
|
91
|
+
}))
|
|
92
|
+
}));
|
|
93
|
+
app.get("/templates/:templateId", (c) => {
|
|
94
|
+
const template = templateRegistry.get(c.req.param("templateId"));
|
|
95
|
+
if (!template) {
|
|
96
|
+
return c.json({ error: "Template not found" }, 404);
|
|
97
|
+
}
|
|
98
|
+
return c.json({
|
|
99
|
+
id: template.id,
|
|
100
|
+
version: template.version,
|
|
101
|
+
description: template.description,
|
|
102
|
+
operations: Object.entries(template.operations).map(([name, operation]) => ({
|
|
103
|
+
name,
|
|
104
|
+
description: operation.description,
|
|
105
|
+
providerHint: operation.providerHint ?? null
|
|
106
|
+
}))
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
app.post("/templates/:templateId/config", async (c) => {
|
|
110
|
+
const customer = requireCustomer(c);
|
|
111
|
+
const template = templateRegistry.get(c.req.param("templateId"));
|
|
112
|
+
if (!template) {
|
|
113
|
+
return c.json({ error: "Template not found" }, 404);
|
|
114
|
+
}
|
|
115
|
+
const body = z.object({ config: z.record(z.string(), z.unknown()) }).parse(await c.req.json());
|
|
116
|
+
const parsed = template.configSchema.parse(body.config);
|
|
117
|
+
database.upsertTemplateConfig({
|
|
118
|
+
id: createId("cfg"),
|
|
119
|
+
customerId: customer.id,
|
|
120
|
+
templateId: template.id,
|
|
121
|
+
config: parsed
|
|
122
|
+
});
|
|
123
|
+
return c.json({ ok: true, template_id: template.id, config: parsed });
|
|
124
|
+
});
|
|
125
|
+
app.post("/templates/:templateId/operations/:operationName", async (c) => {
|
|
126
|
+
const customer = requireCustomer(c);
|
|
127
|
+
const template = templateRegistry.get(c.req.param("templateId"));
|
|
128
|
+
if (!template) {
|
|
129
|
+
return c.json({ error: "Template not found" }, 404);
|
|
130
|
+
}
|
|
131
|
+
const operation = template.operations[c.req.param("operationName")];
|
|
132
|
+
if (!operation) {
|
|
133
|
+
return c.json({ error: "Operation not found" }, 404);
|
|
134
|
+
}
|
|
135
|
+
const body = z.object({
|
|
136
|
+
tracer: z.string().min(3),
|
|
137
|
+
payload: z.record(z.string(), z.unknown()),
|
|
138
|
+
webhook_url: z.string().url().optional()
|
|
139
|
+
}).parse(await c.req.json());
|
|
140
|
+
const payload = operation.inputSchema.parse(body.payload);
|
|
141
|
+
const job = jobs.createRootJob({
|
|
142
|
+
templateId: template.id,
|
|
143
|
+
operationName: c.req.param("operationName"),
|
|
144
|
+
workflowName: operation.workflow,
|
|
145
|
+
tracer: body.tracer,
|
|
146
|
+
payload,
|
|
147
|
+
webhookUrl: body.webhook_url,
|
|
148
|
+
customer,
|
|
149
|
+
providerHint: operation.providerHint
|
|
150
|
+
});
|
|
151
|
+
return c.json({ job_id: job.id, tracer: job.tracer, status: job.status }, 202);
|
|
152
|
+
});
|
|
153
|
+
app.get("/templates/:templateId/jobs/:jobId", (c) => {
|
|
154
|
+
const customer = requireCustomer(c);
|
|
155
|
+
const job = jobs.getJob(c.req.param("jobId"));
|
|
156
|
+
if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
|
|
157
|
+
return c.json({ error: "Job not found" }, 404);
|
|
158
|
+
}
|
|
159
|
+
return c.json({
|
|
160
|
+
job_id: job.id,
|
|
161
|
+
tracer: job.tracer,
|
|
162
|
+
status: job.status,
|
|
163
|
+
progress: job.progress,
|
|
164
|
+
result: job.result,
|
|
165
|
+
error: job.error,
|
|
166
|
+
created_at: job.createdAt,
|
|
167
|
+
updated_at: job.updatedAt
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
app.get("/templates/:templateId/jobs/:jobId/logs", (c) => {
|
|
171
|
+
const customer = requireCustomer(c);
|
|
172
|
+
const job = jobs.getJob(c.req.param("jobId"));
|
|
173
|
+
if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
|
|
174
|
+
return c.json({ error: "Job not found" }, 404);
|
|
175
|
+
}
|
|
176
|
+
const logs = jobs.listLogs(job.id, c.req.query("logs_from") || undefined, Number(c.req.query("limit") ?? 100));
|
|
177
|
+
return c.json({ job_id: job.id, tracer: job.tracer, logs });
|
|
178
|
+
});
|
|
179
|
+
app.post("/templates/:templateId/jobs/:jobId/cancel", (c) => {
|
|
180
|
+
const customer = requireCustomer(c);
|
|
181
|
+
const job = jobs.getJob(c.req.param("jobId"));
|
|
182
|
+
if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
|
|
183
|
+
return c.json({ error: "Job not found" }, 404);
|
|
184
|
+
}
|
|
185
|
+
jobs.cancelJob(job.id);
|
|
186
|
+
return c.json({ ok: true, job_id: job.id, status: "cancelled" });
|
|
187
|
+
});
|
|
188
|
+
app.get("/me", (c) => c.json({ customer: requireCustomer(c) }));
|
|
189
|
+
app.get("/me/jobs", (c) => c.json({ jobs: jobs.listJobs(requireCustomer(c).id, c.req.query("template_id") || undefined) }));
|
|
190
|
+
app.get("/me/provider-keys", (c) => c.json({ provider_keys: database.listProviderKeys(requireCustomer(c).id) }));
|
|
191
|
+
app.post("/me/provider-keys", async (c) => {
|
|
192
|
+
const customer = requireCustomer(c);
|
|
193
|
+
const body = providerKeySchema.parse(await c.req.json());
|
|
194
|
+
database.createProviderKey({
|
|
195
|
+
id: createId("pkey"),
|
|
196
|
+
customerId: customer.id,
|
|
197
|
+
provider: body.provider,
|
|
198
|
+
label: body.label ?? null,
|
|
199
|
+
encryptedSecret: encryptString(body.secret, config.ENCRYPTION_SECRET),
|
|
200
|
+
weight: body.weight
|
|
201
|
+
});
|
|
202
|
+
return c.json({ ok: true }, 201);
|
|
203
|
+
});
|
|
204
|
+
app.post("/me/workspace/presign", async (c) => {
|
|
205
|
+
const customer = requireCustomer(c);
|
|
206
|
+
const body = workspacePresignSchema.parse(await c.req.json());
|
|
207
|
+
return c.json(await storage.createPresignedWorkspaceUpload(customer.id, body.path, body.contentType));
|
|
208
|
+
});
|
|
209
|
+
app.get("/storage/:key", (c) => {
|
|
210
|
+
const key = decodeURIComponent(c.req.param("key"));
|
|
211
|
+
try {
|
|
212
|
+
const object = storage.readLocalObject(key);
|
|
213
|
+
return new Response(object.body, {
|
|
214
|
+
headers: {
|
|
215
|
+
"content-type": object.contentType
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return c.json({ error: "Artifact not found" }, 404);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
app.get("/docs/spec", (c) => c.text(readFileSync(path.resolve("PLATFORM_SPEC.md"), "utf8")));
|
|
224
|
+
export default app;
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
void main().catch((error) => {
|
|
7
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
});
|
|
10
|
+
async function main() {
|
|
11
|
+
const command = process.argv[2] ?? "dev";
|
|
12
|
+
if (command === "session") {
|
|
13
|
+
await runSessionCommand(process.argv.slice(3));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (command === "dev") {
|
|
17
|
+
await runDevCommand(process.argv.slice(3));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Unknown command: ${command}`);
|
|
21
|
+
}
|
|
22
|
+
async function runDevCommand(argv) {
|
|
23
|
+
const parsed = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
options: {
|
|
26
|
+
port: { type: "string", default: "3000" },
|
|
27
|
+
"data-dir": { type: "string", default: ".vidfarm/local" },
|
|
28
|
+
"env-file": { type: "string", default: ".env" },
|
|
29
|
+
email: { type: "string", default: "dev@vidfarm.local" },
|
|
30
|
+
name: { type: "string", default: "Vidfarm Local Dev" },
|
|
31
|
+
"api-key": { type: "string" },
|
|
32
|
+
reset: { type: "boolean", default: false },
|
|
33
|
+
"mock-ai": { type: "boolean" }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const port = Number(parsed.values.port);
|
|
37
|
+
const root = process.cwd();
|
|
38
|
+
const dataDir = path.resolve(root, parsed.values["data-dir"]);
|
|
39
|
+
const dbPath = path.join(dataDir, "vidfarm.sqlite");
|
|
40
|
+
const sessionPath = path.join(dataDir, "session.json");
|
|
41
|
+
dotenv.config({
|
|
42
|
+
path: path.resolve(root, parsed.values["env-file"]),
|
|
43
|
+
override: false
|
|
44
|
+
});
|
|
45
|
+
if (parsed.values.reset) {
|
|
46
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
mkdirSync(dataDir, { recursive: true });
|
|
49
|
+
const hasProviderEnvKey = Boolean(process.env.OPENAI_API_KEY ||
|
|
50
|
+
process.env.GEMINI_API_KEY ||
|
|
51
|
+
process.env.OPENROUTER_API_KEY ||
|
|
52
|
+
process.env.PERPLEXITY_API_KEY);
|
|
53
|
+
process.env.NODE_ENV = process.env.NODE_ENV || "development";
|
|
54
|
+
process.env.PORT = String(port);
|
|
55
|
+
process.env.VIDFARM_DATA_DIR = dataDir;
|
|
56
|
+
process.env.VIDFARM_DB_PATH = dbPath;
|
|
57
|
+
process.env.STORAGE_DRIVER = "local";
|
|
58
|
+
process.env.PUBLIC_BASE_URL = `http://127.0.0.1:${port}`;
|
|
59
|
+
process.env.REMOTION_MODE = "local";
|
|
60
|
+
process.env.MOCK_PROVIDER_RESPONSES =
|
|
61
|
+
parsed.values["mock-ai"] ? "true" : process.env.MOCK_PROVIDER_RESPONSES ?? (hasProviderEnvKey ? "false" : "true");
|
|
62
|
+
const [{ startRuntime }, { database }, { config }, { createId }, crypto] = await Promise.all([
|
|
63
|
+
import("./runtime.js"),
|
|
64
|
+
import("./db.js"),
|
|
65
|
+
import("./config.js"),
|
|
66
|
+
import("./lib/ids.js"),
|
|
67
|
+
import("./lib/crypto.js")
|
|
68
|
+
]);
|
|
69
|
+
const customer = database.getCustomerByEmail(parsed.values.email) ?? database.upsertCustomer({
|
|
70
|
+
id: createId("cus"),
|
|
71
|
+
email: parsed.values.email,
|
|
72
|
+
name: parsed.values.name
|
|
73
|
+
});
|
|
74
|
+
const rawApiKey = parsed.values["api-key"] ?? `vf_local_${Math.random().toString(36).slice(2, 12)}`;
|
|
75
|
+
database.insertApiKey({
|
|
76
|
+
id: createId("api"),
|
|
77
|
+
customerId: customer.id,
|
|
78
|
+
keyHash: crypto.hashSecret(rawApiKey + config.API_KEY_SALT),
|
|
79
|
+
label: "Local CLI session"
|
|
80
|
+
});
|
|
81
|
+
seedEnvProviderKeys({
|
|
82
|
+
customerId: customer.id,
|
|
83
|
+
database,
|
|
84
|
+
createId,
|
|
85
|
+
encryptString: crypto.encryptString,
|
|
86
|
+
encryptionSecret: config.ENCRYPTION_SECRET,
|
|
87
|
+
allowMockPlaceholders: config.mockProviders
|
|
88
|
+
});
|
|
89
|
+
writeFileSync(sessionPath, JSON.stringify({
|
|
90
|
+
baseUrl: process.env.PUBLIC_BASE_URL,
|
|
91
|
+
customerId: customer.id,
|
|
92
|
+
apiKey: rawApiKey,
|
|
93
|
+
email: customer.email,
|
|
94
|
+
dbPath,
|
|
95
|
+
dataDir
|
|
96
|
+
}, null, 2));
|
|
97
|
+
printStartupBanner({
|
|
98
|
+
baseUrl: process.env.PUBLIC_BASE_URL,
|
|
99
|
+
customerId: customer.id,
|
|
100
|
+
apiKey: rawApiKey,
|
|
101
|
+
email: customer.email,
|
|
102
|
+
dataDir,
|
|
103
|
+
dbPath,
|
|
104
|
+
sessionPath,
|
|
105
|
+
mockProviders: config.mockProviders
|
|
106
|
+
});
|
|
107
|
+
const runtime = startRuntime();
|
|
108
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
109
|
+
process.on(signal, () => {
|
|
110
|
+
runtime.shutdown();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function runSessionCommand(argv) {
|
|
116
|
+
const parsed = parseArgs({
|
|
117
|
+
args: argv,
|
|
118
|
+
options: {
|
|
119
|
+
"data-dir": { type: "string", default: ".vidfarm/local" }
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const sessionPath = path.resolve(process.cwd(), parsed.values["data-dir"], "session.json");
|
|
123
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
124
|
+
console.log(JSON.stringify({
|
|
125
|
+
base_url: session.baseUrl,
|
|
126
|
+
email: session.email,
|
|
127
|
+
headers: {
|
|
128
|
+
"vidfarm-user-id": session.customerId,
|
|
129
|
+
"vidfarm-api-key": session.apiKey,
|
|
130
|
+
"content-type": "application/json"
|
|
131
|
+
},
|
|
132
|
+
curl_example: [
|
|
133
|
+
`curl -X POST ${session.baseUrl}/templates/demo-template/operations/generate \\`,
|
|
134
|
+
` -H 'vidfarm-user-id: ${session.customerId}' \\`,
|
|
135
|
+
` -H 'vidfarm-api-key: ${session.apiKey}' \\`,
|
|
136
|
+
" -H 'content-type: application/json' \\",
|
|
137
|
+
" -d '{\"tracer\":\"local-test\",\"payload\":{\"slides\":[[\"a cinematic founder at a desk\",\"Exact text on slide one\"]],\"secondsPerSlide\":4}}'"
|
|
138
|
+
].join("\n")
|
|
139
|
+
}, null, 2));
|
|
140
|
+
}
|
|
141
|
+
function seedEnvProviderKeys(input) {
|
|
142
|
+
const entries = [
|
|
143
|
+
{ provider: "openai", secret: process.env.OPENAI_API_KEY ?? "", placeholder: "mock-openai-key" },
|
|
144
|
+
{ provider: "gemini", secret: process.env.GEMINI_API_KEY ?? "", placeholder: "mock-gemini-key" },
|
|
145
|
+
{ provider: "openrouter", secret: process.env.OPENROUTER_API_KEY ?? "", placeholder: "mock-openrouter-key" },
|
|
146
|
+
{ provider: "perplexity", secret: process.env.PERPLEXITY_API_KEY ?? "", placeholder: "mock-perplexity-key" }
|
|
147
|
+
];
|
|
148
|
+
const existing = input.database.listProviderKeys(input.customerId);
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const secret = entry.secret || (input.allowMockPlaceholders ? entry.placeholder : "");
|
|
151
|
+
if (!secret) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const alreadySeeded = existing.some((row) => row.provider === entry.provider && row.label === "Local CLI env key");
|
|
155
|
+
if (alreadySeeded) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
input.database.createProviderKey({
|
|
159
|
+
id: input.createId("pkey"),
|
|
160
|
+
customerId: input.customerId,
|
|
161
|
+
provider: entry.provider,
|
|
162
|
+
label: "Local CLI env key",
|
|
163
|
+
encryptedSecret: input.encryptString(secret, input.encryptionSecret),
|
|
164
|
+
weight: 1
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function printStartupBanner(input) {
|
|
169
|
+
console.log("");
|
|
170
|
+
console.log("Vidfarm local dev runtime");
|
|
171
|
+
console.log(`Base URL: ${input.baseUrl}`);
|
|
172
|
+
console.log(`Dev user: ${input.email}`);
|
|
173
|
+
console.log(`vidfarm-user-id: ${input.customerId}`);
|
|
174
|
+
console.log(`vidfarm-api-key: ${input.apiKey}`);
|
|
175
|
+
console.log(`Session file: ${input.sessionPath}`);
|
|
176
|
+
console.log(`Data dir: ${input.dataDir}`);
|
|
177
|
+
console.log(`SQLite: ${input.dbPath}`);
|
|
178
|
+
console.log(`AI mode: ${input.mockProviders ? "mock provider responses" : "real provider env keys"}`);
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log("REST API:");
|
|
181
|
+
console.log(` ${input.baseUrl}/health`);
|
|
182
|
+
console.log(` ${input.baseUrl}/dev`);
|
|
183
|
+
console.log("");
|
|
184
|
+
console.log("Get reusable auth headers:");
|
|
185
|
+
console.log(" vidfarm session");
|
|
186
|
+
console.log("");
|
|
187
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
dotenv.config();
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
8
|
+
PORT: z.coerce.number().default(3000),
|
|
9
|
+
VIDFARM_DB_PATH: z.string().default("./data/vidfarm.sqlite"),
|
|
10
|
+
VIDFARM_DATA_DIR: z.string().default("./data"),
|
|
11
|
+
ENCRYPTION_SECRET: z.string().default("development-encryption-secret-change-me"),
|
|
12
|
+
API_KEY_SALT: z.string().default("development-api-key-salt"),
|
|
13
|
+
WORKER_POLL_MS: z.coerce.number().default(1500),
|
|
14
|
+
WORKER_BATCH_SIZE: z.coerce.number().default(4),
|
|
15
|
+
WEBHOOK_SECRET: z.string().default("development-webhook-secret"),
|
|
16
|
+
DEFAULT_JOB_DELAY_SECONDS: z.coerce.number().default(20),
|
|
17
|
+
STORAGE_DRIVER: z.enum(["local", "s3"]).default("local"),
|
|
18
|
+
AWS_REGION: z.string().default("us-east-1"),
|
|
19
|
+
AWS_S3_BUCKET: z.string().default(""),
|
|
20
|
+
AWS_S3_ENDPOINT: z.string().optional(),
|
|
21
|
+
AWS_ACCESS_KEY_ID: z.string().optional(),
|
|
22
|
+
AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
|
23
|
+
PUBLIC_BASE_URL: z.string().optional(),
|
|
24
|
+
RESEND_API_KEY: z.string().optional(),
|
|
25
|
+
RESEND_FROM_EMAIL: z.string().default("noreply@example.com"),
|
|
26
|
+
OPENAI_API_KEY: z.string().optional(),
|
|
27
|
+
OPENROUTER_API_KEY: z.string().optional(),
|
|
28
|
+
GEMINI_API_KEY: z.string().optional(),
|
|
29
|
+
PERPLEXITY_API_KEY: z.string().optional(),
|
|
30
|
+
REMOTION_REGION: z.string().default("us-east-1"),
|
|
31
|
+
REMOTION_BUCKET_NAME: z.string().optional(),
|
|
32
|
+
REMOTION_SITE_NAME: z.string().optional(),
|
|
33
|
+
REMOTION_FUNCTION_NAME: z.string().optional(),
|
|
34
|
+
REMOTION_SERVE_URL: z.string().optional(),
|
|
35
|
+
REMOTION_COMPOSITION_ID: z.string().default("demo-template"),
|
|
36
|
+
REMOTION_AWS_ACCESS_KEY_ID: z.string().optional(),
|
|
37
|
+
REMOTION_AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
|
38
|
+
REMOTION_MODE: z.enum(["auto", "mock", "local", "lambda"]).default("auto"),
|
|
39
|
+
MOCK_PROVIDER_RESPONSES: z.string().default("true")
|
|
40
|
+
});
|
|
41
|
+
const parsed = schema.parse(process.env);
|
|
42
|
+
const dataDir = path.resolve(parsed.VIDFARM_DATA_DIR);
|
|
43
|
+
mkdirSync(dataDir, { recursive: true });
|
|
44
|
+
const publicBaseUrl = parsed.PUBLIC_BASE_URL || `http://localhost:${parsed.PORT}`;
|
|
45
|
+
export const config = {
|
|
46
|
+
...parsed,
|
|
47
|
+
VIDFARM_DB_PATH: path.resolve(parsed.VIDFARM_DB_PATH),
|
|
48
|
+
VIDFARM_DATA_DIR: dataDir,
|
|
49
|
+
PUBLIC_BASE_URL: publicBaseUrl,
|
|
50
|
+
isProduction: parsed.NODE_ENV === "production",
|
|
51
|
+
mockProviders: parsed.MOCK_PROVIDER_RESPONSES !== "false"
|
|
52
|
+
};
|