@mevdragon/vidfarm-devcli 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.director.md +70 -16
- package/SKILL.platform.md +13 -7
- package/demo/dist/app.js +110 -60
- package/dist/src/app.js +579 -57
- package/dist/src/cli.js +304 -136
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +5 -4
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +261 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/dist/src/services/upstream.js +248 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { config } from "./config.js";
|
|
7
7
|
import { definePrimitive } from "./primitive-sdk.js";
|
|
8
8
|
import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
|
|
9
|
+
import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
9
10
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
10
11
|
const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
11
12
|
const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
@@ -360,6 +361,9 @@ const trimAudioPayloadSchema = z.object({
|
|
|
360
361
|
const probeVideoPayloadSchema = z.object({
|
|
361
362
|
source_video_url: mediaSourceUrlSchema
|
|
362
363
|
});
|
|
364
|
+
const removeVideoCaptionsPayloadSchema = z.object({
|
|
365
|
+
source_video_url: mediaSourceUrlSchema
|
|
366
|
+
});
|
|
363
367
|
const probeAudioPayloadSchema = z.object({
|
|
364
368
|
source_audio_url: mediaSourceUrlSchema
|
|
365
369
|
});
|
|
@@ -408,6 +412,14 @@ const downloadVideoPayloadSchema = z.preprocess((raw) => {
|
|
|
408
412
|
quality: z.enum(["best", "hd", "full_hd"]).default("best"),
|
|
409
413
|
save_manifest: z.boolean().default(true)
|
|
410
414
|
}));
|
|
415
|
+
// Ingest of an already-uploaded video file (Add Template → upload). The URL is
|
|
416
|
+
// server-minted (our own storage), so no social-host restrictions apply.
|
|
417
|
+
const ingestVideoPayloadSchema = z.object({
|
|
418
|
+
source_video_url: z.string().url(),
|
|
419
|
+
title: z.string().min(1).max(300).optional(),
|
|
420
|
+
file_name: z.string().min(1).max(300).optional(),
|
|
421
|
+
save_manifest: z.boolean().default(true)
|
|
422
|
+
});
|
|
411
423
|
const brainstormHooksPayloadSchema = z.preprocess(normalizeOfferDescriptionPayload, z.object({
|
|
412
424
|
offer_description: z.string().min(10).max(12_000),
|
|
413
425
|
count: brainstormCountSchema,
|
|
@@ -476,7 +488,9 @@ const PRIMITIVE_VIDEO_GENERATE_ID = "primitive:video_generate";
|
|
|
476
488
|
const PRIMITIVE_VIDEO_RENDER_SLIDES_ID = "primitive:video_render_slides";
|
|
477
489
|
const PRIMITIVE_VIDEO_NORMALIZE_ID = "primitive:video_normalize";
|
|
478
490
|
const PRIMITIVE_VIDEO_DOWNLOAD_ID = "primitive:video_download";
|
|
491
|
+
const PRIMITIVE_VIDEO_INGEST_ID = "primitive:video_ingest";
|
|
479
492
|
const PRIMITIVE_VIDEO_TRIM_ID = "primitive:video_trim";
|
|
493
|
+
const PRIMITIVE_VIDEO_REMOVE_CAPTIONS_ID = "primitive:video_remove_captions";
|
|
480
494
|
const PRIMITIVE_VIDEO_EXTRACT_AUDIO_ID = "primitive:video_extract_audio";
|
|
481
495
|
const PRIMITIVE_AUDIO_TRIM_ID = "primitive:audio_trim";
|
|
482
496
|
const PRIMITIVE_VIDEO_PROBE_ID = "primitive:video_probe";
|
|
@@ -1183,6 +1197,132 @@ const videoDownloadPrimitive = definePrimitive({
|
|
|
1183
1197
|
}
|
|
1184
1198
|
}
|
|
1185
1199
|
});
|
|
1200
|
+
// Upload-based sibling of video_download: the source video already sits in our
|
|
1201
|
+
// storage (uploaded via the Add Template flow), so there is no social lookup —
|
|
1202
|
+
// just mirror it into a durable job artifact, probe duration, and grab a
|
|
1203
|
+
// thumbnail frame. Output shape matches video_download so the inspiration
|
|
1204
|
+
// finalizer treats both identically.
|
|
1205
|
+
const videoIngestPrimitive = definePrimitive({
|
|
1206
|
+
id: PRIMITIVE_VIDEO_INGEST_ID,
|
|
1207
|
+
kind: "video_ingest",
|
|
1208
|
+
operations: {
|
|
1209
|
+
run: {
|
|
1210
|
+
description: "Ingest an uploaded source video file into a durable reusable platform MP4 with probed metadata and a thumbnail.",
|
|
1211
|
+
inputSchema: ingestVideoPayloadSchema,
|
|
1212
|
+
workflow: "run"
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
jobs: {
|
|
1216
|
+
async run(ctx, input) {
|
|
1217
|
+
const payload = ingestVideoPayloadSchema.parse(input);
|
|
1218
|
+
const sourceUrl = payload.source_video_url;
|
|
1219
|
+
const sourceHost = safeUrlHost(sourceUrl);
|
|
1220
|
+
ctx.logger.progress(0.12, "Fetching uploaded source video", { sourceUrl, sourceHost });
|
|
1221
|
+
const remoteVideo = await fetchRemoteBinary(sourceUrl, "video/mp4");
|
|
1222
|
+
const contentType = normalizeVideoContentType(remoteVideo.contentType);
|
|
1223
|
+
const extension = videoExtensionForContentType(contentType);
|
|
1224
|
+
const storedVideo = await storePrimitiveVideo(ctx, {
|
|
1225
|
+
key: `source-video.${extension}`,
|
|
1226
|
+
bytes: remoteVideo.bytes,
|
|
1227
|
+
contentType,
|
|
1228
|
+
metadata: {
|
|
1229
|
+
primitive: "video_ingest",
|
|
1230
|
+
operation: "ingest-video",
|
|
1231
|
+
source_url: sourceUrl,
|
|
1232
|
+
source_host: sourceHost,
|
|
1233
|
+
file_name: payload.file_name ?? null
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
// Best-effort enrichment: a probe past the duration cap or a failed
|
|
1237
|
+
// thumbnail must not sink the ingest — the card still finalizes and the
|
|
1238
|
+
// template can decompose later.
|
|
1239
|
+
let durationSeconds = null;
|
|
1240
|
+
ctx.logger.progress(0.55, "Probing uploaded video metadata");
|
|
1241
|
+
try {
|
|
1242
|
+
const probed = await executePrimitiveMediaOperation(ctx, {
|
|
1243
|
+
operation: "video_probe",
|
|
1244
|
+
payload: { source_video_url: storedVideo.url },
|
|
1245
|
+
outputKey: "probe.json"
|
|
1246
|
+
});
|
|
1247
|
+
const probedDuration = Number(probed.metadata?.durationSeconds);
|
|
1248
|
+
durationSeconds = Number.isFinite(probedDuration) && probedDuration > 0 ? probedDuration : null;
|
|
1249
|
+
}
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
ctx.logger.progress(0.6, "Video probe skipped", {
|
|
1252
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
let thumbnailUrl = null;
|
|
1256
|
+
ctx.logger.progress(0.7, "Extracting thumbnail frame");
|
|
1257
|
+
try {
|
|
1258
|
+
const frame = await executePrimitiveMediaOperation(ctx, {
|
|
1259
|
+
operation: "video_extract_frame",
|
|
1260
|
+
payload: { source_video_url: storedVideo.url, at_ms: 0, output_format: "jpeg" },
|
|
1261
|
+
outputKey: "thumbnail.jpg"
|
|
1262
|
+
});
|
|
1263
|
+
thumbnailUrl = frame.publicUrl;
|
|
1264
|
+
}
|
|
1265
|
+
catch (error) {
|
|
1266
|
+
ctx.logger.progress(0.75, "Thumbnail extraction skipped", {
|
|
1267
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
const manifest = {
|
|
1271
|
+
primitive: "video_ingest",
|
|
1272
|
+
operation: "ingest-video",
|
|
1273
|
+
sourceUrl,
|
|
1274
|
+
sourceHost,
|
|
1275
|
+
// Caller-chosen title only — never the file name. Untitled ingests
|
|
1276
|
+
// finalize with title null so the template displays as its id.
|
|
1277
|
+
title: payload.title ?? null,
|
|
1278
|
+
thumbnail: thumbnailUrl,
|
|
1279
|
+
duration: durationSeconds,
|
|
1280
|
+
source: "upload",
|
|
1281
|
+
videoUrl: storedVideo.url
|
|
1282
|
+
};
|
|
1283
|
+
const manifestArtifact = payload.save_manifest
|
|
1284
|
+
? await ctx.storage.putJson("manifest.json", manifest)
|
|
1285
|
+
: null;
|
|
1286
|
+
await ctx.billing.record({
|
|
1287
|
+
type: "cpu_estimate",
|
|
1288
|
+
costUsd: config.RAPIDAPI_VIDEO_DOWNLOAD_LAMBDA_COST_USD,
|
|
1289
|
+
costCenterSlug: "video_ingest_lambda",
|
|
1290
|
+
idempotencyKey: `video_ingest_lambda:${sourceUrl}:${storedVideo.key}`,
|
|
1291
|
+
occurredAtMs: Date.now(),
|
|
1292
|
+
metadata: {
|
|
1293
|
+
operation: "ingest-video",
|
|
1294
|
+
source_url: sourceUrl,
|
|
1295
|
+
source_host: sourceHost,
|
|
1296
|
+
output_key: storedVideo.key,
|
|
1297
|
+
content_type: contentType,
|
|
1298
|
+
bytes: remoteVideo.bytes.length
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
ctx.logger.progress(1, "Video ingest complete", {
|
|
1302
|
+
videoUrl: storedVideo.url,
|
|
1303
|
+
durationSeconds,
|
|
1304
|
+
thumbnailUrl
|
|
1305
|
+
});
|
|
1306
|
+
return {
|
|
1307
|
+
progress: 1,
|
|
1308
|
+
output: {
|
|
1309
|
+
files: compactUrls([storedVideo.url, thumbnailUrl, manifestArtifact?.url]),
|
|
1310
|
+
sourceUrl,
|
|
1311
|
+
sourceHost,
|
|
1312
|
+
videoUrl: storedVideo.url,
|
|
1313
|
+
sourceVideoUrl: storedVideo.url,
|
|
1314
|
+
primary_file_url: storedVideo.url,
|
|
1315
|
+
title: manifest.title,
|
|
1316
|
+
thumbnail: thumbnailUrl,
|
|
1317
|
+
duration: durationSeconds,
|
|
1318
|
+
// Manifest CONTENT (not the artifact record) so the inspiration
|
|
1319
|
+
// finalizer can read thumbnail/title/duration directly.
|
|
1320
|
+
manifest
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1186
1326
|
const videoTrimPrimitive = definePrimitive({
|
|
1187
1327
|
id: PRIMITIVE_VIDEO_TRIM_ID,
|
|
1188
1328
|
kind: "video_trim",
|
|
@@ -1222,6 +1362,125 @@ const videoTrimPrimitive = definePrimitive({
|
|
|
1222
1362
|
}
|
|
1223
1363
|
}
|
|
1224
1364
|
});
|
|
1365
|
+
// GhostCut task polling budget. The job runner lambda has a 15-minute
|
|
1366
|
+
// timeout; probe + submit + mirror take up the rest. GhostCut usually clears
|
|
1367
|
+
// a sub-3-minute clip in a few minutes.
|
|
1368
|
+
const REMOVE_CAPTIONS_POLL_TIMEOUT_MS = 12 * 60 * 1000;
|
|
1369
|
+
const REMOVE_CAPTIONS_POLL_INTERVAL_MS = 5000;
|
|
1370
|
+
// Removes burned-in captions/subtitles/on-screen text from a source video via
|
|
1371
|
+
// the GhostCut inpainting service — the same plumbing auto-decompose uses
|
|
1372
|
+
// through src/services/ghostcut.ts. Unlike the fork-scoped
|
|
1373
|
+
// /remove-video-captions(-poll) composition routes, where the CLIENT drives
|
|
1374
|
+
// completion to keep per-request lambda time bounded, this primitive runs
|
|
1375
|
+
// inside the job runner and polls GhostCut to completion itself.
|
|
1376
|
+
const videoRemoveCaptionsPrimitive = definePrimitive({
|
|
1377
|
+
id: PRIMITIVE_VIDEO_REMOVE_CAPTIONS_ID,
|
|
1378
|
+
kind: "video_remove_captions",
|
|
1379
|
+
operations: {
|
|
1380
|
+
run: {
|
|
1381
|
+
description: "Remove burned-in captions/subtitles/on-screen text from a source video into a reusable caption-free platform MP4.",
|
|
1382
|
+
inputSchema: removeVideoCaptionsPayloadSchema,
|
|
1383
|
+
workflow: "run"
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
jobs: {
|
|
1387
|
+
async run(ctx, input) {
|
|
1388
|
+
const payload = removeVideoCaptionsPayloadSchema.parse(input);
|
|
1389
|
+
if (!isGhostcutConfigured()) {
|
|
1390
|
+
throw new Error("Caption removal is unavailable: GHOSTCUT_KEY/GHOSTCUT_SECRET are not configured on this deployment.");
|
|
1391
|
+
}
|
|
1392
|
+
// Probe first: confirms the source actually has a video stream and
|
|
1393
|
+
// captures the duration that prices the GhostCut task (per-30s chunks).
|
|
1394
|
+
ctx.logger.progress(0.05, "Probing source video");
|
|
1395
|
+
const probed = await executePrimitiveMediaOperation(ctx, {
|
|
1396
|
+
operation: "video_probe",
|
|
1397
|
+
payload: { source_video_url: payload.source_video_url },
|
|
1398
|
+
outputKey: "probe.json"
|
|
1399
|
+
});
|
|
1400
|
+
const probeMetadata = (probed.metadata ?? {});
|
|
1401
|
+
if (probeMetadata.hasVideo === false) {
|
|
1402
|
+
throw new Error("source_video_url has no video stream — caption removal only works on videos.");
|
|
1403
|
+
}
|
|
1404
|
+
const probedDurationSeconds = Number(probeMetadata.durationSeconds ?? 0);
|
|
1405
|
+
const durationSeconds = Number.isFinite(probedDurationSeconds) && probedDurationSeconds > 0 ? probedDurationSeconds : 30;
|
|
1406
|
+
const cost = estimateGhostcutCostUsd(durationSeconds);
|
|
1407
|
+
ctx.logger.progress(0.12, "Submitting caption-removal task", {
|
|
1408
|
+
durationSeconds: Number(durationSeconds.toFixed(3)),
|
|
1409
|
+
estimatedChunks: cost.chunks,
|
|
1410
|
+
estimatedUsd: cost.usd
|
|
1411
|
+
});
|
|
1412
|
+
const submission = await submitSubtitleRemoval(payload.source_video_url);
|
|
1413
|
+
const startedAtMs = Date.now();
|
|
1414
|
+
let cleanedVideoUrl = null;
|
|
1415
|
+
while (cleanedVideoUrl === null) {
|
|
1416
|
+
if (Date.now() - startedAtMs > REMOVE_CAPTIONS_POLL_TIMEOUT_MS) {
|
|
1417
|
+
throw new Error(`GhostCut task ${submission.taskId} did not finish within ${Math.round(REMOVE_CAPTIONS_POLL_TIMEOUT_MS / 60000)} minutes.`);
|
|
1418
|
+
}
|
|
1419
|
+
const status = await pollGhostcutStatus(submission.taskId);
|
|
1420
|
+
if (status.state === "failed") {
|
|
1421
|
+
throw new Error(`GhostCut task ${submission.taskId} failed: ${status.reason}`);
|
|
1422
|
+
}
|
|
1423
|
+
if (status.state === "done") {
|
|
1424
|
+
cleanedVideoUrl = status.videoUrl;
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
const upstreamProgress = Math.max(0, Math.min(100, status.progress));
|
|
1428
|
+
ctx.logger.progress(0.15 + (upstreamProgress / 100) * 0.65, "Removing burned-in captions", {
|
|
1429
|
+
taskId: submission.taskId,
|
|
1430
|
+
upstreamProgress
|
|
1431
|
+
});
|
|
1432
|
+
await new Promise((resolve) => setTimeout(resolve, REMOVE_CAPTIONS_POLL_INTERVAL_MS));
|
|
1433
|
+
}
|
|
1434
|
+
// GhostCut hosts results on a short-lived third-party CDN — mirror the
|
|
1435
|
+
// cleaned MP4 into platform storage so the output URL is durable.
|
|
1436
|
+
ctx.logger.progress(0.85, "Mirroring caption-free video to platform storage");
|
|
1437
|
+
const remoteVideo = await fetchRemoteBinary(cleanedVideoUrl, "video/mp4");
|
|
1438
|
+
const stored = await storePrimitiveVideo(ctx, {
|
|
1439
|
+
key: "captions-removed.mp4",
|
|
1440
|
+
bytes: remoteVideo.bytes,
|
|
1441
|
+
contentType: "video/mp4",
|
|
1442
|
+
metadata: {
|
|
1443
|
+
primitive: "video_remove_captions",
|
|
1444
|
+
operation: "remove-video-captions",
|
|
1445
|
+
source_video_url: payload.source_video_url,
|
|
1446
|
+
ghostcut_task_id: submission.taskId
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
await ctx.billing.record({
|
|
1450
|
+
type: "cpu_estimate",
|
|
1451
|
+
costUsd: cost.usd,
|
|
1452
|
+
costCenterSlug: "ghostcut_subtitle_removal",
|
|
1453
|
+
idempotencyKey: `ghostcut_subtitle_removal:${submission.taskId}`,
|
|
1454
|
+
occurredAtMs: Date.now(),
|
|
1455
|
+
metadata: {
|
|
1456
|
+
operation: "remove-video-captions",
|
|
1457
|
+
source_video_url: payload.source_video_url,
|
|
1458
|
+
duration_seconds: Number(durationSeconds.toFixed(3)),
|
|
1459
|
+
chunk_count: cost.chunks,
|
|
1460
|
+
usd_per_chunk: cost.usdPerChunk,
|
|
1461
|
+
chunk_seconds: 30,
|
|
1462
|
+
task_id: submission.taskId
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
ctx.logger.progress(1, "Caption removal complete", { videoUrl: stored.url });
|
|
1466
|
+
return {
|
|
1467
|
+
progress: 1,
|
|
1468
|
+
output: {
|
|
1469
|
+
files: compactUrls([payload.source_video_url, stored.url]),
|
|
1470
|
+
sourceVideoUrl: payload.source_video_url,
|
|
1471
|
+
captionsRemovedVideoUrl: stored.url,
|
|
1472
|
+
primary_file_url: stored.url,
|
|
1473
|
+
video: {
|
|
1474
|
+
file_url: stored.url,
|
|
1475
|
+
content_type: stored.contentType,
|
|
1476
|
+
duration_seconds: Number(durationSeconds.toFixed(3)),
|
|
1477
|
+
ghostcut_task_id: submission.taskId
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1225
1484
|
const videoExtractAudioPrimitive = definePrimitive({
|
|
1226
1485
|
id: PRIMITIVE_VIDEO_EXTRACT_AUDIO_ID,
|
|
1227
1486
|
kind: "video_extract_audio",
|
|
@@ -1863,7 +2122,9 @@ class PrimitiveRegistry {
|
|
|
1863
2122
|
[mediaDedupePrimitive.id, mediaDedupePrimitive],
|
|
1864
2123
|
[videoNormalizePrimitive.id, videoNormalizePrimitive],
|
|
1865
2124
|
[videoDownloadPrimitive.id, videoDownloadPrimitive],
|
|
2125
|
+
[videoIngestPrimitive.id, videoIngestPrimitive],
|
|
1866
2126
|
[videoTrimPrimitive.id, videoTrimPrimitive],
|
|
2127
|
+
[videoRemoveCaptionsPrimitive.id, videoRemoveCaptionsPrimitive],
|
|
1867
2128
|
[videoExtractAudioPrimitive.id, videoExtractAudioPrimitive],
|
|
1868
2129
|
[audioTrimPrimitive.id, audioTrimPrimitive],
|
|
1869
2130
|
[videoProbePrimitive.id, videoProbePrimitive],
|
|
@@ -162,6 +162,7 @@ function resolveCostCenterSlug(input) {
|
|
|
162
162
|
|| explicitSlug === "video_normalization"
|
|
163
163
|
|| explicitSlug === "rapidapi_video_download"
|
|
164
164
|
|| explicitSlug === "video_download_lambda"
|
|
165
|
+
|| explicitSlug === "video_ingest_lambda"
|
|
165
166
|
|| explicitSlug === "rapidapi_remove_background"
|
|
166
167
|
|| explicitSlug === "primitive_media_lambda"
|
|
167
168
|
|| explicitSlug === "job_runner_lambda"
|
|
@@ -13,7 +13,7 @@ import ffprobeStatic from "ffprobe-static";
|
|
|
13
13
|
import { parseHTML } from "linkedom";
|
|
14
14
|
import { config } from "../config.js";
|
|
15
15
|
import { devErrorFields, devLog } from "../lib/dev-log.js";
|
|
16
|
-
import { buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
|
|
16
|
+
import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
|
|
17
17
|
import { applyMarkupUsd } from "./billing-pricing.js";
|
|
18
18
|
let stackCache = null;
|
|
19
19
|
const SMART_DECOMPOSE_TEMP_PREFIX = "vidfarm-smart-decompose-";
|
|
@@ -2027,6 +2027,31 @@ export function normalizePublishHtml(html) {
|
|
|
2027
2027
|
continue;
|
|
2028
2028
|
html.style.zIndex = String(trackIndex);
|
|
2029
2029
|
}
|
|
2030
|
+
// Ken Burns motion must span exactly the clip: timeline edits touch
|
|
2031
|
+
// data-duration only, so re-derive the inline --vf-kb-dur (which drives
|
|
2032
|
+
// animation-duration) from data-duration on every [data-kenburns] layer.
|
|
2033
|
+
// Also make sure the shared keyframes stylesheet travels with compositions
|
|
2034
|
+
// stored before the builder started embedding it.
|
|
2035
|
+
const kenBurnsLayers = Array.from(document.querySelectorAll("[data-kenburns]"));
|
|
2036
|
+
for (const el of kenBurnsLayers) {
|
|
2037
|
+
const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
|
|
2038
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
2039
|
+
continue;
|
|
2040
|
+
const styleValue = el.getAttribute("style") || "";
|
|
2041
|
+
const durDecl = `--vf-kb-dur:${Number(duration.toFixed(3))}s`;
|
|
2042
|
+
const nextStyle = /(^|;)\s*--vf-kb-dur\s*:[^;]*/i.test(styleValue)
|
|
2043
|
+
? styleValue.replace(/(^|;)\s*--vf-kb-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
|
|
2044
|
+
: styleValue.length === 0 || styleValue.endsWith(";")
|
|
2045
|
+
? `${styleValue}${durDecl}`
|
|
2046
|
+
: `${styleValue};${durDecl}`;
|
|
2047
|
+
if (nextStyle !== styleValue)
|
|
2048
|
+
el.setAttribute("style", nextStyle);
|
|
2049
|
+
}
|
|
2050
|
+
if (kenBurnsLayers.length > 0 && !html.includes(KEN_BURNS_STYLE_MARKER)) {
|
|
2051
|
+
const styleEl = document.createElement("style");
|
|
2052
|
+
styleEl.textContent = KEN_BURNS_CSS;
|
|
2053
|
+
(document.head ?? document.documentElement)?.appendChild(styleEl);
|
|
2054
|
+
}
|
|
2030
2055
|
// Editor sessions occasionally leave inline style attributes with double
|
|
2031
2056
|
// quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
|
|
2032
2057
|
// an HTML attribute, those quotes become " entities. The render lambda
|
|
@@ -30,6 +30,12 @@ export class StorageService {
|
|
|
30
30
|
legacyUserAttachmentKey(customerId, attachmentId, fileName) {
|
|
31
31
|
return joinStorageKey("users", customerId, attachmentId, fileName);
|
|
32
32
|
}
|
|
33
|
+
// Add Template → upload a video file: staging area for uploaded inspiration
|
|
34
|
+
// sources. Lives under users/ so the bucket's public-read policy covers it —
|
|
35
|
+
// the video_ingest job and the editor fetch it by plain URL.
|
|
36
|
+
inspirationUploadKey(customerId, uploadId, fileName) {
|
|
37
|
+
return joinStorageKey("users", customerId, "inspiration-uploads", uploadId, fileName);
|
|
38
|
+
}
|
|
33
39
|
userTemporaryFileKey(customerId, fileId, fileName, folderPath) {
|
|
34
40
|
return joinStorageKey("users", customerId, "temporary", folderPath || "", fileId, fileName);
|
|
35
41
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { StorageService } from "./storage.js";
|
|
3
|
+
import { serverlessRecords } from "./serverless-records.js";
|
|
4
|
+
// Cloud passthrough for `vidfarm serve`. A locally-booted box keeps the
|
|
5
|
+
// editor, storage, and render fully local, but mirrors the upstream (prod)
|
|
6
|
+
// deployment for everything catalog-shaped: /discover and /library list the
|
|
7
|
+
// cloud data, templates/forks seed onto local disk on demand, and a render
|
|
8
|
+
// can be handed off to the cloud renderer. The upstream host + API key are
|
|
9
|
+
// injected by the serve command (VIDFARM_UPSTREAM_HOST / _API_KEY); deployed
|
|
10
|
+
// environments never set them, so every helper here is a no-op in the cloud.
|
|
11
|
+
const UPSTREAM_TIMEOUT_MS = 15_000;
|
|
12
|
+
export function upstreamHost() {
|
|
13
|
+
const raw = (config.VIDFARM_UPSTREAM_HOST ?? "").trim().replace(/\/+$/, "");
|
|
14
|
+
if (!raw)
|
|
15
|
+
return null;
|
|
16
|
+
if (!/^https?:\/\//i.test(raw))
|
|
17
|
+
return null;
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
// Passthrough only ever activates on a fully-local box. STORAGE_DRIVER=local
|
|
21
|
+
// is the serve fingerprint (same signal the editor's liveReload uses).
|
|
22
|
+
export function isUpstreamEnabled() {
|
|
23
|
+
return config.STORAGE_DRIVER === "local" && Boolean(upstreamHost());
|
|
24
|
+
}
|
|
25
|
+
export function upstreamApiKey() {
|
|
26
|
+
const key = (config.VIDFARM_UPSTREAM_API_KEY ?? "").trim();
|
|
27
|
+
return key || null;
|
|
28
|
+
}
|
|
29
|
+
export function hasUpstreamKey() {
|
|
30
|
+
return isUpstreamEnabled() && Boolean(upstreamApiKey());
|
|
31
|
+
}
|
|
32
|
+
export function upstreamAuthHeaders(extra) {
|
|
33
|
+
const headers = { accept: "application/json", ...(extra ?? {}) };
|
|
34
|
+
const key = upstreamApiKey();
|
|
35
|
+
if (key)
|
|
36
|
+
headers["vidfarm-api-key"] = key;
|
|
37
|
+
return headers;
|
|
38
|
+
}
|
|
39
|
+
// One-shot JSON fetch against the upstream host. Never throws on HTTP or
|
|
40
|
+
// network errors — callers treat a failed upstream as "no cloud data" and
|
|
41
|
+
// fall back to local-only behavior.
|
|
42
|
+
export async function upstreamJson(pathWithQuery, init) {
|
|
43
|
+
const host = upstreamHost();
|
|
44
|
+
if (!host)
|
|
45
|
+
return { ok: false, status: 0, json: null };
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(`${host}${pathWithQuery}`, {
|
|
48
|
+
method: init?.method ?? "GET",
|
|
49
|
+
headers: upstreamAuthHeaders(init?.body !== undefined ? { "content-type": "application/json" } : undefined),
|
|
50
|
+
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
51
|
+
signal: AbortSignal.timeout(init?.timeoutMs ?? UPSTREAM_TIMEOUT_MS)
|
|
52
|
+
});
|
|
53
|
+
const json = await response.json().catch(() => null);
|
|
54
|
+
return { ok: response.ok, status: response.status, json };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.warn(`[upstream] ${init?.method ?? "GET"} ${pathWithQuery} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
return { ok: false, status: 0, json: null };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Forward the incoming request to the same path on the upstream host with the
|
|
62
|
+
// upstream API key swapped in for the local session. Used as a fallback when
|
|
63
|
+
// a resource id (approved post, inspiration) doesn't resolve locally — the id
|
|
64
|
+
// then almost certainly belongs to the cloud account this box mirrors.
|
|
65
|
+
// requireKey defaults to true so authed reads/mutations never proxy without
|
|
66
|
+
// cloud credentials; public-data GET fallbacks pass requireKey: false.
|
|
67
|
+
export async function proxyRequestToUpstream(c, options) {
|
|
68
|
+
const host = upstreamHost();
|
|
69
|
+
if (!host || !isUpstreamEnabled())
|
|
70
|
+
return null;
|
|
71
|
+
if ((options?.requireKey ?? true) && !hasUpstreamKey())
|
|
72
|
+
return null;
|
|
73
|
+
const incoming = new URL(c.req.url);
|
|
74
|
+
const target = `${host}${incoming.pathname}${incoming.search}`;
|
|
75
|
+
const method = c.req.method.toUpperCase();
|
|
76
|
+
const headers = upstreamAuthHeaders();
|
|
77
|
+
let body;
|
|
78
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
79
|
+
body = await c.req.text().catch(() => "");
|
|
80
|
+
const contentType = c.req.header("content-type");
|
|
81
|
+
if (contentType)
|
|
82
|
+
headers["content-type"] = contentType;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(target, {
|
|
86
|
+
method,
|
|
87
|
+
headers,
|
|
88
|
+
body,
|
|
89
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
90
|
+
});
|
|
91
|
+
const payload = Buffer.from(await response.arrayBuffer());
|
|
92
|
+
return new Response(payload, {
|
|
93
|
+
status: response.status,
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": response.headers.get("content-type") ?? "application/json",
|
|
96
|
+
"x-vidfarm-upstream": host
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.warn(`[upstream] proxy ${method} ${incoming.pathname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// On-demand seeding: pull a cloud fork's composition onto local disk and mint
|
|
107
|
+
// the matching local template + fork records so the local editor resolves it.
|
|
108
|
+
// Same behavior as `vidfarm serve <templateId>` boot seeding, but callable
|
|
109
|
+
// from the /editor route when the user clicks a proxied catalog entry.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function seedAuthHeaders(shareToken) {
|
|
112
|
+
const headers = { accept: "text/html,application/json" };
|
|
113
|
+
const key = upstreamApiKey();
|
|
114
|
+
if (key)
|
|
115
|
+
headers["vidfarm-api-key"] = key;
|
|
116
|
+
if (shareToken)
|
|
117
|
+
headers["vidfarm-share-token"] = shareToken;
|
|
118
|
+
return headers;
|
|
119
|
+
}
|
|
120
|
+
// /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
|
|
121
|
+
// whenever the caller (or the template's default fork) has one. Follow the
|
|
122
|
+
// redirect once by hand so we can read the fork id off the query string.
|
|
123
|
+
async function resolveUpstreamForkId(input) {
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`${input.host}/editor/${encodeURIComponent(input.templateId)}`, {
|
|
126
|
+
method: "GET",
|
|
127
|
+
redirect: "manual",
|
|
128
|
+
headers: seedAuthHeaders(input.shareToken),
|
|
129
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
130
|
+
});
|
|
131
|
+
if (res.status >= 300 && res.status < 400) {
|
|
132
|
+
const location = res.headers.get("location");
|
|
133
|
+
if (location) {
|
|
134
|
+
const target = new URL(location, input.host);
|
|
135
|
+
const fork = target.searchParams.get("fork");
|
|
136
|
+
if (fork)
|
|
137
|
+
return fork;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// fall through to null — caller starts empty
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
// Best-effort: returns null (and logs) instead of throwing so callers can
|
|
147
|
+
// fall back to their template-not-found behavior.
|
|
148
|
+
export async function seedFromUpstream(input) {
|
|
149
|
+
const host = upstreamHost();
|
|
150
|
+
if (!host || !isUpstreamEnabled())
|
|
151
|
+
return null;
|
|
152
|
+
const storage = new StorageService();
|
|
153
|
+
const headers = seedAuthHeaders(input.shareToken);
|
|
154
|
+
// 1. Resolve the source fork: explicit fork wins, else the template's
|
|
155
|
+
// default fork (grabs the shared decomposition even if another user owns it).
|
|
156
|
+
let cloudForkId = input.forkId ?? null;
|
|
157
|
+
if (!cloudForkId && input.templateId) {
|
|
158
|
+
cloudForkId = await resolveUpstreamForkId({ host, templateId: input.templateId, shareToken: input.shareToken });
|
|
159
|
+
}
|
|
160
|
+
if (!cloudForkId) {
|
|
161
|
+
console.warn(`[upstream] seed: no fork resolvable for template ${input.templateId ?? "<none>"}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// 2. Read the serialized fork for its template id + title.
|
|
165
|
+
const forkMeta = (await upstreamJson(`/api/v1/compositions/${encodeURIComponent(cloudForkId)}`)).json;
|
|
166
|
+
const templateId = input.templateId ?? (typeof forkMeta?.template_id === "string" ? forkMeta.template_id : null);
|
|
167
|
+
if (!templateId || !templateId.startsWith("template_")) {
|
|
168
|
+
console.warn(`[upstream] seed: could not determine a template id for fork ${cloudForkId}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const title = (typeof forkMeta?.title === "string" && forkMeta.title) || `Local · ${templateId}`;
|
|
172
|
+
// 3. Materialize the local template record (the editor 404s without it).
|
|
173
|
+
const existingTemplate = await serverlessRecords.getTemplate(templateId);
|
|
174
|
+
if (!existingTemplate) {
|
|
175
|
+
await serverlessRecords.createTemplate({
|
|
176
|
+
id: templateId,
|
|
177
|
+
inspirationId: `local:${templateId}`,
|
|
178
|
+
customerId: input.customerId,
|
|
179
|
+
originalUrl: typeof forkMeta?.original_url === "string" ? forkMeta.original_url : "",
|
|
180
|
+
sourceHost: "local",
|
|
181
|
+
title,
|
|
182
|
+
visibility: "public"
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// 4. Materialize the local fork (reuse the cloud id for stable URLs — the
|
|
186
|
+
// shared id is also what lets a later "Render in Cloud" find its source).
|
|
187
|
+
const existingFork = await serverlessRecords.getCompositionFork(cloudForkId);
|
|
188
|
+
if (!existingFork) {
|
|
189
|
+
await serverlessRecords.createCompositionFork({
|
|
190
|
+
id: cloudForkId,
|
|
191
|
+
customerId: input.customerId,
|
|
192
|
+
templateId,
|
|
193
|
+
title,
|
|
194
|
+
visibility: "private"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// 5. Pull composition.html/json onto disk (skip if present unless refetch,
|
|
198
|
+
// so local edits aren't clobbered).
|
|
199
|
+
const htmlKey = storage.compositionForkWorkingKey(cloudForkId, "composition.html");
|
|
200
|
+
const jsonKey = storage.compositionForkWorkingKey(cloudForkId, "composition.json");
|
|
201
|
+
const hasLocalHtml = Boolean(await storage.readText(htmlKey));
|
|
202
|
+
if (!hasLocalHtml || input.refetch) {
|
|
203
|
+
try {
|
|
204
|
+
const htmlRes = await fetch(`${host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.html`, {
|
|
205
|
+
headers,
|
|
206
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
207
|
+
});
|
|
208
|
+
if (!htmlRes.ok) {
|
|
209
|
+
console.warn(`[upstream] seed: could not fetch composition.html for ${cloudForkId} (${htmlRes.status})`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const compositionHtml = await htmlRes.text();
|
|
213
|
+
await storage.putText(htmlKey, compositionHtml, "text/html; charset=utf-8");
|
|
214
|
+
// Backfill the local template's preview metadata from the composition
|
|
215
|
+
// itself (no single-template REST read exists) so the seeded copy also
|
|
216
|
+
// renders a card on the LOCAL /discover feed.
|
|
217
|
+
const sourceVideoMatch = compositionHtml.match(/data-source-video="([^"]+)"/i);
|
|
218
|
+
const durationMatch = compositionHtml.match(/data-duration="([^"]+)"/i);
|
|
219
|
+
const template = await serverlessRecords.getTemplate(templateId);
|
|
220
|
+
if (template && !template.videoUrl && sourceVideoMatch?.[1]) {
|
|
221
|
+
const durationSeconds = durationMatch ? Number.parseFloat(durationMatch[1]) : NaN;
|
|
222
|
+
await serverlessRecords.updateTemplate({
|
|
223
|
+
templateId,
|
|
224
|
+
patch: {
|
|
225
|
+
videoUrl: sourceVideoMatch[1].replace(/&/g, "&"),
|
|
226
|
+
...(Number.isFinite(durationSeconds) && durationSeconds > 0 ? { durationSeconds } : {})
|
|
227
|
+
}
|
|
228
|
+
}).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
const jsonRes = await fetch(`${host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.json`, {
|
|
231
|
+
headers,
|
|
232
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
|
|
233
|
+
});
|
|
234
|
+
if (jsonRes.ok)
|
|
235
|
+
await storage.putBuffer(jsonKey, Buffer.from(await jsonRes.text(), "utf8"), "application/json");
|
|
236
|
+
console.log(`[upstream] seeded fork ${cloudForkId} from ${host}`);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
console.warn(`[upstream] seed: composition fetch failed for ${cloudForkId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// 6. Point the template's default fork at the local copy so a bare
|
|
244
|
+
// /editor/:templateId resolves it too.
|
|
245
|
+
await serverlessRecords.stampDefaultForkIfUnset({ templateId, forkId: cloudForkId });
|
|
246
|
+
return { templateId, forkId: cloudForkId };
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=upstream.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|