@mevdragon/vidfarm-devcli 0.2.8 → 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 -26
  3. package/README.md +3 -0
  4. package/SKILL.developer.md +327 -4
  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 +6627 -244
  14. package/dist/src/cli.js +386 -26
  15. package/dist/src/config.js +12 -2
  16. package/dist/src/context.js +35 -35
  17. package/dist/src/db.js +1016 -53
  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 -371
  32. package/dist/src/lib/template-style-options.js +95 -15
  33. package/dist/src/page-runtime.js +1 -0
  34. package/dist/src/page-shell.js +941 -0
  35. package/dist/src/primitive-context.js +163 -0
  36. package/dist/src/primitive-registry.js +340 -0
  37. package/dist/src/primitive-sdk.js +3 -0
  38. package/dist/src/primitives/remotion/html-image.js +28 -0
  39. package/dist/src/react-page-shell.js +34 -0
  40. package/dist/src/ready-post-schedule-component.js +1514 -0
  41. package/dist/src/registry.js +36 -17
  42. package/dist/src/runtime.js +6 -1
  43. package/dist/src/services/api-call-history.js +245 -0
  44. package/dist/src/services/auth.js +25 -3
  45. package/dist/src/services/billing.js +248 -9
  46. package/dist/src/services/chat-threads.js +88 -0
  47. package/dist/src/services/job-logs.js +14 -7
  48. package/dist/src/services/jobs.js +13 -2
  49. package/dist/src/services/providers.js +552 -133
  50. package/dist/src/services/rate-limits.js +236 -0
  51. package/dist/src/services/remotion.js +143 -16
  52. package/dist/src/services/storage.js +10 -8
  53. package/dist/src/services/template-certification.js +11 -2
  54. package/dist/src/services/template-loader.js +3 -1
  55. package/dist/src/services/template-sources.js +68 -3
  56. package/dist/src/template-editor-pages.js +2309 -0
  57. package/dist/src/template-editor-shell.js +1507 -0
  58. package/dist/src/worker.js +120 -22
  59. package/package.json +27 -4
  60. package/public/assets/homepage-app.js +54 -0
  61. package/public/assets/homepage-client-app.js +54 -0
  62. package/public/assets/page-runtime-client-app.js +68 -0
  63. package/dist/templates/vidfarm_template_0000/src/lib/images.js +0 -202
  64. package/dist/templates/vidfarm_template_0000/src/remotion/Root.js +0 -34
  65. package/dist/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
  66. package/dist/templates/vidfarm_template_0000/src/sdk.js +0 -3
  67. package/dist/templates/vidfarm_template_0000/src/style-options.js +0 -51
  68. package/dist/templates/vidfarm_template_0000/src/template-dna.js +0 -9
  69. package/dist/templates/vidfarm_template_0000/src/template.js +0 -1447
  70. package/templates/vidfarm_template_0000/README.md +0 -100
  71. package/templates/vidfarm_template_0000/SKILL.md +0 -225
  72. package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
  73. package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  74. package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
  75. package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
  76. package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  77. package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
  78. package/templates/vidfarm_template_0000/composition.json +0 -11
  79. package/templates/vidfarm_template_0000/package.json +0 -27
  80. package/templates/vidfarm_template_0000/research/preview/.gitkeep +0 -1
  81. package/templates/vidfarm_template_0000/research/source_notes.md +0 -7
  82. package/templates/vidfarm_template_0000/src/lib/images.js +0 -202
  83. package/templates/vidfarm_template_0000/src/lib/images.ts +0 -241
  84. package/templates/vidfarm_template_0000/src/remotion/Root.js +0 -33
  85. package/templates/vidfarm_template_0000/src/remotion/Root.tsx +0 -75
  86. package/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
  87. package/templates/vidfarm_template_0000/src/remotion/index.tsx +0 -4
  88. package/templates/vidfarm_template_0000/src/sdk.js +0 -3
  89. package/templates/vidfarm_template_0000/src/sdk.ts +0 -140
  90. package/templates/vidfarm_template_0000/src/style-options.js +0 -51
  91. package/templates/vidfarm_template_0000/src/style-options.ts +0 -60
  92. package/templates/vidfarm_template_0000/src/template-dna.js +0 -9
  93. package/templates/vidfarm_template_0000/src/template-dna.ts +0 -15
  94. package/templates/vidfarm_template_0000/src/template.js +0 -1447
  95. package/templates/vidfarm_template_0000/src/template.ts +0 -2049
  96. package/templates/vidfarm_template_0000/template.config.json +0 -21
  97. package/templates/vidfarm_template_0000/tmp/solobacterium-moorei-slideshow.request.json +0 -31
  98. package/templates/vidfarm_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
  };
@@ -15,14 +15,17 @@ export class TemplateCertificationService {
15
15
  template.about.preview_media.every((entry) => typeof entry === "string") &&
16
16
  typeof template.about.link_to_original === "string" &&
17
17
  typeof template.about.proposal_generator === "string" &&
18
+ typeof template.about.skeleton_prompt === "string" &&
19
+ template.about.skeleton_prompt.length > 0 &&
18
20
  Array.isArray(template.about.sample_prompts) &&
19
21
  template.about.sample_prompts.every((entry) => typeof entry === "string") &&
20
- template.about.sample_prompts.length > 0), "Template must define id, slugId, version, and complete about metadata, including proposal_generator and sample prompts.", {
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.", {
21
23
  templateId: template.id,
22
24
  slugId: template.slugId,
23
25
  version: template.version,
24
26
  previewMediaCount: template.about?.preview_media?.length ?? 0,
25
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,
26
29
  samplePromptCount: template.about?.sample_prompts?.length ?? 0
27
30
  }));
28
31
  checks.push(this.check("template.operations", Object.keys(template.operations).length > 0, "Template must define at least one operation."));
@@ -86,9 +89,12 @@ function createSmokeContext(templateId) {
86
89
  defaultWebhookUrl: null,
87
90
  isDeveloper: true,
88
91
  isPaidPlan: true,
92
+ planTier: "standard",
89
93
  about: null,
90
94
  groupchatUrl: null,
91
- flockposterApiKey: null
95
+ flockposterApiKey: null,
96
+ createdAt: new Date(0).toISOString(),
97
+ updatedAt: new Date(0).toISOString()
92
98
  },
93
99
  templateConfig: {},
94
100
  logger: {
@@ -126,6 +132,9 @@ function createSmokeContext(templateId) {
126
132
  }
127
133
  },
128
134
  providers: {
135
+ listAvailableProviders() {
136
+ return ["openai", "gemini", "openrouter", "perplexity"];
137
+ },
129
138
  async generateText() {
130
139
  providerCalls += 1;
131
140
  return { text: "certification text", usage: { inputTokens: 1, outputTokens: 1, costUsd: 0 } };
@@ -14,8 +14,9 @@ function normalizeTemplateDefinition(template) {
14
14
  ...template,
15
15
  about: {
16
16
  ...template.about,
17
- // Older runtime template releases may predate proposal metadata fields.
17
+ // Older runtime template releases may predate prompt metadata fields.
18
18
  proposal_generator: template.about.proposal_generator ?? "",
19
+ skeleton_prompt: template.about.skeleton_prompt ?? "",
19
20
  sample_prompts: template.about.sample_prompts ?? []
20
21
  }
21
22
  };
@@ -46,6 +47,7 @@ function isTemplateDefinition(value) {
46
47
  previewMedia.every((entry) => typeof entry === "string") &&
47
48
  typeof about.link_to_original === "string" &&
48
49
  (typeof about.proposal_generator === "string" || typeof about.proposal_generator === "undefined") &&
50
+ (typeof about.skeleton_prompt === "string" || typeof about.skeleton_prompt === "undefined") &&
49
51
  (samplePrompts === null || samplePrompts.every((entry) => typeof entry === "string")) &&
50
52
  typeof candidate.configSchema === "object" &&
51
53
  typeof candidate.operations === "object" &&