@mevdragon/vidfarm-devcli 0.17.0 → 0.18.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.
Files changed (55) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +43 -6
  2. package/SKILL.director.md +37 -23
  3. package/SKILL.platform.md +6 -6
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1832 -213
  7. package/dist/src/cli.js +238 -17
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +116 -73
  10. package/dist/src/devcli/composition-edit.js +262 -0
  11. package/dist/src/devcli/timeline-edit.js +283 -0
  12. package/dist/src/editor-chat.js +29 -6
  13. package/dist/src/frontend/discover-client.js +130 -0
  14. package/dist/src/frontend/discover-store.js +23 -0
  15. package/dist/src/frontend/file-directory.js +744 -0
  16. package/dist/src/frontend/template-editor-chat.js +22 -19
  17. package/dist/src/landing-page.js +24 -7
  18. package/dist/src/page-shell.js +25 -1
  19. package/dist/src/reskin/agency-page.js +214 -170
  20. package/dist/src/reskin/calendar-page.js +503 -187
  21. package/dist/src/reskin/chat-page.js +739 -299
  22. package/dist/src/reskin/discover-page.js +1450 -279
  23. package/dist/src/reskin/document.js +1392 -16
  24. package/dist/src/reskin/help-page.js +139 -100
  25. package/dist/src/reskin/index-page.js +1 -1
  26. package/dist/src/reskin/inpaint-page.js +547 -0
  27. package/dist/src/reskin/job-runs-page.js +355 -127
  28. package/dist/src/reskin/library-page.js +1188 -317
  29. package/dist/src/reskin/login-page.js +124 -87
  30. package/dist/src/reskin/pricing-page.js +197 -166
  31. package/dist/src/reskin/settings-page.js +566 -187
  32. package/dist/src/reskin/theme.js +434 -17
  33. package/dist/src/services/clip-curation/gemini.js +5 -0
  34. package/dist/src/services/clip-curation/hunt.js +79 -1
  35. package/dist/src/services/clip-curation/index.js +2 -1
  36. package/dist/src/services/clip-curation/local-agent.js +4 -3
  37. package/dist/src/services/clip-curation/media-select.js +85 -0
  38. package/dist/src/services/clip-curation/query.js +5 -1
  39. package/dist/src/services/clip-curation/refine.js +50 -20
  40. package/dist/src/services/clip-curation/scan.js +10 -3
  41. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  42. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  43. package/dist/src/services/clip-records.js +14 -1
  44. package/dist/src/services/clip-search.js +43 -13
  45. package/dist/src/services/file-directory.js +114 -0
  46. package/dist/src/services/storage.js +24 -1
  47. package/dist/src/services/upstream.js +5 -5
  48. package/dist/src/template-editor-shell.js +16 -2
  49. package/package.json +1 -1
  50. package/public/assets/discover-client-app.js +1 -0
  51. package/public/assets/file-directory-app.js +2 -0
  52. package/public/assets/homepage-client-app.js +12 -12
  53. package/public/assets/page-runtime-client-app.js +24 -24
  54. package/src/assets/favicon.ico +0 -0
  55. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/app.js CHANGED
@@ -30,11 +30,13 @@ import { renderReskinLibrary } from "./reskin/library-page.js";
30
30
  import { renderReskinDiscover } from "./reskin/discover-page.js";
31
31
  import { renderReskinChat } from "./reskin/chat-page.js";
32
32
  import { renderReskinCalendar } from "./reskin/calendar-page.js";
33
- import { renderReskinJobRuns } from "./reskin/job-runs-page.js";
33
+ import { renderReskinInpaint } from "./reskin/inpaint-page.js";
34
34
  import { renderReskinAgency } from "./reskin/agency-page.js";
35
35
  import { renderReskinPricing } from "./reskin/pricing-page.js";
36
36
  import { renderReskinLogin } from "./reskin/login-page.js";
37
37
  import { renderReskinHelp } from "./reskin/help-page.js";
38
+ import { renderChatDock, RESKIN_CHROME_SCRIPT } from "./reskin/document.js";
39
+ import { RESKIN_CSS, RESKIN_FONT_LINKS } from "./reskin/theme.js";
38
40
  import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
39
41
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
40
42
  import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
@@ -68,7 +70,8 @@ import { loadSpeechAudioLocally, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./devc
68
70
  import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
69
71
  import { clipRecords, makeUserPreset } from "./services/clip-records.js";
70
72
  import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
71
- import { BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, estimateScanCostFromDuration, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, probeVideo, resolveDurationBand, scanVideo } from "./services/clip-curation/index.js";
73
+ import { ATTACHMENTS_FOLDER_SCOPE, buildDirectoryPath, DIRECTORY_ROOTS, DIRECTORY_ROOT_KEYS, folderItem, immediateChildFolders, normalizeFolderPath, parseDirectoryPath } from "./services/file-directory.js";
74
+ import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
72
75
  import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
73
76
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
74
77
  import { createPrimitiveJobContext } from "./primitive-context.js";
@@ -101,7 +104,10 @@ const COMPOSITIONS_PREFIX = `${API_PREFIX}/compositions`;
101
104
  const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
102
105
  const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
103
106
  const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
104
- const CLIPS_PREFIX = "/clips";
107
+ const RAWS_PREFIX = "/raws";
108
+ // Back-compat: the clip library was renamed "raws". Old /clips callers are
109
+ // transparently redispatched to /raws (see the alias registered below).
110
+ const CLIPS_COMPAT_PREFIX = "/clips";
105
111
  // Temp folder ("My Files → temporary") hard-TTL. Keep in sync with the
106
112
  // tag-scoped S3 lifecycle rule (30 days) in the CDK stacks and the clip-scan
107
113
  // Lambda's TEMP_SOURCE_TTL_DAYS — records, sweeps, and object lifecycle must
@@ -843,7 +849,7 @@ function consumeLoginReturnPath(c) {
843
849
  function resolvePostLoginRedirect(c, customer) {
844
850
  const returnPath = consumeLoginReturnPath(c) ?? sanitizeLoginReturnPath(c.req.query("return_to"));
845
851
  if (!returnPath) {
846
- return withAccountQuery("/settings", customer.id);
852
+ return withAccountQuery("/settings/profile", customer.id);
847
853
  }
848
854
  const parsed = new URL(returnPath, "https://vidfarm.local");
849
855
  if (parsed.pathname === "/settings" || parsed.pathname.startsWith("/settings/")) {
@@ -858,6 +864,10 @@ function setSettingsFlash(c, input) {
858
864
  function clearSettingsFlash(c) {
859
865
  appendCookie(c, `${SETTINGS_FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${cookieSecureAttribute()}`);
860
866
  }
867
+ const SETTINGS_TABS = ["profile", "wallet", "channels", "developer"];
868
+ function isSettingsTab(value) {
869
+ return value === "profile" || value === "wallet" || value === "channels" || value === "developer";
870
+ }
861
871
  function consumeSettingsFlash(c) {
862
872
  const cookies = parseCookieHeader(c.req.header("cookie"));
863
873
  const raw = cookies.get(SETTINGS_FLASH_COOKIE);
@@ -1045,19 +1055,15 @@ function parseTracerList(searchParams) {
1045
1055
  return output.slice(0, 24);
1046
1056
  }
1047
1057
  function redirectToSettings(c, input) {
1048
- if (input?.notice || input?.error || input?.tab) {
1049
- setSettingsFlash(c, input);
1058
+ // Notice/error still travel in a short-lived flash cookie; the tab now travels
1059
+ // in the path (/settings/<tab>) so the URL matches the panel that's shown.
1060
+ if (input?.notice || input?.error) {
1061
+ setSettingsFlash(c, { notice: input.notice, error: input.error, tab: input.tab });
1050
1062
  }
1051
- const params = new URLSearchParams();
1052
1063
  const accountId = input?.accountId ?? getRequestedAccountId(c);
1053
- if (input?.tab) {
1054
- params.set("tab", input.tab);
1055
- }
1056
- if (input?.refreshChannels) {
1057
- params.set("refresh", "channels");
1058
- }
1059
- const suffix = params.size ? `?${params.toString()}` : "";
1060
- return redirect(c, `${accountId ? withAccountQuery("/settings", accountId) : "/settings"}${suffix}`, 303);
1064
+ const tab = isSettingsTab(input?.tab) ? input.tab : "profile";
1065
+ const suffix = input?.refreshChannels ? "?refresh=channels" : "";
1066
+ return redirect(c, `${withAccountQuery(`/settings/${tab}`, accountId)}${suffix}`, 303);
1061
1067
  }
1062
1068
  async function validateFlockPosterApiKey(apiKey) {
1063
1069
  await fetchFlockPosterIntegrations(apiKey);
@@ -2889,6 +2895,16 @@ function sanitizeStorageSubpath(value) {
2889
2895
  .filter((segment) => segment && segment !== "." && segment !== "..")
2890
2896
  .join("/");
2891
2897
  }
2898
+ // Temp files auto-organize by date: an upload with no explicit folder lands in
2899
+ // today's YYYY-MM-DD subfolder (canonical `/temp/2026-07-08/…`), so the scratch
2900
+ // space stays browsable instead of a flat dump. Applied at the write points
2901
+ // (presign + direct upload); finalize trusts the folder the presign echoed back.
2902
+ function todayDateFolder() {
2903
+ return new Date().toISOString().slice(0, 10);
2904
+ }
2905
+ function withTempDateFolder(folderPath) {
2906
+ return folderPath || todayDateFolder();
2907
+ }
2892
2908
  function inferAttachmentContentType(fileName) {
2893
2909
  switch (path.extname(fileName).toLowerCase()) {
2894
2910
  case ".png":
@@ -2966,6 +2982,107 @@ function serializeUserTemporaryFile(c, file) {
2966
2982
  function buildFolderList(items) {
2967
2983
  return Array.from(new Set(items.map((item) => item.folderPath ?? "").filter(Boolean))).sort();
2968
2984
  }
2985
+ // ── Unified file directory: convert each backend's record into the one
2986
+ // canonical DirectoryItem shape so /files, /temp, /raws browse uniformly. ─────
2987
+ function attachmentToDirectoryItem(c, a) {
2988
+ const s = serializeUserAttachment(c, a);
2989
+ const folderPath = normalizeFolderPath(a.folderPath);
2990
+ return {
2991
+ path: buildDirectoryPath("files", folderPath, a.fileName),
2992
+ root: "files",
2993
+ folderPath,
2994
+ name: a.fileName,
2995
+ kind: "file",
2996
+ id: a.id,
2997
+ contentType: a.contentType,
2998
+ viewUrl: s.viewUrl,
2999
+ sizeBytes: a.sizeBytes,
3000
+ meta: { notes: s.notes, hasNotesEmbedding: s.hasNotesEmbedding, createdAt: a.createdAt }
3001
+ };
3002
+ }
3003
+ function temporaryFileToDirectoryItem(c, f) {
3004
+ const s = serializeUserTemporaryFile(c, f);
3005
+ const folderPath = normalizeFolderPath(f.folderPath);
3006
+ return {
3007
+ path: buildDirectoryPath("temp", folderPath, f.fileName),
3008
+ root: "temp",
3009
+ folderPath,
3010
+ name: f.fileName,
3011
+ kind: "file",
3012
+ id: f.id,
3013
+ contentType: f.contentType,
3014
+ viewUrl: s.viewUrl,
3015
+ sizeBytes: f.sizeBytes,
3016
+ meta: { expiresAt: f.expiresAt, createdAt: f.createdAt, s3Url: s.s3Url }
3017
+ };
3018
+ }
3019
+ // Takes the OUTPUT of serializeClip (view/thumbnail URLs already resolved).
3020
+ function clipSerializedToDirectoryItem(s) {
3021
+ const folderPath = normalizeFolderPath(s.folder_path);
3022
+ const name = s.source_filename || s.description || "clip";
3023
+ const duration = s.duration_sec;
3024
+ return {
3025
+ path: buildDirectoryPath("raws", folderPath, name),
3026
+ root: "raws",
3027
+ folderPath,
3028
+ name,
3029
+ kind: "file",
3030
+ id: s.clip_id,
3031
+ contentType: "video/mp4",
3032
+ viewUrl: s.view_url || undefined,
3033
+ thumbUrl: s.thumbnail_url || undefined,
3034
+ durationSec: typeof duration === "number" ? duration : undefined,
3035
+ meta: { description: s.description, tags: s.tags, created_at: s.created_at, source_video_id: s.source_video_id }
3036
+ };
3037
+ }
3038
+ /**
3039
+ * List one folder of one directory root, returning immediate child folders + the
3040
+ * files directly in this folder as canonical DirectoryItems. The single place
3041
+ * that dispatches a canonical path to its backend (attachments / temp / raws).
3042
+ */
3043
+ async function listDirectoryFolder(c, customerId, root, folderPath) {
3044
+ const cur = normalizeFolderPath(folderPath);
3045
+ if (root === "files") {
3046
+ const atts = await serverlessRecords.listUserAttachments(customerId);
3047
+ const explicit = await serverlessRecords.listUserFileFolders(customerId, ATTACHMENTS_FOLDER_SCOPE);
3048
+ const childNames = immediateChildFolders([...explicit, ...atts.map((a) => a.folderPath)], cur);
3049
+ const files = atts
3050
+ .filter((a) => normalizeFolderPath(a.folderPath) === cur)
3051
+ .map((a) => attachmentToDirectoryItem(c, a));
3052
+ return { folders: childNames.map((n) => folderItem("files", cur, n)), files };
3053
+ }
3054
+ if (root === "temp") {
3055
+ const list = await serverlessRecords.listUserTemporaryFiles(customerId);
3056
+ const explicit = await serverlessRecords.listUserFileFolders(customerId, "temporary");
3057
+ const childNames = immediateChildFolders([...explicit, ...list.map((f) => f.folderPath)], cur);
3058
+ const files = list
3059
+ .filter((f) => normalizeFolderPath(f.folderPath) === cur)
3060
+ .map((f) => temporaryFileToDirectoryItem(c, f));
3061
+ return { folders: childNames.map((n) => folderItem("temp", cur, n)), files };
3062
+ }
3063
+ // raws
3064
+ const clips = await clipRecords.listClips(customerId, { limit: 5000 });
3065
+ const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
3066
+ const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
3067
+ const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
3068
+ const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
3069
+ return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
3070
+ }
3071
+ // The user_file_folder scope backing each directory root's empty folders.
3072
+ function directoryFolderScope(root) {
3073
+ return root === "files" ? ATTACHMENTS_FOLDER_SCOPE : root === "temp" ? "temporary" : "raws";
3074
+ }
3075
+ // The three roots presented as folders at the virtual root ("/").
3076
+ function directoryRootFolders() {
3077
+ return DIRECTORY_ROOTS.map((r) => ({
3078
+ path: `/${r.key}`,
3079
+ root: r.key,
3080
+ folderPath: "",
3081
+ name: r.label,
3082
+ kind: "folder",
3083
+ meta: { description: r.description }
3084
+ }));
3085
+ }
2969
3086
  function isFormFile(value) {
2970
3087
  return Boolean(value
2971
3088
  && typeof value === "object"
@@ -3142,6 +3259,7 @@ function buildPrimitiveEditorDocsRoutes() {
3142
3259
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/edit`, summary: "Primitive whole-image edit job. Body must be { tracer, payload: { source_image_url, instruction, reference_attachments?, aspect_ratio?, image_size?, output_format? }, webhook_url? }." },
3143
3260
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/inpaint`, summary: "Primitive masked image edit job. Body must be { tracer, payload: { source_image_url, mask_url, instruction, reference_attachments?, aspect_ratio?, image_size?, output_format? }, webhook_url? }." },
3144
3261
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/render-html`, summary: "Primitive HTML still-image render job. Body must be { tracer, payload: { html, css?, width?, height?, background_color?, output_format? }, webhook_url? }." },
3262
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background`, summary: "Primitive background-removal job. Returns a transparent-background PNG cut-out of the subject (subject isolation for overlays, product cut-outs, sprite cards, caption matting). Body must be { tracer, payload: { source_image_url }, webhook_url? }. BILLS THE WALLET (RapidAPI) — unlike the BYOK images/generate and images/edit routes." },
3145
3263
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/download`, summary: "Primitive social/media video download job. Body must be { tracer, payload: { source_url|video_url|url, quality? }, webhook_url? }. Use this to turn supported page/post URLs into a durable Vidfarm-hosted MP4 before later template work." },
3146
3264
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/generate`, summary: "Primitive AI video generation job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { prompt, provider?, model?, input_references?, frame_images?, aspect_ratio?, duration?, resolution?, generate_audio? }, webhook_url? }. input_references must be direct image asset URLs, not webpage or social post URLs. duration is seconds, not milliseconds; use duration: 4 for a 4-second AI video. Defaults: openai/sora-2, gemini/veo-3.0-generate-001, openrouter/bytedance/seedance-2.0. Use this for new AI-generated footage." },
3147
3265
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/normalize`, summary: "Primitive video normalization job. Body must be { tracer, payload: { source_video_url, max_width?, max_height?, crf?, preset?, max_video_bitrate_kbps?, audio_bitrate_kbps? }, webhook_url? }." },
@@ -3259,11 +3377,11 @@ async function buildClipsEditorChatBoot(input) {
3259
3377
  const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md") || null;
3260
3378
  const availableProviders = (await normalizeEditorChatProviderRows(input.customer.id)).map((entry) => entry.provider);
3261
3379
  const docsRoutes = [
3262
- { method: "GET", path: `${CLIPS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
3263
- { method: "POST", path: `${CLIPS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
3264
- { method: "POST", path: `${CLIPS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
3265
- { method: "GET", path: `${CLIPS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
3266
- { method: "GET", path: `${CLIPS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
3380
+ { method: "GET", path: `${RAWS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
3381
+ { method: "POST", path: `${RAWS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
3382
+ { method: "POST", path: `${RAWS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
3383
+ { method: "GET", path: `${RAWS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
3384
+ { method: "GET", path: `${RAWS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
3267
3385
  ];
3268
3386
  return {
3269
3387
  apiUrl: resolveEditorChatApiUrl(),
@@ -3606,6 +3724,10 @@ function normalizeEditorChatPath(input) {
3606
3724
  PRIMITIVES_PREFIX,
3607
3725
  APPROVED_POSTS_PREFIX,
3608
3726
  `${USER_PREFIX}/me/jobs`,
3727
+ // Unified file directory: browse/search /files, /temp, /raws by canonical
3728
+ // path (GET /me/directory, POST /me/directory/search). browse_files uses the
3729
+ // same endpoints; this lets the agent hit them directly via http_request too.
3730
+ `${USER_PREFIX}/me/directory`,
3609
3731
  // Read-only catalog discovery so the copilot can answer "which templates
3610
3732
  // suit my <offer>?": the template feed (supports ?q=&limit=) and the source
3611
3733
  // video / inspiration catalog (supports ?q=&limit=&mine=). Ingest lives at
@@ -3627,7 +3749,11 @@ function normalizeEditorChatPath(input) {
3627
3749
  // and NEVER runs GhostCut on the long-form source — "no text" is a
3628
3750
  // scene-selection filter; caption removal stays a per-finished-clip
3629
3751
  // primitive (/api/v1/primitives/videos/remove-captions).
3630
- CLIPS_PREFIX,
3752
+ RAWS_PREFIX,
3753
+ // Legacy /clips alias (server redispatches /clips/* → /raws/*). Allowed so a
3754
+ // prompt that still says /clips/scan works identically to /raws/scan and the
3755
+ // allowlist doesn't drift from the deployed Lambda copy.
3756
+ CLIPS_COMPAT_PREFIX,
3631
3757
  // Public skill knowledge: the vendored skill packs (load_skill fetches
3632
3758
  // these too) and per-template SKILL.md manifests. Read-only markdown.
3633
3759
  "/skill/",
@@ -4010,19 +4136,24 @@ function browseFilesTextContentType(fileName) {
4010
4136
  }
4011
4137
  function createBrowseFilesTool(input) {
4012
4138
  return tool({
4013
- description: "Browse AND write the user's My Files filesystem their personal library of uploaded assets (mp4, mov, webm, png, jpg, jpeg, gif, webp, svg, mp3, wav, m4a, aac, pdf, md, txt, csv, and more) organized into virtual folders. Use action=list to enumerate files and virtual folders so you can discover what assets exist; pass folder_path to scope the listing to one folder (omit or empty for the root). Use action=search to find files by MEANING when you don't know where something lives: pass query (plain language, e.g. 'character reference sheet for the mascot' or 'logo on transparent background') and it matches keywords AND vector-embedded metadata notes across every file's name, folder, and notes — prefer search over paging through list when My Files is large or the user references an asset vaguely. Use action=read to fetch one file by file_id (preferred; copy it from a prior list/search result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text), optional folder_path to namescope it under a product/offer folder, and optional notes. This is how you persist Getting Started context the user can reuse later: a product About.md or Interview.md, and awareness-levels.md / persuasive-angles.md / ad-hooks.md distilled from brainstorm output. write saves text files (md, txt, csv, json, srt, vtt) via content, or IMPORTS any media asset into the library by passing its durable URL as source_url — e.g. save a finished image job's output as character_sprite_card.png so it persists in My Files instead of living only in a job result. write returns the new file_id and view_url. Use action=annotate to set metadata notes on ANY existing file (including binary assets): notes say what the file is, who/what it depicts, and when to use it, and they are vector-embedded so future sessions can search their way back to the asset. Annotate every asset worth finding again — especially character references: keep a character_sprite_card.png plus an about_character.md per recurring character in that character's folder, annotate both, and pass the sprite card's view_url into generation reference inputs (images prompt_attachments / videos input_references) for character consistency. Always list or search before you read so you use a real file_id; never invent file ids or view URLs.",
4139
+ description: "Browse AND write the user's file directorya navigable tree with THREE roots: /files (durable My Files: uploaded assets like mp4, mov, png, jpg, mp3, pdf, md, txt, csv, plus text context you save — vector-searchable via notes), /temp (scratch space auto-foldered by date YYYY-MM-DD, auto-deleted after 30 days), and /raws (the reusable clip/source-footage library, foldered by source). Every folder can have nested subfolders and every file has a canonical copyable path like /raws/product-demos/clip.mp4.\n\nNAVIGATE BY PATH: pass `path` to list or search any location — path='/' lists the three roots; path='/raws' or '/files/brand-assets' lists that folder's subfolders + files. Use action=list to enumerate a folder (folders[] + files[], each a canonical item with path, name, kind, view_url). (Legacy: action=list with folder_path and no path still lists /files only.)\n\nSEARCH: action=search with `query` (plain language) and optional `path` scope finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (control with `mode`: auto|semantic|substring|path). Prefer search when the user references an asset vaguely or you don't know which root/folder it's in; scope with path='/raws' to search only footage, path='/files' for durable assets, or path='/' for everything.\n\nMOVE: action=move with `file_id` (a raw's id) and `path` (target folder, e.g. /raws/demos) reorganizes a raw between /raws folders. Use action=search to find files by MEANING when you don't know where something lives: pass query (plain language, e.g. 'character reference sheet for the mascot' or 'logo on transparent background') and it matches keywords AND vector-embedded metadata notes across every file's name, folder, and notes — prefer search over paging through list when My Files is large or the user references an asset vaguely. Use action=read to fetch one file by file_id (preferred; copy it from a prior list/search result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text), optional folder_path to namescope it under a product/offer folder, and optional notes. This is how you persist Getting Started context the user can reuse later: a product About.md or Interview.md, and awareness-levels.md / persuasive-angles.md / ad-hooks.md distilled from brainstorm output. write saves text files (md, txt, csv, json, srt, vtt) via content, or IMPORTS any media asset into the library by passing its durable URL as source_url — e.g. save a finished image job's output as character_sprite_card.png so it persists in My Files instead of living only in a job result. write returns the new file_id and view_url. Use action=annotate to set metadata notes on ANY existing file (including binary assets): notes say what the file is, who/what it depicts, and when to use it, and they are vector-embedded so future sessions can search their way back to the asset. Annotate every asset worth finding again — especially character references: keep a character_sprite_card.png plus an about_character.md per recurring character in that character's folder, annotate both, and pass the sprite card's view_url into generation reference inputs (images prompt_attachments / videos input_references) for character consistency. Always list or search before you read so you use a real file_id; never invent file ids or view URLs.",
4014
4140
  inputSchema: z.object({
4015
- action: z.enum(["list", "read", "write", "annotate", "search"]).describe("list = enumerate files and folders; read = fetch one file's view_url and, for text files, its contents; write = save a text file into My Files; annotate = set the metadata notes on one existing file; search = find files by meaning across names, folders, and notes."),
4016
- folder_path: z.string().optional().describe("Folder to scope a list or search to, the folder of the file to read, or the folder to write into. Omit or leave empty for the root."),
4141
+ action: z.enum(["list", "read", "write", "annotate", "search", "move"]).describe("list = enumerate a folder's subfolders + files (pass path for any root); read = fetch one file's view_url and, for text files, its contents; write = save a text file into /files; annotate = set the metadata notes on one existing /files entry; search = find files by meaning/name/path across roots; move = move a raw between /raws folders."),
4142
+ path: z.string().optional().describe("Canonical directory path to list or search: '/' for the three roots, '/raws' or '/files/brand-assets' for a folder. For move, the target folder (e.g. /raws/demos). Preferred over folder_path for cross-root navigation."),
4143
+ mode: z.enum(["auto", "semantic", "substring", "path"]).optional().describe("For search: which signals to combine. auto (default) = semantic vector + substring + absolute-path; or force one."),
4144
+ folder_path: z.string().optional().describe("Legacy /files-only scope for a list or search, the folder of the file to read, or the folder to write into. Omit or leave empty for the root. Prefer `path` for anything outside /files."),
4017
4145
  file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
4018
4146
  file_name: z.string().optional().describe("File name to read/annotate when you do not have the id, or the name to save under for write (e.g. About.md). Matched within folder_path when one is provided."),
4019
4147
  content: z.string().optional().describe("For write: the full text content of the file to save (e.g. Markdown for About.md). Required for text writes; mutually exclusive with source_url."),
4020
4148
  source_url: z.string().optional().describe("For write: a durable media URL (from a finished primitive job result or an existing asset) to IMPORT into My Files as file_name — this is how you save a generated character_sprite_card.png or other binary asset into the library. Mutually exclusive with content."),
4021
4149
  notes: z.string().optional().describe("For annotate (required) or write (optional): metadata notes describing what the file is, who/what it depicts, and when to use it — e.g. 'Sprite card for Zara, our mascot: front/side/back views, teal jacket. Use as the reference image whenever generating Zara.' Notes are vector-embedded so search finds the file by meaning. For annotate, an empty string clears existing notes."),
4022
4150
  query: z.string().optional().describe("For search: what you're looking for, in plain language. Required for search; ignored otherwise."),
4151
+ content_type: z.string().optional().describe("For list/search of /raws: an EXACT shot-kind filter (not semantic) — one or a comma-separated OR set of talking_head, b_roll, product_shot, screen_recording, demo, reaction, interview, establishing, lifestyle, text_graphic. Narrows raw clips to those kinds; ignored outside /raws."),
4152
+ offset: z.number().int().optional().describe("For list: page offset into a large folder (default 0). The response's next_offset is the offset to pass for the next page; null means no more."),
4153
+ limit: z.number().int().optional().describe("For list/search: max items to return (list default 200, max 500; search default 30, max 50)."),
4023
4154
  explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
4024
4155
  }),
4025
- execute: async ({ action, folder_path, file_id, file_name, content, source_url, notes, query, explanation }) => {
4156
+ execute: async ({ action, path, mode, folder_path, file_id, file_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4026
4157
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
4027
4158
  const headers = new Headers();
4028
4159
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -4031,7 +4162,67 @@ function createBrowseFilesTool(input) {
4031
4162
  timeoutController.abort(new Error("Browse files request timed out after 30 seconds."));
4032
4163
  }, 30_000);
4033
4164
  const normalizedFolder = (folder_path ?? "").trim().replace(/^\/+|\/+$/g, "");
4165
+ // Default list/search to the WHOLE directory ('/') when neither a canonical
4166
+ // path nor a legacy folder_path is given — otherwise these silently fell
4167
+ // back to a /files-only search and the user's /raws + /temp were invisible.
4168
+ const canonicalPath = (path ?? "").trim()
4169
+ || ((action === "list" || action === "search") && !normalizedFolder ? "/" : "");
4170
+ const contentTypeParam = (content_type ?? "").trim();
4034
4171
  try {
4172
+ // ── unified directory navigation (any of /files, /temp, /raws) ──
4173
+ if (action === "list" && canonicalPath) {
4174
+ const url = new URL(`${USER_PREFIX}/me/directory`, config.PUBLIC_BASE_URL);
4175
+ url.searchParams.set("path", canonicalPath);
4176
+ if (contentTypeParam)
4177
+ url.searchParams.set("content_type", contentTypeParam);
4178
+ if (offset !== undefined && Number.isFinite(offset))
4179
+ url.searchParams.set("offset", String(Math.max(0, Math.trunc(offset))));
4180
+ if (limit !== undefined && Number.isFinite(limit))
4181
+ url.searchParams.set("limit", String(Math.max(1, Math.trunc(limit))));
4182
+ const r = await fetch(url, { method: "GET", headers, signal: mergeAbortSignals(input.abortSignal, timeoutController.signal) });
4183
+ const body = await r.json().catch(() => null);
4184
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4185
+ }
4186
+ if (action === "search" && canonicalPath) {
4187
+ const url = new URL(`${USER_PREFIX}/me/directory/search`, config.PUBLIC_BASE_URL);
4188
+ const searchHeaders = new Headers(headers);
4189
+ searchHeaders.set("content-type", "application/json");
4190
+ const searchLimit = limit !== undefined && Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 30;
4191
+ const r = await fetch(url, {
4192
+ method: "POST",
4193
+ headers: searchHeaders,
4194
+ body: JSON.stringify({
4195
+ query: (query ?? "").trim(),
4196
+ path: canonicalPath,
4197
+ mode: mode ?? "auto",
4198
+ limit: searchLimit,
4199
+ ...(contentTypeParam ? { content_type: contentTypeParam } : {})
4200
+ }),
4201
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4202
+ });
4203
+ const body = await r.json().catch(() => null);
4204
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4205
+ }
4206
+ if (action === "move") {
4207
+ const id = (file_id ?? "").trim();
4208
+ const seg = canonicalPath.split("/").map((s) => s.trim()).filter(Boolean);
4209
+ const root = seg[0];
4210
+ if (!id || !root)
4211
+ return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos." };
4212
+ if (root !== "raws")
4213
+ return { action, explanation: explanation ?? null, status: 400, error: "move is currently supported only within /raws." };
4214
+ const url = new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
4215
+ const moveHeaders = new Headers(headers);
4216
+ moveHeaders.set("content-type", "application/json");
4217
+ const r = await fetch(url, {
4218
+ method: "PATCH",
4219
+ headers: moveHeaders,
4220
+ body: JSON.stringify({ folder_path: seg.slice(1).join("/") }),
4221
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4222
+ });
4223
+ const body = await r.json().catch(() => null);
4224
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4225
+ }
4035
4226
  if (action === "write") {
4036
4227
  const name = (file_name ?? "").trim();
4037
4228
  if (!name) {
@@ -4412,8 +4603,13 @@ function createEditorActionTool() {
4412
4603
  "export_composition",
4413
4604
  "generate_layer",
4414
4605
  "set_captions",
4415
- "set_transitions"
4416
- ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit."),
4606
+ "set_transitions",
4607
+ "set_layer_keyframes",
4608
+ "nudge_layers",
4609
+ "ripple_edit",
4610
+ "set_layer_zindex",
4611
+ "trim_layer"
4612
+ ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit. FINE TIMELINE CONTROL: set_layer_keyframes authors a custom script-free CSS @keyframes animation on ONE layer (opacity/translate/scale/rotate over the clip) that previews AND renders; nudge_layers moves one or more layers by a relative delta_start/delta_track (group-aware); ripple_edit inserts or closes time at at_time and shifts all downstream clips; trim_layer moves one edge (start|end) of a clip to a time, coupling media in-point on a left trim; set_layer_zindex restacks a layer (front|back|forward|backward) — stacking is the track index."),
4417
4613
  layer_key: z.string().optional().describe("Existing layer key from editor_context (required for remove/set/duplicate/split). MAY be either the element_id (data-hf-id) or the slug (data-hf-slug). For add_layer, optionally provide a stable id (starts with a letter, [A-Za-z0-9_-], <=64 chars) to reuse in later tool calls this turn."),
4418
4614
  slug: z.string().optional().describe("snake_case slug for the layer. On add_layer, seeds data-hf-slug. On set_layer_identity, sets it. Slugs give viral DNA a stable handle for the element."),
4419
4615
  note: z.string().optional().describe("Human-facing note explaining the layer's role, referenced by viral DNA. On add_layer seeds data-hf-note. On set_layer_identity, sets it. Empty string removes."),
@@ -4483,6 +4679,22 @@ function createEditorActionTool() {
4483
4679
  resolution: z.string().optional().describe("generate_layer (video): optional resolution (480p|720p|1080p|1K|2K|4K)."),
4484
4680
  generate_audio: z.boolean().optional().describe("generate_layer (video): whether the model should generate an audio track."),
4485
4681
  replace_layer_key: z.string().optional().describe("generate_layer intent=replace_layer: the existing layer_key/slug whose scene slot (timing+geometry) the generated clip should take over."),
4682
+ keyframes: z.array(z.object({
4683
+ offset: z.number().describe("Position along the animation as a 0..1 fraction (0 = clip start, 1 = end)."),
4684
+ opacity: z.number().optional().describe("Opacity 0..1 at this stop."),
4685
+ translate_x: z.number().optional().describe("Horizontal move as a percent of the layer's own width (e.g. -100 = fully off to the left, 0 = rest)."),
4686
+ translate_y: z.number().optional().describe("Vertical move as a percent of the layer's own height (e.g. 100 = one height below, 0 = rest)."),
4687
+ scale: z.number().optional().describe("Uniform scale factor (1 = natural size, 1.2 = 20% larger)."),
4688
+ rotate: z.number().optional().describe("Rotation in degrees.")
4689
+ })).optional().describe("set_layer_keyframes: an ordered list of >=2 animation stops for ONE layer. The editor writes a script-free CSS @keyframes rule + inline animation that both previews and renders (the JS runtime adapters like anime.js/GSAP are NOT available in the web editor). Each stop sets any of opacity/translate_x/translate_y/scale/rotate at its offset. Example fly-in: [{offset:0,opacity:0,translate_y:40},{offset:1,opacity:1,translate_y:0}]."),
4690
+ keyframe_easing: z.string().optional().describe("set_layer_keyframes timing function: linear (default), ease, ease-in, ease-out, ease-in-out, or a cubic-bezier(...)/steps(...) value."),
4691
+ keyframe_duration: z.number().optional().describe("set_layer_keyframes: seconds the animation spans (default = the clip's full duration). The keyframe offsets map across this window."),
4692
+ delta_start: z.number().optional().describe("nudge_layers: seconds to shift the layer(s) along the timeline (negative = earlier). ripple_edit: seconds to insert (positive) or close (negative) at at_time."),
4693
+ delta_track: z.number().int().optional().describe("nudge_layers: lanes to shift the layer(s) by (negative = down/behind)."),
4694
+ at_time: z.number().optional().describe("ripple_edit: the composition-time boundary (seconds) — every clip that starts at or after it shifts by delta_start."),
4695
+ edge: z.enum(["start", "end"]).optional().describe("trim_layer: which edge to move. 'start' (left) advances the in-point and, for video/audio, pushes the media start so the visible frame stays put; 'end' (right) only changes duration."),
4696
+ to_time: z.number().optional().describe("trim_layer: the new composition-time position (seconds) for the chosen edge."),
4697
+ z_order: z.enum(["front", "back", "forward", "backward"]).optional().describe("set_layer_zindex: restack a layer. Stacking == track index (higher draws on top); 'front'/'forward' raise it, 'back'/'backward' lower it. Alternatively pass an explicit track."),
4486
4698
  explanation: z.string().describe("One-sentence reason shown in the UI.")
4487
4699
  }),
4488
4700
  execute: async (args) => {
@@ -4750,6 +4962,24 @@ function renderHyperframesTimelineRuler(durationSeconds) {
4750
4962
  }).join("");
4751
4963
  return `<div class="hf-timeline-ruler" aria-hidden="true">${marks}</div>`;
4752
4964
  }
4965
+ // A not-yet-decomposed project is the raw single-video base: one <video> that
4966
+ // is BOTH the playback source AND its own timeline layer (data-layer-kind).
4967
+ // In the EDITOR its own audio is the point, so strip the baked-in `muted`
4968
+ // attribute when serving the composition to the editor stage (the editor plays
4969
+ // on a user gesture, so muted-autoplay is not needed). This is scoped to the
4970
+ // editor's composition.html load ONLY — card/swipe previews load a different
4971
+ // path and keep `muted` so their silent autoplay-loop still works. Decomposed
4972
+ // compositions emit a data-vf-playback-source WITHOUT data-layer-kind, so they
4973
+ // are left untouched.
4974
+ function unmuteRawPlaybackSourceVideo(html) {
4975
+ return html.replace(/<video\b[^>]*>/gi, (tag) => {
4976
+ if (!/data-vf-playback-source\s*=\s*["']?true/i.test(tag))
4977
+ return tag;
4978
+ if (!/data-layer-kind\s*=\s*["']?video/i.test(tag))
4979
+ return tag;
4980
+ return tag.replace(/\smuted(?=(\s|\/?>))/i, "");
4981
+ });
4982
+ }
4753
4983
  function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4754
4984
  const baseHref = `/${normalizeDraftRelativePath(draftPath)}/`;
4755
4985
  const baseTag = `<base href="${escapeAttribute(baseHref)}">`;
@@ -4825,6 +5055,72 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4825
5055
  }
4826
5056
  return rewritten;
4827
5057
  }
5058
+ // Reskin overrides layered on top of the editor's own /assets/editor/app.css.
5059
+ // RESKIN_CSS scopes its element rules under .rk-root, so the only thing it changes
5060
+ // globally is the --rk-* token set + box-sizing. The editor chrome stays on its
5061
+ // original DARK theme; these rules only pin the reused reskin chat dock as a
5062
+ // permanent left column (the one farmville element we keep here).
5063
+ const EDITOR_FARMVILLE_CSS = `
5064
+ html,body{margin:0;background:#050604;color:#fffbe6}
5065
+ #root{min-height:100vh}
5066
+
5067
+ /* The reused farmville chat dock, pinned flush as a permanent LEFT column
5068
+ (desktop). Width stays natural: 440px for the clean chat column, widening to
5069
+ overlay the editor's left edge only while the Files/History drawer is open. */
5070
+ @media (min-width:1025px){
5071
+ .rk-aichat.rk-aichat--editor{
5072
+ left:0 !important;top:0 !important;bottom:0 !important;right:auto !important;
5073
+ height:100vh !important;max-height:100vh !important;
5074
+ margin:0 !important;border-radius:0 !important;border-right:1px solid var(--rk-border) !important;
5075
+ box-shadow:var(--rk-shadow-lg) !important;transform:none !important;z-index:40
5076
+ }
5077
+ /* Make room for the docked chat: shift the SPA workbench right + drop its
5078
+ now-empty first grid column (the old embedded chat lived there). */
5079
+ .vidfarm-workbench{margin-left:440px !important;grid-template-columns:minmax(0,1fr) !important}
5080
+ }
5081
+ .rk-aichat,.rk-fab{font-family:var(--rk-font-body)}
5082
+
5083
+ /* ── Dark-mode the reused farmville chat dock so it matches the editor's dark
5084
+ chrome. The reskin /reskin pages keep the light warm theme; this remap is
5085
+ scoped to the editor dock only. Almost every dock surface reads a --rk-*
5086
+ token, so remapping the token set here re-themes the panel wholesale; the
5087
+ few hardcoded #fff backgrounds from the shared sheet are overridden below. */
5088
+ .rk-aichat.rk-aichat--editor{
5089
+ --rk-white:#12160d;
5090
+ --rk-surface:#0c0f08;--rk-bg:#0c0f08;--rk-bg-section:#0a0d07;
5091
+ --rk-n-50:#0a0d07;--rk-n-100:#191d12;--rk-n-200:#262b1a;--rk-n-300:#333a24;
5092
+ --rk-n-400:#6b7057;--rk-n-500:#8f9472;--rk-n-600:#b7bd97;--rk-n-700:#dcddc4;--rk-n-800:#eceada;
5093
+ --rk-ink:#fffbe6;--rk-text:#e7e6cd;--rk-text-muted:#9a9d80;--rk-text-faint:#71745c;
5094
+ --rk-border:rgba(255,251,230,.10);--rk-border-strong:rgba(255,251,230,.20);
5095
+ --rk-gold-tint:rgba(252,185,0,.14);--rk-gold-tint2:rgba(252,185,0,.22);
5096
+ --rk-shadow-xs:0 1px 2px 0 rgba(0,0,0,.4);
5097
+ background:#0c0f08;
5098
+ }
5099
+ /* surfaces hardcoded #fff in the shared sheet → dark cards on the editor dock */
5100
+ .rk-aichat--editor .rk-aichat-msg.is-ai,
5101
+ .rk-aichat--editor .rk-aichat-composer,
5102
+ .rk-aichat--editor .rk-aichat-tool,
5103
+ .rk-aichat--editor .rk-aichat-back,
5104
+ .rk-aichat--editor .rk-aichat-fstate button,
5105
+ .rk-aichat--editor .rk-aichat-drop,
5106
+ .rk-aichat--editor .rk-aichat-toolnote,
5107
+ .rk-aichat--editor .rk-aichat-http,
5108
+ .rk-aichat--editor .rk-input,
5109
+ .rk-aichat--editor .rk-aichat-loadmore{background:#141810}
5110
+ .rk-aichat--editor .rk-aichat-back:hover{background:#1b2013}
5111
+ .rk-aichat--editor .rk-aichat-loadmore:hover{background:#1b2013}
5112
+ .rk-aichat--editor .rk-input{color:var(--rk-ink)}
5113
+ .rk-aichat--editor .rk-aichat-frow:hover,
5114
+ .rk-aichat--editor .rk-aichat-hist-body .rk-aichat-frow.is-active{background:#1b2013}
5115
+ .rk-aichat--editor .rk-aichat-err{background:#141810;color:#ff9b8f}
5116
+ .rk-aichat--editor .rk-aichat-toolnote.is-ok{color:#7ee2a8}
5117
+ .rk-aichat--editor .rk-aichat-toolnote.is-ok::before{background:#33d17a}
5118
+ .rk-aichat--editor .rk-aichat-toolnote.is-err{color:#ff9b8f}
5119
+ .rk-aichat--editor .rk-aichat-toolnote.is-err::before{background:#ff6a5a}
5120
+
5121
+ /* Hide the SPA's own embedded chat (replaced by the farmville dock). */
5122
+ .chat-dock,.chat-dock-fab{display:none !important}
5123
+ `;
4828
5124
  async function renderHyperframesStudioEditorPage(input) {
4829
5125
  const accountId = input.customer?.id ?? null;
4830
5126
  const compositionId = input.composition.templateId ?? input.composition.slugId;
@@ -4833,6 +5129,8 @@ async function renderHyperframesStudioEditorPage(input) {
4833
5129
  slugId: input.composition.slugId,
4834
5130
  templateId: input.composition.templateId,
4835
5131
  title: input.composition.title,
5132
+ // "raw" = the user's own new/blank project — never auto-prompt Decompose.
5133
+ origin: input.composition.origin ?? null,
4836
5134
  originalUrl: input.composition.originalUrl ?? null,
4837
5135
  songName: input.composition.songName ?? null,
4838
5136
  songUrl: input.composition.songUrl ?? null,
@@ -4854,17 +5152,19 @@ async function renderHyperframesStudioEditorPage(input) {
4854
5152
  <head>
4855
5153
  <meta charset="UTF-8" />
4856
5154
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4857
- <title>${escapeHtml(input.composition.title)} | Studio</title>
4858
- <link rel="preconnect" href="https://fonts.googleapis.com" />
4859
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
5155
+ <title>${escapeHtml(input.composition.title)} | VidFarm Studio</title>
4860
5156
  <link rel="icon" href="/assets/favicon.ico" />
4861
5157
  <link rel="stylesheet" href="/assets/editor/app.css" />
4862
- <style>html,body{margin:0;background:#050604;color:#fffbe6;}#root{min-height:100vh;}</style>
5158
+ ${RESKIN_FONT_LINKS}
5159
+ <style>${RESKIN_CSS}</style>
5160
+ <style>${EDITOR_FARMVILLE_CSS}</style>
4863
5161
  <script src="/assets/sentry-config.js"></script>
4864
5162
  </head>
4865
- <body>
5163
+ <body data-rk-editor-dock>
4866
5164
  <div id="root"></div>
5165
+ ${renderChatDock("editor")}
4867
5166
  <script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
5167
+ <script>${RESKIN_CHROME_SCRIPT}</script>
4868
5168
  <script type="module" src="/assets/editor/app.js"></script>
4869
5169
  </body>
4870
5170
  </html>`;
@@ -4911,30 +5211,444 @@ app.get("/", async (c) => {
4911
5211
  // Each page is a standalone document with its own CSS; renders sample data so
4912
5212
  // it's viewable without auth. Ported pages are wired here one at a time.
4913
5213
  app.get("/reskin", (c) => c.html(renderReskinIndex()));
4914
- app.get("/reskin/settings", (c) => c.html(renderReskinSettings()));
4915
- // Library hosts two tabs at distinct URLs (approved projects + mined clips).
4916
- app.get("/reskin/library", (c) => redirect(c, "/reskin/library/approved"));
4917
- app.get("/reskin/library/approved", (c) => c.html(renderReskinLibrary("approved")));
4918
- app.get("/reskin/library/clips", (c) => c.html(renderReskinLibrary("clips")));
4919
- app.get("/reskin/clips", (c) => redirect(c, "/reskin/library/clips")); // merged into Library
4920
- // Discover has three modes at distinct URLs (feed / swipe / categories).
4921
- app.get("/reskin/discover", (c) => redirect(c, "/reskin/discover/feed"));
5214
+ // Settings lives at /settings/<tab> (profile · wallet · channels · developer).
5215
+ // Tabs still switch client-side without a reload (the page renders every panel
5216
+ // and toggles visibility) but each has its own canonical URL so links,
5217
+ // bookmarks and back/forward land on the right tab.
5218
+ async function renderSettingsTabPage(c, tabOverride) {
5219
+ const customer = await requireBrowserCustomer(c);
5220
+ if (!customer) {
5221
+ setLoginReturnPath(c, currentPathWithSearch(c));
5222
+ return redirect(c, "/login");
5223
+ }
5224
+ const flash = consumeSettingsFlash(c);
5225
+ const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
5226
+ const activeTab = tabOverride
5227
+ ?? (isSettingsTab(flash?.tab)
5228
+ ? flash.tab
5229
+ : isSettingsTab(c.req.query("tab"))
5230
+ ? c.req.query("tab")
5231
+ : "profile");
5232
+ const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
5233
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
5234
+ const [wallet, walletEvents] = await Promise.all([
5235
+ fetchBillingWalletSummary(customer.id).catch((error) => ({
5236
+ balanceUsd: 0, totalFundsAddedUsd: 0, totalChargeUsd: 0, totalBaseCostUsd: 0,
5237
+ totalEventCount: 0, updatedAtMs: 0,
5238
+ unavailableReason: error instanceof Error ? error.message : "Unable to load wallet."
5239
+ })),
5240
+ fetchBillingWalletEvents({ customerId: customer.id, limit: 50 }).catch((error) => ({
5241
+ events: [],
5242
+ nextCursor: null,
5243
+ unavailableReason: error instanceof Error ? error.message : "Unable to load billing history."
5244
+ }))
5245
+ ]);
5246
+ return c.html(renderReskinSettings({
5247
+ userId: customer.id,
5248
+ currentAccountId: customer.id,
5249
+ name: customer.name,
5250
+ notice: flash?.notice ?? null,
5251
+ error: flash?.error ?? null,
5252
+ activeTab,
5253
+ openFlockPosterDialog: c.req.query("connect") === "flockposter" || (activeTab === "channels" && !flockPosterApiKey),
5254
+ email: customer.email,
5255
+ isPaidPlan: customer.isPaidPlan,
5256
+ isDeveloper: customer.isDeveloper,
5257
+ about: customer.about,
5258
+ groupchatUrl: customer.groupchatUrl,
5259
+ flockposterApiKey: flockPosterApiKey,
5260
+ emailChannelIconUrl: await getEmailChannelIconUrl(c),
5261
+ emailChannels,
5262
+ vidfarmApiKey: await getVisibleApiKey(customer.id),
5263
+ billingUrl: config.VIDFARM_BILLING_URL?.trim() || null,
5264
+ directorSkill,
5265
+ wallet,
5266
+ walletEvents,
5267
+ flockposterChannels: [],
5268
+ flockposterChannelsError: null,
5269
+ providerKeys: (await listProviderKeysWithSecretsForCustomer(customer.id)).map((entry) => ({
5270
+ id: String(entry.id),
5271
+ provider: String(entry.provider),
5272
+ label: entry.label ? String(entry.label) : null,
5273
+ secret: readStoredSecret(String(entry.secret)),
5274
+ status: String(entry.status),
5275
+ created_at: String(entry.created_at),
5276
+ last_used_at: entry.last_used_at ? String(entry.last_used_at) : null
5277
+ })),
5278
+ attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
5279
+ }));
5280
+ }
5281
+ // Bare /settings redirects to its canonical first tab; legacy ?tab= / ?connect=
5282
+ // links are preserved by folding them into the path/query of the target.
5283
+ app.get("/settings", (c) => {
5284
+ const q = c.req.query("tab");
5285
+ const tab = isSettingsTab(q) ? q : "profile";
5286
+ const connect = c.req.query("connect");
5287
+ const path = connect ? `/settings/${tab}?connect=${encodeURIComponent(connect)}` : `/settings/${tab}`;
5288
+ return redirect(c, withAccountQuery(path, getRequestedAccountId(c)));
5289
+ });
5290
+ app.get("/settings/profile", (c) => renderSettingsTabPage(c, "profile"));
5291
+ app.get("/settings/wallet", (c) => renderSettingsTabPage(c, "wallet"));
5292
+ app.get("/settings/channels", (c) => renderSettingsTabPage(c, "channels"));
5293
+ app.get("/settings/developer", (c) => renderSettingsTabPage(c, "developer"));
5294
+ // Library hosts tabs at distinct URLs: Approved projects, mined Raws, and the
5295
+ // full-page Files directory explorer (plus a right-floated Logs button → /library/logs).
5296
+ app.get("/library", (c) => redirect(c, "/library/approved"));
5297
+ const reskinLibraryHandler = async (c, tab) => {
5298
+ const customer = await getPreferredBrowserCustomer(c);
5299
+ if (!customer) {
5300
+ setLoginReturnPath(c, currentPathWithSearch(c));
5301
+ return redirect(c, "/login");
5302
+ }
5303
+ // Files is the self-contained directory explorer (client-fetches /me/directory),
5304
+ // so it needs none of the posts/channels payload — skip that comparatively
5305
+ // expensive work for it.
5306
+ if (tab === "files") {
5307
+ return c.html(renderReskinLibrary(tab, {
5308
+ userId: customer.id,
5309
+ currentAccountId: customer.id,
5310
+ name: customer.name,
5311
+ email: customer.email,
5312
+ posts: [],
5313
+ schedule: { channels: [], connectHref: "/settings/channels?connect=flockposter" },
5314
+ editorChat: null
5315
+ }));
5316
+ }
5317
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
5318
+ const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
5319
+ const scheduleChannels = [
5320
+ ...emailChannels.map((channel) => ({ id: channel.id, name: channel.name, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl })),
5321
+ ...flockPoster.channels.map((channel) => ({ id: channel.id, name: channel.title, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl }))
5322
+ ];
5323
+ const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
5324
+ .filter((post) => (post.source ?? "legacy") === "hyperframes")
5325
+ .filter((post) => post.status === "ready" || post.status === "scheduled")
5326
+ .map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
5327
+ const posts = await mergeUpstreamReadyPosts(localPosts);
5328
+ return c.html(renderReskinLibrary(tab, {
5329
+ userId: customer.id,
5330
+ currentAccountId: customer.id,
5331
+ name: customer.name,
5332
+ email: customer.email,
5333
+ posts,
5334
+ schedule: { channels: scheduleChannels, connectHref: "/settings/channels?connect=flockposter" },
5335
+ editorChat: null
5336
+ }));
5337
+ };
5338
+ app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
5339
+ app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
5340
+ app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, floated-right button (not a tab)
5341
+ app.get("/library/files", (c) => reskinLibraryHandler(c, "files")); // full-page file-directory explorer
5342
+ app.get("/library/clips", (c) => redirect(c, "/library/raws")); // renamed Clips → Raws
5343
+ // Discover has three modes, each on its own path: /discover (feed default),
5344
+ // /discover/swipe, /discover/categories. These exact page paths sit alongside
5345
+ // the JSON APIs at /discover/feed, /discover/popular and /discover/swipe/*
5346
+ // (which are all deeper/other paths, so there's no collision).
5347
+ // Shared feed builder — the single source of both the JSON /discover/feed
5348
+ // response and the SSR seed inlined into the reskin Feed page's boot. Feed
5349
+ // browses INSPIRATIONS (the caller's OWN inspirations plus the curated
5350
+ // popularGroup catalog), never the global public pool; raw own-projects are
5351
+ // excluded (they live in the Library). NOTE: for a logged-in caller this
5352
+ // finalizes still-downloading inspirations as a side effect — the feed poll
5353
+ // doubles as the completion check.
5354
+ async function collectDiscoverFeedEntries(customer, opts) {
5355
+ const { query, limit } = opts;
5356
+ const entries = [];
5357
+ const seenInspirations = new Set();
5358
+ const pushInspiration = (inspiration) => {
5359
+ if (seenInspirations.has(inspiration.id))
5360
+ return;
5361
+ if ((inspiration.origin ?? "inspiration") === "raw")
5362
+ return;
5363
+ seenInspirations.add(inspiration.id);
5364
+ entries.push(buildInspirationFeedEntry(inspiration));
5365
+ };
5366
+ if (customer) {
5367
+ // The caller's own inspirations rank first (finalized → "ready" here).
5368
+ const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id));
5369
+ const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
5370
+ finalized
5371
+ .filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
5372
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
5373
+ .forEach(pushInspiration);
5374
+ }
5375
+ // Curated/official catalog: public, ready inspirations flagged popularGroup.
5376
+ const curated = (await serverlessRecords.listInspirationsFeed({ limit, query }))
5377
+ .filter((inspiration) => typeof inspiration.popularGroup === "string" && inspiration.popularGroup.trim().length > 0);
5378
+ curated.forEach(pushInspiration);
5379
+ // Cloud passthrough (vidfarm serve): surface the upstream Feed behind the
5380
+ // local one, deduped on inspirationId (→ templateId). Fail-soft.
5381
+ if (isUpstreamEnabled()) {
5382
+ const search = new URLSearchParams({ limit: String(limit) });
5383
+ if (query)
5384
+ search.set("q", query);
5385
+ const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
5386
+ const seen = new Set(entries.map((entry) => entry.inspirationId || entry.templateId));
5387
+ for (const entry of upstreamFeed) {
5388
+ const key = entry && (entry.inspirationId || entry.templateId);
5389
+ if (entry && typeof key === "string" && key && !seen.has(key) && entry.origin !== "raw") {
5390
+ entries.push(entry);
5391
+ seen.add(key);
5392
+ }
5393
+ }
5394
+ }
5395
+ return entries.slice(0, limit);
5396
+ }
5397
+ const renderDiscoverMode = async (c, mode) => {
5398
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5399
+ let feedInitial;
5400
+ let feedFetchedAt;
5401
+ if (mode === "feed") {
5402
+ // SSR-seed the first page of the default feed into the boot so the client
5403
+ // paints instantly, skipping the redundant follow-up fetch. Fail-soft: on
5404
+ // any error we omit the seed and the client cold-fetches as before. The
5405
+ // limit mirrors the client's un-parameterized fetch (clampPageLimit → 100).
5406
+ try {
5407
+ feedInitial = await collectDiscoverFeedEntries(customer, { query: null, limit: clampPageLimit(Number.NaN, 100, 200) });
5408
+ feedFetchedAt = new Date().toISOString();
5409
+ }
5410
+ catch {
5411
+ feedInitial = undefined;
5412
+ }
5413
+ }
5414
+ return c.html(renderReskinDiscover(mode, {
5415
+ accountId: customer?.id,
5416
+ isLoggedIn: Boolean(customer),
5417
+ name: customer?.name ?? null,
5418
+ email: customer?.email ?? null,
5419
+ feedInitial,
5420
+ feedFetchedAt
5421
+ }));
5422
+ };
5423
+ app.get("/discover", async (c) => {
5424
+ // Every mode has its own canonical path — Feed included — so redirect the bare
5425
+ // /discover (and old ?view= links) onto it. Feed then lives at /discover/feed,
5426
+ // which content-negotiates: browser navigations get the page, fetch/API get
5427
+ // the JSON feed (see the /discover/feed handler below).
5428
+ const view = c.req.query("view");
5429
+ const account = c.req.query(ACCOUNT_QUERY_PARAM);
5430
+ const suffix = account ? `?${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(account)}` : "";
5431
+ if (view === "swipe" || view === "categories") {
5432
+ return redirect(c, `/discover/${view}${suffix}`);
5433
+ }
5434
+ return redirect(c, `/discover/feed${suffix}`);
5435
+ });
5436
+ app.get("/discover/swipe", (c) => renderDiscoverMode(c, "swipe"));
5437
+ app.get("/discover/categories", (c) => renderDiscoverMode(c, "categories"));
5438
+ // Brainstorm chat. /chat is a fresh conversation; /chat/:id deep-links to a
5439
+ // saved thread (the client auto-opens it and keeps the URL in sync as the user
5440
+ // switches / starts conversations).
5441
+ const renderChatBrainstorm = async (c, initialThreadId) => {
5442
+ const customer = await getPreferredBrowserCustomer(c);
5443
+ if (!customer) {
5444
+ setLoginReturnPath(c, currentPathWithSearch(c));
5445
+ return redirect(c, "/login");
5446
+ }
5447
+ return c.html(renderReskinChat({
5448
+ userId: customer.id,
5449
+ currentAccountId: customer.id,
5450
+ name: customer.name,
5451
+ email: customer.email,
5452
+ editorChat: await buildChatBrainstormEditorChatBoot({ customer })
5453
+ }, { initialThreadId: initialThreadId ?? null }));
5454
+ };
5455
+ app.get("/chat", (c) => renderChatBrainstorm(c));
5456
+ app.get("/chat/:id", (c) => renderChatBrainstorm(c, c.req.param("id")));
5457
+ // Boot for the global pop-panel AI chat (renderChatDock, on every reskin page).
5458
+ // The dock is shared chrome so we can't thread a boot through every route —
5459
+ // instead it fetches this lazily on first open (cookie-authed, same-origin) to
5460
+ // get the real editor-chat endpoint + api key + brainstorm template context.
5461
+ // Returns { editorChat: null } for signed-out visitors so the dock degrades to
5462
+ // a friendly "sign in to chat" state instead of erroring.
5463
+ app.get("/chat-dock/boot", async (c) => {
5464
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5465
+ if (!customer)
5466
+ return c.json({ editorChat: null });
5467
+ return c.json({ editorChat: await buildChatBrainstormEditorChatBoot({ customer }) });
5468
+ });
5469
+ // /reskin/inpaint — the "Create Image" masked-image editor (reached from the
5470
+ // chat page's studio-mode select). Auth-gated like the other tools.
5471
+ app.get("/inpaint", async (c) => {
5472
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5473
+ if (!customer) {
5474
+ setLoginReturnPath(c, currentPathWithSearch(c));
5475
+ return redirect(c, "/login");
5476
+ }
5477
+ return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
5478
+ });
5479
+ app.get("/calendar", async (c) => {
5480
+ const customer = await requireBrowserCustomer(c);
5481
+ if (!customer) {
5482
+ setLoginReturnPath(c, currentPathWithSearch(c));
5483
+ return redirect(c, "/login");
5484
+ }
5485
+ const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
5486
+ const emailChannels = await getEmailPseudoChannels(customer.id, c);
5487
+ return c.html(renderReskinCalendar({
5488
+ userId: customer.id,
5489
+ currentAccountId: customer.id,
5490
+ name: customer.name,
5491
+ email: customer.email,
5492
+ channels: emailChannels.map((channel) => ({
5493
+ id: channel.id,
5494
+ name: channel.title,
5495
+ handle: channel.handle,
5496
+ platform: channel.platform,
5497
+ avatarUrl: channel.avatarUrl,
5498
+ platformIconUrl: channel.platformIconUrl
5499
+ })),
5500
+ posts: [],
5501
+ loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
5502
+ hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
5503
+ connectChannelsHref: "/settings/channels?connect=flockposter"
5504
+ }));
5505
+ });
5506
+ // Job runs were renamed and folded into the Library "Logs" tab.
5507
+ app.get("/job-runs", (c) => redirect(c, "/library/logs"));
5508
+ app.get("/agency", async (c) => {
5509
+ const customer = await requireBrowserCustomer(c);
5510
+ return c.html(renderReskinAgency({
5511
+ userId: customer?.id ?? null,
5512
+ currentAccountId: customer?.id ?? null,
5513
+ name: customer?.name ?? null,
5514
+ email: customer?.email ?? null,
5515
+ loggedIn: Boolean(customer)
5516
+ }));
5517
+ });
5518
+ app.get("/pricing", async (c) => {
5519
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5520
+ return c.html(renderReskinPricing({ email: customer?.email ?? "" }));
5521
+ });
5522
+ app.get("/login", (c) => c.html(renderReskinLogin({ mode: getLoginMode(c.req.query("mode")), routePrefix: "/login" })));
5523
+ app.post("/login/password", async (c) => {
5524
+ const mode = getLoginMode(c.req.query("mode"));
5525
+ const routePrefix = "/login";
5526
+ const parsed = passwordLoginSchema.safeParse(await parseFormBody(c));
5527
+ if (!parsed.success)
5528
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email and password." }), 400);
5529
+ try {
5530
+ const result = await auth.authenticateWithPassword(parsed.data.email, parsed.data.password);
5531
+ if (result.status === "pricing")
5532
+ return redirect(c, "/pricing");
5533
+ setBrowserSession(c, result.customer);
5534
+ return redirect(c, resolvePostLoginRedirect(c, result.customer));
5535
+ }
5536
+ catch (error) {
5537
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to login." }), 401);
5538
+ }
5539
+ });
5540
+ app.post("/login/otp/request", async (c) => {
5541
+ const mode = getLoginMode(c.req.query("mode"));
5542
+ const routePrefix = "/login";
5543
+ const parsed = otpRequestSchema.safeParse(await parseFormBody(c));
5544
+ if (!parsed.success)
5545
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email address." }), 400);
5546
+ try {
5547
+ const result = await auth.requestOtp(parsed.data.email);
5548
+ const message = result.delivery === "console" && result.code
5549
+ ? `Local dev mode: OTP is ${result.code}.`
5550
+ : "OTP sent. Enter the code to continue.";
5551
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, message }));
5552
+ }
5553
+ catch (error) {
5554
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to send OTP." }), 502);
5555
+ }
5556
+ });
5557
+ app.post("/login/otp/verify", async (c) => {
5558
+ const mode = getLoginMode(c.req.query("mode"));
5559
+ const routePrefix = "/login";
5560
+ const parsed = otpVerifySchema.safeParse(await parseFormBody(c));
5561
+ if (!parsed.success)
5562
+ return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter the email and 6-digit OTP." }), 400);
5563
+ try {
5564
+ const result = await auth.verifyOtpForBrowserLogin(parsed.data.email, parsed.data.code, parsed.data.name);
5565
+ if (result.status === "pricing") {
5566
+ clearBrowserSession(c, result.customer.id);
5567
+ return redirect(c, "/pricing");
5568
+ }
5569
+ setBrowserSession(c, result.customer);
5570
+ return redirect(c, resolvePostLoginRedirect(c, result.customer));
5571
+ }
5572
+ catch (error) {
5573
+ return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, error: error instanceof Error ? error.message : "Unable to verify OTP." }), 401);
5574
+ }
5575
+ });
5576
+ app.post("/logout", async (c) => {
5577
+ const formBody = await parseFormBody(c);
5578
+ const customer = await requireBrowserCustomer(c, formBody);
5579
+ if (customer) {
5580
+ clearBrowserSession(c, customer.id);
5581
+ }
5582
+ return redirect(c, "/login");
5583
+ });
5584
+ app.get("/help", async (c) => {
5585
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5586
+ return c.html(renderReskinHelp({
5587
+ account: { isLoggedIn: Boolean(customer), userId: customer?.id ?? null, displayName: customer?.name ?? null, email: customer?.email ?? null },
5588
+ expand: c.req.query("expand") ?? null
5589
+ }));
5590
+ });
5591
+ // The reskin IS the app now — every old /reskin/* URL 301/302s to its promoted
5592
+ // canonical route so existing bookmarks/links keep working. The /reskin gallery
5593
+ // (above) is kept as a dev reference; its cards link to the canonical routes.
5594
+ app.get("/reskin/settings", (c) => redirect(c, "/settings"));
5595
+ app.get("/reskin/library", (c) => redirect(c, "/library/approved"));
5596
+ app.get("/reskin/library/approved", (c) => redirect(c, "/library/approved"));
5597
+ app.get("/reskin/library/raws", (c) => redirect(c, "/library/raws"));
5598
+ app.get("/reskin/library/logs", (c) => redirect(c, "/library/logs"));
5599
+ app.get("/reskin/library/clips", (c) => redirect(c, "/library/raws"));
5600
+ app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
5601
+ app.get("/reskin/discover", (c) => redirect(c, "/discover"));
4922
5602
  app.get("/reskin/discover/:mode", (c) => {
4923
- const mode = c.req.param("mode");
4924
- if (mode !== "feed" && mode !== "swipe" && mode !== "categories")
4925
- return c.notFound();
4926
- return c.html(renderReskinDiscover(mode));
4927
- });
4928
- app.get("/reskin/chat", (c) => c.html(renderReskinChat()));
4929
- app.get("/reskin/calendar", (c) => c.html(renderReskinCalendar()));
4930
- app.get("/reskin/job-runs", (c) => c.html(renderReskinJobRuns()));
4931
- app.get("/reskin/agency", (c) => c.html(renderReskinAgency()));
4932
- app.get("/reskin/pricing", (c) => c.html(renderReskinPricing()));
4933
- app.get("/reskin/login", (c) => c.html(renderReskinLogin()));
4934
- app.get("/reskin/help", (c) => c.html(renderReskinHelp()));
5603
+ const m = c.req.param("mode");
5604
+ return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
5605
+ });
5606
+ app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5607
+ app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
5608
+ app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
5609
+ app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
5610
+ app.get("/reskin/agency", (c) => redirect(c, "/agency"));
5611
+ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5612
+ app.get("/reskin/help", (c) => redirect(c, "/help"));
5613
+ app.get("/reskin/login", (c) => redirect(c, "/login"));
4935
5614
  app.get("/discover", async (c) => renderApprovedHomepage(c));
4936
- app.get("/editor/:templateId", async (c) => {
4937
- const templateId = c.req.param("templateId");
5615
+ // Blank / new-project editor. The "New project" button and the empty-library
5616
+ // CTAs point here. There is no template or fork yet: we boot the SPA into an
5617
+ // empty composition, and the client mints a real template_* + fork_* on the
5618
+ // first save/generate/decompose, then history.replaceState's the URL to the
5619
+ // canonical /editor/:templateId/fork/:forkId form.
5620
+ async function renderBlankEditorPage(c) {
5621
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5622
+ if (!customer) {
5623
+ setLoginReturnPath(c, currentPathWithSearch(c));
5624
+ return redirect(c, "/login");
5625
+ }
5626
+ const threadParam = c.req.query("thread");
5627
+ const blankComposition = {
5628
+ title: "Untitled project",
5629
+ templateId: "original",
5630
+ slugId: "original",
5631
+ difficulty: "easy",
5632
+ viralDna: "",
5633
+ previewUrl: null,
5634
+ approvedAt: null,
5635
+ sourceType: "tiktok",
5636
+ durationSeconds: null,
5637
+ originalUrl: null,
5638
+ songName: null,
5639
+ songUrl: null,
5640
+ // Raw origin: a brand-new project, so the SPA never auto-opens Decompose.
5641
+ origin: "raw",
5642
+ initialThreadId: threadParam ?? null
5643
+ };
5644
+ return c.html(await renderHyperframesStudioEditorPage({ composition: blankComposition, customer }));
5645
+ }
5646
+ // Shared handler for both the bare /editor/:templateId (resolver) route and the
5647
+ // canonical /editor/:templateId/fork/:forkId (path-form) route. `forkParam` is
5648
+ // the fork id from the URL path (path route) or null (bare route). We validate
5649
+ // it, and when it's missing/stale/uneditable we resolve a per-user fork and
5650
+ // redirect to the canonical path form.
5651
+ async function renderEditorPage(c, templateId, forkParam) {
4938
5652
  const customer = await getOptionalPreferredBrowserCustomer(c);
4939
5653
  if (!templateId.startsWith("template_"))
4940
5654
  return c.notFound();
@@ -4947,7 +5661,7 @@ app.get("/editor/:templateId", async (c) => {
4947
5661
  await seedFromUpstream({
4948
5662
  customerId: customer.id,
4949
5663
  templateId,
4950
- forkId: c.req.query("fork") ?? undefined,
5664
+ forkId: forkParam ?? undefined,
4951
5665
  shareToken: readShareToken(c) ?? undefined
4952
5666
  });
4953
5667
  template = await serverlessRecords.getTemplate(templateId);
@@ -4958,7 +5672,6 @@ app.get("/editor/:templateId", async (c) => {
4958
5672
  // template id doesn't leak to other accounts.
4959
5673
  if (template.visibility === "private" && template.customerId !== customer?.id)
4960
5674
  return c.notFound();
4961
- const forkParam = c.req.query("fork");
4962
5675
  const threadParam = c.req.query("thread");
4963
5676
  // Validate the caller-provided fork. Stale bookmarks / share links keep
4964
5677
  // pointing at forks we've since hard-deleted, and if we blindly pass the
@@ -5035,11 +5748,11 @@ app.get("/editor/:templateId", async (c) => {
5035
5748
  // source of truth for "which chat is active" via localStorage. Injecting
5036
5749
  // the server's most-recent thread into the URL blows away a fresh
5037
5750
  // client-side "New chat" the user just started but hasn't persisted.
5038
- const path = `/editor/${encodeURIComponent(template.id)}?fork=${encodeURIComponent(targetForkId)}`;
5751
+ const path = `/editor/${encodeURIComponent(template.id)}/fork/${encodeURIComponent(targetForkId)}`;
5039
5752
  return redirect(c, path);
5040
5753
  }
5041
5754
  // No fork exists yet for this template + user — redirect to the bare
5042
- // /editor/{templateId} URL so any stale `?fork=` gets stripped and the
5755
+ // /editor/{templateId} URL so any stale fork segment gets stripped and the
5043
5756
  // Decompose modal opens fresh (client-side flow mints a new fork on
5044
5757
  // first save/decompose).
5045
5758
  if (forkParam) {
@@ -5066,6 +5779,21 @@ app.get("/editor/:templateId", async (c) => {
5066
5779
  initialThreadId: threadParam ?? null
5067
5780
  };
5068
5781
  return c.html(await renderHyperframesStudioEditorPage({ composition: syntheticComposition, customer }));
5782
+ }
5783
+ // Canonical path-form editor route. Sentinel /editor/original/fork/new opens a
5784
+ // blank new project; everything else validates/resolves the fork in the path.
5785
+ app.get("/editor/:templateId/fork/:forkId", async (c) => {
5786
+ const templateId = c.req.param("templateId");
5787
+ const forkId = c.req.param("forkId");
5788
+ if (templateId === "original" && forkId === "new") {
5789
+ return renderBlankEditorPage(c);
5790
+ }
5791
+ return renderEditorPage(c, templateId, forkId);
5792
+ });
5793
+ // Bare editor route: resolves a per-user fork and redirects to the path form.
5794
+ app.get("/editor/:templateId", async (c) => {
5795
+ const templateId = c.req.param("templateId");
5796
+ return renderEditorPage(c, templateId, null);
5069
5797
  });
5070
5798
  app.get("/editor/:templateId/composition", async (c) => {
5071
5799
  const templateId = c.req.param("templateId");
@@ -5092,6 +5820,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}`, (c) => redirect(c, withAccountQuery("/libr
5092
5820
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/discover"));
5093
5821
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
5094
5822
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute(c, "/chat"));
5823
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat/:id`, (c) => dispatchScopedGetToLegacyRoute(c, `/chat/${c.req.param("id")}`));
5095
5824
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
5096
5825
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
5097
5826
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
@@ -5101,8 +5830,24 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyR
5101
5830
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs/history`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs/history"));
5102
5831
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar/feed`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar/feed"));
5103
5832
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/flockposter`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings/flockposter"));
5833
+ // Per-tab settings pages under an account scope (registered AFTER the static
5834
+ // /settings/flockposter route so that stays reachable).
5835
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/:tab`, (c) => {
5836
+ const tab = c.req.param("tab");
5837
+ return dispatchScopedGetToLegacyRoute(c, `/settings/${isSettingsTab(tab) ? tab : "profile"}`);
5838
+ });
5104
5839
  app.post(`${ACCOUNT_FRONTEND_PREFIX}/settings/wallet/funding-link`, (c) => dispatchScopedPostToLegacyRoute(c, "/settings/wallet/funding-link"));
5105
5840
  app.get("/discover/feed", async (c) => {
5841
+ // Content negotiation: a browser NAVIGATION to /discover/feed (Accept:
5842
+ // text/html) renders the Feed page — this is the canonical Feed URL now that
5843
+ // /discover redirects here. Everything else (the in-page loader, which sends
5844
+ // Accept: application/json, plus agents/devcli/upstream passthrough) falls
5845
+ // through to the JSON feed below. The two never collide because the page's
5846
+ // own fetch is explicitly application/json.
5847
+ const acceptHeader = (c.req.header("accept") || "").toLowerCase();
5848
+ if (acceptHeader.includes("text/html") && !acceptHeader.includes("application/json")) {
5849
+ return renderDiscoverMode(c, "feed");
5850
+ }
5106
5851
  const limit = clampPageLimit(Number(c.req.query("limit") || ""), 100, 200);
5107
5852
  // Optional keyword search so callers (UI, agents, devcli) can ask "which
5108
5853
  // templates suit my <offer>?" and get a ranked subset instead of the full
@@ -5110,66 +5855,12 @@ app.get("/discover/feed", async (c) => {
5110
5855
  // decompose-derived promotions/keywords/summary.
5111
5856
  const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
5112
5857
  const customer = await getOptionalPreferredBrowserCustomer(c);
5113
- const templateEntries = [];
5114
- if (customer) {
5115
- // The caller's own private submissions rank above the public catalog.
5116
- // Finalizing pending inspirations here is what mints the private template
5117
- // as soon as the download job completes — the feed poll doubles as the
5118
- // completion check.
5119
- const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id))
5120
- .filter((inspiration) => inspiration.visibility === "private");
5121
- const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
5122
- const ownTemplates = (await serverlessRecords.listTemplatesForCustomer(customer.id))
5123
- .filter((template) => template.visibility === "private")
5124
- .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
5125
- const pending = finalized
5126
- .filter((inspiration) => !inspiration.templateId)
5127
- .filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
5128
- .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
5129
- for (const inspiration of pending) {
5130
- templateEntries.push(buildPendingInspirationHomepageEntry(inspiration));
5131
- }
5132
- for (const template of ownTemplates.filter((template) => recordMatchesSearchQuery(template, query))) {
5133
- const entry = buildTemplateHomepageEntry(template);
5134
- if (entry)
5135
- templateEntries.push(entry);
5136
- }
5137
- }
5138
- const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
5139
- for (const template of templates) {
5140
- // Curated "Popular" picks live only under the Popular toggle
5141
- // (GET /discover/popular) — keep them out of the continuously-grown Feed
5142
- // so the two views stay distinct.
5143
- if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
5144
- continue;
5145
- const entry = buildTemplateHomepageEntry(template);
5146
- if (entry)
5147
- templateEntries.push(entry);
5148
- }
5149
- // Cloud passthrough (vidfarm serve): the local records are just the working
5150
- // set of seeded templates, so surface the upstream (cloud) catalog behind
5151
- // them. Local entries win on id conflicts — a seeded template also exists
5152
- // upstream and the local copy reflects any local edits. Fail-soft: an
5153
- // unreachable upstream degrades to the local-only feed.
5154
- if (isUpstreamEnabled()) {
5155
- const search = new URLSearchParams({ limit: String(limit) });
5156
- if (query)
5157
- search.set("q", query);
5158
- const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
5159
- const seen = new Set(templateEntries.map((entry) => entry.templateId));
5160
- for (const entry of upstreamFeed) {
5161
- if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
5162
- templateEntries.push(entry);
5163
- seen.add(entry.templateId);
5164
- }
5165
- }
5166
- }
5167
- // The public feed is identical for every caller, but a logged-in response
5168
- // interleaves the caller's private templates — never let a shared cache
5169
- // serve that to someone else.
5858
+ const entries = await collectDiscoverFeedEntries(customer, { query, limit });
5859
+ // The Feed interleaves the caller's own inspirations, so a logged-in response
5860
+ // must never be served from a shared cache.
5170
5861
  c.header("cache-control", customer ? "private, max-age=10" : "public, max-age=60");
5171
5862
  return c.json({
5172
- templates: templateEntries,
5863
+ templates: entries,
5173
5864
  next_cursor: null
5174
5865
  });
5175
5866
  });
@@ -5318,8 +6009,40 @@ function injectSwipePreviewFit(doc, cardId) {
5318
6009
  function play() {
5319
6010
  try { if (window.__player) { window.__player.play(); } } catch (e) {}
5320
6011
  }
6012
+ // The runtime paints video through a single backing <video> whose CSS
6013
+ // background is near-black, so until a frame decodes the card shows solid
6014
+ // black behind the captions. Drive that element directly as a
6015
+ // belt-and-suspenders fallback: a muted backing video may autoplay even
6016
+ // if the runtime's own play() was swallowed, and forcing a load()/frame
6017
+ // decode guarantees the first frame paints instead of the black
6018
+ // background — many source clips fade in from black, so we also nudge a
6019
+ // hair off 0 to avoid parking on a dark intro frame.
6020
+ var backingVideo = document.querySelector('[data-vf-playback-source]');
6021
+ var framePainted = false;
6022
+ function kickBackingVideo() {
6023
+ if (!backingVideo) return;
6024
+ try { backingVideo.muted = true; backingVideo.playsInline = true; } catch (e) {}
6025
+ try { if (backingVideo.readyState < 2) { backingVideo.load(); } } catch (e) {}
6026
+ try { var p = backingVideo.play(); if (p && p.catch) { p.catch(function () {}); } } catch (e) {}
6027
+ }
6028
+ if (backingVideo) {
6029
+ backingVideo.addEventListener('loadeddata', function () {
6030
+ kickBackingVideo();
6031
+ if (!framePainted) {
6032
+ framePainted = true;
6033
+ // Only nudge if the runtime hasn't already advanced playback.
6034
+ try {
6035
+ if (backingVideo.currentTime < 0.05 && (!window.__player || !window.__player.isPlaying())) {
6036
+ backingVideo.currentTime = 0.12;
6037
+ }
6038
+ } catch (e) {}
6039
+ }
6040
+ });
6041
+ backingVideo.addEventListener('canplay', kickBackingVideo);
6042
+ }
5321
6043
  applySound(false);
5322
6044
  play();
6045
+ kickBackingVideo();
5323
6046
  var lastWant = false;
5324
6047
  var stalled = 0;
5325
6048
  var mutedFallback = false;
@@ -5328,7 +6051,7 @@ function injectSwipePreviewFit(doc, cardId) {
5328
6051
  var want = wantSound();
5329
6052
  if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
5330
6053
  applySound(want && !mutedFallback);
5331
- if (!window.__player || !window.__player.getDuration) { return; }
6054
+ if (!window.__player || !window.__player.getDuration) { kickBackingVideo(); return; }
5332
6055
  var t = window.__player.getTime(), d = window.__player.getDuration();
5333
6056
  if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
5334
6057
  else if (!window.__player.isPlaying()) {
@@ -5338,6 +6061,7 @@ function injectSwipePreviewFit(doc, cardId) {
5338
6061
  // the preview never freezes on a frame.
5339
6062
  if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
5340
6063
  play();
6064
+ kickBackingVideo();
5341
6065
  } else { stalled = 0; }
5342
6066
  } catch (e) {}
5343
6067
  }, 400);
@@ -5847,6 +6571,36 @@ app.post("/discover/templates", async (c) => {
5847
6571
  });
5848
6572
  return c.json(serializeInspiration(inspiration), 202);
5849
6573
  });
6574
+ // Remix (decompose on demand): mint the template for a Feed inspiration the
6575
+ // moment the user chooses to open it. The Feed browses lightweight inspirations
6576
+ // with no template, so this is where a template is first created — for the
6577
+ // caller's own inspirations and for curated/public ones alike (a non-owner then
6578
+ // gets their own fork when the editor opens template_<id>). Idempotent: an
6579
+ // already-decomposed inspiration just returns its existing template id.
6580
+ app.post("/discover/inspirations/:id/decompose", async (c) => {
6581
+ const customer = await requireBrowserCustomer(c);
6582
+ if (!customer)
6583
+ return c.json({ ok: false, error: "Log in to remix an inspiration." }, 401);
6584
+ const inspiration = await serverlessRecords.getInspiration(c.req.param("id"));
6585
+ if (!inspiration)
6586
+ return c.json({ ok: false, error: "Inspiration not found." }, 404);
6587
+ // Only the owner may open a PRIVATE inspiration; public/curated ones are
6588
+ // remixable by anyone (the editor forks per-user on open).
6589
+ if (inspiration.visibility === "private" && inspiration.customerId !== customer.id) {
6590
+ return c.notFound();
6591
+ }
6592
+ if (inspiration.templateId) {
6593
+ return c.json({ ok: true, templateId: inspiration.templateId });
6594
+ }
6595
+ if (inspiration.status !== "ready" || !inspiration.videoUrl) {
6596
+ return c.json({ ok: false, error: "This inspiration is still processing — try again in a moment." }, 409);
6597
+ }
6598
+ const decomposed = await mintTemplateForInspiration(inspiration);
6599
+ if (!decomposed.templateId) {
6600
+ return c.json({ ok: false, error: "Could not prepare this inspiration for editing." }, 500);
6601
+ }
6602
+ return c.json({ ok: true, templateId: decomposed.templateId });
6603
+ });
5850
6604
  // Delete a private discover entry the caller owns. Accepts either the minted
5851
6605
  // template id (template_...) or the raw inspiration id (inspiration_..., i.e.
5852
6606
  // a still-processing/failed card) and removes both records. Public catalog
@@ -5900,6 +6654,51 @@ app.delete("/discover/templates/:entryId", async (c) => {
5900
6654
  // on its owner's /discover as a placeholder card so the Add Template flow has
5901
6655
  // immediate feedback. It has no template yet, so the editor CTA is disabled
5902
6656
  // client-side via status.
6657
+ // Feed card for a single INSPIRATION (Feed browses inspirations, not templates).
6658
+ // Lightweight: no template is required. If the inspiration has already minted a
6659
+ // template (templateId set), the card opens the editor directly; otherwise the
6660
+ // card previews the source video and its CTA decomposes on demand (preview →
6661
+ // Remix). Covers ready / downloading / failed states in one shape.
6662
+ function buildInspirationFeedEntry(inspiration) {
6663
+ const isReady = inspiration.status === "ready";
6664
+ const isFailed = inspiration.status === "failed";
6665
+ const title = (inspiration.sourceHost === "upload"
6666
+ ? (inspiration.title || inspiration.trendTagline)
6667
+ : (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New inspiration";
6668
+ // Prefer the raw source video so the Feed + TikTok viewer actually play; fall
6669
+ // back to the poster thumbnail (image) when the video URL isn't available yet.
6670
+ const previewUrl = inspiration.videoUrl || inspiration.thumbnailUrl || "";
6671
+ const viralDna = isFailed
6672
+ ? (inspiration.errorMessage || "Video ingest failed. Try adding it again.")
6673
+ : isReady
6674
+ ? (inspiration.notes || inspiration.searchSummary || inspiration.trendTagline
6675
+ || `A proven viral ${inspiration.sourceHost || "social"} post. Preview it, then remix it into an ad for your product.`)
6676
+ : (inspiration.notes || (inspiration.sourceHost === "upload"
6677
+ ? "Processing your uploaded video. This card unlocks automatically once it's ready."
6678
+ : "Downloading the source video. This card unlocks automatically once it's ready."));
6679
+ return {
6680
+ title,
6681
+ // Real template id when already decomposed; "" means "decompose on open".
6682
+ templateId: inspiration.templateId ?? "",
6683
+ inspirationId: inspiration.id,
6684
+ slugId: inspiration.templateId ?? inspiration.id,
6685
+ difficulty: "easy",
6686
+ viralDna,
6687
+ previewUrl,
6688
+ approvedAt: inspiration.createdAt,
6689
+ sourceType: inspiration.sourceHost || "social",
6690
+ durationSeconds: inspiration.durationSeconds ?? null,
6691
+ promotions: inspiration.promotions ?? null,
6692
+ keywords: inspiration.keywords ?? null,
6693
+ summary: inspiration.searchSummary ?? null,
6694
+ visibility: inspiration.visibility,
6695
+ origin: inspiration.origin ?? "inspiration",
6696
+ popularGroup: inspiration.popularGroup ?? null,
6697
+ popularRank: inspiration.popularRank ?? null,
6698
+ notes: inspiration.notes,
6699
+ status: isFailed ? "failed" : isReady ? "ready" : "processing"
6700
+ };
6701
+ }
5903
6702
  function buildPendingInspirationHomepageEntry(inspiration) {
5904
6703
  return {
5905
6704
  // Uploads: explicit title (or file name, stored at submit time) outranks
@@ -6294,6 +7093,8 @@ app.get("/assets/sentry-config.js", (c) => {
6294
7093
  });
6295
7094
  app.get("/assets/homepage-client-app.js", (c) => serveRootJsBundle(c, "public/assets/homepage-client-app.js"));
6296
7095
  app.get("/assets/page-runtime-client-app.js", (c) => serveRootJsBundle(c, "public/assets/page-runtime-client-app.js"));
7096
+ app.get("/assets/file-directory-app.js", (c) => serveRootJsBundle(c, "public/assets/file-directory-app.js"));
7097
+ app.get("/assets/discover-client-app.js", (c) => serveRootJsBundle(c, "public/assets/discover-client-app.js"));
6297
7098
  function serveRootJsBundle(c, relativePath) {
6298
7099
  const filePath = resolveRootFileCandidates(relativePath).find((candidate) => existsSync(candidate));
6299
7100
  if (!filePath) {
@@ -7019,7 +7820,11 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/composition.html`, async (c) => {
7019
7820
  const html = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "composition.html"));
7020
7821
  if (!html)
7021
7822
  return c.json({ ok: false, error: "Composition missing" }, 404);
7022
- return c.body(html, 200, { "content-type": "text/html; charset=utf-8" });
7823
+ // The editor stage loads the composition here (then injects its own runtime),
7824
+ // so unmute the not-yet-decomposed raw single-video base so its own audio
7825
+ // plays. Older stored raw bases baked in a `muted` attribute; strip it on the
7826
+ // way out. Decomposed compositions are left untouched (see helper).
7827
+ return c.body(unmuteRawPlaybackSourceVideo(html), 200, { "content-type": "text/html; charset=utf-8" });
7023
7828
  }
7024
7829
  catch (error) {
7025
7830
  return forkAccessErrorResponse(c, error);
@@ -9583,6 +10388,101 @@ function serializeInspiration(inspiration) {
9583
10388
  updated_at: inspiration.updatedAt
9584
10389
  };
9585
10390
  }
10391
+ // Mint the template for a READY inspiration (idempotent): creates a template
10392
+ // inheriting the inspiration's visibility + origin and links it back via
10393
+ // templateId. Returns the inspiration with templateId set — or unchanged if it
10394
+ // already has one / isn't ready / has no source video. Shared by the eager
10395
+ // (private, on-download) path and the lazy Remix path (POST
10396
+ // /discover/inspirations/:id/decompose).
10397
+ async function mintTemplateForInspiration(inspiration) {
10398
+ if (inspiration.templateId || inspiration.status !== "ready" || !inspiration.videoUrl)
10399
+ return inspiration;
10400
+ const template = await serverlessRecords.createTemplate({
10401
+ inspirationId: inspiration.id,
10402
+ customerId: inspiration.customerId,
10403
+ originalUrl: inspiration.originalUrl,
10404
+ sourceHost: inspiration.sourceHost,
10405
+ // No host/"Untitled" fallback: a template without an explicit title or
10406
+ // tagline displays as its template id on /discover.
10407
+ title: (inspiration.sourceHost === "upload"
10408
+ ? (inspiration.title || inspiration.trendTagline)
10409
+ : (inspiration.trendTagline || inspiration.title)) || null,
10410
+ thumbnailUrl: inspiration.thumbnailUrl,
10411
+ videoUrl: inspiration.videoUrl,
10412
+ durationSeconds: inspiration.durationSeconds,
10413
+ songName: inspiration.songName,
10414
+ songUrl: inspiration.songUrl,
10415
+ visibility: inspiration.visibility === "private" ? "private" : "public",
10416
+ origin: inspiration.origin ?? "inspiration",
10417
+ notes: inspiration.notes
10418
+ });
10419
+ // Seed the template's canonical DEFAULT fork with the raw single-video
10420
+ // timeline so opening the editor lands on an editable project immediately —
10421
+ // the whole clip as ONE element across the trackpad. Because origin stays
10422
+ // "inspiration" the editor still auto-prompts Decompose; decomposing rebuilds
10423
+ // this same default fork in place, so the multi-scene version becomes the new
10424
+ // default everyone forks off. Non-fatal: on failure the template just falls
10425
+ // back to the on-demand raw seed in POST /api/v1/compositions.
10426
+ try {
10427
+ await seedRawDefaultForkForTemplate(template);
10428
+ }
10429
+ catch (error) {
10430
+ console.error("seed raw default fork failed", error instanceof Error ? error.message : error);
10431
+ }
10432
+ return (await serverlessRecords.updateInspiration({
10433
+ inspirationId: inspiration.id,
10434
+ patch: { templateId: template.id }
10435
+ })) ?? { ...inspiration, templateId: template.id };
10436
+ }
10437
+ // Create + stamp a template's canonical DEFAULT fork holding the raw
10438
+ // single-video composition (the full source clip as one timeline element).
10439
+ // Idempotent: returns the existing default fork id if one is already stamped,
10440
+ // or null if the template has no source video to seed from. Owned privately by
10441
+ // the template owner — other users never touch it through permissions; POST
10442
+ // /api/v1/compositions seeds their forks by copying its composition html.
10443
+ async function seedRawDefaultForkForTemplate(template) {
10444
+ if (template.defaultForkId)
10445
+ return template.defaultForkId;
10446
+ const sourceUrl = template.videoUrl?.trim();
10447
+ if (!sourceUrl)
10448
+ return null;
10449
+ const title = template.title ?? `Template ${template.id}`;
10450
+ const fork = await serverlessRecords.createCompositionFork({
10451
+ customerId: template.customerId,
10452
+ templateId: template.id,
10453
+ parentForkId: null,
10454
+ parentVersion: null,
10455
+ visibility: "private",
10456
+ title
10457
+ });
10458
+ const html = buildRawTikTokCompositionHtml({
10459
+ compositionId: fork.id,
10460
+ sourceUrl,
10461
+ durationSeconds: template.durationSeconds ?? 12,
10462
+ title,
10463
+ origin: template.origin ?? "inspiration"
10464
+ });
10465
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
10466
+ await writeForkManifest(storage, fork, { kind: "working" });
10467
+ // Snapshot v1 (reason "clone" bypasses the version ceiling) so version
10468
+ // history + revert work from the very first raw base.
10469
+ try {
10470
+ await snapshotForkVersion({
10471
+ fork,
10472
+ callerCustomerId: template.customerId,
10473
+ reason: "clone",
10474
+ message: "Raw single-video base"
10475
+ });
10476
+ }
10477
+ catch (error) {
10478
+ console.error("seed raw default fork snapshot failed", error instanceof Error ? error.message : error);
10479
+ }
10480
+ const stamp = await serverlessRecords.stampDefaultForkIfUnset({
10481
+ templateId: template.id,
10482
+ forkId: fork.id
10483
+ });
10484
+ return stamp.template?.defaultForkId ?? fork.id;
10485
+ }
9586
10486
  async function finalizeInspirationIfReady(inspiration) {
9587
10487
  if (inspiration.status !== "downloading" || !inspiration.downloadJobId)
9588
10488
  return inspiration;
@@ -9612,38 +10512,12 @@ async function finalizeInspirationIfReady(inspiration) {
9612
10512
  songUrl: songUrl ?? null
9613
10513
  }
9614
10514
  })) ?? inspiration;
9615
- // Private (user-added) inspirations mint their template as soon as the
9616
- // download lands so the card on the owner's /discover becomes openable.
9617
- // Public inspirations keep the existing behavior: templates only come
9618
- // from the admin ingest flow.
9619
- if (ready.visibility === "private" && !ready.templateId && ready.videoUrl) {
9620
- const template = await serverlessRecords.createTemplate({
9621
- inspirationId: ready.id,
9622
- customerId: ready.customerId,
9623
- originalUrl: ready.originalUrl,
9624
- sourceHost: ready.sourceHost,
9625
- // No host/"Untitled" fallback: a template without an explicit title or
9626
- // tagline displays as its template id on /discover. For uploads the
9627
- // manifest title IS the caller's explicit title, so it outranks the
9628
- // tagline; URL ingests keep tagline-first (manifest title is just the
9629
- // social post's own caption).
9630
- title: (ready.sourceHost === "upload"
9631
- ? (ready.title || ready.trendTagline)
9632
- : (ready.trendTagline || ready.title)) || null,
9633
- thumbnailUrl: ready.thumbnailUrl,
9634
- videoUrl: ready.videoUrl,
9635
- durationSeconds: ready.durationSeconds,
9636
- songName: ready.songName,
9637
- songUrl: ready.songUrl,
9638
- visibility: "private",
9639
- origin: ready.origin ?? "inspiration",
9640
- notes: ready.notes
9641
- });
9642
- ready = (await serverlessRecords.updateInspiration({
9643
- inspirationId: ready.id,
9644
- patch: { templateId: template.id }
9645
- })) ?? ready;
9646
- }
10515
+ // EVERY ready inspiration eagerly mints a template + a raw single-video
10516
+ // default fork, so every card opens the editor directly onto an editable
10517
+ // project (the whole clip as one timeline element) with the Decompose
10518
+ // prompt still available. The Feed browses inspirations (deduped on
10519
+ // inspirationId||templateId) so a template per upload doesn't flood it.
10520
+ ready = await mintTemplateForInspiration(ready);
9647
10521
  return ready;
9648
10522
  }
9649
10523
  if (job.status === "failed" || job.status === "cancelled") {
@@ -10582,7 +11456,7 @@ app.get("/library", async (c) => {
10582
11456
  posts,
10583
11457
  schedule: {
10584
11458
  channels: scheduleChannels,
10585
- connectHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
11459
+ connectHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
10586
11460
  },
10587
11461
  editorChat: await buildLibraryEditorChatBoot({
10588
11462
  customer,
@@ -10753,7 +11627,7 @@ app.get("/calendar", async (c) => {
10753
11627
  posts: [],
10754
11628
  loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
10755
11629
  hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
10756
- connectChannelsHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
11630
+ connectChannelsHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
10757
11631
  }));
10758
11632
  });
10759
11633
  app.get("/agency", async (c) => {
@@ -11063,7 +11937,7 @@ app.get("/email-channels/confirm", async (c) => {
11063
11937
  <h1 style="overflow-wrap: anywhere;">${escapeHtml(title)}</h1>
11064
11938
  <p>${channel ? "Your email has been confirmed and can receive email tasks from Vidfarm." : "This confirmation link is invalid or has already been used."}</p>
11065
11939
  <div class="toolbar">
11066
- <a class="button" href="/settings?tab=channels">Go to settings</a>
11940
+ <a class="button" href="/settings/channels">Go to settings</a>
11067
11941
  <a class="button secondary" href="/discover">Back to Discover</a>
11068
11942
  </div>
11069
11943
  </section>
@@ -12134,9 +13008,21 @@ app.use(`${USER_PREFIX}/me/*`, requireAuth);
12134
13008
  // is the public HTML gallery page (like /discover vs /discover/feed). Hono's
12135
13009
  // `/clips/*` also matches the bare `/clips`, so let that one path through
12136
13010
  // unauthenticated.
12137
- const isClipsGalleryPage = (c) => c.req.path === CLIPS_PREFIX || c.req.path === `${CLIPS_PREFIX}/`;
12138
- app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
12139
- app.use(`${CLIPS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
13011
+ const isClipsGalleryPage = (c) => c.req.path === RAWS_PREFIX || c.req.path === `${RAWS_PREFIX}/`;
13012
+ app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
13013
+ app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
13014
+ // Deprecated /clips/* → /raws/* alias: transparently redispatch legacy paths so
13015
+ // existing skills, editor-chat prompts, and the devcli keep working during the
13016
+ // clips→raws rename. Remove once all callers emit /raws.
13017
+ const rawsCompatAlias = (c) => {
13018
+ const url = new URL(c.req.url);
13019
+ url.pathname = RAWS_PREFIX + url.pathname.slice(CLIPS_COMPAT_PREFIX.length);
13020
+ // Redispatch through the app with just the rewritten request — do NOT forward
13021
+ // c.executionCtx (its getter throws in the Node adapter when absent).
13022
+ return app.fetch(new Request(url.toString(), c.req.raw));
13023
+ };
13024
+ app.all(CLIPS_COMPAT_PREFIX, rawsCompatAlias);
13025
+ app.all(`${CLIPS_COMPAT_PREFIX}/*`, rawsCompatAlias);
12140
13026
  app.use(AGENCY_PREFIX, requireAuth);
12141
13027
  app.use(`${AGENCY_PREFIX}/*`, requireAuth);
12142
13028
  app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
@@ -13171,38 +14057,58 @@ function countReadyPostMediaMatches(job, post) {
13171
14057
  return matches;
13172
14058
  }
13173
14059
  async function resolveReadyPostEditorHref(post, accountId) {
14060
+ // "Edit Post" must land in the NEW /editor experience, never the retired
14061
+ // /templates/:id/editor/docs editor. The /editor routes live at the top
14062
+ // level and read the agency sub-account from the ?account= query param (there
14063
+ // is no /u/:account/editor route), so account context is attached that way.
14064
+ const withAccount = (path) => {
14065
+ if (!accountId)
14066
+ return path;
14067
+ const sep = path.includes("?") ? "&" : "?";
14068
+ return `${path}${sep}${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(accountId)}`;
14069
+ };
14070
+ const editorForkHref = (templateId, forkId) => withAccount(`/editor/${encodeURIComponent(templateId)}/fork/${encodeURIComponent(forkId)}`);
14071
+ // Bare editor: the route resolves (or mints) the caller's own fork.
14072
+ const editorTemplateHref = (templateId) => withAccount(`/editor/${encodeURIComponent(templateId)}`);
14073
+ // Fresh blank project in the new editor — used when nothing editable is
14074
+ // linked to this post (e.g. a post approved straight from a primitive render
14075
+ // with no composition fork or tracer).
14076
+ const blankEditorHref = () => withAccount("/editor/original/fork/new");
14077
+ // 1) Preferred: deep-link to the exact composition fork the post came from.
14078
+ const compositionId = post.compositionId?.trim();
14079
+ if (compositionId) {
14080
+ const fork = await serverlessRecords.getCompositionFork(compositionId);
14081
+ if (fork && !fork.deletedAt && fork.templateId) {
14082
+ return editorForkHref(fork.templateId, fork.id);
14083
+ }
14084
+ // Some create paths stash a serverless template id in compositionId rather
14085
+ // than a fork id — open the bare editor for that template.
14086
+ if (compositionId.startsWith("template_")) {
14087
+ return editorTemplateHref(compositionId);
14088
+ }
14089
+ }
14090
+ // 2) Fall back to the tracer's most-relevant render job, but only when that
14091
+ // job points at a serverless (template_) template the new editor can open.
14092
+ // Legacy catalog jobs (plain-UUID templateIds) are skipped on purpose so we
14093
+ // never route back into the old editor.
13174
14094
  const tracer = post.tracer?.trim();
13175
- const buildDocsHref = (templateId, tracers) => {
13176
- const params = new URLSearchParams();
13177
- if (accountId) {
13178
- params.set(ACCOUNT_QUERY_PARAM, accountId);
13179
- }
13180
- if (tracers) {
13181
- params.set("tracers", tracers);
14095
+ if (tracer) {
14096
+ const matchingJob = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
14097
+ .filter((job) => job.templateId.startsWith("template_"))
14098
+ .map((job) => ({
14099
+ job,
14100
+ score: (countReadyPostMediaMatches(job, post) * 1000)
14101
+ + (job.parentJobId ? 0 : 120)
14102
+ + (job.status === "succeeded" ? 30 : 0)
14103
+ }))
14104
+ .sort((a, b) => (b.score - a.score
14105
+ || Date.parse(b.job.completedAt ?? b.job.updatedAt ?? b.job.createdAt) - Date.parse(a.job.completedAt ?? a.job.updatedAt ?? a.job.createdAt)))[0]?.job ?? null;
14106
+ if (matchingJob && await serverlessRecords.getTemplate(matchingJob.templateId)) {
14107
+ return editorTemplateHref(matchingJob.templateId);
13182
14108
  }
13183
- const suffix = params.size ? `?${params.toString()}` : "";
13184
- return `/templates/${encodeURIComponent(templateId)}/editor/docs${suffix}`;
13185
- };
13186
- if (!tracer) {
13187
- const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
13188
- return firstTemplate ? buildDocsHref(firstTemplate.id) : withAccountQuery("/templates", accountId);
13189
14109
  }
13190
- const candidates = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
13191
- .filter((job) => !job.templateId.startsWith("primitive:") && Boolean(templateRegistry.get(job.templateId)));
13192
- const matchingJob = candidates
13193
- .map((job) => ({
13194
- job,
13195
- score: (countReadyPostMediaMatches(job, post) * 1000)
13196
- + (job.parentJobId ? 0 : 120)
13197
- + (job.status === "succeeded" ? 30 : 0)
13198
- }))
13199
- .sort((a, b) => (b.score - a.score
13200
- || Date.parse(b.job.completedAt ?? b.job.updatedAt ?? b.job.createdAt) - Date.parse(a.job.completedAt ?? a.job.updatedAt ?? a.job.createdAt)))[0]?.job ?? null;
13201
- if (matchingJob) {
13202
- return buildDocsHref(matchingJob.templateId, tracer);
13203
- }
13204
- const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
13205
- return firstTemplate ? buildDocsHref(firstTemplate.id, tracer) : withAccountQuery("/templates", accountId);
14110
+ // 3) Nothing editable is linked open a fresh project in the new editor.
14111
+ return blankEditorHref();
13206
14112
  }
13207
14113
  async function renderReadyPostPage(c, post, viewer) {
13208
14114
  const visiblePost = serializeReadyPost(c, post, { password: viewer.hasPasswordAccess ? viewer.password : null });
@@ -13232,7 +14138,7 @@ async function renderReadyPostPage(c, post, viewer) {
13232
14138
  error: scheduleChannels.error,
13233
14139
  feedEndpoint: viewer.canManage && viewer.customer ? withAccountQuery("/calendar/feed", viewer.customer.id) : null,
13234
14140
  scheduleEndpoint: viewer.canManage && viewer.customer ? scopedApprovedPostSchedulePath(viewer.customer.id, post.id) : null,
13235
- connectHref: viewer.customer ? withAccountQuery("/settings?tab=channels&connect=flockposter", viewer.customer.id) : "/settings?tab=channels&connect=flockposter",
14141
+ connectHref: viewer.customer ? withAccountQuery("/settings/channels?connect=flockposter", viewer.customer.id) : "/settings/channels?connect=flockposter",
13236
14142
  userId: viewer.customer?.id ?? null
13237
14143
  };
13238
14144
  const shareUrl = viewer.canView
@@ -14052,6 +14958,282 @@ async function renderReadyPostPage(c, post, viewer) {
14052
14958
  gap: 8px;
14053
14959
  margin-top: 4px;
14054
14960
  }
14961
+
14962
+ /* ─────────────────────────────────────────────────────────────────────
14963
+ farmville-saas-ux restyle (honey-gold accent, Hanken Grotesk display,
14964
+ soft rounded white cards). SCOPED to .ready-post-shell so it never
14965
+ leaks into other legacy pages, and placed last so it wins over both the
14966
+ page-shell base CSS and the ready-post rules above. Structure + JS are
14967
+ untouched — this is a pure visual reskin via token remap + component
14968
+ overrides. Hanken Grotesk is loaded by a <link> in the page body.
14969
+ ───────────────────────────────────────────────────────────────────── */
14970
+ /* page canvas → clean warm-neutral farmville surface. Drops the old
14971
+ beige/tan gradient + technical grid overlay baked into the base shell
14972
+ <body> (which the .ready-post-shell scope can't reach). A whisper of
14973
+ honey glow up top keeps the "sunny afternoon" warmth without the grid. */
14974
+ body:has(.ready-post-shell) {
14975
+ background:
14976
+ radial-gradient(1150px 520px at 50% -12%, rgba(255, 199, 56, 0.10), transparent 62%),
14977
+ linear-gradient(180deg, #fdfcf9 0%, #fbfaf6 46%, #f6f4ee 100%);
14978
+ background-attachment: fixed;
14979
+ }
14980
+ body:has(.ready-post-shell)::before {
14981
+ content: none;
14982
+ }
14983
+ @media (max-width: 1024px) {
14984
+ body:has(.ready-post-shell) {
14985
+ background-attachment: scroll;
14986
+ }
14987
+ }
14988
+ .ready-post-shell {
14989
+ --accent: #ffc738;
14990
+ font-family: "Inter", "Manrope", sans-serif;
14991
+ --accent-deep: #171717;
14992
+ --accent-soft: rgba(255, 199, 56, 0.22);
14993
+ --ink: #171717;
14994
+ --muted: #6f6f70;
14995
+ --line: #ededed;
14996
+ --line-strong: #e0e0e0;
14997
+ --bg: #fbfaf6;
14998
+ --paper: #ffffff;
14999
+ --paper-strong: #ffffff;
15000
+ --panel-muted: #faf9f6;
15001
+ --radius-lg: 24px;
15002
+ --radius-md: 18px;
15003
+ --radius-sm: 12px;
15004
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 18px 40px -20px rgba(0, 0, 0, 0.12);
15005
+ --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 26px -16px rgba(0, 0, 0, 0.14);
15006
+ }
15007
+ .ready-post-shell h1,
15008
+ .ready-post-shell h2,
15009
+ .ready-post-shell .ready-post-schedule-head h2 {
15010
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15011
+ font-weight: 800;
15012
+ letter-spacing: -0.02em;
15013
+ color: #171717;
15014
+ }
15015
+ /* buttons → honey-gold pill / white ghost / red danger */
15016
+ .ready-post-shell .button,
15017
+ .ready-post-shell button,
15018
+ .ready-post-shell .link-button,
15019
+ .ready-post-shell .cta-button {
15020
+ border: 1px solid transparent;
15021
+ border-radius: 999px;
15022
+ background: #ffc738;
15023
+ color: #171717;
15024
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15025
+ font-weight: 700;
15026
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
15027
+ }
15028
+ @media (hover: hover) {
15029
+ .ready-post-shell .button:hover,
15030
+ .ready-post-shell button:hover,
15031
+ .ready-post-shell .link-button:hover {
15032
+ background: #fcb900;
15033
+ border-color: transparent;
15034
+ transform: translateY(-1px);
15035
+ box-shadow: 0 10px 22px -8px rgba(252, 185, 0, 0.55);
15036
+ }
15037
+ }
15038
+ .ready-post-shell .button.secondary,
15039
+ .ready-post-shell button.secondary,
15040
+ .ready-post-shell .link-button.secondary {
15041
+ background: #ffffff;
15042
+ color: #171717;
15043
+ border: 1px solid #e5e5e5;
15044
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
15045
+ }
15046
+ @media (hover: hover) {
15047
+ .ready-post-shell .button.secondary:hover,
15048
+ .ready-post-shell button.secondary:hover {
15049
+ background: #ffffff;
15050
+ border-color: #d4d4d4;
15051
+ box-shadow: 0 4px 12px -6px rgba(0, 0, 0, 0.12);
15052
+ }
15053
+ }
15054
+ .ready-post-shell .button.danger,
15055
+ .ready-post-shell button.danger,
15056
+ .ready-post-shell .ready-post-danger {
15057
+ background: #ef4444;
15058
+ color: #fff;
15059
+ border-color: transparent;
15060
+ }
15061
+ .ready-post-shell button[disabled] {
15062
+ opacity: 0.5;
15063
+ }
15064
+ /* inputs → soft farmville field, gold focus ring */
15065
+ .ready-post-shell input,
15066
+ .ready-post-shell select,
15067
+ .ready-post-shell textarea {
15068
+ border: 1px solid #e5e5e5;
15069
+ border-radius: 12px;
15070
+ background: #ffffff;
15071
+ color: #171717;
15072
+ box-shadow: none;
15073
+ }
15074
+ .ready-post-shell textarea {
15075
+ border-radius: 16px;
15076
+ }
15077
+ .ready-post-shell input:focus,
15078
+ .ready-post-shell select:focus,
15079
+ .ready-post-shell textarea:focus {
15080
+ border-color: #ffc738;
15081
+ box-shadow: 0 0 0 3px rgba(255, 199, 56, 0.28);
15082
+ }
15083
+ /* tracer / status chip */
15084
+ .ready-post-shell .pill {
15085
+ background: #fff5e4;
15086
+ color: #8a6d1f;
15087
+ border: 1px solid #ffe9cc;
15088
+ border-radius: 999px;
15089
+ font-weight: 700;
15090
+ }
15091
+ /* cards / panels → white farmville card */
15092
+ .ready-post-shell .ready-post-card,
15093
+ .ready-post-shell .ready-post-panel {
15094
+ background: #ffffff;
15095
+ border: 1px solid #ededed;
15096
+ border-radius: 24px;
15097
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 30px -18px rgba(0, 0, 0, 0.14);
15098
+ }
15099
+ /* copy + menu buttons → neutral ghost */
15100
+ .ready-post-shell .ready-post-copy,
15101
+ .ready-post-shell .ready-post-actions [data-menu-button] {
15102
+ background: #ffffff;
15103
+ border: 1px solid #e5e5e5;
15104
+ color: #525252;
15105
+ box-shadow: none;
15106
+ }
15107
+ .ready-post-shell .ready-post-copy[data-copied="true"] {
15108
+ background: #dcfce7;
15109
+ border-color: #86efac;
15110
+ color: #15803d;
15111
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
15112
+ }
15113
+ .ready-post-shell .ready-post-menu {
15114
+ border: 1px solid #ededed;
15115
+ border-radius: 16px;
15116
+ background: #ffffff;
15117
+ box-shadow: 0 20px 40px -16px rgba(0, 0, 0, 0.22);
15118
+ }
15119
+ /* video/slides mode toggle → farmville pill toggle */
15120
+ .ready-post-shell .ready-post-media-mode-toggle button.active {
15121
+ background: #ffc738;
15122
+ color: #171717;
15123
+ }
15124
+ /* login modal card */
15125
+ .ready-post-shell .ready-post-login-modal-card {
15126
+ border: 1px solid #ededed;
15127
+ border-radius: 24px;
15128
+ background: #ffffff;
15129
+ box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.3);
15130
+ }
15131
+ /* schedule modal → farmville surface + gold selected day */
15132
+ .ready-post-shell .ready-post-schedule-modal {
15133
+ border-radius: 24px;
15134
+ }
15135
+ /* ── de-saturate: structural buttons must NOT inherit the blanket gold ──
15136
+ The broad gold button rule above is meant for primary
15137
+ CTAs only, but it also painted calendar days, the channel-picker trigger,
15138
+ channel rows and kebab-menu items gold (a wall of yellow). Restore their
15139
+ neutral surfaces here (placed last so they win); only true primaries and
15140
+ genuinely-selected states stay honey-gold. */
15141
+ .ready-post-shell .ready-post-calendar-day {
15142
+ background: #ffffff;
15143
+ color: #3a3a3c;
15144
+ border: 1px solid #ececec;
15145
+ box-shadow: none;
15146
+ font-weight: 700;
15147
+ }
15148
+ .ready-post-shell .ready-post-calendar-day.is-muted {
15149
+ background: #faf9f6;
15150
+ color: #b5b5b7;
15151
+ }
15152
+ .ready-post-shell .ready-post-calendar-day.has-posts {
15153
+ background: rgba(83, 114, 89, 0.10);
15154
+ border-color: rgba(83, 114, 89, 0.30);
15155
+ }
15156
+ .ready-post-shell .ready-post-calendar-day.has-many-posts {
15157
+ background: rgba(255, 199, 56, 0.16);
15158
+ border-color: rgba(255, 199, 56, 0.42);
15159
+ }
15160
+ @media (hover: hover) {
15161
+ .ready-post-shell .ready-post-calendar-day:hover {
15162
+ background: #fff8e8;
15163
+ border-color: #ffc738;
15164
+ transform: none;
15165
+ box-shadow: none;
15166
+ }
15167
+ }
15168
+ .ready-post-shell .ready-post-calendar-day.is-selected,
15169
+ .ready-post-shell .ready-post-calendar-day.is-selected:hover {
15170
+ background: #ffc738;
15171
+ color: #171717;
15172
+ border-color: transparent;
15173
+ }
15174
+ .ready-post-shell .ready-post-calendar-day.is-today {
15175
+ border-color: #ffc738;
15176
+ }
15177
+ /* channel-picker trigger → neutral field, not a gold pill */
15178
+ .ready-post-shell .ready-post-channel-trigger {
15179
+ background: #ffffff;
15180
+ color: #171717;
15181
+ border: 1px solid #e5e5e5;
15182
+ box-shadow: none;
15183
+ font-weight: 600;
15184
+ }
15185
+ .ready-post-shell .ready-post-channel-trigger.is-placeholder {
15186
+ color: #6f6f70;
15187
+ }
15188
+ @media (hover: hover) {
15189
+ .ready-post-shell .ready-post-channel-trigger:hover {
15190
+ background: #ffffff;
15191
+ border-color: #d4d4d4;
15192
+ transform: none;
15193
+ box-shadow: none;
15194
+ }
15195
+ }
15196
+ /* channel rows + kebab-menu items → transparent, gold tint only on hover/selected */
15197
+ .ready-post-shell .ready-post-channel-option,
15198
+ .ready-post-shell .ready-post-menu a,
15199
+ .ready-post-shell .ready-post-menu button {
15200
+ background: transparent;
15201
+ color: #171717;
15202
+ border: 0;
15203
+ box-shadow: none;
15204
+ font-weight: 600;
15205
+ }
15206
+ @media (hover: hover) {
15207
+ .ready-post-shell .ready-post-channel-option:hover,
15208
+ .ready-post-shell .ready-post-menu a:hover,
15209
+ .ready-post-shell .ready-post-menu button:hover {
15210
+ background: #fff5e4;
15211
+ transform: none;
15212
+ box-shadow: none;
15213
+ }
15214
+ }
15215
+ .ready-post-shell .ready-post-channel-option.is-selected {
15216
+ background: #fff5e4;
15217
+ }
15218
+ .ready-post-shell .ready-post-menu .ready-post-danger {
15219
+ color: #dc2626;
15220
+ font-weight: 700;
15221
+ }
15222
+ @media (hover: hover) {
15223
+ .ready-post-shell .ready-post-menu .ready-post-danger:hover {
15224
+ background: #fef2f2;
15225
+ }
15226
+ }
15227
+ /* popover close chip stays a neutral ghost */
15228
+ .ready-post-shell .ready-post-calendar-popover-close {
15229
+ background: #ffffff;
15230
+ color: #6f6f70;
15231
+ border: 1px solid #e5e5e5;
15232
+ box-shadow: none;
15233
+ }
15234
+ .ready-post-shell .ready-post-locked {
15235
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15236
+ }
14055
15237
  `;
14056
15238
  const readyPostBody = viewer.canView ? `
14057
15239
  <section class="ready-post-frame">
@@ -14093,10 +15275,19 @@ async function renderReadyPostPage(c, post, viewer) {
14093
15275
  </footer>
14094
15276
  </div>
14095
15277
  </div>` : `<section class="ready-post-locked"><form method="get" action="${escapeAttribute(accountApprovedPostPath(post.customerId, post.id))}"><h1>Approved post</h1><p>Enter the share password to preview and download this approved post.</p><input name="password" type="password" value="${escapeAttribute(DEFAULT_APPROVED_POST_VIEW_PASSWORD)}" autofocus autocomplete="current-password"><button type="submit">View post</button></form></section>`;
15278
+ const socialImage = thumbnail?.url ?? slides[0]?.url;
15279
+ const socialDescription = post.caption?.trim() || undefined;
14096
15280
  return c.html(renderPageShell({
14097
15281
  title: post.title ?? "Approved post",
14098
15282
  shellClass: "ready-post-shell",
15283
+ description: socialDescription,
15284
+ social: {
15285
+ image: socialImage,
15286
+ url: shareUrl,
15287
+ type: primaryVideo ? "video.other" : "website"
15288
+ },
14099
15289
  body: `
15290
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
14100
15291
  ${readyPostBody}
14101
15292
  <script id="ready-post-data" type="application/json">${escapeJsonForHtml(serialized)}</script>
14102
15293
  <script id="ready-post-zip-data" type="application/json">${escapeJsonForHtml({
@@ -16113,7 +17304,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/presign`, async (c) => {
16113
17304
  const body = userTemporaryFilePresignSchema.parse(await c.req.json());
16114
17305
  const fileName = sanitizeFileName(body.file_name);
16115
17306
  const contentType = body.content_type?.trim() || inferAttachmentContentType(fileName);
16116
- const folderPath = sanitizeStorageSubpath(body.folder_path);
17307
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(body.folder_path));
16117
17308
  if (!isAllowedAttachment(fileName, contentType)) {
16118
17309
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16119
17310
  }
@@ -16171,7 +17362,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
16171
17362
  }
16172
17363
  const fileName = sanitizeFileName(file.name || "upload.bin");
16173
17364
  const contentType = file.type || inferAttachmentContentType(fileName);
16174
- const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
17365
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(String(formData.get("folder_path") ?? "")));
16175
17366
  if (!isAllowedAttachment(fileName, contentType)) {
16176
17367
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16177
17368
  }
@@ -16293,6 +17484,189 @@ app.delete(`${USER_PREFIX}/me/temporary-files/:fileId`, async (c) => {
16293
17484
  await serverlessRecords.deleteUserTemporaryFile(customer.id, file.id);
16294
17485
  return c.json({ ok: true, file_id: file.id });
16295
17486
  });
17487
+ // ── Unified file directory ───────────────────────────────────────────────────
17488
+ // The first-class, navigable view over all three file backends as one tree:
17489
+ // /files → My Files attachments /temp → temp scratch /raws → clip library
17490
+ // Both the reskin explorer UI and the AI chat agent browse through here, so a
17491
+ // path like /raws/product-demos is a real, copyable, agent-addressable location.
17492
+ // GET /me/directory?path=/raws/demos → folders[] + files[] under that path.
17493
+ // path "/" (or omitted) lists the three roots as folders.
17494
+ app.get(`${USER_PREFIX}/me/directory`, async (c) => {
17495
+ const customer = requireCustomer(c);
17496
+ const parsed = parseDirectoryPath(c.req.query("path") ?? "");
17497
+ if (!parsed.root) {
17498
+ return c.json({ path: "/", root: null, folders: directoryRootFolders(), files: [], next_offset: null });
17499
+ }
17500
+ const limit = clampPageLimit(Number(c.req.query("limit") || "200"), 500);
17501
+ const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
17502
+ // Exact shot-kind filter (raws only): comma-separated OR set of content_type tags.
17503
+ const contentTypes = (c.req.query("content_type") || "")
17504
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
17505
+ try {
17506
+ const { folders, files } = await listDirectoryFolder(c, customer.id, parsed.root, parsed.folderPath);
17507
+ const filtered = (parsed.root === "raws" && contentTypes.length)
17508
+ ? files.filter((f) => {
17509
+ const tags = (f.meta && f.meta.tags) || null;
17510
+ const ct = Array.isArray(tags?.content_type) ? tags?.content_type : [];
17511
+ return ct.some((x) => contentTypes.includes(String(x).toLowerCase()));
17512
+ })
17513
+ : files;
17514
+ const page = filtered.slice(offset, offset + limit);
17515
+ const nextOffset = filtered.length > offset + limit ? offset + limit : null;
17516
+ return c.json({
17517
+ path: buildDirectoryPath(parsed.root, parsed.folderPath),
17518
+ root: parsed.root,
17519
+ folders,
17520
+ files: page,
17521
+ next_offset: nextOffset
17522
+ });
17523
+ }
17524
+ catch (error) {
17525
+ return c.json({ error: error instanceof Error ? error.message : "Unable to list directory." }, 400);
17526
+ }
17527
+ });
17528
+ // POST /me/directory/search { query, path?, mode?, limit? }
17529
+ // Unified search combining semantic (vector), substring, and absolute-path
17530
+ // matching across the in-scope roots. `path` narrows scope to one root/folder;
17531
+ // `mode` ∈ auto|semantic|substring|path (default auto = all three signals).
17532
+ function directoryPathContains(itemPath, query) {
17533
+ return itemPath.toLowerCase().includes(query.toLowerCase());
17534
+ }
17535
+ app.post(`${USER_PREFIX}/me/directory/search`, async (c) => {
17536
+ const customer = requireCustomer(c);
17537
+ try {
17538
+ const body = (await c.req.json().catch(() => ({})));
17539
+ const query = (body.query ?? "").trim();
17540
+ if (!query)
17541
+ return c.json({ error: "Provide a search query." }, 400);
17542
+ const limit = Math.max(1, Math.min(50, Number(body.limit) || 20));
17543
+ // Exact shot-kind filter applied to the /raws branch only.
17544
+ const contentTypes = (body.content_type || "")
17545
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
17546
+ const parsed = parseDirectoryPath(body.path ?? "");
17547
+ const scopeRoots = parsed.root ? [parsed.root] : DIRECTORY_ROOT_KEYS;
17548
+ const scopeFolder = normalizeFolderPath(parsed.folderPath);
17549
+ const inScopeFolder = (fp) => !scopeFolder || (normalizeFolderPath(fp) + "/").indexOf(scopeFolder + "/") === 0;
17550
+ const mode = ["semantic", "substring", "path"].includes(String(body.mode)) ? body.mode : "auto";
17551
+ const wantSemantic = mode === "auto" || mode === "semantic";
17552
+ const wantSubstring = mode === "auto" || mode === "substring";
17553
+ const wantPath = mode === "auto" || mode === "path";
17554
+ const scored = [];
17555
+ let semanticUsed = false;
17556
+ // ── /files: notes embedding (semantic) + name/folder/notes substring ──────
17557
+ if (scopeRoots.includes("files")) {
17558
+ const atts = (await serverlessRecords.listUserAttachments(customer.id)).filter((a) => inScopeFolder(a.folderPath));
17559
+ let queryEmbedding;
17560
+ if (wantSemantic) {
17561
+ try {
17562
+ const embedder = await buildFileNotesEmbedder(customer.id);
17563
+ if (embedder)
17564
+ queryEmbedding = await embedder.embedQuery(query);
17565
+ }
17566
+ catch (error) {
17567
+ devLog("directory_search.files_embed_failed", devErrorFields(error));
17568
+ }
17569
+ }
17570
+ if (queryEmbedding)
17571
+ semanticUsed = true;
17572
+ for (const a of atts) {
17573
+ const item = attachmentToDirectoryItem(c, a);
17574
+ let score = 0;
17575
+ let sem = false;
17576
+ if (queryEmbedding && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0) {
17577
+ const sim = cosineSimilarity(a.notesEmbedding, queryEmbedding);
17578
+ if (sim > 0) {
17579
+ score += sim;
17580
+ sem = true;
17581
+ }
17582
+ }
17583
+ if (wantSubstring && attachmentKeywordMatch(a, query))
17584
+ score += 0.5;
17585
+ if (wantPath && directoryPathContains(item.path, query))
17586
+ score += 0.6;
17587
+ if (score > 0)
17588
+ scored.push({ item, score, semantic: sem });
17589
+ }
17590
+ }
17591
+ // ── /temp: no embeddings → substring + absolute-path only ─────────────────
17592
+ if (scopeRoots.includes("temp")) {
17593
+ const files = (await serverlessRecords.listUserTemporaryFiles(customer.id)).filter((f) => inScopeFolder(f.folderPath));
17594
+ for (const f of files) {
17595
+ const item = temporaryFileToDirectoryItem(c, f);
17596
+ let score = 0;
17597
+ if (wantSubstring && attachmentKeywordMatch(f, query))
17598
+ score += 0.5;
17599
+ if (wantPath && directoryPathContains(item.path, query))
17600
+ score += 0.6;
17601
+ if (score > 0)
17602
+ scored.push({ item, score, semantic: false });
17603
+ }
17604
+ }
17605
+ // ── /raws: semantic-first clip search, or keyword/path filter otherwise ────
17606
+ if (scopeRoots.includes("raws")) {
17607
+ if (wantSemantic) {
17608
+ let queryEmbedding;
17609
+ try {
17610
+ const embedder = await buildMatchEmbedder(customer.id);
17611
+ if (embedder)
17612
+ [queryEmbedding] = await embedder.embedQueries([query]);
17613
+ }
17614
+ catch (error) {
17615
+ devLog("directory_search.raws_embed_failed", devErrorFields(error));
17616
+ }
17617
+ if (queryEmbedding)
17618
+ semanticUsed = true;
17619
+ const hits = await searchUserClips(customer.id, {
17620
+ criteria: { semantic_text: query, ...(contentTypes.length ? { content_type: contentTypes } : {}) },
17621
+ queryEmbedding,
17622
+ limit: limit * 3
17623
+ });
17624
+ for (const h of hits) {
17625
+ if (!inScopeFolder(h.clip.folder_path))
17626
+ continue;
17627
+ const item = clipSerializedToDirectoryItem(await serializeClip(h.clip));
17628
+ scored.push({ item, score: h.similarity ?? h.score ?? 0.1, semantic: Boolean(queryEmbedding) });
17629
+ }
17630
+ }
17631
+ else {
17632
+ const clips = (await clipRecords.listClips(customer.id, { limit: 5000 }))
17633
+ .filter((cl) => inScopeFolder(cl.folder_path))
17634
+ .filter((cl) => !contentTypes.length || (cl.tags?.content_type ?? []).some((ct) => contentTypes.includes(String(ct).toLowerCase())));
17635
+ for (const cl of clips) {
17636
+ const kw = wantSubstring && clipKeywordMatch(cl, query);
17637
+ const item = clipSerializedToDirectoryItem(await serializeClip(cl));
17638
+ const pm = wantPath && directoryPathContains(item.path, query);
17639
+ if (kw || pm)
17640
+ scored.push({ item, score: (kw ? 0.5 : 0) + (pm ? 0.6 : 0), semantic: false });
17641
+ }
17642
+ }
17643
+ }
17644
+ scored.sort((x, y) => y.score - x.score);
17645
+ const results = scored.slice(0, limit).map((s) => ({ ...s.item, score: s.score }));
17646
+ return c.json({ results, query, mode, scope: parsed.root ?? "all", semantic: semanticUsed });
17647
+ }
17648
+ catch (error) {
17649
+ return c.json({ error: error instanceof Error ? error.message : "Directory search failed." }, 400);
17650
+ }
17651
+ });
17652
+ // POST /me/directory/folders { path } — create an empty folder at a canonical
17653
+ // path (e.g. /files/brand-assets, /raws/demos). Persisted as a user_file_folder
17654
+ // record scoped to the root, so it shows up in listings before any file lands.
17655
+ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
17656
+ const customer = requireCustomer(c);
17657
+ try {
17658
+ const body = (await c.req.json().catch(() => ({})));
17659
+ const parsed = parseDirectoryPath(body.path ?? "");
17660
+ if (!parsed.root || !parsed.folderPath) {
17661
+ return c.json({ error: "Provide a folder path under a root, e.g. /files/brand-assets or /raws/demos." }, 400);
17662
+ }
17663
+ await serverlessRecords.createUserFileFolder(customer.id, directoryFolderScope(parsed.root), parsed.folderPath);
17664
+ return c.json({ ok: true, path: buildDirectoryPath(parsed.root, parsed.folderPath), root: parsed.root, folder_path: parsed.folderPath });
17665
+ }
17666
+ catch (error) {
17667
+ return c.json({ error: error instanceof Error ? error.message : "Unable to create folder." }, 400);
17668
+ }
17669
+ });
16296
17670
  app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
16297
17671
  try {
16298
17672
  const customer = requireCustomer(c);
@@ -17045,7 +18419,7 @@ function clipKeywordMatch(clip, q) {
17045
18419
  // GET /clips — the Clips gallery, rendered in the same shell as the rest of the
17046
18420
  // frontend and reached via the Library page's "Finished / Clips" toggle. The
17047
18421
  // client fetches /clips/feed with the session cookie (mirrors /discover vs /feed).
17048
- app.get(CLIPS_PREFIX, async (c) => {
18422
+ app.get(RAWS_PREFIX, async (c) => {
17049
18423
  const customer = await getPreferredBrowserCustomer(c);
17050
18424
  if (!customer) {
17051
18425
  return redirect(c, "/login");
@@ -17058,21 +18432,76 @@ app.get(CLIPS_PREFIX, async (c) => {
17058
18432
  editorChat: await buildClipsEditorChatBoot({ customer })
17059
18433
  }));
17060
18434
  });
17061
- // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
17062
- app.get(`${CLIPS_PREFIX}/feed`, async (c) => {
18435
+ // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q,
18436
+ // ?content_type, ?limit, ?offset). ?content_type is an exact tag filter
18437
+ // (comma-separated = OR) over each raw's content_type tags — the /library/raws
18438
+ // content-type chips use it for real structured filtering, distinct from ?q
18439
+ // semantic/keyword search. ?offset pages the (descending) result set for the
18440
+ // chat file browser; the response carries next_offset (null when exhausted).
18441
+ app.get(`${RAWS_PREFIX}/feed`, async (c) => {
17063
18442
  const customer = requireCustomer(c);
17064
18443
  const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
18444
+ const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
17065
18445
  const q = c.req.query("q")?.trim();
18446
+ const contentTypes = (c.req.query("content_type") || "")
18447
+ .split(",")
18448
+ .map((s) => s.trim().toLowerCase())
18449
+ .filter(Boolean);
18450
+ // ?folder= scopes the feed to one virtual Raws subfolder (exact match, or ""
18451
+ // for the raws root) — the directory browser navigates with it.
18452
+ const folderParam = c.req.query("folder");
18453
+ const folderScoped = folderParam != null;
18454
+ const folder = normalizeFolderPath(folderParam ?? "");
18455
+ const narrowed = Boolean(q) || contentTypes.length > 0 || folderScoped;
18456
+ // Unfiltered: load only up to the page window (+1 to detect a next page),
18457
+ // capped so a huge library never pulls unbounded. Filtered paths must scan a
18458
+ // wider set before slicing, since the filter runs in memory.
18459
+ const loadLimit = narrowed ? 5000 : Math.min(offset + limit + 1, 2000);
17066
18460
  let clips = await clipRecords.listClips(customer.id, {
17067
18461
  sourceVideoId: c.req.query("source") || undefined,
17068
- limit: q ? 5000 : limit
18462
+ limit: loadLimit
17069
18463
  });
18464
+ // Derive the folder tree from every loaded clip BEFORE the folder scope trims
18465
+ // the set (so the browser can see sibling folders). Complete whenever the load
18466
+ // scanned the whole library (all narrowed paths do).
18467
+ const folders = buildFolderList(clips.map((clip) => ({ folderPath: clip.folder_path })));
18468
+ if (contentTypes.length) {
18469
+ clips = clips.filter((clip) => (clip.tags.content_type ?? []).some((ct) => contentTypes.includes(ct)));
18470
+ }
18471
+ if (folderScoped) {
18472
+ clips = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === folder);
18473
+ }
17070
18474
  if (q)
17071
- clips = clips.filter((clip) => clipKeywordMatch(clip, q)).slice(0, limit);
17072
- return c.json({ clips: await Promise.all(clips.map(serializeClip)) });
18475
+ clips = clips.filter((clip) => clipKeywordMatch(clip, q));
18476
+ const page = clips.slice(offset, offset + limit);
18477
+ const nextOffset = clips.length > offset + limit ? offset + limit : null;
18478
+ return c.json({ clips: await Promise.all(page.map(serializeClip)), folders, next_offset: nextOffset });
18479
+ });
18480
+ // PATCH /raws/:clipId — move a clip between virtual Raws folders (or to the
18481
+ // root with folder_path: ""). Registered before other /raws/:param routes so a
18482
+ // literal like "feed"/"search" never matches as a clip id (those are GET/POST).
18483
+ app.patch(`${RAWS_PREFIX}/:clipId`, async (c) => {
18484
+ const customer = requireCustomer(c);
18485
+ try {
18486
+ const body = (await c.req.json().catch(() => ({})));
18487
+ if (typeof body.folder_path !== "string") {
18488
+ return c.json({ error: "Provide folder_path (\"\" moves to the raws root)." }, 400);
18489
+ }
18490
+ const clipId = c.req.param("clipId");
18491
+ const existing = await clipRecords.getClip(customer.id, clipId);
18492
+ if (!existing)
18493
+ return c.json({ error: "Raw not found." }, 404);
18494
+ const folderPath = normalizeFolderPath(body.folder_path);
18495
+ await clipRecords.updateClipFolder(customer.id, clipId, folderPath);
18496
+ const updated = await clipRecords.getClip(customer.id, clipId);
18497
+ return c.json({ ok: true, clip: updated ? await serializeClip(updated) : null });
18498
+ }
18499
+ catch (error) {
18500
+ return c.json({ error: error instanceof Error ? error.message : "Unable to move raw." }, 400);
18501
+ }
17073
18502
  });
17074
18503
  // POST /clips/search — hybrid structured + semantic search.
17075
- app.post(`${CLIPS_PREFIX}/search`, async (c) => {
18504
+ app.post(`${RAWS_PREFIX}/search`, async (c) => {
17076
18505
  const customer = requireCustomer(c);
17077
18506
  try {
17078
18507
  const body = (await c.req.json().catch(() => ({})));
@@ -17120,7 +18549,7 @@ function normalizeMatchScene(s, index) {
17120
18549
  }
17121
18550
  // POST /clips/match — find the top library clips that could REPLACE one scene.
17122
18551
  // Body: a scene annotation, or {criteria, embedding_text|text, limit}.
17123
- app.post(`${CLIPS_PREFIX}/match`, async (c) => {
18552
+ app.post(`${RAWS_PREFIX}/match`, async (c) => {
17124
18553
  const customer = requireCustomer(c);
17125
18554
  try {
17126
18555
  const body = (await c.req.json().catch(() => ({})));
@@ -17143,7 +18572,7 @@ app.post(`${CLIPS_PREFIX}/match`, async (c) => {
17143
18572
  // POST /clips/match-scenes — batch: take a decomposed video's scene annotations
17144
18573
  // and return the top replacement-clip matches per scene (one library read).
17145
18574
  // Body: { scenes | annotations: SceneAnnotation[], limit? }.
17146
- app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
18575
+ app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
17147
18576
  const customer = requireCustomer(c);
17148
18577
  try {
17149
18578
  const body = (await c.req.json());
@@ -17184,10 +18613,11 @@ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
17184
18613
  // pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
17185
18614
  // compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
17186
18615
  // the caller's own provider key (BYOK) and is never wallet-billed.
17187
- app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
18616
+ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17188
18617
  const customer = requireCustomer(c);
17189
18618
  try {
17190
18619
  const body = (await c.req.json());
18620
+ const importOnly = body.import_only === true;
17191
18621
  const guidancePrompt = body.prompt?.trim() || "";
17192
18622
  // Build the hunt spec: explicit structured fields win, the free "what to
17193
18623
  // clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
@@ -17198,7 +18628,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17198
18628
  if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
17199
18629
  return c.json({ error: "That doesn't look like a valid video URL." }, 400);
17200
18630
  }
17201
- const huntSpec = normalizeHuntSpec({
18631
+ let huntSpec = normalizeHuntSpec({
17202
18632
  ...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
17203
18633
  ...(body.ranges != null ? { ranges: body.ranges } : {}),
17204
18634
  ...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
@@ -17219,8 +18649,15 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17219
18649
  huntSpec.aspect = parsedPrompt.aspect;
17220
18650
  if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
17221
18651
  huntSpec.avoid_text = true;
18652
+ if (!huntSpec.max_clips && parsedPrompt.max_clips)
18653
+ huntSpec.max_clips = parsedPrompt.max_clips;
17222
18654
  if (!huntSpec.source_url && sourceUrl)
17223
18655
  huntSpec.source_url = sourceUrl;
18656
+ // Fill the unspecified-prompt mining defaults (5–15s clips, vertical 9:16,
18657
+ // avoid on-screen text). Explicit user/prompt choices above already won;
18658
+ // this only fills the gaps. Skipped for import_only (no mining happens).
18659
+ if (!importOnly)
18660
+ huntSpec = applyHuntSpecDefaults(huntSpec);
17224
18661
  // Uploaded-source hunts: resolve a temp-folder file (the canonical home for
17225
18662
  // hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
17226
18663
  let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
@@ -17295,6 +18732,33 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17295
18732
  pipeline: input.pipeline,
17296
18733
  pipeline_deployed: cloudPipeline
17297
18734
  }, 202);
18735
+ // ── Import individual video (no clip mining) ─────────────────────────────
18736
+ // The Import Source modal's unchecked "Clip raws" box: just pull the source
18737
+ // into the raws library as ONE whole-video raw. No BYOK key, no AI tagging,
18738
+ // no wallet bill. Runs inline (awaited) so it's reliable on Lambda, then
18739
+ // returns a "complete" scan the poller sees immediately.
18740
+ if (importOnly) {
18741
+ const importSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
18742
+ if (resolvedS3Key && (!importSourcePath || !existsSync(importSourcePath)) && !huntSpec.source_url) {
18743
+ return c.json({ error: "That upload isn't reachable for a direct import — pass source_url instead." }, 400);
18744
+ }
18745
+ await putScanRecords({ scanStatus: "running" });
18746
+ try {
18747
+ await importWholeVideoAsRaw({
18748
+ ownerId: customer.id,
18749
+ scanId,
18750
+ sourceVideoId,
18751
+ filename,
18752
+ tracer,
18753
+ localSourcePath: importSourcePath && existsSync(importSourcePath) ? importSourcePath : null,
18754
+ sourceUrl: huntSpec.source_url ?? null
18755
+ });
18756
+ }
18757
+ catch (error) {
18758
+ return c.json({ error: error instanceof Error ? error.message : "Could not import that video." }, 502);
18759
+ }
18760
+ return scanResponse({ status: "complete", pipeline: "local" });
18761
+ }
17298
18762
  if (cloudPipeline) {
17299
18763
  // ── Deployed pipeline (Lambda + Step Functions): BYOK API keys only ──
17300
18764
  if (choice === "agent") {
@@ -17503,7 +18967,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
17503
18967
  // GET /clips/scan-options — what the Import Source modal can run a hunt with:
17504
18968
  // which BYOK providers have saved keys, whether a local agent CLI is available
17505
18969
  // (vidfarm serve boxes only), what "auto" resolves to, and where the hunt runs.
17506
- app.get(`${CLIPS_PREFIX}/scan-options`, async (c) => {
18970
+ app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
17507
18971
  const customer = requireCustomer(c);
17508
18972
  const keys = await loadCustomerVisionKeys(customer.id);
17509
18973
  const cloudPipeline = Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN);
@@ -17524,7 +18988,11 @@ app.get(`${CLIPS_PREFIX}/scan-options`, async (c) => {
17524
18988
  });
17525
18989
  function looksLikeDirectVideoUrl(url) {
17526
18990
  try {
17527
- return /\.(mp4|mov|m4v|webm|mkv)$/i.test(new URL(url).pathname);
18991
+ const u = new URL(url);
18992
+ // Match a video extension in the path OR a query value (signed/CDN links
18993
+ // often carry the real filename as `?file=clip.mp4` with a path-less route).
18994
+ return /\.(mp4|mov|m4v|webm|mkv)(\?|$)/i.test(u.pathname) ||
18995
+ /\.(mp4|mov|m4v|webm|mkv)(&|$)/i.test(u.search);
17528
18996
  }
17529
18997
  catch {
17530
18998
  return false;
@@ -17533,10 +19001,13 @@ function looksLikeDirectVideoUrl(url) {
17533
19001
  // Local URL ingest for serve-box hunts: direct video links download straight;
17534
19002
  // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI
17535
19003
  // downloader the video_download primitive uses (needs RAPIDAPI_KEY locally).
19004
+ // Returns the local path plus the source's human title (when RapidAPI gives one)
19005
+ // so callers can name the raw/folder something better than a bare URL slug.
17536
19006
  async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17537
19007
  if (!sourceUrl)
17538
19008
  throw new Error("Clip scan needs a source video.");
17539
19009
  let mediaUrl = sourceUrl;
19010
+ let title = null;
17540
19011
  if (!looksLikeDirectVideoUrl(sourceUrl)) {
17541
19012
  const apiKey = (config.RAPIDAPI_KEY || "").trim();
17542
19013
  if (!apiKey) {
@@ -17554,11 +19025,14 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17554
19025
  if (!lookupRes.ok)
17555
19026
  throw new Error(`Video download lookup failed with HTTP ${lookupRes.status}.`);
17556
19027
  const lookup = (await lookupRes.json());
17557
- const playable = (lookup.medias ?? []).filter((m) => m.url && m.videoAvailable !== false && (m.type ? /video/i.test(m.type) : true));
17558
- const chosen = playable.find((m) => (m.extension ?? "").toLowerCase() === "mp4" || /\.mp4(\?|$)/i.test(m.url ?? "")) ?? playable[0];
19028
+ // Pick the HIGHEST-resolution playable MP4 (not just the first one) so the
19029
+ // ingested source and every clip cut from it is as high-quality as the
19030
+ // provider offers.
19031
+ const chosen = pickBestVideoMedia(lookup.medias ?? []);
17559
19032
  if (!chosen?.url)
17560
19033
  throw new Error("Video download lookup returned no playable MP4 media URL.");
17561
19034
  mediaUrl = chosen.url;
19035
+ title = typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null;
17562
19036
  }
17563
19037
  const localPath = path.join(workDir, "ingest", "source.mp4");
17564
19038
  mkdirSync(path.dirname(localPath), { recursive: true });
@@ -17566,7 +19040,148 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17566
19040
  if (!res.ok || !res.body)
17567
19041
  throw new Error(`Source video download failed with HTTP ${res.status}.`);
17568
19042
  await streamPipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
17569
- return localPath;
19043
+ return { videoPath: localPath, title };
19044
+ }
19045
+ // Record the RapidAPI social-download cost for an in-process URL ingest (the
19046
+ // whole-video import path bypasses the clip-scan Lambda, which is where this
19047
+ // third-party cost is normally billed). Mirrors the Lambda's cost center +
19048
+ // idempotency key so a retry never double-bills. Non-fatal.
19049
+ async function recordRapidApiIngestCost(input) {
19050
+ // Direct .mp4 links never touch RapidAPI, so there's nothing to bill.
19051
+ if (!input.sourceUrl || looksLikeDirectVideoUrl(input.sourceUrl))
19052
+ return;
19053
+ try {
19054
+ await billing.record({
19055
+ customerId: input.ownerId,
19056
+ jobId: input.scanId,
19057
+ tracer: input.tracer ?? null,
19058
+ templateId: "clips/scan",
19059
+ type: "cpu_estimate",
19060
+ costUsd: config.RAPIDAPI_VIDEO_DOWNLOAD_BASE_COST_USD,
19061
+ costCenterSlug: "rapidapi_video_download",
19062
+ idempotencyKey: `rapidapi_video_download:${input.scanId}`,
19063
+ occurredAtMs: Date.now(),
19064
+ metadata: { operation: "clip_import_ingest", source_url: input.sourceUrl }
19065
+ });
19066
+ }
19067
+ catch (error) {
19068
+ devLog("clip_import.ingest_billing_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
19069
+ }
19070
+ }
19071
+ // Import a whole video as a SINGLE raw — the plain "import individual video"
19072
+ // path behind the Import Source modal's unchecked "Clip raws" box. Ingests the
19073
+ // source (direct link or RapidAPI social download), probes its duration, cuts
19074
+ // one thumbnail, and writes ONE clip record spanning the entire video. No BYOK
19075
+ // key, no AI tagging, no wallet bill. Awaited by the caller so it completes
19076
+ // inside the request (reliable on Lambda, which freezes detached work).
19077
+ async function importWholeVideoAsRaw(input) {
19078
+ const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
19079
+ const touchScan = async (patch) => {
19080
+ const scan = await clipRecords.getScan(input.ownerId, input.scanId);
19081
+ if (scan)
19082
+ await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
19083
+ };
19084
+ const touchSource = async (patch) => {
19085
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
19086
+ if (source)
19087
+ await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
19088
+ };
19089
+ try {
19090
+ mkdirSync(workRoot, { recursive: true });
19091
+ let videoPath = input.localSourcePath;
19092
+ let ingestedTitle = null;
19093
+ if (!videoPath) {
19094
+ const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
19095
+ videoPath = ingested.videoPath;
19096
+ ingestedTitle = ingested.title;
19097
+ // URL ingest hits RapidAPI (same third-party cost the clip-scan Lambda
19098
+ // bills on its own ingest); the whole-video path skips the Lambda, so
19099
+ // record the download here or it goes unbilled. Non-fatal on failure.
19100
+ await recordRapidApiIngestCost({
19101
+ ownerId: input.ownerId,
19102
+ scanId: input.scanId,
19103
+ tracer: input.tracer,
19104
+ sourceUrl: input.sourceUrl ?? ""
19105
+ });
19106
+ }
19107
+ const probe = await probeVideo(videoPath);
19108
+ const durationSec = probe.duration_sec || 0;
19109
+ await touchSource({ status: "scanning", duration_sec: durationSec });
19110
+ // A RapidAPI title beats a URL-slug filename for the raw's description and
19111
+ // its source-folder name in the /library/raws explorer.
19112
+ const displayName = ingestedTitle || input.filename;
19113
+ const clipId = createIdV7("clip");
19114
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
19115
+ // Stream the source to storage instead of readFileSync-into-Buffer — whole
19116
+ // videos can be hundreds of MB and would otherwise pin the entire file in
19117
+ // memory on the request Lambda.
19118
+ await storage.putFile(clipKey, videoPath, "video/mp4");
19119
+ let thumbKey = "";
19120
+ const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
19121
+ const wroteThumb = await extractThumbnail({
19122
+ videoPath,
19123
+ scene: { index: 0, start_time_sec: 0, end_time_sec: durationSec, duration_sec: durationSec },
19124
+ outPath: thumbLocal
19125
+ }).catch(() => false);
19126
+ if (wroteThumb && existsSync(thumbLocal)) {
19127
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
19128
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
19129
+ }
19130
+ const clip = {
19131
+ clip_id: clipId,
19132
+ source_video_id: input.sourceVideoId,
19133
+ source_filename: displayName,
19134
+ start_time_sec: 0,
19135
+ end_time_sec: durationSec,
19136
+ duration_sec: durationSec,
19137
+ file_path: clipKey,
19138
+ thumbnail_path: thumbKey,
19139
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
19140
+ description: displayName,
19141
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
19142
+ tier: "flash-lite",
19143
+ tracer: input.tracer,
19144
+ // Default into a source-derived Raws subfolder (canonical `/raws/<slug>`).
19145
+ folder_path: normalizeFolderPath(String(displayName || "")
19146
+ .toLowerCase()
19147
+ .replace(/\.[a-z0-9]+$/i, "")
19148
+ .replace(/[^a-z0-9]+/g, "-")
19149
+ .replace(/^-+|-+$/g, "")
19150
+ .slice(0, 60)),
19151
+ owner_id: input.ownerId,
19152
+ created_at: nowIso()
19153
+ };
19154
+ // Best-effort semantic-search embedding of the title/description so an
19155
+ // imported whole video is findable by meaning (not just visible in its
19156
+ // folder). No AI tagging call, no wallet bill — fail-soft when the owner
19157
+ // has no embed-capable key (keyword-only, as before).
19158
+ try {
19159
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
19160
+ if (embedder?.embeddingModelId) {
19161
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
19162
+ if (vector?.length) {
19163
+ clip.embedding = vector;
19164
+ clip.embedding_model = embedder.embeddingModelId;
19165
+ }
19166
+ }
19167
+ }
19168
+ catch (error) {
19169
+ devLog("clip_import.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
19170
+ }
19171
+ await clipRecords.putClip(clip);
19172
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
19173
+ await touchSource({ status: "complete", clip_count: 1, filename: displayName });
19174
+ await touchScan({ status: "complete" });
19175
+ }
19176
+ catch (error) {
19177
+ devLog("clip_import.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
19178
+ await touchSource({ status: "failed" }).catch(() => undefined);
19179
+ await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
19180
+ throw error;
19181
+ }
19182
+ finally {
19183
+ rmSync(workRoot, { recursive: true, force: true });
19184
+ }
17570
19185
  }
17571
19186
  // In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
17572
19187
  // Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
@@ -17587,7 +19202,7 @@ async function runClipScanInProcess(input) {
17587
19202
  };
17588
19203
  try {
17589
19204
  mkdirSync(workRoot, { recursive: true });
17590
- const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot));
19205
+ const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot)).videoPath;
17591
19206
  const probe = await probeVideo(videoPath);
17592
19207
  await touchSource({ status: "scanning", duration_sec: probe.duration_sec });
17593
19208
  const spec = input.huntSpec;
@@ -17604,6 +19219,9 @@ async function runClipScanInProcess(input) {
17604
19219
  ? { aspect: spec.aspect, focus: normalizeCropFocus(spec.crop_focus) }
17605
19220
  : undefined
17606
19221
  };
19222
+ // Aim for a distinct, density-bounded set (default ~10 clips / 10 min) over
19223
+ // the effective (windowed) source duration.
19224
+ const targetClipCount = resolveTargetClipCount(effectiveHuntDurationSec(probe.duration_sec, spec.windows), spec);
17607
19225
  let stored = 0;
17608
19226
  await scanVideo({
17609
19227
  ctx,
@@ -17611,6 +19229,7 @@ async function runClipScanInProcess(input) {
17611
19229
  probe,
17612
19230
  windows: spec.windows,
17613
19231
  durationBand: spec.duration_band ?? null,
19232
+ targetClipCount,
17614
19233
  sourceVideoId: input.sourceVideoId,
17615
19234
  sourceFilename: input.filename,
17616
19235
  ownerId: input.ownerId,
@@ -17748,7 +19367,7 @@ async function mirrorUpstreamClipsToLocal(input) {
17748
19367
  await clipRecords.putScan({ ...(current ?? scan), status: "complete", updated_at: nowIso() });
17749
19368
  }
17750
19369
  // GET /clips/scan/:scanId — poll scan status.
17751
- app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
19370
+ app.get(`${RAWS_PREFIX}/scan/:scanId`, async (c) => {
17752
19371
  const customer = requireCustomer(c);
17753
19372
  let scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
17754
19373
  if (!scan)
@@ -17786,17 +19405,17 @@ app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
17786
19405
  return c.json({ scan, source });
17787
19406
  });
17788
19407
  // GET /clips/sources — scanned source videos.
17789
- app.get(`${CLIPS_PREFIX}/sources`, async (c) => {
19408
+ app.get(`${RAWS_PREFIX}/sources`, async (c) => {
17790
19409
  const customer = requireCustomer(c);
17791
19410
  return c.json({ sources: await clipRecords.listSources(customer.id) });
17792
19411
  });
17793
19412
  // GET /clips/presets — built-in + saved presets.
17794
- app.get(`${CLIPS_PREFIX}/presets`, async (c) => {
19413
+ app.get(`${RAWS_PREFIX}/presets`, async (c) => {
17795
19414
  const customer = requireCustomer(c);
17796
19415
  return c.json({ presets: await clipRecords.listPresets(customer.id) });
17797
19416
  });
17798
19417
  // POST /clips/presets — save a custom preset from criteria or a NL query.
17799
- app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
19418
+ app.post(`${RAWS_PREFIX}/presets`, async (c) => {
17800
19419
  const customer = requireCustomer(c);
17801
19420
  try {
17802
19421
  const body = (await c.req.json());
@@ -17824,7 +19443,7 @@ app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
17824
19443
  }
17825
19444
  });
17826
19445
  // DELETE /clips/presets/:presetId — delete a saved (non-builtin) preset.
17827
- app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
19446
+ app.delete(`${RAWS_PREFIX}/presets/:presetId`, async (c) => {
17828
19447
  const customer = requireCustomer(c);
17829
19448
  const id = c.req.param("presetId");
17830
19449
  if (id.startsWith("preset_") && BUILTIN_CLIP_PRESET_IDS.has(id)) {
@@ -17834,7 +19453,7 @@ app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
17834
19453
  return c.json({ ok: true });
17835
19454
  });
17836
19455
  // GET /clips/:clipId/download — presigned redirect to the clip mp4.
17837
- app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
19456
+ app.get(`${RAWS_PREFIX}/:clipId/download`, async (c) => {
17838
19457
  const customer = requireCustomer(c);
17839
19458
  const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
17840
19459
  if (!clip)
@@ -17845,7 +19464,7 @@ app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
17845
19464
  return redirect(c, url, 302);
17846
19465
  });
17847
19466
  // GET /clips/:clipId — one clip (+ presigned view/thumbnail urls).
17848
- app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
19467
+ app.get(`${RAWS_PREFIX}/:clipId`, async (c) => {
17849
19468
  const customer = requireCustomer(c);
17850
19469
  const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
17851
19470
  if (!clip)
@@ -17853,7 +19472,7 @@ app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
17853
19472
  return c.json({ clip: await serializeClip(clip) });
17854
19473
  });
17855
19474
  // DELETE /clips/:clipId — remove a clip record (+ vector).
17856
- app.delete(`${CLIPS_PREFIX}/:clipId`, async (c) => {
19475
+ app.delete(`${RAWS_PREFIX}/:clipId`, async (c) => {
17857
19476
  const customer = requireCustomer(c);
17858
19477
  await clipRecords.deleteClip(customer.id, c.req.param("clipId"));
17859
19478
  return c.json({ ok: true });