@mevdragon/vidfarm-devcli 0.2.7 → 0.2.9

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 (98) hide show
  1. package/.env.example +9 -0
  2. package/GETTING_STARTED.developers.md +1 -30
  3. package/README.md +22 -5
  4. package/SKILL.developer.md +464 -12
  5. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
  6. package/auto-create-templates/extractor-system-prompt.md +113 -0
  7. package/auto-create-templates/production-graph.schema.json +276 -0
  8. package/auto-create-templates/runbook.md +140 -0
  9. package/auto-create-templates/template-plan.schema.json +230 -0
  10. package/auto-create-templates/template-planner-prompt.md +75 -0
  11. package/dist/src/account-pages-legacy.js +9064 -0
  12. package/dist/src/account-pages.js +41 -620
  13. package/dist/src/app.js +7173 -494
  14. package/dist/src/cli.js +525 -29
  15. package/dist/src/config.js +14 -7
  16. package/dist/src/context.js +51 -35
  17. package/dist/src/db.js +1049 -55
  18. package/dist/src/dev-app-legacy.js +693 -0
  19. package/dist/src/dev-app.js +4 -904
  20. package/dist/src/domain.js +1 -1
  21. package/dist/src/editor-chat-history.js +72 -0
  22. package/dist/src/editor-chat.js +202 -0
  23. package/dist/src/frontend/flockposter-cache-store.js +116 -0
  24. package/dist/src/frontend/homepage-client.js +114 -0
  25. package/dist/src/frontend/homepage-shared.js +3 -0
  26. package/dist/src/frontend/homepage-store.js +27 -0
  27. package/dist/src/frontend/homepage-view.js +147 -0
  28. package/dist/src/frontend/page-runtime-client.js +100 -0
  29. package/dist/src/frontend/page-runtime-store.js +8 -0
  30. package/dist/src/frontend/template-editor-chat.js +2261 -0
  31. package/dist/src/homepage.js +695 -339
  32. package/dist/src/lib/template-paths.js +10 -4
  33. package/dist/src/lib/template-style-options.js +95 -15
  34. package/dist/src/page-runtime.js +1 -0
  35. package/dist/src/page-shell.js +941 -0
  36. package/dist/src/primitive-context.js +163 -0
  37. package/dist/src/primitive-registry.js +340 -0
  38. package/dist/src/primitive-sdk.js +3 -0
  39. package/dist/src/primitives/remotion/html-image.js +28 -0
  40. package/dist/src/react-page-shell.js +34 -0
  41. package/dist/src/ready-post-schedule-component.js +1514 -0
  42. package/dist/src/registry.js +44 -18
  43. package/dist/src/runtime.js +6 -1
  44. package/dist/src/services/api-call-history.js +245 -0
  45. package/dist/src/services/auth.js +27 -9
  46. package/dist/src/services/billing.js +248 -9
  47. package/dist/src/services/chat-threads.js +88 -0
  48. package/dist/src/services/job-logs.js +10 -3
  49. package/dist/src/services/jobs.js +13 -2
  50. package/dist/src/services/providers.js +944 -55
  51. package/dist/src/services/rate-limits.js +236 -0
  52. package/dist/src/services/remotion.js +143 -16
  53. package/dist/src/services/storage.js +23 -8
  54. package/dist/src/services/template-certification.js +26 -3
  55. package/dist/src/services/template-loader.js +19 -1
  56. package/dist/src/services/template-sources.js +316 -7
  57. package/dist/src/template-editor-pages.js +2309 -0
  58. package/dist/src/template-editor-shell.js +1507 -0
  59. package/dist/src/worker.js +120 -22
  60. package/package.json +30 -6
  61. package/public/assets/homepage-app.js +54 -0
  62. package/public/assets/homepage-client-app.js +54 -0
  63. package/public/assets/page-runtime-client-app.js +68 -0
  64. package/dist/templates/template_0000/src/lib/images.js +0 -202
  65. package/dist/templates/template_0000/src/remotion/Root.js +0 -33
  66. package/dist/templates/template_0000/src/remotion/index.js +0 -3
  67. package/dist/templates/template_0000/src/sdk.js +0 -3
  68. package/dist/templates/template_0000/src/style-options.js +0 -51
  69. package/dist/templates/template_0000/src/template-dna.js +0 -9
  70. package/dist/templates/template_0000/src/template.js +0 -1441
  71. package/templates/template_0000/README.md +0 -77
  72. package/templates/template_0000/SKILL.md +0 -225
  73. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  74. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  75. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  76. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  77. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  78. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  79. package/templates/template_0000/composition.json +0 -11
  80. package/templates/template_0000/package.json +0 -28
  81. package/templates/template_0000/research/preview/.gitkeep +0 -1
  82. package/templates/template_0000/research/source_notes.md +0 -7
  83. package/templates/template_0000/src/lib/images.js +0 -202
  84. package/templates/template_0000/src/lib/images.ts +0 -241
  85. package/templates/template_0000/src/remotion/Root.js +0 -33
  86. package/templates/template_0000/src/remotion/Root.tsx +0 -75
  87. package/templates/template_0000/src/remotion/index.js +0 -3
  88. package/templates/template_0000/src/remotion/index.tsx +0 -4
  89. package/templates/template_0000/src/sdk.js +0 -3
  90. package/templates/template_0000/src/sdk.ts +0 -122
  91. package/templates/template_0000/src/style-options.js +0 -51
  92. package/templates/template_0000/src/style-options.ts +0 -60
  93. package/templates/template_0000/src/template-dna.js +0 -9
  94. package/templates/template_0000/src/template-dna.ts +0 -15
  95. package/templates/template_0000/src/template.js +0 -1441
  96. package/templates/template_0000/src/template.ts +0 -2042
  97. package/templates/template_0000/template.config.json +0 -21
  98. package/templates/template_0000/tsconfig.json +0 -19
@@ -0,0 +1,236 @@
1
+ import { DynamoDBClient, GetItemCommand, TransactWriteItemsCommand } from "@aws-sdk/client-dynamodb";
2
+ import { config } from "../config.js";
3
+ const dynamodb = new DynamoDBClient({});
4
+ const OPERATIONS_TABLE_NAME = config.VIDFARM_RATE_LIMITS_TABLE_NAME?.trim() || "";
5
+ const REST_LIMIT_SUMMARY = "General REST API limits: max 10 requests per second, 300 requests per minute, and 5000 requests per hour.";
6
+ export class RateLimitExceededError extends Error {
7
+ type;
8
+ status;
9
+ constructor(message, type, status) {
10
+ super(message);
11
+ this.type = type;
12
+ this.status = status;
13
+ }
14
+ }
15
+ const OPERATION_LIMITS = {
16
+ starter: {
17
+ month: 750,
18
+ week: 400,
19
+ five_hour: 200
20
+ },
21
+ standard: {
22
+ month: 2500,
23
+ week: 1250,
24
+ five_hour: 500
25
+ },
26
+ agency: {
27
+ month: 10_000,
28
+ week: 5_000,
29
+ five_hour: 2_000
30
+ }
31
+ };
32
+ const REST_LIMITS = {
33
+ second: 10,
34
+ minute: 300,
35
+ hour: 5_000
36
+ };
37
+ const WINDOW_DEFINITIONS = {
38
+ month: { durationMs: 30 * 24 * 60 * 60 * 1000, label: "30-day window" },
39
+ week: { durationMs: 7 * 24 * 60 * 60 * 1000, label: "7-day window" },
40
+ five_hour: { durationMs: 5 * 60 * 60 * 1000, label: "5-hour window" },
41
+ second: { durationMs: 1000, label: "1-second window" },
42
+ minute: { durationMs: 60 * 1000, label: "1-minute window" },
43
+ hour: { durationMs: 60 * 60 * 1000, label: "1-hour window" }
44
+ };
45
+ export class RateLimitService {
46
+ isConfigured() {
47
+ return Boolean(OPERATIONS_TABLE_NAME);
48
+ }
49
+ async consumeTemplateOperation(customer) {
50
+ if (!this.isConfigured()) {
51
+ return;
52
+ }
53
+ await this.consumeWindows({
54
+ scopePk: `customer#${customer.id}`,
55
+ scopeLabel: `customer:${customer.id}`,
56
+ anchorMs: new Date(customer.createdAt).getTime(),
57
+ limits: OPERATION_LIMITS[customer.planTier],
58
+ type: "operation_rate_limit_exceeded"
59
+ });
60
+ }
61
+ async consumeGeneralRest(scopeId) {
62
+ if (!this.isConfigured()) {
63
+ return;
64
+ }
65
+ const statuses = await this.consumeWindows({
66
+ scopePk: `rest#${scopeId}`,
67
+ scopeLabel: scopeId,
68
+ anchorMs: 0,
69
+ limits: REST_LIMITS,
70
+ type: "rest_rate_limit_exceeded"
71
+ });
72
+ const violations = statuses.filter((status) => status.used > status.limit);
73
+ if (violations.length > 0) {
74
+ throw new RateLimitExceededError(violations[0].message, "rest_rate_limit_exceeded", violations);
75
+ }
76
+ }
77
+ async getTemplateOperationStatus(customer) {
78
+ if (!this.isConfigured()) {
79
+ return {
80
+ monthly: this.buildDisabledStatus("month", "30-day window"),
81
+ weekly: this.buildDisabledStatus("week", "7-day window"),
82
+ five_hour: this.buildDisabledStatus("five_hour", "5-hour window")
83
+ };
84
+ }
85
+ const statuses = await this.readWindows({
86
+ scopePk: `customer#${customer.id}`,
87
+ scopeLabel: `customer:${customer.id}`,
88
+ anchorMs: new Date(customer.createdAt).getTime(),
89
+ limits: OPERATION_LIMITS[customer.planTier]
90
+ });
91
+ const mapped = this.buildOperationStatuses(statuses);
92
+ return {
93
+ monthly: mapped.find((status) => status.key === "month"),
94
+ weekly: mapped.find((status) => status.key === "week"),
95
+ five_hour: mapped.find((status) => status.key === "five_hour")
96
+ };
97
+ }
98
+ getRestSummary() {
99
+ return {
100
+ message: REST_LIMIT_SUMMARY,
101
+ limits: {
102
+ per_second: REST_LIMITS.second,
103
+ per_minute: REST_LIMITS.minute,
104
+ per_hour: REST_LIMITS.hour
105
+ }
106
+ };
107
+ }
108
+ async consumeWindows(input) {
109
+ const windowSpecs = this.buildWindowSpecs(input.anchorMs, input.limits);
110
+ let transactionCancelled = false;
111
+ try {
112
+ await dynamodb.send(new TransactWriteItemsCommand({
113
+ TransactItems: windowSpecs.map((spec) => ({
114
+ Update: {
115
+ TableName: OPERATIONS_TABLE_NAME,
116
+ Key: {
117
+ pk: { S: input.scopePk },
118
+ sk: { S: spec.sk }
119
+ },
120
+ UpdateExpression: "SET usage_count = if_not_exists(usage_count, :zero) + :one, #scope = if_not_exists(#scope, :scope), window_key = if_not_exists(window_key, :windowKey), window_started_at = if_not_exists(window_started_at, :windowStartedAt), window_ends_at = if_not_exists(window_ends_at, :windowEndsAt), limit_value = if_not_exists(limit_value, :limitValue), created_at = if_not_exists(created_at, :createdAt), updated_at = :updatedAt, expires_at_epoch_seconds = if_not_exists(expires_at_epoch_seconds, :ttl)",
121
+ ConditionExpression: "attribute_not_exists(usage_count) OR usage_count < :limitValue",
122
+ ExpressionAttributeNames: {
123
+ "#scope": "scope",
124
+ },
125
+ ExpressionAttributeValues: {
126
+ ":zero": { N: "0" },
127
+ ":one": { N: "1" },
128
+ ":scope": { S: input.scopeLabel },
129
+ ":windowKey": { S: spec.key },
130
+ ":windowStartedAt": { S: spec.windowStartedAt },
131
+ ":windowEndsAt": { S: spec.nextAvailableAt },
132
+ ":limitValue": { N: String(spec.limit) },
133
+ ":createdAt": { S: new Date().toISOString() },
134
+ ":updatedAt": { S: new Date().toISOString() },
135
+ ":ttl": { N: String(Math.floor((new Date(spec.nextAvailableAt).getTime() + (2 * 24 * 60 * 60 * 1000)) / 1000)) }
136
+ }
137
+ }
138
+ }))
139
+ }));
140
+ }
141
+ catch (error) {
142
+ if (!isTransactionCanceledError(error)) {
143
+ throw error;
144
+ }
145
+ transactionCancelled = true;
146
+ }
147
+ const statuses = await this.readWindows({
148
+ scopePk: input.scopePk,
149
+ scopeLabel: input.scopeLabel,
150
+ anchorMs: input.anchorMs,
151
+ limits: input.limits
152
+ });
153
+ const violations = statuses.filter((status) => transactionCancelled ? status.used >= status.limit : status.used > status.limit);
154
+ if (violations.length > 0) {
155
+ throw new RateLimitExceededError(violations[0].message, input.type, violations);
156
+ }
157
+ return statuses;
158
+ }
159
+ async readWindows(input) {
160
+ const windowSpecs = this.buildWindowSpecs(input.anchorMs, input.limits);
161
+ const items = await Promise.all(windowSpecs.map(async (spec) => {
162
+ const response = await dynamodb.send(new GetItemCommand({
163
+ TableName: OPERATIONS_TABLE_NAME,
164
+ Key: {
165
+ pk: { S: input.scopePk },
166
+ sk: { S: spec.sk }
167
+ },
168
+ ConsistentRead: true
169
+ }));
170
+ return this.toStatus(spec, response.Item);
171
+ }));
172
+ return items;
173
+ }
174
+ buildOperationStatuses(statuses) {
175
+ return statuses.map((status) => ({
176
+ ...status,
177
+ key: status.key === "month" ? "month" : status.key === "week" ? "week" : "five_hour"
178
+ }));
179
+ }
180
+ buildWindowSpecs(anchorMs, limits) {
181
+ return Object.keys(limits).map((typedKey) => {
182
+ const limit = limits[typedKey];
183
+ const definition = WINDOW_DEFINITIONS[typedKey];
184
+ const nowMs = Date.now();
185
+ const effectiveAnchorMs = Number.isFinite(anchorMs) && anchorMs > 0 ? anchorMs : 0;
186
+ const elapsedMs = Math.max(0, nowMs - effectiveAnchorMs);
187
+ const windowIndex = Math.floor(elapsedMs / definition.durationMs);
188
+ const windowStartedAtMs = effectiveAnchorMs + (windowIndex * definition.durationMs);
189
+ const nextAvailableAtMs = windowStartedAtMs + definition.durationMs;
190
+ const windowStartedAt = new Date(windowStartedAtMs).toISOString();
191
+ const nextAvailableAt = new Date(nextAvailableAtMs).toISOString();
192
+ return {
193
+ key: typedKey,
194
+ label: definition.label,
195
+ limit,
196
+ sk: `${typedKey}#${windowStartedAt}`,
197
+ windowStartedAt,
198
+ nextAvailableAt
199
+ };
200
+ });
201
+ }
202
+ toStatus(spec, item) {
203
+ const used = Number(item?.usage_count?.N ?? "0");
204
+ const remaining = Math.max(0, spec.limit - used);
205
+ const consumedPercent = spec.limit > 0 ? Math.min(100, Number(((used / spec.limit) * 100).toFixed(2))) : 100;
206
+ return {
207
+ key: spec.key,
208
+ label: spec.label,
209
+ limit: spec.limit,
210
+ used,
211
+ remaining,
212
+ consumedPercent,
213
+ windowStartedAt: item?.window_started_at?.S ?? spec.windowStartedAt,
214
+ nextAvailableAt: item?.window_ends_at?.S ?? spec.nextAvailableAt,
215
+ message: used >= spec.limit
216
+ ? `Rate limit reached for the ${spec.label}. Usage resets at ${item?.window_ends_at?.S ?? spec.nextAvailableAt}.`
217
+ : `${used} of ${spec.limit} requests used in the ${spec.label}. Resets at ${item?.window_ends_at?.S ?? spec.nextAvailableAt}.`
218
+ };
219
+ }
220
+ buildDisabledStatus(key, label) {
221
+ return {
222
+ key,
223
+ label,
224
+ limit: 0,
225
+ used: 0,
226
+ remaining: 0,
227
+ consumedPercent: 0,
228
+ windowStartedAt: new Date(0).toISOString(),
229
+ nextAvailableAt: new Date(0).toISOString(),
230
+ message: "Rate limiting is not configured for this environment."
231
+ };
232
+ }
233
+ }
234
+ function isTransactionCanceledError(error) {
235
+ return error instanceof Error && error.name === "TransactionCanceledException";
236
+ }
@@ -2,41 +2,78 @@ import { mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { bundle } from "@remotion/bundler";
4
4
  import { getRenderProgress, renderMediaOnLambda } from "@remotion/lambda";
5
- import { getCompositions, renderMedia } from "@remotion/renderer";
5
+ import { getCompositions, renderMedia, renderStill } from "@remotion/renderer";
6
6
  import { config } from "../config.js";
7
7
  import { createId } from "../lib/ids.js";
8
+ const REQUIRED_VIDEO_FPS = 30;
9
+ const RENDER_MARKUP_MULTIPLIER = 1.2;
8
10
  export class RemotionService {
9
11
  async render(input) {
10
- if ((config.REMOTION_MODE === "local" || (config.REMOTION_MODE === "auto" && input.entryPoint)) && input.entryPoint) {
12
+ const fps = input.fps ?? REQUIRED_VIDEO_FPS;
13
+ if (fps !== REQUIRED_VIDEO_FPS) {
14
+ throw new Error(`Vidfarm renders must be ${REQUIRED_VIDEO_FPS}fps. Received ${fps}fps.`);
15
+ }
16
+ const serveUrl = nonBlank(input.serveUrl) ??
17
+ nonBlank(config.REMOTION_SERVE_URL) ??
18
+ inferHostedServeUrl(input.compositionId);
19
+ const hasHostedLambdaConfig = Boolean(nonBlank(config.REMOTION_FUNCTION_NAME) && serveUrl && nonBlank(config.REMOTION_BUCKET_NAME));
20
+ if (config.isProduction && config.REMOTION_MODE !== "lambda") {
21
+ throw new Error("Production video rendering must use lambda mode.");
22
+ }
23
+ if (input.entryPoint
24
+ && (config.REMOTION_MODE === "local"
25
+ || (config.REMOTION_MODE === "auto" && !hasHostedLambdaConfig)
26
+ || (!config.isProduction && config.REMOTION_MODE === "auto"))) {
27
+ if (config.isProduction) {
28
+ throw new Error("Production video rendering cannot fall back to local mode.");
29
+ }
11
30
  return this.renderLocal({
12
31
  compositionId: input.compositionId,
13
32
  entryPoint: input.entryPoint,
14
33
  outputKey: input.outputKey,
34
+ fps,
15
35
  inputProps: input.inputProps
16
36
  });
17
37
  }
18
- const serveUrl = input.serveUrl ?? config.REMOTION_SERVE_URL;
19
- if (config.REMOTION_MODE === "mock" || !config.REMOTION_FUNCTION_NAME || !serveUrl || !config.REMOTION_BUCKET_NAME) {
38
+ if (config.REMOTION_MODE === "mock") {
39
+ if (config.isProduction) {
40
+ throw new Error("Production video rendering cannot use mock mode.");
41
+ }
20
42
  return {
21
43
  renderId: createId("render"),
22
44
  outputUrl: null,
23
45
  metadata: {
24
46
  mode: "mock",
25
47
  compositionId: input.compositionId,
48
+ fps,
26
49
  inputProps: input.inputProps
27
50
  }
28
51
  };
29
52
  }
53
+ if (!hasHostedLambdaConfig) {
54
+ throw new Error(config.isProduction
55
+ ? "Cloud render is not configured."
56
+ : "Cloud render is not configured. Set REMOTION_FUNCTION_NAME, REMOTION_SERVE_URL, and REMOTION_BUCKET_NAME, or provide a local entryPoint.");
57
+ }
58
+ const hostedServeUrl = serveUrl;
59
+ const hostedFunctionName = nonBlank(config.REMOTION_FUNCTION_NAME);
60
+ const hostedBucketName = nonBlank(config.REMOTION_BUCKET_NAME);
61
+ await this.assertCompositionFps({
62
+ compositionId: input.compositionId,
63
+ serveUrl: hostedServeUrl,
64
+ inputProps: input.inputProps,
65
+ expectedFps: fps
66
+ });
30
67
  return withRemotionCredentials(async () => {
31
68
  const render = await renderMediaOnLambda({
32
69
  region: config.REMOTION_REGION,
33
- functionName: config.REMOTION_FUNCTION_NAME,
34
- serveUrl,
70
+ functionName: hostedFunctionName,
71
+ serveUrl: hostedServeUrl,
35
72
  composition: input.compositionId,
36
73
  inputProps: input.inputProps,
37
74
  codec: "h264",
38
75
  privacy: "public",
39
- forceBucketName: config.REMOTION_BUCKET_NAME,
76
+ forceBucketName: hostedBucketName,
40
77
  forceWidth: 1080,
41
78
  forceHeight: 1920,
42
79
  framesPerLambda: 40,
@@ -47,14 +84,16 @@ export class RemotionService {
47
84
  for (;;) {
48
85
  const progress = await getRenderProgress({
49
86
  region: config.REMOTION_REGION,
50
- functionName: config.REMOTION_FUNCTION_NAME,
51
- bucketName: config.REMOTION_BUCKET_NAME,
87
+ functionName: hostedFunctionName,
88
+ bucketName: hostedBucketName,
52
89
  renderId: render.renderId
53
90
  });
54
91
  if (progress.done) {
55
92
  if (!progress.outputFile) {
56
- throw new Error("Remotion render completed without an output file.");
93
+ throw new Error("Cloud render completed without an output file.");
57
94
  }
95
+ const estimatedCostUsd = progress.costs.accruedSoFar;
96
+ const chargeUsd = roundUsd(estimatedCostUsd * RENDER_MARKUP_MULTIPLIER);
58
97
  return {
59
98
  renderId: render.renderId,
60
99
  outputUrl: progress.outputFile,
@@ -62,14 +101,22 @@ export class RemotionService {
62
101
  mode: "lambda",
63
102
  bucketName: render.bucketName,
64
103
  compositionId: input.compositionId,
104
+ fps,
65
105
  serveUrl,
106
+ estimatedCostUsd,
107
+ chargeUsd,
108
+ costDisplay: progress.costs.displayCost,
109
+ costCurrency: progress.costs.currency,
110
+ costDisclaimer: progress.costs.disclaimer,
111
+ estimatedBillingDurationInMilliseconds: progress.estimatedBillingDurationInMilliseconds,
112
+ lambdasInvoked: progress.lambdasInvoked,
66
113
  overallProgress: progress.overallProgress,
67
114
  renderSize: progress.renderSize
68
115
  }
69
116
  };
70
117
  }
71
118
  if (Date.now() - startedAt > 10 * 60 * 1000) {
72
- throw new Error("Timed out waiting for Remotion render output.");
119
+ throw new Error("Timed out waiting for cloud render output.");
73
120
  }
74
121
  await new Promise((resolve) => setTimeout(resolve, 5000));
75
122
  }
@@ -78,13 +125,16 @@ export class RemotionService {
78
125
  async renderLocal(input) {
79
126
  const renderId = createId("render");
80
127
  const bundled = await getOrCreateBundle(input.entryPoint);
81
- const compositions = await getCompositions(bundled, {
128
+ const composition = await this.resolveComposition({
129
+ source: bundled,
130
+ compositionId: input.compositionId,
82
131
  inputProps: input.inputProps
83
132
  });
84
- const composition = compositions.find((item) => item.id === input.compositionId);
85
- if (!composition) {
86
- throw new Error(`Composition ${input.compositionId} not found in ${input.entryPoint}`);
87
- }
133
+ this.assertExpectedFps({
134
+ compositionId: input.compositionId,
135
+ actualFps: composition.fps,
136
+ expectedFps: input.fps
137
+ });
88
138
  const outputKey = input.outputKey ?? `renders/${renderId}.mp4`;
89
139
  const outputLocation = path.join(config.VIDFARM_DATA_DIR, "storage", outputKey);
90
140
  mkdirSync(path.dirname(outputLocation), { recursive: true });
@@ -103,10 +153,71 @@ export class RemotionService {
103
153
  mode: "local",
104
154
  compositionId: input.compositionId,
105
155
  entryPoint: input.entryPoint,
156
+ fps: input.fps,
106
157
  outputKey
107
158
  }
108
159
  };
109
160
  }
161
+ async renderStill(input) {
162
+ const renderId = createId("render");
163
+ const bundled = await getOrCreateBundle(input.entryPoint);
164
+ const composition = await this.resolveComposition({
165
+ source: bundled,
166
+ compositionId: input.compositionId,
167
+ inputProps: input.inputProps
168
+ });
169
+ const format = input.imageFormat ?? "png";
170
+ const extension = format === "jpeg" ? "jpg" : format;
171
+ const outputKey = input.outputKey ?? `renders/${renderId}.${extension}`;
172
+ const outputLocation = path.join(config.VIDFARM_DATA_DIR, "storage", outputKey);
173
+ mkdirSync(path.dirname(outputLocation), { recursive: true });
174
+ await renderStill({
175
+ serveUrl: bundled,
176
+ composition,
177
+ inputProps: input.inputProps,
178
+ output: outputLocation,
179
+ imageFormat: format,
180
+ overwrite: true
181
+ });
182
+ return {
183
+ renderId,
184
+ outputUrl: `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(outputKey)}`,
185
+ metadata: {
186
+ mode: "local_still",
187
+ compositionId: input.compositionId,
188
+ entryPoint: input.entryPoint,
189
+ outputKey,
190
+ imageFormat: format
191
+ }
192
+ };
193
+ }
194
+ async assertCompositionFps(input) {
195
+ const composition = await this.resolveComposition({
196
+ source: input.serveUrl,
197
+ compositionId: input.compositionId,
198
+ inputProps: input.inputProps
199
+ });
200
+ this.assertExpectedFps({
201
+ compositionId: input.compositionId,
202
+ actualFps: composition.fps,
203
+ expectedFps: input.expectedFps
204
+ });
205
+ }
206
+ async resolveComposition(input) {
207
+ const compositions = await getCompositions(input.source, {
208
+ inputProps: input.inputProps
209
+ });
210
+ const composition = compositions.find((item) => item.id === input.compositionId);
211
+ if (!composition) {
212
+ throw new Error(`Composition ${input.compositionId} not found in ${input.source}`);
213
+ }
214
+ return composition;
215
+ }
216
+ assertExpectedFps(input) {
217
+ if (input.actualFps !== input.expectedFps) {
218
+ throw new Error(`Composition ${input.compositionId} is ${input.actualFps}fps, but Vidfarm requires ${input.expectedFps}fps output.`);
219
+ }
220
+ }
110
221
  }
111
222
  const bundleCache = new Map();
112
223
  function getOrCreateBundle(entryPoint) {
@@ -156,3 +267,19 @@ async function withRemotionCredentials(work) {
156
267
  }
157
268
  }
158
269
  }
270
+ function roundUsd(value) {
271
+ return Number(value.toFixed(6));
272
+ }
273
+ function nonBlank(value) {
274
+ const trimmed = value?.trim();
275
+ return trimmed ? trimmed : undefined;
276
+ }
277
+ function inferHostedServeUrl(compositionId) {
278
+ const bucketName = nonBlank(config.REMOTION_BUCKET_NAME);
279
+ if (!bucketName) {
280
+ return undefined;
281
+ }
282
+ const siteName = nonBlank(config.REMOTION_SITE_NAME) ?? compositionId;
283
+ const encodedSiteName = siteName.split("/").map(encodeURIComponent).join("/");
284
+ return `https://${bucketName}.s3.${config.REMOTION_REGION}.amazonaws.com/sites/${encodedSiteName}/index.html`;
285
+ }
@@ -15,7 +15,7 @@ export class StorageService {
15
15
  mkdirSync(this.localRoot, { recursive: true });
16
16
  }
17
17
  userAttachmentKey(customerId, attachmentId, fileName) {
18
- return joinStorageKey("user", customerId, "attachments", attachmentId, fileName);
18
+ return joinStorageKey("users", customerId, attachmentId, fileName);
19
19
  }
20
20
  developerAttachmentKey(customerId, attachmentId, fileName) {
21
21
  return joinStorageKey("developer", customerId, "attachments", attachmentId, fileName);
@@ -29,6 +29,12 @@ export class StorageService {
29
29
  templateJobKey(templateId, customerId, jobId, key) {
30
30
  return joinStorageKey(this.templateJobPrefix(templateId, customerId, jobId), key);
31
31
  }
32
+ primitiveJobPrefix(primitiveId, customerId, jobId) {
33
+ return joinStorageKey("primitives", primitiveId, "users", customerId, "jobs", jobId);
34
+ }
35
+ primitiveJobKey(primitiveId, customerId, jobId, key) {
36
+ return joinStorageKey(this.primitiveJobPrefix(primitiveId, customerId, jobId), key);
37
+ }
32
38
  templateAboutKey(templateId, assetPath) {
33
39
  return joinStorageKey("templates", templateId, "about", assetPath);
34
40
  }
@@ -46,8 +52,7 @@ export class StorageService {
46
52
  Bucket: config.AWS_S3_BUCKET,
47
53
  Key: key,
48
54
  Body: value,
49
- ContentType: contentType,
50
- ACL: publicRead ? "public-read" : undefined
55
+ ContentType: contentType
51
56
  }));
52
57
  return { key, url: publicRead ? this.getPublicUrl(key) : await this.createReadUrl(key) };
53
58
  }
@@ -84,20 +89,17 @@ export class StorageService {
84
89
  if (!this.s3 || !config.AWS_S3_BUCKET) {
85
90
  throw new Error("Presigned uploads require S3 storage.");
86
91
  }
87
- const publicRead = input.publicRead ?? config.s3PublicRead;
88
92
  const expiresIn = input.expiresIn ?? 3600;
89
93
  const command = new PutObjectCommand({
90
94
  Bucket: config.AWS_S3_BUCKET,
91
95
  Key: key,
92
- ContentType: input.contentType,
93
- ACL: publicRead ? "public-read" : undefined
96
+ ContentType: input.contentType
94
97
  });
95
98
  return {
96
99
  url: await getSignedUrl(this.s3, command, { expiresIn }),
97
100
  method: "PUT",
98
101
  headers: {
99
- "content-type": input.contentType,
100
- ...(publicRead ? { "x-amz-acl": "public-read" } : {})
102
+ "content-type": input.contentType
101
103
  },
102
104
  expiresIn
103
105
  };
@@ -115,6 +117,19 @@ export class StorageService {
115
117
  }
116
118
  return readFileSync(path.join(this.localRoot, key), "utf8");
117
119
  }
120
+ async readBuffer(key) {
121
+ if (this.s3 && config.AWS_S3_BUCKET) {
122
+ const response = await this.s3.send(new GetObjectCommand({
123
+ Bucket: config.AWS_S3_BUCKET,
124
+ Key: key
125
+ }));
126
+ if (!response.Body) {
127
+ return null;
128
+ }
129
+ return Buffer.from(await response.Body.transformToByteArray());
130
+ }
131
+ return readFileSync(path.join(this.localRoot, key));
132
+ }
118
133
  async listKeys(prefix) {
119
134
  if (this.s3 && config.AWS_S3_BUCKET) {
120
135
  const keys = [];
@@ -13,11 +13,20 @@ export class TemplateCertificationService {
13
13
  typeof template.about.visual_dna === "string" &&
14
14
  Array.isArray(template.about.preview_media) &&
15
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.", {
16
+ typeof template.about.link_to_original === "string" &&
17
+ typeof template.about.proposal_generator === "string" &&
18
+ typeof template.about.skeleton_prompt === "string" &&
19
+ template.about.skeleton_prompt.length > 0 &&
20
+ Array.isArray(template.about.sample_prompts) &&
21
+ template.about.sample_prompts.every((entry) => typeof entry === "string") &&
22
+ template.about.sample_prompts.length > 0), "Template must define id, slugId, version, and complete about metadata, including proposal_generator, skeleton_prompt, and sample prompts.", {
17
23
  templateId: template.id,
18
24
  slugId: template.slugId,
19
25
  version: template.version,
20
- previewMediaCount: template.about?.preview_media?.length ?? 0
26
+ previewMediaCount: template.about?.preview_media?.length ?? 0,
27
+ hasProposalGenerator: typeof template.about?.proposal_generator === "string" && template.about.proposal_generator.length > 0,
28
+ hasSkeletonPrompt: typeof template.about?.skeleton_prompt === "string" && template.about.skeleton_prompt.length > 0,
29
+ samplePromptCount: template.about?.sample_prompts?.length ?? 0
21
30
  }));
22
31
  checks.push(this.check("template.operations", Object.keys(template.operations).length > 0, "Template must define at least one operation."));
23
32
  checks.push(this.checkSkill(skillPath));
@@ -80,9 +89,12 @@ function createSmokeContext(templateId) {
80
89
  defaultWebhookUrl: null,
81
90
  isDeveloper: true,
82
91
  isPaidPlan: true,
92
+ planTier: "standard",
83
93
  about: null,
84
94
  groupchatUrl: null,
85
- flockposterApiKey: null
95
+ flockposterApiKey: null,
96
+ createdAt: new Date(0).toISOString(),
97
+ updatedAt: new Date(0).toISOString()
86
98
  },
87
99
  templateConfig: {},
88
100
  logger: {
@@ -120,10 +132,17 @@ function createSmokeContext(templateId) {
120
132
  }
121
133
  },
122
134
  providers: {
135
+ listAvailableProviders() {
136
+ return ["openai", "gemini", "openrouter", "perplexity"];
137
+ },
123
138
  async generateText() {
124
139
  providerCalls += 1;
125
140
  return { text: "certification text", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
126
141
  },
142
+ async generateSpeech() {
143
+ providerCalls += 1;
144
+ return { bytes: Uint8Array.from([0, 1, 2, 3]), contentType: "audio/mpeg", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
145
+ },
127
146
  async generateImage() {
128
147
  providerCalls += 1;
129
148
  return { bytes: MOCK_PNG_BYTES, contentType: "image/png", revisedPrompt: null };
@@ -136,6 +155,10 @@ function createSmokeContext(templateId) {
136
155
  maxWidthPercent: 80,
137
156
  justification: "Certification mock layout."
138
157
  };
158
+ },
159
+ async transcribeSpeech() {
160
+ providerCalls += 1;
161
+ return { text: "certification transcript", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
139
162
  }
140
163
  },
141
164
  remotion: {
@@ -4,11 +4,23 @@ export async function loadTemplateFromModule(modulePath) {
4
4
  const candidates = Object.values(loaded);
5
5
  for (const candidate of candidates) {
6
6
  if (isTemplateDefinition(candidate)) {
7
- return candidate;
7
+ return normalizeTemplateDefinition(candidate);
8
8
  }
9
9
  }
10
10
  throw new Error(`No template definition export found in ${modulePath}`);
11
11
  }
12
+ function normalizeTemplateDefinition(template) {
13
+ return {
14
+ ...template,
15
+ about: {
16
+ ...template.about,
17
+ // Older runtime template releases may predate prompt metadata fields.
18
+ proposal_generator: template.about.proposal_generator ?? "",
19
+ skeleton_prompt: template.about.skeleton_prompt ?? "",
20
+ sample_prompts: template.about.sample_prompts ?? []
21
+ }
22
+ };
23
+ }
12
24
  function isTemplateDefinition(value) {
13
25
  if (!value || typeof value !== "object") {
14
26
  return false;
@@ -20,6 +32,9 @@ function isTemplateDefinition(value) {
20
32
  const previewMedia = about && Array.isArray(about.preview_media)
21
33
  ? about.preview_media
22
34
  : null;
35
+ const samplePrompts = about && Array.isArray(about.sample_prompts)
36
+ ? about.sample_prompts
37
+ : null;
23
38
  return (typeof candidate.id === "string" &&
24
39
  typeof candidate.slugId === "string" &&
25
40
  typeof candidate.version === "string" &&
@@ -31,6 +46,9 @@ function isTemplateDefinition(value) {
31
46
  Array.isArray(previewMedia) &&
32
47
  previewMedia.every((entry) => typeof entry === "string") &&
33
48
  typeof about.link_to_original === "string" &&
49
+ (typeof about.proposal_generator === "string" || typeof about.proposal_generator === "undefined") &&
50
+ (typeof about.skeleton_prompt === "string" || typeof about.skeleton_prompt === "undefined") &&
51
+ (samplePrompts === null || samplePrompts.every((entry) => typeof entry === "string")) &&
34
52
  typeof candidate.configSchema === "object" &&
35
53
  typeof candidate.operations === "object" &&
36
54
  typeof candidate.jobs === "object");