@mevdragon/vidfarm-devcli 0.15.0 → 0.16.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/demo/dist/app.css +1 -1
- package/demo/dist/app.js +74 -74
- package/dist/src/app.js +313 -7
- package/dist/src/config.js +13 -0
- package/dist/src/editor-chat.js +1 -0
- package/dist/src/frontend/homepage-client.js +50 -1
- package/dist/src/frontend/homepage-shared.js +197 -0
- package/dist/src/frontend/homepage-store.js +10 -1
- package/dist/src/frontend/homepage-view.js +43 -5
- package/dist/src/homepage.js +51 -18
- package/dist/src/services/serverless-records.js +9 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
package/dist/src/app.js
CHANGED
|
@@ -22,6 +22,7 @@ import { assertForkAccess, resolveForkAccess, ForkAccessDeniedError, ForkNotFoun
|
|
|
22
22
|
import { writeForkManifest } from "./services/fork-manifest.js";
|
|
23
23
|
import { renderHelpPage } from "./help-page.js";
|
|
24
24
|
import { renderHomepage } from "./homepage.js";
|
|
25
|
+
import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
|
|
25
26
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
26
27
|
import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
|
|
27
28
|
import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
|
|
@@ -59,7 +60,7 @@ import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml,
|
|
|
59
60
|
import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
|
|
60
61
|
import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
61
62
|
import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
|
|
62
|
-
import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
|
|
63
|
+
import { MediaDurationExceededError, probeMediaAsset, trimVideoAsset } from "./services/media-processing.js";
|
|
63
64
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
64
65
|
import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
|
|
65
66
|
import { TemplateSourceService } from "./services/template-sources.js";
|
|
@@ -4937,6 +4938,28 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4937
4938
|
if (!forkRecord || forkRecord.deletedAt) {
|
|
4938
4939
|
effectiveForkParam = null;
|
|
4939
4940
|
}
|
|
4941
|
+
else if (customer) {
|
|
4942
|
+
// A caller who can VIEW but not EDIT this fork (e.g. a public template's
|
|
4943
|
+
// default fork opened via a shared deep link) must not be pinned onto it:
|
|
4944
|
+
// the editor would happily let them "edit", but every composition.html PUT
|
|
4945
|
+
// is 403'd and the change silently reverts on reload — the exact "the AI
|
|
4946
|
+
// said it changed the text but it didn't" failure. Drop the fork and fall
|
|
4947
|
+
// through to per-user resolution so they get (or mint) their own editable
|
|
4948
|
+
// copy instead.
|
|
4949
|
+
try {
|
|
4950
|
+
const access = await resolveForkAccess({
|
|
4951
|
+
forkId: effectiveForkParam,
|
|
4952
|
+
customerId: customer.id,
|
|
4953
|
+
shareToken: readShareToken(c) ?? undefined
|
|
4954
|
+
});
|
|
4955
|
+
if (!access.capabilities.canEdit) {
|
|
4956
|
+
effectiveForkParam = null;
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
catch {
|
|
4960
|
+
effectiveForkParam = null;
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4940
4963
|
}
|
|
4941
4964
|
if (!effectiveForkParam) {
|
|
4942
4965
|
// Per-user default fork resolution: prefer caller's most-recently-updated
|
|
@@ -4953,8 +4976,14 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4953
4976
|
if (mostRecent)
|
|
4954
4977
|
targetForkId = mostRecent.id;
|
|
4955
4978
|
}
|
|
4956
|
-
|
|
4979
|
+
// Only fall back to the template's canonical default fork when the caller can
|
|
4980
|
+
// actually edit it — i.e. they own the template. A non-owner sent to the
|
|
4981
|
+
// default fork would land back on a read-only fork (and, since it's also the
|
|
4982
|
+
// page we just dropped, loop). Leaving targetForkId null routes them to the
|
|
4983
|
+
// bare editor, where the client mints their own editable fork on first save.
|
|
4984
|
+
if (!targetForkId && template.customerId === customer?.id) {
|
|
4957
4985
|
targetForkId = template.defaultForkId;
|
|
4986
|
+
}
|
|
4958
4987
|
if (targetForkId) {
|
|
4959
4988
|
// Do NOT auto-attach a thread_id here: the caller's browser is the
|
|
4960
4989
|
// source of truth for "which chat is active" via localStorage. Injecting
|
|
@@ -5062,6 +5091,11 @@ app.get("/discover/feed", async (c) => {
|
|
|
5062
5091
|
}
|
|
5063
5092
|
const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
|
|
5064
5093
|
for (const template of templates) {
|
|
5094
|
+
// Curated "Popular" picks live only under the Popular toggle
|
|
5095
|
+
// (GET /discover/popular) — keep them out of the continuously-grown Feed
|
|
5096
|
+
// so the two views stay distinct.
|
|
5097
|
+
if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
|
|
5098
|
+
continue;
|
|
5065
5099
|
const entry = buildTemplateHomepageEntry(template);
|
|
5066
5100
|
if (entry)
|
|
5067
5101
|
templateEntries.push(entry);
|
|
@@ -5078,7 +5112,7 @@ app.get("/discover/feed", async (c) => {
|
|
|
5078
5112
|
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
5079
5113
|
const seen = new Set(templateEntries.map((entry) => entry.templateId));
|
|
5080
5114
|
for (const entry of upstreamFeed) {
|
|
5081
|
-
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId)) {
|
|
5115
|
+
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
|
|
5082
5116
|
templateEntries.push(entry);
|
|
5083
5117
|
seen.add(entry.templateId);
|
|
5084
5118
|
}
|
|
@@ -5093,16 +5127,60 @@ app.get("/discover/feed", async (c) => {
|
|
|
5093
5127
|
next_cursor: null
|
|
5094
5128
|
});
|
|
5095
5129
|
});
|
|
5130
|
+
// Curated "Popular" feed: the editorial list surfaced under the /discover
|
|
5131
|
+
// Popular toggle. Same template records as Feed, just the ones flagged with a
|
|
5132
|
+
// popularGroup — rendered client-side as autoplay TemplateCards grouped by that
|
|
5133
|
+
// label. Public + identical for everyone, so it caches shared. Mirrors upstream
|
|
5134
|
+
// (vidfarm serve) exactly like /discover/feed so a locally-empty box still sees
|
|
5135
|
+
// the cloud-seeded picks. Seed with POST /api/v1/admin/popular/seed.
|
|
5136
|
+
app.get("/discover/popular", async (c) => {
|
|
5137
|
+
const templateEntries = [];
|
|
5138
|
+
const seen = new Set();
|
|
5139
|
+
const popular = await serverlessRecords.listPopularTemplates();
|
|
5140
|
+
for (const template of popular) {
|
|
5141
|
+
const entry = buildTemplateHomepageEntry(template);
|
|
5142
|
+
if (entry) {
|
|
5143
|
+
templateEntries.push(entry);
|
|
5144
|
+
seen.add(entry.templateId);
|
|
5145
|
+
}
|
|
5146
|
+
}
|
|
5147
|
+
if (isUpstreamEnabled()) {
|
|
5148
|
+
const upstream = (await upstreamJson(`/discover/popular`)).json?.templates ?? [];
|
|
5149
|
+
for (const entry of upstream) {
|
|
5150
|
+
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && entry.popularGroup) {
|
|
5151
|
+
templateEntries.push(entry);
|
|
5152
|
+
seen.add(entry.templateId);
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
5156
|
+
// Stable editorial order: popularRank is a global sequence assigned by the
|
|
5157
|
+
// seed (group index * 1000 + item index), so a plain rank sort preserves both
|
|
5158
|
+
// group order and within-group order. Group label is only a tiebreak.
|
|
5159
|
+
templateEntries.sort((a, b) => {
|
|
5160
|
+
const rankCompare = (a.popularRank ?? 0) - (b.popularRank ?? 0);
|
|
5161
|
+
if (rankCompare !== 0)
|
|
5162
|
+
return rankCompare;
|
|
5163
|
+
return String(a.popularGroup ?? "").localeCompare(String(b.popularGroup ?? ""));
|
|
5164
|
+
});
|
|
5165
|
+
c.header("cache-control", "public, max-age=60");
|
|
5166
|
+
return c.json({
|
|
5167
|
+
templates: templateEntries,
|
|
5168
|
+
next_cursor: null
|
|
5169
|
+
});
|
|
5170
|
+
});
|
|
5096
5171
|
function buildTemplateHomepageEntry(template) {
|
|
5097
5172
|
// Undecomposed templates (no defaultForkId yet) still appear on /discover so
|
|
5098
5173
|
// users can open them and mint the first fork via the Decompose modal.
|
|
5099
5174
|
if (!template.videoUrl)
|
|
5100
5175
|
return null;
|
|
5101
5176
|
const isPrivate = template.visibility === "private";
|
|
5177
|
+
const isPopular = typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0;
|
|
5102
5178
|
// Older records stored the source host ("youtube.com") as a title fallback —
|
|
5103
5179
|
// never show that as a card title; the template id is the canonical default.
|
|
5104
5180
|
const storedTitle = template.title && template.title !== template.sourceHost ? template.title : null;
|
|
5105
|
-
|
|
5181
|
+
// Public feed cards default to the template id, but curated Popular picks show
|
|
5182
|
+
// their editorial title (the whole point of the Popular list).
|
|
5183
|
+
const title = (isPrivate || isPopular) ? (storedTitle || template.id) : template.id;
|
|
5106
5184
|
const previewUrl = template.thumbnailUrl || template.videoUrl || null;
|
|
5107
5185
|
return {
|
|
5108
5186
|
title,
|
|
@@ -5122,6 +5200,8 @@ function buildTemplateHomepageEntry(template) {
|
|
|
5122
5200
|
summary: template.searchSummary ?? null,
|
|
5123
5201
|
visibility: template.visibility,
|
|
5124
5202
|
origin: template.origin ?? "inspiration",
|
|
5203
|
+
popularGroup: template.popularGroup ?? null,
|
|
5204
|
+
popularRank: template.popularRank ?? null,
|
|
5125
5205
|
notes: template.notes,
|
|
5126
5206
|
status: "ready"
|
|
5127
5207
|
};
|
|
@@ -5489,6 +5569,8 @@ function buildPendingInspirationHomepageEntry(inspiration) {
|
|
|
5489
5569
|
durationSeconds: inspiration.durationSeconds ?? null,
|
|
5490
5570
|
visibility: "private",
|
|
5491
5571
|
origin: inspiration.origin ?? "inspiration",
|
|
5572
|
+
popularGroup: inspiration.popularGroup ?? null,
|
|
5573
|
+
popularRank: inspiration.popularRank ?? null,
|
|
5492
5574
|
notes: inspiration.notes,
|
|
5493
5575
|
status: inspiration.status === "failed" ? "failed" : "processing"
|
|
5494
5576
|
};
|
|
@@ -7131,6 +7213,13 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
|
|
|
7131
7213
|
// so a forced re-run may start. Must exceed the longest real decompose but stay
|
|
7132
7214
|
// under the API lambda's 15-min timeout.
|
|
7133
7215
|
const DECOMPOSE_PROCESSING_STALE_MS = 10 * 60 * 1000;
|
|
7216
|
+
// Inspiration ingest + auto-decompose no longer hard-cap the source at 120s.
|
|
7217
|
+
// Users can bring longer inspiration; an operator can still re-impose a ceiling
|
|
7218
|
+
// via INSPIRATION_MAX_DURATION_SEC (>0). 0 → unlimited, translated to Infinity so
|
|
7219
|
+
// probeMediaAsset never rejects a source purely on length.
|
|
7220
|
+
const INSPIRATION_PROBE_MAX_DURATION_SECONDS = config.INSPIRATION_MAX_DURATION_SEC > 0
|
|
7221
|
+
? config.INSPIRATION_MAX_DURATION_SEC
|
|
7222
|
+
: Number.POSITIVE_INFINITY;
|
|
7134
7223
|
async function readDecomposeJobState(forkId) {
|
|
7135
7224
|
const text = await storage.readText(storage.compositionForkWorkingKey(forkId, "decompose.json"));
|
|
7136
7225
|
if (!text)
|
|
@@ -7250,7 +7339,10 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
7250
7339
|
let probedDurationSeconds = null;
|
|
7251
7340
|
if (sourceUrl) {
|
|
7252
7341
|
try {
|
|
7253
|
-
const probe = await probeMediaAsset({
|
|
7342
|
+
const probe = await probeMediaAsset({
|
|
7343
|
+
sourceUrl,
|
|
7344
|
+
maxDurationSeconds: INSPIRATION_PROBE_MAX_DURATION_SECONDS
|
|
7345
|
+
});
|
|
7254
7346
|
const probed = probe.metadata.durationSeconds;
|
|
7255
7347
|
if (Number.isFinite(probed) && probed && probed > 0) {
|
|
7256
7348
|
probedDurationSeconds = probed;
|
|
@@ -7298,7 +7390,17 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
|
|
|
7298
7390
|
ghostcutSkipReason = "ghostcut_not_configured";
|
|
7299
7391
|
console.warn(`ghostcut: skipping subtitle removal for fork ${fork.id} — GHOSTCUT_KEY/GHOSTCUT_SECRET not configured. Composition will keep the original video with baked-in text.`);
|
|
7300
7392
|
}
|
|
7301
|
-
if (
|
|
7393
|
+
else if (config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC > 0 &&
|
|
7394
|
+
probedDurationSeconds !== null &&
|
|
7395
|
+
probedDurationSeconds > config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC) {
|
|
7396
|
+
// GhostCut is per-30s-chunk and meant for short clips. Long inspiration is
|
|
7397
|
+
// now allowed through ingest/decompose, but laundering a multi-minute video
|
|
7398
|
+
// through GhostCut is disproportionately expensive — skip it and keep the
|
|
7399
|
+
// original burned-in captions instead.
|
|
7400
|
+
ghostcutSkipReason = `source_too_long:${probedDurationSeconds.toFixed(1)}s>${config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC}s`;
|
|
7401
|
+
console.warn(`ghostcut: skipping subtitle removal for fork ${fork.id} — source is ${probedDurationSeconds.toFixed(1)}s, over the ${config.GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC}s decompose ceiling. Composition keeps the original burned-in captions.`);
|
|
7402
|
+
}
|
|
7403
|
+
if (sourceUrl && sourceIsVideo && isGhostcutConfigured() && !ghostcutSkipReason) {
|
|
7302
7404
|
try {
|
|
7303
7405
|
const submission = await submitSubtitleRemoval(sourceUrl);
|
|
7304
7406
|
ghostcutTaskId = submission.taskId;
|
|
@@ -9118,6 +9220,8 @@ function serializeInspiration(inspiration) {
|
|
|
9118
9220
|
summary: inspiration.searchSummary ?? null,
|
|
9119
9221
|
visibility: inspiration.visibility ?? "public",
|
|
9120
9222
|
origin: inspiration.origin ?? "inspiration",
|
|
9223
|
+
popular_group: inspiration.popularGroup ?? null,
|
|
9224
|
+
popular_rank: inspiration.popularRank ?? null,
|
|
9121
9225
|
notes: inspiration.notes ?? null,
|
|
9122
9226
|
ingester_customer_id: inspiration.customerId,
|
|
9123
9227
|
error: inspiration.errorMessage,
|
|
@@ -11222,7 +11326,10 @@ async function ensureInspirationReadyThenFork(input) {
|
|
|
11222
11326
|
// the true length instead of a placeholder.
|
|
11223
11327
|
if (!inspiration.durationSeconds) {
|
|
11224
11328
|
try {
|
|
11225
|
-
const probe = await probeMediaAsset({
|
|
11329
|
+
const probe = await probeMediaAsset({
|
|
11330
|
+
sourceUrl: inspiration.videoUrl,
|
|
11331
|
+
maxDurationSeconds: INSPIRATION_PROBE_MAX_DURATION_SECONDS
|
|
11332
|
+
});
|
|
11226
11333
|
const probedDuration = probe.metadata.durationSeconds;
|
|
11227
11334
|
if (Number.isFinite(probedDuration) && probedDuration && probedDuration > 0) {
|
|
11228
11335
|
inspiration = (await serverlessRecords.updateInspiration({
|
|
@@ -11301,6 +11408,205 @@ function buildTemplateEditorUrl(publicBaseUrl, templateId) {
|
|
|
11301
11408
|
const path = `/editor/${encodeURIComponent(templateId)}`;
|
|
11302
11409
|
return publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}${path}` : path;
|
|
11303
11410
|
}
|
|
11411
|
+
// Curated Popular picks are short-form: a source longer than the 120s ingest
|
|
11412
|
+
// limit is clipped to this many seconds (kept safely under 120 for the probe).
|
|
11413
|
+
const POPULAR_MAX_CLIP_SEC = 115;
|
|
11414
|
+
// Seed the curated "Popular" catalog from POPULAR_INSPIRATION_GROUPS: ingest
|
|
11415
|
+
// each URL once into a real PUBLIC template (same path as admin TikTok
|
|
11416
|
+
// submissions) and stamp it with the editorial title + popularGroup +
|
|
11417
|
+
// popularRank so it always shows under the /discover Popular toggle and
|
|
11418
|
+
// autoplays inline exactly like a Feed card. Over-length sources are clipped to
|
|
11419
|
+
// POPULAR_MAX_CLIP_SEC. Idempotent — find-or-create by URL, then (re)stamp.
|
|
11420
|
+
// Requires RAPIDAPI_KEY (the download primitive), so run this on staging/prod.
|
|
11421
|
+
app.post(`${API_PREFIX}/admin/popular/seed`, async (c) => {
|
|
11422
|
+
try {
|
|
11423
|
+
requireSuperagency(c);
|
|
11424
|
+
}
|
|
11425
|
+
catch (error) {
|
|
11426
|
+
return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
|
|
11427
|
+
}
|
|
11428
|
+
const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
|
|
11429
|
+
const requestedOwnerEmail = readNonEmptyString(body.owner_email)?.trim().toLowerCase();
|
|
11430
|
+
const ownerEmail = requestedOwnerEmail ?? config.adminEmails[0]?.trim().toLowerCase();
|
|
11431
|
+
if (!ownerEmail)
|
|
11432
|
+
return c.json({ error: "No owner_email supplied and no admin email configured." }, 400);
|
|
11433
|
+
const ownerCustomer = await serverlessRecords.getCustomerByEmail(ownerEmail);
|
|
11434
|
+
if (!ownerCustomer)
|
|
11435
|
+
return c.json({ error: `Owner not found: ${ownerEmail}` }, 404);
|
|
11436
|
+
const primitive = primitiveRegistry.get("primitive:video_download");
|
|
11437
|
+
if (!primitive)
|
|
11438
|
+
return c.json({ error: "video_download primitive missing" }, 500);
|
|
11439
|
+
const operation = primitive.operations["run"];
|
|
11440
|
+
if (!operation)
|
|
11441
|
+
return c.json({ error: "video_download.run missing" }, 500);
|
|
11442
|
+
const publicBaseUrl = config.PUBLIC_BASE_URL?.trim() || "";
|
|
11443
|
+
try {
|
|
11444
|
+
await assertWalletCanQueueJobs(ownerCustomer.id);
|
|
11445
|
+
}
|
|
11446
|
+
catch (error) {
|
|
11447
|
+
if (error instanceof InsufficientWalletFundsError)
|
|
11448
|
+
return insufficientFundsResponse(c, error);
|
|
11449
|
+
throw error;
|
|
11450
|
+
}
|
|
11451
|
+
// Flatten the manifest into one ranked list. popularRank is a global sequence
|
|
11452
|
+
// (group index * 1000 + item index) so a plain rank sort restores both the
|
|
11453
|
+
// group order and the order within each group.
|
|
11454
|
+
const allItems = POPULAR_INSPIRATION_GROUPS.flatMap((group, gi) => group.items.map((item, ri) => ({ title: item.title, url: item.url, group: group.label, rank: gi * 1000 + ri })));
|
|
11455
|
+
// Optional { groups: [...] } filter seeds one category at a time so a large
|
|
11456
|
+
// catalog stays well under the Lambda timeout (each URL downloads inline).
|
|
11457
|
+
// Reconcile below still runs against the FULL manifest, so a partial seed
|
|
11458
|
+
// never retires the groups it skipped.
|
|
11459
|
+
const onlyGroups = Array.isArray(body.groups) ? new Set(body.groups.map((g) => String(g))) : null;
|
|
11460
|
+
const items = onlyGroups ? allItems.filter((it) => onlyGroups.has(it.group)) : allItems;
|
|
11461
|
+
const results = [];
|
|
11462
|
+
for (const item of items) {
|
|
11463
|
+
const originalUrl = stripTrackingParams(item.url);
|
|
11464
|
+
const sourceHost = safeUrlHostname(originalUrl);
|
|
11465
|
+
if (!sourceHost) {
|
|
11466
|
+
results.push({ source_url: item.url, title: item.title, error: "invalid URL" });
|
|
11467
|
+
continue;
|
|
11468
|
+
}
|
|
11469
|
+
let inspiration = await serverlessRecords.findInspirationByOriginalUrl(originalUrl);
|
|
11470
|
+
// (Re)start the download only when there's no usable video yet — a genuine
|
|
11471
|
+
// download failure (no videoUrl) retries, but a pick that DID download and
|
|
11472
|
+
// only tripped the duration limit keeps its bytes and gets clipped below.
|
|
11473
|
+
const needsIngest = !inspiration
|
|
11474
|
+
|| (!inspiration.videoUrl && inspiration.status !== "downloading");
|
|
11475
|
+
if (needsIngest) {
|
|
11476
|
+
const payload = operation.inputSchema.parse({ source_url: originalUrl, save_manifest: true });
|
|
11477
|
+
let job;
|
|
11478
|
+
try {
|
|
11479
|
+
job = await jobs.createRootJob({
|
|
11480
|
+
templateId: primitive.id,
|
|
11481
|
+
operationName: "run",
|
|
11482
|
+
workflowName: operation.workflow,
|
|
11483
|
+
tracer: `popular_seed_ingest:${ownerCustomer.id}`,
|
|
11484
|
+
payload,
|
|
11485
|
+
webhookUrl: null,
|
|
11486
|
+
customer: ownerCustomer,
|
|
11487
|
+
providerHint: undefined
|
|
11488
|
+
});
|
|
11489
|
+
}
|
|
11490
|
+
catch (error) {
|
|
11491
|
+
results.push({ source_url: item.url, title: item.title, error: error instanceof Error ? error.message : String(error) });
|
|
11492
|
+
continue;
|
|
11493
|
+
}
|
|
11494
|
+
inspiration = inspiration
|
|
11495
|
+
? ((await serverlessRecords.updateInspiration({
|
|
11496
|
+
inspirationId: inspiration.id,
|
|
11497
|
+
patch: { status: "downloading", downloadJobId: job.id, errorMessage: null }
|
|
11498
|
+
})) ?? inspiration)
|
|
11499
|
+
: await serverlessRecords.createInspiration({
|
|
11500
|
+
customerId: ownerCustomer.id,
|
|
11501
|
+
sourceUrl: item.url,
|
|
11502
|
+
originalUrl,
|
|
11503
|
+
sourceHost,
|
|
11504
|
+
downloadJobId: job.id,
|
|
11505
|
+
trendTagline: item.title
|
|
11506
|
+
});
|
|
11507
|
+
}
|
|
11508
|
+
// Stamp the editorial marker + curated title up front so a still-downloading
|
|
11509
|
+
// pick already surfaces under Popular with the right label (pending cards
|
|
11510
|
+
// read trendTagline, which finalize never clobbers).
|
|
11511
|
+
inspiration = (await serverlessRecords.updateInspiration({
|
|
11512
|
+
inspirationId: inspiration.id,
|
|
11513
|
+
patch: { title: item.title, trendTagline: item.title, popularGroup: item.group, popularRank: item.rank, visibility: "public" }
|
|
11514
|
+
})) ?? inspiration;
|
|
11515
|
+
// On a local box (no Step Functions) the download job would queue forever —
|
|
11516
|
+
// run it in-process so ensureInspirationReadyThenFork can finalize.
|
|
11517
|
+
if (!config.VIDFARM_JOB_STATE_MACHINE_ARN && inspiration.status === "downloading" && inspiration.downloadJobId) {
|
|
11518
|
+
const pendingJobId = inspiration.downloadJobId;
|
|
11519
|
+
void runPrimitiveJobInProcess(pendingJobId).catch((error) => {
|
|
11520
|
+
console.error("popular seed download failed", pendingJobId, error instanceof Error ? error.message : error);
|
|
11521
|
+
});
|
|
11522
|
+
}
|
|
11523
|
+
const finalized = await ensureInspirationReadyThenFork({ inspiration, ownerCustomer, publicBaseUrl, note: null });
|
|
11524
|
+
let templateId = typeof finalized.template_id === "string" ? finalized.template_id : null;
|
|
11525
|
+
let clipped = false;
|
|
11526
|
+
let sourceDurationSec = null;
|
|
11527
|
+
// Curated Popular picks are short-form. If the source downloaded fine but was
|
|
11528
|
+
// rejected ONLY for exceeding the 120s ingest limit, clip it to <=115s and
|
|
11529
|
+
// finalize the clip — so a long reference video still becomes a Popular card.
|
|
11530
|
+
if (!templateId) {
|
|
11531
|
+
const rejected = await serverlessRecords.getInspiration(inspiration.id);
|
|
11532
|
+
const tooLong = Boolean(rejected?.videoUrl)
|
|
11533
|
+
&& ((Number.isFinite(rejected?.durationSeconds) && Number(rejected?.durationSeconds) > POPULAR_MAX_CLIP_SEC)
|
|
11534
|
+
|| /exceeds/i.test(rejected?.errorMessage ?? ""));
|
|
11535
|
+
if (Number.isFinite(rejected?.durationSeconds))
|
|
11536
|
+
sourceDurationSec = Number(rejected?.durationSeconds);
|
|
11537
|
+
else {
|
|
11538
|
+
const m = /([0-9.]+)s exceeds/.exec(rejected?.errorMessage ?? "");
|
|
11539
|
+
if (m)
|
|
11540
|
+
sourceDurationSec = Number(m[1]);
|
|
11541
|
+
}
|
|
11542
|
+
if (rejected?.videoUrl && tooLong) {
|
|
11543
|
+
try {
|
|
11544
|
+
const trimmed = await trimVideoAsset({ sourceUrl: rejected.videoUrl, startMs: 0, durationMs: POPULAR_MAX_CLIP_SEC * 1000 });
|
|
11545
|
+
// Store under the public "primitives/" prefix so getPublicUrl can hand
|
|
11546
|
+
// back a directly-fetchable S3 URL (other prefixes resolve to null).
|
|
11547
|
+
const clipKey = joinStorageKey("primitives", "popular-clips", `${rejected.id}.mp4`);
|
|
11548
|
+
const stored = await storage.putBuffer(clipKey, trimmed.bytes, "video/mp4");
|
|
11549
|
+
const clipUrl = stored.url ?? storage.getPublicUrl(clipKey);
|
|
11550
|
+
if (!clipUrl)
|
|
11551
|
+
console.error("popular seed clip: no public URL for", clipKey);
|
|
11552
|
+
const clipDurRaw = trimmed.metadata.trimmed?.durationSeconds;
|
|
11553
|
+
const clipDur = Number.isFinite(clipDurRaw) && clipDurRaw ? Number(clipDurRaw) : POPULAR_MAX_CLIP_SEC;
|
|
11554
|
+
if (clipUrl) {
|
|
11555
|
+
const readyInsp = await serverlessRecords.updateInspiration({
|
|
11556
|
+
inspirationId: rejected.id,
|
|
11557
|
+
patch: {
|
|
11558
|
+
videoUrl: clipUrl,
|
|
11559
|
+
videoStorageKey: clipKey,
|
|
11560
|
+
thumbnailUrl: null,
|
|
11561
|
+
durationSeconds: clipDur,
|
|
11562
|
+
status: "ready",
|
|
11563
|
+
errorMessage: null
|
|
11564
|
+
}
|
|
11565
|
+
});
|
|
11566
|
+
if (readyInsp) {
|
|
11567
|
+
const reFinalized = await ensureInspirationReadyThenFork({ inspiration: readyInsp, ownerCustomer, publicBaseUrl, note: null });
|
|
11568
|
+
templateId = typeof reFinalized.template_id === "string" ? reFinalized.template_id : null;
|
|
11569
|
+
clipped = Boolean(templateId);
|
|
11570
|
+
}
|
|
11571
|
+
}
|
|
11572
|
+
}
|
|
11573
|
+
catch (error) {
|
|
11574
|
+
console.error("popular seed clip failed", rejected.id, error instanceof Error ? error.message : error);
|
|
11575
|
+
}
|
|
11576
|
+
}
|
|
11577
|
+
}
|
|
11578
|
+
if (templateId) {
|
|
11579
|
+
await serverlessRecords.updateTemplate({
|
|
11580
|
+
templateId,
|
|
11581
|
+
patch: { title: item.title, popularGroup: item.group, popularRank: item.rank, visibility: "public" }
|
|
11582
|
+
});
|
|
11583
|
+
}
|
|
11584
|
+
results.push({
|
|
11585
|
+
source_url: item.url,
|
|
11586
|
+
title: item.title,
|
|
11587
|
+
group: item.group,
|
|
11588
|
+
rank: item.rank,
|
|
11589
|
+
template_id: templateId,
|
|
11590
|
+
status: templateId ? "ready" : (finalized.status ?? null),
|
|
11591
|
+
clipped,
|
|
11592
|
+
source_duration_sec: sourceDurationSec,
|
|
11593
|
+
pending: finalized.pending ?? false
|
|
11594
|
+
});
|
|
11595
|
+
}
|
|
11596
|
+
// Reconcile: any previously-seeded pick whose URL is no longer in the manifest
|
|
11597
|
+
// (a replaced or removed entry) gets un-flagged so it drops out of the Popular
|
|
11598
|
+
// view — the manifest is the single source of truth. Compared after the same
|
|
11599
|
+
// tracking-param stripping the stored originalUrl went through.
|
|
11600
|
+
const manifestUrls = new Set(allItems.map((it) => stripTrackingParams(it.url)));
|
|
11601
|
+
const retired = [];
|
|
11602
|
+
for (const tmpl of await serverlessRecords.listPopularTemplates()) {
|
|
11603
|
+
if (!manifestUrls.has(tmpl.originalUrl)) {
|
|
11604
|
+
await serverlessRecords.updateTemplate({ templateId: tmpl.id, patch: { popularGroup: null, popularRank: null } });
|
|
11605
|
+
retired.push({ template_id: tmpl.id, title: tmpl.title, original_url: tmpl.originalUrl });
|
|
11606
|
+
}
|
|
11607
|
+
}
|
|
11608
|
+
return c.json({ ok: true, owner_customer_id: ownerCustomer.id, count: results.length, items: results, retired }, 202);
|
|
11609
|
+
});
|
|
11304
11610
|
function buildRawTikTokCompositionHtml(input) {
|
|
11305
11611
|
const duration = Number.isFinite(input.durationSeconds) && input.durationSeconds > 0
|
|
11306
11612
|
? Number(input.durationSeconds.toFixed(3))
|
package/dist/src/config.js
CHANGED
|
@@ -73,6 +73,19 @@ const schema = z.object({
|
|
|
73
73
|
// long video through GhostCut. 15 min default ceiling (per-30s pricing makes
|
|
74
74
|
// long-form runs absurd anyway).
|
|
75
75
|
GHOSTCUT_MAX_DURATION_SEC: z.coerce.number().min(0).default(900),
|
|
76
|
+
// Auto-decompose kicks off GhostCut subtitle removal on the source video, but
|
|
77
|
+
// GhostCut prices per-30s chunk and is meant for short clips. Skip it entirely
|
|
78
|
+
// when the decompose source is longer than this — the composition just keeps
|
|
79
|
+
// the original burned-in captions rather than paying to launder a multi-minute
|
|
80
|
+
// video. 0 = never skip on length. Distinct from GHOSTCUT_MAX_DURATION_SEC
|
|
81
|
+
// (the standalone remove-captions primitive's hard ceiling).
|
|
82
|
+
GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC: z.coerce.number().min(0).default(180),
|
|
83
|
+
// Inspiration ingest + auto-decompose used to hard-cap sources at 120s (a
|
|
84
|
+
// short-form-only assumption). Longer inspiration is now allowed. 0 = unlimited;
|
|
85
|
+
// set a positive ceiling to re-impose a limit. Kept separate from
|
|
86
|
+
// MAX_PROBE_DURATION_SECONDS (the per-primitive probe cost-guard) so raising the
|
|
87
|
+
// inspiration ceiling never loosens the generic probe default.
|
|
88
|
+
INSPIRATION_MAX_DURATION_SEC: z.coerce.number().min(0).default(0),
|
|
76
89
|
// Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
|
|
77
90
|
// fully local but lists the upstream (prod) catalog in /discover and
|
|
78
91
|
// /library, seeds forks on demand, and can hand a render off to the cloud.
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -72,6 +72,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
72
72
|
"The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
|
|
73
73
|
"Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
|
|
74
74
|
"Treat the most recent <editor_context> block in the user message as the source of truth for current layer_keys, layer indices (called track in HTML: track=layer index, higher = drawn on top), durations, and composition_duration_seconds. Never invent layer_keys for remove/set/duplicate/split actions; only reference keys that appear there.",
|
|
75
|
+
"CRITICAL — verify your edits actually landed. An editor_action tool result only echoes the arguments you sent; it is NOT proof the change was applied or saved. The real outcome shows up in the NEXT <editor_context>. Before telling the user an edit succeeded, check that block: (1) if it contains save_status:\"error\" (or is_read_only:true / a save_error message), the composition is NOT being saved — do NOT claim any edit worked; tell the user exactly what save_error says (e.g. the video is read-only from this link and they should open their own copy to edit) and stop making mutating editor_action calls until it clears; (2) if it contains recent_action_results, each entry with ok:false is an edit from your previous turn that FAILED (read its error) — acknowledge the failure and retry or explain it rather than repeating a false success; (3) otherwise confirm the intended change is reflected in the layers (e.g. the new text on the target layer) before saying it's done. Never report a timeline edit as successful without this check.",
|
|
75
76
|
"editor_context also carries the canvas shape and free time: composition_width, composition_height, and aspect_ratio (e.g. '9:16') describe the output frame — when generating media to place on this timeline, request the matching aspect_ratio on /videos/generate and /images/generate so it fills the canvas without letterboxing or wrong cropping. timeline_gaps is a precomputed array of {start, end, duration} spans (seconds) where NO visual layer is on screen — i.e. the literal blank/black moments. When the user says something like 'at 00:05:52 there's blank space, add X', map their timestamp (interpret mm:ss or seconds sensibly) to the covering entry in timeline_gaps, set the new layer's start to that gap's start (or the user's exact time if inside the gap) and clamp its duration to fit the gap unless the user asks otherwise. If the requested time is NOT inside any timeline_gaps entry, tell the user it already has content there and offer to overlay on a higher track or replace the existing layer instead of silently colliding.",
|
|
76
77
|
"For add_layer, always supply a stable layer_key (e.g., vf-caption-hook, vf-hero-image, vf-narration-audio) so later tool calls in the same turn can reference the new layer without waiting for the next <editor_context>. The editor uses that layer_key as the new layer's id. Choose kind from video, image, audio, text, or html. Provide src (exact URL) for video/image/audio, text for text layers, and html for html layers. Pick a track index higher than every existing layer on the requested time range, or set start/duration so the range is collision-free. Default visual layers fill roughly the upper-middle of the canvas; text layers default to a lower-third band. Always include explanation.",
|
|
77
78
|
"GENERATE-AND-PLACE (preferred for NEW AI footage/images on the timeline): when the user wants to generate a NEW AI video or image and drop it on the timeline — fill a blank gap, replace a scene, or add an overlay — call editor_action with action_type=generate_layer. This ONE action submits the primitive generate job, immediately drops a 'Generating…' placeholder clip into the target slot, and auto-swaps in the finished media when the job settles — you do NOT poll and you do NOT make a separate add_layer call. Set media_type ('video' or 'image'), prompt, and aspect_ratio to the editor_context aspect_ratio so it fills the canvas. Set intent: 'fill_gap' (also set start=the timeline_gaps span start and duration to fit the gap), 'replace_layer' (also set replace_layer_key to the scene's layer_key/slug — its timing and geometry, including full-canvas, are copied automatically), or 'add' (set start/track/geometry for an overlay). For video pass gen_duration (seconds of footage, e.g. 4) and, for character consistency, input_references=[cast reference_url or still_url]; for image pass prompt_attachments=[reference image URL]. Optionally set provider/model/resolution/generate_audio. Give explanation. Because generation is asynchronous, the placeholder appears now and the real media lands in a later turn — do NOT claim the media is finished; tell the user it is generating and will appear on the timeline shortly.",
|
|
@@ -65,6 +65,10 @@ function HomepageClientApp({ boot }) {
|
|
|
65
65
|
const bookmarks = useStore(store, (state) => state.bookmarks);
|
|
66
66
|
const loading = useStore(store, (state) => state.loading);
|
|
67
67
|
const error = useStore(store, (state) => state.error);
|
|
68
|
+
const view = useStore(store, (state) => state.view);
|
|
69
|
+
const popularTemplates = useStore(store, (state) => state.popularTemplates);
|
|
70
|
+
const popularLoading = useStore(store, (state) => state.popularLoading);
|
|
71
|
+
const popularError = useStore(store, (state) => state.popularError);
|
|
68
72
|
const [bookmarksReady, setBookmarksReady] = useState(false);
|
|
69
73
|
const visibleTemplates = useMemo(() => filterTemplates({ templates, searchQuery, bookmarksOnly, bookmarks }), [templates, searchQuery, bookmarksOnly, bookmarks]);
|
|
70
74
|
useEffect(() => {
|
|
@@ -121,6 +125,51 @@ function HomepageClientApp({ boot }) {
|
|
|
121
125
|
}, 8000);
|
|
122
126
|
return () => window.clearInterval(timer);
|
|
123
127
|
}, [hasProcessingTemplates, refreshFeed]);
|
|
128
|
+
// The curated Popular picks come from a separate endpoint and are the same
|
|
129
|
+
// for everyone, so they're fetched lazily the first time the toggle is opened
|
|
130
|
+
// (and re-fetched, without a spinner, while any pick is still downloading).
|
|
131
|
+
const refreshPopular = useCallback(async (options) => {
|
|
132
|
+
if (options?.showLoading) {
|
|
133
|
+
store.getState().setPopularLoading(true);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch("/discover/popular", {
|
|
137
|
+
credentials: "same-origin",
|
|
138
|
+
cache: "no-store",
|
|
139
|
+
headers: { Accept: "application/json" }
|
|
140
|
+
});
|
|
141
|
+
const payload = await response.json().catch(() => ({}));
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(typeof payload.error === "string" && payload.error ? payload.error : "Unable to load popular formats.");
|
|
144
|
+
}
|
|
145
|
+
store.getState().setPopularTemplates(Array.isArray(payload.templates) ? payload.templates : []);
|
|
146
|
+
}
|
|
147
|
+
catch (fetchError) {
|
|
148
|
+
if (options?.showLoading) {
|
|
149
|
+
store.getState().setPopularError(fetchError instanceof Error ? fetchError.message : "Unable to load popular formats.");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
debugError("homepage.popular_refresh_failed", { message: fetchError instanceof Error ? fetchError.message : String(fetchError) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}, [store]);
|
|
156
|
+
const popularLoaded = useStore(store, (state) => state.popularLoaded);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (view !== "popular" || popularLoaded) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
void refreshPopular({ showLoading: true });
|
|
162
|
+
}, [view, popularLoaded, refreshPopular]);
|
|
163
|
+
const hasProcessingPopular = view === "popular" && popularTemplates.some((template) => template.status === "processing");
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!hasProcessingPopular) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const timer = window.setInterval(() => {
|
|
169
|
+
void refreshPopular();
|
|
170
|
+
}, 8000);
|
|
171
|
+
return () => window.clearInterval(timer);
|
|
172
|
+
}, [hasProcessingPopular, refreshPopular]);
|
|
124
173
|
const handleDeleteTemplate = useCallback(async (templateId) => {
|
|
125
174
|
try {
|
|
126
175
|
const response = await fetch(`/discover/templates/${encodeURIComponent(templateId)}`, {
|
|
@@ -219,7 +268,7 @@ function HomepageClientApp({ boot }) {
|
|
|
219
268
|
return { ok: false, error: submitError instanceof Error ? submitError.message : "Unable to add that video." };
|
|
220
269
|
}
|
|
221
270
|
}, [refreshFeed]);
|
|
222
|
-
return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate }));
|
|
271
|
+
return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, view: view, popularTemplates: popularTemplates, popularLoading: popularLoading, popularError: popularError, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onViewChange: (value) => store.getState().setView(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate }));
|
|
223
272
|
}
|
|
224
273
|
const boot = readBoot();
|
|
225
274
|
const root = document.getElementById("homepage-root");
|