@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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 +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +79 -75
  8. package/dist/src/account-pages-legacy.js +1 -1
  9. package/dist/src/app.js +1694 -217
  10. package/dist/src/cli.js +1365 -105
  11. package/dist/src/config.js +8 -0
  12. package/dist/src/devcli/clips.js +3 -0
  13. package/dist/src/devcli/composition-edit.js +401 -10
  14. package/dist/src/devcli/local-backend.js +123 -0
  15. package/dist/src/devcli/migrate-local.js +140 -0
  16. package/dist/src/devcli/sync.js +311 -0
  17. package/dist/src/devcli/timeline-edit.js +208 -1
  18. package/dist/src/editor-chat.js +37 -18
  19. package/dist/src/frontend/file-directory.js +271 -20
  20. package/dist/src/frontend/homepage-view.js +3 -3
  21. package/dist/src/frontend/template-editor-chat.js +6 -3
  22. package/dist/src/page-shell.js +1 -1
  23. package/dist/src/primitive-context.js +31 -2
  24. package/dist/src/primitive-registry.js +409 -4
  25. package/dist/src/reskin/chat-page.js +103 -15
  26. package/dist/src/reskin/discover-page.js +247 -10
  27. package/dist/src/reskin/document.js +147 -10
  28. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  29. package/dist/src/reskin/inpaint-page.js +2459 -452
  30. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  31. package/dist/src/reskin/library-page.js +324 -82
  32. package/dist/src/reskin/portfolio-page.js +687 -0
  33. package/dist/src/reskin/theme.js +36 -11
  34. package/dist/src/services/billing.js +4 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-records.js +28 -0
  37. package/dist/src/services/file-directory.js +6 -3
  38. package/dist/src/services/hyperframes.js +535 -86
  39. package/dist/src/services/serverless-jobs.js +3 -1
  40. package/dist/src/services/serverless-records.js +43 -0
  41. package/dist/src/services/storage.js +24 -2
  42. package/dist/src/template-editor-pages.js +1 -1
  43. package/package.json +1 -1
  44. package/public/assets/file-directory-app.js +2 -2
  45. package/public/assets/homepage-client-app.js +1 -1
  46. package/public/assets/page-runtime-client-app.js +2 -2
  47. package/public/assets/placeholders/scene-placeholder.png +0 -0
package/dist/src/app.js CHANGED
@@ -31,7 +31,10 @@ 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";
37
+ import { renderReskinPortfolio, DEFAULT_PORTFOLIO_SLUG } from "./reskin/portfolio-page.js";
35
38
  import { renderReskinPricing } from "./reskin/pricing-page.js";
36
39
  import { renderReskinLogin } from "./reskin/login-page.js";
37
40
  import { renderReskinHelp } from "./reskin/help-page.js";
@@ -62,7 +65,7 @@ import { RateLimitExceededError, RateLimitService } from "./services/rate-limits
62
65
  import { ServerlessProviderKeyService } from "./services/serverless-provider-keys.js";
63
66
  import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
64
67
  import { parseHTML } from "linkedom";
65
- import { joinStorageKey, StorageService } from "./services/storage.js";
68
+ import { joinStorageKey, storage as sharedStorage } from "./services/storage.js";
66
69
  import { buildCaptionCues, cuesFromText } from "./services/captions.js";
67
70
  import { defaultSpeechModelFor as speechModelFor, transcribeSpeechWithKey } from "./services/speech.js";
68
71
  import { insertCaptionLayers } from "./devcli/composition-edit.js";
@@ -71,12 +74,12 @@ import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws
71
74
  import { clipRecords, makeUserPreset } from "./services/clip-records.js";
72
75
  import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
73
76
  import { ATTACHMENTS_FOLDER_SCOPE, buildDirectoryPath, DIRECTORY_ROOTS, DIRECTORY_ROOT_KEYS, folderItem, immediateChildFolders, normalizeFolderPath, parseDirectoryPath } from "./services/file-directory.js";
74
- import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
75
- import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
77
+ 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";
78
+ import { analyzeCastMembers, annotateScenesFromSource, buildBlankCompositionHtml, buildSmartDecomposedHtml, DEFAULT_EDITOR_HARNESS, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
76
79
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
77
80
  import { createPrimitiveJobContext } from "./primitive-context.js";
78
81
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
79
- import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
82
+ import { MediaDurationExceededError, extractVideoFrameAsset, probeMediaAsset } from "./services/media-processing.js";
80
83
  import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
81
84
  import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
82
85
  import { TemplateSourceService } from "./services/template-sources.js";
@@ -92,7 +95,7 @@ const providers = new ProviderService();
92
95
  const rateLimits = new RateLimitService();
93
96
  const serverlessProviderKeys = new ServerlessProviderKeyService();
94
97
  const serverlessTemplateConfigs = new ServerlessTemplateConfigService();
95
- const storage = new StorageService();
98
+ const storage = sharedStorage;
96
99
  const templateSources = new TemplateSourceService();
97
100
  const hyperframes = new HyperframesService();
98
101
  const API_PREFIX = "/api/v1";
@@ -1627,6 +1630,12 @@ function primitiveOperationPathForJob(job) {
1627
1630
  if (job.templateId === "primitive:image_remove_background" && job.operationName === "run") {
1628
1631
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
1629
1632
  }
1633
+ if (job.templateId === "primitive:image_remove_background_greenscreen" && job.operationName === "run") {
1634
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
1635
+ }
1636
+ if (job.templateId === "primitive:media_overlay" && job.operationName === "run") {
1637
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
1638
+ }
1630
1639
  if (job.templateId === "primitive:video_generate" && job.operationName === "run") {
1631
1640
  return `${PRIMITIVES_PREFIX}/videos/generate`;
1632
1641
  }
@@ -2951,7 +2960,50 @@ function buildUserAttachmentViewUrl(c, storageKey) {
2951
2960
  function buildUserFileUrl(c, storageKey) {
2952
2961
  return storage.getPublicUrl(storageKey) ?? buildAbsoluteUrl(c, `/template-media?key=${encodeURIComponent(storageKey)}`);
2953
2962
  }
2963
+ // Ensure a durable poster image URL for a My Files attachment so the file
2964
+ // directory can render a snappy thumbnail. Policy per media type:
2965
+ // image/* → the image IS its own poster (return its own view URL, no re-encode
2966
+ // — cheapest + snappiest).
2967
+ // video/* → extract a frame (~1000ms in, fall back to 0), store the JPEG under
2968
+ // thumbs/attachments/<customer>/<id>.jpg and return that key + url.
2969
+ // audio / pdf / text / everything else → null; the frontend shows a type icon.
2970
+ // BEST-EFFORT / fail-soft: a failed extraction NEVER fails the upload — it just
2971
+ // returns null and logs (mirrors importWholeVideoAsRaw's `.catch(() => false)`).
2972
+ async function ensureAttachmentThumbnail(input) {
2973
+ const type = (input.contentType || "").toLowerCase();
2974
+ if (type.startsWith("image/")) {
2975
+ // An image is its own poster — never re-encode.
2976
+ const url = input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2977
+ return url ? { thumbnailStorageKey: null, thumbnailUrl: url } : null;
2978
+ }
2979
+ if (!type.startsWith("video/")) {
2980
+ // Audio, PDF, text, etc. — the file directory already renders a type icon.
2981
+ return null;
2982
+ }
2983
+ // extractVideoFrameAsset downloads a URL, so point it at the just-stored
2984
+ // object's own public URL when no explicit source URL is supplied.
2985
+ const frameSource = input.sourceUrl || input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2986
+ if (!frameSource)
2987
+ return null;
2988
+ try {
2989
+ const frame = await extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 1000, format: "jpeg" })
2990
+ // Very short clips have no frame 1s in — fall back to the very first frame.
2991
+ .catch(() => extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 0, format: "jpeg" }));
2992
+ const thumbKey = joinStorageKey("thumbs", "attachments", input.customerId, `${input.attachmentId}.jpg`);
2993
+ const stored = await storage.putBuffer(thumbKey, frame.bytes, "image/jpeg");
2994
+ const thumbnailUrl = stored.url || storage.getPublicUrl(thumbKey);
2995
+ return { thumbnailStorageKey: thumbKey, thumbnailUrl: thumbnailUrl ?? null };
2996
+ }
2997
+ catch (error) {
2998
+ devLog("attachment.thumbnail_failed", { attachment_id: input.attachmentId, ...devErrorFields(error) }, "warn");
2999
+ return null;
3000
+ }
3001
+ }
2954
3002
  function serializeUserAttachment(c, attachment) {
3003
+ // Every image poster falls back to the file's own view URL so image entries
3004
+ // always render a thumbnail even if the record predates thumbnail wiring.
3005
+ const viewUrl = attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey);
3006
+ const isImage = (attachment.contentType || "").toLowerCase().startsWith("image/");
2955
3007
  return {
2956
3008
  id: attachment.id,
2957
3009
  fileName: attachment.fileName,
@@ -2962,7 +3014,8 @@ function serializeUserAttachment(c, attachment) {
2962
3014
  notes: attachment.notes ?? null,
2963
3015
  // Flag only — the raw vector never ships to the client (mirrors serializeClip).
2964
3016
  hasNotesEmbedding: Boolean(attachment.notesEmbedding && attachment.notesEmbedding.length > 0),
2965
- viewUrl: attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey)
3017
+ viewUrl,
3018
+ thumbnailUrl: attachment.thumbnailUrl || (isImage ? viewUrl : null)
2966
3019
  };
2967
3020
  }
2968
3021
  function serializeUserTemporaryFile(c, file) {
@@ -2996,6 +3049,7 @@ function attachmentToDirectoryItem(c, a) {
2996
3049
  id: a.id,
2997
3050
  contentType: a.contentType,
2998
3051
  viewUrl: s.viewUrl,
3052
+ thumbUrl: s.thumbnailUrl ?? undefined,
2999
3053
  sizeBytes: a.sizeBytes,
3000
3054
  meta: { notes: s.notes, hasNotesEmbedding: s.hasNotesEmbedding, createdAt: a.createdAt }
3001
3055
  };
@@ -3035,10 +3089,160 @@ function clipSerializedToDirectoryItem(s) {
3035
3089
  meta: { description: s.description, tags: s.tags, created_at: s.created_at, source_video_id: s.source_video_id }
3036
3090
  };
3037
3091
  }
3092
+ // An approved ready-post as a canonical directory file. view/thumb come from the
3093
+ // post's media assets (video for playback, an image asset for the poster).
3094
+ function readyPostToDirectoryItem(post) {
3095
+ const folderPath = normalizeFolderPath(post.folderPath ?? "");
3096
+ const name = (post.title && post.title.trim()) || `Post ${post.id}`;
3097
+ const media = post.mediaAssets || [];
3098
+ const video = media.find((a) => a.kind === "video");
3099
+ const image = media.find((a) => a.kind === "image");
3100
+ const primary = media.find((a) => a.role === "primary") || video || media[0] || null;
3101
+ return {
3102
+ path: buildDirectoryPath("approved", folderPath, name),
3103
+ root: "approved",
3104
+ folderPath,
3105
+ name,
3106
+ kind: "file",
3107
+ id: post.id,
3108
+ contentType: video ? "video/mp4" : (primary?.contentType || "application/octet-stream"),
3109
+ viewUrl: (primary?.url || video?.url) || undefined,
3110
+ thumbUrl: image?.url || undefined,
3111
+ meta: { tracer: post.tracer, status: post.status, caption: post.caption, created_at: post.createdAt }
3112
+ };
3113
+ }
3114
+ // Infer a MIME type from a fork file's name so the explorer can preview images /
3115
+ // video / audio and the agent can tell text (composition.html/json) from binary.
3116
+ function inferProjectFileContentType(name) {
3117
+ const ext = (name.split(".").pop() || "").toLowerCase();
3118
+ switch (ext) {
3119
+ case "html":
3120
+ case "htm": return "text/html; charset=utf-8";
3121
+ case "json": return "application/json";
3122
+ case "txt": return "text/plain; charset=utf-8";
3123
+ case "md": return "text/markdown; charset=utf-8";
3124
+ case "css": return "text/css; charset=utf-8";
3125
+ case "js":
3126
+ case "mjs": return "text/javascript; charset=utf-8";
3127
+ case "srt": return "application/x-subrip";
3128
+ case "vtt": return "text/vtt";
3129
+ case "png": return "image/png";
3130
+ case "jpg":
3131
+ case "jpeg": return "image/jpeg";
3132
+ case "gif": return "image/gif";
3133
+ case "webp": return "image/webp";
3134
+ case "svg": return "image/svg+xml";
3135
+ case "mp4":
3136
+ case "m4v": return "video/mp4";
3137
+ case "webm": return "video/webm";
3138
+ case "mov": return "video/quicktime";
3139
+ case "mp3": return "audio/mpeg";
3140
+ case "wav": return "audio/wav";
3141
+ case "m4a": return "audio/mp4";
3142
+ default: return "application/octet-stream";
3143
+ }
3144
+ }
3145
+ // A fork (composition-editing project) as a top-level /projects folder. The
3146
+ // folder path segment is the STABLE fork id (so paths round-trip); the display
3147
+ // name prefers a human title (the fork's own, else its template's) so the tile
3148
+ // is recognizable.
3149
+ function projectForkToFolderItem(fork, displayName) {
3150
+ const versions = fork.latestVersion ?? 0;
3151
+ return {
3152
+ path: buildDirectoryPath("projects", fork.id),
3153
+ root: "projects",
3154
+ folderPath: fork.id,
3155
+ name: displayName,
3156
+ kind: "folder",
3157
+ meta: {
3158
+ description: `video project · ${versions ? `v${versions}` : "unsaved"}`,
3159
+ forkId: fork.id,
3160
+ templateId: fork.templateId,
3161
+ updated_at: fork.updatedAt,
3162
+ latest_version: versions
3163
+ }
3164
+ };
3165
+ }
3166
+ // One object under a fork's storage root as a canonical directory file. `name`
3167
+ // is the trailing filename; `key` is the physical S3 key — it lives under the
3168
+ // public `compositions/forks/` prefix, so getPublicUrl yields a directly
3169
+ // fetchable view URL (and the composition.html/json are also readable through
3170
+ // the existing GET /api/v1/compositions/:forkId/* routes).
3171
+ function forkStorageFileToDirectoryItem(folderPath, name, key) {
3172
+ return {
3173
+ path: buildDirectoryPath("projects", folderPath, name),
3174
+ root: "projects",
3175
+ folderPath,
3176
+ name,
3177
+ kind: "file",
3178
+ id: key,
3179
+ contentType: inferProjectFileContentType(name),
3180
+ viewUrl: storage.getPublicUrl(key) || undefined,
3181
+ meta: { storageKey: key }
3182
+ };
3183
+ }
3184
+ // List one folder of the /projects root. At depth 0 (folderPath "") every fork
3185
+ // the customer owns is a folder; deeper, the fork's S3 storage tree (working/ +
3186
+ // versions/…) is presented as a generic prefix explorer, so any composition file
3187
+ // is a real, copyable, agent-addressable path like
3188
+ // /projects/<forkId>/working/composition.html. Read-only.
3189
+ async function listProjectsFolder(customerId, cur) {
3190
+ const segments = cur ? cur.split("/") : [];
3191
+ if (segments.length === 0) {
3192
+ const forks = (await serverlessRecords.listCompositionForksForCustomer(customerId))
3193
+ .filter((f) => !f.deletedAt);
3194
+ forks.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
3195
+ // One template lookup, mapped, to give forks recognizable names.
3196
+ const templates = await serverlessRecords.listTemplatesForCustomer(customerId).catch(() => []);
3197
+ const titleByTemplate = new Map();
3198
+ for (const t of templates) {
3199
+ if (t.title && t.title.trim())
3200
+ titleByTemplate.set(t.id, t.title.trim());
3201
+ }
3202
+ const folders = forks.map((fork) => {
3203
+ const name = (fork.title && fork.title.trim())
3204
+ || titleByTemplate.get(fork.templateId)
3205
+ || `Project ${fork.id.slice(-6)}`;
3206
+ return projectForkToFolderItem(fork, name);
3207
+ });
3208
+ return { folders, files: [] };
3209
+ }
3210
+ const forkId = segments[0];
3211
+ // Browse is scoped to the owner: only the customer's own forks are listable.
3212
+ const fork = await serverlessRecords.getCompositionFork(forkId);
3213
+ if (!fork || fork.customerId !== customerId || fork.deletedAt) {
3214
+ return { folders: [], files: [] };
3215
+ }
3216
+ const rootPrefix = storage.compositionForkRoot(forkId) + "/"; // compositions/forks/<id>/
3217
+ const keys = await storage.listKeys(rootPrefix);
3218
+ const subPath = segments.slice(1).join("/");
3219
+ const subPrefix = subPath ? subPath + "/" : "";
3220
+ const childFolders = new Set();
3221
+ const files = [];
3222
+ for (const key of keys) {
3223
+ if (key.indexOf(rootPrefix) !== 0)
3224
+ continue;
3225
+ const rel = key.slice(rootPrefix.length);
3226
+ if (!rel || (subPrefix && rel.indexOf(subPrefix) !== 0))
3227
+ continue;
3228
+ const remainder = rel.slice(subPrefix.length);
3229
+ if (!remainder)
3230
+ continue;
3231
+ const slash = remainder.indexOf("/");
3232
+ if (slash >= 0)
3233
+ childFolders.add(remainder.slice(0, slash));
3234
+ else
3235
+ files.push(forkStorageFileToDirectoryItem(cur, remainder, key));
3236
+ }
3237
+ const folders = Array.from(childFolders).sort().map((n) => folderItem("projects", cur, n));
3238
+ files.sort((a, b) => a.name.localeCompare(b.name));
3239
+ return { folders, files };
3240
+ }
3038
3241
  /**
3039
3242
  * List one folder of one directory root, returning immediate child folders + the
3040
3243
  * files directly in this folder as canonical DirectoryItems. The single place
3041
- * that dispatches a canonical path to its backend (attachments / temp / raws).
3244
+ * that dispatches a canonical path to its backend (attachments / temp / raws /
3245
+ * projects / approved).
3042
3246
  */
3043
3247
  async function listDirectoryFolder(c, customerId, root, folderPath) {
3044
3248
  const cur = normalizeFolderPath(folderPath);
@@ -3060,17 +3264,31 @@ async function listDirectoryFolder(c, customerId, root, folderPath) {
3060
3264
  .map((f) => temporaryFileToDirectoryItem(c, f));
3061
3265
  return { folders: childNames.map((n) => folderItem("temp", cur, n)), files };
3062
3266
  }
3063
- // raws
3064
- const clips = await clipRecords.listClips(customerId, { limit: 5000 });
3065
- const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
3066
- const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
3067
- const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
3068
- const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
3069
- return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
3267
+ if (root === "raws") {
3268
+ const clips = await clipRecords.listClips(customerId, { limit: 5000 });
3269
+ const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
3270
+ const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
3271
+ const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
3272
+ const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
3273
+ return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
3274
+ }
3275
+ if (root === "projects") {
3276
+ return listProjectsFolder(customerId, cur);
3277
+ }
3278
+ // approved — finished ready-to-post cuts, foldered by folder_path.
3279
+ const posts = (await serverlessRecords.listReadyPosts(customerId)).filter((p) => p.status !== "deleted");
3280
+ const explicitApprovedFolders = await serverlessRecords.listUserFileFolders(customerId, "approved");
3281
+ const childNames = immediateChildFolders([...explicitApprovedFolders, ...posts.map((p) => p.folderPath ?? "")], cur);
3282
+ const here = posts.filter((p) => normalizeFolderPath(p.folderPath ?? "") === cur);
3283
+ const files = here.map(readyPostToDirectoryItem);
3284
+ return { folders: childNames.map((n) => folderItem("approved", cur, n)), files };
3070
3285
  }
3071
3286
  // The user_file_folder scope backing each directory root's empty folders.
3072
3287
  function directoryFolderScope(root) {
3073
- return root === "files" ? ATTACHMENTS_FOLDER_SCOPE : root === "temp" ? "temporary" : "raws";
3288
+ return root === "files" ? ATTACHMENTS_FOLDER_SCOPE
3289
+ : root === "temp" ? "temporary"
3290
+ : root === "approved" ? "approved"
3291
+ : "raws";
3074
3292
  }
3075
3293
  // The three roots presented as folders at the virtual root ("/").
3076
3294
  function directoryRootFolders() {
@@ -3259,7 +3477,9 @@ function buildPrimitiveEditorDocsRoutes() {
3259
3477
  { 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? }." },
3260
3478
  { 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? }." },
3261
3479
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/render-html`, summary: "Primitive HTML still-image render job. Body must be { tracer, payload: { html, css?, width?, height?, background_color?, output_format? }, webhook_url? }." },
3262
- { method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background`, summary: "Primitive background-removal job. Returns a transparent-background PNG cut-out of the subject (subject isolation for overlays, product cut-outs, sprite cards, caption matting). Body must be { tracer, payload: { source_image_url }, webhook_url? }. BILLS THE WALLET (RapidAPI) — unlike the BYOK images/generate and images/edit routes." },
3480
+ { 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." },
3481
+ { 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." },
3482
+ { 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." },
3263
3483
  { 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." },
3264
3484
  { 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." },
3265
3485
  { 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? }." },
@@ -3987,10 +4207,18 @@ function createVideoContextTool(input) {
3987
4207
  return tool({
3988
4208
  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.",
3989
4209
  inputSchema: z.object({
3990
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4210
+ 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."),
3991
4211
  explanation: z.string().optional().describe("Short reason for fetching the video context.")
3992
4212
  }),
3993
4213
  execute: async ({ fork_id, explanation }) => {
4214
+ if (fork_id.startsWith("template_")) {
4215
+ return {
4216
+ explanation: explanation ?? null,
4217
+ status: 0,
4218
+ video_context: null,
4219
+ 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.`
4220
+ };
4221
+ }
3994
4222
  const url = normalizeEditorChatPath({
3995
4223
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/video-context.json`,
3996
4224
  template: input.template
@@ -4039,10 +4267,18 @@ function createProductPlacementContextTool(input) {
4039
4267
  return tool({
4040
4268
  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.",
4041
4269
  inputSchema: z.object({
4042
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4270
+ 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."),
4043
4271
  explanation: z.string().optional().describe("Short reason for fetching the placement surfaces.")
4044
4272
  }),
4045
4273
  execute: async ({ fork_id, explanation }) => {
4274
+ if (fork_id.startsWith("template_")) {
4275
+ return {
4276
+ explanation: explanation ?? null,
4277
+ status: 0,
4278
+ product_placement: null,
4279
+ 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.`
4280
+ };
4281
+ }
4046
4282
  const url = normalizeEditorChatPath({
4047
4283
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/product-placement.json`,
4048
4284
  template: input.template
@@ -4136,10 +4372,11 @@ function browseFilesTextContentType(fileName) {
4136
4372
  }
4137
4373
  function createBrowseFilesTool(input) {
4138
4374
  return tool({
4139
- description: "Browse AND write the user's file directory — a navigable tree with THREE roots: /files (durable My Files: uploaded assets like mp4, mov, png, jpg, mp3, pdf, md, txt, csv, plus text context you save — vector-searchable via notes), /temp (scratch space auto-foldered by date YYYY-MM-DD, auto-deleted after 30 days), and /raws (the reusable clip/source-footage library, foldered by source). Every folder can have nested subfolders and every file has a canonical copyable path like /raws/product-demos/clip.mp4.\n\nNAVIGATE BY PATH: pass `path` to list or search any location — path='/' lists the three roots; path='/raws' or '/files/brand-assets' lists that folder's subfolders + files. Use action=list to enumerate a folder (folders[] + files[], each a canonical item with path, name, kind, view_url). (Legacy: action=list with folder_path and no path still lists /files only.)\n\nSEARCH: action=search with `query` (plain language) and optional `path` scope finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (control with `mode`: auto|semantic|substring|path). Prefer search when the user references an asset vaguely or you don't know which root/folder it's in; scope with path='/raws' to search only footage, path='/files' for durable assets, or path='/' for everything.\n\nMOVE: action=move with `file_id` (a raw's id) and `path` (target folder, e.g. /raws/demos) reorganizes a raw between /raws folders. Use action=search to find files by MEANING when you don't know where something lives: pass query (plain language, e.g. 'character reference sheet for the mascot' or 'logo on transparent background') and it matches keywords AND vector-embedded metadata notes across every file's name, folder, and notes — prefer search over paging through list when My Files is large or the user references an asset vaguely. Use action=read to fetch one file by file_id (preferred; copy it from a prior list/search result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text), optional folder_path to namescope it under a product/offer folder, and optional notes. This is how you persist Getting Started context the user can reuse later: a product About.md or Interview.md, and awareness-levels.md / persuasive-angles.md / ad-hooks.md distilled from brainstorm output. write saves text files (md, txt, csv, json, srt, vtt) via content, or IMPORTS any media asset into the library by passing its durable URL as source_url — e.g. save a finished image job's output as character_sprite_card.png so it persists in My Files instead of living only in a job result. write returns the new file_id and view_url. Use action=annotate to set metadata notes on ANY existing file (including binary assets): notes say what the file is, who/what it depicts, and when to use it, and they are vector-embedded so future sessions can search their way back to the asset. Annotate every asset worth finding again — especially character references: keep a character_sprite_card.png plus an about_character.md per recurring character in that character's folder, annotate both, and pass the sprite card's view_url into generation reference inputs (images prompt_attachments / videos input_references) for character consistency. Always list or search before you read so you use a real file_id; never invent file ids or view URLs.",
4375
+ description: "Browse AND write the user's file directory — a 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.",
4140
4376
  inputSchema: z.object({
4141
- action: z.enum(["list", "read", "write", "annotate", "search", "move"]).describe("list = enumerate a folder's subfolders + files (pass path for any root); read = fetch one file's view_url and, for text files, its contents; write = save a text file into /files; annotate = set the metadata notes on one existing /files entry; search = find files by meaning/name/path across roots; move = move a raw between /raws folders."),
4142
- path: z.string().optional().describe("Canonical directory path to list or search: '/' for the three roots, '/raws' or '/files/brand-assets' for a folder. For move, the target folder (e.g. /raws/demos). Preferred over folder_path for cross-root navigation."),
4377
+ 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."),
4378
+ 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."),
4379
+ 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)."),
4143
4380
  mode: z.enum(["auto", "semantic", "substring", "path"]).optional().describe("For search: which signals to combine. auto (default) = semantic vector + substring + absolute-path; or force one."),
4144
4381
  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."),
4145
4382
  file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
@@ -4153,7 +4390,7 @@ function createBrowseFilesTool(input) {
4153
4390
  limit: z.number().int().optional().describe("For list/search: max items to return (list default 200, max 500; search default 30, max 50)."),
4154
4391
  explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
4155
4392
  }),
4156
- execute: async ({ action, path, mode, folder_path, file_id, file_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4393
+ execute: async ({ action, path, mode, folder_path, file_id, file_name, new_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4157
4394
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
4158
4395
  const headers = new Headers();
4159
4396
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -4208,10 +4445,12 @@ function createBrowseFilesTool(input) {
4208
4445
  const seg = canonicalPath.split("/").map((s) => s.trim()).filter(Boolean);
4209
4446
  const root = seg[0];
4210
4447
  if (!id || !root)
4211
- return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos." };
4212
- if (root !== "raws")
4213
- return { action, explanation: explanation ?? null, status: 400, error: "move is currently supported only within /raws." };
4214
- const url = new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
4448
+ return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos or /approved/summer-launch." };
4449
+ if (root !== "raws" && root !== "approved")
4450
+ return { action, explanation: explanation ?? null, status: 400, error: "move is supported within /raws (clips) and /approved (posts)." };
4451
+ const url = root === "approved"
4452
+ ? new URL(`${APPROVED_POSTS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL)
4453
+ : new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
4215
4454
  const moveHeaders = new Headers(headers);
4216
4455
  moveHeaders.set("content-type", "application/json");
4217
4456
  const r = await fetch(url, {
@@ -4223,6 +4462,24 @@ function createBrowseFilesTool(input) {
4223
4462
  const body = await r.json().catch(() => null);
4224
4463
  return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4225
4464
  }
4465
+ if (action === "rename") {
4466
+ const target = (path ?? "").trim();
4467
+ const nextName = (new_name ?? "").trim();
4468
+ if (!target || !nextName) {
4469
+ 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." };
4470
+ }
4471
+ const url = new URL(`${USER_PREFIX}/me/directory/rename`, config.PUBLIC_BASE_URL);
4472
+ const renameHeaders = new Headers(headers);
4473
+ renameHeaders.set("content-type", "application/json");
4474
+ const r = await fetch(url, {
4475
+ method: "POST",
4476
+ headers: renameHeaders,
4477
+ body: JSON.stringify({ path: target, new_name: nextName, ...((file_id ?? "").trim() ? { file_id: (file_id ?? "").trim() } : {}) }),
4478
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4479
+ });
4480
+ const body = await r.json().catch(() => null);
4481
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4482
+ }
4226
4483
  if (action === "write") {
4227
4484
  const name = (file_name ?? "").trim();
4228
4485
  if (!name) {
@@ -4584,7 +4841,7 @@ function createVideoCreationTool() {
4584
4841
  }
4585
4842
  function createEditorActionTool() {
4586
4843
  return tool({
4587
- 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.",
4844
+ 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.",
4588
4845
  inputSchema: z.object({
4589
4846
  action_type: z.enum([
4590
4847
  "add_layer",
@@ -4608,9 +4865,12 @@ function createEditorActionTool() {
4608
4865
  "nudge_layers",
4609
4866
  "ripple_edit",
4610
4867
  "set_layer_zindex",
4611
- "trim_layer"
4612
- ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit. FINE TIMELINE CONTROL: set_layer_keyframes authors a custom script-free CSS @keyframes animation on ONE layer (opacity/translate/scale/rotate over the clip) that previews AND renders; nudge_layers moves one or more layers by a relative delta_start/delta_track (group-aware); ripple_edit inserts or closes time at at_time and shifts all downstream clips; trim_layer moves one edge (start|end) of a clip to a time, coupling media in-point on a left trim; set_layer_zindex restacks a layer (front|back|forward|backward) — stacking is the track index."),
4868
+ "trim_layer",
4869
+ "set_composition",
4870
+ "fork_composition"
4871
+ ]).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)."),
4613
4872
  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."),
4873
+ 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'."),
4614
4874
  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."),
4615
4875
  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."),
4616
4876
  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."),
@@ -4635,11 +4895,18 @@ function createEditorActionTool() {
4635
4895
  underline: z.boolean().optional().describe("Toggle underline on text layers."),
4636
4896
  text_align: z.string().optional().describe("CSS text-align value (left, center, right, justify)."),
4637
4897
  border_radius: z.number().optional().describe("Border radius in pixels."),
4898
+ 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."),
4899
+ 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."),
4900
+ 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."),
4901
+ 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."),
4902
+ 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."),
4903
+ 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."),
4904
+ 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."),
4638
4905
  color: z.string().optional().describe("CSS color for text foreground."),
4639
4906
  background: z.string().optional().describe("CSS color for background."),
4640
4907
  background_style: z.enum(["plain", "outline", "highlight-solid", "highlight-translucent"]).optional().describe("Text background treatment."),
4641
- object_fit: z.string().optional().describe("CSS object-fit for image/video (cover, contain, fill, none, scale-down)."),
4642
- object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
4908
+ 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."),
4909
+ 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."),
4643
4910
  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."),
4644
4911
  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."),
4645
4912
  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."),
@@ -4757,9 +5024,16 @@ async function streamEditorChatAgent(input, send) {
4757
5024
  tracerRegistry
4758
5025
  }),
4759
5026
  editor_action: createEditorActionTool(),
4760
- // Discover/brainstorm surface only: "create me a video of…" / "replicate
4761
- // this URL…". The editor surfaces edit an already-open composition.
4762
- ...(input.template.templateId === "chat-brainstorm"
5027
+ // create_video is for making a BRAND-NEW video. It belongs on the
5028
+ // discover/brainstorm surface ("create me a video of…" / "replicate this
5029
+ // URL…") AND on a BLANK editor project (templateId "original", the
5030
+ // /editor/original/fork/new sentinel): a blank project has no template to
5031
+ // fork yet, so editor_action edits can't persist — the model must mint a
5032
+ // real composition first, and the dock already owns runCreateVideoFlow to
5033
+ // run the ingest→decompose→fork pipeline and drop an "Open in editor" link.
5034
+ // Real template_… compositions stay edit-only (editor_action) so the model
5035
+ // never spawns a new template when the user just wanted to edit this one.
5036
+ ...(input.template.templateId === "chat-brainstorm" || input.template.templateId === "original"
4763
5037
  ? { create_video: createVideoCreationTool() }
4764
5038
  : {})
4765
5039
  };
@@ -5165,6 +5439,11 @@ async function renderHyperframesStudioEditorPage(input) {
5165
5439
  ${renderChatDock("editor")}
5166
5440
  <script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
5167
5441
  <script>${RESKIN_CHROME_SCRIPT}</script>
5442
+ <!-- Files drawer of the editor dock = the reusable directory-explorer component,
5443
+ auto-mounts [data-rk-directory] (#rkAichatFilesMount). reskinDocument ships
5444
+ this bundle on full-chrome pages, but the editor builds its own document, so
5445
+ it must be loaded here too or the "Your files" panel mounts empty. -->
5446
+ <script type="module" src="/assets/file-directory-app.js"></script>
5168
5447
  <script type="module" src="/assets/editor/app.js"></script>
5169
5448
  </body>
5170
5449
  </html>`;
@@ -5311,7 +5590,14 @@ const reskinLibraryHandler = async (c, tab) => {
5311
5590
  email: customer.email,
5312
5591
  posts: [],
5313
5592
  schedule: { channels: [], connectHref: "/settings/channels?connect=flockposter" },
5314
- editorChat: null
5593
+ editorChat: null,
5594
+ // On a `vidfarm serve` box with a cloud upstream, offer the Local ⇄ Cloud
5595
+ // space switch in the explorer (reads /me/cloud/directory passthrough).
5596
+ cloudSpace: isUpstreamEnabled(),
5597
+ // ?path= deep-links the explorer into a folder (e.g. ?path=/approved/swipes).
5598
+ // Safe raw: the client's parseDirectoryPath only honors the known roots and
5599
+ // drops anything else back to the virtual root "/".
5600
+ initialFilesPath: (c.req.query("path") ?? "").trim() || undefined
5315
5601
  }));
5316
5602
  }
5317
5603
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
@@ -5323,7 +5609,12 @@ const reskinLibraryHandler = async (c, tab) => {
5323
5609
  const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
5324
5610
  .filter((post) => (post.source ?? "legacy") === "hyperframes")
5325
5611
  .filter((post) => post.status === "ready" || post.status === "scheduled")
5326
- .map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
5612
+ .map(async (post) => {
5613
+ // Backfill the rendered MP4 for swipe-approved posts whose detached
5614
+ // finalize poll died on Lambda before it could attach the media.
5615
+ const reconciled = await reconcileReadyPostMedia(customer.id, post);
5616
+ return { ...serializeReadyPost(c, reconciled, { includePrivate: true }), template_id: reconciled.compositionId ?? null };
5617
+ }));
5327
5618
  const posts = await mergeUpstreamReadyPosts(localPosts);
5328
5619
  return c.html(renderReskinLibrary(tab, {
5329
5620
  userId: customer.id,
@@ -5466,9 +5757,17 @@ app.get("/chat-dock/boot", async (c) => {
5466
5757
  return c.json({ editorChat: null });
5467
5758
  return c.json({ editorChat: await buildChatBrainstormEditorChatBoot({ customer }) });
5468
5759
  });
5469
- // /reskin/inpaint the "Create Image" masked-image editor (reached from the
5470
- // chat page's studio-mode select). Auth-gated like the other tools.
5471
- app.get("/inpaint", async (c) => {
5760
+ // The creative tools are three twins under /tools: /tools/image (masked image
5761
+ // edits), /tools/video (start/end-frame + time-scoped video edits), and
5762
+ // /tools/clipper (subrange-clip a source video into the raws library). The bare
5763
+ // /tools redirects to the image editor, preserving any ?source_image_url /
5764
+ // ?tracer preload params. The legacy /inpaint/* paths 302 here for back-compat
5765
+ // so existing deep-links (and editor "Edit Media") keep working.
5766
+ app.get("/tools", (c) => {
5767
+ const search = new URL(c.req.url).search;
5768
+ return redirect(c, "/tools/image" + search);
5769
+ });
5770
+ app.get("/tools/image", async (c) => {
5472
5771
  const customer = await getOptionalPreferredBrowserCustomer(c);
5473
5772
  if (!customer) {
5474
5773
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5476,6 +5775,27 @@ app.get("/inpaint", async (c) => {
5476
5775
  }
5477
5776
  return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
5478
5777
  });
5778
+ app.get("/tools/video", async (c) => {
5779
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5780
+ if (!customer) {
5781
+ setLoginReturnPath(c, currentPathWithSearch(c));
5782
+ return redirect(c, "/login");
5783
+ }
5784
+ return c.html(renderReskinInpaintVideo({ accountId: customer.id, isLoggedIn: true }));
5785
+ });
5786
+ app.get("/tools/clipper", async (c) => {
5787
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5788
+ if (!customer) {
5789
+ setLoginReturnPath(c, currentPathWithSearch(c));
5790
+ return redirect(c, "/login");
5791
+ }
5792
+ return c.html(renderReskinInpaintClipper({ accountId: customer.id, isLoggedIn: true }));
5793
+ });
5794
+ // Legacy /inpaint/* → /tools/* back-compat redirects (preserve query string).
5795
+ app.get("/inpaint", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5796
+ app.get("/inpaint/image", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5797
+ app.get("/inpaint/video", (c) => redirect(c, "/tools/video" + new URL(c.req.url).search));
5798
+ app.get("/inpaint/clipper", (c) => redirect(c, "/tools/clipper" + new URL(c.req.url).search));
5479
5799
  app.get("/calendar", async (c) => {
5480
5800
  const customer = await requireBrowserCustomer(c);
5481
5801
  if (!customer) {
@@ -5519,6 +5839,19 @@ app.get("/pricing", async (c) => {
5519
5839
  const customer = await getOptionalPreferredBrowserCustomer(c);
5520
5840
  return c.html(renderReskinPricing({ email: customer?.email ?? "" }));
5521
5841
  });
5842
+ // /portfolio/:slug — public creator/agency showcase (hardcoded data, no auth).
5843
+ app.get("/portfolio", (c) => redirect(c, `/portfolio/${DEFAULT_PORTFOLIO_SLUG}`));
5844
+ app.get("/portfolio/:slug", async (c) => {
5845
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5846
+ const html = renderReskinPortfolio(c.req.param("slug"), {
5847
+ isLoggedIn: Boolean(customer),
5848
+ name: customer?.name ?? null,
5849
+ email: customer?.email ?? null
5850
+ });
5851
+ if (!html)
5852
+ return c.notFound();
5853
+ return c.html(html);
5854
+ });
5522
5855
  app.get("/login", (c) => c.html(renderReskinLogin({ mode: getLoginMode(c.req.query("mode")), routePrefix: "/login" })));
5523
5856
  app.post("/login/password", async (c) => {
5524
5857
  const mode = getLoginMode(c.req.query("mode"));
@@ -5604,7 +5937,7 @@ app.get("/reskin/discover/:mode", (c) => {
5604
5937
  return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
5605
5938
  });
5606
5939
  app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5607
- app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
5940
+ app.get("/reskin/inpaint", (c) => redirect(c, "/tools/image"));
5608
5941
  app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
5609
5942
  app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
5610
5943
  app.get("/reskin/agency", (c) => redirect(c, "/agency"));
@@ -5612,12 +5945,18 @@ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5612
5945
  app.get("/reskin/help", (c) => redirect(c, "/help"));
5613
5946
  app.get("/reskin/login", (c) => redirect(c, "/login"));
5614
5947
  app.get("/discover", async (c) => renderApprovedHomepage(c));
5948
+ // The sentinel composition/template id for a brand-new blank project. It never
5949
+ // collides with a real template (those are `template_<uuid>`). Pre-fork it is
5950
+ // served the canonical blank scaffold (buildBlankCompositionHtml); on first edit
5951
+ // the SPA POSTs it as the fork `source`, minting a per-user fork seeded from
5952
+ // that same scaffold — so "every new blank is a fork of the blank composition".
5953
+ const BLANK_COMPOSITION_ID = "original";
5615
5954
  // Blank / new-project editor. The "New project" button and the empty-library
5616
- // CTAs point here. There is no template or fork yet: we boot the SPA into an
5617
- // empty composition, and the client mints a real template_* + fork_* on the
5618
- // first save/generate/decompose, then history.replaceState's the URL to the
5619
- // canonical /editor/:templateId/fork/:forkId form.
5620
- async function renderBlankEditorPage(c) {
5955
+ // CTAs point here. There is no template or fork yet: we boot the SPA into the
5956
+ // canonical blank composition (3 placeholder scenes), and the client mints a
5957
+ // fork_* seeded from it on the first save/generate/decompose, then
5958
+ // history.replaceState's the URL to the /editor/original/fork/:forkId form.
5959
+ async function renderBlankEditorPage(c, titleOverride) {
5621
5960
  const customer = await getOptionalPreferredBrowserCustomer(c);
5622
5961
  if (!customer) {
5623
5962
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5625,7 +5964,7 @@ async function renderBlankEditorPage(c) {
5625
5964
  }
5626
5965
  const threadParam = c.req.query("thread");
5627
5966
  const blankComposition = {
5628
- title: "Untitled project",
5967
+ title: titleOverride?.trim() || "Untitled project",
5629
5968
  templateId: "original",
5630
5969
  slugId: "original",
5631
5970
  difficulty: "easy",
@@ -5643,6 +5982,26 @@ async function renderBlankEditorPage(c) {
5643
5982
  };
5644
5983
  return c.html(await renderHyperframesStudioEditorPage({ composition: blankComposition, customer }));
5645
5984
  }
5985
+ // Reload / direct-load of a blank project's own fork URL (/editor/original/fork/
5986
+ // <forkId>) after the SPA minted it. There is no template to resolve — we just
5987
+ // confirm the caller owns this blank fork, then render the same blank editor
5988
+ // shell (the SPA reads the forkId from the URL and the player loads the fork's
5989
+ // stored composition via ?fork=). Anything stale/foreign falls back to a fresh
5990
+ // blank so the user never lands on a 404.
5991
+ async function renderBlankForkEditorPage(c, forkParam) {
5992
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5993
+ if (!customer) {
5994
+ setLoginReturnPath(c, currentPathWithSearch(c));
5995
+ return redirect(c, "/login");
5996
+ }
5997
+ if (forkParam) {
5998
+ const fork = await serverlessRecords.getCompositionFork(forkParam);
5999
+ if (fork && !fork.deletedAt && fork.customerId === customer.id && fork.templateId === BLANK_COMPOSITION_ID) {
6000
+ return renderBlankEditorPage(c, fork.title ?? undefined);
6001
+ }
6002
+ }
6003
+ return redirect(c, "/editor/original/fork/new");
6004
+ }
5646
6005
  // Shared handler for both the bare /editor/:templateId (resolver) route and the
5647
6006
  // canonical /editor/:templateId/fork/:forkId (path-form) route. `forkParam` is
5648
6007
  // the fork id from the URL path (path route) or null (bare route). We validate
@@ -5650,6 +6009,10 @@ async function renderBlankEditorPage(c) {
5650
6009
  // redirect to the canonical path form.
5651
6010
  async function renderEditorPage(c, templateId, forkParam) {
5652
6011
  const customer = await getOptionalPreferredBrowserCustomer(c);
6012
+ // Blank project forks carry the sentinel templateId, not a real template_*.
6013
+ // Route them to the blank-fork renderer instead of 404'ing.
6014
+ if (templateId === BLANK_COMPOSITION_ID)
6015
+ return renderBlankForkEditorPage(c, forkParam);
5653
6016
  if (!templateId.startsWith("template_"))
5654
6017
  return c.notFound();
5655
6018
  let template = await serverlessRecords.getTemplate(templateId);
@@ -5797,6 +6160,13 @@ app.get("/editor/:templateId", async (c) => {
5797
6160
  });
5798
6161
  app.get("/editor/:templateId/composition", async (c) => {
5799
6162
  const templateId = c.req.param("templateId");
6163
+ // Blank new project: the SPA fetches this to seed its client-side timeline
6164
+ // state. Serve the same canonical blank scaffold the player iframe gets (raw,
6165
+ // no runtime — the studio only parses the clips out of it) so client state and
6166
+ // the rendered player agree on element ids.
6167
+ if (templateId === BLANK_COMPOSITION_ID) {
6168
+ return c.html(buildBlankCompositionHtml(templateId), 200, { "cache-control": "no-store" });
6169
+ }
5800
6170
  if (!templateId.startsWith("template_"))
5801
6171
  return c.notFound();
5802
6172
  const template = await serverlessRecords.getTemplate(templateId);
@@ -5823,7 +6193,13 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute
5823
6193
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat/:id`, (c) => dispatchScopedGetToLegacyRoute(c, `/chat/${c.req.param("id")}`));
5824
6194
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
5825
6195
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
5826
- app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
6196
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6197
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6198
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
6199
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/clipper`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/clipper"));
6200
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6201
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6202
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
5827
6203
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar"));
5828
6204
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings"));
5829
6205
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs"));
@@ -6138,6 +6514,12 @@ app.post("/discover/swipe/:id/approve", async (c) => {
6138
6514
  status: "ready",
6139
6515
  compositionId: fork.id,
6140
6516
  tracer: `swipe:${record.id}`,
6517
+ // All approved swipe cuts collect into ONE canonical /approved/swipes folder.
6518
+ // Without this explicit path the tracer-slug default would mint a unique
6519
+ // per-swipe folder (swipe-swipe-<id>) — 1 post each — which is the clutter
6520
+ // seen on /library/approved. The tracer still carries the swipe id for
6521
+ // job-run correlation.
6522
+ folderPath: "swipes",
6141
6523
  title: record.tailoredTitle,
6142
6524
  caption: record.tailoredCaption,
6143
6525
  pinnedComment: record.tailoredPinnedComment,
@@ -6246,6 +6628,50 @@ async function finalizeSwipeApproveRender(input) {
6246
6628
  }
6247
6629
  }
6248
6630
  }
6631
+ // Self-heal a ready post that is still missing its rendered media. Swipe-approve
6632
+ // mints the post with EMPTY media and relies on the fire-and-forget
6633
+ // finalizeSwipeApproveRender poll to backfill the finished MP4 — but that
6634
+ // detached poll does NOT survive a serverless/Lambda response, so on prod the
6635
+ // render completes (the Step Functions worker is durable) yet the MP4 is never
6636
+ // attached, and the library shows "No preview" forever. Reconciling on read
6637
+ // closes that gap: for any ready hyperframes post with no media, resolve its
6638
+ // fork's render job and attach the completed MP4 when the render has finished.
6639
+ // No-ops (short-circuits) once media exists, so a post is reconciled at most
6640
+ // once. Best-effort: any failure leaves the post untouched.
6641
+ async function reconcileReadyPostMedia(customerId, post) {
6642
+ if (post.mediaAssets.length > 0)
6643
+ return post;
6644
+ if ((post.source ?? "legacy") !== "hyperframes" || !post.compositionId)
6645
+ return post;
6646
+ try {
6647
+ const fork = await serverlessRecords.getCompositionFork(post.compositionId);
6648
+ const renderJobId = fork?.currentRenderJobId;
6649
+ if (!renderJobId)
6650
+ return post;
6651
+ const job = await jobs.getJobAsync(renderJobId);
6652
+ const status = String(asRecord(job)?.status ?? "").toLowerCase();
6653
+ if (status !== "succeeded" && status !== "completed")
6654
+ return post;
6655
+ const url = extractOutputUrlFromJob(job);
6656
+ if (!url)
6657
+ return post;
6658
+ const slug = (post.title || "swipe-ad").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 40) || "swipe-ad";
6659
+ const asset = {
6660
+ id: `render-${renderJobId}`,
6661
+ url,
6662
+ fileName: `${slug}.mp4`,
6663
+ contentType: "video/mp4",
6664
+ kind: "video",
6665
+ role: "primary"
6666
+ };
6667
+ const updated = await serverlessRecords.setReadyPostMedia({ customerId, postId: post.id, mediaAssets: [asset] });
6668
+ return updated ?? { ...post, mediaAssets: [asset] };
6669
+ }
6670
+ catch (error) {
6671
+ console.error("ready-post media reconcile failed", post.id, error instanceof Error ? error.message : error);
6672
+ return post;
6673
+ }
6674
+ }
6249
6675
  function buildTemplateHomepageEntry(template) {
6250
6676
  // Undecomposed templates (no defaultForkId yet) still appear on /discover so
6251
6677
  // users can open them and mint the first fork via the Decompose modal.
@@ -6763,6 +7189,23 @@ app.delete(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
6763
7189
  const deleted = await serverlessRecords.deleteReadyPost(customer.id, post.id);
6764
7190
  return c.json({ post: deleted ? serializeReadyPost(c, deleted, { includePrivate: true }) : null });
6765
7191
  });
7192
+ // Move an approved post between /approved folders ({ folder_path }, ""=root).
7193
+ // Mirrors PATCH /raws/:clipId — the /library/approved explorer uses it to
7194
+ // reorganize posts and to materialize a folder when renaming a legacy group.
7195
+ app.patch(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
7196
+ const customer = await requireBrowserCustomer(c);
7197
+ if (!customer) {
7198
+ return c.json({ error: "Unauthorized" }, 401);
7199
+ }
7200
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
7201
+ if (!post || post.status === "deleted" || post.customerId !== customer.id) {
7202
+ return c.json({ error: "Approved post not found." }, 404);
7203
+ }
7204
+ const body = (await c.req.json().catch(() => ({})));
7205
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
7206
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
7207
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
7208
+ });
6766
7209
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId/schedules`, async (c) => {
6767
7210
  const customer = await requireBrowserCustomer(c);
6768
7211
  if (!customer) {
@@ -7251,6 +7694,14 @@ app.get("/composition/current.html", async (c) => {
7251
7694
  const compositionId = (c.req.query("composition") ?? "").trim();
7252
7695
  if (!compositionId)
7253
7696
  return c.text("Missing composition", 400);
7697
+ // Blank new project (the /editor/original/fork/new sentinel, before its first
7698
+ // edit mints a fork). Serve the canonical blank scaffold — 3 placeholder image
7699
+ // scenes + "Scene N" captions — so the player paints an editable 12s timeline
7700
+ // instead of "Composition not found". Runtime is injected by the rewrite.
7701
+ if (compositionId === BLANK_COMPOSITION_ID) {
7702
+ const rewritten = rewriteHyperframesDraftCompositionHtml(buildBlankCompositionHtml(compositionId), "blank");
7703
+ return c.html(rewritten, 200, { "cache-control": "no-store" });
7704
+ }
7254
7705
  if (compositionId.startsWith("template_")) {
7255
7706
  const template = await serverlessRecords.getTemplate(compositionId);
7256
7707
  if (!template || !template.videoUrl)
@@ -7643,6 +8094,9 @@ async function snapshotForkVersion(input) {
7643
8094
  // Scene annotations (clip-replacement + recreation DNA) likewise travel with
7644
8095
  // every version — reusable decompose metadata, same as placement surfaces.
7645
8096
  const workingAnnotations = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "scene-annotations.json"));
8097
+ // Editor harness (technical editing direction) travels with every version too
8098
+ // — reusable decompose style metadata, same as placement / annotations.
8099
+ const workingHarness = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "editor-harness.json"));
7646
8100
  if (workingHtml) {
7647
8101
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
7648
8102
  }
@@ -7658,6 +8112,9 @@ async function snapshotForkVersion(input) {
7658
8112
  if (workingAnnotations) {
7659
8113
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
7660
8114
  }
8115
+ if (workingHarness) {
8116
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "editor-harness.json"), Buffer.from(workingHarness, "utf8"), "application/json");
8117
+ }
7661
8118
  const versionRecord = await serverlessRecords.createForkVersion({
7662
8119
  forkId: input.fork.id,
7663
8120
  ownerCustomerId: input.fork.customerId,
@@ -7712,58 +8169,71 @@ function serializeFork(fork, caps) {
7712
8169
  : null
7713
8170
  };
7714
8171
  }
7715
- app.post(COMPOSITIONS_PREFIX, async (c) => {
7716
- const customer = await requireBrowserCustomer(c);
7717
- if (!customer)
7718
- return c.json({ ok: false, error: "Unauthorized" }, 401);
7719
- const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
7720
- const templateId = readNonEmptyString(body.template_id) ?? readNonEmptyString(body.source);
7721
- if (!templateId || !templateId.startsWith("template_")) {
7722
- return c.json({ ok: false, error: "Missing or invalid template_id" }, 400);
8172
+ // ── Shared fork-creation helpers ────────────────────────────────────────────
8173
+ // A "new fork" always has one of two SOURCES, and every surface (the editor's
8174
+ // New-fork modal, the AI chat fork_composition action, and the devcli clone
8175
+ // command) funnels through these so the seed logic stays uniform:
8176
+ // • "default" fresh from the template's canonical default fork (start over
8177
+ // from the original template discard the current working edits).
8178
+ // • "current" branch from an existing working copy (clone this fork,
8179
+ // optionally pinned to a saved version).
8180
+ // Copy the canonical working files from a source fork (optionally a pinned
8181
+ // version) into a destination fork's working tree. Returns whether an HTML
8182
+ // body was seeded.
8183
+ async function seedForkWorkingFromFork(destForkId, sourceForkId, version) {
8184
+ const read = async (filename) => {
8185
+ if (version && Number.isFinite(version)) {
8186
+ const versioned = await storage.readText(storage.compositionForkVersionKey(sourceForkId, version, filename));
8187
+ if (versioned)
8188
+ return versioned;
8189
+ }
8190
+ return storage.readText(storage.compositionForkWorkingKey(sourceForkId, filename));
8191
+ };
8192
+ const html = await read("composition.html");
8193
+ const json = await read("composition.json");
8194
+ const cast = await read("cast.json");
8195
+ const placement = await read("product-placement.json");
8196
+ const annotations = await read("scene-annotations.json");
8197
+ const harness = await read("editor-harness.json");
8198
+ if (html) {
8199
+ await storage.putText(storage.compositionForkWorkingKey(destForkId, "composition.html"), html, "text/html; charset=utf-8");
7723
8200
  }
7724
- const template = await serverlessRecords.getTemplate(templateId);
7725
- if (!template)
7726
- return c.json({ ok: false, error: "Template not found" }, 404);
8201
+ if (json) {
8202
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "composition.json"), Buffer.from(json, "utf8"), "application/json");
8203
+ }
8204
+ if (cast) {
8205
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8206
+ }
8207
+ if (placement) {
8208
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
8209
+ }
8210
+ if (annotations) {
8211
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8212
+ }
8213
+ if (harness) {
8214
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "editor-harness.json"), Buffer.from(harness, "utf8"), "application/json");
8215
+ }
8216
+ return Boolean(html);
8217
+ }
8218
+ // Fork FRESH from a template's canonical default fork (from: "default"). Never
8219
+ // overwrites template.defaultForkId — that stays canonical so other users keep
8220
+ // getting it as their fallback. No version snapshot (matches a first fork).
8221
+ async function forkCompositionFromTemplateDefault(customerId, template, title) {
7727
8222
  const canonicalFork = template.defaultForkId
7728
8223
  ? await serverlessRecords.getCompositionFork(template.defaultForkId)
7729
8224
  : null;
7730
- const title = readNonEmptyString(body.title) ?? template.title ?? `Template ${template.id}`;
7731
- // Per-user fork: never overwrite template.defaultForkId. That stays as the
7732
- // canonical decomposition so other users continue to get it as their
7733
- // fallback.
7734
8225
  const fork = await serverlessRecords.createCompositionFork({
7735
- customerId: customer.id,
8226
+ customerId,
7736
8227
  templateId: template.id,
7737
8228
  parentForkId: canonicalFork?.id ?? null,
7738
8229
  parentVersion: canonicalFork?.latestVersion ?? null,
7739
8230
  visibility: "private",
7740
8231
  title
7741
8232
  });
7742
- // Seed new fork with the canonical composition html/json if available so
7743
- // it starts from the same base as everyone else.
7744
8233
  if (canonicalFork) {
7745
- const html = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "composition.html"));
7746
- const json = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "composition.json"));
7747
- // Seed the canonical cast so every user's fresh fork inherits the same
7748
- // identified people + reference stills as the shared default composition.
7749
- const cast = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "cast.json"));
7750
- const placement = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "product-placement.json"));
7751
- const annotations = await storage.readText(storage.compositionForkWorkingKey(canonicalFork.id, "scene-annotations.json"));
7752
- if (html) {
7753
- await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
7754
- }
7755
- if (json) {
7756
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "composition.json"), Buffer.from(json, "utf8"), "application/json");
7757
- }
7758
- if (cast) {
7759
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
7760
- }
7761
- if (placement) {
7762
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
7763
- }
7764
- if (annotations) {
7765
- await storage.putBuffer(storage.compositionForkWorkingKey(fork.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
7766
- }
8234
+ // Seed with the canonical composition (html/json/cast/placement/annotations)
8235
+ // so it starts from the same base as everyone else.
8236
+ await seedForkWorkingFromFork(fork.id, canonicalFork.id, null);
7767
8237
  }
7768
8238
  else if (template.videoUrl) {
7769
8239
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -7777,6 +8247,71 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
7777
8247
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rawHtml, "text/html; charset=utf-8");
7778
8248
  }
7779
8249
  await writeForkManifest(storage, fork, { kind: "working" });
8250
+ return fork;
8251
+ }
8252
+ // Fork from an EXISTING fork (from: "current"). Copies its working (or a pinned
8253
+ // version) files into a new private fork and snapshots v-next.
8254
+ async function forkCompositionFromExistingFork(customerId, sourceFork, parentVersion, title) {
8255
+ const cloned = await serverlessRecords.createCompositionFork({
8256
+ customerId,
8257
+ templateId: sourceFork.templateId,
8258
+ parentForkId: sourceFork.id,
8259
+ parentVersion: parentVersion ?? null,
8260
+ visibility: "private",
8261
+ title
8262
+ });
8263
+ await seedForkWorkingFromFork(cloned.id, sourceFork.id, parentVersion);
8264
+ await snapshotForkVersion({
8265
+ fork: cloned,
8266
+ callerCustomerId: customerId,
8267
+ reason: "clone",
8268
+ message: `Cloned from ${sourceFork.id}${parentVersion ? `@v${parentVersion}` : ""}`
8269
+ });
8270
+ return (await serverlessRecords.getCompositionFork(cloned.id));
8271
+ }
8272
+ // Mint a fresh per-user fork of the canonical blank composition. There is no
8273
+ // template record — the fork carries the sentinel templateId so it stays
8274
+ // isolated per customer — and its working copy is seeded with the same blank
8275
+ // scaffold the pre-fork editor shows, so the user's first edit lands on the 3
8276
+ // placeholder scenes and now persists. This is the "every new blank is a fork of
8277
+ // the blank composition" path (see BLANK_COMPOSITION_ID).
8278
+ async function forkBlankComposition(customerId, title) {
8279
+ const fork = await serverlessRecords.createCompositionFork({
8280
+ customerId,
8281
+ templateId: BLANK_COMPOSITION_ID,
8282
+ parentForkId: null,
8283
+ parentVersion: null,
8284
+ visibility: "private",
8285
+ title
8286
+ });
8287
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), buildBlankCompositionHtml(fork.id), "text/html; charset=utf-8");
8288
+ await writeForkManifest(storage, fork, { kind: "working" });
8289
+ return (await serverlessRecords.getCompositionFork(fork.id));
8290
+ }
8291
+ app.post(COMPOSITIONS_PREFIX, async (c) => {
8292
+ const customer = await requireBrowserCustomer(c);
8293
+ if (!customer)
8294
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
8295
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
8296
+ const templateId = readNonEmptyString(body.template_id) ?? readNonEmptyString(body.source);
8297
+ // Blank project: the SPA POSTs source="original" on the first edit. Mint a
8298
+ // fork seeded from the canonical blank scaffold instead of rejecting it, so
8299
+ // edits on a new project persist (previously blank edits were lost on reload).
8300
+ if (templateId === BLANK_COMPOSITION_ID) {
8301
+ const blankTitle = readNonEmptyString(body.title) ?? "Untitled project";
8302
+ const fork = await forkBlankComposition(customer.id, blankTitle);
8303
+ return c.json(serializeFork(fork, {
8304
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
8305
+ }), 201);
8306
+ }
8307
+ if (!templateId || !templateId.startsWith("template_")) {
8308
+ return c.json({ ok: false, error: "Missing or invalid template_id" }, 400);
8309
+ }
8310
+ const template = await serverlessRecords.getTemplate(templateId);
8311
+ if (!template)
8312
+ return c.json({ ok: false, error: "Template not found" }, 404);
8313
+ const title = readNonEmptyString(body.title) ?? template.title ?? `Template ${template.id}`;
8314
+ const fork = await forkCompositionFromTemplateDefault(customer.id, template, title);
7780
8315
  return c.json(serializeFork(fork, {
7781
8316
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
7782
8317
  }), 201);
@@ -8277,6 +8812,24 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
8277
8812
  catch (placementPersistError) {
8278
8813
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
8279
8814
  }
8815
+ // Persist the editor harness on the canonical fork too, before the
8816
+ // snapshot, so it travels with v1 and seeds every downstream fork.
8817
+ try {
8818
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "editor-harness.json"), {
8819
+ version: 1,
8820
+ savedAtMs: Date.now(),
8821
+ compositionId: canonicalFork.id,
8822
+ sourceUrl,
8823
+ title,
8824
+ provider: smart.provider,
8825
+ model: smart.model,
8826
+ durationSeconds: smart.durationSeconds,
8827
+ harness: smart.editorHarness
8828
+ });
8829
+ }
8830
+ catch (harnessPersistError) {
8831
+ console.error("inspiration editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
8832
+ }
8280
8833
  // Seed the scene-annotation pass "pending" on the canonical fork too,
8281
8834
  // before the snapshot, so the pending state travels with v1 and every
8282
8835
  // downstream fork can drive /scene-annotations-poll (or inherit the
@@ -8749,6 +9302,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8749
9302
  result: {
8750
9303
  summary: smart.summary,
8751
9304
  viralDna: smart.viralDna,
9305
+ editorHarness: smart.editorHarness,
8752
9306
  transcript: smart.transcript,
8753
9307
  durationSeconds: smart.durationSeconds,
8754
9308
  width: smart.width,
@@ -8783,6 +9337,27 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8783
9337
  catch (placementPersistError) {
8784
9338
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
8785
9339
  }
9340
+ // Persist the EDITOR HARNESS as its own decompose artifact (mirrors
9341
+ // product-placement.json). Technical editing direction — read-only style
9342
+ // context injected into both the web /editor AI (via the editor_context
9343
+ // snapshot's editor_harness field, parsed from data-editor-harness in the
9344
+ // HTML) and the local devcli agent (via this synced file). Non-fatal.
9345
+ try {
9346
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "editor-harness.json"), {
9347
+ version: 1,
9348
+ savedAtMs: Date.now(),
9349
+ compositionId,
9350
+ sourceUrl: analysisSourceUrl,
9351
+ title,
9352
+ provider: smart.provider,
9353
+ model: smart.model,
9354
+ durationSeconds: smart.durationSeconds,
9355
+ harness: smart.editorHarness
9356
+ });
9357
+ }
9358
+ catch (harnessPersistError) {
9359
+ console.error("editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
9360
+ }
8786
9361
  // Seed the per-scene clip-annotation pass as "pending" and let the client
8787
9362
  // drive it via POST /scene-annotations-poll (same async model as cast /
8788
9363
  // ghostcut). The annotation is its own vision call — running it inline would
@@ -8818,6 +9393,40 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8818
9393
  reason: "manual",
8819
9394
  message: `Smart-decomposed via ${smart.provider}(${smart.model}): ${smart.scenes.length} scene(s), ${smart.captions.length} caption(s)${ghostcutTaskId ? ` + GhostCut task ${ghostcutTaskId} pending` : ""}`
8820
9395
  });
9396
+ // Race-closer: the GhostCut subtitle-removal poll (fast) may have already
9397
+ // finished and mirrored the cleaned video while this (slow) vision analysis
9398
+ // was running — in which case it parked ghostcut.json in
9399
+ // "awaiting_composition" because smart-decompose.json didn't exist yet. Now
9400
+ // that BOTH composition.html and smart-decompose.json are written, apply the
9401
+ // rebuild here so the composition points at the subtitle-removed video even
9402
+ // if the client never polls again. Also self-heals any "done"-without-swap
9403
+ // fork left by the pre-fix code path. Non-fatal.
9404
+ if (ghostcutTaskId !== null) {
9405
+ try {
9406
+ const gcText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"));
9407
+ if (gcText) {
9408
+ const gc = JSON.parse(gcText);
9409
+ if (gc.mirroredUrl && (gc.status === "awaiting_composition" || gc.status === "done")) {
9410
+ const currentHtml = await storage.readText(storage.compositionForkWorkingKey(fork.id, "composition.html"));
9411
+ const alreadyClean = currentHtml ? currentHtml.includes(gc.mirroredUrl) : false;
9412
+ if (!alreadyClean) {
9413
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9414
+ fork,
9415
+ callerCustomerId: caller.id,
9416
+ cleanedSourceUrl: gc.mirroredUrl,
9417
+ snapshotMessage: `GhostCut task ${gc.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9418
+ });
9419
+ if (swapApplied && gc.status !== "done") {
9420
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "ghostcut.json"), { ...gc, status: "done", completedAtMs: gc.completedAtMs ?? Date.now() });
9421
+ }
9422
+ }
9423
+ }
9424
+ }
9425
+ }
9426
+ catch (finalizeError) {
9427
+ console.error("auto-decompose ghostcut finalize failed", finalizeError instanceof Error ? finalizeError.message : finalizeError);
9428
+ }
9429
+ }
8821
9430
  // First successful decompose for this template becomes everyone's default.
8822
9431
  // Later users get seeded from this fork's composition html. Enforced at the
8823
9432
  // storage layer via a conditional write so the first decompose wins even
@@ -9028,6 +9637,68 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/video-context.json`, async (c) => {
9028
9637
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9029
9638
  }
9030
9639
  });
9640
+ // Rebuild a decomposed fork's composition.html DETERMINISTICALLY from the
9641
+ // persisted smart-decompose.json snapshot, substituting `cleanedSourceUrl` (the
9642
+ // GhostCut-mirrored, subtitle-removed video) as the source for every media
9643
+ // layer. Shared by BOTH the async GhostCut poll AND the synchronous tail of
9644
+ // /auto-decompose. Rationale: GhostCut removal (fast, ~30-90s) routinely
9645
+ // finishes BEFORE the vision analysis (slow, minutes) has written
9646
+ // smart-decompose.json, so whichever side "wins" the race must be able to apply
9647
+ // the swap. Returns true iff composition.html was rewritten against the clean
9648
+ // video; false when the snapshot is missing/unparseable/empty (the caller keeps
9649
+ // the original in place and retries later).
9650
+ async function rebuildDecomposedCompositionAgainstCleanVideo(input) {
9651
+ const { fork, callerCustomerId, cleanedSourceUrl, snapshotMessage } = input;
9652
+ const persistedText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
9653
+ if (!persistedText) {
9654
+ console.error(`ghostcut-rebuild: smart-decompose.json missing for fork ${fork.id}; composition NOT rebuilt (still references original video)`);
9655
+ return false;
9656
+ }
9657
+ let persisted;
9658
+ try {
9659
+ persisted = JSON.parse(persistedText);
9660
+ }
9661
+ catch (parseError) {
9662
+ console.error("ghostcut-rebuild: smart-decompose.json parse failed", parseError instanceof Error ? parseError.message : parseError);
9663
+ return false;
9664
+ }
9665
+ if (!persisted.result || !Array.isArray(persisted.result.scenes) || persisted.result.scenes.length === 0) {
9666
+ console.error(`ghostcut-rebuild: smart-decompose.json for fork ${fork.id} has no scenes; skipping rebuild`);
9667
+ return false;
9668
+ }
9669
+ const rebuilt = buildSmartDecomposedHtml({
9670
+ compositionId: persisted.compositionId ?? fork.id,
9671
+ sourceUrl: cleanedSourceUrl,
9672
+ title: persisted.title ?? fork.title ?? fork.id,
9673
+ result: {
9674
+ summary: persisted.result.summary ?? "",
9675
+ viralDna: persisted.result.viralDna ?? {
9676
+ trend_tagline: "", hook: "", retention: "", payoff: "",
9677
+ preserve: [], avoid: [], promotions: [], keywords: []
9678
+ },
9679
+ transcript: persisted.result.transcript ?? null,
9680
+ durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9681
+ width: Number(persisted.result.width ?? 720),
9682
+ height: Number(persisted.result.height ?? 1280),
9683
+ scenes: persisted.result.scenes,
9684
+ captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9685
+ // Placement surfaces live in their own artifact and aren't used by the
9686
+ // HTML rebuild; supply an empty list to satisfy the type.
9687
+ placementSurfaces: [],
9688
+ // Editor harness round-trips from the snapshot so the rebuilt HTML keeps
9689
+ // its data-editor-harness; default if a pre-harness snapshot is rebuilt.
9690
+ editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
9691
+ }
9692
+ });
9693
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
9694
+ await snapshotForkVersion({
9695
+ fork,
9696
+ callerCustomerId,
9697
+ reason: "manual",
9698
+ message: snapshotMessage
9699
+ });
9700
+ return true;
9701
+ }
9031
9702
  // Idempotent status probe for the async GhostCut subtitle-removal task kicked
9032
9703
  // off by /auto-decompose. The client polls this every few seconds after a
9033
9704
  // successful decompose. On this endpoint's side:
@@ -9076,6 +9747,41 @@ const pollForkSubtitleRemoval = async (c) => {
9076
9747
  completed_at_ms: state.completedAtMs ?? null
9077
9748
  });
9078
9749
  }
9750
+ // "awaiting_composition": GhostCut already finished and the cleaned video is
9751
+ // mirrored; we're only waiting for smart-decompose.json to exist so we can
9752
+ // rebuild. Do NOT re-poll GhostCut and do NOT re-bill — just retry the
9753
+ // rebuild. Flips to terminal "done" the moment the snapshot is available.
9754
+ if (state.status === "awaiting_composition" && state.mirroredUrl) {
9755
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9756
+ fork,
9757
+ callerCustomerId: caller.id,
9758
+ cleanedSourceUrl: state.mirroredUrl,
9759
+ snapshotMessage: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9760
+ });
9761
+ if (swapApplied) {
9762
+ const next = {
9763
+ ...state,
9764
+ status: "done",
9765
+ completedAtMs: state.completedAtMs ?? Date.now()
9766
+ };
9767
+ await storage.putJson(stateKey, next);
9768
+ return c.json({
9769
+ ok: true,
9770
+ status: "done",
9771
+ task_id: state.taskId,
9772
+ mirrored_url: state.mirroredUrl,
9773
+ completed_at_ms: next.completedAtMs,
9774
+ swap_applied: true
9775
+ });
9776
+ }
9777
+ // Snapshot still not written — keep the client polling.
9778
+ return c.json({
9779
+ ok: true,
9780
+ status: "pending",
9781
+ task_id: state.taskId,
9782
+ mirrored_url: state.mirroredUrl
9783
+ });
9784
+ }
9079
9785
  // Poll GhostCut once. We deliberately do only ONE HTTP call per invocation
9080
9786
  // so the client controls cadence and lambda time is bounded.
9081
9787
  const status = await pollGhostcutStatus(state.taskId);
@@ -9138,71 +9844,11 @@ const pollForkSubtitleRemoval = async (c) => {
9138
9844
  if (!finalMirroredUrl) {
9139
9845
  throw new Error(`ghostcut mirror key not in a public prefix: ${mirrored.key}`);
9140
9846
  }
9141
- // REBUILD (not swap) the composition from the deterministic smart-decompose
9142
- // JSON we persisted at decompose time, substituting the ghostcut-mirrored
9143
- // URL as the source. No regex, no escape/unescape shuffling, no partial
9144
- // matches on URLs that contain "&". The composition is DERIVED from
9145
- // (scenes + captions + viralDna) × sourceUrl — flip the sourceUrl and
9146
- // reemit the HTML. If the persisted JSON is missing (shouldn't happen
9147
- // outside of a stale/interrupted fork), we log and skip the HTML update
9148
- // — the composition stays pointing at the original video, which the
9149
- // /render guard then blocks anyway.
9150
- let swapApplied = false;
9151
- const persistedText = await storage.readText(storage.compositionForkWorkingKey(fork.id, "smart-decompose.json"));
9152
- if (persistedText) {
9153
- try {
9154
- const persisted = JSON.parse(persistedText);
9155
- if (persisted.result && Array.isArray(persisted.result.scenes)) {
9156
- const rebuilt = buildSmartDecomposedHtml({
9157
- compositionId: persisted.compositionId ?? fork.id,
9158
- sourceUrl: finalMirroredUrl,
9159
- title: persisted.title ?? fork.title ?? fork.id,
9160
- result: {
9161
- summary: persisted.result.summary ?? "",
9162
- viralDna: persisted.result.viralDna ?? {
9163
- trend_tagline: "", hook: "", retention: "", payoff: "",
9164
- preserve: [], avoid: [], promotions: [], keywords: []
9165
- },
9166
- transcript: persisted.result.transcript ?? null,
9167
- durationSeconds: Number(persisted.result.durationSeconds ?? 0),
9168
- width: Number(persisted.result.width ?? 720),
9169
- height: Number(persisted.result.height ?? 1280),
9170
- scenes: persisted.result.scenes,
9171
- captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9172
- // Placement surfaces live in their own artifact and aren't used
9173
- // by the HTML rebuild; supply an empty list to satisfy the type.
9174
- placementSurfaces: []
9175
- }
9176
- });
9177
- await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
9178
- await snapshotForkVersion({
9179
- fork,
9180
- callerCustomerId: caller.id,
9181
- reason: "manual",
9182
- message: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9183
- });
9184
- swapApplied = true;
9185
- }
9186
- else {
9187
- console.error(`ghostcut-poll: smart-decompose.json for fork ${fork.id} has no scenes; skipping rebuild`);
9188
- }
9189
- }
9190
- catch (parseError) {
9191
- console.error("ghostcut-poll: smart-decompose.json parse failed", parseError instanceof Error ? parseError.message : parseError);
9192
- }
9193
- }
9194
- else {
9195
- console.error(`ghostcut-poll: smart-decompose.json missing for fork ${fork.id}; composition NOT rebuilt (still references original video)`);
9196
- }
9197
- const next = {
9198
- ...state,
9199
- status: "done",
9200
- mirroredUrl: finalMirroredUrl,
9201
- completedAtMs: Date.now()
9202
- };
9203
- await storage.putJson(stateKey, next);
9204
- // Bill the GhostCut chunk cost. Idempotency keyed on taskId so re-polls
9205
- // (in case of network hiccups) never double-charge.
9847
+ // Bill the GhostCut chunk cost NOW that the mirror succeeded the vendor
9848
+ // work is done and paid for regardless of whether the composition rebuild
9849
+ // lands this tick (it can't until smart-decompose.json exists). Idempotency
9850
+ // keyed on taskId so re-polls / the awaiting_composition retry loop never
9851
+ // double-charge.
9206
9852
  const seconds = state.probedDurationSeconds ?? 30;
9207
9853
  const cost = estimateGhostcutCostUsd(seconds);
9208
9854
  try {
@@ -9230,14 +9876,39 @@ const pollForkSubtitleRemoval = async (c) => {
9230
9876
  catch (billingError) {
9231
9877
  console.error("ghostcut-poll billing failed", billingError instanceof Error ? billingError.message : billingError);
9232
9878
  }
9233
- return c.json({
9234
- ok: true,
9235
- status: "done",
9236
- task_id: state.taskId,
9237
- mirrored_url: finalMirroredUrl,
9238
- completed_at_ms: next.completedAtMs,
9239
- // True IFF *this* poll call is what rebuilt composition.html against
9240
- // the cleaned video. The persistent frontend poll uses this to force
9879
+ // REBUILD (not swap) the composition from the deterministic smart-decompose
9880
+ // snapshot, substituting the ghostcut-mirrored URL as the source. If the
9881
+ // snapshot doesn't exist yet — EXPECTED, because the fast subtitle removal
9882
+ // routinely beats the slow vision analysis that writes it — do NOT go
9883
+ // terminal. Park in "awaiting_composition" (mirror preserved, already
9884
+ // billed) and keep the client polling; the tail of /auto-decompose also
9885
+ // attempts this same rebuild once the snapshot lands, so the swap happens
9886
+ // whichever side wins the race. Going terminal-"done" here without the
9887
+ // rebuild is exactly the bug that stranded compositions on the original
9888
+ // text-baked video.
9889
+ const swapApplied = await rebuildDecomposedCompositionAgainstCleanVideo({
9890
+ fork,
9891
+ callerCustomerId: caller.id,
9892
+ cleanedSourceUrl: finalMirroredUrl,
9893
+ snapshotMessage: `GhostCut task ${state.taskId} completed — subtitles removed, composition rebuilt against cleaned video`
9894
+ });
9895
+ const next = {
9896
+ ...state,
9897
+ status: swapApplied ? "done" : "awaiting_composition",
9898
+ mirroredUrl: finalMirroredUrl,
9899
+ completedAtMs: Date.now()
9900
+ };
9901
+ await storage.putJson(stateKey, next);
9902
+ return c.json({
9903
+ ok: true,
9904
+ // "pending" (not "done") until the rebuild actually lands, so the client
9905
+ // keeps polling instead of stopping on a still-original composition.
9906
+ status: swapApplied ? "done" : "pending",
9907
+ task_id: state.taskId,
9908
+ mirrored_url: finalMirroredUrl,
9909
+ completed_at_ms: next.completedAtMs,
9910
+ // True IFF *this* poll call is what rebuilt composition.html against
9911
+ // the cleaned video. The persistent frontend poll uses this to force
9241
9912
  // one reload on the exact tick the swap landed, closing the gap
9242
9913
  // where the browser was holding a pre-swap composition in memory.
9243
9914
  swap_applied: swapApplied
@@ -9372,6 +10043,50 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
9372
10043
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9373
10044
  }
9374
10045
  });
10046
+ // Read-only EDITOR HARNESS for a fork: the technical editing direction extracted
10047
+ // during decompose (pace, typography, b-roll usage, transitions, audio bed, and
10048
+ // the role each scene plays). This is the "how to edit like this" companion to
10049
+ // viral DNA's "why it works". The web /editor AI already receives it inline via
10050
+ // editor_context.editor_harness (parsed from data-editor-harness in the HTML);
10051
+ // this route is how the local devcli agent + http_request tool fetch it.
10052
+ // Optional-auth like /product-placement.json. Returns { status: "none" } when
10053
+ // the fork was never decomposed (or predates this pass).
10054
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/editor-harness.json`, async (c) => {
10055
+ const customer = await getBrowserCustomer(c);
10056
+ try {
10057
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
10058
+ const text = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "editor-harness.json"));
10059
+ if (!text) {
10060
+ return c.json({
10061
+ ok: true,
10062
+ status: "none",
10063
+ hint: "This fork has no editor-harness pass yet. Run POST /api/v1/compositions/:forkId/auto-decompose to generate it."
10064
+ });
10065
+ }
10066
+ let parsed;
10067
+ try {
10068
+ parsed = JSON.parse(text);
10069
+ }
10070
+ catch {
10071
+ return c.json({ ok: true, status: "none" });
10072
+ }
10073
+ const harness = (parsed.harness && typeof parsed.harness === "object") ? parsed.harness : null;
10074
+ return c.json({
10075
+ ok: true,
10076
+ status: harness ? "ready" : "empty",
10077
+ provider: parsed.provider ?? null,
10078
+ model: parsed.model ?? null,
10079
+ source_url: parsed.sourceUrl ?? null,
10080
+ duration_seconds: parsed.durationSeconds ?? null,
10081
+ harness: harness ?? DEFAULT_EDITOR_HARNESS
10082
+ });
10083
+ }
10084
+ catch (error) {
10085
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
10086
+ return forkAccessErrorResponse(c, error);
10087
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
10088
+ }
10089
+ });
9375
10090
  // Read-only scene-annotation context for a fork: the detected content FORMAT
9376
10091
  // plus one annotation per scene carrying the literal + viral DNA needed to
9377
10092
  // (a) match a REPLACEMENT clip from the library (each annotation ships a
@@ -10186,6 +10901,42 @@ app.patch(`${COMPOSITIONS_PREFIX}/:forkId/visibility`, async (c) => {
10186
10901
  return forkAccessErrorResponse(c, error);
10187
10902
  }
10188
10903
  });
10904
+ // Unified "new fork" endpoint. from="current" (default) branches from this
10905
+ // fork's working copy (or a pinned version); from="default" starts a fresh copy
10906
+ // from the template's canonical default fork, discarding the current edits.
10907
+ // This is the single REST surface behind the editor's New-fork modal, the AI
10908
+ // chat fork_composition action, and the devcli clone command.
10909
+ app.post(`${COMPOSITIONS_PREFIX}/:forkId/fork`, async (c) => {
10910
+ const customer = await requireBrowserCustomer(c);
10911
+ if (!customer)
10912
+ return c.json({ ok: false, error: "Login required to fork" }, 401);
10913
+ try {
10914
+ const sourceAccess = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer.id, shareToken: readShareToken(c) }, "view");
10915
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
10916
+ const from = readNonEmptyString(body.from) === "default" ? "default" : "current";
10917
+ if (from === "default") {
10918
+ const template = await serverlessRecords.getTemplate(sourceAccess.fork.templateId);
10919
+ if (!template)
10920
+ return c.json({ ok: false, error: "Template not found for this fork" }, 404);
10921
+ const title = readNonEmptyString(body.title) ?? template.title ?? sourceAccess.fork.title;
10922
+ const fresh = await forkCompositionFromTemplateDefault(customer.id, template, title);
10923
+ return c.json(serializeFork(fresh, {
10924
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10925
+ }), 201);
10926
+ }
10927
+ const parentVersionRaw = readNonEmptyString(body.parent_version);
10928
+ const parentVersion = parentVersionRaw ? Number.parseInt(parentVersionRaw, 10) : sourceAccess.fork.latestVersion || null;
10929
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10930
+ return c.json(serializeFork(cloned, {
10931
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10932
+ }), 201);
10933
+ }
10934
+ catch (error) {
10935
+ return forkAccessErrorResponse(c, error);
10936
+ }
10937
+ });
10938
+ // Legacy alias: clone == fork from the current working copy. Retained for
10939
+ // existing callers; new callers should use POST /:forkId/fork { from }.
10189
10940
  app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
10190
10941
  const customer = await requireBrowserCustomer(c);
10191
10942
  if (!customer)
@@ -10195,50 +10946,8 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
10195
10946
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
10196
10947
  const parentVersionRaw = readNonEmptyString(body.parent_version);
10197
10948
  const parentVersion = parentVersionRaw ? Number.parseInt(parentVersionRaw, 10) : sourceAccess.fork.latestVersion || null;
10198
- const cloned = await serverlessRecords.createCompositionFork({
10199
- customerId: customer.id,
10200
- templateId: sourceAccess.fork.templateId,
10201
- parentForkId: sourceAccess.fork.id,
10202
- parentVersion: parentVersion ?? null,
10203
- visibility: "private",
10204
- title: readNonEmptyString(body.title) ?? sourceAccess.fork.title
10205
- });
10206
- const readVersionFile = async (filename) => {
10207
- if (parentVersion && Number.isFinite(parentVersion)) {
10208
- const versioned = await storage.readText(storage.compositionForkVersionKey(sourceAccess.fork.id, parentVersion, filename));
10209
- if (versioned)
10210
- return versioned;
10211
- }
10212
- return storage.readText(storage.compositionForkWorkingKey(sourceAccess.fork.id, filename));
10213
- };
10214
- const html = await readVersionFile("composition.html");
10215
- const json = await readVersionFile("composition.json");
10216
- const cast = await readVersionFile("cast.json");
10217
- const placement = await readVersionFile("product-placement.json");
10218
- const annotations = await readVersionFile("scene-annotations.json");
10219
- if (html) {
10220
- await storage.putText(storage.compositionForkWorkingKey(cloned.id, "composition.html"), html, "text/html; charset=utf-8");
10221
- }
10222
- if (json) {
10223
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "composition.json"), Buffer.from(json, "utf8"), "application/json");
10224
- }
10225
- if (cast) {
10226
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
10227
- }
10228
- if (placement) {
10229
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
10230
- }
10231
- if (annotations) {
10232
- await storage.putBuffer(storage.compositionForkWorkingKey(cloned.id, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
10233
- }
10234
- await snapshotForkVersion({
10235
- fork: cloned,
10236
- callerCustomerId: customer.id,
10237
- reason: "clone",
10238
- message: `Cloned from ${sourceAccess.fork.id}${parentVersion ? `@v${parentVersion}` : ""}`
10239
- });
10240
- const refreshed = await serverlessRecords.getCompositionFork(cloned.id);
10241
- return c.json(serializeFork(refreshed, {
10949
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10950
+ return c.json(serializeFork(cloned, {
10242
10951
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10243
10952
  }), 201);
10244
10953
  }
@@ -12006,6 +12715,15 @@ app.post("/settings/attachments", async (c) => {
12006
12715
  const attachmentId = randomUUID();
12007
12716
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName);
12008
12717
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
12718
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
12719
+ const thumb = await ensureAttachmentThumbnail({
12720
+ customerId: customer.id,
12721
+ attachmentId,
12722
+ storageKey,
12723
+ contentType,
12724
+ attachmentUrl: publicUrl,
12725
+ sourceUrl: publicUrl
12726
+ });
12009
12727
  await serverlessRecords.createUserAttachment({
12010
12728
  id: attachmentId,
12011
12729
  customerId: customer.id,
@@ -12013,7 +12731,9 @@ app.post("/settings/attachments", async (c) => {
12013
12731
  contentType,
12014
12732
  sizeBytes: buffer.byteLength,
12015
12733
  storageKey,
12016
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey)
12734
+ publicUrl,
12735
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
12736
+ thumbnailUrl: thumb?.thumbnailUrl ?? null
12017
12737
  });
12018
12738
  }
12019
12739
  return redirectToSettings(c, { notice: "Attachment upload complete.", accountId: customer.id });
@@ -13342,6 +14062,7 @@ function serializeReadyPost(c, post, input) {
13342
14062
  status: post.status,
13343
14063
  title: post.title,
13344
14064
  caption: post.caption,
14065
+ folder_path: normalizeFolderPath(post.folderPath ?? ""),
13345
14066
  pinned_comment: post.pinnedComment,
13346
14067
  media: post.mediaAssets.map((asset) => ({
13347
14068
  id: asset.id,
@@ -15948,6 +16669,9 @@ function buildJobDebugHints(input) {
15948
16669
  if (input.job.status === "waiting_for_provider" && Date.parse(input.job.runAfter) > Date.now()) {
15949
16670
  hints.push("The job is waiting for an available customer AI provider key. Step Functions will retry it after run_after.");
15950
16671
  }
16672
+ if (input.job.status === "waiting_for_render") {
16673
+ hints.push("The job dispatched a distributed render to its own state machine and is polling it via Step Functions wait+re-invoke (waiting_for_render). This is normal for long renders; check the hyperframes render execution if it never terminates.");
16674
+ }
15951
16675
  if (input.job.status === "running" && input.job.updatedAt) {
15952
16676
  hints.push("For running jobs, compare updated_at with the latest structured log event to check heartbeat/progress freshness.");
15953
16677
  }
@@ -15991,6 +16715,10 @@ function primitiveAliasPathForKind(kind) {
15991
16715
  return `${PRIMITIVES_PREFIX}/images/render-html`;
15992
16716
  case "image_remove_background":
15993
16717
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
16718
+ case "image_remove_background_greenscreen":
16719
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
16720
+ case "media_overlay":
16721
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
15994
16722
  case "video_generate":
15995
16723
  return `${PRIMITIVES_PREFIX}/videos/generate`;
15996
16724
  case "video_render_slides":
@@ -16651,6 +17379,14 @@ app.post(`${PRIMITIVES_PREFIX}/images/edit`, async (c) => createPrimitiveJob(c,
16651
17379
  app.post(`${PRIMITIVES_PREFIX}/images/inpaint`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_inpaint", operationName: "run" }));
16652
17380
  app.post(`${PRIMITIVES_PREFIX}/images/render-html`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_render_html", operationName: "run" }));
16653
17381
  app.post(`${PRIMITIVES_PREFIX}/images/remove-background`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background", operationName: "run" }));
17382
+ // Cheap local chroma-key background removal. Canonical images/* path plus a flat
17383
+ // plain-English alias matching the primitive's spoken name.
17384
+ app.post(`${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17385
+ app.post(`${PRIMITIVES_PREFIX}/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17386
+ // Two-step "create media overlay": BYOK image gen on a forced key-color
17387
+ // background + local chroma key → transparent overlay.
17388
+ app.post(`${PRIMITIVES_PREFIX}/images/create-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
17389
+ app.post(`${PRIMITIVES_PREFIX}/create-media-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
16654
17390
  app.post(`${PRIMITIVES_PREFIX}/videos/generate`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_generate", operationName: "run" }));
16655
17391
  app.post(`${PRIMITIVES_PREFIX}/videos/download`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_download", operationName: "run" }));
16656
17392
  app.post(`${PRIMITIVES_PREFIX}/videos/render-slides`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_render_slides", operationName: "run" }));
@@ -16814,6 +17550,27 @@ app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
16814
17550
  return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
16815
17551
  }
16816
17552
  });
17553
+ // PATCH /api/v1/approved/posts/:postId { folder_path } — move a post between
17554
+ // /approved folders (""=root). Mirrors PATCH /raws/:clipId for the agent.
17555
+ app.patch(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
17556
+ const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
17557
+ if (proxied)
17558
+ return proxied;
17559
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
17560
+ if (!post) {
17561
+ return c.json({ error: "Approved post not found." }, 404);
17562
+ }
17563
+ try {
17564
+ const customer = requireReadyPostOwner(c, post);
17565
+ const body = (await c.req.json().catch(() => ({})));
17566
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
17567
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
17568
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
17569
+ }
17570
+ catch (error) {
17571
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
17572
+ }
17573
+ });
16817
17574
  app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
16818
17575
  const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
16819
17576
  if (proxied)
@@ -17073,7 +17830,18 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17073
17830
  const attachmentId = randomUUID();
17074
17831
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
17075
17832
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
17833
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
17076
17834
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17835
+ // Durable poster for the file directory (image = itself, video = extracted
17836
+ // frame from the just-stored object, else none). Fail-soft — never blocks.
17837
+ const thumb = await ensureAttachmentThumbnail({
17838
+ customerId: customer.id,
17839
+ attachmentId,
17840
+ storageKey,
17841
+ contentType,
17842
+ attachmentUrl: publicUrl,
17843
+ sourceUrl: publicUrl
17844
+ });
17077
17845
  await serverlessRecords.createUserAttachment({
17078
17846
  id: attachmentId,
17079
17847
  customerId: customer.id,
@@ -17082,7 +17850,9 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17082
17850
  sizeBytes: buffer.byteLength,
17083
17851
  storageKey,
17084
17852
  folderPath,
17085
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey),
17853
+ publicUrl,
17854
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17855
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17086
17856
  notes,
17087
17857
  notesEmbedding: embedded?.embedding ?? null,
17088
17858
  notesEmbeddingModel: embedded?.model ?? null
@@ -17097,6 +17867,122 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17097
17867
  return c.json({ error: error instanceof Error ? error.message : "Unable to upload attachment." }, 400);
17098
17868
  }
17099
17869
  });
17870
+ // POST /me/attachments/from-url — save a durable media URL (e.g. a finished
17871
+ // inpaint / generate job's output) into My Files at a chosen folder. Fetched
17872
+ // server-side so cross-origin S3 objects don't need browser CORS. Only trusted
17873
+ // app / storage origins are allowed (no arbitrary SSRF fetch).
17874
+ function isTrustedMediaOrigin(u) {
17875
+ try {
17876
+ if (u.protocol !== "https:" && u.protocol !== "http:")
17877
+ return false;
17878
+ const appOrigin = new URL(config.PUBLIC_BASE_URL).origin;
17879
+ if (u.origin === appOrigin)
17880
+ return true;
17881
+ if (config.AWS_S3_ENDPOINT) {
17882
+ try {
17883
+ if (u.origin === new URL(config.AWS_S3_ENDPOINT).origin)
17884
+ return true;
17885
+ }
17886
+ catch { /* noop */ }
17887
+ }
17888
+ const host = u.hostname.toLowerCase();
17889
+ if (host.endsWith(".amazonaws.com"))
17890
+ return true;
17891
+ if (host.endsWith(".cloudfront.net"))
17892
+ return true;
17893
+ return false;
17894
+ }
17895
+ catch {
17896
+ return false;
17897
+ }
17898
+ }
17899
+ app.post(`${USER_PREFIX}/me/attachments/from-url`, async (c) => {
17900
+ try {
17901
+ const customer = requireCustomer(c);
17902
+ const raw = (await c.req.json().catch(() => null));
17903
+ const src = typeof raw?.source_url === "string" ? raw.source_url.trim() : "";
17904
+ if (!src) {
17905
+ return c.json({ error: "source_url is required." }, 400);
17906
+ }
17907
+ let sourceUrl;
17908
+ try {
17909
+ sourceUrl = new URL(src, config.PUBLIC_BASE_URL);
17910
+ }
17911
+ catch {
17912
+ return c.json({ error: "source_url is not a valid URL." }, 400);
17913
+ }
17914
+ if (!isTrustedMediaOrigin(sourceUrl)) {
17915
+ return c.json({ error: "source_url must point to a vidfarm asset or job output." }, 400);
17916
+ }
17917
+ const folderPath = sanitizeStorageSubpath(typeof raw?.folder_path === "string" ? raw.folder_path : "");
17918
+ const notes = (typeof raw?.notes === "string" ? raw.notes : "").trim().slice(0, 4000) || null;
17919
+ const response = await fetch(sourceUrl, { method: "GET" });
17920
+ if (!response.ok) {
17921
+ return c.json({ error: `Could not fetch the media (${response.status}).` }, 400);
17922
+ }
17923
+ const arrayBuffer = await response.arrayBuffer();
17924
+ const buffer = Buffer.from(arrayBuffer);
17925
+ if (buffer.byteLength > 50 * 1024 * 1024) {
17926
+ return c.json({ error: "My Files uploads can be at most 50 MB." }, 400);
17927
+ }
17928
+ const responseType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
17929
+ // Prefer an explicit file_name; else derive from the URL path; else fall back.
17930
+ const urlName = decodeURIComponent((sourceUrl.pathname.split("/").pop() || "").trim());
17931
+ const requested = typeof raw?.file_name === "string" ? raw.file_name.trim() : "";
17932
+ let fileName = sanitizeFileName(requested || urlName || "media.bin");
17933
+ const contentType = responseType || inferAttachmentContentType(fileName);
17934
+ // Ensure the name carries an extension matching the content type so the file
17935
+ // directory renders it correctly (job outputs are often extensionless keys).
17936
+ if (!/\.[a-z0-9]+$/i.test(fileName)) {
17937
+ const extByType = {
17938
+ "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif",
17939
+ "video/mp4": "mp4", "video/webm": "webm", "audio/mpeg": "mp3", "audio/wav": "wav"
17940
+ };
17941
+ const ext = extByType[contentType.toLowerCase()];
17942
+ if (ext)
17943
+ fileName = `${fileName}.${ext}`;
17944
+ }
17945
+ if (!isAllowedAttachment(fileName, contentType)) {
17946
+ return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
17947
+ }
17948
+ const attachmentId = randomUUID();
17949
+ const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
17950
+ const stored = await storage.putBuffer(storageKey, buffer, contentType);
17951
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
17952
+ const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17953
+ const thumb = await ensureAttachmentThumbnail({
17954
+ customerId: customer.id,
17955
+ attachmentId,
17956
+ storageKey,
17957
+ contentType,
17958
+ attachmentUrl: publicUrl,
17959
+ sourceUrl: publicUrl
17960
+ });
17961
+ await serverlessRecords.createUserAttachment({
17962
+ id: attachmentId,
17963
+ customerId: customer.id,
17964
+ fileName,
17965
+ contentType,
17966
+ sizeBytes: buffer.byteLength,
17967
+ storageKey,
17968
+ folderPath,
17969
+ publicUrl,
17970
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17971
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17972
+ notes,
17973
+ notesEmbedding: embedded?.embedding ?? null,
17974
+ notesEmbeddingModel: embedded?.model ?? null
17975
+ });
17976
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
17977
+ if (!attachment) {
17978
+ return c.json({ error: "Attachment could not be saved." }, 500);
17979
+ }
17980
+ return c.json({ ok: true, attachment: serializeUserAttachment(c, attachment) }, 201);
17981
+ }
17982
+ catch (error) {
17983
+ return c.json({ error: error instanceof Error ? error.message : "Unable to save media to My Files." }, 400);
17984
+ }
17985
+ });
17100
17986
  app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17101
17987
  try {
17102
17988
  const customer = requireCustomer(c);
@@ -17116,6 +18002,17 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17116
18002
  }
17117
18003
  const notes = body.notes?.trim() || null;
17118
18004
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
18005
+ const publicUrl = storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey);
18006
+ // The bytes were PUT straight to storage (no local copy here), so the frame
18007
+ // extractor fetches them back from the object's own durable URL. Fail-soft.
18008
+ const thumb = await ensureAttachmentThumbnail({
18009
+ customerId: customer.id,
18010
+ attachmentId: body.attachment_id,
18011
+ storageKey: expectedStorageKey,
18012
+ contentType,
18013
+ attachmentUrl: publicUrl,
18014
+ sourceUrl: publicUrl
18015
+ });
17119
18016
  await serverlessRecords.createUserAttachment({
17120
18017
  id: body.attachment_id,
17121
18018
  customerId: customer.id,
@@ -17124,7 +18021,9 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17124
18021
  sizeBytes: body.size_bytes,
17125
18022
  storageKey: expectedStorageKey,
17126
18023
  folderPath,
17127
- publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey),
18024
+ publicUrl,
18025
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
18026
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17128
18027
  notes,
17129
18028
  notesEmbedding: embedded?.embedding ?? null,
17130
18029
  notesEmbeddingModel: embedded?.model ?? null
@@ -17525,6 +18424,36 @@ app.get(`${USER_PREFIX}/me/directory`, async (c) => {
17525
18424
  return c.json({ error: error instanceof Error ? error.message : "Unable to list directory." }, 400);
17526
18425
  }
17527
18426
  });
18427
+ // Cloud passthrough for a `vidfarm serve` box: browse the CLOUD (vidfarm.cc)
18428
+ // directory alongside the local one so the file explorer can offer /local vs
18429
+ // /cloud spaces (and `vidfarm directory --both`). Proxies to the same upstream
18430
+ // routes with the ambient cloud key. No upstream (e.g. running ON cloud) → 400
18431
+ // so the UI simply hides the /cloud tab.
18432
+ app.get(`${USER_PREFIX}/me/cloud/directory`, async (c) => {
18433
+ requireCustomer(c);
18434
+ if (!isUpstreamEnabled())
18435
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18436
+ const qs = new URLSearchParams();
18437
+ for (const key of ["path", "limit", "offset", "content_type"]) {
18438
+ const v = c.req.query(key);
18439
+ if (v)
18440
+ qs.set(key, v);
18441
+ }
18442
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory${qs.toString() ? `?${qs.toString()}` : ""}`);
18443
+ if (!res.ok || !res.json)
18444
+ return c.json({ error: "Cloud directory unavailable." }, 502);
18445
+ return c.json(res.json);
18446
+ });
18447
+ app.post(`${USER_PREFIX}/me/cloud/directory/search`, async (c) => {
18448
+ requireCustomer(c);
18449
+ if (!isUpstreamEnabled())
18450
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18451
+ const body = await c.req.json().catch(() => ({}));
18452
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory/search`, { method: "POST", body });
18453
+ if (!res.ok || !res.json)
18454
+ return c.json({ error: "Cloud search unavailable." }, 502);
18455
+ return c.json(res.json);
18456
+ });
17528
18457
  // POST /me/directory/search { query, path?, mode?, limit? }
17529
18458
  // Unified search combining semantic (vector), substring, and absolute-path
17530
18459
  // matching across the in-scope roots. `path` narrows scope to one root/folder;
@@ -17641,6 +18570,24 @@ app.post(`${USER_PREFIX}/me/directory/search`, async (c) => {
17641
18570
  }
17642
18571
  }
17643
18572
  }
18573
+ // ── /approved: no embeddings → substring (title/caption/tracer) + path ─────
18574
+ if (scopeRoots.includes("approved")) {
18575
+ const posts = (await serverlessRecords.listReadyPosts(customer.id))
18576
+ .filter((p) => p.status !== "deleted")
18577
+ .filter((p) => inScopeFolder(p.folderPath ?? ""));
18578
+ const ql = query.toLowerCase();
18579
+ for (const p of posts) {
18580
+ const item = readyPostToDirectoryItem(p);
18581
+ let score = 0;
18582
+ const hay = [p.title, p.caption, p.tracer].filter(Boolean).join(" ").toLowerCase();
18583
+ if (wantSubstring && hay.includes(ql))
18584
+ score += 0.5;
18585
+ if (wantPath && directoryPathContains(item.path, query))
18586
+ score += 0.6;
18587
+ if (score > 0)
18588
+ scored.push({ item, score, semantic: false });
18589
+ }
18590
+ }
17644
18591
  scored.sort((x, y) => y.score - x.score);
17645
18592
  const results = scored.slice(0, limit).map((s) => ({ ...s.item, score: s.score }));
17646
18593
  return c.json({ results, query, mode, scope: parsed.root ?? "all", semantic: semanticUsed });
@@ -17660,6 +18607,9 @@ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
17660
18607
  if (!parsed.root || !parsed.folderPath) {
17661
18608
  return c.json({ error: "Provide a folder path under a root, e.g. /files/brand-assets or /raws/demos." }, 400);
17662
18609
  }
18610
+ if (parsed.root === "projects") {
18611
+ 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);
18612
+ }
17663
18613
  await serverlessRecords.createUserFileFolder(customer.id, directoryFolderScope(parsed.root), parsed.folderPath);
17664
18614
  return c.json({ ok: true, path: buildDirectoryPath(parsed.root, parsed.folderPath), root: parsed.root, folder_path: parsed.folderPath });
17665
18615
  }
@@ -17667,6 +18617,104 @@ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
17667
18617
  return c.json({ error: error instanceof Error ? error.message : "Unable to create folder." }, 400);
17668
18618
  }
17669
18619
  });
18620
+ // POST /me/directory/rename { path, new_name, file_id? } — rename a file or a
18621
+ // folder in place. With file_id it's a FILE rename (display name only — the S3
18622
+ // key and view URL are untouched); without it, the trailing segment of `path`
18623
+ // is treated as a FOLDER and renamed (its files, and for /raws its nested
18624
+ // subfolders, move with it). Both the explorer UI and the chat agent call this,
18625
+ // so directory entries — character folders especially — stay tidy over time.
18626
+ app.post(`${USER_PREFIX}/me/directory/rename`, async (c) => {
18627
+ const customer = requireCustomer(c);
18628
+ try {
18629
+ const body = (await c.req.json().catch(() => ({})));
18630
+ const parsed = parseDirectoryPath(body.path ?? "");
18631
+ const rawName = String(body.new_name ?? "").trim();
18632
+ const fileId = String(body.file_id ?? "").trim();
18633
+ if (!parsed.root) {
18634
+ return c.json({ error: "Provide a path under a root (/files, /temp, /raws, /projects, /approved)." }, 400);
18635
+ }
18636
+ if (parsed.root === "projects") {
18637
+ 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);
18638
+ }
18639
+ if (!rawName) {
18640
+ return c.json({ error: "Provide new_name." }, 400);
18641
+ }
18642
+ // ── FILE rename (file_id given) — /files and /temp only ──────────────────
18643
+ if (fileId) {
18644
+ const newName = sanitizeFileName(rawName);
18645
+ if (!newName)
18646
+ return c.json({ error: "Provide a valid new file name." }, 400);
18647
+ if (parsed.root === "files") {
18648
+ const att = await serverlessRecords.getUserAttachment(customer.id, fileId);
18649
+ if (!att)
18650
+ return c.json({ error: "File not found." }, 404);
18651
+ if (!isAllowedAttachment(newName, att.contentType)) {
18652
+ return c.json({ error: `Unsupported file name: ${newName}` }, 400);
18653
+ }
18654
+ const updated = await serverlessRecords.renameUserAttachment(customer.id, fileId, newName);
18655
+ if (!updated)
18656
+ return c.json({ error: "File not found." }, 404);
18657
+ return c.json({
18658
+ ok: true,
18659
+ path: buildDirectoryPath("files", updated.folderPath, newName),
18660
+ file: serializeUserAttachment(c, updated)
18661
+ });
18662
+ }
18663
+ if (parsed.root === "temp") {
18664
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, fileId);
18665
+ if (!file)
18666
+ return c.json({ error: "File not found." }, 404);
18667
+ const updated = await serverlessRecords.renameUserTemporaryFile(customer.id, fileId, newName);
18668
+ if (!updated)
18669
+ return c.json({ error: "File not found." }, 404);
18670
+ return c.json({
18671
+ ok: true,
18672
+ path: buildDirectoryPath("temp", updated.folderPath, newName),
18673
+ file: serializeUserTemporaryFile(c, updated)
18674
+ });
18675
+ }
18676
+ 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);
18677
+ }
18678
+ // ── FOLDER rename (no file_id) — rename the trailing path segment ─────────
18679
+ const fromFolder = normalizeFolderPath(parsed.folderPath);
18680
+ if (!fromFolder) {
18681
+ return c.json({ error: "Provide a folder path (e.g. /files/characters/zara) or a file_id to rename a file." }, 400);
18682
+ }
18683
+ const newSegment = sanitizeStorageSubpath(rawName);
18684
+ if (!newSegment || newSegment.includes("/")) {
18685
+ return c.json({ error: "A folder name can't be empty or contain slashes." }, 400);
18686
+ }
18687
+ const segs = fromFolder.split("/");
18688
+ segs[segs.length - 1] = newSegment;
18689
+ const toFolder = normalizeFolderPath(segs.join("/"));
18690
+ if (parsed.root === "files") {
18691
+ await serverlessRecords.moveUserFileFolder(customer.id, "my_files", fromFolder, toFolder);
18692
+ await serverlessRecords.moveUserAttachmentFolder(customer.id, fromFolder, toFolder);
18693
+ }
18694
+ else if (parsed.root === "temp") {
18695
+ await serverlessRecords.moveUserFileFolder(customer.id, "temporary", fromFolder, toFolder);
18696
+ await serverlessRecords.moveUserTemporaryFolder(customer.id, fromFolder, toFolder);
18697
+ }
18698
+ else if (parsed.root === "approved") {
18699
+ await serverlessRecords.moveUserFileFolder(customer.id, "approved", fromFolder, toFolder);
18700
+ await serverlessRecords.moveReadyPostFolder(customer.id, fromFolder, toFolder);
18701
+ }
18702
+ else {
18703
+ await serverlessRecords.moveUserFileFolder(customer.id, "raws", fromFolder, toFolder);
18704
+ await clipRecords.moveClipsFolder(customer.id, fromFolder, toFolder);
18705
+ }
18706
+ return c.json({
18707
+ ok: true,
18708
+ path: buildDirectoryPath(parsed.root, toFolder),
18709
+ root: parsed.root,
18710
+ from_folder_path: fromFolder,
18711
+ to_folder_path: toFolder
18712
+ });
18713
+ }
18714
+ catch (error) {
18715
+ return c.json({ error: error instanceof Error ? error.message : "Rename failed." }, 400);
18716
+ }
18717
+ });
17670
18718
  app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
17671
18719
  try {
17672
18720
  const customer = requireCustomer(c);
@@ -18126,8 +19174,10 @@ function renderClipsPage(input) {
18126
19174
  : '<div class="clip-noimg">no thumb</div>';
18127
19175
  var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
18128
19176
  var tags = topTags(clip.tags).map(function(t){ return '<span class="clip-tag">'+esc(t)+'</span>'; }).join('');
19177
+ // The full original video a hunt was mined from is badged distinctly.
19178
+ 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>' : '';
18129
19179
  return '<div class="clip-card">'
18130
- + '<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>'
19180
+ + '<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>'
18131
19181
  + '<div class="clip-body">'
18132
19182
  + '<div class="clip-desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
18133
19183
  + '<div class="clip-tags">'+tags+'</div>'
@@ -18403,7 +19453,9 @@ async function serializeClip(clip) {
18403
19453
  ]);
18404
19454
  const { embedding, ...rest } = clip;
18405
19455
  void embedding; // never ship the raw vector to the client
18406
- return { ...rest, view_url, thumbnail_url };
19456
+ // Convenience flag alongside `role` so the raws UI can badge/sort the full
19457
+ // original video without reasoning about the union type.
19458
+ return { ...rest, view_url, thumbnail_url, is_source_original: clip.role === "source" };
18407
19459
  }
18408
19460
  function normalizeScanTier(value) {
18409
19461
  return value === "flash" ? "flash" : "flash-lite";
@@ -18613,11 +19665,27 @@ app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
18613
19665
  // pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
18614
19666
  // compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
18615
19667
  // the caller's own provider key (BYOK) and is never wallet-billed.
19668
+ // Turn a free-text folder name (e.g. "My Campaign 001") into the canonical
19669
+ // single-segment Raws folder slug ("my-campaign-001"). Returns null when the
19670
+ // input is empty so callers keep the source-derived default. Mirrors the slug
19671
+ // transform used elsewhere for auto-named raws folders.
19672
+ function slugifyRawFolder(value) {
19673
+ const slug = String(value ?? "")
19674
+ .toLowerCase()
19675
+ .replace(/[^a-z0-9]+/g, "-")
19676
+ .replace(/^-+|-+$/g, "")
19677
+ .slice(0, 60);
19678
+ return slug || null;
19679
+ }
18616
19680
  app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18617
19681
  const customer = requireCustomer(c);
18618
19682
  try {
18619
19683
  const body = (await c.req.json());
18620
19684
  const importOnly = body.import_only === true;
19685
+ // A caller-named destination folder (the /editor "Clip from Raw" modal sends
19686
+ // the folder name here) overrides the default source-title slug for every
19687
+ // clip this import/hunt produces. Null keeps the source-derived default.
19688
+ const folderOverride = slugifyRawFolder(body.folder_path);
18621
19689
  const guidancePrompt = body.prompt?.trim() || "";
18622
19690
  // Build the hunt spec: explicit structured fields win, the free "what to
18623
19691
  // clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
@@ -18653,6 +19721,11 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18653
19721
  huntSpec.max_clips = parsedPrompt.max_clips;
18654
19722
  if (!huntSpec.source_url && sourceUrl)
18655
19723
  huntSpec.source_url = sourceUrl;
19724
+ // Carry the named destination folder on the hunt spec — it rides the opaque
19725
+ // hunt_spec object end-to-end (cloud Step Functions, local runner, upstream
19726
+ // backup) so mined clips land in `/raws/<folder>` without a pipeline change.
19727
+ if (folderOverride)
19728
+ huntSpec.folder_path = folderOverride;
18656
19729
  // Fill the unspecified-prompt mining defaults (5–15s clips, vertical 9:16,
18657
19730
  // avoid on-screen text). Explicit user/prompt choices above already won;
18658
19731
  // this only fills the gaps. Skipped for import_only (no mining happens).
@@ -18750,6 +19823,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18750
19823
  sourceVideoId,
18751
19824
  filename,
18752
19825
  tracer,
19826
+ folderPath: folderOverride,
18753
19827
  localSourcePath: importSourcePath && existsSync(importSourcePath) ? importSourcePath : null,
18754
19828
  sourceUrl: huntSpec.source_url ?? null
18755
19829
  });
@@ -19141,13 +20215,16 @@ async function importWholeVideoAsRaw(input) {
19141
20215
  taxonomy_version: ACTIVE_TAXONOMY_VERSION,
19142
20216
  tier: "flash-lite",
19143
20217
  tracer: input.tracer,
19144
- // Default into a source-derived Raws subfolder (canonical `/raws/<slug>`).
19145
- folder_path: normalizeFolderPath(String(displayName || "")
19146
- .toLowerCase()
19147
- .replace(/\.[a-z0-9]+$/i, "")
19148
- .replace(/[^a-z0-9]+/g, "-")
19149
- .replace(/^-+|-+$/g, "")
19150
- .slice(0, 60)),
20218
+ // A caller-named folder wins; otherwise default into a source-derived Raws
20219
+ // subfolder (canonical `/raws/<slug>`).
20220
+ folder_path: input.folderPath
20221
+ ? normalizeFolderPath(input.folderPath)
20222
+ : normalizeFolderPath(String(displayName || "")
20223
+ .toLowerCase()
20224
+ .replace(/\.[a-z0-9]+$/i, "")
20225
+ .replace(/[^a-z0-9]+/g, "-")
20226
+ .replace(/^-+|-+$/g, "")
20227
+ .slice(0, 60)),
19151
20228
  owner_id: input.ownerId,
19152
20229
  created_at: nowIso()
19153
20230
  };
@@ -19183,6 +20260,373 @@ async function importWholeVideoAsRaw(input) {
19183
20260
  rmSync(workRoot, { recursive: true, force: true });
19184
20261
  }
19185
20262
  }
20263
+ // ── /tools/clipper support: resolve a preview URL + save an exact subrange ─────
20264
+ // Resolve a pasted source URL to a DIRECT, playable media URL (plus a human
20265
+ // title / poster) WITHOUT downloading or re-hosting it — the clipper page plays
20266
+ // this URL in a <video> so the user can pick a subrange, and only the chosen
20267
+ // subrange is ever written to our S3. Direct .mp4/.mov links pass through as-is;
20268
+ // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI downloader
20269
+ // the video_download primitive uses. This is a lightweight metadata lookup — the
20270
+ // billed download/trim happens later in importSubrangeAsRaw when a clip is saved.
20271
+ async function resolveSourcePreview(sourceUrl) {
20272
+ const url = (sourceUrl || "").trim();
20273
+ if (!url)
20274
+ throw new Error("A source video URL is required.");
20275
+ if (looksLikeDirectVideoUrl(url)) {
20276
+ const name = (url.split("/").pop() || "").split("?")[0] || null;
20277
+ return { mediaUrl: url, title: name, thumbnail: null, durationSec: null };
20278
+ }
20279
+ const apiKey = (config.RAPIDAPI_KEY || "").trim();
20280
+ if (!apiKey) {
20281
+ throw new Error("Can't resolve that URL for preview (RAPIDAPI_KEY unset) — paste a direct .mp4 URL instead.");
20282
+ }
20283
+ const lookupRes = await fetch(config.RAPIDAPI_VIDEO_DOWNLOAD_URL, {
20284
+ method: "POST",
20285
+ headers: {
20286
+ "x-rapidapi-key": apiKey,
20287
+ "x-rapidapi-host": config.RAPIDAPI_VIDEO_DOWNLOAD_HOST,
20288
+ "content-type": "application/x-www-form-urlencoded"
20289
+ },
20290
+ body: new URLSearchParams({ url })
20291
+ });
20292
+ if (!lookupRes.ok)
20293
+ throw new Error(`Video lookup failed with HTTP ${lookupRes.status}.`);
20294
+ const lookup = (await lookupRes.json());
20295
+ const chosen = pickBestVideoMedia(lookup.medias ?? []);
20296
+ if (!chosen?.url)
20297
+ throw new Error("That URL returned no playable MP4 to preview.");
20298
+ const durationRaw = typeof lookup.duration === "number" ? lookup.duration : Number(lookup.duration);
20299
+ return {
20300
+ mediaUrl: chosen.url,
20301
+ title: typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null,
20302
+ thumbnail: typeof lookup.thumbnail === "string" && lookup.thumbnail.trim() ? lookup.thumbnail.trim() : null,
20303
+ durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : null
20304
+ };
20305
+ }
20306
+ // Stream a direct media URL to a local file (no RapidAPI). Used when the clipper
20307
+ // hands back an already-resolved preview_url so we don't re-resolve it.
20308
+ async function downloadDirectToFile(mediaUrl, destPath) {
20309
+ mkdirSync(path.dirname(destPath), { recursive: true });
20310
+ const res = await fetch(mediaUrl);
20311
+ if (!res.ok || !res.body)
20312
+ throw new Error(`Source download failed with HTTP ${res.status}.`);
20313
+ await streamPipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
20314
+ }
20315
+ // Trim EXACTLY [startSec, endSec] out of a source video and save that single
20316
+ // subrange as one raw — the /tools/clipper equivalent of importWholeVideoAsRaw,
20317
+ // but frame-trimmed instead of whole. The whole source is fetched once to a
20318
+ // scratch dir, ffmpeg cuts the range, and only the trimmed mp4 (+ its thumbnail)
20319
+ // is written to durable storage. Runs inline; returns the finished clip so the
20320
+ // caller can surface it immediately.
20321
+ async function importSubrangeAsRaw(input) {
20322
+ const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
20323
+ const touchScan = async (patch) => {
20324
+ const scan = await clipRecords.getScan(input.ownerId, input.scanId);
20325
+ if (scan)
20326
+ await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
20327
+ };
20328
+ const touchSource = async (patch) => {
20329
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20330
+ if (source)
20331
+ await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
20332
+ };
20333
+ try {
20334
+ mkdirSync(workRoot, { recursive: true });
20335
+ let videoPath = input.localSourcePath && existsSync(input.localSourcePath) ? input.localSourcePath : null;
20336
+ let ingestedTitle = null;
20337
+ if (!videoPath && input.mediaUrl) {
20338
+ videoPath = path.join(workRoot, "ingest", "source.mp4");
20339
+ await downloadDirectToFile(input.mediaUrl, videoPath);
20340
+ }
20341
+ else if (!videoPath) {
20342
+ const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
20343
+ videoPath = ingested.videoPath;
20344
+ ingestedTitle = ingested.title;
20345
+ await recordRapidApiIngestCost({ ownerId: input.ownerId, scanId: input.scanId, tracer: input.tracer, sourceUrl: input.sourceUrl ?? "" });
20346
+ }
20347
+ const probe = await probeVideo(videoPath);
20348
+ const sourceDurationSec = probe.duration_sec || 0;
20349
+ // Clamp the requested range into the real source duration and enforce end>start.
20350
+ const start = Math.max(0, sourceDurationSec > 0 ? Math.min(input.startSec, sourceDurationSec - 0.05) : input.startSec);
20351
+ const rawEnd = sourceDurationSec > 0 ? Math.min(input.endSec, sourceDurationSec) : input.endSec;
20352
+ const end = Math.max(start + 0.05, rawEnd);
20353
+ const clipDurationSec = Number((end - start).toFixed(3));
20354
+ await touchSource({ status: "scanning", duration_sec: sourceDurationSec });
20355
+ const clipId = createIdV7("clip");
20356
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20357
+ const localClip = path.join(workRoot, `${clipId}.mp4`);
20358
+ const trimmed = await extractClip({
20359
+ videoPath,
20360
+ scene: { index: 0, start_time_sec: start, end_time_sec: end, duration_sec: clipDurationSec },
20361
+ outPath: localClip
20362
+ });
20363
+ if (!trimmed || !existsSync(localClip))
20364
+ throw new Error("Could not trim that subrange.");
20365
+ await storage.putFile(clipKey, localClip, "video/mp4");
20366
+ let thumbKey = "";
20367
+ const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
20368
+ const wroteThumb = await extractThumbnail({
20369
+ videoPath: localClip,
20370
+ scene: { index: 0, start_time_sec: 0, end_time_sec: clipDurationSec, duration_sec: clipDurationSec },
20371
+ outPath: thumbLocal
20372
+ }).catch(() => false);
20373
+ if (wroteThumb && existsSync(thumbLocal)) {
20374
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20375
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20376
+ }
20377
+ const baseName = (input.name || ingestedTitle || input.filename || "clip").trim() || "clip";
20378
+ const description = `${baseName} (${formatClockRange(start)}–${formatClockRange(end)})`;
20379
+ const folderPath = input.folderPath
20380
+ ? normalizeFolderPath(input.folderPath)
20381
+ : normalizeFolderPath(String(baseName).toLowerCase().replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60));
20382
+ const clip = {
20383
+ clip_id: clipId,
20384
+ source_video_id: input.sourceVideoId,
20385
+ source_filename: baseName,
20386
+ start_time_sec: 0,
20387
+ end_time_sec: clipDurationSec,
20388
+ duration_sec: clipDurationSec,
20389
+ file_path: clipKey,
20390
+ thumbnail_path: thumbKey,
20391
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20392
+ description,
20393
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20394
+ tier: "flash-lite",
20395
+ tracer: input.tracer,
20396
+ folder_path: folderPath,
20397
+ owner_id: input.ownerId,
20398
+ created_at: nowIso()
20399
+ };
20400
+ try {
20401
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20402
+ if (embedder?.embeddingModelId) {
20403
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20404
+ if (vector?.length) {
20405
+ clip.embedding = vector;
20406
+ clip.embedding_model = embedder.embeddingModelId;
20407
+ }
20408
+ }
20409
+ }
20410
+ catch (error) {
20411
+ devLog("clip_range.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
20412
+ }
20413
+ await clipRecords.putClip(clip);
20414
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20415
+ await touchSource({ status: "complete", clip_count: 1, filename: baseName });
20416
+ await touchScan({ status: "complete" });
20417
+ return { clipId, clipKey, thumbKey, durationSec: clipDurationSec, sourceStartSec: start, sourceEndSec: end, description, folderPath };
20418
+ }
20419
+ catch (error) {
20420
+ devLog("clip_range.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
20421
+ await touchSource({ status: "failed" }).catch(() => undefined);
20422
+ await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
20423
+ throw error;
20424
+ }
20425
+ finally {
20426
+ rmSync(workRoot, { recursive: true, force: true });
20427
+ }
20428
+ }
20429
+ // Compact H:MM:SS clock for a clip's human description/range label.
20430
+ function formatClockRange(sec) {
20431
+ const s = Math.max(0, Math.floor(sec));
20432
+ const h = Math.floor(s / 3600);
20433
+ const m = Math.floor((s % 3600) / 60);
20434
+ const ss = s % 60;
20435
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
20436
+ return `${h > 0 ? h + ":" : ""}${mm}:${String(ss).padStart(2, "0")}`;
20437
+ }
20438
+ // POST /raws/resolve-source — resolve a pasted URL to a direct preview media URL
20439
+ // (no download, no S3 write). The /tools/clipper page calls this on load so it
20440
+ // can play the source and let the user pick a subrange before anything is saved.
20441
+ app.post(`${RAWS_PREFIX}/resolve-source`, async (c) => {
20442
+ const customer = requireCustomer(c);
20443
+ try {
20444
+ const body = (await c.req.json().catch(() => ({})));
20445
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20446
+ if (!sourceUrl)
20447
+ return c.json({ error: "Provide source_url (a video URL)." }, 400);
20448
+ if (!/^https?:\/\//i.test(sourceUrl))
20449
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20450
+ void customer;
20451
+ const resolved = await resolveSourcePreview(sourceUrl);
20452
+ return c.json({
20453
+ source_url: sourceUrl,
20454
+ preview_url: resolved.mediaUrl,
20455
+ is_direct: looksLikeDirectVideoUrl(sourceUrl),
20456
+ title: resolved.title,
20457
+ duration_sec: resolved.durationSec,
20458
+ thumbnail_url: resolved.thumbnail
20459
+ });
20460
+ }
20461
+ catch (error) {
20462
+ return c.json({ error: error instanceof Error ? error.message : "Could not resolve that video URL." }, 502);
20463
+ }
20464
+ });
20465
+ // POST /raws/clip-range — trim EXACTLY [start_sec, end_sec] from a source and
20466
+ // save that single subrange as one raw. Reusable by the web clipper (which sends
20467
+ // the already-resolved preview_url), the devcli `clipper` command, and the AI
20468
+ // editor-chat. Runs inline and returns the finished clip.
20469
+ app.post(`${RAWS_PREFIX}/clip-range`, async (c) => {
20470
+ const customer = requireCustomer(c);
20471
+ try {
20472
+ const body = (await c.req.json().catch(() => ({})));
20473
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20474
+ const mediaUrl = readNonEmptyString(body.preview_url) ?? readNonEmptyString(body.media_url);
20475
+ // Resolve an uploaded source (temp file / My Files attachment) to a local key.
20476
+ let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
20477
+ const tempFileId = readNonEmptyString(body.temp_file_id);
20478
+ const attachmentId = readNonEmptyString(body.attachment_id);
20479
+ if (!resolvedS3Key && tempFileId) {
20480
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, tempFileId);
20481
+ if (!file)
20482
+ return c.json({ error: "Temporary file not found." }, 404);
20483
+ resolvedS3Key = file.storageKey;
20484
+ }
20485
+ else if (!resolvedS3Key && attachmentId) {
20486
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
20487
+ if (!attachment)
20488
+ return c.json({ error: "Attachment not found in My Files." }, 404);
20489
+ resolvedS3Key = attachment.storageKey;
20490
+ }
20491
+ const localSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
20492
+ if (!sourceUrl && !mediaUrl && !(localSourcePath && existsSync(localSourcePath))) {
20493
+ return c.json({ error: "Provide source_url / preview_url (a video URL) or temp_file_id/attachment_id/s3_key of an upload." }, 400);
20494
+ }
20495
+ if ((sourceUrl && !/^https?:\/\//i.test(sourceUrl)) || (mediaUrl && !/^https?:\/\//i.test(mediaUrl))) {
20496
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20497
+ }
20498
+ // Accept seconds or milliseconds; require a positive-length range.
20499
+ const startSec = body.start_sec != null ? Number(body.start_sec) : body.start_ms != null ? Number(body.start_ms) / 1000 : 0;
20500
+ const endSec = body.end_sec != null ? Number(body.end_sec) : body.end_ms != null ? Number(body.end_ms) / 1000 : NaN;
20501
+ if (!Number.isFinite(startSec) || startSec < 0)
20502
+ return c.json({ error: "start_sec must be a non-negative number." }, 400);
20503
+ if (!Number.isFinite(endSec))
20504
+ return c.json({ error: "Provide end_sec (or duration via end_ms)." }, 400);
20505
+ if (endSec <= startSec)
20506
+ return c.json({ error: "end_sec must be greater than start_sec." }, 400);
20507
+ const tracer = readNonEmptyString(body.tracer) ?? `clipper:${customer.id}`;
20508
+ const folderOverride = slugifyRawFolder(body.folder_path) ?? slugifyRawFolder(tracer);
20509
+ const now = nowIso();
20510
+ const scanId = createIdV7("clipscan");
20511
+ const sourceVideoId = createIdV7("srcvid");
20512
+ const sourcePointer = mediaUrl ?? sourceUrl ?? resolvedS3Key ?? "";
20513
+ const filename = readNonEmptyString(body.filename) ?? readNonEmptyString(body.name) ?? ((sourcePointer.split("/").pop() || "source.mp4").split("?")[0] || "source.mp4");
20514
+ 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 });
20515
+ 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 });
20516
+ let result;
20517
+ try {
20518
+ result = await importSubrangeAsRaw({
20519
+ ownerId: customer.id,
20520
+ scanId,
20521
+ sourceVideoId,
20522
+ filename,
20523
+ tracer,
20524
+ folderPath: folderOverride,
20525
+ localSourcePath: localSourcePath && existsSync(localSourcePath) ? localSourcePath : null,
20526
+ mediaUrl: mediaUrl ?? null,
20527
+ sourceUrl: sourceUrl ?? null,
20528
+ startSec,
20529
+ endSec,
20530
+ name: readNonEmptyString(body.name) ?? null
20531
+ });
20532
+ }
20533
+ catch (error) {
20534
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 502);
20535
+ }
20536
+ const [viewUrl, thumbnailUrl] = await Promise.all([
20537
+ clipMediaUrl(result.clipKey),
20538
+ result.thumbKey ? clipMediaUrl(result.thumbKey) : Promise.resolve(null)
20539
+ ]);
20540
+ return c.json({
20541
+ clip_id: result.clipId,
20542
+ source_video_id: sourceVideoId,
20543
+ tracer,
20544
+ folder_path: result.folderPath,
20545
+ duration_sec: result.durationSec,
20546
+ source_start_sec: result.sourceStartSec,
20547
+ source_end_sec: result.sourceEndSec,
20548
+ description: result.description,
20549
+ view_url: viewUrl ?? `/clips/${result.clipId}/download`,
20550
+ thumbnail_url: thumbnailUrl
20551
+ });
20552
+ }
20553
+ catch (error) {
20554
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 500);
20555
+ }
20556
+ });
20557
+ // Store the full-length ORIGINAL video as a durable role:"source" raw for a
20558
+ // serve-box hunt — the same durable-original behavior the cloud finalize adds,
20559
+ // but reusing the local storage driver. The source is still on disk at finalize,
20560
+ // so it's copied straight in at its ORIGINAL aspect (no 9:16 crop). It lands in
20561
+ // the SAME source-slug folder as its mined clips so /library/raws groups them.
20562
+ // Best-effort; the caller ignores failures so the hunt still completes.
20563
+ async function storeSourceOriginalRawLocal(input) {
20564
+ // Dedupe: never mint a second source-original for this source (re-run safety).
20565
+ const existing = await clipRecords.listClips(input.ownerId, { sourceVideoId: input.sourceVideoId, limit: 5000 });
20566
+ if (existing.some((c) => c.role === "source"))
20567
+ return;
20568
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20569
+ const tracer = source?.tracer;
20570
+ const displayName = source?.filename || input.filename;
20571
+ const clipId = createIdV7("clip");
20572
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20573
+ await storage.putFile(clipKey, input.videoPath, "video/mp4");
20574
+ let thumbKey = "";
20575
+ const thumbLocal = path.join(input.workRoot, `source-${clipId}.jpg`);
20576
+ const wroteThumb = await extractThumbnail({
20577
+ videoPath: input.videoPath,
20578
+ scene: { index: 0, start_time_sec: 0, end_time_sec: input.durationSec, duration_sec: input.durationSec },
20579
+ outPath: thumbLocal
20580
+ }).catch(() => false);
20581
+ if (wroteThumb && existsSync(thumbLocal)) {
20582
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20583
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20584
+ }
20585
+ const clip = {
20586
+ clip_id: clipId,
20587
+ source_video_id: input.sourceVideoId,
20588
+ source_filename: input.filename,
20589
+ start_time_sec: 0,
20590
+ end_time_sec: input.durationSec,
20591
+ duration_sec: input.durationSec,
20592
+ file_path: clipKey,
20593
+ thumbnail_path: thumbKey,
20594
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20595
+ description: displayName,
20596
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20597
+ tier: "flash-lite",
20598
+ role: "source",
20599
+ ...(tracer ? { tracer } : {}),
20600
+ // Group with the mined clips: reuse the folder they actually landed in (a
20601
+ // caller-named destination or the source slug) so the "Full video" raw sits
20602
+ // in the same folder. Falls back to the source-title slug when no mined clip
20603
+ // recorded one.
20604
+ folder_path: existing.find((c) => c.folder_path)?.folder_path ?? normalizeFolderPath(String(input.filename || "")
20605
+ .toLowerCase()
20606
+ .replace(/\.[a-z0-9]+$/i, "")
20607
+ .replace(/[^a-z0-9]+/g, "-")
20608
+ .replace(/^-+|-+$/g, "")
20609
+ .slice(0, 60)),
20610
+ owner_id: input.ownerId,
20611
+ created_at: nowIso()
20612
+ };
20613
+ // Best-effort embedding so the full original is findable by meaning too.
20614
+ try {
20615
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20616
+ if (embedder?.embeddingModelId) {
20617
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20618
+ if (vector?.length) {
20619
+ clip.embedding = vector;
20620
+ clip.embedding_model = embedder.embeddingModelId;
20621
+ }
20622
+ }
20623
+ }
20624
+ catch (error) {
20625
+ devLog("clip_scan.source_original_embed_failed", { source_video_id: input.sourceVideoId, ...devErrorFields(error) }, "warn");
20626
+ }
20627
+ await clipRecords.putClip(clip);
20628
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20629
+ }
19186
20630
  // In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
19187
20631
  // Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
19188
20632
  // per-scene tag/embed → finalize — with local ffmpeg and the resolved
@@ -19236,6 +20680,21 @@ async function runClipScanInProcess(input) {
19236
20680
  // Local agents run one CLI process per call — keep parallelism modest.
19237
20681
  concurrency: input.client.provider === "local-agent" ? 2 : 3,
19238
20682
  onClip: async (clip) => {
20683
+ // Folder the mined clip: a caller-named destination (hunt_spec.folder_path,
20684
+ // e.g. the /editor "Clip from Raw" folder) wins; otherwise fall back to the
20685
+ // source-title slug so the local library groups by source like the cloud
20686
+ // pipeline does. Legacy clips without either stay unfoldered.
20687
+ if (!clip.folder_path) {
20688
+ const sourceSlug = String(input.filename || "")
20689
+ .toLowerCase()
20690
+ .replace(/\.[a-z0-9]+$/i, "")
20691
+ .replace(/[^a-z0-9]+/g, "-")
20692
+ .replace(/^-+|-+$/g, "")
20693
+ .slice(0, 60);
20694
+ const folder = spec.folder_path || sourceSlug;
20695
+ if (folder)
20696
+ clip.folder_path = folder;
20697
+ }
19239
20698
  // Persist through the storage service (local driver) as bare keys —
19240
20699
  // clipMediaUrl resolves clips/… + thumbs/… keys to fetchable URLs.
19241
20700
  const clipKey = `clips/${input.ownerId}/${clip.clip_id}.mp4`;
@@ -19259,6 +20718,20 @@ async function runClipScanInProcess(input) {
19259
20718
  devLog("clip_scan.local_scene_failed", { scan_id: input.scanId, scene_start: scene.start_time_sec, ...devErrorFields(error) }, "warn");
19260
20719
  }
19261
20720
  });
20721
+ // Keep a durable full-length copy of the original as its own role:"source"
20722
+ // raw, grouped with the mined clips — only when the hunt produced clips.
20723
+ // Fail-soft: a failure here must NOT fail the hunt. clip_count stays the
20724
+ // mined count (the source-original is an extra reference raw).
20725
+ if (stored > 0) {
20726
+ await storeSourceOriginalRawLocal({
20727
+ ownerId: input.ownerId,
20728
+ sourceVideoId: input.sourceVideoId,
20729
+ filename: input.filename,
20730
+ videoPath,
20731
+ durationSec: probe.duration_sec,
20732
+ workRoot
20733
+ }).catch((error) => devLog("clip_scan.source_original_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn"));
20734
+ }
19262
20735
  await touchSource({ status: "complete", clip_count: stored });
19263
20736
  await touchScan({ status: "complete" });
19264
20737
  }
@@ -19358,7 +20831,11 @@ async function mirrorUpstreamClipsToLocal(input) {
19358
20831
  }
19359
20832
  await clipRecords.putClip(clip);
19360
20833
  await vectorIndex.upsert(ownerId, clip);
19361
- stored++;
20834
+ // The upstream feed includes the source-original raw (role:"source"); mirror
20835
+ // it down like any clip but keep it OUT of the mined-clip count so clip_count
20836
+ // stays consistent with the cloud/serve pipelines.
20837
+ if (clip.role !== "source")
20838
+ stored++;
19362
20839
  }
19363
20840
  const source = await clipRecords.getSource(ownerId, scan.source_video_id);
19364
20841
  if (source)