@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/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
- if (!targetForkId)
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
- const title = isPrivate ? (storedTitle || template.id) : template.id;
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({ sourceUrl });
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 (sourceUrl && sourceIsVideo && isGhostcutConfigured()) {
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({ sourceUrl: inspiration.videoUrl });
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))
@@ -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.
@@ -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");