@mevdragon/vidfarm-devcli 0.1.0 → 0.2.1
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 +12 -5
- package/PLATFORM_SPEC.md +143 -2
- package/README.md +165 -16
- package/SKILL.developer.md +258 -0
- package/SKILL.director.md +599 -0
- package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
- package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
- package/dist/src/account-pages.js +630 -0
- package/dist/src/app.js +897 -66
- package/dist/src/cli.js +284 -5
- package/dist/src/config.js +25 -5
- package/dist/src/context.js +1 -1
- package/dist/src/db.js +427 -18
- package/dist/src/dev-app.js +59 -12
- package/dist/src/homepage.js +441 -0
- package/dist/src/index.js +12 -7
- package/dist/src/lib/crypto.js +14 -0
- package/dist/src/lib/template-dna.js +542 -0
- package/dist/src/lib/template-style-options.js +49 -0
- package/dist/src/registry.js +54 -7
- package/dist/src/runtime.js +3 -1
- package/dist/src/services/auth.js +69 -5
- package/dist/src/services/jobs.js +23 -4
- package/dist/src/services/providers.js +74 -12
- package/dist/src/services/storage.js +74 -18
- package/dist/src/services/template-certification.js +160 -0
- package/dist/src/services/template-loader.js +37 -0
- package/dist/src/services/template-sources.js +135 -0
- package/dist/src/worker.js +19 -7
- package/dist/templates/template_0000/src/lib/images.js +242 -0
- package/dist/templates/template_0000/src/remotion/Root.js +33 -0
- package/dist/templates/template_0000/src/sdk.js +3 -0
- package/dist/templates/template_0000/src/style-options.js +51 -0
- package/dist/templates/template_0000/src/template-dna.js +9 -0
- package/dist/templates/template_0000/src/template.js +1217 -0
- package/package.json +10 -1
- package/templates/template_0000/README.md +121 -0
- package/templates/template_0000/SKILL.md +193 -0
- package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/template_0000/composition.json +11 -0
- package/templates/template_0000/package-lock.json +5505 -0
- package/templates/template_0000/package.json +31 -0
- package/templates/template_0000/research/preview/.gitkeep +1 -0
- package/templates/template_0000/research/source_notes.md +7 -0
- package/templates/template_0000/scripts/create-site.mjs +27 -0
- package/templates/template_0000/scripts/render-cloud.mjs +72 -0
- package/templates/template_0000/src/lib/images.js +242 -0
- package/templates/template_0000/src/lib/images.ts +284 -0
- package/templates/template_0000/src/remotion/Root.js +33 -0
- package/templates/template_0000/src/remotion/Root.tsx +75 -0
- package/templates/template_0000/src/remotion/index.js +3 -0
- package/templates/template_0000/src/remotion/index.tsx +4 -0
- package/templates/template_0000/src/sdk.js +3 -0
- package/templates/template_0000/src/sdk.ts +122 -0
- package/templates/template_0000/src/style-options.js +51 -0
- package/templates/template_0000/src/style-options.ts +60 -0
- package/templates/template_0000/src/template-dna.ts +15 -0
- package/templates/template_0000/src/template.js +1117 -0
- package/templates/template_0000/src/template.ts +1747 -0
- package/templates/template_0000/template.config.json +26 -0
- package/templates/template_0000/tsconfig.json +19 -0
- package/dist/templates/template_0000/demo-template.js +0 -196
- package/dist/templates/template_0000/remotion/Root.js +0 -66
- /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomInt } from "node:crypto";
|
|
2
2
|
import { config } from "../config.js";
|
|
3
3
|
import { database } from "../db.js";
|
|
4
|
-
import { hashSecret, safeEqualHash } from "../lib/crypto.js";
|
|
4
|
+
import { hashPassword, hashSecret, safeEqualHash, verifyPassword } from "../lib/crypto.js";
|
|
5
5
|
import { createId } from "../lib/ids.js";
|
|
6
6
|
import { addSeconds, nowIso } from "../lib/time.js";
|
|
7
7
|
export class AuthService {
|
|
@@ -47,16 +47,24 @@ export class AuthService {
|
|
|
47
47
|
throw new Error("Invalid OTP code.");
|
|
48
48
|
}
|
|
49
49
|
database.consumeOtpChallenge(String(challenge.id));
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const existing = database.getCustomerByEmail(email);
|
|
51
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
52
|
+
const customer = database.upsertCustomer({
|
|
53
|
+
id: existing?.id ?? createId("cus"),
|
|
52
54
|
email,
|
|
53
|
-
name: name ?? null
|
|
55
|
+
name: name ?? existing?.name ?? null,
|
|
56
|
+
defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
|
|
57
|
+
isDeveloper: existing?.isDeveloper
|
|
58
|
+
|| config.adminEmails.includes(normalizedEmail)
|
|
59
|
+
|| config.developerEmails.includes(normalizedEmail),
|
|
60
|
+
isPaidPlan: existing?.isPaidPlan ?? false
|
|
54
61
|
});
|
|
55
62
|
const rawApiKey = `vf_${createId("key")}`;
|
|
56
63
|
database.insertApiKey({
|
|
57
64
|
id: createId("api"),
|
|
58
65
|
customerId: customer.id,
|
|
59
66
|
keyHash: hashSecret(rawApiKey + config.API_KEY_SALT),
|
|
67
|
+
rawValue: rawApiKey,
|
|
60
68
|
label: `Issued ${nowIso()}`
|
|
61
69
|
});
|
|
62
70
|
return { customer, apiKey: rawApiKey };
|
|
@@ -69,12 +77,68 @@ export class AuthService {
|
|
|
69
77
|
if (!row || String(row.customer_id) !== userId) {
|
|
70
78
|
throw new Error("Invalid API credentials.");
|
|
71
79
|
}
|
|
80
|
+
if (!Boolean(row.is_paid_plan)) {
|
|
81
|
+
throw new Error("Paid plan required.");
|
|
82
|
+
}
|
|
72
83
|
database.touchApiKey(String(row.id));
|
|
73
84
|
return {
|
|
74
85
|
id: String(row.customer_id),
|
|
75
86
|
email: String(row.email),
|
|
76
87
|
name: row.name ? String(row.name) : null,
|
|
77
|
-
defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null
|
|
88
|
+
defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null,
|
|
89
|
+
isDeveloper: Boolean(row.is_developer),
|
|
90
|
+
isPaidPlan: Boolean(row.is_paid_plan),
|
|
91
|
+
about: row.about ? String(row.about) : null,
|
|
92
|
+
groupchatUrl: row.groupchat_url ? String(row.groupchat_url) : null,
|
|
93
|
+
flockposterApiKey: row.flockposter_api_key ? String(row.flockposter_api_key) : null
|
|
78
94
|
};
|
|
79
95
|
}
|
|
96
|
+
verifyOtpForBrowserLogin(email, code, name) {
|
|
97
|
+
const result = this.verifyOtp(email, code, name);
|
|
98
|
+
if (!result.customer.isPaidPlan) {
|
|
99
|
+
return {
|
|
100
|
+
status: "pricing",
|
|
101
|
+
customer: result.customer
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
status: "authenticated",
|
|
106
|
+
customer: result.customer
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
authenticateWithPassword(email, password) {
|
|
110
|
+
const row = database.getCustomerAuthByEmail(email);
|
|
111
|
+
if (!row || !row.password_hash || !verifyPassword(password, String(row.password_hash))) {
|
|
112
|
+
throw new Error("Invalid email or password.");
|
|
113
|
+
}
|
|
114
|
+
const customer = database.getCustomerById(String(row.id));
|
|
115
|
+
if (!customer) {
|
|
116
|
+
throw new Error("Customer not found.");
|
|
117
|
+
}
|
|
118
|
+
if (!customer.isPaidPlan) {
|
|
119
|
+
return {
|
|
120
|
+
status: "pricing",
|
|
121
|
+
customer
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
status: "authenticated",
|
|
126
|
+
customer
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
createPasswordUser(input) {
|
|
130
|
+
const normalizedEmail = input.email.trim().toLowerCase();
|
|
131
|
+
const existing = database.getCustomerByEmail(normalizedEmail);
|
|
132
|
+
return database.upsertCustomer({
|
|
133
|
+
id: existing?.id ?? createId("cus"),
|
|
134
|
+
email: normalizedEmail,
|
|
135
|
+
name: input.name ?? existing?.name ?? null,
|
|
136
|
+
defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
|
|
137
|
+
isDeveloper: existing?.isDeveloper
|
|
138
|
+
|| config.adminEmails.includes(normalizedEmail)
|
|
139
|
+
|| config.developerEmails.includes(normalizedEmail),
|
|
140
|
+
isPaidPlan: true,
|
|
141
|
+
passwordHash: hashPassword(input.password)
|
|
142
|
+
});
|
|
143
|
+
}
|
|
80
144
|
}
|
|
@@ -2,8 +2,26 @@ import { config } from "../config.js";
|
|
|
2
2
|
import { database } from "../db.js";
|
|
3
3
|
import { createId } from "../lib/ids.js";
|
|
4
4
|
import { addSeconds, nowIso } from "../lib/time.js";
|
|
5
|
+
const PENDING_JOB_STATUSES = ["queued", "running", "waiting_for_child", "waiting_for_human"];
|
|
5
6
|
export class JobsService {
|
|
7
|
+
assertQueueCapacity(customerId) {
|
|
8
|
+
if (config.MAX_PENDING_JOBS_GLOBAL <= 0 && config.MAX_PENDING_JOBS_PER_CUSTOMER <= 0) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const globalPending = database.countJobsByStatuses({ statuses: PENDING_JOB_STATUSES });
|
|
12
|
+
if (config.MAX_PENDING_JOBS_GLOBAL > 0 && globalPending >= config.MAX_PENDING_JOBS_GLOBAL) {
|
|
13
|
+
throw new Error(`Vidfarm queue is full. Try again later. Active backlog: ${globalPending}.`);
|
|
14
|
+
}
|
|
15
|
+
const customerPending = database.countJobsByStatuses({
|
|
16
|
+
statuses: PENDING_JOB_STATUSES,
|
|
17
|
+
customerId
|
|
18
|
+
});
|
|
19
|
+
if (config.MAX_PENDING_JOBS_PER_CUSTOMER > 0 && customerPending >= config.MAX_PENDING_JOBS_PER_CUSTOMER) {
|
|
20
|
+
throw new Error(`Customer queue limit reached. Reduce outstanding jobs before submitting more.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
6
23
|
createRootJob(input) {
|
|
24
|
+
this.assertQueueCapacity(input.customer.id);
|
|
7
25
|
return database.createJob({
|
|
8
26
|
id: createId("job"),
|
|
9
27
|
templateId: input.templateId,
|
|
@@ -26,6 +44,7 @@ export class JobsService {
|
|
|
26
44
|
});
|
|
27
45
|
}
|
|
28
46
|
async enqueueChildJob(input) {
|
|
47
|
+
this.assertQueueCapacity(input.customerId);
|
|
29
48
|
return database.createJob({
|
|
30
49
|
id: createId("job"),
|
|
31
50
|
templateId: input.templateId,
|
|
@@ -50,11 +69,11 @@ export class JobsService {
|
|
|
50
69
|
getJob(jobId) {
|
|
51
70
|
return database.getJob(jobId);
|
|
52
71
|
}
|
|
53
|
-
listJobs(
|
|
54
|
-
return database.listJobsForCustomer(
|
|
72
|
+
listJobs(input) {
|
|
73
|
+
return database.listJobsForCustomer(input);
|
|
55
74
|
}
|
|
56
|
-
listLogs(
|
|
57
|
-
return database.getJobEvents(
|
|
75
|
+
listLogs(input) {
|
|
76
|
+
return database.getJobEvents(input);
|
|
58
77
|
}
|
|
59
78
|
cancelJob(jobId) {
|
|
60
79
|
database.updateJobStatus({
|
|
@@ -15,6 +15,14 @@ export class ProviderRateLimitError extends Error {
|
|
|
15
15
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
function readStoredProviderSecret(value) {
|
|
19
|
+
try {
|
|
20
|
+
return decryptString(value, config.ENCRYPTION_SECRET);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
18
26
|
export class ProviderService {
|
|
19
27
|
async leaseCustomerKey(input) {
|
|
20
28
|
const leaseToken = createId("lease");
|
|
@@ -33,7 +41,7 @@ export class ProviderService {
|
|
|
33
41
|
keyId: row.keyId,
|
|
34
42
|
leaseToken,
|
|
35
43
|
provider: input.provider,
|
|
36
|
-
secret:
|
|
44
|
+
secret: readStoredProviderSecret(row.encryptedSecret)
|
|
37
45
|
};
|
|
38
46
|
}
|
|
39
47
|
async releaseLease(lease) {
|
|
@@ -154,11 +162,15 @@ export class ProviderService {
|
|
|
154
162
|
? await this.callGeminiImageGeneration({
|
|
155
163
|
model: input.model,
|
|
156
164
|
prompt: input.prompt,
|
|
165
|
+
promptAttachments: input.promptAttachments,
|
|
166
|
+
aspectRatio: input.aspectRatio,
|
|
167
|
+
imageSize: input.imageSize,
|
|
157
168
|
apiKey: lease.secret
|
|
158
169
|
})
|
|
159
170
|
: await this.callOpenAIImageGeneration({
|
|
160
171
|
model: input.model,
|
|
161
172
|
prompt: input.prompt,
|
|
173
|
+
promptAttachments: input.promptAttachments,
|
|
162
174
|
size: input.size,
|
|
163
175
|
apiKey: lease.secret
|
|
164
176
|
});
|
|
@@ -217,7 +229,8 @@ export class ProviderService {
|
|
|
217
229
|
overlayText: input.overlayText,
|
|
218
230
|
apiKey: lease.secret
|
|
219
231
|
})
|
|
220
|
-
: await this.
|
|
232
|
+
: await this.callOpenAICompatibleLayoutAnalysis({
|
|
233
|
+
provider: input.provider === "openrouter" ? "openrouter" : "openai",
|
|
221
234
|
model: input.model,
|
|
222
235
|
imageUrl: input.imageUrl,
|
|
223
236
|
overlayText: input.overlayText,
|
|
@@ -252,6 +265,9 @@ export class ProviderService {
|
|
|
252
265
|
}
|
|
253
266
|
}
|
|
254
267
|
async callOpenAIImageGeneration(input) {
|
|
268
|
+
if (input.promptAttachments?.length) {
|
|
269
|
+
throw new Error("OpenAI image generation attachments are not implemented in this provider path. Use Gemini for reference attachments.");
|
|
270
|
+
}
|
|
255
271
|
const response = await fetch("https://api.openai.com/v1/images/generations", {
|
|
256
272
|
method: "POST",
|
|
257
273
|
headers: {
|
|
@@ -286,15 +302,27 @@ export class ProviderService {
|
|
|
286
302
|
};
|
|
287
303
|
}
|
|
288
304
|
async callGeminiImageGeneration(input) {
|
|
305
|
+
const imageConfig = {};
|
|
306
|
+
if (input.aspectRatio) {
|
|
307
|
+
imageConfig.aspectRatio = input.aspectRatio;
|
|
308
|
+
}
|
|
309
|
+
if (input.imageSize && supportsGeminiImageSize(input.model)) {
|
|
310
|
+
imageConfig.imageSize = input.imageSize;
|
|
311
|
+
}
|
|
289
312
|
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
290
313
|
method: "POST",
|
|
291
314
|
headers: {
|
|
292
315
|
"Content-Type": "application/json"
|
|
293
316
|
},
|
|
294
317
|
body: JSON.stringify({
|
|
295
|
-
contents: [{ parts:
|
|
318
|
+
contents: [{ parts: await buildGeminiPromptParts(input.prompt, input.promptAttachments ?? []) }],
|
|
296
319
|
generationConfig: {
|
|
297
|
-
responseModalities: ["TEXT", "IMAGE"]
|
|
320
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
321
|
+
...(Object.keys(imageConfig).length
|
|
322
|
+
? {
|
|
323
|
+
imageConfig
|
|
324
|
+
}
|
|
325
|
+
: {})
|
|
298
326
|
}
|
|
299
327
|
})
|
|
300
328
|
});
|
|
@@ -319,8 +347,11 @@ export class ProviderService {
|
|
|
319
347
|
revisedPrompt: null
|
|
320
348
|
};
|
|
321
349
|
}
|
|
322
|
-
async
|
|
323
|
-
const
|
|
350
|
+
async callOpenAICompatibleLayoutAnalysis(input) {
|
|
351
|
+
const endpoint = input.provider === "openrouter"
|
|
352
|
+
? "https://openrouter.ai/api/v1/chat/completions"
|
|
353
|
+
: "https://api.openai.com/v1/chat/completions";
|
|
354
|
+
const response = await fetch(endpoint, {
|
|
324
355
|
method: "POST",
|
|
325
356
|
headers: {
|
|
326
357
|
"Content-Type": "application/json",
|
|
@@ -333,14 +364,14 @@ export class ProviderService {
|
|
|
333
364
|
messages: [
|
|
334
365
|
{
|
|
335
366
|
role: "system",
|
|
336
|
-
content: "Analyze a 9:16 slideshow image and return JSON with zone, align, maxWidthPercent, justification. Prefer
|
|
367
|
+
content: "Analyze a 9:16 slideshow image and return JSON with zone, align, maxWidthPercent, justification. Prefer centered text placement in the middle third of the frame unless that would cover the subject. Avoid covering the subject, avoid fake caption panels, and keep the caption inside a TikTok-safe region that avoids the top tabs, right-side action rail, and bottom caption/audio UI."
|
|
337
368
|
},
|
|
338
369
|
{
|
|
339
370
|
role: "user",
|
|
340
371
|
content: [
|
|
341
372
|
{
|
|
342
373
|
type: "text",
|
|
343
|
-
text: `The exact overlay text is: ${input.overlayText}\nChoose zone from top, center, bottom. Choose align from left, center, right. maxWidthPercent must be between
|
|
374
|
+
text: `The exact overlay text is: ${input.overlayText}\nChoose zone from top, center, bottom. Choose align from left, center, right. Prefer center when viable. maxWidthPercent must be between 46 and 62.\nTreat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.\nThe visual goal is native TikTok caption placement, not poster typography.`
|
|
344
375
|
},
|
|
345
376
|
{
|
|
346
377
|
type: "image_url",
|
|
@@ -354,13 +385,13 @@ export class ProviderService {
|
|
|
354
385
|
})
|
|
355
386
|
});
|
|
356
387
|
if (response.status === 401) {
|
|
357
|
-
throw new ProviderAuthError(
|
|
388
|
+
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
358
389
|
}
|
|
359
390
|
if (response.status === 429) {
|
|
360
|
-
throw new ProviderRateLimitError(
|
|
391
|
+
throw new ProviderRateLimitError(`${input.provider} rate limited`);
|
|
361
392
|
}
|
|
362
393
|
if (!response.ok) {
|
|
363
|
-
throw new Error(
|
|
394
|
+
throw new Error(`${input.provider} layout analysis returned ${response.status}`);
|
|
364
395
|
}
|
|
365
396
|
const data = await response.json();
|
|
366
397
|
return String(data.choices?.[0]?.message?.content ?? "");
|
|
@@ -385,8 +416,11 @@ export class ProviderService {
|
|
|
385
416
|
"Analyze this 9:16 slideshow image and return JSON with keys zone, align, maxWidthPercent, justification.",
|
|
386
417
|
"zone must be one of top, center, bottom.",
|
|
387
418
|
"align must be one of left, center, right.",
|
|
388
|
-
"
|
|
419
|
+
"Prefer centered text placement in the middle third unless that would cover the subject.",
|
|
420
|
+
"maxWidthPercent must be between 46 and 62.",
|
|
389
421
|
"Prefer negative space and avoid covering the subject.",
|
|
422
|
+
"Treat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.",
|
|
423
|
+
"The visual goal is native TikTok caption placement, not poster typography or large title cards.",
|
|
390
424
|
`Exact overlay text: ${input.overlayText}`
|
|
391
425
|
].join("\n")
|
|
392
426
|
},
|
|
@@ -501,6 +535,34 @@ export class ProviderService {
|
|
|
501
535
|
function clamp(value, min, max) {
|
|
502
536
|
return Math.max(min, Math.min(max, value));
|
|
503
537
|
}
|
|
538
|
+
function supportsGeminiImageSize(model) {
|
|
539
|
+
return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
|
|
540
|
+
}
|
|
541
|
+
async function buildGeminiPromptParts(prompt, attachments) {
|
|
542
|
+
const parts = [];
|
|
543
|
+
for (const attachment of attachments) {
|
|
544
|
+
if (!/^https?:\/\//i.test(attachment)) {
|
|
545
|
+
throw new Error(`Gemini prompt attachments must be public URLs: ${attachment}`);
|
|
546
|
+
}
|
|
547
|
+
const response = await fetch(attachment);
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
throw new Error(`Could not fetch Gemini prompt attachment ${attachment}: ${response.status}`);
|
|
550
|
+
}
|
|
551
|
+
const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim();
|
|
552
|
+
if (!mimeType) {
|
|
553
|
+
throw new Error(`Gemini prompt attachment did not return a content type: ${attachment}`);
|
|
554
|
+
}
|
|
555
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
556
|
+
parts.push({
|
|
557
|
+
inlineData: {
|
|
558
|
+
mimeType,
|
|
559
|
+
data: bytes.toString("base64")
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
parts.push({ text: prompt });
|
|
564
|
+
return parts;
|
|
565
|
+
}
|
|
504
566
|
function buildMockSlideSvg(prompt) {
|
|
505
567
|
const escaped = escapeXml(prompt.slice(0, 220));
|
|
506
568
|
return `
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
4
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
export class StorageService {
|
|
@@ -14,22 +14,39 @@ export class StorageService {
|
|
|
14
14
|
constructor() {
|
|
15
15
|
mkdirSync(this.localRoot, { recursive: true });
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
userAttachmentKey(customerId, attachmentId, fileName) {
|
|
18
|
+
return joinStorageKey("user", customerId, "attachments", attachmentId, fileName);
|
|
19
|
+
}
|
|
20
|
+
developerAttachmentKey(customerId, attachmentId, fileName) {
|
|
21
|
+
return joinStorageKey("developer", customerId, "attachments", attachmentId, fileName);
|
|
22
|
+
}
|
|
23
|
+
templateJobPrefix(templateId, customerId, jobId) {
|
|
24
|
+
return joinStorageKey("templates", templateId, "users", customerId, "jobs", jobId);
|
|
25
|
+
}
|
|
26
|
+
templateJobKey(templateId, customerId, jobId, key) {
|
|
27
|
+
return joinStorageKey(this.templateJobPrefix(templateId, customerId, jobId), key);
|
|
28
|
+
}
|
|
29
|
+
templateAboutKey(templateId, assetPath) {
|
|
30
|
+
return joinStorageKey("templates", templateId, "about", assetPath);
|
|
31
|
+
}
|
|
32
|
+
async putJson(key, value, options) {
|
|
18
33
|
const body = JSON.stringify(value, null, 2);
|
|
19
|
-
return this.putBuffer(key, Buffer.from(body, "utf8"), "application/json");
|
|
34
|
+
return this.putBuffer(key, Buffer.from(body, "utf8"), "application/json", options);
|
|
20
35
|
}
|
|
21
|
-
async putText(key, value, contentType = "text/plain; charset=utf-8") {
|
|
22
|
-
return this.putBuffer(key, Buffer.from(value, "utf8"), contentType);
|
|
36
|
+
async putText(key, value, contentType = "text/plain; charset=utf-8", options) {
|
|
37
|
+
return this.putBuffer(key, Buffer.from(value, "utf8"), contentType, options);
|
|
23
38
|
}
|
|
24
|
-
async putBuffer(key, value, contentType = "application/octet-stream") {
|
|
39
|
+
async putBuffer(key, value, contentType = "application/octet-stream", options) {
|
|
40
|
+
const publicRead = options?.publicRead ?? config.s3PublicRead;
|
|
25
41
|
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
26
42
|
await this.s3.send(new PutObjectCommand({
|
|
27
43
|
Bucket: config.AWS_S3_BUCKET,
|
|
28
44
|
Key: key,
|
|
29
45
|
Body: value,
|
|
30
|
-
ContentType: contentType
|
|
46
|
+
ContentType: contentType,
|
|
47
|
+
ACL: publicRead ? "public-read" : undefined
|
|
31
48
|
}));
|
|
32
|
-
return { key, url: this.getPublicUrl(key) };
|
|
49
|
+
return { key, url: publicRead ? this.getPublicUrl(key) : await this.createReadUrl(key) };
|
|
33
50
|
}
|
|
34
51
|
const filePath = path.join(this.localRoot, key);
|
|
35
52
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -57,34 +74,73 @@ export class StorageService {
|
|
|
57
74
|
contentType: inferContentType(filePath)
|
|
58
75
|
};
|
|
59
76
|
}
|
|
60
|
-
async
|
|
61
|
-
|
|
77
|
+
async getReadUrl(key) {
|
|
78
|
+
return this.createReadUrl(key);
|
|
79
|
+
}
|
|
80
|
+
async deleteObject(key) {
|
|
62
81
|
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
63
|
-
|
|
82
|
+
await this.s3.send(new DeleteObjectCommand({
|
|
64
83
|
Bucket: config.AWS_S3_BUCKET,
|
|
65
|
-
Key: key
|
|
66
|
-
|
|
84
|
+
Key: key
|
|
85
|
+
}));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
rmSync(path.join(this.localRoot, key), { force: true });
|
|
89
|
+
}
|
|
90
|
+
async createReadUrl(key) {
|
|
91
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
92
|
+
if (config.s3PublicRead) {
|
|
93
|
+
return this.getPublicUrl(key);
|
|
94
|
+
}
|
|
95
|
+
const command = new GetObjectCommand({
|
|
96
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
97
|
+
Key: key
|
|
67
98
|
});
|
|
68
|
-
|
|
69
|
-
return { key, url, method: "PUT" };
|
|
99
|
+
return getSignedUrl(this.s3, command, { expiresIn: 86400 });
|
|
70
100
|
}
|
|
71
|
-
|
|
72
|
-
return { key, url, method: "PUT" };
|
|
101
|
+
return this.getPublicUrl(key);
|
|
73
102
|
}
|
|
74
103
|
}
|
|
104
|
+
function joinStorageKey(...parts) {
|
|
105
|
+
return parts
|
|
106
|
+
.flatMap((part) => part.split("/"))
|
|
107
|
+
.map((part) => part.trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join("/");
|
|
110
|
+
}
|
|
75
111
|
function inferContentType(filePath) {
|
|
76
112
|
switch (path.extname(filePath).toLowerCase()) {
|
|
77
113
|
case ".json":
|
|
78
114
|
return "application/json";
|
|
79
115
|
case ".png":
|
|
80
116
|
return "image/png";
|
|
117
|
+
case ".gif":
|
|
118
|
+
return "image/gif";
|
|
81
119
|
case ".jpg":
|
|
82
120
|
case ".jpeg":
|
|
83
121
|
return "image/jpeg";
|
|
122
|
+
case ".webp":
|
|
123
|
+
return "image/webp";
|
|
84
124
|
case ".svg":
|
|
85
125
|
return "image/svg+xml";
|
|
86
126
|
case ".mp4":
|
|
87
127
|
return "video/mp4";
|
|
128
|
+
case ".webm":
|
|
129
|
+
return "video/webm";
|
|
130
|
+
case ".mov":
|
|
131
|
+
return "video/quicktime";
|
|
132
|
+
case ".mp3":
|
|
133
|
+
return "audio/mpeg";
|
|
134
|
+
case ".wav":
|
|
135
|
+
return "audio/wav";
|
|
136
|
+
case ".m4a":
|
|
137
|
+
return "audio/mp4";
|
|
138
|
+
case ".ogg":
|
|
139
|
+
return "audio/ogg";
|
|
140
|
+
case ".pdf":
|
|
141
|
+
return "application/pdf";
|
|
142
|
+
case ".md":
|
|
143
|
+
return "text/markdown; charset=utf-8";
|
|
88
144
|
case ".txt":
|
|
89
145
|
return "text/plain; charset=utf-8";
|
|
90
146
|
default:
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
export class TemplateCertificationService {
|
|
3
|
+
async certify(input) {
|
|
4
|
+
const checks = [];
|
|
5
|
+
const { template, skillPath } = input;
|
|
6
|
+
checks.push(this.check("template.metadata", Boolean(template.id &&
|
|
7
|
+
template.slugId &&
|
|
8
|
+
template.version &&
|
|
9
|
+
template.about &&
|
|
10
|
+
typeof template.about.title === "string" &&
|
|
11
|
+
typeof template.about.description === "string" &&
|
|
12
|
+
typeof template.about.viral_dna === "string" &&
|
|
13
|
+
typeof template.about.visual_dna === "string" &&
|
|
14
|
+
Array.isArray(template.about.preview_media) &&
|
|
15
|
+
template.about.preview_media.every((entry) => typeof entry === "string") &&
|
|
16
|
+
typeof template.about.link_to_original === "string"), "Template must define id, slugId, version, and complete about metadata.", {
|
|
17
|
+
templateId: template.id,
|
|
18
|
+
slugId: template.slugId,
|
|
19
|
+
version: template.version,
|
|
20
|
+
previewMediaCount: template.about?.preview_media?.length ?? 0
|
|
21
|
+
}));
|
|
22
|
+
checks.push(this.check("template.operations", Object.keys(template.operations).length > 0, "Template must define at least one operation."));
|
|
23
|
+
checks.push(this.checkSkill(skillPath));
|
|
24
|
+
for (const [operationName, operation] of Object.entries(template.operations)) {
|
|
25
|
+
checks.push(this.check(`operation.${operationName}.workflow`, typeof template.jobs[operation.workflow] === "function", `Operation ${operationName} must reference an existing workflow.`, { workflow: operation.workflow }));
|
|
26
|
+
checks.push(this.check(`operation.${operationName}.smoke_payload`, Boolean(operation.smokeTestPayload), `Operation ${operationName} must define smokeTestPayload for certification.`));
|
|
27
|
+
if (!operation.smokeTestPayload) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const payload = operation.inputSchema.parse(operation.smokeTestPayload);
|
|
32
|
+
const workflow = template.jobs[operation.workflow];
|
|
33
|
+
const smoke = createSmokeContext(template.id);
|
|
34
|
+
const result = await workflow(smoke.ctx, payload);
|
|
35
|
+
checks.push(this.check(`operation.${operationName}.smoke_run`, Boolean(result && (result.output || result.progress !== undefined)), `Operation ${operationName} smoke run completed.`, {
|
|
36
|
+
logCount: smoke.logCount,
|
|
37
|
+
storageWrites: smoke.storageWrites,
|
|
38
|
+
billingCalls: smoke.billingCalls,
|
|
39
|
+
providerCalls: smoke.providerCalls,
|
|
40
|
+
remotionCalls: smoke.remotionCalls
|
|
41
|
+
}));
|
|
42
|
+
checks.push(this.check(`operation.${operationName}.logs`, smoke.logCount > 0, `Operation ${operationName} must emit at least one log/progress event during smoke run.`));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
checks.push(this.check(`operation.${operationName}.smoke_run`, false, error instanceof Error ? error.message : "Smoke run failed."));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
passed: checks.every((check) => check.ok),
|
|
50
|
+
checks
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
check(code, ok, message, details) {
|
|
54
|
+
return { code, ok, message, details };
|
|
55
|
+
}
|
|
56
|
+
checkSkill(skillPath) {
|
|
57
|
+
try {
|
|
58
|
+
const contents = readFileSync(skillPath, "utf8").trim();
|
|
59
|
+
return this.check("template.skill", contents.length >= 40, "Template must include a non-trivial SKILL.md for customer agents.", { skillPath });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return this.check("template.skill", false, error instanceof Error ? error.message : "Unable to read SKILL.md.", { skillPath });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const MOCK_PNG_BYTES = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==", "base64"));
|
|
67
|
+
function createSmokeContext(templateId) {
|
|
68
|
+
let logCount = 0;
|
|
69
|
+
let storageWrites = 0;
|
|
70
|
+
let billingCalls = 0;
|
|
71
|
+
let providerCalls = 0;
|
|
72
|
+
let remotionCalls = 0;
|
|
73
|
+
const prefix = `templates/${templateId}/users/certification-user/jobs/certification-job`;
|
|
74
|
+
const ctx = {
|
|
75
|
+
env: "development",
|
|
76
|
+
customer: {
|
|
77
|
+
id: "certification-user",
|
|
78
|
+
email: "certification@vidfarm.local",
|
|
79
|
+
name: "Certification",
|
|
80
|
+
defaultWebhookUrl: null,
|
|
81
|
+
isDeveloper: true,
|
|
82
|
+
isPaidPlan: true,
|
|
83
|
+
about: null,
|
|
84
|
+
groupchatUrl: null,
|
|
85
|
+
flockposterApiKey: null
|
|
86
|
+
},
|
|
87
|
+
templateConfig: {},
|
|
88
|
+
logger: {
|
|
89
|
+
debug() { logCount += 1; },
|
|
90
|
+
info() { logCount += 1; },
|
|
91
|
+
warn() { logCount += 1; },
|
|
92
|
+
error() { logCount += 1; },
|
|
93
|
+
progress() { logCount += 1; }
|
|
94
|
+
},
|
|
95
|
+
jobs: {
|
|
96
|
+
async enqueueChild() {
|
|
97
|
+
return { jobId: "child-certification-job" };
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
storage: {
|
|
101
|
+
async putJson(key) {
|
|
102
|
+
storageWrites += 1;
|
|
103
|
+
return { key: `${prefix}/${key}`, url: `https://certification.local/${encodeURIComponent(key)}` };
|
|
104
|
+
},
|
|
105
|
+
async putText(key) {
|
|
106
|
+
storageWrites += 1;
|
|
107
|
+
return { key: `${prefix}/${key}`, url: `https://certification.local/${encodeURIComponent(key)}` };
|
|
108
|
+
},
|
|
109
|
+
async putBuffer(key) {
|
|
110
|
+
storageWrites += 1;
|
|
111
|
+
return { key: `${prefix}/${key}`, url: `https://certification.local/${encodeURIComponent(key)}` };
|
|
112
|
+
},
|
|
113
|
+
getPublicUrl(key) {
|
|
114
|
+
return `https://certification.local/${encodeURIComponent(`${prefix}/${key}`)}`;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
billing: {
|
|
118
|
+
async record() {
|
|
119
|
+
billingCalls += 1;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
providers: {
|
|
123
|
+
async generateText() {
|
|
124
|
+
providerCalls += 1;
|
|
125
|
+
return { text: "certification text", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
|
|
126
|
+
},
|
|
127
|
+
async generateImage() {
|
|
128
|
+
providerCalls += 1;
|
|
129
|
+
return { bytes: MOCK_PNG_BYTES, contentType: "image/png", revisedPrompt: null };
|
|
130
|
+
},
|
|
131
|
+
async analyzeImageLayout() {
|
|
132
|
+
providerCalls += 1;
|
|
133
|
+
return {
|
|
134
|
+
zone: "bottom",
|
|
135
|
+
align: "center",
|
|
136
|
+
maxWidthPercent: 80,
|
|
137
|
+
justification: "Certification mock layout."
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
remotion: {
|
|
142
|
+
async render() {
|
|
143
|
+
remotionCalls += 1;
|
|
144
|
+
return {
|
|
145
|
+
renderId: "render-certification",
|
|
146
|
+
outputUrl: "https://certification.local/render.mp4",
|
|
147
|
+
metadata: { mode: "certification-mock" }
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
ctx,
|
|
154
|
+
get logCount() { return logCount; },
|
|
155
|
+
get storageWrites() { return storageWrites; },
|
|
156
|
+
get billingCalls() { return billingCalls; },
|
|
157
|
+
get providerCalls() { return providerCalls; },
|
|
158
|
+
get remotionCalls() { return remotionCalls; }
|
|
159
|
+
};
|
|
160
|
+
}
|