@mevdragon/vidfarm-devcli 0.18.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 (53) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +41 -4
  2. package/SKILL.director.md +17 -3
  3. package/SKILL.platform.md +3 -3
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1550 -187
  7. package/dist/src/cli.js +227 -7
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +52 -9
  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 +1 -1
  20. package/dist/src/reskin/calendar-page.js +2 -1
  21. package/dist/src/reskin/chat-page.js +319 -72
  22. package/dist/src/reskin/discover-page.js +486 -31
  23. package/dist/src/reskin/document.js +1171 -384
  24. package/dist/src/reskin/help-page.js +1 -0
  25. package/dist/src/reskin/inpaint-page.js +10 -4
  26. package/dist/src/reskin/library-page.js +918 -220
  27. package/dist/src/reskin/login-page.js +6 -6
  28. package/dist/src/reskin/pricing-page.js +2 -0
  29. package/dist/src/reskin/settings-page.js +55 -10
  30. package/dist/src/reskin/theme.js +337 -17
  31. package/dist/src/services/clip-curation/gemini.js +5 -0
  32. package/dist/src/services/clip-curation/hunt.js +79 -1
  33. package/dist/src/services/clip-curation/index.js +2 -1
  34. package/dist/src/services/clip-curation/local-agent.js +4 -3
  35. package/dist/src/services/clip-curation/media-select.js +85 -0
  36. package/dist/src/services/clip-curation/query.js +5 -1
  37. package/dist/src/services/clip-curation/refine.js +50 -20
  38. package/dist/src/services/clip-curation/scan.js +10 -3
  39. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  40. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  41. package/dist/src/services/clip-records.js +14 -1
  42. package/dist/src/services/clip-search.js +43 -13
  43. package/dist/src/services/file-directory.js +114 -0
  44. package/dist/src/services/storage.js +24 -1
  45. package/dist/src/services/upstream.js +5 -5
  46. package/dist/src/template-editor-shell.js +16 -2
  47. package/package.json +1 -1
  48. package/public/assets/discover-client-app.js +1 -0
  49. package/public/assets/file-directory-app.js +2 -0
  50. package/public/assets/homepage-client-app.js +12 -12
  51. package/public/assets/page-runtime-client-app.js +24 -24
  52. package/src/assets/favicon.ico +0 -0
  53. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/app.js CHANGED
@@ -35,6 +35,8 @@ 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";
@@ -846,7 +849,7 @@ function consumeLoginReturnPath(c) {
846
849
  function resolvePostLoginRedirect(c, customer) {
847
850
  const returnPath = consumeLoginReturnPath(c) ?? sanitizeLoginReturnPath(c.req.query("return_to"));
848
851
  if (!returnPath) {
849
- return withAccountQuery("/settings", customer.id);
852
+ return withAccountQuery("/settings/profile", customer.id);
850
853
  }
851
854
  const parsed = new URL(returnPath, "https://vidfarm.local");
852
855
  if (parsed.pathname === "/settings" || parsed.pathname.startsWith("/settings/")) {
@@ -861,6 +864,10 @@ function setSettingsFlash(c, input) {
861
864
  function clearSettingsFlash(c) {
862
865
  appendCookie(c, `${SETTINGS_FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${cookieSecureAttribute()}`);
863
866
  }
867
+ const SETTINGS_TABS = ["profile", "wallet", "channels", "developer"];
868
+ function isSettingsTab(value) {
869
+ return value === "profile" || value === "wallet" || value === "channels" || value === "developer";
870
+ }
864
871
  function consumeSettingsFlash(c) {
865
872
  const cookies = parseCookieHeader(c.req.header("cookie"));
866
873
  const raw = cookies.get(SETTINGS_FLASH_COOKIE);
@@ -1048,19 +1055,15 @@ function parseTracerList(searchParams) {
1048
1055
  return output.slice(0, 24);
1049
1056
  }
1050
1057
  function redirectToSettings(c, input) {
1051
- if (input?.notice || input?.error || input?.tab) {
1052
- 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 });
1053
1062
  }
1054
- const params = new URLSearchParams();
1055
1063
  const accountId = input?.accountId ?? getRequestedAccountId(c);
1056
- if (input?.tab) {
1057
- params.set("tab", input.tab);
1058
- }
1059
- if (input?.refreshChannels) {
1060
- params.set("refresh", "channels");
1061
- }
1062
- const suffix = params.size ? `?${params.toString()}` : "";
1063
- 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);
1064
1067
  }
1065
1068
  async function validateFlockPosterApiKey(apiKey) {
1066
1069
  await fetchFlockPosterIntegrations(apiKey);
@@ -2892,6 +2895,16 @@ function sanitizeStorageSubpath(value) {
2892
2895
  .filter((segment) => segment && segment !== "." && segment !== "..")
2893
2896
  .join("/");
2894
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
+ }
2895
2908
  function inferAttachmentContentType(fileName) {
2896
2909
  switch (path.extname(fileName).toLowerCase()) {
2897
2910
  case ".png":
@@ -2969,6 +2982,107 @@ function serializeUserTemporaryFile(c, file) {
2969
2982
  function buildFolderList(items) {
2970
2983
  return Array.from(new Set(items.map((item) => item.folderPath ?? "").filter(Boolean))).sort();
2971
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
+ }
2972
3086
  function isFormFile(value) {
2973
3087
  return Boolean(value
2974
3088
  && typeof value === "object"
@@ -3145,6 +3259,7 @@ function buildPrimitiveEditorDocsRoutes() {
3145
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? }." },
3146
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? }." },
3147
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." },
3148
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." },
3149
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." },
3150
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? }." },
@@ -3609,6 +3724,10 @@ function normalizeEditorChatPath(input) {
3609
3724
  PRIMITIVES_PREFIX,
3610
3725
  APPROVED_POSTS_PREFIX,
3611
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`,
3612
3731
  // Read-only catalog discovery so the copilot can answer "which templates
3613
3732
  // suit my <offer>?": the template feed (supports ?q=&limit=) and the source
3614
3733
  // video / inspiration catalog (supports ?q=&limit=&mine=). Ingest lives at
@@ -3631,6 +3750,10 @@ function normalizeEditorChatPath(input) {
3631
3750
  // scene-selection filter; caption removal stays a per-finished-clip
3632
3751
  // primitive (/api/v1/primitives/videos/remove-captions).
3633
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,
3634
3757
  // Public skill knowledge: the vendored skill packs (load_skill fetches
3635
3758
  // these too) and per-template SKILL.md manifests. Read-only markdown.
3636
3759
  "/skill/",
@@ -4013,19 +4136,24 @@ function browseFilesTextContentType(fileName) {
4013
4136
  }
4014
4137
  function createBrowseFilesTool(input) {
4015
4138
  return tool({
4016
- 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.",
4017
4140
  inputSchema: z.object({
4018
- 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."),
4019
- 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."),
4020
4145
  file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
4021
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."),
4022
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."),
4023
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."),
4024
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."),
4025
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)."),
4026
4154
  explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
4027
4155
  }),
4028
- 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 }) => {
4029
4157
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
4030
4158
  const headers = new Headers();
4031
4159
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -4034,7 +4162,67 @@ function createBrowseFilesTool(input) {
4034
4162
  timeoutController.abort(new Error("Browse files request timed out after 30 seconds."));
4035
4163
  }, 30_000);
4036
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();
4037
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
+ }
4038
4226
  if (action === "write") {
4039
4227
  const name = (file_name ?? "").trim();
4040
4228
  if (!name) {
@@ -4415,8 +4603,13 @@ function createEditorActionTool() {
4415
4603
  "export_composition",
4416
4604
  "generate_layer",
4417
4605
  "set_captions",
4418
- "set_transitions"
4419
- ]).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."),
4420
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."),
4421
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."),
4422
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."),
@@ -4486,6 +4679,22 @@ function createEditorActionTool() {
4486
4679
  resolution: z.string().optional().describe("generate_layer (video): optional resolution (480p|720p|1080p|1K|2K|4K)."),
4487
4680
  generate_audio: z.boolean().optional().describe("generate_layer (video): whether the model should generate an audio track."),
4488
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."),
4489
4698
  explanation: z.string().describe("One-sentence reason shown in the UI.")
4490
4699
  }),
4491
4700
  execute: async (args) => {
@@ -4753,6 +4962,24 @@ function renderHyperframesTimelineRuler(durationSeconds) {
4753
4962
  }).join("");
4754
4963
  return `<div class="hf-timeline-ruler" aria-hidden="true">${marks}</div>`;
4755
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
+ }
4756
4983
  function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4757
4984
  const baseHref = `/${normalizeDraftRelativePath(draftPath)}/`;
4758
4985
  const baseTag = `<base href="${escapeAttribute(baseHref)}">`;
@@ -4828,6 +5055,72 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4828
5055
  }
4829
5056
  return rewritten;
4830
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
+ `;
4831
5124
  async function renderHyperframesStudioEditorPage(input) {
4832
5125
  const accountId = input.customer?.id ?? null;
4833
5126
  const compositionId = input.composition.templateId ?? input.composition.slugId;
@@ -4836,6 +5129,8 @@ async function renderHyperframesStudioEditorPage(input) {
4836
5129
  slugId: input.composition.slugId,
4837
5130
  templateId: input.composition.templateId,
4838
5131
  title: input.composition.title,
5132
+ // "raw" = the user's own new/blank project — never auto-prompt Decompose.
5133
+ origin: input.composition.origin ?? null,
4839
5134
  originalUrl: input.composition.originalUrl ?? null,
4840
5135
  songName: input.composition.songName ?? null,
4841
5136
  songUrl: input.composition.songUrl ?? null,
@@ -4857,17 +5152,19 @@ async function renderHyperframesStudioEditorPage(input) {
4857
5152
  <head>
4858
5153
  <meta charset="UTF-8" />
4859
5154
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4860
- <title>${escapeHtml(input.composition.title)} | Studio</title>
4861
- <link rel="preconnect" href="https://fonts.googleapis.com" />
4862
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
5155
+ <title>${escapeHtml(input.composition.title)} | VidFarm Studio</title>
4863
5156
  <link rel="icon" href="/assets/favicon.ico" />
4864
5157
  <link rel="stylesheet" href="/assets/editor/app.css" />
4865
- <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>
4866
5161
  <script src="/assets/sentry-config.js"></script>
4867
5162
  </head>
4868
- <body>
5163
+ <body data-rk-editor-dock>
4869
5164
  <div id="root"></div>
5165
+ ${renderChatDock("editor")}
4870
5166
  <script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
5167
+ <script>${RESKIN_CHROME_SCRIPT}</script>
4871
5168
  <script type="module" src="/assets/editor/app.js"></script>
4872
5169
  </body>
4873
5170
  </html>`;
@@ -4914,7 +5211,11 @@ app.get("/", async (c) => {
4914
5211
  // Each page is a standalone document with its own CSS; renders sample data so
4915
5212
  // it's viewable without auth. Ported pages are wired here one at a time.
4916
5213
  app.get("/reskin", (c) => c.html(renderReskinIndex()));
4917
- app.get("/settings", async (c) => {
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) {
4918
5219
  const customer = await requireBrowserCustomer(c);
4919
5220
  if (!customer) {
4920
5221
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -4922,11 +5223,12 @@ app.get("/settings", async (c) => {
4922
5223
  }
4923
5224
  const flash = consumeSettingsFlash(c);
4924
5225
  const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
4925
- const activeTab = flash?.tab === "wallet" || flash?.tab === "channels" || flash?.tab === "developer"
4926
- ? flash.tab
4927
- : c.req.query("tab") === "wallet" || c.req.query("tab") === "channels" || c.req.query("tab") === "developer"
4928
- ? c.req.query("tab")
4929
- : "profile";
5226
+ const activeTab = tabOverride
5227
+ ?? (isSettingsTab(flash?.tab)
5228
+ ? flash.tab
5229
+ : isSettingsTab(c.req.query("tab"))
5230
+ ? c.req.query("tab")
5231
+ : "profile");
4930
5232
  const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
4931
5233
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
4932
5234
  const [wallet, walletEvents] = await Promise.all([
@@ -4975,8 +5277,22 @@ app.get("/settings", async (c) => {
4975
5277
  })),
4976
5278
  attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
4977
5279
  }));
4978
- });
4979
- // Library hosts two tabs at distinct URLs (approved projects + mined clips).
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).
4980
5296
  app.get("/library", (c) => redirect(c, "/library/approved"));
4981
5297
  const reskinLibraryHandler = async (c, tab) => {
4982
5298
  const customer = await getPreferredBrowserCustomer(c);
@@ -4984,6 +5300,20 @@ const reskinLibraryHandler = async (c, tab) => {
4984
5300
  setLoginReturnPath(c, currentPathWithSearch(c));
4985
5301
  return redirect(c, "/login");
4986
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
+ }
4987
5317
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
4988
5318
  const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
4989
5319
  const scheduleChannels = [
@@ -5001,23 +5331,114 @@ const reskinLibraryHandler = async (c, tab) => {
5001
5331
  name: customer.name,
5002
5332
  email: customer.email,
5003
5333
  posts,
5004
- schedule: { channels: scheduleChannels, connectHref: "/settings?tab=channels&connect=flockposter" },
5334
+ schedule: { channels: scheduleChannels, connectHref: "/settings/channels?connect=flockposter" },
5005
5335
  editorChat: null
5006
5336
  }));
5007
5337
  };
5008
5338
  app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
5009
5339
  app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
5010
- app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, folded in
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
5011
5342
  app.get("/library/clips", (c) => redirect(c, "/library/raws")); // renamed Clips → Raws
5012
- // Discover has three modes selected via ?view= (feed default / swipe / categories).
5013
- // Path segments are avoided because /discover/feed, /discover/swipe/* are JSON APIs.
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
+ };
5014
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).
5015
5428
  const view = c.req.query("view");
5016
- const mode = view === "swipe" || view === "categories" ? view : "feed";
5017
- const customer = await getOptionalPreferredBrowserCustomer(c);
5018
- return c.html(renderReskinDiscover(mode, { accountId: customer?.id, isLoggedIn: Boolean(customer) }));
5019
- });
5020
- app.get("/chat", async (c) => {
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) => {
5021
5442
  const customer = await getPreferredBrowserCustomer(c);
5022
5443
  if (!customer) {
5023
5444
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5029,7 +5450,21 @@ app.get("/chat", async (c) => {
5029
5450
  name: customer.name,
5030
5451
  email: customer.email,
5031
5452
  editorChat: await buildChatBrainstormEditorChatBoot({ customer })
5032
- }));
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 }) });
5033
5468
  });
5034
5469
  // /reskin/inpaint — the "Create Image" masked-image editor (reached from the
5035
5470
  // chat page's studio-mode select). Auth-gated like the other tools.
@@ -5065,7 +5500,7 @@ app.get("/calendar", async (c) => {
5065
5500
  posts: [],
5066
5501
  loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
5067
5502
  hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
5068
- connectChannelsHref: "/settings?tab=channels&connect=flockposter"
5503
+ connectChannelsHref: "/settings/channels?connect=flockposter"
5069
5504
  }));
5070
5505
  });
5071
5506
  // Job runs were renamed and folded into the Library "Logs" tab.
@@ -5166,7 +5601,7 @@ app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
5166
5601
  app.get("/reskin/discover", (c) => redirect(c, "/discover"));
5167
5602
  app.get("/reskin/discover/:mode", (c) => {
5168
5603
  const m = c.req.param("mode");
5169
- return redirect(c, m === "swipe" || m === "categories" ? `/discover?view=${m}` : "/discover");
5604
+ return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
5170
5605
  });
5171
5606
  app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5172
5607
  app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
@@ -5177,8 +5612,43 @@ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5177
5612
  app.get("/reskin/help", (c) => redirect(c, "/help"));
5178
5613
  app.get("/reskin/login", (c) => redirect(c, "/login"));
5179
5614
  app.get("/discover", async (c) => renderApprovedHomepage(c));
5180
- app.get("/editor/:templateId", async (c) => {
5181
- 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) {
5182
5652
  const customer = await getOptionalPreferredBrowserCustomer(c);
5183
5653
  if (!templateId.startsWith("template_"))
5184
5654
  return c.notFound();
@@ -5191,7 +5661,7 @@ app.get("/editor/:templateId", async (c) => {
5191
5661
  await seedFromUpstream({
5192
5662
  customerId: customer.id,
5193
5663
  templateId,
5194
- forkId: c.req.query("fork") ?? undefined,
5664
+ forkId: forkParam ?? undefined,
5195
5665
  shareToken: readShareToken(c) ?? undefined
5196
5666
  });
5197
5667
  template = await serverlessRecords.getTemplate(templateId);
@@ -5202,7 +5672,6 @@ app.get("/editor/:templateId", async (c) => {
5202
5672
  // template id doesn't leak to other accounts.
5203
5673
  if (template.visibility === "private" && template.customerId !== customer?.id)
5204
5674
  return c.notFound();
5205
- const forkParam = c.req.query("fork");
5206
5675
  const threadParam = c.req.query("thread");
5207
5676
  // Validate the caller-provided fork. Stale bookmarks / share links keep
5208
5677
  // pointing at forks we've since hard-deleted, and if we blindly pass the
@@ -5279,11 +5748,11 @@ app.get("/editor/:templateId", async (c) => {
5279
5748
  // source of truth for "which chat is active" via localStorage. Injecting
5280
5749
  // the server's most-recent thread into the URL blows away a fresh
5281
5750
  // client-side "New chat" the user just started but hasn't persisted.
5282
- const path = `/editor/${encodeURIComponent(template.id)}?fork=${encodeURIComponent(targetForkId)}`;
5751
+ const path = `/editor/${encodeURIComponent(template.id)}/fork/${encodeURIComponent(targetForkId)}`;
5283
5752
  return redirect(c, path);
5284
5753
  }
5285
5754
  // No fork exists yet for this template + user — redirect to the bare
5286
- // /editor/{templateId} URL so any stale `?fork=` gets stripped and the
5755
+ // /editor/{templateId} URL so any stale fork segment gets stripped and the
5287
5756
  // Decompose modal opens fresh (client-side flow mints a new fork on
5288
5757
  // first save/decompose).
5289
5758
  if (forkParam) {
@@ -5310,6 +5779,21 @@ app.get("/editor/:templateId", async (c) => {
5310
5779
  initialThreadId: threadParam ?? null
5311
5780
  };
5312
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);
5313
5797
  });
5314
5798
  app.get("/editor/:templateId/composition", async (c) => {
5315
5799
  const templateId = c.req.param("templateId");
@@ -5336,6 +5820,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}`, (c) => redirect(c, withAccountQuery("/libr
5336
5820
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/discover"));
5337
5821
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
5338
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")}`));
5339
5824
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
5340
5825
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
5341
5826
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
@@ -5345,8 +5830,24 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyR
5345
5830
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs/history`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs/history"));
5346
5831
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar/feed`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar/feed"));
5347
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
+ });
5348
5839
  app.post(`${ACCOUNT_FRONTEND_PREFIX}/settings/wallet/funding-link`, (c) => dispatchScopedPostToLegacyRoute(c, "/settings/wallet/funding-link"));
5349
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
+ }
5350
5851
  const limit = clampPageLimit(Number(c.req.query("limit") || ""), 100, 200);
5351
5852
  // Optional keyword search so callers (UI, agents, devcli) can ask "which
5352
5853
  // templates suit my <offer>?" and get a ranked subset instead of the full
@@ -5354,66 +5855,12 @@ app.get("/discover/feed", async (c) => {
5354
5855
  // decompose-derived promotions/keywords/summary.
5355
5856
  const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
5356
5857
  const customer = await getOptionalPreferredBrowserCustomer(c);
5357
- const templateEntries = [];
5358
- if (customer) {
5359
- // The caller's own private submissions rank above the public catalog.
5360
- // Finalizing pending inspirations here is what mints the private template
5361
- // as soon as the download job completes — the feed poll doubles as the
5362
- // completion check.
5363
- const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id))
5364
- .filter((inspiration) => inspiration.visibility === "private");
5365
- const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
5366
- const ownTemplates = (await serverlessRecords.listTemplatesForCustomer(customer.id))
5367
- .filter((template) => template.visibility === "private")
5368
- .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
5369
- const pending = finalized
5370
- .filter((inspiration) => !inspiration.templateId)
5371
- .filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
5372
- .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
5373
- for (const inspiration of pending) {
5374
- templateEntries.push(buildPendingInspirationHomepageEntry(inspiration));
5375
- }
5376
- for (const template of ownTemplates.filter((template) => recordMatchesSearchQuery(template, query))) {
5377
- const entry = buildTemplateHomepageEntry(template);
5378
- if (entry)
5379
- templateEntries.push(entry);
5380
- }
5381
- }
5382
- const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
5383
- for (const template of templates) {
5384
- // Curated "Popular" picks live only under the Popular toggle
5385
- // (GET /discover/popular) — keep them out of the continuously-grown Feed
5386
- // so the two views stay distinct.
5387
- if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
5388
- continue;
5389
- const entry = buildTemplateHomepageEntry(template);
5390
- if (entry)
5391
- templateEntries.push(entry);
5392
- }
5393
- // Cloud passthrough (vidfarm serve): the local records are just the working
5394
- // set of seeded templates, so surface the upstream (cloud) catalog behind
5395
- // them. Local entries win on id conflicts — a seeded template also exists
5396
- // upstream and the local copy reflects any local edits. Fail-soft: an
5397
- // unreachable upstream degrades to the local-only feed.
5398
- if (isUpstreamEnabled()) {
5399
- const search = new URLSearchParams({ limit: String(limit) });
5400
- if (query)
5401
- search.set("q", query);
5402
- const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
5403
- const seen = new Set(templateEntries.map((entry) => entry.templateId));
5404
- for (const entry of upstreamFeed) {
5405
- if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
5406
- templateEntries.push(entry);
5407
- seen.add(entry.templateId);
5408
- }
5409
- }
5410
- }
5411
- // The public feed is identical for every caller, but a logged-in response
5412
- // interleaves the caller's private templates — never let a shared cache
5413
- // 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.
5414
5861
  c.header("cache-control", customer ? "private, max-age=10" : "public, max-age=60");
5415
5862
  return c.json({
5416
- templates: templateEntries,
5863
+ templates: entries,
5417
5864
  next_cursor: null
5418
5865
  });
5419
5866
  });
@@ -5562,8 +6009,40 @@ function injectSwipePreviewFit(doc, cardId) {
5562
6009
  function play() {
5563
6010
  try { if (window.__player) { window.__player.play(); } } catch (e) {}
5564
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
+ }
5565
6043
  applySound(false);
5566
6044
  play();
6045
+ kickBackingVideo();
5567
6046
  var lastWant = false;
5568
6047
  var stalled = 0;
5569
6048
  var mutedFallback = false;
@@ -5572,7 +6051,7 @@ function injectSwipePreviewFit(doc, cardId) {
5572
6051
  var want = wantSound();
5573
6052
  if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
5574
6053
  applySound(want && !mutedFallback);
5575
- if (!window.__player || !window.__player.getDuration) { return; }
6054
+ if (!window.__player || !window.__player.getDuration) { kickBackingVideo(); return; }
5576
6055
  var t = window.__player.getTime(), d = window.__player.getDuration();
5577
6056
  if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
5578
6057
  else if (!window.__player.isPlaying()) {
@@ -5582,6 +6061,7 @@ function injectSwipePreviewFit(doc, cardId) {
5582
6061
  // the preview never freezes on a frame.
5583
6062
  if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
5584
6063
  play();
6064
+ kickBackingVideo();
5585
6065
  } else { stalled = 0; }
5586
6066
  } catch (e) {}
5587
6067
  }, 400);
@@ -6091,6 +6571,36 @@ app.post("/discover/templates", async (c) => {
6091
6571
  });
6092
6572
  return c.json(serializeInspiration(inspiration), 202);
6093
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
+ });
6094
6604
  // Delete a private discover entry the caller owns. Accepts either the minted
6095
6605
  // template id (template_...) or the raw inspiration id (inspiration_..., i.e.
6096
6606
  // a still-processing/failed card) and removes both records. Public catalog
@@ -6144,6 +6654,51 @@ app.delete("/discover/templates/:entryId", async (c) => {
6144
6654
  // on its owner's /discover as a placeholder card so the Add Template flow has
6145
6655
  // immediate feedback. It has no template yet, so the editor CTA is disabled
6146
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
+ }
6147
6702
  function buildPendingInspirationHomepageEntry(inspiration) {
6148
6703
  return {
6149
6704
  // Uploads: explicit title (or file name, stored at submit time) outranks
@@ -6538,6 +7093,8 @@ app.get("/assets/sentry-config.js", (c) => {
6538
7093
  });
6539
7094
  app.get("/assets/homepage-client-app.js", (c) => serveRootJsBundle(c, "public/assets/homepage-client-app.js"));
6540
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"));
6541
7098
  function serveRootJsBundle(c, relativePath) {
6542
7099
  const filePath = resolveRootFileCandidates(relativePath).find((candidate) => existsSync(candidate));
6543
7100
  if (!filePath) {
@@ -7263,7 +7820,11 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/composition.html`, async (c) => {
7263
7820
  const html = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "composition.html"));
7264
7821
  if (!html)
7265
7822
  return c.json({ ok: false, error: "Composition missing" }, 404);
7266
- 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" });
7267
7828
  }
7268
7829
  catch (error) {
7269
7830
  return forkAccessErrorResponse(c, error);
@@ -9827,6 +10388,101 @@ function serializeInspiration(inspiration) {
9827
10388
  updated_at: inspiration.updatedAt
9828
10389
  };
9829
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
+ }
9830
10486
  async function finalizeInspirationIfReady(inspiration) {
9831
10487
  if (inspiration.status !== "downloading" || !inspiration.downloadJobId)
9832
10488
  return inspiration;
@@ -9856,38 +10512,12 @@ async function finalizeInspirationIfReady(inspiration) {
9856
10512
  songUrl: songUrl ?? null
9857
10513
  }
9858
10514
  })) ?? inspiration;
9859
- // Private (user-added) inspirations mint their template as soon as the
9860
- // download lands so the card on the owner's /discover becomes openable.
9861
- // Public inspirations keep the existing behavior: templates only come
9862
- // from the admin ingest flow.
9863
- if (ready.visibility === "private" && !ready.templateId && ready.videoUrl) {
9864
- const template = await serverlessRecords.createTemplate({
9865
- inspirationId: ready.id,
9866
- customerId: ready.customerId,
9867
- originalUrl: ready.originalUrl,
9868
- sourceHost: ready.sourceHost,
9869
- // No host/"Untitled" fallback: a template without an explicit title or
9870
- // tagline displays as its template id on /discover. For uploads the
9871
- // manifest title IS the caller's explicit title, so it outranks the
9872
- // tagline; URL ingests keep tagline-first (manifest title is just the
9873
- // social post's own caption).
9874
- title: (ready.sourceHost === "upload"
9875
- ? (ready.title || ready.trendTagline)
9876
- : (ready.trendTagline || ready.title)) || null,
9877
- thumbnailUrl: ready.thumbnailUrl,
9878
- videoUrl: ready.videoUrl,
9879
- durationSeconds: ready.durationSeconds,
9880
- songName: ready.songName,
9881
- songUrl: ready.songUrl,
9882
- visibility: "private",
9883
- origin: ready.origin ?? "inspiration",
9884
- notes: ready.notes
9885
- });
9886
- ready = (await serverlessRecords.updateInspiration({
9887
- inspirationId: ready.id,
9888
- patch: { templateId: template.id }
9889
- })) ?? ready;
9890
- }
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);
9891
10521
  return ready;
9892
10522
  }
9893
10523
  if (job.status === "failed" || job.status === "cancelled") {
@@ -10826,7 +11456,7 @@ app.get("/library", async (c) => {
10826
11456
  posts,
10827
11457
  schedule: {
10828
11458
  channels: scheduleChannels,
10829
- connectHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
11459
+ connectHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
10830
11460
  },
10831
11461
  editorChat: await buildLibraryEditorChatBoot({
10832
11462
  customer,
@@ -10997,7 +11627,7 @@ app.get("/calendar", async (c) => {
10997
11627
  posts: [],
10998
11628
  loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
10999
11629
  hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
11000
- connectChannelsHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
11630
+ connectChannelsHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
11001
11631
  }));
11002
11632
  });
11003
11633
  app.get("/agency", async (c) => {
@@ -11307,7 +11937,7 @@ app.get("/email-channels/confirm", async (c) => {
11307
11937
  <h1 style="overflow-wrap: anywhere;">${escapeHtml(title)}</h1>
11308
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>
11309
11939
  <div class="toolbar">
11310
- <a class="button" href="/settings?tab=channels">Go to settings</a>
11940
+ <a class="button" href="/settings/channels">Go to settings</a>
11311
11941
  <a class="button secondary" href="/discover">Back to Discover</a>
11312
11942
  </div>
11313
11943
  </section>
@@ -13427,38 +14057,58 @@ function countReadyPostMediaMatches(job, post) {
13427
14057
  return matches;
13428
14058
  }
13429
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.
13430
14094
  const tracer = post.tracer?.trim();
13431
- const buildDocsHref = (templateId, tracers) => {
13432
- const params = new URLSearchParams();
13433
- if (accountId) {
13434
- params.set(ACCOUNT_QUERY_PARAM, accountId);
13435
- }
13436
- if (tracers) {
13437
- 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);
13438
14108
  }
13439
- const suffix = params.size ? `?${params.toString()}` : "";
13440
- return `/templates/${encodeURIComponent(templateId)}/editor/docs${suffix}`;
13441
- };
13442
- if (!tracer) {
13443
- const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
13444
- return firstTemplate ? buildDocsHref(firstTemplate.id) : withAccountQuery("/templates", accountId);
13445
- }
13446
- const candidates = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
13447
- .filter((job) => !job.templateId.startsWith("primitive:") && Boolean(templateRegistry.get(job.templateId)));
13448
- const matchingJob = candidates
13449
- .map((job) => ({
13450
- job,
13451
- score: (countReadyPostMediaMatches(job, post) * 1000)
13452
- + (job.parentJobId ? 0 : 120)
13453
- + (job.status === "succeeded" ? 30 : 0)
13454
- }))
13455
- .sort((a, b) => (b.score - a.score
13456
- || 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;
13457
- if (matchingJob) {
13458
- return buildDocsHref(matchingJob.templateId, tracer);
13459
14109
  }
13460
- const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
13461
- 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();
13462
14112
  }
13463
14113
  async function renderReadyPostPage(c, post, viewer) {
13464
14114
  const visiblePost = serializeReadyPost(c, post, { password: viewer.hasPasswordAccess ? viewer.password : null });
@@ -13488,7 +14138,7 @@ async function renderReadyPostPage(c, post, viewer) {
13488
14138
  error: scheduleChannels.error,
13489
14139
  feedEndpoint: viewer.canManage && viewer.customer ? withAccountQuery("/calendar/feed", viewer.customer.id) : null,
13490
14140
  scheduleEndpoint: viewer.canManage && viewer.customer ? scopedApprovedPostSchedulePath(viewer.customer.id, post.id) : null,
13491
- 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",
13492
14142
  userId: viewer.customer?.id ?? null
13493
14143
  };
13494
14144
  const shareUrl = viewer.canView
@@ -14308,6 +14958,282 @@ async function renderReadyPostPage(c, post, viewer) {
14308
14958
  gap: 8px;
14309
14959
  margin-top: 4px;
14310
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
+ }
14311
15237
  `;
14312
15238
  const readyPostBody = viewer.canView ? `
14313
15239
  <section class="ready-post-frame">
@@ -14349,10 +15275,19 @@ async function renderReadyPostPage(c, post, viewer) {
14349
15275
  </footer>
14350
15276
  </div>
14351
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;
14352
15280
  return c.html(renderPageShell({
14353
15281
  title: post.title ?? "Approved post",
14354
15282
  shellClass: "ready-post-shell",
15283
+ description: socialDescription,
15284
+ social: {
15285
+ image: socialImage,
15286
+ url: shareUrl,
15287
+ type: primaryVideo ? "video.other" : "website"
15288
+ },
14355
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">
14356
15291
  ${readyPostBody}
14357
15292
  <script id="ready-post-data" type="application/json">${escapeJsonForHtml(serialized)}</script>
14358
15293
  <script id="ready-post-zip-data" type="application/json">${escapeJsonForHtml({
@@ -16369,7 +17304,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/presign`, async (c) => {
16369
17304
  const body = userTemporaryFilePresignSchema.parse(await c.req.json());
16370
17305
  const fileName = sanitizeFileName(body.file_name);
16371
17306
  const contentType = body.content_type?.trim() || inferAttachmentContentType(fileName);
16372
- const folderPath = sanitizeStorageSubpath(body.folder_path);
17307
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(body.folder_path));
16373
17308
  if (!isAllowedAttachment(fileName, contentType)) {
16374
17309
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16375
17310
  }
@@ -16427,7 +17362,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
16427
17362
  }
16428
17363
  const fileName = sanitizeFileName(file.name || "upload.bin");
16429
17364
  const contentType = file.type || inferAttachmentContentType(fileName);
16430
- const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
17365
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(String(formData.get("folder_path") ?? "")));
16431
17366
  if (!isAllowedAttachment(fileName, contentType)) {
16432
17367
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16433
17368
  }
@@ -16549,6 +17484,189 @@ app.delete(`${USER_PREFIX}/me/temporary-files/:fileId`, async (c) => {
16549
17484
  await serverlessRecords.deleteUserTemporaryFile(customer.id, file.id);
16550
17485
  return c.json({ ok: true, file_id: file.id });
16551
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
+ });
16552
17670
  app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
16553
17671
  try {
16554
17672
  const customer = requireCustomer(c);
@@ -17314,18 +18432,73 @@ app.get(RAWS_PREFIX, async (c) => {
17314
18432
  editorChat: await buildClipsEditorChatBoot({ customer })
17315
18433
  }));
17316
18434
  });
17317
- // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
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).
17318
18441
  app.get(`${RAWS_PREFIX}/feed`, async (c) => {
17319
18442
  const customer = requireCustomer(c);
17320
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);
17321
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);
17322
18460
  let clips = await clipRecords.listClips(customer.id, {
17323
18461
  sourceVideoId: c.req.query("source") || undefined,
17324
- limit: q ? 5000 : limit
18462
+ limit: loadLimit
17325
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
+ }
17326
18474
  if (q)
17327
- clips = clips.filter((clip) => clipKeywordMatch(clip, q)).slice(0, limit);
17328
- 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
+ }
17329
18502
  });
17330
18503
  // POST /clips/search — hybrid structured + semantic search.
17331
18504
  app.post(`${RAWS_PREFIX}/search`, async (c) => {
@@ -17444,6 +18617,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17444
18617
  const customer = requireCustomer(c);
17445
18618
  try {
17446
18619
  const body = (await c.req.json());
18620
+ const importOnly = body.import_only === true;
17447
18621
  const guidancePrompt = body.prompt?.trim() || "";
17448
18622
  // Build the hunt spec: explicit structured fields win, the free "what to
17449
18623
  // clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
@@ -17454,7 +18628,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17454
18628
  if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
17455
18629
  return c.json({ error: "That doesn't look like a valid video URL." }, 400);
17456
18630
  }
17457
- const huntSpec = normalizeHuntSpec({
18631
+ let huntSpec = normalizeHuntSpec({
17458
18632
  ...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
17459
18633
  ...(body.ranges != null ? { ranges: body.ranges } : {}),
17460
18634
  ...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
@@ -17475,8 +18649,15 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17475
18649
  huntSpec.aspect = parsedPrompt.aspect;
17476
18650
  if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
17477
18651
  huntSpec.avoid_text = true;
18652
+ if (!huntSpec.max_clips && parsedPrompt.max_clips)
18653
+ huntSpec.max_clips = parsedPrompt.max_clips;
17478
18654
  if (!huntSpec.source_url && sourceUrl)
17479
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);
17480
18661
  // Uploaded-source hunts: resolve a temp-folder file (the canonical home for
17481
18662
  // hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
17482
18663
  let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
@@ -17551,6 +18732,33 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17551
18732
  pipeline: input.pipeline,
17552
18733
  pipeline_deployed: cloudPipeline
17553
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
+ }
17554
18762
  if (cloudPipeline) {
17555
18763
  // ── Deployed pipeline (Lambda + Step Functions): BYOK API keys only ──
17556
18764
  if (choice === "agent") {
@@ -17780,7 +18988,11 @@ app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
17780
18988
  });
17781
18989
  function looksLikeDirectVideoUrl(url) {
17782
18990
  try {
17783
- 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);
17784
18996
  }
17785
18997
  catch {
17786
18998
  return false;
@@ -17789,10 +19001,13 @@ function looksLikeDirectVideoUrl(url) {
17789
19001
  // Local URL ingest for serve-box hunts: direct video links download straight;
17790
19002
  // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI
17791
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.
17792
19006
  async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17793
19007
  if (!sourceUrl)
17794
19008
  throw new Error("Clip scan needs a source video.");
17795
19009
  let mediaUrl = sourceUrl;
19010
+ let title = null;
17796
19011
  if (!looksLikeDirectVideoUrl(sourceUrl)) {
17797
19012
  const apiKey = (config.RAPIDAPI_KEY || "").trim();
17798
19013
  if (!apiKey) {
@@ -17810,11 +19025,14 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17810
19025
  if (!lookupRes.ok)
17811
19026
  throw new Error(`Video download lookup failed with HTTP ${lookupRes.status}.`);
17812
19027
  const lookup = (await lookupRes.json());
17813
- const playable = (lookup.medias ?? []).filter((m) => m.url && m.videoAvailable !== false && (m.type ? /video/i.test(m.type) : true));
17814
- 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 ?? []);
17815
19032
  if (!chosen?.url)
17816
19033
  throw new Error("Video download lookup returned no playable MP4 media URL.");
17817
19034
  mediaUrl = chosen.url;
19035
+ title = typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null;
17818
19036
  }
17819
19037
  const localPath = path.join(workDir, "ingest", "source.mp4");
17820
19038
  mkdirSync(path.dirname(localPath), { recursive: true });
@@ -17822,7 +19040,148 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17822
19040
  if (!res.ok || !res.body)
17823
19041
  throw new Error(`Source video download failed with HTTP ${res.status}.`);
17824
19042
  await streamPipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
17825
- 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
+ }
17826
19185
  }
17827
19186
  // In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
17828
19187
  // Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
@@ -17843,7 +19202,7 @@ async function runClipScanInProcess(input) {
17843
19202
  };
17844
19203
  try {
17845
19204
  mkdirSync(workRoot, { recursive: true });
17846
- const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot));
19205
+ const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot)).videoPath;
17847
19206
  const probe = await probeVideo(videoPath);
17848
19207
  await touchSource({ status: "scanning", duration_sec: probe.duration_sec });
17849
19208
  const spec = input.huntSpec;
@@ -17860,6 +19219,9 @@ async function runClipScanInProcess(input) {
17860
19219
  ? { aspect: spec.aspect, focus: normalizeCropFocus(spec.crop_focus) }
17861
19220
  : undefined
17862
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);
17863
19225
  let stored = 0;
17864
19226
  await scanVideo({
17865
19227
  ctx,
@@ -17867,6 +19229,7 @@ async function runClipScanInProcess(input) {
17867
19229
  probe,
17868
19230
  windows: spec.windows,
17869
19231
  durationBand: spec.duration_band ?? null,
19232
+ targetClipCount,
17870
19233
  sourceVideoId: input.sourceVideoId,
17871
19234
  sourceFilename: input.filename,
17872
19235
  ownerId: input.ownerId,