@mevdragon/vidfarm-devcli 0.1.0
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 +41 -0
- package/AWS_REMOTION_HANDOFF.md +311 -0
- package/PLATFORM_SPEC.md +898 -0
- package/README.md +151 -0
- package/dist/src/app.js +224 -0
- package/dist/src/cli.js +187 -0
- package/dist/src/config.js +52 -0
- package/dist/src/context.js +198 -0
- package/dist/src/db.js +588 -0
- package/dist/src/dev-app.js +859 -0
- package/dist/src/domain.js +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/lib/crypto.js +30 -0
- package/dist/src/lib/ids.js +4 -0
- package/dist/src/lib/images.js +18 -0
- package/dist/src/lib/json.js +14 -0
- package/dist/src/lib/time.js +6 -0
- package/dist/src/registry.js +10 -0
- package/dist/src/runtime.js +19 -0
- package/dist/src/services/auth.js +80 -0
- package/dist/src/services/billing.js +16 -0
- package/dist/src/services/jobs.js +97 -0
- package/dist/src/services/providers.js +529 -0
- package/dist/src/services/remotion.js +158 -0
- package/dist/src/services/storage.js +93 -0
- package/dist/src/services/webhooks.js +61 -0
- package/dist/src/template-sdk.js +3 -0
- package/dist/src/worker.js +122 -0
- package/dist/templates/template_0000/demo-template.js +196 -0
- package/dist/templates/template_0000/remotion/Root.js +66 -0
- package/dist/templates/template_0000/remotion/index.js +3 -0
- package/package.json +59 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
4
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
export class StorageService {
|
|
7
|
+
localRoot = path.join(config.VIDFARM_DATA_DIR, "storage");
|
|
8
|
+
s3 = config.STORAGE_DRIVER === "s3"
|
|
9
|
+
? new S3Client({
|
|
10
|
+
region: config.AWS_REGION,
|
|
11
|
+
endpoint: config.AWS_S3_ENDPOINT || undefined
|
|
12
|
+
})
|
|
13
|
+
: null;
|
|
14
|
+
constructor() {
|
|
15
|
+
mkdirSync(this.localRoot, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
async putJson(key, value) {
|
|
18
|
+
const body = JSON.stringify(value, null, 2);
|
|
19
|
+
return this.putBuffer(key, Buffer.from(body, "utf8"), "application/json");
|
|
20
|
+
}
|
|
21
|
+
async putText(key, value, contentType = "text/plain; charset=utf-8") {
|
|
22
|
+
return this.putBuffer(key, Buffer.from(value, "utf8"), contentType);
|
|
23
|
+
}
|
|
24
|
+
async putBuffer(key, value, contentType = "application/octet-stream") {
|
|
25
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
26
|
+
await this.s3.send(new PutObjectCommand({
|
|
27
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
28
|
+
Key: key,
|
|
29
|
+
Body: value,
|
|
30
|
+
ContentType: contentType
|
|
31
|
+
}));
|
|
32
|
+
return { key, url: this.getPublicUrl(key) };
|
|
33
|
+
}
|
|
34
|
+
const filePath = path.join(this.localRoot, key);
|
|
35
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
36
|
+
writeFileSync(filePath, value);
|
|
37
|
+
return { key, url: this.getPublicUrl(key) };
|
|
38
|
+
}
|
|
39
|
+
getPublicUrl(key) {
|
|
40
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
41
|
+
const encodedKey = key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
42
|
+
if (config.AWS_S3_ENDPOINT) {
|
|
43
|
+
return `${config.AWS_S3_ENDPOINT.replace(/\/$/, "")}/${config.AWS_S3_BUCKET}/${encodedKey}`;
|
|
44
|
+
}
|
|
45
|
+
return `https://${config.AWS_S3_BUCKET}.s3.${config.AWS_REGION}.amazonaws.com/${encodedKey}`;
|
|
46
|
+
}
|
|
47
|
+
return `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(key)}`;
|
|
48
|
+
}
|
|
49
|
+
readLocalJson(key) {
|
|
50
|
+
return JSON.parse(readFileSync(path.join(this.localRoot, key), "utf8"));
|
|
51
|
+
}
|
|
52
|
+
readLocalObject(key) {
|
|
53
|
+
const filePath = path.join(this.localRoot, key);
|
|
54
|
+
const body = readFileSync(filePath);
|
|
55
|
+
return {
|
|
56
|
+
body,
|
|
57
|
+
contentType: inferContentType(filePath)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async createPresignedWorkspaceUpload(customerId, relativePath, contentType) {
|
|
61
|
+
const key = `customers/${customerId}/workspace/${relativePath}`;
|
|
62
|
+
if (this.s3 && config.AWS_S3_BUCKET) {
|
|
63
|
+
const command = new PutObjectCommand({
|
|
64
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
65
|
+
Key: key,
|
|
66
|
+
ContentType: contentType
|
|
67
|
+
});
|
|
68
|
+
const url = await getSignedUrl(this.s3, command, { expiresIn: 900 });
|
|
69
|
+
return { key, url, method: "PUT" };
|
|
70
|
+
}
|
|
71
|
+
const url = `${config.PUBLIC_BASE_URL}/storage/${encodeURIComponent(key)}`;
|
|
72
|
+
return { key, url, method: "PUT" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function inferContentType(filePath) {
|
|
76
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
77
|
+
case ".json":
|
|
78
|
+
return "application/json";
|
|
79
|
+
case ".png":
|
|
80
|
+
return "image/png";
|
|
81
|
+
case ".jpg":
|
|
82
|
+
case ".jpeg":
|
|
83
|
+
return "image/jpeg";
|
|
84
|
+
case ".svg":
|
|
85
|
+
return "image/svg+xml";
|
|
86
|
+
case ".mp4":
|
|
87
|
+
return "video/mp4";
|
|
88
|
+
case ".txt":
|
|
89
|
+
return "text/plain; charset=utf-8";
|
|
90
|
+
default:
|
|
91
|
+
return "application/octet-stream";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { database } from "../db.js";
|
|
4
|
+
import { createId } from "../lib/ids.js";
|
|
5
|
+
import { addSeconds, nowIso } from "../lib/time.js";
|
|
6
|
+
export class WebhookService {
|
|
7
|
+
queueJobEvent(input) {
|
|
8
|
+
database.queueWebhookDelivery({
|
|
9
|
+
id: createId("whd"),
|
|
10
|
+
jobId: input.jobId,
|
|
11
|
+
customerId: input.customerId,
|
|
12
|
+
destinationUrl: input.destinationUrl,
|
|
13
|
+
eventType: input.eventType,
|
|
14
|
+
payload: input.payload
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async flushPending(limit = 20) {
|
|
18
|
+
const deliveries = database.listPendingWebhookDeliveries(limit);
|
|
19
|
+
for (const delivery of deliveries) {
|
|
20
|
+
const payload = String(delivery.payload_json);
|
|
21
|
+
const signature = createHmac("sha256", config.WEBHOOK_SECRET).update(payload).digest("hex");
|
|
22
|
+
const attemptCount = Number(delivery.attempt_count) + 1;
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(String(delivery.destination_url), {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"x-vidfarm-signature": signature,
|
|
29
|
+
"x-vidfarm-event": String(delivery.event_type)
|
|
30
|
+
},
|
|
31
|
+
body: payload
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(`Webhook returned ${response.status}`);
|
|
35
|
+
}
|
|
36
|
+
database.markWebhookDelivery({
|
|
37
|
+
id: String(delivery.id),
|
|
38
|
+
status: "delivered",
|
|
39
|
+
attemptCount
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const retriesLeft = attemptCount < 6;
|
|
44
|
+
database.markWebhookDelivery({
|
|
45
|
+
id: String(delivery.id),
|
|
46
|
+
status: retriesLeft ? "retrying" : "failed",
|
|
47
|
+
attemptCount,
|
|
48
|
+
nextAttemptAt: retriesLeft ? addSeconds(new Date(), Math.min(300, attemptCount * 30)) : undefined,
|
|
49
|
+
lastError: error instanceof Error ? error.message : "Unknown webhook error"
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
buildJobPayload(eventType, job) {
|
|
55
|
+
return {
|
|
56
|
+
type: eventType,
|
|
57
|
+
emitted_at: nowIso(),
|
|
58
|
+
job
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { config } from "./config.js";
|
|
2
|
+
import { createTemplateJobContext } from "./context.js";
|
|
3
|
+
import { database } from "./db.js";
|
|
4
|
+
import { templateRegistry } from "./registry.js";
|
|
5
|
+
import { BillingService } from "./services/billing.js";
|
|
6
|
+
import { JobsService } from "./services/jobs.js";
|
|
7
|
+
import { ProviderAuthError, ProviderKeyUnavailableError, ProviderRateLimitError, ProviderService } from "./services/providers.js";
|
|
8
|
+
import { RemotionService } from "./services/remotion.js";
|
|
9
|
+
import { StorageService } from "./services/storage.js";
|
|
10
|
+
import { WebhookService } from "./services/webhooks.js";
|
|
11
|
+
export class Worker {
|
|
12
|
+
timer = null;
|
|
13
|
+
workerId = `worker-${process.pid}`;
|
|
14
|
+
jobs = new JobsService();
|
|
15
|
+
billing = new BillingService();
|
|
16
|
+
providers = new ProviderService();
|
|
17
|
+
storage = new StorageService();
|
|
18
|
+
remotion = new RemotionService();
|
|
19
|
+
webhooks = new WebhookService();
|
|
20
|
+
running = false;
|
|
21
|
+
start() {
|
|
22
|
+
this.timer = setInterval(() => void this.tick(), config.WORKER_POLL_MS);
|
|
23
|
+
void this.tick();
|
|
24
|
+
}
|
|
25
|
+
stop() {
|
|
26
|
+
if (this.timer) {
|
|
27
|
+
clearInterval(this.timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async tick() {
|
|
31
|
+
if (this.running) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.running = true;
|
|
35
|
+
try {
|
|
36
|
+
const runnable = database.listRunnableJobs(config.WORKER_BATCH_SIZE);
|
|
37
|
+
for (const job of runnable) {
|
|
38
|
+
await this.runJob(job.id);
|
|
39
|
+
}
|
|
40
|
+
await this.webhooks.flushPending();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
this.running = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async runJob(jobId) {
|
|
47
|
+
const job = this.jobs.getJob(jobId);
|
|
48
|
+
if (!job || job.status !== "queued") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const customer = database.getCustomerById(job.customerId);
|
|
52
|
+
const template = templateRegistry.get(job.templateId);
|
|
53
|
+
if (!customer || !template) {
|
|
54
|
+
this.jobs.failJob(job.id, { message: "Missing customer or template." });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const workflow = template.jobs[job.workflowName];
|
|
58
|
+
if (!workflow) {
|
|
59
|
+
this.jobs.failJob(job.id, { message: `Unknown workflow ${job.workflowName}` });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.jobs.markRunning(job.id);
|
|
63
|
+
await this.emitWebhookIfNeeded("job.running", job.id);
|
|
64
|
+
const ctx = createTemplateJobContext({
|
|
65
|
+
customer,
|
|
66
|
+
job,
|
|
67
|
+
template,
|
|
68
|
+
workerId: this.workerId,
|
|
69
|
+
storage: this.storage,
|
|
70
|
+
jobs: this.jobs,
|
|
71
|
+
billing: this.billing,
|
|
72
|
+
providers: this.providers,
|
|
73
|
+
remotion: this.remotion
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const result = await workflow(ctx, job.payload);
|
|
77
|
+
this.jobs.succeedJob(job.id, result.output ?? {}, result.progress ?? 1);
|
|
78
|
+
await this.emitWebhookIfNeeded("job.succeeded", job.id);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof ProviderKeyUnavailableError) {
|
|
82
|
+
this.jobs.requeueJob(job, config.DEFAULT_JOB_DELAY_SECONDS, { message: error.message, type: "provider_key_unavailable" });
|
|
83
|
+
await this.emitWebhookIfNeeded("job.queued", job.id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof ProviderRateLimitError) {
|
|
87
|
+
this.jobs.requeueJob(job, error.retryAfterSeconds, { message: error.message, type: "provider_rate_limited" });
|
|
88
|
+
await this.emitWebhookIfNeeded("job.queued", job.id);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (error instanceof ProviderAuthError) {
|
|
92
|
+
this.jobs.requeueJob(job, 5, { message: error.message, type: "provider_auth_error" });
|
|
93
|
+
await this.emitWebhookIfNeeded("job.queued", job.id);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.jobs.failJob(job.id, { message: error instanceof Error ? error.message : "Unknown worker error" });
|
|
97
|
+
await this.emitWebhookIfNeeded("job.failed", job.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async emitWebhookIfNeeded(eventType, jobId) {
|
|
101
|
+
const job = this.jobs.getJob(jobId);
|
|
102
|
+
if (!job || !job.webhookUrl) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.webhooks.queueJobEvent({
|
|
106
|
+
jobId: job.id,
|
|
107
|
+
customerId: job.customerId,
|
|
108
|
+
destinationUrl: job.webhookUrl,
|
|
109
|
+
eventType,
|
|
110
|
+
payload: this.webhooks.buildJobPayload(eventType, {
|
|
111
|
+
job_id: job.id,
|
|
112
|
+
tracer: job.tracer,
|
|
113
|
+
status: job.status,
|
|
114
|
+
template_id: job.templateId,
|
|
115
|
+
operation_name: job.operationName,
|
|
116
|
+
progress: job.progress,
|
|
117
|
+
result: job.result,
|
|
118
|
+
error: job.error
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { normalizeToPortraitFrame } from "../../src/lib/images.js";
|
|
5
|
+
import { defineTemplate } from "../../src/template-sdk.js";
|
|
6
|
+
const slideInputSchema = z.tuple([z.string().min(3), z.string().min(1)]);
|
|
7
|
+
const generateInputSchema = z.object({
|
|
8
|
+
slides: z.array(slideInputSchema).min(1).max(20),
|
|
9
|
+
secondsPerSlide: z.number().min(2).max(10).default(4)
|
|
10
|
+
});
|
|
11
|
+
const remotionEntryPoint = resolveRemotionEntryPoint();
|
|
12
|
+
export const demoTemplate = defineTemplate({
|
|
13
|
+
id: "demo-template",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
description: "Opinionated slideshow generator that makes tall images, picks text placement, and renders a TikTok-ready Remotion video.",
|
|
16
|
+
configSchema: z.object({
|
|
17
|
+
defaultProvider: z.enum(["openai", "gemini"]).default("gemini"),
|
|
18
|
+
textModel: z.string().default("gemini-3.1-flash-lite"),
|
|
19
|
+
imageModel: z.string().default("gemini-2.5-flash-image"),
|
|
20
|
+
renderCompositionId: z.string().default("demo-template")
|
|
21
|
+
}),
|
|
22
|
+
operations: {
|
|
23
|
+
generate: {
|
|
24
|
+
description: "Generate a slideshow from [[imagePrompt, exactOverlayText], ...] and return public artifact URLs.",
|
|
25
|
+
inputSchema: generateInputSchema,
|
|
26
|
+
workflow: "generateWorkflow",
|
|
27
|
+
webhookSupport: true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
jobs: {
|
|
31
|
+
async generateWorkflow(ctx, input) {
|
|
32
|
+
const payload = generateInputSchema.parse(input);
|
|
33
|
+
ctx.logger.progress(0.04, "Starting demo slideshow pipeline");
|
|
34
|
+
const provider = String(ctx.templateConfig.defaultProvider ?? "gemini");
|
|
35
|
+
const textModel = String(ctx.templateConfig.textModel ?? "gemini-3.1-flash-lite");
|
|
36
|
+
const imageModel = String(ctx.templateConfig.imageModel ?? "gemini-2.5-flash-image");
|
|
37
|
+
const compositionId = String(ctx.templateConfig.renderCompositionId ?? "demo-template");
|
|
38
|
+
const slides = [];
|
|
39
|
+
for (const [index, [imagePrompt, overlayText]] of payload.slides.entries()) {
|
|
40
|
+
const prompt = buildImagePrompt(imagePrompt, overlayText);
|
|
41
|
+
ctx.logger.progress(0.08 + (index / payload.slides.length) * 0.42, `Generating slide ${index + 1} image`);
|
|
42
|
+
const image = await ctx.providers.generateImage({
|
|
43
|
+
provider,
|
|
44
|
+
model: imageModel,
|
|
45
|
+
prompt,
|
|
46
|
+
size: sourceImageSizeForProvider(provider)
|
|
47
|
+
});
|
|
48
|
+
await ctx.billing.record({
|
|
49
|
+
type: "ai_generation",
|
|
50
|
+
costUsd: 0.04,
|
|
51
|
+
metadata: { stage: "image_generation", slideIndex: index, model: imageModel }
|
|
52
|
+
});
|
|
53
|
+
ctx.logger.progress(0.13 + (index / payload.slides.length) * 0.42, `Normalizing slide ${index + 1} to strict 9:16 portrait`);
|
|
54
|
+
const normalizedImage = await normalizeToPortraitFrame(image.bytes, { width: 1080, height: 1920 });
|
|
55
|
+
const imageArtifact = await ctx.storage.putBuffer(`slides/slide-${pad2(index + 1)}.png`, normalizedImage.bytes, {
|
|
56
|
+
contentType: normalizedImage.contentType,
|
|
57
|
+
kind: "image",
|
|
58
|
+
metadata: {
|
|
59
|
+
slideIndex: index,
|
|
60
|
+
prompt,
|
|
61
|
+
revisedPrompt: image.revisedPrompt,
|
|
62
|
+
width: normalizedImage.width,
|
|
63
|
+
height: normalizedImage.height,
|
|
64
|
+
aspectRatio: "9:16"
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const layout = await safeAnalyzeLayout(ctx, {
|
|
68
|
+
imageUrl: imageArtifact.url,
|
|
69
|
+
overlayText,
|
|
70
|
+
textModel
|
|
71
|
+
});
|
|
72
|
+
await ctx.billing.record({
|
|
73
|
+
type: "ai_generation",
|
|
74
|
+
costUsd: 0.005,
|
|
75
|
+
metadata: { stage: "layout_analysis", slideIndex: index, model: textModel }
|
|
76
|
+
});
|
|
77
|
+
slides.push({
|
|
78
|
+
index,
|
|
79
|
+
imagePrompt,
|
|
80
|
+
overlayText,
|
|
81
|
+
imageUrl: imageArtifact.url,
|
|
82
|
+
prompt,
|
|
83
|
+
revisedPrompt: image.revisedPrompt,
|
|
84
|
+
layout
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
ctx.logger.progress(0.58, "Saving slideshow manifest");
|
|
88
|
+
const manifest = {
|
|
89
|
+
templateId: "demo-template",
|
|
90
|
+
size: { width: 1080, height: 1920, aspectRatio: "9:16" },
|
|
91
|
+
secondsPerSlide: payload.secondsPerSlide,
|
|
92
|
+
font: "Times New Roman",
|
|
93
|
+
slides
|
|
94
|
+
};
|
|
95
|
+
const manifestArtifact = await ctx.storage.putJson("manifests/demo-template.json", manifest);
|
|
96
|
+
ctx.logger.progress(0.72, "Submitting Remotion render");
|
|
97
|
+
const render = await ctx.remotion.render({
|
|
98
|
+
compositionId,
|
|
99
|
+
entryPoint: remotionEntryPoint,
|
|
100
|
+
outputKey: "renders/final.mp4",
|
|
101
|
+
inputProps: {
|
|
102
|
+
slides: slides.map((slide) => ({
|
|
103
|
+
imageUrl: slide.imageUrl,
|
|
104
|
+
text: slide.overlayText,
|
|
105
|
+
layout: slide.layout
|
|
106
|
+
})),
|
|
107
|
+
fps: 30,
|
|
108
|
+
secondsPerSlide: payload.secondsPerSlide
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await ctx.billing.record({
|
|
112
|
+
type: "render",
|
|
113
|
+
costUsd: 0.35,
|
|
114
|
+
metadata: render.metadata
|
|
115
|
+
});
|
|
116
|
+
const files = slides.map((slide) => slide.imageUrl).filter((value) => Boolean(value));
|
|
117
|
+
if (manifestArtifact.url) {
|
|
118
|
+
files.push(manifestArtifact.url);
|
|
119
|
+
}
|
|
120
|
+
if (render.outputUrl) {
|
|
121
|
+
files.push(render.outputUrl);
|
|
122
|
+
}
|
|
123
|
+
ctx.logger.progress(1, "Demo slideshow complete", {
|
|
124
|
+
fileCount: files.length,
|
|
125
|
+
renderId: render.renderId
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
progress: 1,
|
|
129
|
+
output: {
|
|
130
|
+
files,
|
|
131
|
+
render,
|
|
132
|
+
manifest: manifestArtifact,
|
|
133
|
+
slides
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
function buildImagePrompt(imagePrompt, overlayText) {
|
|
140
|
+
return [
|
|
141
|
+
"Create an exact 9:16 portrait slideshow image for a TikTok-style vertical video.",
|
|
142
|
+
"The composition must be designed for strict full-frame mobile portrait output at 1080x1920.",
|
|
143
|
+
"Do not produce square, landscape, or loose portrait framing.",
|
|
144
|
+
"Do not letterbox, pillarbox, add borders, or leave empty margins.",
|
|
145
|
+
"Keep the subject composition strong but leave one large clean zone with low visual detail for text overlay.",
|
|
146
|
+
"Do not place important faces, hands, or product details in the top or bottom caption-safe regions.",
|
|
147
|
+
"Do not render any words, letters, captions, subtitles, titles, logos, signage, labels, watermarks, or typography inside the image.",
|
|
148
|
+
"The final image must contain zero visible text.",
|
|
149
|
+
"Use cinematic lighting, crisp detail, and framing that already fits an exact 9:16 portrait frame.",
|
|
150
|
+
`User visual prompt: ${imagePrompt}`,
|
|
151
|
+
`Reserve room for a short editorial overlay approximately ${overlayText.length} characters long, but do not render the overlay text itself.`
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
async function safeAnalyzeLayout(ctx, input) {
|
|
155
|
+
if (!input.imageUrl) {
|
|
156
|
+
return defaultLayout();
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return await ctx.providers.analyzeImageLayout({
|
|
160
|
+
provider: String(ctx.templateConfig.defaultProvider ?? "gemini"),
|
|
161
|
+
model: input.textModel,
|
|
162
|
+
imageUrl: input.imageUrl,
|
|
163
|
+
overlayText: input.overlayText
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
ctx.logger.warn("Layout analysis failed, using default placement", {
|
|
168
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
169
|
+
});
|
|
170
|
+
return defaultLayout();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function defaultLayout() {
|
|
174
|
+
return {
|
|
175
|
+
zone: "bottom",
|
|
176
|
+
align: "center",
|
|
177
|
+
maxWidthPercent: 82,
|
|
178
|
+
justification: "Fallback placement keeps the overlay in a readable lower safe zone."
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function sourceImageSizeForProvider(provider) {
|
|
182
|
+
if (provider === "openai") {
|
|
183
|
+
return "1024x1792";
|
|
184
|
+
}
|
|
185
|
+
return "1080x1920";
|
|
186
|
+
}
|
|
187
|
+
function pad2(value) {
|
|
188
|
+
return String(value).padStart(2, "0");
|
|
189
|
+
}
|
|
190
|
+
function resolveRemotionEntryPoint() {
|
|
191
|
+
const builtPath = fileURLToPath(new URL("./remotion/index.js", import.meta.url));
|
|
192
|
+
if (existsSync(builtPath)) {
|
|
193
|
+
return builtPath;
|
|
194
|
+
}
|
|
195
|
+
return fileURLToPath(new URL("./remotion/index.tsx", import.meta.url));
|
|
196
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AbsoluteFill, Composition, Img, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
export const RemotionRoot = () => {
|
|
4
|
+
return (_jsx(Composition, { id: "demo-template", component: DemoTemplateVideo, width: 1080, height: 1920, fps: 30, durationInFrames: 120, defaultProps: { slides: [], secondsPerSlide: 4 }, calculateMetadata: ({ props }) => ({
|
|
5
|
+
width: 1080,
|
|
6
|
+
height: 1920,
|
|
7
|
+
durationInFrames: Math.max(1, props.slides.length) * Math.max(1, Math.round((props.secondsPerSlide ?? 4) * 30))
|
|
8
|
+
}) }));
|
|
9
|
+
};
|
|
10
|
+
const DemoTemplateVideo = ({ slides, secondsPerSlide = 4 }) => {
|
|
11
|
+
const { fps } = useVideoConfig();
|
|
12
|
+
const framesPerSlide = Math.max(1, Math.round(secondsPerSlide * fps));
|
|
13
|
+
return (_jsx(AbsoluteFill, { style: { backgroundColor: "#120f0b" }, children: slides.map((slide, index) => (_jsx(Sequence, { from: index * framesPerSlide, durationInFrames: framesPerSlide, children: _jsx(SlideFrame, { slide: slide }) }, `${slide.imageUrl}-${index}`))) }));
|
|
14
|
+
};
|
|
15
|
+
const SlideFrame = ({ slide }) => {
|
|
16
|
+
const frame = useCurrentFrame();
|
|
17
|
+
const { fps } = useVideoConfig();
|
|
18
|
+
const entrance = spring({ fps, frame, config: { damping: 200, stiffness: 140 } });
|
|
19
|
+
const scale = interpolate(entrance, [0, 1], [1.06, 1]);
|
|
20
|
+
const opacity = interpolate(entrance, [0, 1], [0.2, 1]);
|
|
21
|
+
return (_jsxs(AbsoluteFill, { children: [_jsx(AbsoluteFill, { style: { opacity, transform: `scale(${scale})` }, children: _jsx(Img, { src: slide.imageUrl, style: { width: "100%", height: "100%", objectFit: "cover" } }) }), _jsx(AbsoluteFill, { style: { background: "linear-gradient(180deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 36%, rgba(0,0,0,0.34) 100%)" } }), _jsx(TextOverlay, { slide: slide })] }));
|
|
22
|
+
};
|
|
23
|
+
const TextOverlay = ({ slide }) => {
|
|
24
|
+
const zoneStyle = slide.layout.zone === "top"
|
|
25
|
+
? { top: "8%", bottom: "auto" }
|
|
26
|
+
: slide.layout.zone === "center"
|
|
27
|
+
? { top: "50%", bottom: "auto", transform: "translateY(-50%)" }
|
|
28
|
+
: { top: "auto", bottom: "9%" };
|
|
29
|
+
return (_jsx("div", { style: {
|
|
30
|
+
position: "absolute",
|
|
31
|
+
left: "9%",
|
|
32
|
+
right: "9%",
|
|
33
|
+
display: "flex",
|
|
34
|
+
justifyContent: alignmentToJustify(slide.layout.align),
|
|
35
|
+
pointerEvents: "none",
|
|
36
|
+
...zoneStyle
|
|
37
|
+
}, children: _jsx("div", { style: {
|
|
38
|
+
maxWidth: `${slide.layout.maxWidthPercent}%`,
|
|
39
|
+
width: `${slide.layout.maxWidthPercent}%`,
|
|
40
|
+
boxSizing: "border-box",
|
|
41
|
+
padding: "28px 32px",
|
|
42
|
+
color: "#fffaf2",
|
|
43
|
+
fontFamily: '"Times New Roman", Times, serif',
|
|
44
|
+
fontSize: 72,
|
|
45
|
+
lineHeight: 1.08,
|
|
46
|
+
letterSpacing: "-0.03em",
|
|
47
|
+
textAlign: slide.layout.align,
|
|
48
|
+
overflowWrap: "anywhere",
|
|
49
|
+
wordBreak: "break-word",
|
|
50
|
+
whiteSpace: "pre-wrap",
|
|
51
|
+
background: "rgba(18, 14, 10, 0.28)",
|
|
52
|
+
boxShadow: "0 18px 44px rgba(0, 0, 0, 0.22)",
|
|
53
|
+
textShadow: "0 2px 6px rgba(0, 0, 0, 0.48)",
|
|
54
|
+
backdropFilter: "blur(8px)"
|
|
55
|
+
}, children: slide.text }) }));
|
|
56
|
+
};
|
|
57
|
+
function alignmentToJustify(align) {
|
|
58
|
+
switch (align) {
|
|
59
|
+
case "left":
|
|
60
|
+
return "flex-start";
|
|
61
|
+
case "right":
|
|
62
|
+
return "flex-end";
|
|
63
|
+
default:
|
|
64
|
+
return "center";
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Developer CLI for running the Vidfarm local template platform.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vidfarm": "dist/src/cli.js",
|
|
8
|
+
"vidfarm-devcli": "dist/src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"PLATFORM_SPEC.md",
|
|
14
|
+
"AWS_REMOTION_HANDOFF.md",
|
|
15
|
+
".env.example"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "restricted"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/OfficeXApp/vidfarm-devcli.git"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.0.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsx watch src/index.ts",
|
|
29
|
+
"dev:cli": "tsx src/cli.ts dev",
|
|
30
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
|
|
31
|
+
"start": "node dist/src/index.js",
|
|
32
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"prepack": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@aws-sdk/client-s3": "^3.787.0",
|
|
37
|
+
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
|
38
|
+
"@hono/node-server": "^1.14.4",
|
|
39
|
+
"@remotion/bundler": "4.0.355",
|
|
40
|
+
"@remotion/lambda": "4.0.355",
|
|
41
|
+
"@remotion/renderer": "4.0.355",
|
|
42
|
+
"better-sqlite3": "^12.1.1",
|
|
43
|
+
"dotenv": "^16.5.0",
|
|
44
|
+
"hono": "^4.8.3",
|
|
45
|
+
"react": "^18.3.1",
|
|
46
|
+
"react-dom": "^18.3.1",
|
|
47
|
+
"remotion": "4.0.355",
|
|
48
|
+
"sharp": "^0.34.5",
|
|
49
|
+
"zod": "^3.25.28"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
53
|
+
"@types/node": "^24.0.1",
|
|
54
|
+
"@types/react": "^18.3.23",
|
|
55
|
+
"@types/react-dom": "^18.3.7",
|
|
56
|
+
"tsx": "^4.19.4",
|
|
57
|
+
"typescript": "^5.8.3"
|
|
58
|
+
}
|
|
59
|
+
}
|