@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.
- package/.env.example +9 -0
- package/GETTING_STARTED.developers.md +1 -26
- package/README.md +3 -0
- package/SKILL.developer.md +327 -4
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
- package/auto-create-templates/extractor-system-prompt.md +113 -0
- package/auto-create-templates/production-graph.schema.json +276 -0
- package/auto-create-templates/runbook.md +140 -0
- package/auto-create-templates/template-plan.schema.json +230 -0
- package/auto-create-templates/template-planner-prompt.md +75 -0
- package/dist/src/account-pages-legacy.js +9064 -0
- package/dist/src/account-pages.js +41 -620
- package/dist/src/app.js +6627 -244
- package/dist/src/cli.js +386 -26
- package/dist/src/config.js +12 -2
- package/dist/src/context.js +35 -35
- package/dist/src/db.js +1016 -53
- package/dist/src/dev-app-legacy.js +693 -0
- package/dist/src/dev-app.js +4 -904
- package/dist/src/domain.js +1 -1
- package/dist/src/editor-chat-history.js +72 -0
- package/dist/src/editor-chat.js +202 -0
- package/dist/src/frontend/flockposter-cache-store.js +116 -0
- package/dist/src/frontend/homepage-client.js +114 -0
- package/dist/src/frontend/homepage-shared.js +3 -0
- package/dist/src/frontend/homepage-store.js +27 -0
- package/dist/src/frontend/homepage-view.js +147 -0
- package/dist/src/frontend/page-runtime-client.js +100 -0
- package/dist/src/frontend/page-runtime-store.js +8 -0
- package/dist/src/frontend/template-editor-chat.js +2261 -0
- package/dist/src/homepage.js +695 -371
- package/dist/src/lib/template-style-options.js +95 -15
- package/dist/src/page-runtime.js +1 -0
- package/dist/src/page-shell.js +941 -0
- package/dist/src/primitive-context.js +163 -0
- package/dist/src/primitive-registry.js +340 -0
- package/dist/src/primitive-sdk.js +3 -0
- package/dist/src/primitives/remotion/html-image.js +28 -0
- package/dist/src/react-page-shell.js +34 -0
- package/dist/src/ready-post-schedule-component.js +1514 -0
- package/dist/src/registry.js +36 -17
- package/dist/src/runtime.js +6 -1
- package/dist/src/services/api-call-history.js +245 -0
- package/dist/src/services/auth.js +25 -3
- package/dist/src/services/billing.js +248 -9
- package/dist/src/services/chat-threads.js +88 -0
- package/dist/src/services/job-logs.js +14 -7
- package/dist/src/services/jobs.js +13 -2
- package/dist/src/services/providers.js +552 -133
- package/dist/src/services/rate-limits.js +236 -0
- package/dist/src/services/remotion.js +143 -16
- package/dist/src/services/storage.js +10 -8
- package/dist/src/services/template-certification.js +11 -2
- package/dist/src/services/template-loader.js +3 -1
- package/dist/src/services/template-sources.js +68 -3
- package/dist/src/template-editor-pages.js +2309 -0
- package/dist/src/template-editor-shell.js +1507 -0
- package/dist/src/worker.js +120 -22
- package/package.json +27 -4
- package/public/assets/homepage-app.js +54 -0
- package/public/assets/homepage-client-app.js +54 -0
- package/public/assets/page-runtime-client-app.js +68 -0
- package/dist/templates/vidfarm_template_0000/src/lib/images.js +0 -202
- package/dist/templates/vidfarm_template_0000/src/remotion/Root.js +0 -34
- package/dist/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
- package/dist/templates/vidfarm_template_0000/src/sdk.js +0 -3
- package/dist/templates/vidfarm_template_0000/src/style-options.js +0 -51
- package/dist/templates/vidfarm_template_0000/src/template-dna.js +0 -9
- package/dist/templates/vidfarm_template_0000/src/template.js +0 -1447
- package/templates/vidfarm_template_0000/README.md +0 -100
- package/templates/vidfarm_template_0000/SKILL.md +0 -225
- package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/composition.json +0 -11
- package/templates/vidfarm_template_0000/package.json +0 -27
- package/templates/vidfarm_template_0000/research/preview/.gitkeep +0 -1
- package/templates/vidfarm_template_0000/research/source_notes.md +0 -7
- package/templates/vidfarm_template_0000/src/lib/images.js +0 -202
- package/templates/vidfarm_template_0000/src/lib/images.ts +0 -241
- package/templates/vidfarm_template_0000/src/remotion/Root.js +0 -33
- package/templates/vidfarm_template_0000/src/remotion/Root.tsx +0 -75
- package/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
- package/templates/vidfarm_template_0000/src/remotion/index.tsx +0 -4
- package/templates/vidfarm_template_0000/src/sdk.js +0 -3
- package/templates/vidfarm_template_0000/src/sdk.ts +0 -140
- package/templates/vidfarm_template_0000/src/style-options.js +0 -51
- package/templates/vidfarm_template_0000/src/style-options.ts +0 -60
- package/templates/vidfarm_template_0000/src/template-dna.js +0 -9
- package/templates/vidfarm_template_0000/src/template-dna.ts +0 -15
- package/templates/vidfarm_template_0000/src/template.js +0 -1447
- package/templates/vidfarm_template_0000/src/template.ts +0 -2049
- package/templates/vidfarm_template_0000/template.config.json +0 -21
- package/templates/vidfarm_template_0000/tmp/solobacterium-moorei-slideshow.request.json +0 -31
- 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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
51
|
-
bucketName:
|
|
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("
|
|
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
|
|
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
|
|
128
|
+
const composition = await this.resolveComposition({
|
|
129
|
+
source: bundled,
|
|
130
|
+
compositionId: input.compositionId,
|
|
82
131
|
inputProps: input.inputProps
|
|
83
132
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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("
|
|
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
|
|
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" &&
|