@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
function deriveKey(secret) {
|
|
3
|
+
return scryptSync(secret, "vidfarm", 32);
|
|
4
|
+
}
|
|
5
|
+
export function hashSecret(value) {
|
|
6
|
+
return createHash("sha256").update(value).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
export function safeEqualHash(raw, hashed) {
|
|
9
|
+
const left = Buffer.from(hashSecret(raw), "hex");
|
|
10
|
+
const right = Buffer.from(hashed, "hex");
|
|
11
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
12
|
+
}
|
|
13
|
+
export function encryptString(value, secret) {
|
|
14
|
+
const iv = randomBytes(12);
|
|
15
|
+
const key = deriveKey(secret);
|
|
16
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
17
|
+
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
18
|
+
const tag = cipher.getAuthTag();
|
|
19
|
+
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
|
20
|
+
}
|
|
21
|
+
export function decryptString(value, secret) {
|
|
22
|
+
const raw = Buffer.from(value, "base64");
|
|
23
|
+
const iv = raw.subarray(0, 12);
|
|
24
|
+
const tag = raw.subarray(12, 28);
|
|
25
|
+
const encrypted = raw.subarray(28);
|
|
26
|
+
const key = deriveKey(secret);
|
|
27
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
28
|
+
decipher.setAuthTag(tag);
|
|
29
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
export async function normalizeToPortraitFrame(input, target = { width: 1080, height: 1920 }) {
|
|
3
|
+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
4
|
+
const output = await sharp(buffer, { density: 144 })
|
|
5
|
+
.rotate()
|
|
6
|
+
.resize(target.width, target.height, {
|
|
7
|
+
fit: "cover",
|
|
8
|
+
position: sharp.strategy.attention
|
|
9
|
+
})
|
|
10
|
+
.png()
|
|
11
|
+
.toBuffer();
|
|
12
|
+
return {
|
|
13
|
+
bytes: output,
|
|
14
|
+
contentType: "image/png",
|
|
15
|
+
width: target.width,
|
|
16
|
+
height: target.height
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { demoTemplate } from "../templates/template_0000/demo-template.js";
|
|
2
|
+
const templates = [demoTemplate];
|
|
3
|
+
export const templateRegistry = {
|
|
4
|
+
list() {
|
|
5
|
+
return templates;
|
|
6
|
+
},
|
|
7
|
+
get(templateId) {
|
|
8
|
+
return templates.find((template) => template.id === templateId) ?? null;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import app from "./app.js";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
import { Worker } from "./worker.js";
|
|
5
|
+
export function startRuntime() {
|
|
6
|
+
const worker = new Worker();
|
|
7
|
+
worker.start();
|
|
8
|
+
const server = serve({
|
|
9
|
+
fetch: app.fetch,
|
|
10
|
+
port: config.PORT
|
|
11
|
+
}, (info) => {
|
|
12
|
+
console.log(`[vidfarm] listening on http://localhost:${info.port}`);
|
|
13
|
+
});
|
|
14
|
+
const shutdown = () => {
|
|
15
|
+
worker.stop();
|
|
16
|
+
server.close();
|
|
17
|
+
};
|
|
18
|
+
return { server, worker, shutdown };
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { database } from "../db.js";
|
|
4
|
+
import { hashSecret, safeEqualHash } from "../lib/crypto.js";
|
|
5
|
+
import { createId } from "../lib/ids.js";
|
|
6
|
+
import { addSeconds, nowIso } from "../lib/time.js";
|
|
7
|
+
export class AuthService {
|
|
8
|
+
async requestOtp(email) {
|
|
9
|
+
const code = `${randomInt(100000, 999999)}`;
|
|
10
|
+
database.insertOtpChallenge({
|
|
11
|
+
id: createId("otp"),
|
|
12
|
+
email,
|
|
13
|
+
codeHash: hashSecret(code),
|
|
14
|
+
expiresAt: addSeconds(new Date(), 600)
|
|
15
|
+
});
|
|
16
|
+
if (config.RESEND_API_KEY) {
|
|
17
|
+
await fetch("https://api.resend.com/emails", {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Authorization: `Bearer ${config.RESEND_API_KEY}`
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
from: config.RESEND_FROM_EMAIL,
|
|
25
|
+
to: email,
|
|
26
|
+
subject: "Your Vidfarm login code",
|
|
27
|
+
text: `Your login code is ${code}. It expires in 10 minutes.`
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(`[vidfarm] OTP for ${email}: ${code}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
verifyOtp(email, code, name) {
|
|
36
|
+
const challenge = database.findLatestOtpChallenge(email);
|
|
37
|
+
if (!challenge) {
|
|
38
|
+
throw new Error("No OTP challenge found for that email.");
|
|
39
|
+
}
|
|
40
|
+
if (challenge.consumed_at) {
|
|
41
|
+
throw new Error("OTP challenge already used.");
|
|
42
|
+
}
|
|
43
|
+
if (new Date(String(challenge.expires_at)).getTime() < Date.now()) {
|
|
44
|
+
throw new Error("OTP challenge expired.");
|
|
45
|
+
}
|
|
46
|
+
if (!safeEqualHash(code, String(challenge.code_hash))) {
|
|
47
|
+
throw new Error("Invalid OTP code.");
|
|
48
|
+
}
|
|
49
|
+
database.consumeOtpChallenge(String(challenge.id));
|
|
50
|
+
const customer = database.getCustomerByEmail(email) ?? database.upsertCustomer({
|
|
51
|
+
id: createId("cus"),
|
|
52
|
+
email,
|
|
53
|
+
name: name ?? null
|
|
54
|
+
});
|
|
55
|
+
const rawApiKey = `vf_${createId("key")}`;
|
|
56
|
+
database.insertApiKey({
|
|
57
|
+
id: createId("api"),
|
|
58
|
+
customerId: customer.id,
|
|
59
|
+
keyHash: hashSecret(rawApiKey + config.API_KEY_SALT),
|
|
60
|
+
label: `Issued ${nowIso()}`
|
|
61
|
+
});
|
|
62
|
+
return { customer, apiKey: rawApiKey };
|
|
63
|
+
}
|
|
64
|
+
authenticate(userId, apiKey) {
|
|
65
|
+
if (!userId || !apiKey) {
|
|
66
|
+
throw new Error("Missing vidfarm-user-id or vidfarm-api-key header.");
|
|
67
|
+
}
|
|
68
|
+
const row = database.findApiKeyByHash(hashSecret(apiKey + config.API_KEY_SALT));
|
|
69
|
+
if (!row || String(row.customer_id) !== userId) {
|
|
70
|
+
throw new Error("Invalid API credentials.");
|
|
71
|
+
}
|
|
72
|
+
database.touchApiKey(String(row.id));
|
|
73
|
+
return {
|
|
74
|
+
id: String(row.customer_id),
|
|
75
|
+
email: String(row.email),
|
|
76
|
+
name: row.name ? String(row.name) : null,
|
|
77
|
+
defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { database } from "../db.js";
|
|
2
|
+
import { createId } from "../lib/ids.js";
|
|
3
|
+
export class BillingService {
|
|
4
|
+
async record(input) {
|
|
5
|
+
database.insertBillingEvent({
|
|
6
|
+
id: createId("bill"),
|
|
7
|
+
customerId: input.customerId,
|
|
8
|
+
jobId: input.jobId,
|
|
9
|
+
templateId: input.templateId,
|
|
10
|
+
type: input.type,
|
|
11
|
+
costUsd: input.costUsd,
|
|
12
|
+
chargeUsd: input.chargeUsd,
|
|
13
|
+
metadata: input.metadata
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { database } from "../db.js";
|
|
3
|
+
import { createId } from "../lib/ids.js";
|
|
4
|
+
import { addSeconds, nowIso } from "../lib/time.js";
|
|
5
|
+
export class JobsService {
|
|
6
|
+
createRootJob(input) {
|
|
7
|
+
return database.createJob({
|
|
8
|
+
id: createId("job"),
|
|
9
|
+
templateId: input.templateId,
|
|
10
|
+
operationName: input.operationName,
|
|
11
|
+
workflowName: input.workflowName,
|
|
12
|
+
tracer: input.tracer,
|
|
13
|
+
status: "queued",
|
|
14
|
+
customerId: input.customer.id,
|
|
15
|
+
payload: {
|
|
16
|
+
...input.payload,
|
|
17
|
+
_providerHint: input.providerHint ?? null
|
|
18
|
+
},
|
|
19
|
+
progress: 0,
|
|
20
|
+
webhookUrl: input.webhookUrl ?? input.customer.defaultWebhookUrl ?? null,
|
|
21
|
+
parentJobId: null,
|
|
22
|
+
priority: 100,
|
|
23
|
+
attemptCount: 0,
|
|
24
|
+
maxAttempts: 6,
|
|
25
|
+
runAfter: nowIso()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async enqueueChildJob(input) {
|
|
29
|
+
return database.createJob({
|
|
30
|
+
id: createId("job"),
|
|
31
|
+
templateId: input.templateId,
|
|
32
|
+
operationName: input.operationName,
|
|
33
|
+
workflowName: input.workflowName,
|
|
34
|
+
tracer: input.tracer,
|
|
35
|
+
status: "queued",
|
|
36
|
+
customerId: input.customerId,
|
|
37
|
+
payload: {
|
|
38
|
+
...input.payload,
|
|
39
|
+
_providerHint: input.providerHint ?? null
|
|
40
|
+
},
|
|
41
|
+
progress: 0,
|
|
42
|
+
webhookUrl: null,
|
|
43
|
+
parentJobId: input.parentJobId,
|
|
44
|
+
priority: 110,
|
|
45
|
+
attemptCount: 0,
|
|
46
|
+
maxAttempts: 6,
|
|
47
|
+
runAfter: nowIso()
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
getJob(jobId) {
|
|
51
|
+
return database.getJob(jobId);
|
|
52
|
+
}
|
|
53
|
+
listJobs(customerId, templateId) {
|
|
54
|
+
return database.listJobsForCustomer(customerId, templateId);
|
|
55
|
+
}
|
|
56
|
+
listLogs(jobId, since, limit) {
|
|
57
|
+
return database.getJobEvents(jobId, since, limit);
|
|
58
|
+
}
|
|
59
|
+
cancelJob(jobId) {
|
|
60
|
+
database.updateJobStatus({
|
|
61
|
+
id: jobId,
|
|
62
|
+
status: "cancelled",
|
|
63
|
+
completedAt: nowIso(),
|
|
64
|
+
error: { message: "Cancelled by user request." }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
markRunning(jobId) {
|
|
68
|
+
database.markJobRunning(jobId);
|
|
69
|
+
}
|
|
70
|
+
succeedJob(jobId, result, progress = 1) {
|
|
71
|
+
database.updateJobStatus({
|
|
72
|
+
id: jobId,
|
|
73
|
+
status: "succeeded",
|
|
74
|
+
progress,
|
|
75
|
+
result,
|
|
76
|
+
completedAt: nowIso()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
failJob(jobId, error) {
|
|
80
|
+
database.updateJobStatus({
|
|
81
|
+
id: jobId,
|
|
82
|
+
status: "failed",
|
|
83
|
+
error,
|
|
84
|
+
completedAt: nowIso()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
requeueJob(job, delaySeconds = config.DEFAULT_JOB_DELAY_SECONDS, error) {
|
|
88
|
+
const nextStatus = job.attemptCount + 1 >= job.maxAttempts ? "failed" : "queued";
|
|
89
|
+
database.updateJobStatus({
|
|
90
|
+
id: job.id,
|
|
91
|
+
status: nextStatus,
|
|
92
|
+
error,
|
|
93
|
+
runAfter: addSeconds(new Date(), delaySeconds),
|
|
94
|
+
completedAt: nextStatus === "failed" ? nowIso() : null
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|