@mevdragon/vidfarm-devcli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +43 -4
  4. package/SKILL.director.md +190 -18
  5. package/SKILL.platform.md +8 -4
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/demo/dist/favicon.ico +0 -0
  9. package/dist/src/app.js +3031 -300
  10. package/dist/src/cli.js +1549 -69
  11. package/dist/src/config.js +7 -0
  12. package/dist/src/devcli/clip-store.js +29 -2
  13. package/dist/src/devcli/clips.js +55 -9
  14. package/dist/src/devcli/composition-edit.js +658 -8
  15. package/dist/src/devcli/local-backend.js +123 -0
  16. package/dist/src/devcli/migrate-local.js +140 -0
  17. package/dist/src/devcli/sync.js +311 -0
  18. package/dist/src/devcli/timeline-edit.js +490 -0
  19. package/dist/src/editor-chat.js +56 -17
  20. package/dist/src/frontend/discover-client.js +130 -0
  21. package/dist/src/frontend/discover-store.js +23 -0
  22. package/dist/src/frontend/file-directory.js +995 -0
  23. package/dist/src/frontend/homepage-view.js +3 -3
  24. package/dist/src/frontend/template-editor-chat.js +28 -22
  25. package/dist/src/landing-page.js +24 -7
  26. package/dist/src/page-shell.js +26 -2
  27. package/dist/src/primitive-registry.js +409 -4
  28. package/dist/src/reskin/agency-page.js +1 -1
  29. package/dist/src/reskin/calendar-page.js +2 -1
  30. package/dist/src/reskin/chat-page.js +420 -85
  31. package/dist/src/reskin/discover-page.js +731 -39
  32. package/dist/src/reskin/document.js +1311 -387
  33. package/dist/src/reskin/help-page.js +1 -0
  34. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  35. package/dist/src/reskin/inpaint-page.js +2459 -446
  36. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  37. package/dist/src/reskin/library-page.js +1168 -228
  38. package/dist/src/reskin/login-page.js +6 -6
  39. package/dist/src/reskin/pricing-page.js +2 -0
  40. package/dist/src/reskin/settings-page.js +55 -10
  41. package/dist/src/reskin/theme.js +365 -20
  42. package/dist/src/services/billing.js +4 -0
  43. package/dist/src/services/clip-curation/gemini.js +5 -0
  44. package/dist/src/services/clip-curation/hunt.js +81 -1
  45. package/dist/src/services/clip-curation/index.js +2 -1
  46. package/dist/src/services/clip-curation/local-agent.js +4 -3
  47. package/dist/src/services/clip-curation/media-select.js +85 -0
  48. package/dist/src/services/clip-curation/query.js +5 -1
  49. package/dist/src/services/clip-curation/refine.js +50 -20
  50. package/dist/src/services/clip-curation/scan.js +10 -3
  51. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  52. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  53. package/dist/src/services/clip-records.js +42 -1
  54. package/dist/src/services/clip-search.js +43 -13
  55. package/dist/src/services/file-directory.js +117 -0
  56. package/dist/src/services/hyperframes.js +283 -3
  57. package/dist/src/services/serverless-records.js +43 -0
  58. package/dist/src/services/storage.js +47 -2
  59. package/dist/src/services/upstream.js +5 -5
  60. package/dist/src/template-editor-shell.js +16 -2
  61. package/package.json +1 -1
  62. package/public/assets/discover-client-app.js +1 -0
  63. package/public/assets/file-directory-app.js +2 -0
  64. package/public/assets/homepage-client-app.js +12 -12
  65. package/public/assets/page-runtime-client-app.js +24 -24
  66. package/public/assets/placeholders/scene-placeholder.png +0 -0
  67. package/src/assets/favicon.ico +0 -0
  68. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/app.js CHANGED
@@ -31,10 +31,14 @@ import { renderReskinDiscover } from "./reskin/discover-page.js";
31
31
  import { renderReskinChat } from "./reskin/chat-page.js";
32
32
  import { renderReskinCalendar } from "./reskin/calendar-page.js";
33
33
  import { renderReskinInpaint } from "./reskin/inpaint-page.js";
34
+ import { renderReskinInpaintVideo } from "./reskin/inpaint-video-page.js";
35
+ import { renderReskinInpaintClipper } from "./reskin/inpaint-clipper-page.js";
34
36
  import { renderReskinAgency } from "./reskin/agency-page.js";
35
37
  import { renderReskinPricing } from "./reskin/pricing-page.js";
36
38
  import { renderReskinLogin } from "./reskin/login-page.js";
37
39
  import { renderReskinHelp } from "./reskin/help-page.js";
40
+ import { renderChatDock, RESKIN_CHROME_SCRIPT } from "./reskin/document.js";
41
+ import { RESKIN_CSS, RESKIN_FONT_LINKS } from "./reskin/theme.js";
38
42
  import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
39
43
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
40
44
  import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
@@ -60,7 +64,7 @@ import { RateLimitExceededError, RateLimitService } from "./services/rate-limits
60
64
  import { ServerlessProviderKeyService } from "./services/serverless-provider-keys.js";
61
65
  import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
62
66
  import { parseHTML } from "linkedom";
63
- import { joinStorageKey, StorageService } from "./services/storage.js";
67
+ import { joinStorageKey, storage as sharedStorage } from "./services/storage.js";
64
68
  import { buildCaptionCues, cuesFromText } from "./services/captions.js";
65
69
  import { defaultSpeechModelFor as speechModelFor, transcribeSpeechWithKey } from "./services/speech.js";
66
70
  import { insertCaptionLayers } from "./devcli/composition-edit.js";
@@ -68,12 +72,13 @@ import { loadSpeechAudioLocally, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./devc
68
72
  import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
69
73
  import { clipRecords, makeUserPreset } from "./services/clip-records.js";
70
74
  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";
72
- import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
75
+ import { ATTACHMENTS_FOLDER_SCOPE, buildDirectoryPath, DIRECTORY_ROOTS, DIRECTORY_ROOT_KEYS, folderItem, immediateChildFolders, normalizeFolderPath, parseDirectoryPath } from "./services/file-directory.js";
76
+ import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractClip, extractThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
77
+ import { analyzeCastMembers, annotateScenesFromSource, buildBlankCompositionHtml, buildSmartDecomposedHtml, DEFAULT_EDITOR_HARNESS, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
73
78
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
74
79
  import { createPrimitiveJobContext } from "./primitive-context.js";
75
80
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
76
- import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
81
+ import { MediaDurationExceededError, extractVideoFrameAsset, probeMediaAsset } from "./services/media-processing.js";
77
82
  import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
78
83
  import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
79
84
  import { TemplateSourceService } from "./services/template-sources.js";
@@ -89,7 +94,7 @@ const providers = new ProviderService();
89
94
  const rateLimits = new RateLimitService();
90
95
  const serverlessProviderKeys = new ServerlessProviderKeyService();
91
96
  const serverlessTemplateConfigs = new ServerlessTemplateConfigService();
92
- const storage = new StorageService();
97
+ const storage = sharedStorage;
93
98
  const templateSources = new TemplateSourceService();
94
99
  const hyperframes = new HyperframesService();
95
100
  const API_PREFIX = "/api/v1";
@@ -846,7 +851,7 @@ function consumeLoginReturnPath(c) {
846
851
  function resolvePostLoginRedirect(c, customer) {
847
852
  const returnPath = consumeLoginReturnPath(c) ?? sanitizeLoginReturnPath(c.req.query("return_to"));
848
853
  if (!returnPath) {
849
- return withAccountQuery("/settings", customer.id);
854
+ return withAccountQuery("/settings/profile", customer.id);
850
855
  }
851
856
  const parsed = new URL(returnPath, "https://vidfarm.local");
852
857
  if (parsed.pathname === "/settings" || parsed.pathname.startsWith("/settings/")) {
@@ -861,6 +866,10 @@ function setSettingsFlash(c, input) {
861
866
  function clearSettingsFlash(c) {
862
867
  appendCookie(c, `${SETTINGS_FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${cookieSecureAttribute()}`);
863
868
  }
869
+ const SETTINGS_TABS = ["profile", "wallet", "channels", "developer"];
870
+ function isSettingsTab(value) {
871
+ return value === "profile" || value === "wallet" || value === "channels" || value === "developer";
872
+ }
864
873
  function consumeSettingsFlash(c) {
865
874
  const cookies = parseCookieHeader(c.req.header("cookie"));
866
875
  const raw = cookies.get(SETTINGS_FLASH_COOKIE);
@@ -1048,19 +1057,15 @@ function parseTracerList(searchParams) {
1048
1057
  return output.slice(0, 24);
1049
1058
  }
1050
1059
  function redirectToSettings(c, input) {
1051
- if (input?.notice || input?.error || input?.tab) {
1052
- setSettingsFlash(c, input);
1060
+ // Notice/error still travel in a short-lived flash cookie; the tab now travels
1061
+ // in the path (/settings/<tab>) so the URL matches the panel that's shown.
1062
+ if (input?.notice || input?.error) {
1063
+ setSettingsFlash(c, { notice: input.notice, error: input.error, tab: input.tab });
1053
1064
  }
1054
- const params = new URLSearchParams();
1055
1065
  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);
1066
+ const tab = isSettingsTab(input?.tab) ? input.tab : "profile";
1067
+ const suffix = input?.refreshChannels ? "?refresh=channels" : "";
1068
+ return redirect(c, `${withAccountQuery(`/settings/${tab}`, accountId)}${suffix}`, 303);
1064
1069
  }
1065
1070
  async function validateFlockPosterApiKey(apiKey) {
1066
1071
  await fetchFlockPosterIntegrations(apiKey);
@@ -1624,6 +1629,12 @@ function primitiveOperationPathForJob(job) {
1624
1629
  if (job.templateId === "primitive:image_remove_background" && job.operationName === "run") {
1625
1630
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
1626
1631
  }
1632
+ if (job.templateId === "primitive:image_remove_background_greenscreen" && job.operationName === "run") {
1633
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
1634
+ }
1635
+ if (job.templateId === "primitive:media_overlay" && job.operationName === "run") {
1636
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
1637
+ }
1627
1638
  if (job.templateId === "primitive:video_generate" && job.operationName === "run") {
1628
1639
  return `${PRIMITIVES_PREFIX}/videos/generate`;
1629
1640
  }
@@ -2892,6 +2903,16 @@ function sanitizeStorageSubpath(value) {
2892
2903
  .filter((segment) => segment && segment !== "." && segment !== "..")
2893
2904
  .join("/");
2894
2905
  }
2906
+ // Temp files auto-organize by date: an upload with no explicit folder lands in
2907
+ // today's YYYY-MM-DD subfolder (canonical `/temp/2026-07-08/…`), so the scratch
2908
+ // space stays browsable instead of a flat dump. Applied at the write points
2909
+ // (presign + direct upload); finalize trusts the folder the presign echoed back.
2910
+ function todayDateFolder() {
2911
+ return new Date().toISOString().slice(0, 10);
2912
+ }
2913
+ function withTempDateFolder(folderPath) {
2914
+ return folderPath || todayDateFolder();
2915
+ }
2895
2916
  function inferAttachmentContentType(fileName) {
2896
2917
  switch (path.extname(fileName).toLowerCase()) {
2897
2918
  case ".png":
@@ -2938,7 +2959,50 @@ function buildUserAttachmentViewUrl(c, storageKey) {
2938
2959
  function buildUserFileUrl(c, storageKey) {
2939
2960
  return storage.getPublicUrl(storageKey) ?? buildAbsoluteUrl(c, `/template-media?key=${encodeURIComponent(storageKey)}`);
2940
2961
  }
2962
+ // Ensure a durable poster image URL for a My Files attachment so the file
2963
+ // directory can render a snappy thumbnail. Policy per media type:
2964
+ // image/* → the image IS its own poster (return its own view URL, no re-encode
2965
+ // — cheapest + snappiest).
2966
+ // video/* → extract a frame (~1000ms in, fall back to 0), store the JPEG under
2967
+ // thumbs/attachments/<customer>/<id>.jpg and return that key + url.
2968
+ // audio / pdf / text / everything else → null; the frontend shows a type icon.
2969
+ // BEST-EFFORT / fail-soft: a failed extraction NEVER fails the upload — it just
2970
+ // returns null and logs (mirrors importWholeVideoAsRaw's `.catch(() => false)`).
2971
+ async function ensureAttachmentThumbnail(input) {
2972
+ const type = (input.contentType || "").toLowerCase();
2973
+ if (type.startsWith("image/")) {
2974
+ // An image is its own poster — never re-encode.
2975
+ const url = input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2976
+ return url ? { thumbnailStorageKey: null, thumbnailUrl: url } : null;
2977
+ }
2978
+ if (!type.startsWith("video/")) {
2979
+ // Audio, PDF, text, etc. — the file directory already renders a type icon.
2980
+ return null;
2981
+ }
2982
+ // extractVideoFrameAsset downloads a URL, so point it at the just-stored
2983
+ // object's own public URL when no explicit source URL is supplied.
2984
+ const frameSource = input.sourceUrl || input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2985
+ if (!frameSource)
2986
+ return null;
2987
+ try {
2988
+ const frame = await extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 1000, format: "jpeg" })
2989
+ // Very short clips have no frame 1s in — fall back to the very first frame.
2990
+ .catch(() => extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 0, format: "jpeg" }));
2991
+ const thumbKey = joinStorageKey("thumbs", "attachments", input.customerId, `${input.attachmentId}.jpg`);
2992
+ const stored = await storage.putBuffer(thumbKey, frame.bytes, "image/jpeg");
2993
+ const thumbnailUrl = stored.url || storage.getPublicUrl(thumbKey);
2994
+ return { thumbnailStorageKey: thumbKey, thumbnailUrl: thumbnailUrl ?? null };
2995
+ }
2996
+ catch (error) {
2997
+ devLog("attachment.thumbnail_failed", { attachment_id: input.attachmentId, ...devErrorFields(error) }, "warn");
2998
+ return null;
2999
+ }
3000
+ }
2941
3001
  function serializeUserAttachment(c, attachment) {
3002
+ // Every image poster falls back to the file's own view URL so image entries
3003
+ // always render a thumbnail even if the record predates thumbnail wiring.
3004
+ const viewUrl = attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey);
3005
+ const isImage = (attachment.contentType || "").toLowerCase().startsWith("image/");
2942
3006
  return {
2943
3007
  id: attachment.id,
2944
3008
  fileName: attachment.fileName,
@@ -2949,7 +3013,8 @@ function serializeUserAttachment(c, attachment) {
2949
3013
  notes: attachment.notes ?? null,
2950
3014
  // Flag only — the raw vector never ships to the client (mirrors serializeClip).
2951
3015
  hasNotesEmbedding: Boolean(attachment.notesEmbedding && attachment.notesEmbedding.length > 0),
2952
- viewUrl: attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey)
3016
+ viewUrl,
3017
+ thumbnailUrl: attachment.thumbnailUrl || (isImage ? viewUrl : null)
2953
3018
  };
2954
3019
  }
2955
3020
  function serializeUserTemporaryFile(c, file) {
@@ -2969,6 +3034,272 @@ function serializeUserTemporaryFile(c, file) {
2969
3034
  function buildFolderList(items) {
2970
3035
  return Array.from(new Set(items.map((item) => item.folderPath ?? "").filter(Boolean))).sort();
2971
3036
  }
3037
+ // ── Unified file directory: convert each backend's record into the one
3038
+ // canonical DirectoryItem shape so /files, /temp, /raws browse uniformly. ─────
3039
+ function attachmentToDirectoryItem(c, a) {
3040
+ const s = serializeUserAttachment(c, a);
3041
+ const folderPath = normalizeFolderPath(a.folderPath);
3042
+ return {
3043
+ path: buildDirectoryPath("files", folderPath, a.fileName),
3044
+ root: "files",
3045
+ folderPath,
3046
+ name: a.fileName,
3047
+ kind: "file",
3048
+ id: a.id,
3049
+ contentType: a.contentType,
3050
+ viewUrl: s.viewUrl,
3051
+ thumbUrl: s.thumbnailUrl ?? undefined,
3052
+ sizeBytes: a.sizeBytes,
3053
+ meta: { notes: s.notes, hasNotesEmbedding: s.hasNotesEmbedding, createdAt: a.createdAt }
3054
+ };
3055
+ }
3056
+ function temporaryFileToDirectoryItem(c, f) {
3057
+ const s = serializeUserTemporaryFile(c, f);
3058
+ const folderPath = normalizeFolderPath(f.folderPath);
3059
+ return {
3060
+ path: buildDirectoryPath("temp", folderPath, f.fileName),
3061
+ root: "temp",
3062
+ folderPath,
3063
+ name: f.fileName,
3064
+ kind: "file",
3065
+ id: f.id,
3066
+ contentType: f.contentType,
3067
+ viewUrl: s.viewUrl,
3068
+ sizeBytes: f.sizeBytes,
3069
+ meta: { expiresAt: f.expiresAt, createdAt: f.createdAt, s3Url: s.s3Url }
3070
+ };
3071
+ }
3072
+ // Takes the OUTPUT of serializeClip (view/thumbnail URLs already resolved).
3073
+ function clipSerializedToDirectoryItem(s) {
3074
+ const folderPath = normalizeFolderPath(s.folder_path);
3075
+ const name = s.source_filename || s.description || "clip";
3076
+ const duration = s.duration_sec;
3077
+ return {
3078
+ path: buildDirectoryPath("raws", folderPath, name),
3079
+ root: "raws",
3080
+ folderPath,
3081
+ name,
3082
+ kind: "file",
3083
+ id: s.clip_id,
3084
+ contentType: "video/mp4",
3085
+ viewUrl: s.view_url || undefined,
3086
+ thumbUrl: s.thumbnail_url || undefined,
3087
+ durationSec: typeof duration === "number" ? duration : undefined,
3088
+ meta: { description: s.description, tags: s.tags, created_at: s.created_at, source_video_id: s.source_video_id }
3089
+ };
3090
+ }
3091
+ // An approved ready-post as a canonical directory file. view/thumb come from the
3092
+ // post's media assets (video for playback, an image asset for the poster).
3093
+ function readyPostToDirectoryItem(post) {
3094
+ const folderPath = normalizeFolderPath(post.folderPath ?? "");
3095
+ const name = (post.title && post.title.trim()) || `Post ${post.id}`;
3096
+ const media = post.mediaAssets || [];
3097
+ const video = media.find((a) => a.kind === "video");
3098
+ const image = media.find((a) => a.kind === "image");
3099
+ const primary = media.find((a) => a.role === "primary") || video || media[0] || null;
3100
+ return {
3101
+ path: buildDirectoryPath("approved", folderPath, name),
3102
+ root: "approved",
3103
+ folderPath,
3104
+ name,
3105
+ kind: "file",
3106
+ id: post.id,
3107
+ contentType: video ? "video/mp4" : (primary?.contentType || "application/octet-stream"),
3108
+ viewUrl: (primary?.url || video?.url) || undefined,
3109
+ thumbUrl: image?.url || undefined,
3110
+ meta: { tracer: post.tracer, status: post.status, caption: post.caption, created_at: post.createdAt }
3111
+ };
3112
+ }
3113
+ // Infer a MIME type from a fork file's name so the explorer can preview images /
3114
+ // video / audio and the agent can tell text (composition.html/json) from binary.
3115
+ function inferProjectFileContentType(name) {
3116
+ const ext = (name.split(".").pop() || "").toLowerCase();
3117
+ switch (ext) {
3118
+ case "html":
3119
+ case "htm": return "text/html; charset=utf-8";
3120
+ case "json": return "application/json";
3121
+ case "txt": return "text/plain; charset=utf-8";
3122
+ case "md": return "text/markdown; charset=utf-8";
3123
+ case "css": return "text/css; charset=utf-8";
3124
+ case "js":
3125
+ case "mjs": return "text/javascript; charset=utf-8";
3126
+ case "srt": return "application/x-subrip";
3127
+ case "vtt": return "text/vtt";
3128
+ case "png": return "image/png";
3129
+ case "jpg":
3130
+ case "jpeg": return "image/jpeg";
3131
+ case "gif": return "image/gif";
3132
+ case "webp": return "image/webp";
3133
+ case "svg": return "image/svg+xml";
3134
+ case "mp4":
3135
+ case "m4v": return "video/mp4";
3136
+ case "webm": return "video/webm";
3137
+ case "mov": return "video/quicktime";
3138
+ case "mp3": return "audio/mpeg";
3139
+ case "wav": return "audio/wav";
3140
+ case "m4a": return "audio/mp4";
3141
+ default: return "application/octet-stream";
3142
+ }
3143
+ }
3144
+ // A fork (composition-editing project) as a top-level /projects folder. The
3145
+ // folder path segment is the STABLE fork id (so paths round-trip); the display
3146
+ // name prefers a human title (the fork's own, else its template's) so the tile
3147
+ // is recognizable.
3148
+ function projectForkToFolderItem(fork, displayName) {
3149
+ const versions = fork.latestVersion ?? 0;
3150
+ return {
3151
+ path: buildDirectoryPath("projects", fork.id),
3152
+ root: "projects",
3153
+ folderPath: fork.id,
3154
+ name: displayName,
3155
+ kind: "folder",
3156
+ meta: {
3157
+ description: `video project · ${versions ? `v${versions}` : "unsaved"}`,
3158
+ forkId: fork.id,
3159
+ templateId: fork.templateId,
3160
+ updated_at: fork.updatedAt,
3161
+ latest_version: versions
3162
+ }
3163
+ };
3164
+ }
3165
+ // One object under a fork's storage root as a canonical directory file. `name`
3166
+ // is the trailing filename; `key` is the physical S3 key — it lives under the
3167
+ // public `compositions/forks/` prefix, so getPublicUrl yields a directly
3168
+ // fetchable view URL (and the composition.html/json are also readable through
3169
+ // the existing GET /api/v1/compositions/:forkId/* routes).
3170
+ function forkStorageFileToDirectoryItem(folderPath, name, key) {
3171
+ return {
3172
+ path: buildDirectoryPath("projects", folderPath, name),
3173
+ root: "projects",
3174
+ folderPath,
3175
+ name,
3176
+ kind: "file",
3177
+ id: key,
3178
+ contentType: inferProjectFileContentType(name),
3179
+ viewUrl: storage.getPublicUrl(key) || undefined,
3180
+ meta: { storageKey: key }
3181
+ };
3182
+ }
3183
+ // List one folder of the /projects root. At depth 0 (folderPath "") every fork
3184
+ // the customer owns is a folder; deeper, the fork's S3 storage tree (working/ +
3185
+ // versions/…) is presented as a generic prefix explorer, so any composition file
3186
+ // is a real, copyable, agent-addressable path like
3187
+ // /projects/<forkId>/working/composition.html. Read-only.
3188
+ async function listProjectsFolder(customerId, cur) {
3189
+ const segments = cur ? cur.split("/") : [];
3190
+ if (segments.length === 0) {
3191
+ const forks = (await serverlessRecords.listCompositionForksForCustomer(customerId))
3192
+ .filter((f) => !f.deletedAt);
3193
+ forks.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
3194
+ // One template lookup, mapped, to give forks recognizable names.
3195
+ const templates = await serverlessRecords.listTemplatesForCustomer(customerId).catch(() => []);
3196
+ const titleByTemplate = new Map();
3197
+ for (const t of templates) {
3198
+ if (t.title && t.title.trim())
3199
+ titleByTemplate.set(t.id, t.title.trim());
3200
+ }
3201
+ const folders = forks.map((fork) => {
3202
+ const name = (fork.title && fork.title.trim())
3203
+ || titleByTemplate.get(fork.templateId)
3204
+ || `Project ${fork.id.slice(-6)}`;
3205
+ return projectForkToFolderItem(fork, name);
3206
+ });
3207
+ return { folders, files: [] };
3208
+ }
3209
+ const forkId = segments[0];
3210
+ // Browse is scoped to the owner: only the customer's own forks are listable.
3211
+ const fork = await serverlessRecords.getCompositionFork(forkId);
3212
+ if (!fork || fork.customerId !== customerId || fork.deletedAt) {
3213
+ return { folders: [], files: [] };
3214
+ }
3215
+ const rootPrefix = storage.compositionForkRoot(forkId) + "/"; // compositions/forks/<id>/
3216
+ const keys = await storage.listKeys(rootPrefix);
3217
+ const subPath = segments.slice(1).join("/");
3218
+ const subPrefix = subPath ? subPath + "/" : "";
3219
+ const childFolders = new Set();
3220
+ const files = [];
3221
+ for (const key of keys) {
3222
+ if (key.indexOf(rootPrefix) !== 0)
3223
+ continue;
3224
+ const rel = key.slice(rootPrefix.length);
3225
+ if (!rel || (subPrefix && rel.indexOf(subPrefix) !== 0))
3226
+ continue;
3227
+ const remainder = rel.slice(subPrefix.length);
3228
+ if (!remainder)
3229
+ continue;
3230
+ const slash = remainder.indexOf("/");
3231
+ if (slash >= 0)
3232
+ childFolders.add(remainder.slice(0, slash));
3233
+ else
3234
+ files.push(forkStorageFileToDirectoryItem(cur, remainder, key));
3235
+ }
3236
+ const folders = Array.from(childFolders).sort().map((n) => folderItem("projects", cur, n));
3237
+ files.sort((a, b) => a.name.localeCompare(b.name));
3238
+ return { folders, files };
3239
+ }
3240
+ /**
3241
+ * List one folder of one directory root, returning immediate child folders + the
3242
+ * files directly in this folder as canonical DirectoryItems. The single place
3243
+ * that dispatches a canonical path to its backend (attachments / temp / raws /
3244
+ * projects / approved).
3245
+ */
3246
+ async function listDirectoryFolder(c, customerId, root, folderPath) {
3247
+ const cur = normalizeFolderPath(folderPath);
3248
+ if (root === "files") {
3249
+ const atts = await serverlessRecords.listUserAttachments(customerId);
3250
+ const explicit = await serverlessRecords.listUserFileFolders(customerId, ATTACHMENTS_FOLDER_SCOPE);
3251
+ const childNames = immediateChildFolders([...explicit, ...atts.map((a) => a.folderPath)], cur);
3252
+ const files = atts
3253
+ .filter((a) => normalizeFolderPath(a.folderPath) === cur)
3254
+ .map((a) => attachmentToDirectoryItem(c, a));
3255
+ return { folders: childNames.map((n) => folderItem("files", cur, n)), files };
3256
+ }
3257
+ if (root === "temp") {
3258
+ const list = await serverlessRecords.listUserTemporaryFiles(customerId);
3259
+ const explicit = await serverlessRecords.listUserFileFolders(customerId, "temporary");
3260
+ const childNames = immediateChildFolders([...explicit, ...list.map((f) => f.folderPath)], cur);
3261
+ const files = list
3262
+ .filter((f) => normalizeFolderPath(f.folderPath) === cur)
3263
+ .map((f) => temporaryFileToDirectoryItem(c, f));
3264
+ return { folders: childNames.map((n) => folderItem("temp", cur, n)), files };
3265
+ }
3266
+ if (root === "raws") {
3267
+ const clips = await clipRecords.listClips(customerId, { limit: 5000 });
3268
+ const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
3269
+ const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
3270
+ const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
3271
+ const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
3272
+ return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
3273
+ }
3274
+ if (root === "projects") {
3275
+ return listProjectsFolder(customerId, cur);
3276
+ }
3277
+ // approved — finished ready-to-post cuts, foldered by folder_path.
3278
+ const posts = (await serverlessRecords.listReadyPosts(customerId)).filter((p) => p.status !== "deleted");
3279
+ const explicitApprovedFolders = await serverlessRecords.listUserFileFolders(customerId, "approved");
3280
+ const childNames = immediateChildFolders([...explicitApprovedFolders, ...posts.map((p) => p.folderPath ?? "")], cur);
3281
+ const here = posts.filter((p) => normalizeFolderPath(p.folderPath ?? "") === cur);
3282
+ const files = here.map(readyPostToDirectoryItem);
3283
+ return { folders: childNames.map((n) => folderItem("approved", cur, n)), files };
3284
+ }
3285
+ // The user_file_folder scope backing each directory root's empty folders.
3286
+ function directoryFolderScope(root) {
3287
+ return root === "files" ? ATTACHMENTS_FOLDER_SCOPE
3288
+ : root === "temp" ? "temporary"
3289
+ : root === "approved" ? "approved"
3290
+ : "raws";
3291
+ }
3292
+ // The three roots presented as folders at the virtual root ("/").
3293
+ function directoryRootFolders() {
3294
+ return DIRECTORY_ROOTS.map((r) => ({
3295
+ path: `/${r.key}`,
3296
+ root: r.key,
3297
+ folderPath: "",
3298
+ name: r.label,
3299
+ kind: "folder",
3300
+ meta: { description: r.description }
3301
+ }));
3302
+ }
2972
3303
  function isFormFile(value) {
2973
3304
  return Boolean(value
2974
3305
  && typeof value === "object"
@@ -3145,6 +3476,9 @@ function buildPrimitiveEditorDocsRoutes() {
3145
3476
  { 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
3477
  { 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
3478
  { 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? }." },
3479
+ { 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? }. AI matting for ARBITRARY photos with a messy background. BILLS THE WALLET (RapidAPI) — unlike the BYOK images/generate and images/edit routes. If the source already sits on a FLAT solid background, prefer the much cheaper images/remove-background-greenscreen." },
3480
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`, summary: "CHEAP local chroma-key background removal. Keys out a FLAT, solid background (green screen by default; pass key_color for white/blue/black/any flat fill) to a transparent PNG/WebP — sharp, no third-party API, compute-only wallet fee. Body must be { tracer, payload: { source_image_url, key_color? (default #00FF00), tolerance? (0..1, default 0.3), softness? (0..1, default 0.1), despill? (default true), output_format? (png|webp) }, webhook_url? }. Use for images you generated on a controlled solid background; for uncontrolled photos use images/remove-background. Flat alias: POST /api/v1/primitives/remove-background-greenscreen." },
3481
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/create-overlay`, summary: "CREATE MEDIA OVERLAY (Vox-style) — the preferred one-shot way to mint a transparent floating illustration/prop/icon/character to animate over a composition. ONE job: generate an AI image of the subject on a forced flat key-color background (BYOK image keys) THEN chroma-key it out to a ready-to-composite transparent PNG/WebP. Body must be { tracer, payload: { prompt, provider?, model?, prompt_attachments?, aspect_ratio?, image_size?, key_color? (default #00FF00), tolerance? (default 0.3), softness? (default 0.1), despill? (default true), output_format? (png|webp) }, webhook_url? }. Result carries primary_file_url (the transparent overlay) plus greenscreen_source_url (the raw pre-key frame). BILLS THE WALLET (small platform fee); the image-gen leg additionally rides the caller's own provider key. Flat alias: POST /api/v1/primitives/create-media-overlay." },
3148
3482
  { 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
3483
  { 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
3484
  { 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 +3943,10 @@ function normalizeEditorChatPath(input) {
3609
3943
  PRIMITIVES_PREFIX,
3610
3944
  APPROVED_POSTS_PREFIX,
3611
3945
  `${USER_PREFIX}/me/jobs`,
3946
+ // Unified file directory: browse/search /files, /temp, /raws by canonical
3947
+ // path (GET /me/directory, POST /me/directory/search). browse_files uses the
3948
+ // same endpoints; this lets the agent hit them directly via http_request too.
3949
+ `${USER_PREFIX}/me/directory`,
3612
3950
  // Read-only catalog discovery so the copilot can answer "which templates
3613
3951
  // suit my <offer>?": the template feed (supports ?q=&limit=) and the source
3614
3952
  // video / inspiration catalog (supports ?q=&limit=&mine=). Ingest lives at
@@ -3631,6 +3969,10 @@ function normalizeEditorChatPath(input) {
3631
3969
  // scene-selection filter; caption removal stays a per-finished-clip
3632
3970
  // primitive (/api/v1/primitives/videos/remove-captions).
3633
3971
  RAWS_PREFIX,
3972
+ // Legacy /clips alias (server redispatches /clips/* → /raws/*). Allowed so a
3973
+ // prompt that still says /clips/scan works identically to /raws/scan and the
3974
+ // allowlist doesn't drift from the deployed Lambda copy.
3975
+ CLIPS_COMPAT_PREFIX,
3634
3976
  // Public skill knowledge: the vendored skill packs (load_skill fetches
3635
3977
  // these too) and per-template SKILL.md manifests. Read-only markdown.
3636
3978
  "/skill/",
@@ -3864,10 +4206,18 @@ function createVideoContextTool(input) {
3864
4206
  return tool({
3865
4207
  description: "OPTIONAL context fetch: retrieve the current composition fork's decomposed video context — the source video's verbatim audio transcript (full text plus timestamped segments) and a literal visual description of every scene, along with viral DNA and per-scene transcript excerpts. Call it when the user asks what the source video says or shows, when writing or translating captions/hooks/dubs that must match the spoken audio, or when you need scene-by-scene grounding before planning timeline edits. Read-only and non-billing. The data comes from the smart-decompose snapshot; status=\"none\" means the fork was never smart-decomposed — offer to run POST /api/v1/compositions/:forkId/auto-decompose first. The same data is also available via GET /api/v1/compositions/:forkId/video-context.json through http_request.",
3866
4208
  inputSchema: z.object({
3867
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4209
+ fork_id: z.string().min(1).describe("Composition fork id — a fork_* id. Copy it verbatim from editor_context.fork_id in the most recent <editor_context>; never pass editor_context.composition_id (that is the template_* id, not a fork) and never invent one. If editor_context has no fork_id, this composition has no saved fork yet — do not call this tool; edit the composition first to mint a fork."),
3868
4210
  explanation: z.string().optional().describe("Short reason for fetching the video context.")
3869
4211
  }),
3870
4212
  execute: async ({ fork_id, explanation }) => {
4213
+ if (fork_id.startsWith("template_")) {
4214
+ return {
4215
+ explanation: explanation ?? null,
4216
+ status: 0,
4217
+ video_context: null,
4218
+ error: `"${fork_id}" is a template id (composition_id), not a fork id. Video context is keyed by fork — read editor_context.fork_id (a fork_* id) from the most recent <editor_context> and call again with that. If editor_context has no fork_id, this composition has no saved fork yet; make an edit first to mint one.`
4219
+ };
4220
+ }
3871
4221
  const url = normalizeEditorChatPath({
3872
4222
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/video-context.json`,
3873
4223
  template: input.template
@@ -3916,10 +4266,18 @@ function createProductPlacementContextTool(input) {
3916
4266
  return tool({
3917
4267
  description: "OPTIONAL context fetch: retrieve the current composition fork's product-placement surfaces — the product-AGNOSTIC slots found during decompose where any product could later be inserted natively (each with a timestamp, start seconds, scene_slug, surface_type, surface_note, best_for product categories, and why_it_works). Call it BEFORE running the brainstorm_product_placement primitive so you can seed it with real, grounded moments, and whenever the user asks where a product could be placed in this video. Read-only and non-billing. status=\"none\" means the fork was never decomposed (offer POST /api/v1/compositions/:forkId/auto-decompose); status=\"empty\" means decompose found no natural surface. Same data is available via GET /api/v1/compositions/:forkId/product-placement.json through http_request.",
3918
4268
  inputSchema: z.object({
3919
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4269
+ fork_id: z.string().min(1).describe("Composition fork id — a fork_* id. Copy it verbatim from editor_context.fork_id in the most recent <editor_context>; never pass editor_context.composition_id (that is the template_* id, not a fork) and never invent one. If editor_context has no fork_id, this composition has no saved fork yet — do not call this tool; edit the composition first to mint a fork."),
3920
4270
  explanation: z.string().optional().describe("Short reason for fetching the placement surfaces.")
3921
4271
  }),
3922
4272
  execute: async ({ fork_id, explanation }) => {
4273
+ if (fork_id.startsWith("template_")) {
4274
+ return {
4275
+ explanation: explanation ?? null,
4276
+ status: 0,
4277
+ product_placement: null,
4278
+ error: `"${fork_id}" is a template id (composition_id), not a fork id. Product-placement surfaces are keyed by fork — read editor_context.fork_id (a fork_* id) from the most recent <editor_context> and call again with that. If editor_context has no fork_id, this composition has no saved fork yet; make an edit first to mint one.`
4279
+ };
4280
+ }
3923
4281
  const url = normalizeEditorChatPath({
3924
4282
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/product-placement.json`,
3925
4283
  template: input.template
@@ -4013,19 +4371,25 @@ function browseFilesTextContentType(fileName) {
4013
4371
  }
4014
4372
  function createBrowseFilesTool(input) {
4015
4373
  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.",
4374
+ description: "Browse AND write the user's file directorya navigable tree with FIVE 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), /raws (the reusable clip/source-footage library, foldered by source), /projects (READ-ONLY: the user's video-editing projects — one folder per composition fork, holding that fork's stored files like working/composition.html and working/composition.json plus saved versions/; browse here to study or reference ANOTHER fork, then read its composition through GET /api/v1/compositions/<forkId>/composition.html), and /approved (finished, ready-to-post cuts, foldered — defaults to a folder per tracer/campaign). 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 five roots; path='/raws', '/approved/summer-launch', 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` and `path` (target folder) reorganizes a raw between /raws folders (e.g. /raws/demos) OR an approved post between /approved folders (e.g. /approved/summer-launch). RENAME: action=rename tidies the directory — pass path + new_name + file_id to rename a /files or /temp file (display name only; its view_url keeps working), or path + new_name with NO file_id to rename a folder in ANY root — /files, /temp, /raws, or /approved (its files come along; renaming a /raws folder re-points every clip inside it, so this is how you tidy an auto-named source folder like /raws/lvlldspajcg into /raws/marlin-fishing). 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: RECURRING CHARACTERS live under /files/characters/<slug>/ as a trio — <character_id>.json (the manifest, named after the id 'character_'+slug, e.g. character_zara.json; fields: id, slug, name, appearance, wardrobe, palette, voice, sprite_card_path, about_path), character_sprite_card.png (the reference sheet), and character_about.md (the prose). List /files/characters to see who exists, annotate all three, 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
4375
  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."),
4376
+ action: z.enum(["list", "read", "write", "annotate", "search", "move", "rename"]).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 OR an approved post between /approved folders; rename = rename a file (pass file_id + its path + new_name; /files or /temp only) or a folder (pass the folder path + new_name, no file_id) in ANY root — /files, /temp, /raws, or /approved."),
4377
+ path: z.string().optional().describe("Canonical directory path to list or search: '/' for the four roots, '/raws', '/approved/summer-launch', or '/files/brand-assets' for a folder. For move, the target folder (e.g. /raws/demos or /approved/summer-launch). For rename, the current path of the file or folder to rename. Preferred over folder_path for cross-root navigation."),
4378
+ new_name: z.string().optional().describe("For rename: the new file name (e.g. character_about.md) or the new folder name (a single segment, no slashes)."),
4379
+ 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."),
4380
+ 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
4381
  file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
4021
4382
  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
4383
  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
4384
  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
4385
  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
4386
  query: z.string().optional().describe("For search: what you're looking for, in plain language. Required for search; ignored otherwise."),
4387
+ 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."),
4388
+ 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."),
4389
+ limit: z.number().int().optional().describe("For list/search: max items to return (list default 200, max 500; search default 30, max 50)."),
4026
4390
  explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
4027
4391
  }),
4028
- execute: async ({ action, folder_path, file_id, file_name, content, source_url, notes, query, explanation }) => {
4392
+ execute: async ({ action, path, mode, folder_path, file_id, file_name, new_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4029
4393
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
4030
4394
  const headers = new Headers();
4031
4395
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -4034,7 +4398,87 @@ function createBrowseFilesTool(input) {
4034
4398
  timeoutController.abort(new Error("Browse files request timed out after 30 seconds."));
4035
4399
  }, 30_000);
4036
4400
  const normalizedFolder = (folder_path ?? "").trim().replace(/^\/+|\/+$/g, "");
4401
+ // Default list/search to the WHOLE directory ('/') when neither a canonical
4402
+ // path nor a legacy folder_path is given — otherwise these silently fell
4403
+ // back to a /files-only search and the user's /raws + /temp were invisible.
4404
+ const canonicalPath = (path ?? "").trim()
4405
+ || ((action === "list" || action === "search") && !normalizedFolder ? "/" : "");
4406
+ const contentTypeParam = (content_type ?? "").trim();
4037
4407
  try {
4408
+ // ── unified directory navigation (any of /files, /temp, /raws) ──
4409
+ if (action === "list" && canonicalPath) {
4410
+ const url = new URL(`${USER_PREFIX}/me/directory`, config.PUBLIC_BASE_URL);
4411
+ url.searchParams.set("path", canonicalPath);
4412
+ if (contentTypeParam)
4413
+ url.searchParams.set("content_type", contentTypeParam);
4414
+ if (offset !== undefined && Number.isFinite(offset))
4415
+ url.searchParams.set("offset", String(Math.max(0, Math.trunc(offset))));
4416
+ if (limit !== undefined && Number.isFinite(limit))
4417
+ url.searchParams.set("limit", String(Math.max(1, Math.trunc(limit))));
4418
+ const r = await fetch(url, { method: "GET", headers, signal: mergeAbortSignals(input.abortSignal, timeoutController.signal) });
4419
+ const body = await r.json().catch(() => null);
4420
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4421
+ }
4422
+ if (action === "search" && canonicalPath) {
4423
+ const url = new URL(`${USER_PREFIX}/me/directory/search`, config.PUBLIC_BASE_URL);
4424
+ const searchHeaders = new Headers(headers);
4425
+ searchHeaders.set("content-type", "application/json");
4426
+ const searchLimit = limit !== undefined && Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 30;
4427
+ const r = await fetch(url, {
4428
+ method: "POST",
4429
+ headers: searchHeaders,
4430
+ body: JSON.stringify({
4431
+ query: (query ?? "").trim(),
4432
+ path: canonicalPath,
4433
+ mode: mode ?? "auto",
4434
+ limit: searchLimit,
4435
+ ...(contentTypeParam ? { content_type: contentTypeParam } : {})
4436
+ }),
4437
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4438
+ });
4439
+ const body = await r.json().catch(() => null);
4440
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4441
+ }
4442
+ if (action === "move") {
4443
+ const id = (file_id ?? "").trim();
4444
+ const seg = canonicalPath.split("/").map((s) => s.trim()).filter(Boolean);
4445
+ const root = seg[0];
4446
+ if (!id || !root)
4447
+ return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos or /approved/summer-launch." };
4448
+ if (root !== "raws" && root !== "approved")
4449
+ return { action, explanation: explanation ?? null, status: 400, error: "move is supported within /raws (clips) and /approved (posts)." };
4450
+ const url = root === "approved"
4451
+ ? new URL(`${APPROVED_POSTS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL)
4452
+ : new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
4453
+ const moveHeaders = new Headers(headers);
4454
+ moveHeaders.set("content-type", "application/json");
4455
+ const r = await fetch(url, {
4456
+ method: "PATCH",
4457
+ headers: moveHeaders,
4458
+ body: JSON.stringify({ folder_path: seg.slice(1).join("/") }),
4459
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4460
+ });
4461
+ const body = await r.json().catch(() => null);
4462
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4463
+ }
4464
+ if (action === "rename") {
4465
+ const target = (path ?? "").trim();
4466
+ const nextName = (new_name ?? "").trim();
4467
+ if (!target || !nextName) {
4468
+ return { action, explanation: explanation ?? null, status: 400, error: "rename requires path (the file or folder to rename) and new_name. For a file also pass file_id." };
4469
+ }
4470
+ const url = new URL(`${USER_PREFIX}/me/directory/rename`, config.PUBLIC_BASE_URL);
4471
+ const renameHeaders = new Headers(headers);
4472
+ renameHeaders.set("content-type", "application/json");
4473
+ const r = await fetch(url, {
4474
+ method: "POST",
4475
+ headers: renameHeaders,
4476
+ body: JSON.stringify({ path: target, new_name: nextName, ...((file_id ?? "").trim() ? { file_id: (file_id ?? "").trim() } : {}) }),
4477
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4478
+ });
4479
+ const body = await r.json().catch(() => null);
4480
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4481
+ }
4038
4482
  if (action === "write") {
4039
4483
  const name = (file_name ?? "").trim();
4040
4484
  if (!name) {
@@ -4396,7 +4840,7 @@ function createVideoCreationTool() {
4396
4840
  }
4397
4841
  function createEditorActionTool() {
4398
4842
  return tool({
4399
- description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url.",
4843
+ description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Forking is automatic — the FIRST mutating editor_action on a real template auto-mints the user's own private editable copy; you never create a fork manually, just edit (the exception is a blank project with composition_id \"original\", which has no template to fork yet, so edits there won't persist until a real video is created). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url. Editing works on THREE axes — SCENES (video/image clips), AUDIO (narration/music/SFX), and TEXT (captions/titles/overlays) — and on each the user may lightly SWAP (change content in place, keeping timing/geometry) or heavily REPLACE (restructure outright, often keeping only the template's viral DNA); for a heavy scene REPLACE prefer hunting real raw clips (POST /clips/scan) or the user's /raws library over AI generation, and carry the whole re-work rather than only single-layer nudges.",
4400
4844
  inputSchema: z.object({
4401
4845
  action_type: z.enum([
4402
4846
  "add_layer",
@@ -4415,9 +4859,17 @@ function createEditorActionTool() {
4415
4859
  "export_composition",
4416
4860
  "generate_layer",
4417
4861
  "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."),
4862
+ "set_transitions",
4863
+ "set_layer_keyframes",
4864
+ "nudge_layers",
4865
+ "ripple_edit",
4866
+ "set_layer_zindex",
4867
+ "trim_layer",
4868
+ "set_composition",
4869
+ "fork_composition"
4870
+ ]).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 fork_composition ONLY when the user EXPLICITLY asks to branch / duplicate / 'save as' / 'make a copy' / 'start a fresh version' of this project (routine editing already auto-forks — do NOT call this to begin editing): set fork_from='current' to branch from the current working copy (keep all edits) or fork_from='default' to start a fresh copy from the original template (discard current edits); the editor mints the new fork and opens it. 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. STATIC LOOK: set_layer_visual also sets a constant opacity (0..1, e.g. a ghosted b-roll underlay — a FADE over time is set_layer_keyframes instead); set_layer_style also sets line_height (unitless leading) and letter_spacing (px tracking, negative tightens) for typography the presets can't reach. COMPOSITION-LEVEL: set_composition changes the whole canvas — canvas_width/canvas_height resize the frame, canvas_duration retargets total render length, background_color recolors the canvas (all written on the composition root, no layer_key)."),
4420
4871
  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."),
4872
+ fork_from: z.enum(["current", "default"]).optional().describe("For action_type=fork_composition: 'current' branches a new fork from the current working copy (keeps all edits); 'default' starts a fresh fork from the template's original default (discards current edits). Defaults to 'current'."),
4421
4873
  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
4874
  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."),
4423
4875
  layer_keys: z.array(z.string()).optional().describe("Multiple layer keys. Required for group_layers (>=2) and ungroup_layers. For duplicate_layer, layer_keys[1] optionally names the duplicate."),
@@ -4442,11 +4894,18 @@ function createEditorActionTool() {
4442
4894
  underline: z.boolean().optional().describe("Toggle underline on text layers."),
4443
4895
  text_align: z.string().optional().describe("CSS text-align value (left, center, right, justify)."),
4444
4896
  border_radius: z.number().optional().describe("Border radius in pixels."),
4897
+ opacity: z.number().optional().describe("set_layer_visual: constant layer opacity, 0 (invisible) to 1 (opaque). Baked as static inline style (seek-safe) — use it for a ghosted/dimmed underlay or a translucent overlay. For an opacity change OVER TIME (fade in/out) use set_layer_keyframes instead."),
4898
+ line_height: z.number().optional().describe("set_layer_style: unitless CSS line-height (leading) for text/caption layers, e.g. 1.1 tight, 1.5 airy."),
4899
+ letter_spacing: z.number().optional().describe("set_layer_style: CSS letter-spacing in px for text/caption layers (negative tightens, positive expands) — the lever for a condensed all-caps or wide-tracked look the font presets can't reach."),
4900
+ canvas_width: z.number().optional().describe("set_composition: new canvas WIDTH in pixels (e.g. 1080). Written to the composition root's data-width. Resizes the output frame."),
4901
+ canvas_height: z.number().optional().describe("set_composition: new canvas HEIGHT in pixels (e.g. 1920 for 9:16, 1080 for 1:1, or 1080 with width 1920 for 16:9). Written to data-height."),
4902
+ canvas_duration: z.number().optional().describe("set_composition: total render length in seconds. Written to the root's data-duration (the overall video length). Distinct from a single clip's duration."),
4903
+ background_color: z.string().optional().describe("set_composition: CSS color for the whole composition CANVAS background (behind all layers), e.g. '#000000' or 'white'. Distinct from a single layer's background."),
4445
4904
  color: z.string().optional().describe("CSS color for text foreground."),
4446
4905
  background: z.string().optional().describe("CSS color for background."),
4447
4906
  background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional().describe("Text background treatment."),
4448
- object_fit: z.string().optional().describe("CSS object-fit for image/video (cover, contain, fill, none, scale-down)."),
4449
- object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
4907
+ object_fit: z.string().optional().describe("CSS object-fit for image/video — how the media fills its layer box when its native aspect differs from the frame. 'cover' (default) fills the frame and crops the overflow (best for footage whose subject you keep centered or steer with object_position); 'contain' shows the WHOLE media with black letterbox/pillarbox bars (use only when nothing may be cropped — an infographic, whole screenshot, chart, logo); 'fill' stretches/distorts (avoid); 'none'; 'scale-down'. To fit a 16:9 landscape clip on a 9:16 vertical frame, prefer cover + a subject-aware object_position over contain unless the entire frame must be visible."),
4908
+ object_position: z.string().optional().describe("CSS object-position WHERE an object_fit:cover crop is anchored, so the subject stays in frame instead of being center-cropped out. Accepts keywords (center, left, right, top, bottom, 'top left', 'bottom right', ...) OR percentages/lengths ('30% 50%' holds the point 30% from the left and 50% down; '50% 20%' keeps the upper-middle). Landscape clip with the subject on the left → 'left' or '25% 50%'; tall screenshot with key content up top → 'top'. Only meaningful with object_fit cover/none."),
4450
4909
  ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
4451
4910
  ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
4452
4911
  transition: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. On set_transitions it is the junction preset applied at EVERY cut. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. Preset energy: crossfade/fade-black/fade-white/blur = calm, smoke = dreamy, slide/wipe = energetic, whip/flash = fast-paced social, zoom/circle-open = punchy."),
@@ -4486,6 +4945,22 @@ function createEditorActionTool() {
4486
4945
  resolution: z.string().optional().describe("generate_layer (video): optional resolution (480p|720p|1080p|1K|2K|4K)."),
4487
4946
  generate_audio: z.boolean().optional().describe("generate_layer (video): whether the model should generate an audio track."),
4488
4947
  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."),
4948
+ keyframes: z.array(z.object({
4949
+ offset: z.number().describe("Position along the animation as a 0..1 fraction (0 = clip start, 1 = end)."),
4950
+ opacity: z.number().optional().describe("Opacity 0..1 at this stop."),
4951
+ 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)."),
4952
+ 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)."),
4953
+ scale: z.number().optional().describe("Uniform scale factor (1 = natural size, 1.2 = 20% larger)."),
4954
+ rotate: z.number().optional().describe("Rotation in degrees.")
4955
+ })).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}]."),
4956
+ 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."),
4957
+ 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."),
4958
+ 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."),
4959
+ delta_track: z.number().int().optional().describe("nudge_layers: lanes to shift the layer(s) by (negative = down/behind)."),
4960
+ 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."),
4961
+ 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."),
4962
+ to_time: z.number().optional().describe("trim_layer: the new composition-time position (seconds) for the chosen edge."),
4963
+ 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
4964
  explanation: z.string().describe("One-sentence reason shown in the UI.")
4490
4965
  }),
4491
4966
  execute: async (args) => {
@@ -4548,9 +5023,16 @@ async function streamEditorChatAgent(input, send) {
4548
5023
  tracerRegistry
4549
5024
  }),
4550
5025
  editor_action: createEditorActionTool(),
4551
- // Discover/brainstorm surface only: "create me a video of…" / "replicate
4552
- // this URL…". The editor surfaces edit an already-open composition.
4553
- ...(input.template.templateId === "chat-brainstorm"
5026
+ // create_video is for making a BRAND-NEW video. It belongs on the
5027
+ // discover/brainstorm surface ("create me a video of…" / "replicate this
5028
+ // URL…") AND on a BLANK editor project (templateId "original", the
5029
+ // /editor/original/fork/new sentinel): a blank project has no template to
5030
+ // fork yet, so editor_action edits can't persist — the model must mint a
5031
+ // real composition first, and the dock already owns runCreateVideoFlow to
5032
+ // run the ingest→decompose→fork pipeline and drop an "Open in editor" link.
5033
+ // Real template_… compositions stay edit-only (editor_action) so the model
5034
+ // never spawns a new template when the user just wanted to edit this one.
5035
+ ...(input.template.templateId === "chat-brainstorm" || input.template.templateId === "original"
4554
5036
  ? { create_video: createVideoCreationTool() }
4555
5037
  : {})
4556
5038
  };
@@ -4753,6 +5235,24 @@ function renderHyperframesTimelineRuler(durationSeconds) {
4753
5235
  }).join("");
4754
5236
  return `<div class="hf-timeline-ruler" aria-hidden="true">${marks}</div>`;
4755
5237
  }
5238
+ // A not-yet-decomposed project is the raw single-video base: one <video> that
5239
+ // is BOTH the playback source AND its own timeline layer (data-layer-kind).
5240
+ // In the EDITOR its own audio is the point, so strip the baked-in `muted`
5241
+ // attribute when serving the composition to the editor stage (the editor plays
5242
+ // on a user gesture, so muted-autoplay is not needed). This is scoped to the
5243
+ // editor's composition.html load ONLY — card/swipe previews load a different
5244
+ // path and keep `muted` so their silent autoplay-loop still works. Decomposed
5245
+ // compositions emit a data-vf-playback-source WITHOUT data-layer-kind, so they
5246
+ // are left untouched.
5247
+ function unmuteRawPlaybackSourceVideo(html) {
5248
+ return html.replace(/<video\b[^>]*>/gi, (tag) => {
5249
+ if (!/data-vf-playback-source\s*=\s*["']?true/i.test(tag))
5250
+ return tag;
5251
+ if (!/data-layer-kind\s*=\s*["']?video/i.test(tag))
5252
+ return tag;
5253
+ return tag.replace(/\smuted(?=(\s|\/?>))/i, "");
5254
+ });
5255
+ }
4756
5256
  function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4757
5257
  const baseHref = `/${normalizeDraftRelativePath(draftPath)}/`;
4758
5258
  const baseTag = `<base href="${escapeAttribute(baseHref)}">`;
@@ -4828,6 +5328,72 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4828
5328
  }
4829
5329
  return rewritten;
4830
5330
  }
5331
+ // Reskin overrides layered on top of the editor's own /assets/editor/app.css.
5332
+ // RESKIN_CSS scopes its element rules under .rk-root, so the only thing it changes
5333
+ // globally is the --rk-* token set + box-sizing. The editor chrome stays on its
5334
+ // original DARK theme; these rules only pin the reused reskin chat dock as a
5335
+ // permanent left column (the one farmville element we keep here).
5336
+ const EDITOR_FARMVILLE_CSS = `
5337
+ html,body{margin:0;background:#050604;color:#fffbe6}
5338
+ #root{min-height:100vh}
5339
+
5340
+ /* The reused farmville chat dock, pinned flush as a permanent LEFT column
5341
+ (desktop). Width stays natural: 440px for the clean chat column, widening to
5342
+ overlay the editor's left edge only while the Files/History drawer is open. */
5343
+ @media (min-width:1025px){
5344
+ .rk-aichat.rk-aichat--editor{
5345
+ left:0 !important;top:0 !important;bottom:0 !important;right:auto !important;
5346
+ height:100vh !important;max-height:100vh !important;
5347
+ margin:0 !important;border-radius:0 !important;border-right:1px solid var(--rk-border) !important;
5348
+ box-shadow:var(--rk-shadow-lg) !important;transform:none !important;z-index:40
5349
+ }
5350
+ /* Make room for the docked chat: shift the SPA workbench right + drop its
5351
+ now-empty first grid column (the old embedded chat lived there). */
5352
+ .vidfarm-workbench{margin-left:440px !important;grid-template-columns:minmax(0,1fr) !important}
5353
+ }
5354
+ .rk-aichat,.rk-fab{font-family:var(--rk-font-body)}
5355
+
5356
+ /* ── Dark-mode the reused farmville chat dock so it matches the editor's dark
5357
+ chrome. The reskin /reskin pages keep the light warm theme; this remap is
5358
+ scoped to the editor dock only. Almost every dock surface reads a --rk-*
5359
+ token, so remapping the token set here re-themes the panel wholesale; the
5360
+ few hardcoded #fff backgrounds from the shared sheet are overridden below. */
5361
+ .rk-aichat.rk-aichat--editor{
5362
+ --rk-white:#12160d;
5363
+ --rk-surface:#0c0f08;--rk-bg:#0c0f08;--rk-bg-section:#0a0d07;
5364
+ --rk-n-50:#0a0d07;--rk-n-100:#191d12;--rk-n-200:#262b1a;--rk-n-300:#333a24;
5365
+ --rk-n-400:#6b7057;--rk-n-500:#8f9472;--rk-n-600:#b7bd97;--rk-n-700:#dcddc4;--rk-n-800:#eceada;
5366
+ --rk-ink:#fffbe6;--rk-text:#e7e6cd;--rk-text-muted:#9a9d80;--rk-text-faint:#71745c;
5367
+ --rk-border:rgba(255,251,230,.10);--rk-border-strong:rgba(255,251,230,.20);
5368
+ --rk-gold-tint:rgba(252,185,0,.14);--rk-gold-tint2:rgba(252,185,0,.22);
5369
+ --rk-shadow-xs:0 1px 2px 0 rgba(0,0,0,.4);
5370
+ background:#0c0f08;
5371
+ }
5372
+ /* surfaces hardcoded #fff in the shared sheet → dark cards on the editor dock */
5373
+ .rk-aichat--editor .rk-aichat-msg.is-ai,
5374
+ .rk-aichat--editor .rk-aichat-composer,
5375
+ .rk-aichat--editor .rk-aichat-tool,
5376
+ .rk-aichat--editor .rk-aichat-back,
5377
+ .rk-aichat--editor .rk-aichat-fstate button,
5378
+ .rk-aichat--editor .rk-aichat-drop,
5379
+ .rk-aichat--editor .rk-aichat-toolnote,
5380
+ .rk-aichat--editor .rk-aichat-http,
5381
+ .rk-aichat--editor .rk-input,
5382
+ .rk-aichat--editor .rk-aichat-loadmore{background:#141810}
5383
+ .rk-aichat--editor .rk-aichat-back:hover{background:#1b2013}
5384
+ .rk-aichat--editor .rk-aichat-loadmore:hover{background:#1b2013}
5385
+ .rk-aichat--editor .rk-input{color:var(--rk-ink)}
5386
+ .rk-aichat--editor .rk-aichat-frow:hover,
5387
+ .rk-aichat--editor .rk-aichat-hist-body .rk-aichat-frow.is-active{background:#1b2013}
5388
+ .rk-aichat--editor .rk-aichat-err{background:#141810;color:#ff9b8f}
5389
+ .rk-aichat--editor .rk-aichat-toolnote.is-ok{color:#7ee2a8}
5390
+ .rk-aichat--editor .rk-aichat-toolnote.is-ok::before{background:#33d17a}
5391
+ .rk-aichat--editor .rk-aichat-toolnote.is-err{color:#ff9b8f}
5392
+ .rk-aichat--editor .rk-aichat-toolnote.is-err::before{background:#ff6a5a}
5393
+
5394
+ /* Hide the SPA's own embedded chat (replaced by the farmville dock). */
5395
+ .chat-dock,.chat-dock-fab{display:none !important}
5396
+ `;
4831
5397
  async function renderHyperframesStudioEditorPage(input) {
4832
5398
  const accountId = input.customer?.id ?? null;
4833
5399
  const compositionId = input.composition.templateId ?? input.composition.slugId;
@@ -4836,6 +5402,8 @@ async function renderHyperframesStudioEditorPage(input) {
4836
5402
  slugId: input.composition.slugId,
4837
5403
  templateId: input.composition.templateId,
4838
5404
  title: input.composition.title,
5405
+ // "raw" = the user's own new/blank project — never auto-prompt Decompose.
5406
+ origin: input.composition.origin ?? null,
4839
5407
  originalUrl: input.composition.originalUrl ?? null,
4840
5408
  songName: input.composition.songName ?? null,
4841
5409
  songUrl: input.composition.songUrl ?? null,
@@ -4857,17 +5425,24 @@ async function renderHyperframesStudioEditorPage(input) {
4857
5425
  <head>
4858
5426
  <meta charset="UTF-8" />
4859
5427
  <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 />
5428
+ <title>${escapeHtml(input.composition.title)} | VidFarm Studio</title>
4863
5429
  <link rel="icon" href="/assets/favicon.ico" />
4864
5430
  <link rel="stylesheet" href="/assets/editor/app.css" />
4865
- <style>html,body{margin:0;background:#050604;color:#fffbe6;}#root{min-height:100vh;}</style>
5431
+ ${RESKIN_FONT_LINKS}
5432
+ <style>${RESKIN_CSS}</style>
5433
+ <style>${EDITOR_FARMVILLE_CSS}</style>
4866
5434
  <script src="/assets/sentry-config.js"></script>
4867
5435
  </head>
4868
- <body>
5436
+ <body data-rk-editor-dock>
4869
5437
  <div id="root"></div>
5438
+ ${renderChatDock("editor")}
4870
5439
  <script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
5440
+ <script>${RESKIN_CHROME_SCRIPT}</script>
5441
+ <!-- Files drawer of the editor dock = the reusable directory-explorer component,
5442
+ auto-mounts [data-rk-directory] (#rkAichatFilesMount). reskinDocument ships
5443
+ this bundle on full-chrome pages, but the editor builds its own document, so
5444
+ it must be loaded here too or the "Your files" panel mounts empty. -->
5445
+ <script type="module" src="/assets/file-directory-app.js"></script>
4871
5446
  <script type="module" src="/assets/editor/app.js"></script>
4872
5447
  </body>
4873
5448
  </html>`;
@@ -4914,7 +5489,11 @@ app.get("/", async (c) => {
4914
5489
  // Each page is a standalone document with its own CSS; renders sample data so
4915
5490
  // it's viewable without auth. Ported pages are wired here one at a time.
4916
5491
  app.get("/reskin", (c) => c.html(renderReskinIndex()));
4917
- app.get("/settings", async (c) => {
5492
+ // Settings lives at /settings/<tab> (profile · wallet · channels · developer).
5493
+ // Tabs still switch client-side without a reload (the page renders every panel
5494
+ // and toggles visibility) but each has its own canonical URL so links,
5495
+ // bookmarks and back/forward land on the right tab.
5496
+ async function renderSettingsTabPage(c, tabOverride) {
4918
5497
  const customer = await requireBrowserCustomer(c);
4919
5498
  if (!customer) {
4920
5499
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -4922,11 +5501,12 @@ app.get("/settings", async (c) => {
4922
5501
  }
4923
5502
  const flash = consumeSettingsFlash(c);
4924
5503
  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";
5504
+ const activeTab = tabOverride
5505
+ ?? (isSettingsTab(flash?.tab)
5506
+ ? flash.tab
5507
+ : isSettingsTab(c.req.query("tab"))
5508
+ ? c.req.query("tab")
5509
+ : "profile");
4930
5510
  const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
4931
5511
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
4932
5512
  const [wallet, walletEvents] = await Promise.all([
@@ -4975,8 +5555,22 @@ app.get("/settings", async (c) => {
4975
5555
  })),
4976
5556
  attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
4977
5557
  }));
4978
- });
4979
- // Library hosts two tabs at distinct URLs (approved projects + mined clips).
5558
+ }
5559
+ // Bare /settings redirects to its canonical first tab; legacy ?tab= / ?connect=
5560
+ // links are preserved by folding them into the path/query of the target.
5561
+ app.get("/settings", (c) => {
5562
+ const q = c.req.query("tab");
5563
+ const tab = isSettingsTab(q) ? q : "profile";
5564
+ const connect = c.req.query("connect");
5565
+ const path = connect ? `/settings/${tab}?connect=${encodeURIComponent(connect)}` : `/settings/${tab}`;
5566
+ return redirect(c, withAccountQuery(path, getRequestedAccountId(c)));
5567
+ });
5568
+ app.get("/settings/profile", (c) => renderSettingsTabPage(c, "profile"));
5569
+ app.get("/settings/wallet", (c) => renderSettingsTabPage(c, "wallet"));
5570
+ app.get("/settings/channels", (c) => renderSettingsTabPage(c, "channels"));
5571
+ app.get("/settings/developer", (c) => renderSettingsTabPage(c, "developer"));
5572
+ // Library hosts tabs at distinct URLs: Approved projects, mined Raws, and the
5573
+ // full-page Files directory explorer (plus a right-floated Logs button → /library/logs).
4980
5574
  app.get("/library", (c) => redirect(c, "/library/approved"));
4981
5575
  const reskinLibraryHandler = async (c, tab) => {
4982
5576
  const customer = await getPreferredBrowserCustomer(c);
@@ -4984,6 +5578,27 @@ const reskinLibraryHandler = async (c, tab) => {
4984
5578
  setLoginReturnPath(c, currentPathWithSearch(c));
4985
5579
  return redirect(c, "/login");
4986
5580
  }
5581
+ // Files is the self-contained directory explorer (client-fetches /me/directory),
5582
+ // so it needs none of the posts/channels payload — skip that comparatively
5583
+ // expensive work for it.
5584
+ if (tab === "files") {
5585
+ return c.html(renderReskinLibrary(tab, {
5586
+ userId: customer.id,
5587
+ currentAccountId: customer.id,
5588
+ name: customer.name,
5589
+ email: customer.email,
5590
+ posts: [],
5591
+ schedule: { channels: [], connectHref: "/settings/channels?connect=flockposter" },
5592
+ editorChat: null,
5593
+ // On a `vidfarm serve` box with a cloud upstream, offer the Local ⇄ Cloud
5594
+ // space switch in the explorer (reads /me/cloud/directory passthrough).
5595
+ cloudSpace: isUpstreamEnabled(),
5596
+ // ?path= deep-links the explorer into a folder (e.g. ?path=/approved/swipes).
5597
+ // Safe raw: the client's parseDirectoryPath only honors the known roots and
5598
+ // drops anything else back to the virtual root "/".
5599
+ initialFilesPath: (c.req.query("path") ?? "").trim() || undefined
5600
+ }));
5601
+ }
4987
5602
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
4988
5603
  const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
4989
5604
  const scheduleChannels = [
@@ -4993,7 +5608,12 @@ const reskinLibraryHandler = async (c, tab) => {
4993
5608
  const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
4994
5609
  .filter((post) => (post.source ?? "legacy") === "hyperframes")
4995
5610
  .filter((post) => post.status === "ready" || post.status === "scheduled")
4996
- .map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
5611
+ .map(async (post) => {
5612
+ // Backfill the rendered MP4 for swipe-approved posts whose detached
5613
+ // finalize poll died on Lambda before it could attach the media.
5614
+ const reconciled = await reconcileReadyPostMedia(customer.id, post);
5615
+ return { ...serializeReadyPost(c, reconciled, { includePrivate: true }), template_id: reconciled.compositionId ?? null };
5616
+ }));
4997
5617
  const posts = await mergeUpstreamReadyPosts(localPosts);
4998
5618
  return c.html(renderReskinLibrary(tab, {
4999
5619
  userId: customer.id,
@@ -5001,23 +5621,114 @@ const reskinLibraryHandler = async (c, tab) => {
5001
5621
  name: customer.name,
5002
5622
  email: customer.email,
5003
5623
  posts,
5004
- schedule: { channels: scheduleChannels, connectHref: "/settings?tab=channels&connect=flockposter" },
5624
+ schedule: { channels: scheduleChannels, connectHref: "/settings/channels?connect=flockposter" },
5005
5625
  editorChat: null
5006
5626
  }));
5007
5627
  };
5008
5628
  app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
5009
5629
  app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
5010
- app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, folded in
5630
+ app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, floated-right button (not a tab)
5631
+ app.get("/library/files", (c) => reskinLibraryHandler(c, "files")); // full-page file-directory explorer
5011
5632
  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.
5633
+ // Discover has three modes, each on its own path: /discover (feed default),
5634
+ // /discover/swipe, /discover/categories. These exact page paths sit alongside
5635
+ // the JSON APIs at /discover/feed, /discover/popular and /discover/swipe/*
5636
+ // (which are all deeper/other paths, so there's no collision).
5637
+ // Shared feed builder — the single source of both the JSON /discover/feed
5638
+ // response and the SSR seed inlined into the reskin Feed page's boot. Feed
5639
+ // browses INSPIRATIONS (the caller's OWN inspirations plus the curated
5640
+ // popularGroup catalog), never the global public pool; raw own-projects are
5641
+ // excluded (they live in the Library). NOTE: for a logged-in caller this
5642
+ // finalizes still-downloading inspirations as a side effect — the feed poll
5643
+ // doubles as the completion check.
5644
+ async function collectDiscoverFeedEntries(customer, opts) {
5645
+ const { query, limit } = opts;
5646
+ const entries = [];
5647
+ const seenInspirations = new Set();
5648
+ const pushInspiration = (inspiration) => {
5649
+ if (seenInspirations.has(inspiration.id))
5650
+ return;
5651
+ if ((inspiration.origin ?? "inspiration") === "raw")
5652
+ return;
5653
+ seenInspirations.add(inspiration.id);
5654
+ entries.push(buildInspirationFeedEntry(inspiration));
5655
+ };
5656
+ if (customer) {
5657
+ // The caller's own inspirations rank first (finalized → "ready" here).
5658
+ const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id));
5659
+ const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
5660
+ finalized
5661
+ .filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
5662
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
5663
+ .forEach(pushInspiration);
5664
+ }
5665
+ // Curated/official catalog: public, ready inspirations flagged popularGroup.
5666
+ const curated = (await serverlessRecords.listInspirationsFeed({ limit, query }))
5667
+ .filter((inspiration) => typeof inspiration.popularGroup === "string" && inspiration.popularGroup.trim().length > 0);
5668
+ curated.forEach(pushInspiration);
5669
+ // Cloud passthrough (vidfarm serve): surface the upstream Feed behind the
5670
+ // local one, deduped on inspirationId (→ templateId). Fail-soft.
5671
+ if (isUpstreamEnabled()) {
5672
+ const search = new URLSearchParams({ limit: String(limit) });
5673
+ if (query)
5674
+ search.set("q", query);
5675
+ const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
5676
+ const seen = new Set(entries.map((entry) => entry.inspirationId || entry.templateId));
5677
+ for (const entry of upstreamFeed) {
5678
+ const key = entry && (entry.inspirationId || entry.templateId);
5679
+ if (entry && typeof key === "string" && key && !seen.has(key) && entry.origin !== "raw") {
5680
+ entries.push(entry);
5681
+ seen.add(key);
5682
+ }
5683
+ }
5684
+ }
5685
+ return entries.slice(0, limit);
5686
+ }
5687
+ const renderDiscoverMode = async (c, mode) => {
5688
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5689
+ let feedInitial;
5690
+ let feedFetchedAt;
5691
+ if (mode === "feed") {
5692
+ // SSR-seed the first page of the default feed into the boot so the client
5693
+ // paints instantly, skipping the redundant follow-up fetch. Fail-soft: on
5694
+ // any error we omit the seed and the client cold-fetches as before. The
5695
+ // limit mirrors the client's un-parameterized fetch (clampPageLimit → 100).
5696
+ try {
5697
+ feedInitial = await collectDiscoverFeedEntries(customer, { query: null, limit: clampPageLimit(Number.NaN, 100, 200) });
5698
+ feedFetchedAt = new Date().toISOString();
5699
+ }
5700
+ catch {
5701
+ feedInitial = undefined;
5702
+ }
5703
+ }
5704
+ return c.html(renderReskinDiscover(mode, {
5705
+ accountId: customer?.id,
5706
+ isLoggedIn: Boolean(customer),
5707
+ name: customer?.name ?? null,
5708
+ email: customer?.email ?? null,
5709
+ feedInitial,
5710
+ feedFetchedAt
5711
+ }));
5712
+ };
5014
5713
  app.get("/discover", async (c) => {
5714
+ // Every mode has its own canonical path — Feed included — so redirect the bare
5715
+ // /discover (and old ?view= links) onto it. Feed then lives at /discover/feed,
5716
+ // which content-negotiates: browser navigations get the page, fetch/API get
5717
+ // the JSON feed (see the /discover/feed handler below).
5015
5718
  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) => {
5719
+ const account = c.req.query(ACCOUNT_QUERY_PARAM);
5720
+ const suffix = account ? `?${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(account)}` : "";
5721
+ if (view === "swipe" || view === "categories") {
5722
+ return redirect(c, `/discover/${view}${suffix}`);
5723
+ }
5724
+ return redirect(c, `/discover/feed${suffix}`);
5725
+ });
5726
+ app.get("/discover/swipe", (c) => renderDiscoverMode(c, "swipe"));
5727
+ app.get("/discover/categories", (c) => renderDiscoverMode(c, "categories"));
5728
+ // Brainstorm chat. /chat is a fresh conversation; /chat/:id deep-links to a
5729
+ // saved thread (the client auto-opens it and keeps the URL in sync as the user
5730
+ // switches / starts conversations).
5731
+ const renderChatBrainstorm = async (c, initialThreadId) => {
5021
5732
  const customer = await getPreferredBrowserCustomer(c);
5022
5733
  if (!customer) {
5023
5734
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5029,11 +5740,33 @@ app.get("/chat", async (c) => {
5029
5740
  name: customer.name,
5030
5741
  email: customer.email,
5031
5742
  editorChat: await buildChatBrainstormEditorChatBoot({ customer })
5032
- }));
5033
- });
5034
- // /reskin/inpaint — the "Create Image" masked-image editor (reached from the
5035
- // chat page's studio-mode select). Auth-gated like the other tools.
5036
- app.get("/inpaint", async (c) => {
5743
+ }, { initialThreadId: initialThreadId ?? null }));
5744
+ };
5745
+ app.get("/chat", (c) => renderChatBrainstorm(c));
5746
+ app.get("/chat/:id", (c) => renderChatBrainstorm(c, c.req.param("id")));
5747
+ // Boot for the global pop-panel AI chat (renderChatDock, on every reskin page).
5748
+ // The dock is shared chrome so we can't thread a boot through every route —
5749
+ // instead it fetches this lazily on first open (cookie-authed, same-origin) to
5750
+ // get the real editor-chat endpoint + api key + brainstorm template context.
5751
+ // Returns { editorChat: null } for signed-out visitors so the dock degrades to
5752
+ // a friendly "sign in to chat" state instead of erroring.
5753
+ app.get("/chat-dock/boot", async (c) => {
5754
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5755
+ if (!customer)
5756
+ return c.json({ editorChat: null });
5757
+ return c.json({ editorChat: await buildChatBrainstormEditorChatBoot({ customer }) });
5758
+ });
5759
+ // The creative tools are three twins under /tools: /tools/image (masked image
5760
+ // edits), /tools/video (start/end-frame + time-scoped video edits), and
5761
+ // /tools/clipper (subrange-clip a source video into the raws library). The bare
5762
+ // /tools redirects to the image editor, preserving any ?source_image_url /
5763
+ // ?tracer preload params. The legacy /inpaint/* paths 302 here for back-compat
5764
+ // so existing deep-links (and editor "Edit Media") keep working.
5765
+ app.get("/tools", (c) => {
5766
+ const search = new URL(c.req.url).search;
5767
+ return redirect(c, "/tools/image" + search);
5768
+ });
5769
+ app.get("/tools/image", async (c) => {
5037
5770
  const customer = await getOptionalPreferredBrowserCustomer(c);
5038
5771
  if (!customer) {
5039
5772
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5041,6 +5774,27 @@ app.get("/inpaint", async (c) => {
5041
5774
  }
5042
5775
  return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
5043
5776
  });
5777
+ app.get("/tools/video", async (c) => {
5778
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5779
+ if (!customer) {
5780
+ setLoginReturnPath(c, currentPathWithSearch(c));
5781
+ return redirect(c, "/login");
5782
+ }
5783
+ return c.html(renderReskinInpaintVideo({ accountId: customer.id, isLoggedIn: true }));
5784
+ });
5785
+ app.get("/tools/clipper", async (c) => {
5786
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5787
+ if (!customer) {
5788
+ setLoginReturnPath(c, currentPathWithSearch(c));
5789
+ return redirect(c, "/login");
5790
+ }
5791
+ return c.html(renderReskinInpaintClipper({ accountId: customer.id, isLoggedIn: true }));
5792
+ });
5793
+ // Legacy /inpaint/* → /tools/* back-compat redirects (preserve query string).
5794
+ app.get("/inpaint", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5795
+ app.get("/inpaint/image", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5796
+ app.get("/inpaint/video", (c) => redirect(c, "/tools/video" + new URL(c.req.url).search));
5797
+ app.get("/inpaint/clipper", (c) => redirect(c, "/tools/clipper" + new URL(c.req.url).search));
5044
5798
  app.get("/calendar", async (c) => {
5045
5799
  const customer = await requireBrowserCustomer(c);
5046
5800
  if (!customer) {
@@ -5065,7 +5819,7 @@ app.get("/calendar", async (c) => {
5065
5819
  posts: [],
5066
5820
  loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
5067
5821
  hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
5068
- connectChannelsHref: "/settings?tab=channels&connect=flockposter"
5822
+ connectChannelsHref: "/settings/channels?connect=flockposter"
5069
5823
  }));
5070
5824
  });
5071
5825
  // Job runs were renamed and folded into the Library "Logs" tab.
@@ -5166,10 +5920,10 @@ app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
5166
5920
  app.get("/reskin/discover", (c) => redirect(c, "/discover"));
5167
5921
  app.get("/reskin/discover/:mode", (c) => {
5168
5922
  const m = c.req.param("mode");
5169
- return redirect(c, m === "swipe" || m === "categories" ? `/discover?view=${m}` : "/discover");
5923
+ return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
5170
5924
  });
5171
5925
  app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5172
- app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
5926
+ app.get("/reskin/inpaint", (c) => redirect(c, "/tools/image"));
5173
5927
  app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
5174
5928
  app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
5175
5929
  app.get("/reskin/agency", (c) => redirect(c, "/agency"));
@@ -5177,9 +5931,74 @@ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5177
5931
  app.get("/reskin/help", (c) => redirect(c, "/help"));
5178
5932
  app.get("/reskin/login", (c) => redirect(c, "/login"));
5179
5933
  app.get("/discover", async (c) => renderApprovedHomepage(c));
5180
- app.get("/editor/:templateId", async (c) => {
5181
- const templateId = c.req.param("templateId");
5934
+ // The sentinel composition/template id for a brand-new blank project. It never
5935
+ // collides with a real template (those are `template_<uuid>`). Pre-fork it is
5936
+ // served the canonical blank scaffold (buildBlankCompositionHtml); on first edit
5937
+ // the SPA POSTs it as the fork `source`, minting a per-user fork seeded from
5938
+ // that same scaffold — so "every new blank is a fork of the blank composition".
5939
+ const BLANK_COMPOSITION_ID = "original";
5940
+ // Blank / new-project editor. The "New project" button and the empty-library
5941
+ // CTAs point here. There is no template or fork yet: we boot the SPA into the
5942
+ // canonical blank composition (3 placeholder scenes), and the client mints a
5943
+ // fork_* seeded from it on the first save/generate/decompose, then
5944
+ // history.replaceState's the URL to the /editor/original/fork/:forkId form.
5945
+ async function renderBlankEditorPage(c, titleOverride) {
5946
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5947
+ if (!customer) {
5948
+ setLoginReturnPath(c, currentPathWithSearch(c));
5949
+ return redirect(c, "/login");
5950
+ }
5951
+ const threadParam = c.req.query("thread");
5952
+ const blankComposition = {
5953
+ title: titleOverride?.trim() || "Untitled project",
5954
+ templateId: "original",
5955
+ slugId: "original",
5956
+ difficulty: "easy",
5957
+ viralDna: "",
5958
+ previewUrl: null,
5959
+ approvedAt: null,
5960
+ sourceType: "tiktok",
5961
+ durationSeconds: null,
5962
+ originalUrl: null,
5963
+ songName: null,
5964
+ songUrl: null,
5965
+ // Raw origin: a brand-new project, so the SPA never auto-opens Decompose.
5966
+ origin: "raw",
5967
+ initialThreadId: threadParam ?? null
5968
+ };
5969
+ return c.html(await renderHyperframesStudioEditorPage({ composition: blankComposition, customer }));
5970
+ }
5971
+ // Reload / direct-load of a blank project's own fork URL (/editor/original/fork/
5972
+ // <forkId>) after the SPA minted it. There is no template to resolve — we just
5973
+ // confirm the caller owns this blank fork, then render the same blank editor
5974
+ // shell (the SPA reads the forkId from the URL and the player loads the fork's
5975
+ // stored composition via ?fork=). Anything stale/foreign falls back to a fresh
5976
+ // blank so the user never lands on a 404.
5977
+ async function renderBlankForkEditorPage(c, forkParam) {
5978
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5979
+ if (!customer) {
5980
+ setLoginReturnPath(c, currentPathWithSearch(c));
5981
+ return redirect(c, "/login");
5982
+ }
5983
+ if (forkParam) {
5984
+ const fork = await serverlessRecords.getCompositionFork(forkParam);
5985
+ if (fork && !fork.deletedAt && fork.customerId === customer.id && fork.templateId === BLANK_COMPOSITION_ID) {
5986
+ return renderBlankEditorPage(c, fork.title ?? undefined);
5987
+ }
5988
+ }
5989
+ return redirect(c, "/editor/original/fork/new");
5990
+ }
5991
+ // Shared handler for both the bare /editor/:templateId (resolver) route and the
5992
+ // canonical /editor/:templateId/fork/:forkId (path-form) route. `forkParam` is
5993
+ // the fork id from the URL path (path route) or null (bare route). We validate
5994
+ // it, and when it's missing/stale/uneditable we resolve a per-user fork and
5995
+ // redirect to the canonical path form.
5996
+ async function renderEditorPage(c, templateId, forkParam) {
5182
5997
  const customer = await getOptionalPreferredBrowserCustomer(c);
5998
+ // Blank project forks carry the sentinel templateId, not a real template_*.
5999
+ // Route them to the blank-fork renderer instead of 404'ing.
6000
+ if (templateId === BLANK_COMPOSITION_ID)
6001
+ return renderBlankForkEditorPage(c, forkParam);
5183
6002
  if (!templateId.startsWith("template_"))
5184
6003
  return c.notFound();
5185
6004
  let template = await serverlessRecords.getTemplate(templateId);
@@ -5191,7 +6010,7 @@ app.get("/editor/:templateId", async (c) => {
5191
6010
  await seedFromUpstream({
5192
6011
  customerId: customer.id,
5193
6012
  templateId,
5194
- forkId: c.req.query("fork") ?? undefined,
6013
+ forkId: forkParam ?? undefined,
5195
6014
  shareToken: readShareToken(c) ?? undefined
5196
6015
  });
5197
6016
  template = await serverlessRecords.getTemplate(templateId);
@@ -5202,7 +6021,6 @@ app.get("/editor/:templateId", async (c) => {
5202
6021
  // template id doesn't leak to other accounts.
5203
6022
  if (template.visibility === "private" && template.customerId !== customer?.id)
5204
6023
  return c.notFound();
5205
- const forkParam = c.req.query("fork");
5206
6024
  const threadParam = c.req.query("thread");
5207
6025
  // Validate the caller-provided fork. Stale bookmarks / share links keep
5208
6026
  // pointing at forks we've since hard-deleted, and if we blindly pass the
@@ -5279,11 +6097,11 @@ app.get("/editor/:templateId", async (c) => {
5279
6097
  // source of truth for "which chat is active" via localStorage. Injecting
5280
6098
  // the server's most-recent thread into the URL blows away a fresh
5281
6099
  // client-side "New chat" the user just started but hasn't persisted.
5282
- const path = `/editor/${encodeURIComponent(template.id)}?fork=${encodeURIComponent(targetForkId)}`;
6100
+ const path = `/editor/${encodeURIComponent(template.id)}/fork/${encodeURIComponent(targetForkId)}`;
5283
6101
  return redirect(c, path);
5284
6102
  }
5285
6103
  // No fork exists yet for this template + user — redirect to the bare
5286
- // /editor/{templateId} URL so any stale `?fork=` gets stripped and the
6104
+ // /editor/{templateId} URL so any stale fork segment gets stripped and the
5287
6105
  // Decompose modal opens fresh (client-side flow mints a new fork on
5288
6106
  // first save/decompose).
5289
6107
  if (forkParam) {
@@ -5310,9 +6128,31 @@ app.get("/editor/:templateId", async (c) => {
5310
6128
  initialThreadId: threadParam ?? null
5311
6129
  };
5312
6130
  return c.html(await renderHyperframesStudioEditorPage({ composition: syntheticComposition, customer }));
6131
+ }
6132
+ // Canonical path-form editor route. Sentinel /editor/original/fork/new opens a
6133
+ // blank new project; everything else validates/resolves the fork in the path.
6134
+ app.get("/editor/:templateId/fork/:forkId", async (c) => {
6135
+ const templateId = c.req.param("templateId");
6136
+ const forkId = c.req.param("forkId");
6137
+ if (templateId === "original" && forkId === "new") {
6138
+ return renderBlankEditorPage(c);
6139
+ }
6140
+ return renderEditorPage(c, templateId, forkId);
6141
+ });
6142
+ // Bare editor route: resolves a per-user fork and redirects to the path form.
6143
+ app.get("/editor/:templateId", async (c) => {
6144
+ const templateId = c.req.param("templateId");
6145
+ return renderEditorPage(c, templateId, null);
5313
6146
  });
5314
6147
  app.get("/editor/:templateId/composition", async (c) => {
5315
6148
  const templateId = c.req.param("templateId");
6149
+ // Blank new project: the SPA fetches this to seed its client-side timeline
6150
+ // state. Serve the same canonical blank scaffold the player iframe gets (raw,
6151
+ // no runtime — the studio only parses the clips out of it) so client state and
6152
+ // the rendered player agree on element ids.
6153
+ if (templateId === BLANK_COMPOSITION_ID) {
6154
+ return c.html(buildBlankCompositionHtml(templateId), 200, { "cache-control": "no-store" });
6155
+ }
5316
6156
  if (!templateId.startsWith("template_"))
5317
6157
  return c.notFound();
5318
6158
  const template = await serverlessRecords.getTemplate(templateId);
@@ -5336,17 +6176,40 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}`, (c) => redirect(c, withAccountQuery("/libr
5336
6176
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/discover"));
5337
6177
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
5338
6178
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute(c, "/chat"));
6179
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat/:id`, (c) => dispatchScopedGetToLegacyRoute(c, `/chat/${c.req.param("id")}`));
5339
6180
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
5340
6181
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
5341
- app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
6182
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6183
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6184
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
6185
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/clipper`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/clipper"));
6186
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6187
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6188
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
5342
6189
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar"));
5343
6190
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings"));
5344
6191
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs"));
5345
6192
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs/history`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs/history"));
5346
6193
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar/feed`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar/feed"));
5347
6194
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/flockposter`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings/flockposter"));
6195
+ // Per-tab settings pages under an account scope (registered AFTER the static
6196
+ // /settings/flockposter route so that stays reachable).
6197
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/:tab`, (c) => {
6198
+ const tab = c.req.param("tab");
6199
+ return dispatchScopedGetToLegacyRoute(c, `/settings/${isSettingsTab(tab) ? tab : "profile"}`);
6200
+ });
5348
6201
  app.post(`${ACCOUNT_FRONTEND_PREFIX}/settings/wallet/funding-link`, (c) => dispatchScopedPostToLegacyRoute(c, "/settings/wallet/funding-link"));
5349
6202
  app.get("/discover/feed", async (c) => {
6203
+ // Content negotiation: a browser NAVIGATION to /discover/feed (Accept:
6204
+ // text/html) renders the Feed page — this is the canonical Feed URL now that
6205
+ // /discover redirects here. Everything else (the in-page loader, which sends
6206
+ // Accept: application/json, plus agents/devcli/upstream passthrough) falls
6207
+ // through to the JSON feed below. The two never collide because the page's
6208
+ // own fetch is explicitly application/json.
6209
+ const acceptHeader = (c.req.header("accept") || "").toLowerCase();
6210
+ if (acceptHeader.includes("text/html") && !acceptHeader.includes("application/json")) {
6211
+ return renderDiscoverMode(c, "feed");
6212
+ }
5350
6213
  const limit = clampPageLimit(Number(c.req.query("limit") || ""), 100, 200);
5351
6214
  // Optional keyword search so callers (UI, agents, devcli) can ask "which
5352
6215
  // templates suit my <offer>?" and get a ranked subset instead of the full
@@ -5354,66 +6217,12 @@ app.get("/discover/feed", async (c) => {
5354
6217
  // decompose-derived promotions/keywords/summary.
5355
6218
  const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
5356
6219
  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.
6220
+ const entries = await collectDiscoverFeedEntries(customer, { query, limit });
6221
+ // The Feed interleaves the caller's own inspirations, so a logged-in response
6222
+ // must never be served from a shared cache.
5414
6223
  c.header("cache-control", customer ? "private, max-age=10" : "public, max-age=60");
5415
6224
  return c.json({
5416
- templates: templateEntries,
6225
+ templates: entries,
5417
6226
  next_cursor: null
5418
6227
  });
5419
6228
  });
@@ -5562,8 +6371,40 @@ function injectSwipePreviewFit(doc, cardId) {
5562
6371
  function play() {
5563
6372
  try { if (window.__player) { window.__player.play(); } } catch (e) {}
5564
6373
  }
6374
+ // The runtime paints video through a single backing <video> whose CSS
6375
+ // background is near-black, so until a frame decodes the card shows solid
6376
+ // black behind the captions. Drive that element directly as a
6377
+ // belt-and-suspenders fallback: a muted backing video may autoplay even
6378
+ // if the runtime's own play() was swallowed, and forcing a load()/frame
6379
+ // decode guarantees the first frame paints instead of the black
6380
+ // background — many source clips fade in from black, so we also nudge a
6381
+ // hair off 0 to avoid parking on a dark intro frame.
6382
+ var backingVideo = document.querySelector('[data-vf-playback-source]');
6383
+ var framePainted = false;
6384
+ function kickBackingVideo() {
6385
+ if (!backingVideo) return;
6386
+ try { backingVideo.muted = true; backingVideo.playsInline = true; } catch (e) {}
6387
+ try { if (backingVideo.readyState < 2) { backingVideo.load(); } } catch (e) {}
6388
+ try { var p = backingVideo.play(); if (p && p.catch) { p.catch(function () {}); } } catch (e) {}
6389
+ }
6390
+ if (backingVideo) {
6391
+ backingVideo.addEventListener('loadeddata', function () {
6392
+ kickBackingVideo();
6393
+ if (!framePainted) {
6394
+ framePainted = true;
6395
+ // Only nudge if the runtime hasn't already advanced playback.
6396
+ try {
6397
+ if (backingVideo.currentTime < 0.05 && (!window.__player || !window.__player.isPlaying())) {
6398
+ backingVideo.currentTime = 0.12;
6399
+ }
6400
+ } catch (e) {}
6401
+ }
6402
+ });
6403
+ backingVideo.addEventListener('canplay', kickBackingVideo);
6404
+ }
5565
6405
  applySound(false);
5566
6406
  play();
6407
+ kickBackingVideo();
5567
6408
  var lastWant = false;
5568
6409
  var stalled = 0;
5569
6410
  var mutedFallback = false;
@@ -5572,7 +6413,7 @@ function injectSwipePreviewFit(doc, cardId) {
5572
6413
  var want = wantSound();
5573
6414
  if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
5574
6415
  applySound(want && !mutedFallback);
5575
- if (!window.__player || !window.__player.getDuration) { return; }
6416
+ if (!window.__player || !window.__player.getDuration) { kickBackingVideo(); return; }
5576
6417
  var t = window.__player.getTime(), d = window.__player.getDuration();
5577
6418
  if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
5578
6419
  else if (!window.__player.isPlaying()) {
@@ -5582,6 +6423,7 @@ function injectSwipePreviewFit(doc, cardId) {
5582
6423
  // the preview never freezes on a frame.
5583
6424
  if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
5584
6425
  play();
6426
+ kickBackingVideo();
5585
6427
  } else { stalled = 0; }
5586
6428
  } catch (e) {}
5587
6429
  }, 400);
@@ -5658,6 +6500,12 @@ app.post("/discover/swipe/:id/approve", async (c) => {
5658
6500
  status: "ready",
5659
6501
  compositionId: fork.id,
5660
6502
  tracer: `swipe:${record.id}`,
6503
+ // All approved swipe cuts collect into ONE canonical /approved/swipes folder.
6504
+ // Without this explicit path the tracer-slug default would mint a unique
6505
+ // per-swipe folder (swipe-swipe-<id>) — 1 post each — which is the clutter
6506
+ // seen on /library/approved. The tracer still carries the swipe id for
6507
+ // job-run correlation.
6508
+ folderPath: "swipes",
5661
6509
  title: record.tailoredTitle,
5662
6510
  caption: record.tailoredCaption,
5663
6511
  pinnedComment: record.tailoredPinnedComment,
@@ -5766,6 +6614,50 @@ async function finalizeSwipeApproveRender(input) {
5766
6614
  }
5767
6615
  }
5768
6616
  }
6617
+ // Self-heal a ready post that is still missing its rendered media. Swipe-approve
6618
+ // mints the post with EMPTY media and relies on the fire-and-forget
6619
+ // finalizeSwipeApproveRender poll to backfill the finished MP4 — but that
6620
+ // detached poll does NOT survive a serverless/Lambda response, so on prod the
6621
+ // render completes (the Step Functions worker is durable) yet the MP4 is never
6622
+ // attached, and the library shows "No preview" forever. Reconciling on read
6623
+ // closes that gap: for any ready hyperframes post with no media, resolve its
6624
+ // fork's render job and attach the completed MP4 when the render has finished.
6625
+ // No-ops (short-circuits) once media exists, so a post is reconciled at most
6626
+ // once. Best-effort: any failure leaves the post untouched.
6627
+ async function reconcileReadyPostMedia(customerId, post) {
6628
+ if (post.mediaAssets.length > 0)
6629
+ return post;
6630
+ if ((post.source ?? "legacy") !== "hyperframes" || !post.compositionId)
6631
+ return post;
6632
+ try {
6633
+ const fork = await serverlessRecords.getCompositionFork(post.compositionId);
6634
+ const renderJobId = fork?.currentRenderJobId;
6635
+ if (!renderJobId)
6636
+ return post;
6637
+ const job = await jobs.getJobAsync(renderJobId);
6638
+ const status = String(asRecord(job)?.status ?? "").toLowerCase();
6639
+ if (status !== "succeeded" && status !== "completed")
6640
+ return post;
6641
+ const url = extractOutputUrlFromJob(job);
6642
+ if (!url)
6643
+ return post;
6644
+ const slug = (post.title || "swipe-ad").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 40) || "swipe-ad";
6645
+ const asset = {
6646
+ id: `render-${renderJobId}`,
6647
+ url,
6648
+ fileName: `${slug}.mp4`,
6649
+ contentType: "video/mp4",
6650
+ kind: "video",
6651
+ role: "primary"
6652
+ };
6653
+ const updated = await serverlessRecords.setReadyPostMedia({ customerId, postId: post.id, mediaAssets: [asset] });
6654
+ return updated ?? { ...post, mediaAssets: [asset] };
6655
+ }
6656
+ catch (error) {
6657
+ console.error("ready-post media reconcile failed", post.id, error instanceof Error ? error.message : error);
6658
+ return post;
6659
+ }
6660
+ }
5769
6661
  function buildTemplateHomepageEntry(template) {
5770
6662
  // Undecomposed templates (no defaultForkId yet) still appear on /discover so
5771
6663
  // users can open them and mint the first fork via the Decompose modal.
@@ -6091,6 +6983,36 @@ app.post("/discover/templates", async (c) => {
6091
6983
  });
6092
6984
  return c.json(serializeInspiration(inspiration), 202);
6093
6985
  });
6986
+ // Remix (decompose on demand): mint the template for a Feed inspiration the
6987
+ // moment the user chooses to open it. The Feed browses lightweight inspirations
6988
+ // with no template, so this is where a template is first created — for the
6989
+ // caller's own inspirations and for curated/public ones alike (a non-owner then
6990
+ // gets their own fork when the editor opens template_<id>). Idempotent: an
6991
+ // already-decomposed inspiration just returns its existing template id.
6992
+ app.post("/discover/inspirations/:id/decompose", async (c) => {
6993
+ const customer = await requireBrowserCustomer(c);
6994
+ if (!customer)
6995
+ return c.json({ ok: false, error: "Log in to remix an inspiration." }, 401);
6996
+ const inspiration = await serverlessRecords.getInspiration(c.req.param("id"));
6997
+ if (!inspiration)
6998
+ return c.json({ ok: false, error: "Inspiration not found." }, 404);
6999
+ // Only the owner may open a PRIVATE inspiration; public/curated ones are
7000
+ // remixable by anyone (the editor forks per-user on open).
7001
+ if (inspiration.visibility === "private" && inspiration.customerId !== customer.id) {
7002
+ return c.notFound();
7003
+ }
7004
+ if (inspiration.templateId) {
7005
+ return c.json({ ok: true, templateId: inspiration.templateId });
7006
+ }
7007
+ if (inspiration.status !== "ready" || !inspiration.videoUrl) {
7008
+ return c.json({ ok: false, error: "This inspiration is still processing — try again in a moment." }, 409);
7009
+ }
7010
+ const decomposed = await mintTemplateForInspiration(inspiration);
7011
+ if (!decomposed.templateId) {
7012
+ return c.json({ ok: false, error: "Could not prepare this inspiration for editing." }, 500);
7013
+ }
7014
+ return c.json({ ok: true, templateId: decomposed.templateId });
7015
+ });
6094
7016
  // Delete a private discover entry the caller owns. Accepts either the minted
6095
7017
  // template id (template_...) or the raw inspiration id (inspiration_..., i.e.
6096
7018
  // a still-processing/failed card) and removes both records. Public catalog
@@ -6144,6 +7066,51 @@ app.delete("/discover/templates/:entryId", async (c) => {
6144
7066
  // on its owner's /discover as a placeholder card so the Add Template flow has
6145
7067
  // immediate feedback. It has no template yet, so the editor CTA is disabled
6146
7068
  // client-side via status.
7069
+ // Feed card for a single INSPIRATION (Feed browses inspirations, not templates).
7070
+ // Lightweight: no template is required. If the inspiration has already minted a
7071
+ // template (templateId set), the card opens the editor directly; otherwise the
7072
+ // card previews the source video and its CTA decomposes on demand (preview →
7073
+ // Remix). Covers ready / downloading / failed states in one shape.
7074
+ function buildInspirationFeedEntry(inspiration) {
7075
+ const isReady = inspiration.status === "ready";
7076
+ const isFailed = inspiration.status === "failed";
7077
+ const title = (inspiration.sourceHost === "upload"
7078
+ ? (inspiration.title || inspiration.trendTagline)
7079
+ : (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New inspiration";
7080
+ // Prefer the raw source video so the Feed + TikTok viewer actually play; fall
7081
+ // back to the poster thumbnail (image) when the video URL isn't available yet.
7082
+ const previewUrl = inspiration.videoUrl || inspiration.thumbnailUrl || "";
7083
+ const viralDna = isFailed
7084
+ ? (inspiration.errorMessage || "Video ingest failed. Try adding it again.")
7085
+ : isReady
7086
+ ? (inspiration.notes || inspiration.searchSummary || inspiration.trendTagline
7087
+ || `A proven viral ${inspiration.sourceHost || "social"} post. Preview it, then remix it into an ad for your product.`)
7088
+ : (inspiration.notes || (inspiration.sourceHost === "upload"
7089
+ ? "Processing your uploaded video. This card unlocks automatically once it's ready."
7090
+ : "Downloading the source video. This card unlocks automatically once it's ready."));
7091
+ return {
7092
+ title,
7093
+ // Real template id when already decomposed; "" means "decompose on open".
7094
+ templateId: inspiration.templateId ?? "",
7095
+ inspirationId: inspiration.id,
7096
+ slugId: inspiration.templateId ?? inspiration.id,
7097
+ difficulty: "easy",
7098
+ viralDna,
7099
+ previewUrl,
7100
+ approvedAt: inspiration.createdAt,
7101
+ sourceType: inspiration.sourceHost || "social",
7102
+ durationSeconds: inspiration.durationSeconds ?? null,
7103
+ promotions: inspiration.promotions ?? null,
7104
+ keywords: inspiration.keywords ?? null,
7105
+ summary: inspiration.searchSummary ?? null,
7106
+ visibility: inspiration.visibility,
7107
+ origin: inspiration.origin ?? "inspiration",
7108
+ popularGroup: inspiration.popularGroup ?? null,
7109
+ popularRank: inspiration.popularRank ?? null,
7110
+ notes: inspiration.notes,
7111
+ status: isFailed ? "failed" : isReady ? "ready" : "processing"
7112
+ };
7113
+ }
6147
7114
  function buildPendingInspirationHomepageEntry(inspiration) {
6148
7115
  return {
6149
7116
  // Uploads: explicit title (or file name, stored at submit time) outranks
@@ -6208,6 +7175,23 @@ app.delete(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
6208
7175
  const deleted = await serverlessRecords.deleteReadyPost(customer.id, post.id);
6209
7176
  return c.json({ post: deleted ? serializeReadyPost(c, deleted, { includePrivate: true }) : null });
6210
7177
  });
7178
+ // Move an approved post between /approved folders ({ folder_path }, ""=root).
7179
+ // Mirrors PATCH /raws/:clipId — the /library/approved explorer uses it to
7180
+ // reorganize posts and to materialize a folder when renaming a legacy group.
7181
+ app.patch(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
7182
+ const customer = await requireBrowserCustomer(c);
7183
+ if (!customer) {
7184
+ return c.json({ error: "Unauthorized" }, 401);
7185
+ }
7186
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
7187
+ if (!post || post.status === "deleted" || post.customerId !== customer.id) {
7188
+ return c.json({ error: "Approved post not found." }, 404);
7189
+ }
7190
+ const body = (await c.req.json().catch(() => ({})));
7191
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
7192
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
7193
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
7194
+ });
6211
7195
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId/schedules`, async (c) => {
6212
7196
  const customer = await requireBrowserCustomer(c);
6213
7197
  if (!customer) {
@@ -6538,6 +7522,8 @@ app.get("/assets/sentry-config.js", (c) => {
6538
7522
  });
6539
7523
  app.get("/assets/homepage-client-app.js", (c) => serveRootJsBundle(c, "public/assets/homepage-client-app.js"));
6540
7524
  app.get("/assets/page-runtime-client-app.js", (c) => serveRootJsBundle(c, "public/assets/page-runtime-client-app.js"));
7525
+ app.get("/assets/file-directory-app.js", (c) => serveRootJsBundle(c, "public/assets/file-directory-app.js"));
7526
+ app.get("/assets/discover-client-app.js", (c) => serveRootJsBundle(c, "public/assets/discover-client-app.js"));
6541
7527
  function serveRootJsBundle(c, relativePath) {
6542
7528
  const filePath = resolveRootFileCandidates(relativePath).find((candidate) => existsSync(candidate));
6543
7529
  if (!filePath) {
@@ -6694,6 +7680,14 @@ app.get("/composition/current.html", async (c) => {
6694
7680
  const compositionId = (c.req.query("composition") ?? "").trim();
6695
7681
  if (!compositionId)
6696
7682
  return c.text("Missing composition", 400);
7683
+ // Blank new project (the /editor/original/fork/new sentinel, before its first
7684
+ // edit mints a fork). Serve the canonical blank scaffold — 3 placeholder image
7685
+ // scenes + "Scene N" captions — so the player paints an editable 12s timeline
7686
+ // instead of "Composition not found". Runtime is injected by the rewrite.
7687
+ if (compositionId === BLANK_COMPOSITION_ID) {
7688
+ const rewritten = rewriteHyperframesDraftCompositionHtml(buildBlankCompositionHtml(compositionId), "blank");
7689
+ return c.html(rewritten, 200, { "cache-control": "no-store" });
7690
+ }
6697
7691
  if (compositionId.startsWith("template_")) {
6698
7692
  const template = await serverlessRecords.getTemplate(compositionId);
6699
7693
  if (!template || !template.videoUrl)
@@ -7086,6 +8080,9 @@ async function snapshotForkVersion(input) {
7086
8080
  // Scene annotations (clip-replacement + recreation DNA) likewise travel with
7087
8081
  // every version — reusable decompose metadata, same as placement surfaces.
7088
8082
  const workingAnnotations = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "scene-annotations.json"));
8083
+ // Editor harness (technical editing direction) travels with every version too
8084
+ // — reusable decompose style metadata, same as placement / annotations.
8085
+ const workingHarness = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "editor-harness.json"));
7089
8086
  if (workingHtml) {
7090
8087
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
7091
8088
  }
@@ -7101,6 +8098,9 @@ async function snapshotForkVersion(input) {
7101
8098
  if (workingAnnotations) {
7102
8099
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
7103
8100
  }
8101
+ if (workingHarness) {
8102
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "editor-harness.json"), Buffer.from(workingHarness, "utf8"), "application/json");
8103
+ }
7104
8104
  const versionRecord = await serverlessRecords.createForkVersion({
7105
8105
  forkId: input.fork.id,
7106
8106
  ownerCustomerId: input.fork.customerId,
@@ -7155,58 +8155,71 @@ function serializeFork(fork, caps) {
7155
8155
  : null
7156
8156
  };
7157
8157
  }
7158
- app.post(COMPOSITIONS_PREFIX, async (c) => {
7159
- const customer = await requireBrowserCustomer(c);
7160
- if (!customer)
7161
- return c.json({ ok: false, error: "Unauthorized" }, 401);
7162
- const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
7163
- const templateId = readNonEmptyString(body.template_id) ?? readNonEmptyString(body.source);
7164
- if (!templateId || !templateId.startsWith("template_")) {
7165
- return c.json({ ok: false, error: "Missing or invalid template_id" }, 400);
8158
+ // ── Shared fork-creation helpers ────────────────────────────────────────────
8159
+ // A "new fork" always has one of two SOURCES, and every surface (the editor's
8160
+ // New-fork modal, the AI chat fork_composition action, and the devcli clone
8161
+ // command) funnels through these so the seed logic stays uniform:
8162
+ // • "default" fresh from the template's canonical default fork (start over
8163
+ // from the original template discard the current working edits).
8164
+ // • "current" branch from an existing working copy (clone this fork,
8165
+ // optionally pinned to a saved version).
8166
+ // Copy the canonical working files from a source fork (optionally a pinned
8167
+ // version) into a destination fork's working tree. Returns whether an HTML
8168
+ // body was seeded.
8169
+ async function seedForkWorkingFromFork(destForkId, sourceForkId, version) {
8170
+ const read = async (filename) => {
8171
+ if (version && Number.isFinite(version)) {
8172
+ const versioned = await storage.readText(storage.compositionForkVersionKey(sourceForkId, version, filename));
8173
+ if (versioned)
8174
+ return versioned;
8175
+ }
8176
+ return storage.readText(storage.compositionForkWorkingKey(sourceForkId, filename));
8177
+ };
8178
+ const html = await read("composition.html");
8179
+ const json = await read("composition.json");
8180
+ const cast = await read("cast.json");
8181
+ const placement = await read("product-placement.json");
8182
+ const annotations = await read("scene-annotations.json");
8183
+ const harness = await read("editor-harness.json");
8184
+ if (html) {
8185
+ await storage.putText(storage.compositionForkWorkingKey(destForkId, "composition.html"), html, "text/html; charset=utf-8");
7166
8186
  }
7167
- const template = await serverlessRecords.getTemplate(templateId);
7168
- if (!template)
7169
- return c.json({ ok: false, error: "Template not found" }, 404);
8187
+ if (json) {
8188
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "composition.json"), Buffer.from(json, "utf8"), "application/json");
8189
+ }
8190
+ if (cast) {
8191
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8192
+ }
8193
+ if (placement) {
8194
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
8195
+ }
8196
+ if (annotations) {
8197
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8198
+ }
8199
+ if (harness) {
8200
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "editor-harness.json"), Buffer.from(harness, "utf8"), "application/json");
8201
+ }
8202
+ return Boolean(html);
8203
+ }
8204
+ // Fork FRESH from a template's canonical default fork (from: "default"). Never
8205
+ // overwrites template.defaultForkId — that stays canonical so other users keep
8206
+ // getting it as their fallback. No version snapshot (matches a first fork).
8207
+ async function forkCompositionFromTemplateDefault(customerId, template, title) {
7170
8208
  const canonicalFork = template.defaultForkId
7171
8209
  ? await serverlessRecords.getCompositionFork(template.defaultForkId)
7172
8210
  : null;
7173
- const title = readNonEmptyString(body.title) ?? template.title ?? `Template ${template.id}`;
7174
- // Per-user fork: never overwrite template.defaultForkId. That stays as the
7175
- // canonical decomposition so other users continue to get it as their
7176
- // fallback.
7177
8211
  const fork = await serverlessRecords.createCompositionFork({
7178
- customerId: customer.id,
8212
+ customerId,
7179
8213
  templateId: template.id,
7180
8214
  parentForkId: canonicalFork?.id ?? null,
7181
8215
  parentVersion: canonicalFork?.latestVersion ?? null,
7182
8216
  visibility: "private",
7183
8217
  title
7184
8218
  });
7185
- // Seed new fork with the canonical composition html/json if available so
7186
- // it starts from the same base as everyone else.
7187
8219
  if (canonicalFork) {
7188
- const html = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "composition.html"));
7189
- const json = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "composition.json"));
7190
- // Seed the canonical cast so every user's fresh fork inherits the same
7191
- // identified people + reference stills as the shared default composition.
7192
- const cast = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "cast.json"));
7193
- const placement = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "product-placement.json"));
7194
- const annotations = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"));
7195
- if (html) {
7196
- await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
7197
- }
7198
- if (json) {
7199
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "composition.json"), Buffer.from(json, "utf8"), "application/json");
7200
- }
7201
- if (cast) {
7202
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
7203
- }
7204
- if (placement) {
7205
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
7206
- }
7207
- if (annotations) {
7208
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
7209
- }
8220
+ // Seed with the canonical composition (html/json/cast/placement/annotations)
8221
+ // so it starts from the same base as everyone else.
8222
+ await seedForkWorkingFromFork(fork.id, canonicalFork.id, null);
7210
8223
  }
7211
8224
  else if (template.videoUrl) {
7212
8225
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -7220,6 +8233,71 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
7220
8233
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rawHtml, "text/html; charset=utf-8");
7221
8234
  }
7222
8235
  await writeForkManifest(storage, fork, { kind: "working" });
8236
+ return fork;
8237
+ }
8238
+ // Fork from an EXISTING fork (from: "current"). Copies its working (or a pinned
8239
+ // version) files into a new private fork and snapshots v-next.
8240
+ async function forkCompositionFromExistingFork(customerId, sourceFork, parentVersion, title) {
8241
+ const cloned = await serverlessRecords.createCompositionFork({
8242
+ customerId,
8243
+ templateId: sourceFork.templateId,
8244
+ parentForkId: sourceFork.id,
8245
+ parentVersion: parentVersion ?? null,
8246
+ visibility: "private",
8247
+ title
8248
+ });
8249
+ await seedForkWorkingFromFork(cloned.id, sourceFork.id, parentVersion);
8250
+ await snapshotForkVersion({
8251
+ fork: cloned,
8252
+ callerCustomerId: customerId,
8253
+ reason: "clone",
8254
+ message: `Cloned from ${sourceFork.id}${parentVersion ? `@v${parentVersion}` : ""}`
8255
+ });
8256
+ return (await serverlessRecords.getCompositionFork(cloned.id));
8257
+ }
8258
+ // Mint a fresh per-user fork of the canonical blank composition. There is no
8259
+ // template record — the fork carries the sentinel templateId so it stays
8260
+ // isolated per customer — and its working copy is seeded with the same blank
8261
+ // scaffold the pre-fork editor shows, so the user's first edit lands on the 3
8262
+ // placeholder scenes and now persists. This is the "every new blank is a fork of
8263
+ // the blank composition" path (see BLANK_COMPOSITION_ID).
8264
+ async function forkBlankComposition(customerId, title) {
8265
+ const fork = await serverlessRecords.createCompositionFork({
8266
+ customerId,
8267
+ templateId: BLANK_COMPOSITION_ID,
8268
+ parentForkId: null,
8269
+ parentVersion: null,
8270
+ visibility: "private",
8271
+ title
8272
+ });
8273
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), buildBlankCompositionHtml(fork.id), "text/html; charset=utf-8");
8274
+ await writeForkManifest(storage, fork, { kind: "working" });
8275
+ return (await serverlessRecords.getCompositionFork(fork.id));
8276
+ }
8277
+ app.post(COMPOSITIONS_PREFIX, async (c) => {
8278
+ const customer = await requireBrowserCustomer(c);
8279
+ if (!customer)
8280
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
8281
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
8282
+ const templateId = readNonEmptyString(body.template_id) ?? readNonEmptyString(body.source);
8283
+ // Blank project: the SPA POSTs source="original" on the first edit. Mint a
8284
+ // fork seeded from the canonical blank scaffold instead of rejecting it, so
8285
+ // edits on a new project persist (previously blank edits were lost on reload).
8286
+ if (templateId === BLANK_COMPOSITION_ID) {
8287
+ const blankTitle = readNonEmptyString(body.title) ?? "Untitled project";
8288
+ const fork = await forkBlankComposition(customer.id, blankTitle);
8289
+ return c.json(serializeFork(fork, {
8290
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
8291
+ }), 201);
8292
+ }
8293
+ if (!templateId || !templateId.startsWith("template_")) {
8294
+ return c.json({ ok: false, error: "Missing or invalid template_id" }, 400);
8295
+ }
8296
+ const template = await serverlessRecords.getTemplate(templateId);
8297
+ if (!template)
8298
+ return c.json({ ok: false, error: "Template not found" }, 404);
8299
+ const title = readNonEmptyString(body.title) ?? template.title ?? `Template ${template.id}`;
8300
+ const fork = await forkCompositionFromTemplateDefault(customer.id, template, title);
7223
8301
  return c.json(serializeFork(fork, {
7224
8302
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
7225
8303
  }), 201);
@@ -7263,7 +8341,11 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/composition.html`, async (c) => {
7263
8341
  const html = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "composition.html"));
7264
8342
  if (!html)
7265
8343
  return c.json({ ok: false, error: "Composition missing" }, 404);
7266
- return c.body(html, 200, { "content-type": "text/html; charset=utf-8" });
8344
+ // The editor stage loads the composition here (then injects its own runtime),
8345
+ // so unmute the not-yet-decomposed raw single-video base so its own audio
8346
+ // plays. Older stored raw bases baked in a `muted` attribute; strip it on the
8347
+ // way out. Decomposed compositions are left untouched (see helper).
8348
+ return c.body(unmuteRawPlaybackSourceVideo(html), 200, { "content-type": "text/html; charset=utf-8" });
7267
8349
  }
7268
8350
  catch (error) {
7269
8351
  return forkAccessErrorResponse(c, error);
@@ -7716,6 +8798,24 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
7716
8798
  catch (placementPersistError) {
7717
8799
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
7718
8800
  }
8801
+ // Persist the editor harness on the canonical fork too, before the
8802
+ // snapshot, so it travels with v1 and seeds every downstream fork.
8803
+ try {
8804
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "editor-harness.json"), {
8805
+ version: 1,
8806
+ savedAtMs: Date.now(),
8807
+ compositionId: canonicalFork.id,
8808
+ sourceUrl,
8809
+ title,
8810
+ provider: smart.provider,
8811
+ model: smart.model,
8812
+ durationSeconds: smart.durationSeconds,
8813
+ harness: smart.editorHarness
8814
+ });
8815
+ }
8816
+ catch (harnessPersistError) {
8817
+ console.error("inspiration editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
8818
+ }
7719
8819
  // Seed the scene-annotation pass "pending" on the canonical fork too,
7720
8820
  // before the snapshot, so the pending state travels with v1 and every
7721
8821
  // downstream fork can drive /scene-annotations-poll (or inherit the
@@ -8188,6 +9288,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8188
9288
  result: {
8189
9289
  summary: smart.summary,
8190
9290
  viralDna: smart.viralDna,
9291
+ editorHarness: smart.editorHarness,
8191
9292
  transcript: smart.transcript,
8192
9293
  durationSeconds: smart.durationSeconds,
8193
9294
  width: smart.width,
@@ -8222,6 +9323,27 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8222
9323
  catch (placementPersistError) {
8223
9324
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
8224
9325
  }
9326
+ // Persist the EDITOR HARNESS as its own decompose artifact (mirrors
9327
+ // product-placement.json). Technical editing direction — read-only style
9328
+ // context injected into both the web /editor AI (via the editor_context
9329
+ // snapshot's editor_harness field, parsed from data-editor-harness in the
9330
+ // HTML) and the local devcli agent (via this synced file). Non-fatal.
9331
+ try {
9332
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "editor-harness.json"), {
9333
+ version: 1,
9334
+ savedAtMs: Date.now(),
9335
+ compositionId,
9336
+ sourceUrl: analysisSourceUrl,
9337
+ title,
9338
+ provider: smart.provider,
9339
+ model: smart.model,
9340
+ durationSeconds: smart.durationSeconds,
9341
+ harness: smart.editorHarness
9342
+ });
9343
+ }
9344
+ catch (harnessPersistError) {
9345
+ console.error("editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
9346
+ }
8225
9347
  // Seed the per-scene clip-annotation pass as "pending" and let the client
8226
9348
  // drive it via POST /scene-annotations-poll (same async model as cast /
8227
9349
  // ghostcut). The annotation is its own vision call — running it inline would
@@ -8610,7 +9732,11 @@ const pollForkSubtitleRemoval = async (c) => {
8610
9732
  captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
8611
9733
  // Placement surfaces live in their own artifact and aren't used
8612
9734
  // by the HTML rebuild; supply an empty list to satisfy the type.
8613
- placementSurfaces: []
9735
+ placementSurfaces: [],
9736
+ // Editor harness round-trips from the snapshot so the rebuilt
9737
+ // HTML keeps its data-editor-harness; default if a pre-harness
9738
+ // snapshot is being rebuilt.
9739
+ editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
8614
9740
  }
8615
9741
  });
8616
9742
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
@@ -8811,6 +9937,50 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
8811
9937
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
8812
9938
  }
8813
9939
  });
9940
+ // Read-only EDITOR HARNESS for a fork: the technical editing direction extracted
9941
+ // during decompose (pace, typography, b-roll usage, transitions, audio bed, and
9942
+ // the role each scene plays). This is the "how to edit like this" companion to
9943
+ // viral DNA's "why it works". The web /editor AI already receives it inline via
9944
+ // editor_context.editor_harness (parsed from data-editor-harness in the HTML);
9945
+ // this route is how the local devcli agent + http_request tool fetch it.
9946
+ // Optional-auth like /product-placement.json. Returns { status: "none" } when
9947
+ // the fork was never decomposed (or predates this pass).
9948
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/editor-harness.json`, async (c) => {
9949
+ const customer = await getBrowserCustomer(c);
9950
+ try {
9951
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
9952
+ const text = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "editor-harness.json"));
9953
+ if (!text) {
9954
+ return c.json({
9955
+ ok: true,
9956
+ status: "none",
9957
+ hint: "This fork has no editor-harness pass yet. Run POST /api/v1/compositions/:forkId/auto-decompose to generate it."
9958
+ });
9959
+ }
9960
+ let parsed;
9961
+ try {
9962
+ parsed = JSON.parse(text);
9963
+ }
9964
+ catch {
9965
+ return c.json({ ok: true, status: "none" });
9966
+ }
9967
+ const harness = (parsed.harness && typeof parsed.harness === "object") ? parsed.harness : null;
9968
+ return c.json({
9969
+ ok: true,
9970
+ status: harness ? "ready" : "empty",
9971
+ provider: parsed.provider ?? null,
9972
+ model: parsed.model ?? null,
9973
+ source_url: parsed.sourceUrl ?? null,
9974
+ duration_seconds: parsed.durationSeconds ?? null,
9975
+ harness: harness ?? DEFAULT_EDITOR_HARNESS
9976
+ });
9977
+ }
9978
+ catch (error) {
9979
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
9980
+ return forkAccessErrorResponse(c, error);
9981
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9982
+ }
9983
+ });
8814
9984
  // Read-only scene-annotation context for a fork: the detected content FORMAT
8815
9985
  // plus one annotation per scene carrying the literal + viral DNA needed to
8816
9986
  // (a) match a REPLACEMENT clip from the library (each annotation ships a
@@ -9625,6 +10795,42 @@ app.patch(`${COMPOSITIONS_PREFIX}/:forkId/visibility`, async (c) => {
9625
10795
  return forkAccessErrorResponse(c, error);
9626
10796
  }
9627
10797
  });
10798
+ // Unified "new fork" endpoint. from="current" (default) branches from this
10799
+ // fork's working copy (or a pinned version); from="default" starts a fresh copy
10800
+ // from the template's canonical default fork, discarding the current edits.
10801
+ // This is the single REST surface behind the editor's New-fork modal, the AI
10802
+ // chat fork_composition action, and the devcli clone command.
10803
+ app.post(`${COMPOSITIONS_PREFIX}/:forkId/fork`, async (c) => {
10804
+ const customer = await requireBrowserCustomer(c);
10805
+ if (!customer)
10806
+ return c.json({ ok: false, error: "Login required to fork" }, 401);
10807
+ try {
10808
+ const sourceAccess = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer.id, shareToken: readShareToken(c) }, "view");
10809
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
10810
+ const from = readNonEmptyString(body.from) === "default" ? "default" : "current";
10811
+ if (from === "default") {
10812
+ const template = await serverlessRecords.getTemplate(sourceAccess.fork.templateId);
10813
+ if (!template)
10814
+ return c.json({ ok: false, error: "Template not found for this fork" }, 404);
10815
+ const title = readNonEmptyString(body.title) ?? template.title ?? sourceAccess.fork.title;
10816
+ const fresh = await forkCompositionFromTemplateDefault(customer.id, template, title);
10817
+ return c.json(serializeFork(fresh, {
10818
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10819
+ }), 201);
10820
+ }
10821
+ const parentVersionRaw = readNonEmptyString(body.parent_version);
10822
+ const parentVersion = parentVersionRaw ? Number.parseInt(parentVersionRaw, 10) : sourceAccess.fork.latestVersion || null;
10823
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10824
+ return c.json(serializeFork(cloned, {
10825
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10826
+ }), 201);
10827
+ }
10828
+ catch (error) {
10829
+ return forkAccessErrorResponse(c, error);
10830
+ }
10831
+ });
10832
+ // Legacy alias: clone == fork from the current working copy. Retained for
10833
+ // existing callers; new callers should use POST /:forkId/fork { from }.
9628
10834
  app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
9629
10835
  const customer = await requireBrowserCustomer(c);
9630
10836
  if (!customer)
@@ -9634,50 +10840,8 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
9634
10840
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
9635
10841
  const parentVersionRaw = readNonEmptyString(body.parent_version);
9636
10842
  const parentVersion = parentVersionRaw ? Number.parseInt(parentVersionRaw, 10) : sourceAccess.fork.latestVersion || null;
9637
- const cloned = await serverlessRecords.createCompositionFork({
9638
- customerId: customer.id,
9639
- templateId: sourceAccess.fork.templateId,
9640
- parentForkId: sourceAccess.fork.id,
9641
- parentVersion: parentVersion ?? null,
9642
- visibility: "private",
9643
- title: readNonEmptyString(body.title) ?? sourceAccess.fork.title
9644
- });
9645
- const readVersionFile = async (filename) => {
9646
- if (parentVersion && Number.isFinite(parentVersion)) {
9647
- const versioned = await storage.readText(storage.compositionForkVersionKey(sourceAccess.fork.id, parentVersion, filename));
9648
- if (versioned)
9649
- return versioned;
9650
- }
9651
- return storage.readText(storage.compositionForkWorkingKey(sourceAccess.fork.id, filename));
9652
- };
9653
- const html = await readVersionFile("composition.html");
9654
- const json = await readVersionFile("composition.json");
9655
- const cast = await readVersionFile("cast.json");
9656
- const placement = await readVersionFile("product-placement.json");
9657
- const annotations = await readVersionFile("scene-annotations.json");
9658
- if (html) {
9659
- await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
9660
- }
9661
- if (json) {
9662
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "composition.json"), Buffer.from(json, "utf8"), "application/json");
9663
- }
9664
- if (cast) {
9665
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
9666
- }
9667
- if (placement) {
9668
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
9669
- }
9670
- if (annotations) {
9671
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
9672
- }
9673
- await snapshotForkVersion({
9674
- fork: cloned,
9675
- callerCustomerId: customer.id,
9676
- reason: "clone",
9677
- message: `Cloned from ${sourceAccess.fork.id}${parentVersion ? `@v${parentVersion}` : ""}`
9678
- });
9679
- const refreshed = await serverlessRecords.getCompositionFork(cloned.id);
9680
- return c.json(serializeFork(refreshed, {
10843
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10844
+ return c.json(serializeFork(cloned, {
9681
10845
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
9682
10846
  }), 201);
9683
10847
  }
@@ -9827,6 +10991,101 @@ function serializeInspiration(inspiration) {
9827
10991
  updated_at: inspiration.updatedAt
9828
10992
  };
9829
10993
  }
10994
+ // Mint the template for a READY inspiration (idempotent): creates a template
10995
+ // inheriting the inspiration's visibility + origin and links it back via
10996
+ // templateId. Returns the inspiration with templateId set — or unchanged if it
10997
+ // already has one / isn't ready / has no source video. Shared by the eager
10998
+ // (private, on-download) path and the lazy Remix path (POST
10999
+ // /discover/inspirations/:id/decompose).
11000
+ async function mintTemplateForInspiration(inspiration) {
11001
+ if (inspiration.templateId || inspiration.status !== "ready" || !inspiration.videoUrl)
11002
+ return inspiration;
11003
+ const template = await serverlessRecords.createTemplate({
11004
+ inspirationId: inspiration.id,
11005
+ customerId: inspiration.customerId,
11006
+ originalUrl: inspiration.originalUrl,
11007
+ sourceHost: inspiration.sourceHost,
11008
+ // No host/"Untitled" fallback: a template without an explicit title or
11009
+ // tagline displays as its template id on /discover.
11010
+ title: (inspiration.sourceHost === "upload"
11011
+ ? (inspiration.title || inspiration.trendTagline)
11012
+ : (inspiration.trendTagline || inspiration.title)) || null,
11013
+ thumbnailUrl: inspiration.thumbnailUrl,
11014
+ videoUrl: inspiration.videoUrl,
11015
+ durationSeconds: inspiration.durationSeconds,
11016
+ songName: inspiration.songName,
11017
+ songUrl: inspiration.songUrl,
11018
+ visibility: inspiration.visibility === "private" ? "private" : "public",
11019
+ origin: inspiration.origin ?? "inspiration",
11020
+ notes: inspiration.notes
11021
+ });
11022
+ // Seed the template's canonical DEFAULT fork with the raw single-video
11023
+ // timeline so opening the editor lands on an editable project immediately —
11024
+ // the whole clip as ONE element across the trackpad. Because origin stays
11025
+ // "inspiration" the editor still auto-prompts Decompose; decomposing rebuilds
11026
+ // this same default fork in place, so the multi-scene version becomes the new
11027
+ // default everyone forks off. Non-fatal: on failure the template just falls
11028
+ // back to the on-demand raw seed in POST /api/v1/compositions.
11029
+ try {
11030
+ await seedRawDefaultForkForTemplate(template);
11031
+ }
11032
+ catch (error) {
11033
+ console.error("seed raw default fork failed", error instanceof Error ? error.message : error);
11034
+ }
11035
+ return (await serverlessRecords.updateInspiration({
11036
+ inspirationId: inspiration.id,
11037
+ patch: { templateId: template.id }
11038
+ })) ?? { ...inspiration, templateId: template.id };
11039
+ }
11040
+ // Create + stamp a template's canonical DEFAULT fork holding the raw
11041
+ // single-video composition (the full source clip as one timeline element).
11042
+ // Idempotent: returns the existing default fork id if one is already stamped,
11043
+ // or null if the template has no source video to seed from. Owned privately by
11044
+ // the template owner — other users never touch it through permissions; POST
11045
+ // /api/v1/compositions seeds their forks by copying its composition html.
11046
+ async function seedRawDefaultForkForTemplate(template) {
11047
+ if (template.defaultForkId)
11048
+ return template.defaultForkId;
11049
+ const sourceUrl = template.videoUrl?.trim();
11050
+ if (!sourceUrl)
11051
+ return null;
11052
+ const title = template.title ?? `Template ${template.id}`;
11053
+ const fork = await serverlessRecords.createCompositionFork({
11054
+ customerId: template.customerId,
11055
+ templateId: template.id,
11056
+ parentForkId: null,
11057
+ parentVersion: null,
11058
+ visibility: "private",
11059
+ title
11060
+ });
11061
+ const html = buildRawTikTokCompositionHtml({
11062
+ compositionId: fork.id,
11063
+ sourceUrl,
11064
+ durationSeconds: template.durationSeconds ?? 12,
11065
+ title,
11066
+ origin: template.origin ?? "inspiration"
11067
+ });
11068
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
11069
+ await writeForkManifest(storage, fork, { kind: "working" });
11070
+ // Snapshot v1 (reason "clone" bypasses the version ceiling) so version
11071
+ // history + revert work from the very first raw base.
11072
+ try {
11073
+ await snapshotForkVersion({
11074
+ fork,
11075
+ callerCustomerId: template.customerId,
11076
+ reason: "clone",
11077
+ message: "Raw single-video base"
11078
+ });
11079
+ }
11080
+ catch (error) {
11081
+ console.error("seed raw default fork snapshot failed", error instanceof Error ? error.message : error);
11082
+ }
11083
+ const stamp = await serverlessRecords.stampDefaultForkIfUnset({
11084
+ templateId: template.id,
11085
+ forkId: fork.id
11086
+ });
11087
+ return stamp.template?.defaultForkId ?? fork.id;
11088
+ }
9830
11089
  async function finalizeInspirationIfReady(inspiration) {
9831
11090
  if (inspiration.status !== "downloading" || !inspiration.downloadJobId)
9832
11091
  return inspiration;
@@ -9856,38 +11115,12 @@ async function finalizeInspirationIfReady(inspiration) {
9856
11115
  songUrl: songUrl ?? null
9857
11116
  }
9858
11117
  })) ?? 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
- }
11118
+ // EVERY ready inspiration eagerly mints a template + a raw single-video
11119
+ // default fork, so every card opens the editor directly onto an editable
11120
+ // project (the whole clip as one timeline element) with the Decompose
11121
+ // prompt still available. The Feed browses inspirations (deduped on
11122
+ // inspirationId||templateId) so a template per upload doesn't flood it.
11123
+ ready = await mintTemplateForInspiration(ready);
9891
11124
  return ready;
9892
11125
  }
9893
11126
  if (job.status === "failed" || job.status === "cancelled") {
@@ -10826,7 +12059,7 @@ app.get("/library", async (c) => {
10826
12059
  posts,
10827
12060
  schedule: {
10828
12061
  channels: scheduleChannels,
10829
- connectHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
12062
+ connectHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
10830
12063
  },
10831
12064
  editorChat: await buildLibraryEditorChatBoot({
10832
12065
  customer,
@@ -10997,7 +12230,7 @@ app.get("/calendar", async (c) => {
10997
12230
  posts: [],
10998
12231
  loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
10999
12232
  hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
11000
- connectChannelsHref: withAccountQuery("/settings?tab=channels&connect=flockposter", customer.id)
12233
+ connectChannelsHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
11001
12234
  }));
11002
12235
  });
11003
12236
  app.get("/agency", async (c) => {
@@ -11307,7 +12540,7 @@ app.get("/email-channels/confirm", async (c) => {
11307
12540
  <h1 style="overflow-wrap: anywhere;">${escapeHtml(title)}</h1>
11308
12541
  <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
12542
  <div class="toolbar">
11310
- <a class="button" href="/settings?tab=channels">Go to settings</a>
12543
+ <a class="button" href="/settings/channels">Go to settings</a>
11311
12544
  <a class="button secondary" href="/discover">Back to Discover</a>
11312
12545
  </div>
11313
12546
  </section>
@@ -11376,6 +12609,15 @@ app.post("/settings/attachments", async (c) => {
11376
12609
  const attachmentId = randomUUID();
11377
12610
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName);
11378
12611
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
12612
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
12613
+ const thumb = await ensureAttachmentThumbnail({
12614
+ customerId: customer.id,
12615
+ attachmentId,
12616
+ storageKey,
12617
+ contentType,
12618
+ attachmentUrl: publicUrl,
12619
+ sourceUrl: publicUrl
12620
+ });
11379
12621
  await serverlessRecords.createUserAttachment({
11380
12622
  id: attachmentId,
11381
12623
  customerId: customer.id,
@@ -11383,7 +12625,9 @@ app.post("/settings/attachments", async (c) => {
11383
12625
  contentType,
11384
12626
  sizeBytes: buffer.byteLength,
11385
12627
  storageKey,
11386
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey)
12628
+ publicUrl,
12629
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
12630
+ thumbnailUrl: thumb?.thumbnailUrl ?? null
11387
12631
  });
11388
12632
  }
11389
12633
  return redirectToSettings(c, { notice: "Attachment upload complete.", accountId: customer.id });
@@ -12712,6 +13956,7 @@ function serializeReadyPost(c, post, input) {
12712
13956
  status: post.status,
12713
13957
  title: post.title,
12714
13958
  caption: post.caption,
13959
+ folder_path: normalizeFolderPath(post.folderPath ?? ""),
12715
13960
  pinned_comment: post.pinnedComment,
12716
13961
  media: post.mediaAssets.map((asset) => ({
12717
13962
  id: asset.id,
@@ -13427,38 +14672,58 @@ function countReadyPostMediaMatches(job, post) {
13427
14672
  return matches;
13428
14673
  }
13429
14674
  async function resolveReadyPostEditorHref(post, accountId) {
14675
+ // "Edit Post" must land in the NEW /editor experience, never the retired
14676
+ // /templates/:id/editor/docs editor. The /editor routes live at the top
14677
+ // level and read the agency sub-account from the ?account= query param (there
14678
+ // is no /u/:account/editor route), so account context is attached that way.
14679
+ const withAccount = (path) => {
14680
+ if (!accountId)
14681
+ return path;
14682
+ const sep = path.includes("?") ? "&" : "?";
14683
+ return `${path}${sep}${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(accountId)}`;
14684
+ };
14685
+ const editorForkHref = (templateId, forkId) => withAccount(`/editor/${encodeURIComponent(templateId)}/fork/${encodeURIComponent(forkId)}`);
14686
+ // Bare editor: the route resolves (or mints) the caller's own fork.
14687
+ const editorTemplateHref = (templateId) => withAccount(`/editor/${encodeURIComponent(templateId)}`);
14688
+ // Fresh blank project in the new editor — used when nothing editable is
14689
+ // linked to this post (e.g. a post approved straight from a primitive render
14690
+ // with no composition fork or tracer).
14691
+ const blankEditorHref = () => withAccount("/editor/original/fork/new");
14692
+ // 1) Preferred: deep-link to the exact composition fork the post came from.
14693
+ const compositionId = post.compositionId?.trim();
14694
+ if (compositionId) {
14695
+ const fork = await serverlessRecords.getCompositionFork(compositionId);
14696
+ if (fork && !fork.deletedAt && fork.templateId) {
14697
+ return editorForkHref(fork.templateId, fork.id);
14698
+ }
14699
+ // Some create paths stash a serverless template id in compositionId rather
14700
+ // than a fork id — open the bare editor for that template.
14701
+ if (compositionId.startsWith("template_")) {
14702
+ return editorTemplateHref(compositionId);
14703
+ }
14704
+ }
14705
+ // 2) Fall back to the tracer's most-relevant render job, but only when that
14706
+ // job points at a serverless (template_) template the new editor can open.
14707
+ // Legacy catalog jobs (plain-UUID templateIds) are skipped on purpose so we
14708
+ // never route back into the old editor.
13430
14709
  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);
14710
+ if (tracer) {
14711
+ const matchingJob = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
14712
+ .filter((job) => job.templateId.startsWith("template_"))
14713
+ .map((job) => ({
14714
+ job,
14715
+ score: (countReadyPostMediaMatches(job, post) * 1000)
14716
+ + (job.parentJobId ? 0 : 120)
14717
+ + (job.status === "succeeded" ? 30 : 0)
14718
+ }))
14719
+ .sort((a, b) => (b.score - a.score
14720
+ || 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;
14721
+ if (matchingJob && await serverlessRecords.getTemplate(matchingJob.templateId)) {
14722
+ return editorTemplateHref(matchingJob.templateId);
13438
14723
  }
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
14724
  }
13460
- const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
13461
- return firstTemplate ? buildDocsHref(firstTemplate.id, tracer) : withAccountQuery("/templates", accountId);
14725
+ // 3) Nothing editable is linked — open a fresh project in the new editor.
14726
+ return blankEditorHref();
13462
14727
  }
13463
14728
  async function renderReadyPostPage(c, post, viewer) {
13464
14729
  const visiblePost = serializeReadyPost(c, post, { password: viewer.hasPasswordAccess ? viewer.password : null });
@@ -13488,7 +14753,7 @@ async function renderReadyPostPage(c, post, viewer) {
13488
14753
  error: scheduleChannels.error,
13489
14754
  feedEndpoint: viewer.canManage && viewer.customer ? withAccountQuery("/calendar/feed", viewer.customer.id) : null,
13490
14755
  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",
14756
+ connectHref: viewer.customer ? withAccountQuery("/settings/channels?connect=flockposter", viewer.customer.id) : "/settings/channels?connect=flockposter",
13492
14757
  userId: viewer.customer?.id ?? null
13493
14758
  };
13494
14759
  const shareUrl = viewer.canView
@@ -14308,6 +15573,282 @@ async function renderReadyPostPage(c, post, viewer) {
14308
15573
  gap: 8px;
14309
15574
  margin-top: 4px;
14310
15575
  }
15576
+
15577
+ /* ─────────────────────────────────────────────────────────────────────
15578
+ farmville-saas-ux restyle (honey-gold accent, Hanken Grotesk display,
15579
+ soft rounded white cards). SCOPED to .ready-post-shell so it never
15580
+ leaks into other legacy pages, and placed last so it wins over both the
15581
+ page-shell base CSS and the ready-post rules above. Structure + JS are
15582
+ untouched — this is a pure visual reskin via token remap + component
15583
+ overrides. Hanken Grotesk is loaded by a <link> in the page body.
15584
+ ───────────────────────────────────────────────────────────────────── */
15585
+ /* page canvas → clean warm-neutral farmville surface. Drops the old
15586
+ beige/tan gradient + technical grid overlay baked into the base shell
15587
+ <body> (which the .ready-post-shell scope can't reach). A whisper of
15588
+ honey glow up top keeps the "sunny afternoon" warmth without the grid. */
15589
+ body:has(.ready-post-shell) {
15590
+ background:
15591
+ radial-gradient(1150px 520px at 50% -12%, rgba(255, 199, 56, 0.10), transparent 62%),
15592
+ linear-gradient(180deg, #fdfcf9 0%, #fbfaf6 46%, #f6f4ee 100%);
15593
+ background-attachment: fixed;
15594
+ }
15595
+ body:has(.ready-post-shell)::before {
15596
+ content: none;
15597
+ }
15598
+ @media (max-width: 1024px) {
15599
+ body:has(.ready-post-shell) {
15600
+ background-attachment: scroll;
15601
+ }
15602
+ }
15603
+ .ready-post-shell {
15604
+ --accent: #ffc738;
15605
+ font-family: "Inter", "Manrope", sans-serif;
15606
+ --accent-deep: #171717;
15607
+ --accent-soft: rgba(255, 199, 56, 0.22);
15608
+ --ink: #171717;
15609
+ --muted: #6f6f70;
15610
+ --line: #ededed;
15611
+ --line-strong: #e0e0e0;
15612
+ --bg: #fbfaf6;
15613
+ --paper: #ffffff;
15614
+ --paper-strong: #ffffff;
15615
+ --panel-muted: #faf9f6;
15616
+ --radius-lg: 24px;
15617
+ --radius-md: 18px;
15618
+ --radius-sm: 12px;
15619
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 18px 40px -20px rgba(0, 0, 0, 0.12);
15620
+ --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 26px -16px rgba(0, 0, 0, 0.14);
15621
+ }
15622
+ .ready-post-shell h1,
15623
+ .ready-post-shell h2,
15624
+ .ready-post-shell .ready-post-schedule-head h2 {
15625
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15626
+ font-weight: 800;
15627
+ letter-spacing: -0.02em;
15628
+ color: #171717;
15629
+ }
15630
+ /* buttons → honey-gold pill / white ghost / red danger */
15631
+ .ready-post-shell .button,
15632
+ .ready-post-shell button,
15633
+ .ready-post-shell .link-button,
15634
+ .ready-post-shell .cta-button {
15635
+ border: 1px solid transparent;
15636
+ border-radius: 999px;
15637
+ background: #ffc738;
15638
+ color: #171717;
15639
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15640
+ font-weight: 700;
15641
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
15642
+ }
15643
+ @media (hover: hover) {
15644
+ .ready-post-shell .button:hover,
15645
+ .ready-post-shell button:hover,
15646
+ .ready-post-shell .link-button:hover {
15647
+ background: #fcb900;
15648
+ border-color: transparent;
15649
+ transform: translateY(-1px);
15650
+ box-shadow: 0 10px 22px -8px rgba(252, 185, 0, 0.55);
15651
+ }
15652
+ }
15653
+ .ready-post-shell .button.secondary,
15654
+ .ready-post-shell button.secondary,
15655
+ .ready-post-shell .link-button.secondary {
15656
+ background: #ffffff;
15657
+ color: #171717;
15658
+ border: 1px solid #e5e5e5;
15659
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
15660
+ }
15661
+ @media (hover: hover) {
15662
+ .ready-post-shell .button.secondary:hover,
15663
+ .ready-post-shell button.secondary:hover {
15664
+ background: #ffffff;
15665
+ border-color: #d4d4d4;
15666
+ box-shadow: 0 4px 12px -6px rgba(0, 0, 0, 0.12);
15667
+ }
15668
+ }
15669
+ .ready-post-shell .button.danger,
15670
+ .ready-post-shell button.danger,
15671
+ .ready-post-shell .ready-post-danger {
15672
+ background: #ef4444;
15673
+ color: #fff;
15674
+ border-color: transparent;
15675
+ }
15676
+ .ready-post-shell button[disabled] {
15677
+ opacity: 0.5;
15678
+ }
15679
+ /* inputs → soft farmville field, gold focus ring */
15680
+ .ready-post-shell input,
15681
+ .ready-post-shell select,
15682
+ .ready-post-shell textarea {
15683
+ border: 1px solid #e5e5e5;
15684
+ border-radius: 12px;
15685
+ background: #ffffff;
15686
+ color: #171717;
15687
+ box-shadow: none;
15688
+ }
15689
+ .ready-post-shell textarea {
15690
+ border-radius: 16px;
15691
+ }
15692
+ .ready-post-shell input:focus,
15693
+ .ready-post-shell select:focus,
15694
+ .ready-post-shell textarea:focus {
15695
+ border-color: #ffc738;
15696
+ box-shadow: 0 0 0 3px rgba(255, 199, 56, 0.28);
15697
+ }
15698
+ /* tracer / status chip */
15699
+ .ready-post-shell .pill {
15700
+ background: #fff5e4;
15701
+ color: #8a6d1f;
15702
+ border: 1px solid #ffe9cc;
15703
+ border-radius: 999px;
15704
+ font-weight: 700;
15705
+ }
15706
+ /* cards / panels → white farmville card */
15707
+ .ready-post-shell .ready-post-card,
15708
+ .ready-post-shell .ready-post-panel {
15709
+ background: #ffffff;
15710
+ border: 1px solid #ededed;
15711
+ border-radius: 24px;
15712
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 30px -18px rgba(0, 0, 0, 0.14);
15713
+ }
15714
+ /* copy + menu buttons → neutral ghost */
15715
+ .ready-post-shell .ready-post-copy,
15716
+ .ready-post-shell .ready-post-actions [data-menu-button] {
15717
+ background: #ffffff;
15718
+ border: 1px solid #e5e5e5;
15719
+ color: #525252;
15720
+ box-shadow: none;
15721
+ }
15722
+ .ready-post-shell .ready-post-copy[data-copied="true"] {
15723
+ background: #dcfce7;
15724
+ border-color: #86efac;
15725
+ color: #15803d;
15726
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
15727
+ }
15728
+ .ready-post-shell .ready-post-menu {
15729
+ border: 1px solid #ededed;
15730
+ border-radius: 16px;
15731
+ background: #ffffff;
15732
+ box-shadow: 0 20px 40px -16px rgba(0, 0, 0, 0.22);
15733
+ }
15734
+ /* video/slides mode toggle → farmville pill toggle */
15735
+ .ready-post-shell .ready-post-media-mode-toggle button.active {
15736
+ background: #ffc738;
15737
+ color: #171717;
15738
+ }
15739
+ /* login modal card */
15740
+ .ready-post-shell .ready-post-login-modal-card {
15741
+ border: 1px solid #ededed;
15742
+ border-radius: 24px;
15743
+ background: #ffffff;
15744
+ box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.3);
15745
+ }
15746
+ /* schedule modal → farmville surface + gold selected day */
15747
+ .ready-post-shell .ready-post-schedule-modal {
15748
+ border-radius: 24px;
15749
+ }
15750
+ /* ── de-saturate: structural buttons must NOT inherit the blanket gold ──
15751
+ The broad gold button rule above is meant for primary
15752
+ CTAs only, but it also painted calendar days, the channel-picker trigger,
15753
+ channel rows and kebab-menu items gold (a wall of yellow). Restore their
15754
+ neutral surfaces here (placed last so they win); only true primaries and
15755
+ genuinely-selected states stay honey-gold. */
15756
+ .ready-post-shell .ready-post-calendar-day {
15757
+ background: #ffffff;
15758
+ color: #3a3a3c;
15759
+ border: 1px solid #ececec;
15760
+ box-shadow: none;
15761
+ font-weight: 700;
15762
+ }
15763
+ .ready-post-shell .ready-post-calendar-day.is-muted {
15764
+ background: #faf9f6;
15765
+ color: #b5b5b7;
15766
+ }
15767
+ .ready-post-shell .ready-post-calendar-day.has-posts {
15768
+ background: rgba(83, 114, 89, 0.10);
15769
+ border-color: rgba(83, 114, 89, 0.30);
15770
+ }
15771
+ .ready-post-shell .ready-post-calendar-day.has-many-posts {
15772
+ background: rgba(255, 199, 56, 0.16);
15773
+ border-color: rgba(255, 199, 56, 0.42);
15774
+ }
15775
+ @media (hover: hover) {
15776
+ .ready-post-shell .ready-post-calendar-day:hover {
15777
+ background: #fff8e8;
15778
+ border-color: #ffc738;
15779
+ transform: none;
15780
+ box-shadow: none;
15781
+ }
15782
+ }
15783
+ .ready-post-shell .ready-post-calendar-day.is-selected,
15784
+ .ready-post-shell .ready-post-calendar-day.is-selected:hover {
15785
+ background: #ffc738;
15786
+ color: #171717;
15787
+ border-color: transparent;
15788
+ }
15789
+ .ready-post-shell .ready-post-calendar-day.is-today {
15790
+ border-color: #ffc738;
15791
+ }
15792
+ /* channel-picker trigger → neutral field, not a gold pill */
15793
+ .ready-post-shell .ready-post-channel-trigger {
15794
+ background: #ffffff;
15795
+ color: #171717;
15796
+ border: 1px solid #e5e5e5;
15797
+ box-shadow: none;
15798
+ font-weight: 600;
15799
+ }
15800
+ .ready-post-shell .ready-post-channel-trigger.is-placeholder {
15801
+ color: #6f6f70;
15802
+ }
15803
+ @media (hover: hover) {
15804
+ .ready-post-shell .ready-post-channel-trigger:hover {
15805
+ background: #ffffff;
15806
+ border-color: #d4d4d4;
15807
+ transform: none;
15808
+ box-shadow: none;
15809
+ }
15810
+ }
15811
+ /* channel rows + kebab-menu items → transparent, gold tint only on hover/selected */
15812
+ .ready-post-shell .ready-post-channel-option,
15813
+ .ready-post-shell .ready-post-menu a,
15814
+ .ready-post-shell .ready-post-menu button {
15815
+ background: transparent;
15816
+ color: #171717;
15817
+ border: 0;
15818
+ box-shadow: none;
15819
+ font-weight: 600;
15820
+ }
15821
+ @media (hover: hover) {
15822
+ .ready-post-shell .ready-post-channel-option:hover,
15823
+ .ready-post-shell .ready-post-menu a:hover,
15824
+ .ready-post-shell .ready-post-menu button:hover {
15825
+ background: #fff5e4;
15826
+ transform: none;
15827
+ box-shadow: none;
15828
+ }
15829
+ }
15830
+ .ready-post-shell .ready-post-channel-option.is-selected {
15831
+ background: #fff5e4;
15832
+ }
15833
+ .ready-post-shell .ready-post-menu .ready-post-danger {
15834
+ color: #dc2626;
15835
+ font-weight: 700;
15836
+ }
15837
+ @media (hover: hover) {
15838
+ .ready-post-shell .ready-post-menu .ready-post-danger:hover {
15839
+ background: #fef2f2;
15840
+ }
15841
+ }
15842
+ /* popover close chip stays a neutral ghost */
15843
+ .ready-post-shell .ready-post-calendar-popover-close {
15844
+ background: #ffffff;
15845
+ color: #6f6f70;
15846
+ border: 1px solid #e5e5e5;
15847
+ box-shadow: none;
15848
+ }
15849
+ .ready-post-shell .ready-post-locked {
15850
+ font-family: "Hanken Grotesk", "Manrope", sans-serif;
15851
+ }
14311
15852
  `;
14312
15853
  const readyPostBody = viewer.canView ? `
14313
15854
  <section class="ready-post-frame">
@@ -14349,10 +15890,19 @@ async function renderReadyPostPage(c, post, viewer) {
14349
15890
  </footer>
14350
15891
  </div>
14351
15892
  </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>`;
15893
+ const socialImage = thumbnail?.url ?? slides[0]?.url;
15894
+ const socialDescription = post.caption?.trim() || undefined;
14352
15895
  return c.html(renderPageShell({
14353
15896
  title: post.title ?? "Approved post",
14354
15897
  shellClass: "ready-post-shell",
15898
+ description: socialDescription,
15899
+ social: {
15900
+ image: socialImage,
15901
+ url: shareUrl,
15902
+ type: primaryVideo ? "video.other" : "website"
15903
+ },
14355
15904
  body: `
15905
+ <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
15906
  ${readyPostBody}
14357
15907
  <script id="ready-post-data" type="application/json">${escapeJsonForHtml(serialized)}</script>
14358
15908
  <script id="ready-post-zip-data" type="application/json">${escapeJsonForHtml({
@@ -15056,6 +16606,10 @@ function primitiveAliasPathForKind(kind) {
15056
16606
  return `${PRIMITIVES_PREFIX}/images/render-html`;
15057
16607
  case "image_remove_background":
15058
16608
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
16609
+ case "image_remove_background_greenscreen":
16610
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
16611
+ case "media_overlay":
16612
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
15059
16613
  case "video_generate":
15060
16614
  return `${PRIMITIVES_PREFIX}/videos/generate`;
15061
16615
  case "video_render_slides":
@@ -15716,6 +17270,14 @@ app.post(`${PRIMITIVES_PREFIX}/images/edit`, async (c) => createPrimitiveJob(c,
15716
17270
  app.post(`${PRIMITIVES_PREFIX}/images/inpaint`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_inpaint", operationName: "run" }));
15717
17271
  app.post(`${PRIMITIVES_PREFIX}/images/render-html`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_render_html", operationName: "run" }));
15718
17272
  app.post(`${PRIMITIVES_PREFIX}/images/remove-background`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background", operationName: "run" }));
17273
+ // Cheap local chroma-key background removal. Canonical images/* path plus a flat
17274
+ // plain-English alias matching the primitive's spoken name.
17275
+ app.post(`${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17276
+ app.post(`${PRIMITIVES_PREFIX}/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17277
+ // Two-step "create media overlay": BYOK image gen on a forced key-color
17278
+ // background + local chroma key → transparent overlay.
17279
+ app.post(`${PRIMITIVES_PREFIX}/images/create-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
17280
+ app.post(`${PRIMITIVES_PREFIX}/create-media-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
15719
17281
  app.post(`${PRIMITIVES_PREFIX}/videos/generate`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_generate", operationName: "run" }));
15720
17282
  app.post(`${PRIMITIVES_PREFIX}/videos/download`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_download", operationName: "run" }));
15721
17283
  app.post(`${PRIMITIVES_PREFIX}/videos/render-slides`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_render_slides", operationName: "run" }));
@@ -15879,11 +17441,32 @@ app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
15879
17441
  return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
15880
17442
  }
15881
17443
  });
15882
- app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
17444
+ // PATCH /api/v1/approved/posts/:postId { folder_path } — move a post between
17445
+ // /approved folders (""=root). Mirrors PATCH /raws/:clipId for the agent.
17446
+ app.patch(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
15883
17447
  const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
15884
17448
  if (proxied)
15885
17449
  return proxied;
15886
- const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
17450
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
17451
+ if (!post) {
17452
+ return c.json({ error: "Approved post not found." }, 404);
17453
+ }
17454
+ try {
17455
+ const customer = requireReadyPostOwner(c, post);
17456
+ const body = (await c.req.json().catch(() => ({})));
17457
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
17458
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
17459
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
17460
+ }
17461
+ catch (error) {
17462
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
17463
+ }
17464
+ });
17465
+ app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
17466
+ const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
17467
+ if (proxied)
17468
+ return proxied;
17469
+ const post = await serverlessRecords.getReadyPost(c.req.param("postId"));
15887
17470
  if (!post || post.status === "deleted") {
15888
17471
  return c.json({ error: "Approved post not found." }, 404);
15889
17472
  }
@@ -16138,7 +17721,18 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
16138
17721
  const attachmentId = randomUUID();
16139
17722
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
16140
17723
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
17724
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
16141
17725
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17726
+ // Durable poster for the file directory (image = itself, video = extracted
17727
+ // frame from the just-stored object, else none). Fail-soft — never blocks.
17728
+ const thumb = await ensureAttachmentThumbnail({
17729
+ customerId: customer.id,
17730
+ attachmentId,
17731
+ storageKey,
17732
+ contentType,
17733
+ attachmentUrl: publicUrl,
17734
+ sourceUrl: publicUrl
17735
+ });
16142
17736
  await serverlessRecords.createUserAttachment({
16143
17737
  id: attachmentId,
16144
17738
  customerId: customer.id,
@@ -16147,7 +17741,9 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
16147
17741
  sizeBytes: buffer.byteLength,
16148
17742
  storageKey,
16149
17743
  folderPath,
16150
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey),
17744
+ publicUrl,
17745
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17746
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
16151
17747
  notes,
16152
17748
  notesEmbedding: embedded?.embedding ?? null,
16153
17749
  notesEmbeddingModel: embedded?.model ?? null
@@ -16162,6 +17758,122 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
16162
17758
  return c.json({ error: error instanceof Error ? error.message : "Unable to upload attachment." }, 400);
16163
17759
  }
16164
17760
  });
17761
+ // POST /me/attachments/from-url — save a durable media URL (e.g. a finished
17762
+ // inpaint / generate job's output) into My Files at a chosen folder. Fetched
17763
+ // server-side so cross-origin S3 objects don't need browser CORS. Only trusted
17764
+ // app / storage origins are allowed (no arbitrary SSRF fetch).
17765
+ function isTrustedMediaOrigin(u) {
17766
+ try {
17767
+ if (u.protocol !== "https:" && u.protocol !== "http:")
17768
+ return false;
17769
+ const appOrigin = new URL(config.PUBLIC_BASE_URL).origin;
17770
+ if (u.origin === appOrigin)
17771
+ return true;
17772
+ if (config.AWS_S3_ENDPOINT) {
17773
+ try {
17774
+ if (u.origin === new URL(config.AWS_S3_ENDPOINT).origin)
17775
+ return true;
17776
+ }
17777
+ catch { /* noop */ }
17778
+ }
17779
+ const host = u.hostname.toLowerCase();
17780
+ if (host.endsWith(".amazonaws.com"))
17781
+ return true;
17782
+ if (host.endsWith(".cloudfront.net"))
17783
+ return true;
17784
+ return false;
17785
+ }
17786
+ catch {
17787
+ return false;
17788
+ }
17789
+ }
17790
+ app.post(`${USER_PREFIX}/me/attachments/from-url`, async (c) => {
17791
+ try {
17792
+ const customer = requireCustomer(c);
17793
+ const raw = (await c.req.json().catch(() => null));
17794
+ const src = typeof raw?.source_url === "string" ? raw.source_url.trim() : "";
17795
+ if (!src) {
17796
+ return c.json({ error: "source_url is required." }, 400);
17797
+ }
17798
+ let sourceUrl;
17799
+ try {
17800
+ sourceUrl = new URL(src, config.PUBLIC_BASE_URL);
17801
+ }
17802
+ catch {
17803
+ return c.json({ error: "source_url is not a valid URL." }, 400);
17804
+ }
17805
+ if (!isTrustedMediaOrigin(sourceUrl)) {
17806
+ return c.json({ error: "source_url must point to a vidfarm asset or job output." }, 400);
17807
+ }
17808
+ const folderPath = sanitizeStorageSubpath(typeof raw?.folder_path === "string" ? raw.folder_path : "");
17809
+ const notes = (typeof raw?.notes === "string" ? raw.notes : "").trim().slice(0, 4000) || null;
17810
+ const response = await fetch(sourceUrl, { method: "GET" });
17811
+ if (!response.ok) {
17812
+ return c.json({ error: `Could not fetch the media (${response.status}).` }, 400);
17813
+ }
17814
+ const arrayBuffer = await response.arrayBuffer();
17815
+ const buffer = Buffer.from(arrayBuffer);
17816
+ if (buffer.byteLength > 50 * 1024 * 1024) {
17817
+ return c.json({ error: "My Files uploads can be at most 50 MB." }, 400);
17818
+ }
17819
+ const responseType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
17820
+ // Prefer an explicit file_name; else derive from the URL path; else fall back.
17821
+ const urlName = decodeURIComponent((sourceUrl.pathname.split("/").pop() || "").trim());
17822
+ const requested = typeof raw?.file_name === "string" ? raw.file_name.trim() : "";
17823
+ let fileName = sanitizeFileName(requested || urlName || "media.bin");
17824
+ const contentType = responseType || inferAttachmentContentType(fileName);
17825
+ // Ensure the name carries an extension matching the content type so the file
17826
+ // directory renders it correctly (job outputs are often extensionless keys).
17827
+ if (!/\.[a-z0-9]+$/i.test(fileName)) {
17828
+ const extByType = {
17829
+ "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif",
17830
+ "video/mp4": "mp4", "video/webm": "webm", "audio/mpeg": "mp3", "audio/wav": "wav"
17831
+ };
17832
+ const ext = extByType[contentType.toLowerCase()];
17833
+ if (ext)
17834
+ fileName = `${fileName}.${ext}`;
17835
+ }
17836
+ if (!isAllowedAttachment(fileName, contentType)) {
17837
+ return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
17838
+ }
17839
+ const attachmentId = randomUUID();
17840
+ const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
17841
+ const stored = await storage.putBuffer(storageKey, buffer, contentType);
17842
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
17843
+ const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17844
+ const thumb = await ensureAttachmentThumbnail({
17845
+ customerId: customer.id,
17846
+ attachmentId,
17847
+ storageKey,
17848
+ contentType,
17849
+ attachmentUrl: publicUrl,
17850
+ sourceUrl: publicUrl
17851
+ });
17852
+ await serverlessRecords.createUserAttachment({
17853
+ id: attachmentId,
17854
+ customerId: customer.id,
17855
+ fileName,
17856
+ contentType,
17857
+ sizeBytes: buffer.byteLength,
17858
+ storageKey,
17859
+ folderPath,
17860
+ publicUrl,
17861
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17862
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17863
+ notes,
17864
+ notesEmbedding: embedded?.embedding ?? null,
17865
+ notesEmbeddingModel: embedded?.model ?? null
17866
+ });
17867
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
17868
+ if (!attachment) {
17869
+ return c.json({ error: "Attachment could not be saved." }, 500);
17870
+ }
17871
+ return c.json({ ok: true, attachment: serializeUserAttachment(c, attachment) }, 201);
17872
+ }
17873
+ catch (error) {
17874
+ return c.json({ error: error instanceof Error ? error.message : "Unable to save media to My Files." }, 400);
17875
+ }
17876
+ });
16165
17877
  app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
16166
17878
  try {
16167
17879
  const customer = requireCustomer(c);
@@ -16181,6 +17893,17 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
16181
17893
  }
16182
17894
  const notes = body.notes?.trim() || null;
16183
17895
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17896
+ const publicUrl = storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey);
17897
+ // The bytes were PUT straight to storage (no local copy here), so the frame
17898
+ // extractor fetches them back from the object's own durable URL. Fail-soft.
17899
+ const thumb = await ensureAttachmentThumbnail({
17900
+ customerId: customer.id,
17901
+ attachmentId: body.attachment_id,
17902
+ storageKey: expectedStorageKey,
17903
+ contentType,
17904
+ attachmentUrl: publicUrl,
17905
+ sourceUrl: publicUrl
17906
+ });
16184
17907
  await serverlessRecords.createUserAttachment({
16185
17908
  id: body.attachment_id,
16186
17909
  customerId: customer.id,
@@ -16189,7 +17912,9 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
16189
17912
  sizeBytes: body.size_bytes,
16190
17913
  storageKey: expectedStorageKey,
16191
17914
  folderPath,
16192
- publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey),
17915
+ publicUrl,
17916
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17917
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
16193
17918
  notes,
16194
17919
  notesEmbedding: embedded?.embedding ?? null,
16195
17920
  notesEmbeddingModel: embedded?.model ?? null
@@ -16369,7 +18094,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/presign`, async (c) => {
16369
18094
  const body = userTemporaryFilePresignSchema.parse(await c.req.json());
16370
18095
  const fileName = sanitizeFileName(body.file_name);
16371
18096
  const contentType = body.content_type?.trim() || inferAttachmentContentType(fileName);
16372
- const folderPath = sanitizeStorageSubpath(body.folder_path);
18097
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(body.folder_path));
16373
18098
  if (!isAllowedAttachment(fileName, contentType)) {
16374
18099
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16375
18100
  }
@@ -16427,7 +18152,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
16427
18152
  }
16428
18153
  const fileName = sanitizeFileName(file.name || "upload.bin");
16429
18154
  const contentType = file.type || inferAttachmentContentType(fileName);
16430
- const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
18155
+ const folderPath = withTempDateFolder(sanitizeStorageSubpath(String(formData.get("folder_path") ?? "")));
16431
18156
  if (!isAllowedAttachment(fileName, contentType)) {
16432
18157
  return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
16433
18158
  }
@@ -16549,6 +18274,338 @@ app.delete(`${USER_PREFIX}/me/temporary-files/:fileId`, async (c) => {
16549
18274
  await serverlessRecords.deleteUserTemporaryFile(customer.id, file.id);
16550
18275
  return c.json({ ok: true, file_id: file.id });
16551
18276
  });
18277
+ // ── Unified file directory ───────────────────────────────────────────────────
18278
+ // The first-class, navigable view over all three file backends as one tree:
18279
+ // /files → My Files attachments /temp → temp scratch /raws → clip library
18280
+ // Both the reskin explorer UI and the AI chat agent browse through here, so a
18281
+ // path like /raws/product-demos is a real, copyable, agent-addressable location.
18282
+ // GET /me/directory?path=/raws/demos → folders[] + files[] under that path.
18283
+ // path "/" (or omitted) lists the three roots as folders.
18284
+ app.get(`${USER_PREFIX}/me/directory`, async (c) => {
18285
+ const customer = requireCustomer(c);
18286
+ const parsed = parseDirectoryPath(c.req.query("path") ?? "");
18287
+ if (!parsed.root) {
18288
+ return c.json({ path: "/", root: null, folders: directoryRootFolders(), files: [], next_offset: null });
18289
+ }
18290
+ const limit = clampPageLimit(Number(c.req.query("limit") || "200"), 500);
18291
+ const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
18292
+ // Exact shot-kind filter (raws only): comma-separated OR set of content_type tags.
18293
+ const contentTypes = (c.req.query("content_type") || "")
18294
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
18295
+ try {
18296
+ const { folders, files } = await listDirectoryFolder(c, customer.id, parsed.root, parsed.folderPath);
18297
+ const filtered = (parsed.root === "raws" && contentTypes.length)
18298
+ ? files.filter((f) => {
18299
+ const tags = (f.meta && f.meta.tags) || null;
18300
+ const ct = Array.isArray(tags?.content_type) ? tags?.content_type : [];
18301
+ return ct.some((x) => contentTypes.includes(String(x).toLowerCase()));
18302
+ })
18303
+ : files;
18304
+ const page = filtered.slice(offset, offset + limit);
18305
+ const nextOffset = filtered.length > offset + limit ? offset + limit : null;
18306
+ return c.json({
18307
+ path: buildDirectoryPath(parsed.root, parsed.folderPath),
18308
+ root: parsed.root,
18309
+ folders,
18310
+ files: page,
18311
+ next_offset: nextOffset
18312
+ });
18313
+ }
18314
+ catch (error) {
18315
+ return c.json({ error: error instanceof Error ? error.message : "Unable to list directory." }, 400);
18316
+ }
18317
+ });
18318
+ // Cloud passthrough for a `vidfarm serve` box: browse the CLOUD (vidfarm.cc)
18319
+ // directory alongside the local one so the file explorer can offer /local vs
18320
+ // /cloud spaces (and `vidfarm directory --both`). Proxies to the same upstream
18321
+ // routes with the ambient cloud key. No upstream (e.g. running ON cloud) → 400
18322
+ // so the UI simply hides the /cloud tab.
18323
+ app.get(`${USER_PREFIX}/me/cloud/directory`, async (c) => {
18324
+ requireCustomer(c);
18325
+ if (!isUpstreamEnabled())
18326
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18327
+ const qs = new URLSearchParams();
18328
+ for (const key of ["path", "limit", "offset", "content_type"]) {
18329
+ const v = c.req.query(key);
18330
+ if (v)
18331
+ qs.set(key, v);
18332
+ }
18333
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory${qs.toString() ? `?${qs.toString()}` : ""}`);
18334
+ if (!res.ok || !res.json)
18335
+ return c.json({ error: "Cloud directory unavailable." }, 502);
18336
+ return c.json(res.json);
18337
+ });
18338
+ app.post(`${USER_PREFIX}/me/cloud/directory/search`, async (c) => {
18339
+ requireCustomer(c);
18340
+ if (!isUpstreamEnabled())
18341
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18342
+ const body = await c.req.json().catch(() => ({}));
18343
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory/search`, { method: "POST", body });
18344
+ if (!res.ok || !res.json)
18345
+ return c.json({ error: "Cloud search unavailable." }, 502);
18346
+ return c.json(res.json);
18347
+ });
18348
+ // POST /me/directory/search { query, path?, mode?, limit? }
18349
+ // Unified search combining semantic (vector), substring, and absolute-path
18350
+ // matching across the in-scope roots. `path` narrows scope to one root/folder;
18351
+ // `mode` ∈ auto|semantic|substring|path (default auto = all three signals).
18352
+ function directoryPathContains(itemPath, query) {
18353
+ return itemPath.toLowerCase().includes(query.toLowerCase());
18354
+ }
18355
+ app.post(`${USER_PREFIX}/me/directory/search`, async (c) => {
18356
+ const customer = requireCustomer(c);
18357
+ try {
18358
+ const body = (await c.req.json().catch(() => ({})));
18359
+ const query = (body.query ?? "").trim();
18360
+ if (!query)
18361
+ return c.json({ error: "Provide a search query." }, 400);
18362
+ const limit = Math.max(1, Math.min(50, Number(body.limit) || 20));
18363
+ // Exact shot-kind filter applied to the /raws branch only.
18364
+ const contentTypes = (body.content_type || "")
18365
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
18366
+ const parsed = parseDirectoryPath(body.path ?? "");
18367
+ const scopeRoots = parsed.root ? [parsed.root] : DIRECTORY_ROOT_KEYS;
18368
+ const scopeFolder = normalizeFolderPath(parsed.folderPath);
18369
+ const inScopeFolder = (fp) => !scopeFolder || (normalizeFolderPath(fp) + "/").indexOf(scopeFolder + "/") === 0;
18370
+ const mode = ["semantic", "substring", "path"].includes(String(body.mode)) ? body.mode : "auto";
18371
+ const wantSemantic = mode === "auto" || mode === "semantic";
18372
+ const wantSubstring = mode === "auto" || mode === "substring";
18373
+ const wantPath = mode === "auto" || mode === "path";
18374
+ const scored = [];
18375
+ let semanticUsed = false;
18376
+ // ── /files: notes embedding (semantic) + name/folder/notes substring ──────
18377
+ if (scopeRoots.includes("files")) {
18378
+ const atts = (await serverlessRecords.listUserAttachments(customer.id)).filter((a) => inScopeFolder(a.folderPath));
18379
+ let queryEmbedding;
18380
+ if (wantSemantic) {
18381
+ try {
18382
+ const embedder = await buildFileNotesEmbedder(customer.id);
18383
+ if (embedder)
18384
+ queryEmbedding = await embedder.embedQuery(query);
18385
+ }
18386
+ catch (error) {
18387
+ devLog("directory_search.files_embed_failed", devErrorFields(error));
18388
+ }
18389
+ }
18390
+ if (queryEmbedding)
18391
+ semanticUsed = true;
18392
+ for (const a of atts) {
18393
+ const item = attachmentToDirectoryItem(c, a);
18394
+ let score = 0;
18395
+ let sem = false;
18396
+ if (queryEmbedding && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0) {
18397
+ const sim = cosineSimilarity(a.notesEmbedding, queryEmbedding);
18398
+ if (sim > 0) {
18399
+ score += sim;
18400
+ sem = true;
18401
+ }
18402
+ }
18403
+ if (wantSubstring && attachmentKeywordMatch(a, query))
18404
+ score += 0.5;
18405
+ if (wantPath && directoryPathContains(item.path, query))
18406
+ score += 0.6;
18407
+ if (score > 0)
18408
+ scored.push({ item, score, semantic: sem });
18409
+ }
18410
+ }
18411
+ // ── /temp: no embeddings → substring + absolute-path only ─────────────────
18412
+ if (scopeRoots.includes("temp")) {
18413
+ const files = (await serverlessRecords.listUserTemporaryFiles(customer.id)).filter((f) => inScopeFolder(f.folderPath));
18414
+ for (const f of files) {
18415
+ const item = temporaryFileToDirectoryItem(c, f);
18416
+ let score = 0;
18417
+ if (wantSubstring && attachmentKeywordMatch(f, query))
18418
+ score += 0.5;
18419
+ if (wantPath && directoryPathContains(item.path, query))
18420
+ score += 0.6;
18421
+ if (score > 0)
18422
+ scored.push({ item, score, semantic: false });
18423
+ }
18424
+ }
18425
+ // ── /raws: semantic-first clip search, or keyword/path filter otherwise ────
18426
+ if (scopeRoots.includes("raws")) {
18427
+ if (wantSemantic) {
18428
+ let queryEmbedding;
18429
+ try {
18430
+ const embedder = await buildMatchEmbedder(customer.id);
18431
+ if (embedder)
18432
+ [queryEmbedding] = await embedder.embedQueries([query]);
18433
+ }
18434
+ catch (error) {
18435
+ devLog("directory_search.raws_embed_failed", devErrorFields(error));
18436
+ }
18437
+ if (queryEmbedding)
18438
+ semanticUsed = true;
18439
+ const hits = await searchUserClips(customer.id, {
18440
+ criteria: { semantic_text: query, ...(contentTypes.length ? { content_type: contentTypes } : {}) },
18441
+ queryEmbedding,
18442
+ limit: limit * 3
18443
+ });
18444
+ for (const h of hits) {
18445
+ if (!inScopeFolder(h.clip.folder_path))
18446
+ continue;
18447
+ const item = clipSerializedToDirectoryItem(await serializeClip(h.clip));
18448
+ scored.push({ item, score: h.similarity ?? h.score ?? 0.1, semantic: Boolean(queryEmbedding) });
18449
+ }
18450
+ }
18451
+ else {
18452
+ const clips = (await clipRecords.listClips(customer.id, { limit: 5000 }))
18453
+ .filter((cl) => inScopeFolder(cl.folder_path))
18454
+ .filter((cl) => !contentTypes.length || (cl.tags?.content_type ?? []).some((ct) => contentTypes.includes(String(ct).toLowerCase())));
18455
+ for (const cl of clips) {
18456
+ const kw = wantSubstring && clipKeywordMatch(cl, query);
18457
+ const item = clipSerializedToDirectoryItem(await serializeClip(cl));
18458
+ const pm = wantPath && directoryPathContains(item.path, query);
18459
+ if (kw || pm)
18460
+ scored.push({ item, score: (kw ? 0.5 : 0) + (pm ? 0.6 : 0), semantic: false });
18461
+ }
18462
+ }
18463
+ }
18464
+ // ── /approved: no embeddings → substring (title/caption/tracer) + path ─────
18465
+ if (scopeRoots.includes("approved")) {
18466
+ const posts = (await serverlessRecords.listReadyPosts(customer.id))
18467
+ .filter((p) => p.status !== "deleted")
18468
+ .filter((p) => inScopeFolder(p.folderPath ?? ""));
18469
+ const ql = query.toLowerCase();
18470
+ for (const p of posts) {
18471
+ const item = readyPostToDirectoryItem(p);
18472
+ let score = 0;
18473
+ const hay = [p.title, p.caption, p.tracer].filter(Boolean).join(" ").toLowerCase();
18474
+ if (wantSubstring && hay.includes(ql))
18475
+ score += 0.5;
18476
+ if (wantPath && directoryPathContains(item.path, query))
18477
+ score += 0.6;
18478
+ if (score > 0)
18479
+ scored.push({ item, score, semantic: false });
18480
+ }
18481
+ }
18482
+ scored.sort((x, y) => y.score - x.score);
18483
+ const results = scored.slice(0, limit).map((s) => ({ ...s.item, score: s.score }));
18484
+ return c.json({ results, query, mode, scope: parsed.root ?? "all", semantic: semanticUsed });
18485
+ }
18486
+ catch (error) {
18487
+ return c.json({ error: error instanceof Error ? error.message : "Directory search failed." }, 400);
18488
+ }
18489
+ });
18490
+ // POST /me/directory/folders { path } — create an empty folder at a canonical
18491
+ // path (e.g. /files/brand-assets, /raws/demos). Persisted as a user_file_folder
18492
+ // record scoped to the root, so it shows up in listings before any file lands.
18493
+ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
18494
+ const customer = requireCustomer(c);
18495
+ try {
18496
+ const body = (await c.req.json().catch(() => ({})));
18497
+ const parsed = parseDirectoryPath(body.path ?? "");
18498
+ if (!parsed.root || !parsed.folderPath) {
18499
+ return c.json({ error: "Provide a folder path under a root, e.g. /files/brand-assets or /raws/demos." }, 400);
18500
+ }
18501
+ if (parsed.root === "projects") {
18502
+ return c.json({ error: "/projects is read-only — folders there are your composition forks. Create a project by forking a template in the editor." }, 400);
18503
+ }
18504
+ await serverlessRecords.createUserFileFolder(customer.id, directoryFolderScope(parsed.root), parsed.folderPath);
18505
+ return c.json({ ok: true, path: buildDirectoryPath(parsed.root, parsed.folderPath), root: parsed.root, folder_path: parsed.folderPath });
18506
+ }
18507
+ catch (error) {
18508
+ return c.json({ error: error instanceof Error ? error.message : "Unable to create folder." }, 400);
18509
+ }
18510
+ });
18511
+ // POST /me/directory/rename { path, new_name, file_id? } — rename a file or a
18512
+ // folder in place. With file_id it's a FILE rename (display name only — the S3
18513
+ // key and view URL are untouched); without it, the trailing segment of `path`
18514
+ // is treated as a FOLDER and renamed (its files, and for /raws its nested
18515
+ // subfolders, move with it). Both the explorer UI and the chat agent call this,
18516
+ // so directory entries — character folders especially — stay tidy over time.
18517
+ app.post(`${USER_PREFIX}/me/directory/rename`, async (c) => {
18518
+ const customer = requireCustomer(c);
18519
+ try {
18520
+ const body = (await c.req.json().catch(() => ({})));
18521
+ const parsed = parseDirectoryPath(body.path ?? "");
18522
+ const rawName = String(body.new_name ?? "").trim();
18523
+ const fileId = String(body.file_id ?? "").trim();
18524
+ if (!parsed.root) {
18525
+ return c.json({ error: "Provide a path under a root (/files, /temp, /raws, /projects, /approved)." }, 400);
18526
+ }
18527
+ if (parsed.root === "projects") {
18528
+ return c.json({ error: "/projects is read-only — its folders are your composition forks and its files are stored composition assets. Rename a project by editing its title in the editor." }, 400);
18529
+ }
18530
+ if (!rawName) {
18531
+ return c.json({ error: "Provide new_name." }, 400);
18532
+ }
18533
+ // ── FILE rename (file_id given) — /files and /temp only ──────────────────
18534
+ if (fileId) {
18535
+ const newName = sanitizeFileName(rawName);
18536
+ if (!newName)
18537
+ return c.json({ error: "Provide a valid new file name." }, 400);
18538
+ if (parsed.root === "files") {
18539
+ const att = await serverlessRecords.getUserAttachment(customer.id, fileId);
18540
+ if (!att)
18541
+ return c.json({ error: "File not found." }, 404);
18542
+ if (!isAllowedAttachment(newName, att.contentType)) {
18543
+ return c.json({ error: `Unsupported file name: ${newName}` }, 400);
18544
+ }
18545
+ const updated = await serverlessRecords.renameUserAttachment(customer.id, fileId, newName);
18546
+ if (!updated)
18547
+ return c.json({ error: "File not found." }, 404);
18548
+ return c.json({
18549
+ ok: true,
18550
+ path: buildDirectoryPath("files", updated.folderPath, newName),
18551
+ file: serializeUserAttachment(c, updated)
18552
+ });
18553
+ }
18554
+ if (parsed.root === "temp") {
18555
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, fileId);
18556
+ if (!file)
18557
+ return c.json({ error: "File not found." }, 404);
18558
+ const updated = await serverlessRecords.renameUserTemporaryFile(customer.id, fileId, newName);
18559
+ if (!updated)
18560
+ return c.json({ error: "File not found." }, 404);
18561
+ return c.json({
18562
+ ok: true,
18563
+ path: buildDirectoryPath("temp", updated.folderPath, newName),
18564
+ file: serializeUserTemporaryFile(c, updated)
18565
+ });
18566
+ }
18567
+ return c.json({ error: "Raws and approved posts are named after their source/title and can't be renamed as files — rename their folder, or use move to reorganize them." }, 400);
18568
+ }
18569
+ // ── FOLDER rename (no file_id) — rename the trailing path segment ─────────
18570
+ const fromFolder = normalizeFolderPath(parsed.folderPath);
18571
+ if (!fromFolder) {
18572
+ return c.json({ error: "Provide a folder path (e.g. /files/characters/zara) or a file_id to rename a file." }, 400);
18573
+ }
18574
+ const newSegment = sanitizeStorageSubpath(rawName);
18575
+ if (!newSegment || newSegment.includes("/")) {
18576
+ return c.json({ error: "A folder name can't be empty or contain slashes." }, 400);
18577
+ }
18578
+ const segs = fromFolder.split("/");
18579
+ segs[segs.length - 1] = newSegment;
18580
+ const toFolder = normalizeFolderPath(segs.join("/"));
18581
+ if (parsed.root === "files") {
18582
+ await serverlessRecords.moveUserFileFolder(customer.id, "my_files", fromFolder, toFolder);
18583
+ await serverlessRecords.moveUserAttachmentFolder(customer.id, fromFolder, toFolder);
18584
+ }
18585
+ else if (parsed.root === "temp") {
18586
+ await serverlessRecords.moveUserFileFolder(customer.id, "temporary", fromFolder, toFolder);
18587
+ await serverlessRecords.moveUserTemporaryFolder(customer.id, fromFolder, toFolder);
18588
+ }
18589
+ else if (parsed.root === "approved") {
18590
+ await serverlessRecords.moveUserFileFolder(customer.id, "approved", fromFolder, toFolder);
18591
+ await serverlessRecords.moveReadyPostFolder(customer.id, fromFolder, toFolder);
18592
+ }
18593
+ else {
18594
+ await serverlessRecords.moveUserFileFolder(customer.id, "raws", fromFolder, toFolder);
18595
+ await clipRecords.moveClipsFolder(customer.id, fromFolder, toFolder);
18596
+ }
18597
+ return c.json({
18598
+ ok: true,
18599
+ path: buildDirectoryPath(parsed.root, toFolder),
18600
+ root: parsed.root,
18601
+ from_folder_path: fromFolder,
18602
+ to_folder_path: toFolder
18603
+ });
18604
+ }
18605
+ catch (error) {
18606
+ return c.json({ error: error instanceof Error ? error.message : "Rename failed." }, 400);
18607
+ }
18608
+ });
16552
18609
  app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
16553
18610
  try {
16554
18611
  const customer = requireCustomer(c);
@@ -17008,8 +19065,10 @@ function renderClipsPage(input) {
17008
19065
  : '<div class="clip-noimg">no thumb</div>';
17009
19066
  var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
17010
19067
  var tags = topTags(clip.tags).map(function(t){ return '<span class="clip-tag">'+esc(t)+'</span>'; }).join('');
19068
+ // The full original video a hunt was mined from is badged distinctly.
19069
+ var sourceBadge = clip.is_source_original ? '<div class="clip-fullbadge" style="position:absolute;top:6px;left:6px;background:rgba(20,20,22,.82);color:#fff;font-size:11px;font-weight:600;padding:2px 7px;border-radius:6px;">Full video</div>' : '';
17011
19070
  return '<div class="clip-card">'
17012
- + '<div class="clip-thumb" onmouseleave="var v=this.querySelector(&quot;video&quot;); if(v){v.pause();v.style.display=&quot;none&quot;;}">'+media+vid+'<div class="clip-range">'+range+' · '+clip.duration_sec.toFixed(1)+'s</div>'+score+'</div>'
19071
+ + '<div class="clip-thumb" onmouseleave="var v=this.querySelector(&quot;video&quot;); if(v){v.pause();v.style.display=&quot;none&quot;;}">'+media+vid+sourceBadge+'<div class="clip-range">'+range+' · '+clip.duration_sec.toFixed(1)+'s</div>'+score+'</div>'
17013
19072
  + '<div class="clip-body">'
17014
19073
  + '<div class="clip-desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
17015
19074
  + '<div class="clip-tags">'+tags+'</div>'
@@ -17285,7 +19344,9 @@ async function serializeClip(clip) {
17285
19344
  ]);
17286
19345
  const { embedding, ...rest } = clip;
17287
19346
  void embedding; // never ship the raw vector to the client
17288
- return { ...rest, view_url, thumbnail_url };
19347
+ // Convenience flag alongside `role` so the raws UI can badge/sort the full
19348
+ // original video without reasoning about the union type.
19349
+ return { ...rest, view_url, thumbnail_url, is_source_original: clip.role === "source" };
17289
19350
  }
17290
19351
  function normalizeScanTier(value) {
17291
19352
  return value === "flash" ? "flash" : "flash-lite";
@@ -17314,18 +19375,73 @@ app.get(RAWS_PREFIX, async (c) => {
17314
19375
  editorChat: await buildClipsEditorChatBoot({ customer })
17315
19376
  }));
17316
19377
  });
17317
- // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q, ?limit).
19378
+ // GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q,
19379
+ // ?content_type, ?limit, ?offset). ?content_type is an exact tag filter
19380
+ // (comma-separated = OR) over each raw's content_type tags — the /library/raws
19381
+ // content-type chips use it for real structured filtering, distinct from ?q
19382
+ // semantic/keyword search. ?offset pages the (descending) result set for the
19383
+ // chat file browser; the response carries next_offset (null when exhausted).
17318
19384
  app.get(`${RAWS_PREFIX}/feed`, async (c) => {
17319
19385
  const customer = requireCustomer(c);
17320
19386
  const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
19387
+ const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
17321
19388
  const q = c.req.query("q")?.trim();
19389
+ const contentTypes = (c.req.query("content_type") || "")
19390
+ .split(",")
19391
+ .map((s) => s.trim().toLowerCase())
19392
+ .filter(Boolean);
19393
+ // ?folder= scopes the feed to one virtual Raws subfolder (exact match, or ""
19394
+ // for the raws root) — the directory browser navigates with it.
19395
+ const folderParam = c.req.query("folder");
19396
+ const folderScoped = folderParam != null;
19397
+ const folder = normalizeFolderPath(folderParam ?? "");
19398
+ const narrowed = Boolean(q) || contentTypes.length > 0 || folderScoped;
19399
+ // Unfiltered: load only up to the page window (+1 to detect a next page),
19400
+ // capped so a huge library never pulls unbounded. Filtered paths must scan a
19401
+ // wider set before slicing, since the filter runs in memory.
19402
+ const loadLimit = narrowed ? 5000 : Math.min(offset + limit + 1, 2000);
17322
19403
  let clips = await clipRecords.listClips(customer.id, {
17323
19404
  sourceVideoId: c.req.query("source") || undefined,
17324
- limit: q ? 5000 : limit
19405
+ limit: loadLimit
17325
19406
  });
19407
+ // Derive the folder tree from every loaded clip BEFORE the folder scope trims
19408
+ // the set (so the browser can see sibling folders). Complete whenever the load
19409
+ // scanned the whole library (all narrowed paths do).
19410
+ const folders = buildFolderList(clips.map((clip) => ({ folderPath: clip.folder_path })));
19411
+ if (contentTypes.length) {
19412
+ clips = clips.filter((clip) => (clip.tags.content_type ?? []).some((ct) => contentTypes.includes(ct)));
19413
+ }
19414
+ if (folderScoped) {
19415
+ clips = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === folder);
19416
+ }
17326
19417
  if (q)
17327
- clips = clips.filter((clip) => clipKeywordMatch(clip, q)).slice(0, limit);
17328
- return c.json({ clips: await Promise.all(clips.map(serializeClip)) });
19418
+ clips = clips.filter((clip) => clipKeywordMatch(clip, q));
19419
+ const page = clips.slice(offset, offset + limit);
19420
+ const nextOffset = clips.length > offset + limit ? offset + limit : null;
19421
+ return c.json({ clips: await Promise.all(page.map(serializeClip)), folders, next_offset: nextOffset });
19422
+ });
19423
+ // PATCH /raws/:clipId — move a clip between virtual Raws folders (or to the
19424
+ // root with folder_path: ""). Registered before other /raws/:param routes so a
19425
+ // literal like "feed"/"search" never matches as a clip id (those are GET/POST).
19426
+ app.patch(`${RAWS_PREFIX}/:clipId`, async (c) => {
19427
+ const customer = requireCustomer(c);
19428
+ try {
19429
+ const body = (await c.req.json().catch(() => ({})));
19430
+ if (typeof body.folder_path !== "string") {
19431
+ return c.json({ error: "Provide folder_path (\"\" moves to the raws root)." }, 400);
19432
+ }
19433
+ const clipId = c.req.param("clipId");
19434
+ const existing = await clipRecords.getClip(customer.id, clipId);
19435
+ if (!existing)
19436
+ return c.json({ error: "Raw not found." }, 404);
19437
+ const folderPath = normalizeFolderPath(body.folder_path);
19438
+ await clipRecords.updateClipFolder(customer.id, clipId, folderPath);
19439
+ const updated = await clipRecords.getClip(customer.id, clipId);
19440
+ return c.json({ ok: true, clip: updated ? await serializeClip(updated) : null });
19441
+ }
19442
+ catch (error) {
19443
+ return c.json({ error: error instanceof Error ? error.message : "Unable to move raw." }, 400);
19444
+ }
17329
19445
  });
17330
19446
  // POST /clips/search — hybrid structured + semantic search.
17331
19447
  app.post(`${RAWS_PREFIX}/search`, async (c) => {
@@ -17440,10 +19556,27 @@ app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
17440
19556
  // pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
17441
19557
  // compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
17442
19558
  // the caller's own provider key (BYOK) and is never wallet-billed.
19559
+ // Turn a free-text folder name (e.g. "My Campaign 001") into the canonical
19560
+ // single-segment Raws folder slug ("my-campaign-001"). Returns null when the
19561
+ // input is empty so callers keep the source-derived default. Mirrors the slug
19562
+ // transform used elsewhere for auto-named raws folders.
19563
+ function slugifyRawFolder(value) {
19564
+ const slug = String(value ?? "")
19565
+ .toLowerCase()
19566
+ .replace(/[^a-z0-9]+/g, "-")
19567
+ .replace(/^-+|-+$/g, "")
19568
+ .slice(0, 60);
19569
+ return slug || null;
19570
+ }
17443
19571
  app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17444
19572
  const customer = requireCustomer(c);
17445
19573
  try {
17446
19574
  const body = (await c.req.json());
19575
+ const importOnly = body.import_only === true;
19576
+ // A caller-named destination folder (the /editor "Clip from Raw" modal sends
19577
+ // the folder name here) overrides the default source-title slug for every
19578
+ // clip this import/hunt produces. Null keeps the source-derived default.
19579
+ const folderOverride = slugifyRawFolder(body.folder_path);
17447
19580
  const guidancePrompt = body.prompt?.trim() || "";
17448
19581
  // Build the hunt spec: explicit structured fields win, the free "what to
17449
19582
  // clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
@@ -17454,7 +19587,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17454
19587
  if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
17455
19588
  return c.json({ error: "That doesn't look like a valid video URL." }, 400);
17456
19589
  }
17457
- const huntSpec = normalizeHuntSpec({
19590
+ let huntSpec = normalizeHuntSpec({
17458
19591
  ...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
17459
19592
  ...(body.ranges != null ? { ranges: body.ranges } : {}),
17460
19593
  ...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
@@ -17475,8 +19608,20 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17475
19608
  huntSpec.aspect = parsedPrompt.aspect;
17476
19609
  if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
17477
19610
  huntSpec.avoid_text = true;
19611
+ if (!huntSpec.max_clips && parsedPrompt.max_clips)
19612
+ huntSpec.max_clips = parsedPrompt.max_clips;
17478
19613
  if (!huntSpec.source_url && sourceUrl)
17479
19614
  huntSpec.source_url = sourceUrl;
19615
+ // Carry the named destination folder on the hunt spec — it rides the opaque
19616
+ // hunt_spec object end-to-end (cloud Step Functions, local runner, upstream
19617
+ // backup) so mined clips land in `/raws/<folder>` without a pipeline change.
19618
+ if (folderOverride)
19619
+ huntSpec.folder_path = folderOverride;
19620
+ // Fill the unspecified-prompt mining defaults (5–15s clips, vertical 9:16,
19621
+ // avoid on-screen text). Explicit user/prompt choices above already won;
19622
+ // this only fills the gaps. Skipped for import_only (no mining happens).
19623
+ if (!importOnly)
19624
+ huntSpec = applyHuntSpecDefaults(huntSpec);
17480
19625
  // Uploaded-source hunts: resolve a temp-folder file (the canonical home for
17481
19626
  // hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
17482
19627
  let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
@@ -17551,6 +19696,34 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
17551
19696
  pipeline: input.pipeline,
17552
19697
  pipeline_deployed: cloudPipeline
17553
19698
  }, 202);
19699
+ // ── Import individual video (no clip mining) ─────────────────────────────
19700
+ // The Import Source modal's unchecked "Clip raws" box: just pull the source
19701
+ // into the raws library as ONE whole-video raw. No BYOK key, no AI tagging,
19702
+ // no wallet bill. Runs inline (awaited) so it's reliable on Lambda, then
19703
+ // returns a "complete" scan the poller sees immediately.
19704
+ if (importOnly) {
19705
+ const importSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
19706
+ if (resolvedS3Key && (!importSourcePath || !existsSync(importSourcePath)) && !huntSpec.source_url) {
19707
+ return c.json({ error: "That upload isn't reachable for a direct import — pass source_url instead." }, 400);
19708
+ }
19709
+ await putScanRecords({ scanStatus: "running" });
19710
+ try {
19711
+ await importWholeVideoAsRaw({
19712
+ ownerId: customer.id,
19713
+ scanId,
19714
+ sourceVideoId,
19715
+ filename,
19716
+ tracer,
19717
+ folderPath: folderOverride,
19718
+ localSourcePath: importSourcePath && existsSync(importSourcePath) ? importSourcePath : null,
19719
+ sourceUrl: huntSpec.source_url ?? null
19720
+ });
19721
+ }
19722
+ catch (error) {
19723
+ return c.json({ error: error instanceof Error ? error.message : "Could not import that video." }, 502);
19724
+ }
19725
+ return scanResponse({ status: "complete", pipeline: "local" });
19726
+ }
17554
19727
  if (cloudPipeline) {
17555
19728
  // ── Deployed pipeline (Lambda + Step Functions): BYOK API keys only ──
17556
19729
  if (choice === "agent") {
@@ -17780,7 +19953,11 @@ app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
17780
19953
  });
17781
19954
  function looksLikeDirectVideoUrl(url) {
17782
19955
  try {
17783
- return /\.(mp4|mov|m4v|webm|mkv)$/i.test(new URL(url).pathname);
19956
+ const u = new URL(url);
19957
+ // Match a video extension in the path OR a query value (signed/CDN links
19958
+ // often carry the real filename as `?file=clip.mp4` with a path-less route).
19959
+ return /\.(mp4|mov|m4v|webm|mkv)(\?|$)/i.test(u.pathname) ||
19960
+ /\.(mp4|mov|m4v|webm|mkv)(&|$)/i.test(u.search);
17784
19961
  }
17785
19962
  catch {
17786
19963
  return false;
@@ -17789,10 +19966,13 @@ function looksLikeDirectVideoUrl(url) {
17789
19966
  // Local URL ingest for serve-box hunts: direct video links download straight;
17790
19967
  // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI
17791
19968
  // downloader the video_download primitive uses (needs RAPIDAPI_KEY locally).
19969
+ // Returns the local path plus the source's human title (when RapidAPI gives one)
19970
+ // so callers can name the raw/folder something better than a bare URL slug.
17792
19971
  async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17793
19972
  if (!sourceUrl)
17794
19973
  throw new Error("Clip scan needs a source video.");
17795
19974
  let mediaUrl = sourceUrl;
19975
+ let title = null;
17796
19976
  if (!looksLikeDirectVideoUrl(sourceUrl)) {
17797
19977
  const apiKey = (config.RAPIDAPI_KEY || "").trim();
17798
19978
  if (!apiKey) {
@@ -17810,11 +19990,14 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17810
19990
  if (!lookupRes.ok)
17811
19991
  throw new Error(`Video download lookup failed with HTTP ${lookupRes.status}.`);
17812
19992
  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];
19993
+ // Pick the HIGHEST-resolution playable MP4 (not just the first one) so the
19994
+ // ingested source and every clip cut from it is as high-quality as the
19995
+ // provider offers.
19996
+ const chosen = pickBestVideoMedia(lookup.medias ?? []);
17815
19997
  if (!chosen?.url)
17816
19998
  throw new Error("Video download lookup returned no playable MP4 media URL.");
17817
19999
  mediaUrl = chosen.url;
20000
+ title = typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null;
17818
20001
  }
17819
20002
  const localPath = path.join(workDir, "ingest", "source.mp4");
17820
20003
  mkdirSync(path.dirname(localPath), { recursive: true });
@@ -17822,7 +20005,518 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
17822
20005
  if (!res.ok || !res.body)
17823
20006
  throw new Error(`Source video download failed with HTTP ${res.status}.`);
17824
20007
  await streamPipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
17825
- return localPath;
20008
+ return { videoPath: localPath, title };
20009
+ }
20010
+ // Record the RapidAPI social-download cost for an in-process URL ingest (the
20011
+ // whole-video import path bypasses the clip-scan Lambda, which is where this
20012
+ // third-party cost is normally billed). Mirrors the Lambda's cost center +
20013
+ // idempotency key so a retry never double-bills. Non-fatal.
20014
+ async function recordRapidApiIngestCost(input) {
20015
+ // Direct .mp4 links never touch RapidAPI, so there's nothing to bill.
20016
+ if (!input.sourceUrl || looksLikeDirectVideoUrl(input.sourceUrl))
20017
+ return;
20018
+ try {
20019
+ await billing.record({
20020
+ customerId: input.ownerId,
20021
+ jobId: input.scanId,
20022
+ tracer: input.tracer ?? null,
20023
+ templateId: "clips/scan",
20024
+ type: "cpu_estimate",
20025
+ costUsd: config.RAPIDAPI_VIDEO_DOWNLOAD_BASE_COST_USD,
20026
+ costCenterSlug: "rapidapi_video_download",
20027
+ idempotencyKey: `rapidapi_video_download:${input.scanId}`,
20028
+ occurredAtMs: Date.now(),
20029
+ metadata: { operation: "clip_import_ingest", source_url: input.sourceUrl }
20030
+ });
20031
+ }
20032
+ catch (error) {
20033
+ devLog("clip_import.ingest_billing_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
20034
+ }
20035
+ }
20036
+ // Import a whole video as a SINGLE raw — the plain "import individual video"
20037
+ // path behind the Import Source modal's unchecked "Clip raws" box. Ingests the
20038
+ // source (direct link or RapidAPI social download), probes its duration, cuts
20039
+ // one thumbnail, and writes ONE clip record spanning the entire video. No BYOK
20040
+ // key, no AI tagging, no wallet bill. Awaited by the caller so it completes
20041
+ // inside the request (reliable on Lambda, which freezes detached work).
20042
+ async function importWholeVideoAsRaw(input) {
20043
+ const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
20044
+ const touchScan = async (patch) => {
20045
+ const scan = await clipRecords.getScan(input.ownerId, input.scanId);
20046
+ if (scan)
20047
+ await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
20048
+ };
20049
+ const touchSource = async (patch) => {
20050
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20051
+ if (source)
20052
+ await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
20053
+ };
20054
+ try {
20055
+ mkdirSync(workRoot, { recursive: true });
20056
+ let videoPath = input.localSourcePath;
20057
+ let ingestedTitle = null;
20058
+ if (!videoPath) {
20059
+ const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
20060
+ videoPath = ingested.videoPath;
20061
+ ingestedTitle = ingested.title;
20062
+ // URL ingest hits RapidAPI (same third-party cost the clip-scan Lambda
20063
+ // bills on its own ingest); the whole-video path skips the Lambda, so
20064
+ // record the download here or it goes unbilled. Non-fatal on failure.
20065
+ await recordRapidApiIngestCost({
20066
+ ownerId: input.ownerId,
20067
+ scanId: input.scanId,
20068
+ tracer: input.tracer,
20069
+ sourceUrl: input.sourceUrl ?? ""
20070
+ });
20071
+ }
20072
+ const probe = await probeVideo(videoPath);
20073
+ const durationSec = probe.duration_sec || 0;
20074
+ await touchSource({ status: "scanning", duration_sec: durationSec });
20075
+ // A RapidAPI title beats a URL-slug filename for the raw's description and
20076
+ // its source-folder name in the /library/raws explorer.
20077
+ const displayName = ingestedTitle || input.filename;
20078
+ const clipId = createIdV7("clip");
20079
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20080
+ // Stream the source to storage instead of readFileSync-into-Buffer — whole
20081
+ // videos can be hundreds of MB and would otherwise pin the entire file in
20082
+ // memory on the request Lambda.
20083
+ await storage.putFile(clipKey, videoPath, "video/mp4");
20084
+ let thumbKey = "";
20085
+ const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
20086
+ const wroteThumb = await extractThumbnail({
20087
+ videoPath,
20088
+ scene: { index: 0, start_time_sec: 0, end_time_sec: durationSec, duration_sec: durationSec },
20089
+ outPath: thumbLocal
20090
+ }).catch(() => false);
20091
+ if (wroteThumb && existsSync(thumbLocal)) {
20092
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20093
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20094
+ }
20095
+ const clip = {
20096
+ clip_id: clipId,
20097
+ source_video_id: input.sourceVideoId,
20098
+ source_filename: displayName,
20099
+ start_time_sec: 0,
20100
+ end_time_sec: durationSec,
20101
+ duration_sec: durationSec,
20102
+ file_path: clipKey,
20103
+ thumbnail_path: thumbKey,
20104
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20105
+ description: displayName,
20106
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20107
+ tier: "flash-lite",
20108
+ tracer: input.tracer,
20109
+ // A caller-named folder wins; otherwise default into a source-derived Raws
20110
+ // subfolder (canonical `/raws/<slug>`).
20111
+ folder_path: input.folderPath
20112
+ ? normalizeFolderPath(input.folderPath)
20113
+ : normalizeFolderPath(String(displayName || "")
20114
+ .toLowerCase()
20115
+ .replace(/\.[a-z0-9]+$/i, "")
20116
+ .replace(/[^a-z0-9]+/g, "-")
20117
+ .replace(/^-+|-+$/g, "")
20118
+ .slice(0, 60)),
20119
+ owner_id: input.ownerId,
20120
+ created_at: nowIso()
20121
+ };
20122
+ // Best-effort semantic-search embedding of the title/description so an
20123
+ // imported whole video is findable by meaning (not just visible in its
20124
+ // folder). No AI tagging call, no wallet bill — fail-soft when the owner
20125
+ // has no embed-capable key (keyword-only, as before).
20126
+ try {
20127
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20128
+ if (embedder?.embeddingModelId) {
20129
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20130
+ if (vector?.length) {
20131
+ clip.embedding = vector;
20132
+ clip.embedding_model = embedder.embeddingModelId;
20133
+ }
20134
+ }
20135
+ }
20136
+ catch (error) {
20137
+ devLog("clip_import.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
20138
+ }
20139
+ await clipRecords.putClip(clip);
20140
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20141
+ await touchSource({ status: "complete", clip_count: 1, filename: displayName });
20142
+ await touchScan({ status: "complete" });
20143
+ }
20144
+ catch (error) {
20145
+ devLog("clip_import.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
20146
+ await touchSource({ status: "failed" }).catch(() => undefined);
20147
+ await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
20148
+ throw error;
20149
+ }
20150
+ finally {
20151
+ rmSync(workRoot, { recursive: true, force: true });
20152
+ }
20153
+ }
20154
+ // ── /tools/clipper support: resolve a preview URL + save an exact subrange ─────
20155
+ // Resolve a pasted source URL to a DIRECT, playable media URL (plus a human
20156
+ // title / poster) WITHOUT downloading or re-hosting it — the clipper page plays
20157
+ // this URL in a <video> so the user can pick a subrange, and only the chosen
20158
+ // subrange is ever written to our S3. Direct .mp4/.mov links pass through as-is;
20159
+ // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI downloader
20160
+ // the video_download primitive uses. This is a lightweight metadata lookup — the
20161
+ // billed download/trim happens later in importSubrangeAsRaw when a clip is saved.
20162
+ async function resolveSourcePreview(sourceUrl) {
20163
+ const url = (sourceUrl || "").trim();
20164
+ if (!url)
20165
+ throw new Error("A source video URL is required.");
20166
+ if (looksLikeDirectVideoUrl(url)) {
20167
+ const name = (url.split("/").pop() || "").split("?")[0] || null;
20168
+ return { mediaUrl: url, title: name, thumbnail: null, durationSec: null };
20169
+ }
20170
+ const apiKey = (config.RAPIDAPI_KEY || "").trim();
20171
+ if (!apiKey) {
20172
+ throw new Error("Can't resolve that URL for preview (RAPIDAPI_KEY unset) — paste a direct .mp4 URL instead.");
20173
+ }
20174
+ const lookupRes = await fetch(config.RAPIDAPI_VIDEO_DOWNLOAD_URL, {
20175
+ method: "POST",
20176
+ headers: {
20177
+ "x-rapidapi-key": apiKey,
20178
+ "x-rapidapi-host": config.RAPIDAPI_VIDEO_DOWNLOAD_HOST,
20179
+ "content-type": "application/x-www-form-urlencoded"
20180
+ },
20181
+ body: new URLSearchParams({ url })
20182
+ });
20183
+ if (!lookupRes.ok)
20184
+ throw new Error(`Video lookup failed with HTTP ${lookupRes.status}.`);
20185
+ const lookup = (await lookupRes.json());
20186
+ const chosen = pickBestVideoMedia(lookup.medias ?? []);
20187
+ if (!chosen?.url)
20188
+ throw new Error("That URL returned no playable MP4 to preview.");
20189
+ const durationRaw = typeof lookup.duration === "number" ? lookup.duration : Number(lookup.duration);
20190
+ return {
20191
+ mediaUrl: chosen.url,
20192
+ title: typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null,
20193
+ thumbnail: typeof lookup.thumbnail === "string" && lookup.thumbnail.trim() ? lookup.thumbnail.trim() : null,
20194
+ durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : null
20195
+ };
20196
+ }
20197
+ // Stream a direct media URL to a local file (no RapidAPI). Used when the clipper
20198
+ // hands back an already-resolved preview_url so we don't re-resolve it.
20199
+ async function downloadDirectToFile(mediaUrl, destPath) {
20200
+ mkdirSync(path.dirname(destPath), { recursive: true });
20201
+ const res = await fetch(mediaUrl);
20202
+ if (!res.ok || !res.body)
20203
+ throw new Error(`Source download failed with HTTP ${res.status}.`);
20204
+ await streamPipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
20205
+ }
20206
+ // Trim EXACTLY [startSec, endSec] out of a source video and save that single
20207
+ // subrange as one raw — the /tools/clipper equivalent of importWholeVideoAsRaw,
20208
+ // but frame-trimmed instead of whole. The whole source is fetched once to a
20209
+ // scratch dir, ffmpeg cuts the range, and only the trimmed mp4 (+ its thumbnail)
20210
+ // is written to durable storage. Runs inline; returns the finished clip so the
20211
+ // caller can surface it immediately.
20212
+ async function importSubrangeAsRaw(input) {
20213
+ const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
20214
+ const touchScan = async (patch) => {
20215
+ const scan = await clipRecords.getScan(input.ownerId, input.scanId);
20216
+ if (scan)
20217
+ await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
20218
+ };
20219
+ const touchSource = async (patch) => {
20220
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20221
+ if (source)
20222
+ await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
20223
+ };
20224
+ try {
20225
+ mkdirSync(workRoot, { recursive: true });
20226
+ let videoPath = input.localSourcePath && existsSync(input.localSourcePath) ? input.localSourcePath : null;
20227
+ let ingestedTitle = null;
20228
+ if (!videoPath && input.mediaUrl) {
20229
+ videoPath = path.join(workRoot, "ingest", "source.mp4");
20230
+ await downloadDirectToFile(input.mediaUrl, videoPath);
20231
+ }
20232
+ else if (!videoPath) {
20233
+ const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
20234
+ videoPath = ingested.videoPath;
20235
+ ingestedTitle = ingested.title;
20236
+ await recordRapidApiIngestCost({ ownerId: input.ownerId, scanId: input.scanId, tracer: input.tracer, sourceUrl: input.sourceUrl ?? "" });
20237
+ }
20238
+ const probe = await probeVideo(videoPath);
20239
+ const sourceDurationSec = probe.duration_sec || 0;
20240
+ // Clamp the requested range into the real source duration and enforce end>start.
20241
+ const start = Math.max(0, sourceDurationSec > 0 ? Math.min(input.startSec, sourceDurationSec - 0.05) : input.startSec);
20242
+ const rawEnd = sourceDurationSec > 0 ? Math.min(input.endSec, sourceDurationSec) : input.endSec;
20243
+ const end = Math.max(start + 0.05, rawEnd);
20244
+ const clipDurationSec = Number((end - start).toFixed(3));
20245
+ await touchSource({ status: "scanning", duration_sec: sourceDurationSec });
20246
+ const clipId = createIdV7("clip");
20247
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20248
+ const localClip = path.join(workRoot, `${clipId}.mp4`);
20249
+ const trimmed = await extractClip({
20250
+ videoPath,
20251
+ scene: { index: 0, start_time_sec: start, end_time_sec: end, duration_sec: clipDurationSec },
20252
+ outPath: localClip
20253
+ });
20254
+ if (!trimmed || !existsSync(localClip))
20255
+ throw new Error("Could not trim that subrange.");
20256
+ await storage.putFile(clipKey, localClip, "video/mp4");
20257
+ let thumbKey = "";
20258
+ const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
20259
+ const wroteThumb = await extractThumbnail({
20260
+ videoPath: localClip,
20261
+ scene: { index: 0, start_time_sec: 0, end_time_sec: clipDurationSec, duration_sec: clipDurationSec },
20262
+ outPath: thumbLocal
20263
+ }).catch(() => false);
20264
+ if (wroteThumb && existsSync(thumbLocal)) {
20265
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20266
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20267
+ }
20268
+ const baseName = (input.name || ingestedTitle || input.filename || "clip").trim() || "clip";
20269
+ const description = `${baseName} (${formatClockRange(start)}–${formatClockRange(end)})`;
20270
+ const folderPath = input.folderPath
20271
+ ? normalizeFolderPath(input.folderPath)
20272
+ : normalizeFolderPath(String(baseName).toLowerCase().replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60));
20273
+ const clip = {
20274
+ clip_id: clipId,
20275
+ source_video_id: input.sourceVideoId,
20276
+ source_filename: baseName,
20277
+ start_time_sec: 0,
20278
+ end_time_sec: clipDurationSec,
20279
+ duration_sec: clipDurationSec,
20280
+ file_path: clipKey,
20281
+ thumbnail_path: thumbKey,
20282
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20283
+ description,
20284
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20285
+ tier: "flash-lite",
20286
+ tracer: input.tracer,
20287
+ folder_path: folderPath,
20288
+ owner_id: input.ownerId,
20289
+ created_at: nowIso()
20290
+ };
20291
+ try {
20292
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20293
+ if (embedder?.embeddingModelId) {
20294
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20295
+ if (vector?.length) {
20296
+ clip.embedding = vector;
20297
+ clip.embedding_model = embedder.embeddingModelId;
20298
+ }
20299
+ }
20300
+ }
20301
+ catch (error) {
20302
+ devLog("clip_range.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
20303
+ }
20304
+ await clipRecords.putClip(clip);
20305
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20306
+ await touchSource({ status: "complete", clip_count: 1, filename: baseName });
20307
+ await touchScan({ status: "complete" });
20308
+ return { clipId, clipKey, thumbKey, durationSec: clipDurationSec, sourceStartSec: start, sourceEndSec: end, description, folderPath };
20309
+ }
20310
+ catch (error) {
20311
+ devLog("clip_range.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
20312
+ await touchSource({ status: "failed" }).catch(() => undefined);
20313
+ await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
20314
+ throw error;
20315
+ }
20316
+ finally {
20317
+ rmSync(workRoot, { recursive: true, force: true });
20318
+ }
20319
+ }
20320
+ // Compact H:MM:SS clock for a clip's human description/range label.
20321
+ function formatClockRange(sec) {
20322
+ const s = Math.max(0, Math.floor(sec));
20323
+ const h = Math.floor(s / 3600);
20324
+ const m = Math.floor((s % 3600) / 60);
20325
+ const ss = s % 60;
20326
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
20327
+ return `${h > 0 ? h + ":" : ""}${mm}:${String(ss).padStart(2, "0")}`;
20328
+ }
20329
+ // POST /raws/resolve-source — resolve a pasted URL to a direct preview media URL
20330
+ // (no download, no S3 write). The /tools/clipper page calls this on load so it
20331
+ // can play the source and let the user pick a subrange before anything is saved.
20332
+ app.post(`${RAWS_PREFIX}/resolve-source`, async (c) => {
20333
+ const customer = requireCustomer(c);
20334
+ try {
20335
+ const body = (await c.req.json().catch(() => ({})));
20336
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20337
+ if (!sourceUrl)
20338
+ return c.json({ error: "Provide source_url (a video URL)." }, 400);
20339
+ if (!/^https?:\/\//i.test(sourceUrl))
20340
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20341
+ void customer;
20342
+ const resolved = await resolveSourcePreview(sourceUrl);
20343
+ return c.json({
20344
+ source_url: sourceUrl,
20345
+ preview_url: resolved.mediaUrl,
20346
+ is_direct: looksLikeDirectVideoUrl(sourceUrl),
20347
+ title: resolved.title,
20348
+ duration_sec: resolved.durationSec,
20349
+ thumbnail_url: resolved.thumbnail
20350
+ });
20351
+ }
20352
+ catch (error) {
20353
+ return c.json({ error: error instanceof Error ? error.message : "Could not resolve that video URL." }, 502);
20354
+ }
20355
+ });
20356
+ // POST /raws/clip-range — trim EXACTLY [start_sec, end_sec] from a source and
20357
+ // save that single subrange as one raw. Reusable by the web clipper (which sends
20358
+ // the already-resolved preview_url), the devcli `clipper` command, and the AI
20359
+ // editor-chat. Runs inline and returns the finished clip.
20360
+ app.post(`${RAWS_PREFIX}/clip-range`, async (c) => {
20361
+ const customer = requireCustomer(c);
20362
+ try {
20363
+ const body = (await c.req.json().catch(() => ({})));
20364
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20365
+ const mediaUrl = readNonEmptyString(body.preview_url) ?? readNonEmptyString(body.media_url);
20366
+ // Resolve an uploaded source (temp file / My Files attachment) to a local key.
20367
+ let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
20368
+ const tempFileId = readNonEmptyString(body.temp_file_id);
20369
+ const attachmentId = readNonEmptyString(body.attachment_id);
20370
+ if (!resolvedS3Key && tempFileId) {
20371
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, tempFileId);
20372
+ if (!file)
20373
+ return c.json({ error: "Temporary file not found." }, 404);
20374
+ resolvedS3Key = file.storageKey;
20375
+ }
20376
+ else if (!resolvedS3Key && attachmentId) {
20377
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
20378
+ if (!attachment)
20379
+ return c.json({ error: "Attachment not found in My Files." }, 404);
20380
+ resolvedS3Key = attachment.storageKey;
20381
+ }
20382
+ const localSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
20383
+ if (!sourceUrl && !mediaUrl && !(localSourcePath && existsSync(localSourcePath))) {
20384
+ return c.json({ error: "Provide source_url / preview_url (a video URL) or temp_file_id/attachment_id/s3_key of an upload." }, 400);
20385
+ }
20386
+ if ((sourceUrl && !/^https?:\/\//i.test(sourceUrl)) || (mediaUrl && !/^https?:\/\//i.test(mediaUrl))) {
20387
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20388
+ }
20389
+ // Accept seconds or milliseconds; require a positive-length range.
20390
+ const startSec = body.start_sec != null ? Number(body.start_sec) : body.start_ms != null ? Number(body.start_ms) / 1000 : 0;
20391
+ const endSec = body.end_sec != null ? Number(body.end_sec) : body.end_ms != null ? Number(body.end_ms) / 1000 : NaN;
20392
+ if (!Number.isFinite(startSec) || startSec < 0)
20393
+ return c.json({ error: "start_sec must be a non-negative number." }, 400);
20394
+ if (!Number.isFinite(endSec))
20395
+ return c.json({ error: "Provide end_sec (or duration via end_ms)." }, 400);
20396
+ if (endSec <= startSec)
20397
+ return c.json({ error: "end_sec must be greater than start_sec." }, 400);
20398
+ const tracer = readNonEmptyString(body.tracer) ?? `clipper:${customer.id}`;
20399
+ const folderOverride = slugifyRawFolder(body.folder_path) ?? slugifyRawFolder(tracer);
20400
+ const now = nowIso();
20401
+ const scanId = createIdV7("clipscan");
20402
+ const sourceVideoId = createIdV7("srcvid");
20403
+ const sourcePointer = mediaUrl ?? sourceUrl ?? resolvedS3Key ?? "";
20404
+ const filename = readNonEmptyString(body.filename) ?? readNonEmptyString(body.name) ?? ((sourcePointer.split("/").pop() || "source.mp4").split("?")[0] || "source.mp4");
20405
+ await clipRecords.putSource({ source_video_id: sourceVideoId, owner_id: customer.id, filename, raw_s3_uri: sourceUrl ?? sourcePointer, clip_count: 0, tier: "flash-lite", status: "pending", scan_id: scanId, tracer, created_at: now, updated_at: now });
20406
+ await clipRecords.putScan({ scan_id: scanId, owner_id: customer.id, source_video_id: sourceVideoId, status: "running", tier: "flash-lite", raw_s3_uri: sourceUrl ?? sourcePointer, tracer, created_at: now, updated_at: now });
20407
+ let result;
20408
+ try {
20409
+ result = await importSubrangeAsRaw({
20410
+ ownerId: customer.id,
20411
+ scanId,
20412
+ sourceVideoId,
20413
+ filename,
20414
+ tracer,
20415
+ folderPath: folderOverride,
20416
+ localSourcePath: localSourcePath && existsSync(localSourcePath) ? localSourcePath : null,
20417
+ mediaUrl: mediaUrl ?? null,
20418
+ sourceUrl: sourceUrl ?? null,
20419
+ startSec,
20420
+ endSec,
20421
+ name: readNonEmptyString(body.name) ?? null
20422
+ });
20423
+ }
20424
+ catch (error) {
20425
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 502);
20426
+ }
20427
+ const [viewUrl, thumbnailUrl] = await Promise.all([
20428
+ clipMediaUrl(result.clipKey),
20429
+ result.thumbKey ? clipMediaUrl(result.thumbKey) : Promise.resolve(null)
20430
+ ]);
20431
+ return c.json({
20432
+ clip_id: result.clipId,
20433
+ source_video_id: sourceVideoId,
20434
+ tracer,
20435
+ folder_path: result.folderPath,
20436
+ duration_sec: result.durationSec,
20437
+ source_start_sec: result.sourceStartSec,
20438
+ source_end_sec: result.sourceEndSec,
20439
+ description: result.description,
20440
+ view_url: viewUrl ?? `/clips/${result.clipId}/download`,
20441
+ thumbnail_url: thumbnailUrl
20442
+ });
20443
+ }
20444
+ catch (error) {
20445
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 500);
20446
+ }
20447
+ });
20448
+ // Store the full-length ORIGINAL video as a durable role:"source" raw for a
20449
+ // serve-box hunt — the same durable-original behavior the cloud finalize adds,
20450
+ // but reusing the local storage driver. The source is still on disk at finalize,
20451
+ // so it's copied straight in at its ORIGINAL aspect (no 9:16 crop). It lands in
20452
+ // the SAME source-slug folder as its mined clips so /library/raws groups them.
20453
+ // Best-effort; the caller ignores failures so the hunt still completes.
20454
+ async function storeSourceOriginalRawLocal(input) {
20455
+ // Dedupe: never mint a second source-original for this source (re-run safety).
20456
+ const existing = await clipRecords.listClips(input.ownerId, { sourceVideoId: input.sourceVideoId, limit: 5000 });
20457
+ if (existing.some((c) => c.role === "source"))
20458
+ return;
20459
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20460
+ const tracer = source?.tracer;
20461
+ const displayName = source?.filename || input.filename;
20462
+ const clipId = createIdV7("clip");
20463
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20464
+ await storage.putFile(clipKey, input.videoPath, "video/mp4");
20465
+ let thumbKey = "";
20466
+ const thumbLocal = path.join(input.workRoot, `source-${clipId}.jpg`);
20467
+ const wroteThumb = await extractThumbnail({
20468
+ videoPath: input.videoPath,
20469
+ scene: { index: 0, start_time_sec: 0, end_time_sec: input.durationSec, duration_sec: input.durationSec },
20470
+ outPath: thumbLocal
20471
+ }).catch(() => false);
20472
+ if (wroteThumb && existsSync(thumbLocal)) {
20473
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20474
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20475
+ }
20476
+ const clip = {
20477
+ clip_id: clipId,
20478
+ source_video_id: input.sourceVideoId,
20479
+ source_filename: input.filename,
20480
+ start_time_sec: 0,
20481
+ end_time_sec: input.durationSec,
20482
+ duration_sec: input.durationSec,
20483
+ file_path: clipKey,
20484
+ thumbnail_path: thumbKey,
20485
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20486
+ description: displayName,
20487
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20488
+ tier: "flash-lite",
20489
+ role: "source",
20490
+ ...(tracer ? { tracer } : {}),
20491
+ // Group with the mined clips: reuse the folder they actually landed in (a
20492
+ // caller-named destination or the source slug) so the "Full video" raw sits
20493
+ // in the same folder. Falls back to the source-title slug when no mined clip
20494
+ // recorded one.
20495
+ folder_path: existing.find((c) => c.folder_path)?.folder_path ?? normalizeFolderPath(String(input.filename || "")
20496
+ .toLowerCase()
20497
+ .replace(/\.[a-z0-9]+$/i, "")
20498
+ .replace(/[^a-z0-9]+/g, "-")
20499
+ .replace(/^-+|-+$/g, "")
20500
+ .slice(0, 60)),
20501
+ owner_id: input.ownerId,
20502
+ created_at: nowIso()
20503
+ };
20504
+ // Best-effort embedding so the full original is findable by meaning too.
20505
+ try {
20506
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20507
+ if (embedder?.embeddingModelId) {
20508
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20509
+ if (vector?.length) {
20510
+ clip.embedding = vector;
20511
+ clip.embedding_model = embedder.embeddingModelId;
20512
+ }
20513
+ }
20514
+ }
20515
+ catch (error) {
20516
+ devLog("clip_scan.source_original_embed_failed", { source_video_id: input.sourceVideoId, ...devErrorFields(error) }, "warn");
20517
+ }
20518
+ await clipRecords.putClip(clip);
20519
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
17826
20520
  }
17827
20521
  // In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
17828
20522
  // Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
@@ -17843,7 +20537,7 @@ async function runClipScanInProcess(input) {
17843
20537
  };
17844
20538
  try {
17845
20539
  mkdirSync(workRoot, { recursive: true });
17846
- const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot));
20540
+ const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot)).videoPath;
17847
20541
  const probe = await probeVideo(videoPath);
17848
20542
  await touchSource({ status: "scanning", duration_sec: probe.duration_sec });
17849
20543
  const spec = input.huntSpec;
@@ -17860,6 +20554,9 @@ async function runClipScanInProcess(input) {
17860
20554
  ? { aspect: spec.aspect, focus: normalizeCropFocus(spec.crop_focus) }
17861
20555
  : undefined
17862
20556
  };
20557
+ // Aim for a distinct, density-bounded set (default ~10 clips / 10 min) over
20558
+ // the effective (windowed) source duration.
20559
+ const targetClipCount = resolveTargetClipCount(effectiveHuntDurationSec(probe.duration_sec, spec.windows), spec);
17863
20560
  let stored = 0;
17864
20561
  await scanVideo({
17865
20562
  ctx,
@@ -17867,12 +20564,28 @@ async function runClipScanInProcess(input) {
17867
20564
  probe,
17868
20565
  windows: spec.windows,
17869
20566
  durationBand: spec.duration_band ?? null,
20567
+ targetClipCount,
17870
20568
  sourceVideoId: input.sourceVideoId,
17871
20569
  sourceFilename: input.filename,
17872
20570
  ownerId: input.ownerId,
17873
20571
  // Local agents run one CLI process per call — keep parallelism modest.
17874
20572
  concurrency: input.client.provider === "local-agent" ? 2 : 3,
17875
20573
  onClip: async (clip) => {
20574
+ // Folder the mined clip: a caller-named destination (hunt_spec.folder_path,
20575
+ // e.g. the /editor "Clip from Raw" folder) wins; otherwise fall back to the
20576
+ // source-title slug so the local library groups by source like the cloud
20577
+ // pipeline does. Legacy clips without either stay unfoldered.
20578
+ if (!clip.folder_path) {
20579
+ const sourceSlug = String(input.filename || "")
20580
+ .toLowerCase()
20581
+ .replace(/\.[a-z0-9]+$/i, "")
20582
+ .replace(/[^a-z0-9]+/g, "-")
20583
+ .replace(/^-+|-+$/g, "")
20584
+ .slice(0, 60);
20585
+ const folder = spec.folder_path || sourceSlug;
20586
+ if (folder)
20587
+ clip.folder_path = folder;
20588
+ }
17876
20589
  // Persist through the storage service (local driver) as bare keys —
17877
20590
  // clipMediaUrl resolves clips/… + thumbs/… keys to fetchable URLs.
17878
20591
  const clipKey = `clips/${input.ownerId}/${clip.clip_id}.mp4`;
@@ -17896,6 +20609,20 @@ async function runClipScanInProcess(input) {
17896
20609
  devLog("clip_scan.local_scene_failed", { scan_id: input.scanId, scene_start: scene.start_time_sec, ...devErrorFields(error) }, "warn");
17897
20610
  }
17898
20611
  });
20612
+ // Keep a durable full-length copy of the original as its own role:"source"
20613
+ // raw, grouped with the mined clips — only when the hunt produced clips.
20614
+ // Fail-soft: a failure here must NOT fail the hunt. clip_count stays the
20615
+ // mined count (the source-original is an extra reference raw).
20616
+ if (stored > 0) {
20617
+ await storeSourceOriginalRawLocal({
20618
+ ownerId: input.ownerId,
20619
+ sourceVideoId: input.sourceVideoId,
20620
+ filename: input.filename,
20621
+ videoPath,
20622
+ durationSec: probe.duration_sec,
20623
+ workRoot
20624
+ }).catch((error) => devLog("clip_scan.source_original_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn"));
20625
+ }
17899
20626
  await touchSource({ status: "complete", clip_count: stored });
17900
20627
  await touchScan({ status: "complete" });
17901
20628
  }
@@ -17995,7 +20722,11 @@ async function mirrorUpstreamClipsToLocal(input) {
17995
20722
  }
17996
20723
  await clipRecords.putClip(clip);
17997
20724
  await vectorIndex.upsert(ownerId, clip);
17998
- stored++;
20725
+ // The upstream feed includes the source-original raw (role:"source"); mirror
20726
+ // it down like any clip but keep it OUT of the mined-clip count so clip_count
20727
+ // stays consistent with the cloud/serve pipelines.
20728
+ if (clip.role !== "source")
20729
+ stored++;
17999
20730
  }
18000
20731
  const source = await clipRecords.getSource(ownerId, scan.source_video_id);
18001
20732
  if (source)