@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.
Files changed (69) hide show
  1. package/.env.example +12 -5
  2. package/PLATFORM_SPEC.md +143 -2
  3. package/README.md +165 -16
  4. package/SKILL.developer.md +258 -0
  5. package/SKILL.director.md +599 -0
  6. package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
  7. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
  8. package/dist/src/account-pages.js +630 -0
  9. package/dist/src/app.js +897 -66
  10. package/dist/src/cli.js +284 -5
  11. package/dist/src/config.js +25 -5
  12. package/dist/src/context.js +1 -1
  13. package/dist/src/db.js +427 -18
  14. package/dist/src/dev-app.js +59 -12
  15. package/dist/src/homepage.js +441 -0
  16. package/dist/src/index.js +12 -7
  17. package/dist/src/lib/crypto.js +14 -0
  18. package/dist/src/lib/template-dna.js +542 -0
  19. package/dist/src/lib/template-style-options.js +49 -0
  20. package/dist/src/registry.js +54 -7
  21. package/dist/src/runtime.js +3 -1
  22. package/dist/src/services/auth.js +69 -5
  23. package/dist/src/services/jobs.js +23 -4
  24. package/dist/src/services/providers.js +74 -12
  25. package/dist/src/services/storage.js +74 -18
  26. package/dist/src/services/template-certification.js +160 -0
  27. package/dist/src/services/template-loader.js +37 -0
  28. package/dist/src/services/template-sources.js +135 -0
  29. package/dist/src/worker.js +19 -7
  30. package/dist/templates/template_0000/src/lib/images.js +242 -0
  31. package/dist/templates/template_0000/src/remotion/Root.js +33 -0
  32. package/dist/templates/template_0000/src/sdk.js +3 -0
  33. package/dist/templates/template_0000/src/style-options.js +51 -0
  34. package/dist/templates/template_0000/src/template-dna.js +9 -0
  35. package/dist/templates/template_0000/src/template.js +1217 -0
  36. package/package.json +10 -1
  37. package/templates/template_0000/README.md +121 -0
  38. package/templates/template_0000/SKILL.md +193 -0
  39. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  40. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  41. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  42. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  43. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  44. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  45. package/templates/template_0000/composition.json +11 -0
  46. package/templates/template_0000/package-lock.json +5505 -0
  47. package/templates/template_0000/package.json +31 -0
  48. package/templates/template_0000/research/preview/.gitkeep +1 -0
  49. package/templates/template_0000/research/source_notes.md +7 -0
  50. package/templates/template_0000/scripts/create-site.mjs +27 -0
  51. package/templates/template_0000/scripts/render-cloud.mjs +72 -0
  52. package/templates/template_0000/src/lib/images.js +242 -0
  53. package/templates/template_0000/src/lib/images.ts +284 -0
  54. package/templates/template_0000/src/remotion/Root.js +33 -0
  55. package/templates/template_0000/src/remotion/Root.tsx +75 -0
  56. package/templates/template_0000/src/remotion/index.js +3 -0
  57. package/templates/template_0000/src/remotion/index.tsx +4 -0
  58. package/templates/template_0000/src/sdk.js +3 -0
  59. package/templates/template_0000/src/sdk.ts +122 -0
  60. package/templates/template_0000/src/style-options.js +51 -0
  61. package/templates/template_0000/src/style-options.ts +60 -0
  62. package/templates/template_0000/src/template-dna.ts +15 -0
  63. package/templates/template_0000/src/template.js +1117 -0
  64. package/templates/template_0000/src/template.ts +1747 -0
  65. package/templates/template_0000/template.config.json +26 -0
  66. package/templates/template_0000/tsconfig.json +19 -0
  67. package/dist/templates/template_0000/demo-template.js +0 -196
  68. package/dist/templates/template_0000/remotion/Root.js +0 -66
  69. /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 customer = database.getCustomerByEmail(email) ?? database.upsertCustomer({
51
- id: createId("cus"),
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(customerId, templateId) {
54
- return database.listJobsForCustomer(customerId, templateId);
72
+ listJobs(input) {
73
+ return database.listJobsForCustomer(input);
55
74
  }
56
- listLogs(jobId, since, limit) {
57
- return database.getJobEvents(jobId, since, limit);
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: decryptString(row.encryptedSecret, config.ENCRYPTION_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.callOpenAILayoutAnalysis({
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: [{ text: input.prompt }] }],
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 callOpenAILayoutAnalysis(input) {
323
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
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 negative space and avoid covering a subject."
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 52 and 88.`
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("openai authentication failed");
388
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
358
389
  }
359
390
  if (response.status === 429) {
360
- throw new ProviderRateLimitError("openai rate limited");
391
+ throw new ProviderRateLimitError(`${input.provider} rate limited`);
361
392
  }
362
393
  if (!response.ok) {
363
- throw new Error(`openai layout analysis returned ${response.status}`);
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
- "maxWidthPercent must be between 52 and 88.",
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
- async putJson(key, value) {
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 createPresignedWorkspaceUpload(customerId, relativePath, contentType) {
61
- const key = `customers/${customerId}/workspace/${relativePath}`;
77
+ async getReadUrl(key) {
78
+ return this.createReadUrl(key);
79
+ }
80
+ async deleteObject(key) {
62
81
  if (this.s3 && config.AWS_S3_BUCKET) {
63
- const command = new PutObjectCommand({
82
+ await this.s3.send(new DeleteObjectCommand({
64
83
  Bucket: config.AWS_S3_BUCKET,
65
- Key: key,
66
- ContentType: contentType
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
- const url = await getSignedUrl(this.s3, command, { expiresIn: 900 });
69
- return { key, url, method: "PUT" };
99
+ return getSignedUrl(this.s3, command, { expiresIn: 86400 });
70
100
  }
71
- const url = `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(key)}`;
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
+ }