@mevdragon/vidfarm-devcli 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) 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 +77 -75
  8. package/dist/src/app.js +1513 -145
  9. package/dist/src/cli.js +1365 -105
  10. package/dist/src/config.js +7 -0
  11. package/dist/src/devcli/clips.js +3 -0
  12. package/dist/src/devcli/composition-edit.js +396 -8
  13. package/dist/src/devcli/local-backend.js +123 -0
  14. package/dist/src/devcli/migrate-local.js +140 -0
  15. package/dist/src/devcli/sync.js +311 -0
  16. package/dist/src/devcli/timeline-edit.js +208 -1
  17. package/dist/src/editor-chat.js +33 -17
  18. package/dist/src/frontend/file-directory.js +271 -20
  19. package/dist/src/frontend/homepage-view.js +3 -3
  20. package/dist/src/frontend/template-editor-chat.js +6 -3
  21. package/dist/src/page-shell.js +1 -1
  22. package/dist/src/primitive-registry.js +409 -4
  23. package/dist/src/reskin/chat-page.js +103 -15
  24. package/dist/src/reskin/discover-page.js +247 -10
  25. package/dist/src/reskin/document.js +147 -10
  26. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  27. package/dist/src/reskin/inpaint-page.js +2459 -452
  28. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  29. package/dist/src/reskin/library-page.js +324 -82
  30. package/dist/src/reskin/theme.js +36 -11
  31. package/dist/src/services/billing.js +4 -0
  32. package/dist/src/services/clip-curation/hunt.js +2 -0
  33. package/dist/src/services/clip-records.js +28 -0
  34. package/dist/src/services/file-directory.js +6 -3
  35. package/dist/src/services/hyperframes.js +283 -3
  36. package/dist/src/services/serverless-records.js +43 -0
  37. package/dist/src/services/storage.js +24 -2
  38. package/package.json +1 -1
  39. package/public/assets/file-directory-app.js +2 -2
  40. package/public/assets/homepage-client-app.js +1 -1
  41. package/public/assets/page-runtime-client-app.js +2 -2
  42. package/public/assets/placeholders/scene-placeholder.png +0 -0
package/dist/src/app.js CHANGED
@@ -31,6 +31,8 @@ import { renderReskinDiscover } from "./reskin/discover-page.js";
31
31
  import { renderReskinChat } from "./reskin/chat-page.js";
32
32
  import { renderReskinCalendar } from "./reskin/calendar-page.js";
33
33
  import { renderReskinInpaint } from "./reskin/inpaint-page.js";
34
+ import { renderReskinInpaintVideo } from "./reskin/inpaint-video-page.js";
35
+ import { renderReskinInpaintClipper } from "./reskin/inpaint-clipper-page.js";
34
36
  import { renderReskinAgency } from "./reskin/agency-page.js";
35
37
  import { renderReskinPricing } from "./reskin/pricing-page.js";
36
38
  import { renderReskinLogin } from "./reskin/login-page.js";
@@ -62,7 +64,7 @@ import { RateLimitExceededError, RateLimitService } from "./services/rate-limits
62
64
  import { ServerlessProviderKeyService } from "./services/serverless-provider-keys.js";
63
65
  import { ServerlessTemplateConfigService } from "./services/serverless-template-configs.js";
64
66
  import { parseHTML } from "linkedom";
65
- import { joinStorageKey, StorageService } from "./services/storage.js";
67
+ import { joinStorageKey, storage as sharedStorage } from "./services/storage.js";
66
68
  import { buildCaptionCues, cuesFromText } from "./services/captions.js";
67
69
  import { defaultSpeechModelFor as speechModelFor, transcribeSpeechWithKey } from "./services/speech.js";
68
70
  import { insertCaptionLayers } from "./devcli/composition-edit.js";
@@ -71,12 +73,12 @@ import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws
71
73
  import { clipRecords, makeUserPreset } from "./services/clip-records.js";
72
74
  import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
73
75
  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";
76
+ import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractClip, extractThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
77
+ import { analyzeCastMembers, annotateScenesFromSource, buildBlankCompositionHtml, buildSmartDecomposedHtml, DEFAULT_EDITOR_HARNESS, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
76
78
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
77
79
  import { createPrimitiveJobContext } from "./primitive-context.js";
78
80
  import { buildPendingCastState, castMemberFromRaw, extractCastStill, isolateCastMember } from "./services/cast.js";
79
- import { MediaDurationExceededError, probeMediaAsset } from "./services/media-processing.js";
81
+ import { MediaDurationExceededError, extractVideoFrameAsset, probeMediaAsset } from "./services/media-processing.js";
80
82
  import { estimateGhostcutCostUsd, isGhostcutConfigured, mirrorGhostcutVideoToStorage, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
81
83
  import { hasUpstreamKey, isUpstreamEnabled, proxyRequestToUpstream, seedFromUpstream, upstreamHost, upstreamJson } from "./services/upstream.js";
82
84
  import { TemplateSourceService } from "./services/template-sources.js";
@@ -92,7 +94,7 @@ const providers = new ProviderService();
92
94
  const rateLimits = new RateLimitService();
93
95
  const serverlessProviderKeys = new ServerlessProviderKeyService();
94
96
  const serverlessTemplateConfigs = new ServerlessTemplateConfigService();
95
- const storage = new StorageService();
97
+ const storage = sharedStorage;
96
98
  const templateSources = new TemplateSourceService();
97
99
  const hyperframes = new HyperframesService();
98
100
  const API_PREFIX = "/api/v1";
@@ -1627,6 +1629,12 @@ function primitiveOperationPathForJob(job) {
1627
1629
  if (job.templateId === "primitive:image_remove_background" && job.operationName === "run") {
1628
1630
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
1629
1631
  }
1632
+ if (job.templateId === "primitive:image_remove_background_greenscreen" && job.operationName === "run") {
1633
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
1634
+ }
1635
+ if (job.templateId === "primitive:media_overlay" && job.operationName === "run") {
1636
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
1637
+ }
1630
1638
  if (job.templateId === "primitive:video_generate" && job.operationName === "run") {
1631
1639
  return `${PRIMITIVES_PREFIX}/videos/generate`;
1632
1640
  }
@@ -2951,7 +2959,50 @@ function buildUserAttachmentViewUrl(c, storageKey) {
2951
2959
  function buildUserFileUrl(c, storageKey) {
2952
2960
  return storage.getPublicUrl(storageKey) ?? buildAbsoluteUrl(c, `/template-media?key=${encodeURIComponent(storageKey)}`);
2953
2961
  }
2962
+ // Ensure a durable poster image URL for a My Files attachment so the file
2963
+ // directory can render a snappy thumbnail. Policy per media type:
2964
+ // image/* → the image IS its own poster (return its own view URL, no re-encode
2965
+ // — cheapest + snappiest).
2966
+ // video/* → extract a frame (~1000ms in, fall back to 0), store the JPEG under
2967
+ // thumbs/attachments/<customer>/<id>.jpg and return that key + url.
2968
+ // audio / pdf / text / everything else → null; the frontend shows a type icon.
2969
+ // BEST-EFFORT / fail-soft: a failed extraction NEVER fails the upload — it just
2970
+ // returns null and logs (mirrors importWholeVideoAsRaw's `.catch(() => false)`).
2971
+ async function ensureAttachmentThumbnail(input) {
2972
+ const type = (input.contentType || "").toLowerCase();
2973
+ if (type.startsWith("image/")) {
2974
+ // An image is its own poster — never re-encode.
2975
+ const url = input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2976
+ return url ? { thumbnailStorageKey: null, thumbnailUrl: url } : null;
2977
+ }
2978
+ if (!type.startsWith("video/")) {
2979
+ // Audio, PDF, text, etc. — the file directory already renders a type icon.
2980
+ return null;
2981
+ }
2982
+ // extractVideoFrameAsset downloads a URL, so point it at the just-stored
2983
+ // object's own public URL when no explicit source URL is supplied.
2984
+ const frameSource = input.sourceUrl || input.attachmentUrl || storage.getPublicUrl(input.storageKey);
2985
+ if (!frameSource)
2986
+ return null;
2987
+ try {
2988
+ const frame = await extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 1000, format: "jpeg" })
2989
+ // Very short clips have no frame 1s in — fall back to the very first frame.
2990
+ .catch(() => extractVideoFrameAsset({ sourceUrl: frameSource, atMs: 0, format: "jpeg" }));
2991
+ const thumbKey = joinStorageKey("thumbs", "attachments", input.customerId, `${input.attachmentId}.jpg`);
2992
+ const stored = await storage.putBuffer(thumbKey, frame.bytes, "image/jpeg");
2993
+ const thumbnailUrl = stored.url || storage.getPublicUrl(thumbKey);
2994
+ return { thumbnailStorageKey: thumbKey, thumbnailUrl: thumbnailUrl ?? null };
2995
+ }
2996
+ catch (error) {
2997
+ devLog("attachment.thumbnail_failed", { attachment_id: input.attachmentId, ...devErrorFields(error) }, "warn");
2998
+ return null;
2999
+ }
3000
+ }
2954
3001
  function serializeUserAttachment(c, attachment) {
3002
+ // Every image poster falls back to the file's own view URL so image entries
3003
+ // always render a thumbnail even if the record predates thumbnail wiring.
3004
+ const viewUrl = attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey);
3005
+ const isImage = (attachment.contentType || "").toLowerCase().startsWith("image/");
2955
3006
  return {
2956
3007
  id: attachment.id,
2957
3008
  fileName: attachment.fileName,
@@ -2962,7 +3013,8 @@ function serializeUserAttachment(c, attachment) {
2962
3013
  notes: attachment.notes ?? null,
2963
3014
  // Flag only — the raw vector never ships to the client (mirrors serializeClip).
2964
3015
  hasNotesEmbedding: Boolean(attachment.notesEmbedding && attachment.notesEmbedding.length > 0),
2965
- viewUrl: attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey)
3016
+ viewUrl,
3017
+ thumbnailUrl: attachment.thumbnailUrl || (isImage ? viewUrl : null)
2966
3018
  };
2967
3019
  }
2968
3020
  function serializeUserTemporaryFile(c, file) {
@@ -2996,6 +3048,7 @@ function attachmentToDirectoryItem(c, a) {
2996
3048
  id: a.id,
2997
3049
  contentType: a.contentType,
2998
3050
  viewUrl: s.viewUrl,
3051
+ thumbUrl: s.thumbnailUrl ?? undefined,
2999
3052
  sizeBytes: a.sizeBytes,
3000
3053
  meta: { notes: s.notes, hasNotesEmbedding: s.hasNotesEmbedding, createdAt: a.createdAt }
3001
3054
  };
@@ -3035,10 +3088,160 @@ function clipSerializedToDirectoryItem(s) {
3035
3088
  meta: { description: s.description, tags: s.tags, created_at: s.created_at, source_video_id: s.source_video_id }
3036
3089
  };
3037
3090
  }
3091
+ // An approved ready-post as a canonical directory file. view/thumb come from the
3092
+ // post's media assets (video for playback, an image asset for the poster).
3093
+ function readyPostToDirectoryItem(post) {
3094
+ const folderPath = normalizeFolderPath(post.folderPath ?? "");
3095
+ const name = (post.title && post.title.trim()) || `Post ${post.id}`;
3096
+ const media = post.mediaAssets || [];
3097
+ const video = media.find((a) => a.kind === "video");
3098
+ const image = media.find((a) => a.kind === "image");
3099
+ const primary = media.find((a) => a.role === "primary") || video || media[0] || null;
3100
+ return {
3101
+ path: buildDirectoryPath("approved", folderPath, name),
3102
+ root: "approved",
3103
+ folderPath,
3104
+ name,
3105
+ kind: "file",
3106
+ id: post.id,
3107
+ contentType: video ? "video/mp4" : (primary?.contentType || "application/octet-stream"),
3108
+ viewUrl: (primary?.url || video?.url) || undefined,
3109
+ thumbUrl: image?.url || undefined,
3110
+ meta: { tracer: post.tracer, status: post.status, caption: post.caption, created_at: post.createdAt }
3111
+ };
3112
+ }
3113
+ // Infer a MIME type from a fork file's name so the explorer can preview images /
3114
+ // video / audio and the agent can tell text (composition.html/json) from binary.
3115
+ function inferProjectFileContentType(name) {
3116
+ const ext = (name.split(".").pop() || "").toLowerCase();
3117
+ switch (ext) {
3118
+ case "html":
3119
+ case "htm": return "text/html; charset=utf-8";
3120
+ case "json": return "application/json";
3121
+ case "txt": return "text/plain; charset=utf-8";
3122
+ case "md": return "text/markdown; charset=utf-8";
3123
+ case "css": return "text/css; charset=utf-8";
3124
+ case "js":
3125
+ case "mjs": return "text/javascript; charset=utf-8";
3126
+ case "srt": return "application/x-subrip";
3127
+ case "vtt": return "text/vtt";
3128
+ case "png": return "image/png";
3129
+ case "jpg":
3130
+ case "jpeg": return "image/jpeg";
3131
+ case "gif": return "image/gif";
3132
+ case "webp": return "image/webp";
3133
+ case "svg": return "image/svg+xml";
3134
+ case "mp4":
3135
+ case "m4v": return "video/mp4";
3136
+ case "webm": return "video/webm";
3137
+ case "mov": return "video/quicktime";
3138
+ case "mp3": return "audio/mpeg";
3139
+ case "wav": return "audio/wav";
3140
+ case "m4a": return "audio/mp4";
3141
+ default: return "application/octet-stream";
3142
+ }
3143
+ }
3144
+ // A fork (composition-editing project) as a top-level /projects folder. The
3145
+ // folder path segment is the STABLE fork id (so paths round-trip); the display
3146
+ // name prefers a human title (the fork's own, else its template's) so the tile
3147
+ // is recognizable.
3148
+ function projectForkToFolderItem(fork, displayName) {
3149
+ const versions = fork.latestVersion ?? 0;
3150
+ return {
3151
+ path: buildDirectoryPath("projects", fork.id),
3152
+ root: "projects",
3153
+ folderPath: fork.id,
3154
+ name: displayName,
3155
+ kind: "folder",
3156
+ meta: {
3157
+ description: `video project · ${versions ? `v${versions}` : "unsaved"}`,
3158
+ forkId: fork.id,
3159
+ templateId: fork.templateId,
3160
+ updated_at: fork.updatedAt,
3161
+ latest_version: versions
3162
+ }
3163
+ };
3164
+ }
3165
+ // One object under a fork's storage root as a canonical directory file. `name`
3166
+ // is the trailing filename; `key` is the physical S3 key — it lives under the
3167
+ // public `compositions/forks/` prefix, so getPublicUrl yields a directly
3168
+ // fetchable view URL (and the composition.html/json are also readable through
3169
+ // the existing GET /api/v1/compositions/:forkId/* routes).
3170
+ function forkStorageFileToDirectoryItem(folderPath, name, key) {
3171
+ return {
3172
+ path: buildDirectoryPath("projects", folderPath, name),
3173
+ root: "projects",
3174
+ folderPath,
3175
+ name,
3176
+ kind: "file",
3177
+ id: key,
3178
+ contentType: inferProjectFileContentType(name),
3179
+ viewUrl: storage.getPublicUrl(key) || undefined,
3180
+ meta: { storageKey: key }
3181
+ };
3182
+ }
3183
+ // List one folder of the /projects root. At depth 0 (folderPath "") every fork
3184
+ // the customer owns is a folder; deeper, the fork's S3 storage tree (working/ +
3185
+ // versions/…) is presented as a generic prefix explorer, so any composition file
3186
+ // is a real, copyable, agent-addressable path like
3187
+ // /projects/<forkId>/working/composition.html. Read-only.
3188
+ async function listProjectsFolder(customerId, cur) {
3189
+ const segments = cur ? cur.split("/") : [];
3190
+ if (segments.length === 0) {
3191
+ const forks = (await serverlessRecords.listCompositionForksForCustomer(customerId))
3192
+ .filter((f) => !f.deletedAt);
3193
+ forks.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
3194
+ // One template lookup, mapped, to give forks recognizable names.
3195
+ const templates = await serverlessRecords.listTemplatesForCustomer(customerId).catch(() => []);
3196
+ const titleByTemplate = new Map();
3197
+ for (const t of templates) {
3198
+ if (t.title && t.title.trim())
3199
+ titleByTemplate.set(t.id, t.title.trim());
3200
+ }
3201
+ const folders = forks.map((fork) => {
3202
+ const name = (fork.title && fork.title.trim())
3203
+ || titleByTemplate.get(fork.templateId)
3204
+ || `Project ${fork.id.slice(-6)}`;
3205
+ return projectForkToFolderItem(fork, name);
3206
+ });
3207
+ return { folders, files: [] };
3208
+ }
3209
+ const forkId = segments[0];
3210
+ // Browse is scoped to the owner: only the customer's own forks are listable.
3211
+ const fork = await serverlessRecords.getCompositionFork(forkId);
3212
+ if (!fork || fork.customerId !== customerId || fork.deletedAt) {
3213
+ return { folders: [], files: [] };
3214
+ }
3215
+ const rootPrefix = storage.compositionForkRoot(forkId) + "/"; // compositions/forks/<id>/
3216
+ const keys = await storage.listKeys(rootPrefix);
3217
+ const subPath = segments.slice(1).join("/");
3218
+ const subPrefix = subPath ? subPath + "/" : "";
3219
+ const childFolders = new Set();
3220
+ const files = [];
3221
+ for (const key of keys) {
3222
+ if (key.indexOf(rootPrefix) !== 0)
3223
+ continue;
3224
+ const rel = key.slice(rootPrefix.length);
3225
+ if (!rel || (subPrefix && rel.indexOf(subPrefix) !== 0))
3226
+ continue;
3227
+ const remainder = rel.slice(subPrefix.length);
3228
+ if (!remainder)
3229
+ continue;
3230
+ const slash = remainder.indexOf("/");
3231
+ if (slash >= 0)
3232
+ childFolders.add(remainder.slice(0, slash));
3233
+ else
3234
+ files.push(forkStorageFileToDirectoryItem(cur, remainder, key));
3235
+ }
3236
+ const folders = Array.from(childFolders).sort().map((n) => folderItem("projects", cur, n));
3237
+ files.sort((a, b) => a.name.localeCompare(b.name));
3238
+ return { folders, files };
3239
+ }
3038
3240
  /**
3039
3241
  * List one folder of one directory root, returning immediate child folders + the
3040
3242
  * files directly in this folder as canonical DirectoryItems. The single place
3041
- * that dispatches a canonical path to its backend (attachments / temp / raws).
3243
+ * that dispatches a canonical path to its backend (attachments / temp / raws /
3244
+ * projects / approved).
3042
3245
  */
3043
3246
  async function listDirectoryFolder(c, customerId, root, folderPath) {
3044
3247
  const cur = normalizeFolderPath(folderPath);
@@ -3060,17 +3263,31 @@ async function listDirectoryFolder(c, customerId, root, folderPath) {
3060
3263
  .map((f) => temporaryFileToDirectoryItem(c, f));
3061
3264
  return { folders: childNames.map((n) => folderItem("temp", cur, n)), files };
3062
3265
  }
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 };
3266
+ if (root === "raws") {
3267
+ const clips = await clipRecords.listClips(customerId, { limit: 5000 });
3268
+ const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
3269
+ const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
3270
+ const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
3271
+ const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
3272
+ return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
3273
+ }
3274
+ if (root === "projects") {
3275
+ return listProjectsFolder(customerId, cur);
3276
+ }
3277
+ // approved — finished ready-to-post cuts, foldered by folder_path.
3278
+ const posts = (await serverlessRecords.listReadyPosts(customerId)).filter((p) => p.status !== "deleted");
3279
+ const explicitApprovedFolders = await serverlessRecords.listUserFileFolders(customerId, "approved");
3280
+ const childNames = immediateChildFolders([...explicitApprovedFolders, ...posts.map((p) => p.folderPath ?? "")], cur);
3281
+ const here = posts.filter((p) => normalizeFolderPath(p.folderPath ?? "") === cur);
3282
+ const files = here.map(readyPostToDirectoryItem);
3283
+ return { folders: childNames.map((n) => folderItem("approved", cur, n)), files };
3070
3284
  }
3071
3285
  // The user_file_folder scope backing each directory root's empty folders.
3072
3286
  function directoryFolderScope(root) {
3073
- return root === "files" ? ATTACHMENTS_FOLDER_SCOPE : root === "temp" ? "temporary" : "raws";
3287
+ return root === "files" ? ATTACHMENTS_FOLDER_SCOPE
3288
+ : root === "temp" ? "temporary"
3289
+ : root === "approved" ? "approved"
3290
+ : "raws";
3074
3291
  }
3075
3292
  // The three roots presented as folders at the virtual root ("/").
3076
3293
  function directoryRootFolders() {
@@ -3259,7 +3476,9 @@ function buildPrimitiveEditorDocsRoutes() {
3259
3476
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/edit`, summary: "Primitive whole-image edit job. Body must be { tracer, payload: { source_image_url, instruction, reference_attachments?, aspect_ratio?, image_size?, output_format? }, webhook_url? }." },
3260
3477
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/inpaint`, summary: "Primitive masked image edit job. Body must be { tracer, payload: { source_image_url, mask_url, instruction, reference_attachments?, aspect_ratio?, image_size?, output_format? }, webhook_url? }." },
3261
3478
  { method: "POST", path: `${PRIMITIVES_PREFIX}/images/render-html`, summary: "Primitive HTML still-image render job. Body must be { tracer, payload: { html, css?, width?, height?, background_color?, output_format? }, webhook_url? }." },
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." },
3479
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background`, summary: "Primitive background-removal job. Returns a transparent-background PNG cut-out of the subject (subject isolation for overlays, product cut-outs, sprite cards, caption matting). Body must be { tracer, payload: { source_image_url }, webhook_url? }. AI matting for ARBITRARY photos with a messy background. BILLS THE WALLET (RapidAPI) — unlike the BYOK images/generate and images/edit routes. If the source already sits on a FLAT solid background, prefer the much cheaper images/remove-background-greenscreen." },
3480
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`, summary: "CHEAP local chroma-key background removal. Keys out a FLAT, solid background (green screen by default; pass key_color for white/blue/black/any flat fill) to a transparent PNG/WebP — sharp, no third-party API, compute-only wallet fee. Body must be { tracer, payload: { source_image_url, key_color? (default #00FF00), tolerance? (0..1, default 0.3), softness? (0..1, default 0.1), despill? (default true), output_format? (png|webp) }, webhook_url? }. Use for images you generated on a controlled solid background; for uncontrolled photos use images/remove-background. Flat alias: POST /api/v1/primitives/remove-background-greenscreen." },
3481
+ { method: "POST", path: `${PRIMITIVES_PREFIX}/images/create-overlay`, summary: "CREATE MEDIA OVERLAY (Vox-style) — the preferred one-shot way to mint a transparent floating illustration/prop/icon/character to animate over a composition. ONE job: generate an AI image of the subject on a forced flat key-color background (BYOK image keys) THEN chroma-key it out to a ready-to-composite transparent PNG/WebP. Body must be { tracer, payload: { prompt, provider?, model?, prompt_attachments?, aspect_ratio?, image_size?, key_color? (default #00FF00), tolerance? (default 0.3), softness? (default 0.1), despill? (default true), output_format? (png|webp) }, webhook_url? }. Result carries primary_file_url (the transparent overlay) plus greenscreen_source_url (the raw pre-key frame). BILLS THE WALLET (small platform fee); the image-gen leg additionally rides the caller's own provider key. Flat alias: POST /api/v1/primitives/create-media-overlay." },
3263
3482
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/download`, summary: "Primitive social/media video download job. Body must be { tracer, payload: { source_url|video_url|url, quality? }, webhook_url? }. Use this to turn supported page/post URLs into a durable Vidfarm-hosted MP4 before later template work." },
3264
3483
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/generate`, summary: "Primitive AI video generation job using saved OpenAI, Gemini, or OpenRouter provider keys. Body must be { tracer, payload: { prompt, provider?, model?, input_references?, frame_images?, aspect_ratio?, duration?, resolution?, generate_audio? }, webhook_url? }. input_references must be direct image asset URLs, not webpage or social post URLs. duration is seconds, not milliseconds; use duration: 4 for a 4-second AI video. Defaults: openai/sora-2, gemini/veo-3.0-generate-001, openrouter/bytedance/seedance-2.0. Use this for new AI-generated footage." },
3265
3484
  { method: "POST", path: `${PRIMITIVES_PREFIX}/videos/normalize`, summary: "Primitive video normalization job. Body must be { tracer, payload: { source_video_url, max_width?, max_height?, crf?, preset?, max_video_bitrate_kbps?, audio_bitrate_kbps? }, webhook_url? }." },
@@ -3987,10 +4206,18 @@ function createVideoContextTool(input) {
3987
4206
  return tool({
3988
4207
  description: "OPTIONAL context fetch: retrieve the current composition fork's decomposed video context — the source video's verbatim audio transcript (full text plus timestamped segments) and a literal visual description of every scene, along with viral DNA and per-scene transcript excerpts. Call it when the user asks what the source video says or shows, when writing or translating captions/hooks/dubs that must match the spoken audio, or when you need scene-by-scene grounding before planning timeline edits. Read-only and non-billing. The data comes from the smart-decompose snapshot; status=\"none\" means the fork was never smart-decomposed — offer to run POST /api/v1/compositions/:forkId/auto-decompose first. The same data is also available via GET /api/v1/compositions/:forkId/video-context.json through http_request.",
3989
4208
  inputSchema: z.object({
3990
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4209
+ fork_id: z.string().min(1).describe("Composition fork id — a fork_* id. Copy it verbatim from editor_context.fork_id in the most recent <editor_context>; never pass editor_context.composition_id (that is the template_* id, not a fork) and never invent one. If editor_context has no fork_id, this composition has no saved fork yet — do not call this tool; edit the composition first to mint a fork."),
3991
4210
  explanation: z.string().optional().describe("Short reason for fetching the video context.")
3992
4211
  }),
3993
4212
  execute: async ({ fork_id, explanation }) => {
4213
+ if (fork_id.startsWith("template_")) {
4214
+ return {
4215
+ explanation: explanation ?? null,
4216
+ status: 0,
4217
+ video_context: null,
4218
+ error: `"${fork_id}" is a template id (composition_id), not a fork id. Video context is keyed by fork — read editor_context.fork_id (a fork_* id) from the most recent <editor_context> and call again with that. If editor_context has no fork_id, this composition has no saved fork yet; make an edit first to mint one.`
4219
+ };
4220
+ }
3994
4221
  const url = normalizeEditorChatPath({
3995
4222
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/video-context.json`,
3996
4223
  template: input.template
@@ -4039,10 +4266,18 @@ function createProductPlacementContextTool(input) {
4039
4266
  return tool({
4040
4267
  description: "OPTIONAL context fetch: retrieve the current composition fork's product-placement surfaces — the product-AGNOSTIC slots found during decompose where any product could later be inserted natively (each with a timestamp, start seconds, scene_slug, surface_type, surface_note, best_for product categories, and why_it_works). Call it BEFORE running the brainstorm_product_placement primitive so you can seed it with real, grounded moments, and whenever the user asks where a product could be placed in this video. Read-only and non-billing. status=\"none\" means the fork was never decomposed (offer POST /api/v1/compositions/:forkId/auto-decompose); status=\"empty\" means decompose found no natural surface. Same data is available via GET /api/v1/compositions/:forkId/product-placement.json through http_request.",
4041
4268
  inputSchema: z.object({
4042
- fork_id: z.string().min(1).describe("Composition fork id. Copy it from the current editor_context; never invent one."),
4269
+ fork_id: z.string().min(1).describe("Composition fork id — a fork_* id. Copy it verbatim from editor_context.fork_id in the most recent <editor_context>; never pass editor_context.composition_id (that is the template_* id, not a fork) and never invent one. If editor_context has no fork_id, this composition has no saved fork yet — do not call this tool; edit the composition first to mint a fork."),
4043
4270
  explanation: z.string().optional().describe("Short reason for fetching the placement surfaces.")
4044
4271
  }),
4045
4272
  execute: async ({ fork_id, explanation }) => {
4273
+ if (fork_id.startsWith("template_")) {
4274
+ return {
4275
+ explanation: explanation ?? null,
4276
+ status: 0,
4277
+ product_placement: null,
4278
+ error: `"${fork_id}" is a template id (composition_id), not a fork id. Product-placement surfaces are keyed by fork — read editor_context.fork_id (a fork_* id) from the most recent <editor_context> and call again with that. If editor_context has no fork_id, this composition has no saved fork yet; make an edit first to mint one.`
4279
+ };
4280
+ }
4046
4281
  const url = normalizeEditorChatPath({
4047
4282
  path: `/api/v1/compositions/${encodeURIComponent(fork_id)}/product-placement.json`,
4048
4283
  template: input.template
@@ -4136,10 +4371,11 @@ function browseFilesTextContentType(fileName) {
4136
4371
  }
4137
4372
  function createBrowseFilesTool(input) {
4138
4373
  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.",
4374
+ 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
4375
  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."),
4376
+ action: z.enum(["list", "read", "write", "annotate", "search", "move", "rename"]).describe("list = enumerate a folder's subfolders + files (pass path for any root); read = fetch one file's view_url and, for text files, its contents; write = save a text file into /files; annotate = set the metadata notes on one existing /files entry; search = find files by meaning/name/path across roots; move = move a raw between /raws folders OR an approved post between /approved folders; rename = rename a file (pass file_id + its path + new_name; /files or /temp only) or a folder (pass the folder path + new_name, no file_id) in ANY root — /files, /temp, /raws, or /approved."),
4377
+ path: z.string().optional().describe("Canonical directory path to list or search: '/' for the four roots, '/raws', '/approved/summer-launch', or '/files/brand-assets' for a folder. For move, the target folder (e.g. /raws/demos or /approved/summer-launch). For rename, the current path of the file or folder to rename. Preferred over folder_path for cross-root navigation."),
4378
+ new_name: z.string().optional().describe("For rename: the new file name (e.g. character_about.md) or the new folder name (a single segment, no slashes)."),
4143
4379
  mode: z.enum(["auto", "semantic", "substring", "path"]).optional().describe("For search: which signals to combine. auto (default) = semantic vector + substring + absolute-path; or force one."),
4144
4380
  folder_path: z.string().optional().describe("Legacy /files-only scope for a list or search, the folder of the file to read, or the folder to write into. Omit or leave empty for the root. Prefer `path` for anything outside /files."),
4145
4381
  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 +4389,7 @@ function createBrowseFilesTool(input) {
4153
4389
  limit: z.number().int().optional().describe("For list/search: max items to return (list default 200, max 500; search default 30, max 50)."),
4154
4390
  explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
4155
4391
  }),
4156
- execute: async ({ action, path, mode, folder_path, file_id, file_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4392
+ execute: async ({ action, path, mode, folder_path, file_id, file_name, new_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
4157
4393
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
4158
4394
  const headers = new Headers();
4159
4395
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -4208,10 +4444,12 @@ function createBrowseFilesTool(input) {
4208
4444
  const seg = canonicalPath.split("/").map((s) => s.trim()).filter(Boolean);
4209
4445
  const root = seg[0];
4210
4446
  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);
4447
+ return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos or /approved/summer-launch." };
4448
+ if (root !== "raws" && root !== "approved")
4449
+ return { action, explanation: explanation ?? null, status: 400, error: "move is supported within /raws (clips) and /approved (posts)." };
4450
+ const url = root === "approved"
4451
+ ? new URL(`${APPROVED_POSTS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL)
4452
+ : new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
4215
4453
  const moveHeaders = new Headers(headers);
4216
4454
  moveHeaders.set("content-type", "application/json");
4217
4455
  const r = await fetch(url, {
@@ -4223,6 +4461,24 @@ function createBrowseFilesTool(input) {
4223
4461
  const body = await r.json().catch(() => null);
4224
4462
  return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4225
4463
  }
4464
+ if (action === "rename") {
4465
+ const target = (path ?? "").trim();
4466
+ const nextName = (new_name ?? "").trim();
4467
+ if (!target || !nextName) {
4468
+ return { action, explanation: explanation ?? null, status: 400, error: "rename requires path (the file or folder to rename) and new_name. For a file also pass file_id." };
4469
+ }
4470
+ const url = new URL(`${USER_PREFIX}/me/directory/rename`, config.PUBLIC_BASE_URL);
4471
+ const renameHeaders = new Headers(headers);
4472
+ renameHeaders.set("content-type", "application/json");
4473
+ const r = await fetch(url, {
4474
+ method: "POST",
4475
+ headers: renameHeaders,
4476
+ body: JSON.stringify({ path: target, new_name: nextName, ...((file_id ?? "").trim() ? { file_id: (file_id ?? "").trim() } : {}) }),
4477
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4478
+ });
4479
+ const body = await r.json().catch(() => null);
4480
+ return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
4481
+ }
4226
4482
  if (action === "write") {
4227
4483
  const name = (file_name ?? "").trim();
4228
4484
  if (!name) {
@@ -4584,7 +4840,7 @@ function createVideoCreationTool() {
4584
4840
  }
4585
4841
  function createEditorActionTool() {
4586
4842
  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.",
4843
+ description: "Directly mutate the composition timeline in the editor OR start an export render OR generate-and-place AI media. Use this whenever the user asks for a timeline change (add/remove/duplicate/split layers, retime, reposition, resize, restyle text, swap media, group/ungroup), asks to generate an AI video/image and drop it on the timeline (action_type=generate_layer), or asks to export/render/publish/download the final MP4 (action_type=export_composition). The most recent <editor_context> in the user message is the source of truth for current layer_keys, layer indices (called 'track'), durations, timeline_gaps (blank spans), pending_generations (AI gens still rendering), and the most recent exported MP4 (last_export_url). Forking is automatic — the FIRST mutating editor_action on a real template auto-mints the user's own private editable copy; you never create a fork manually, just edit (the exception is a blank project with composition_id \"original\", which has no template to fork yet, so edits there won't persist until a real video is created). Always provide explanation. For add_layer, also provide layer_key with a stable descriptive id (e.g., 'vf-caption-hook', 'vf-hero-image') so subsequent tool calls in the same turn can reference it — the editor will use that id as the new layer's data-hf-id. PREFER action_type=generate_layer to create NEW AI footage/images on the timeline: it submits the primitive generate job, drops a placeholder clip into the target slot immediately, and auto-swaps in the finished media when the job settles (no manual poll, no separate add_layer). Only use the http_request→add_layer flow for media that already has a URL. Avoid layer/track collisions: pick a track index higher than any existing layer on the requested time range, or set start/duration so the range is free. Never invent layer_keys for remove/set/duplicate/split actions; only use keys present in the most recent editor_context. For action_type=export_composition, no layer_key/geometry fields are needed; the editor kicks off the render and streams status in the UI, and the resulting MP4 URL will appear in the next <editor_context> as last_export_url. Editing works on THREE axes — SCENES (video/image clips), AUDIO (narration/music/SFX), and TEXT (captions/titles/overlays) — and on each the user may lightly SWAP (change content in place, keeping timing/geometry) or heavily REPLACE (restructure outright, often keeping only the template's viral DNA); for a heavy scene REPLACE prefer hunting real raw clips (POST /clips/scan) or the user's /raws library over AI generation, and carry the whole re-work rather than only single-layer nudges.",
4588
4844
  inputSchema: z.object({
4589
4845
  action_type: z.enum([
4590
4846
  "add_layer",
@@ -4608,9 +4864,12 @@ function createEditorActionTool() {
4608
4864
  "nudge_layers",
4609
4865
  "ripple_edit",
4610
4866
  "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."),
4867
+ "trim_layer",
4868
+ "set_composition",
4869
+ "fork_composition"
4870
+ ]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use fork_composition ONLY when the user EXPLICITLY asks to branch / duplicate / 'save as' / 'make a copy' / 'start a fresh version' of this project (routine editing already auto-forks — do NOT call this to begin editing): set fork_from='current' to branch from the current working copy (keep all edits) or fork_from='default' to start a fresh copy from the original template (discard current edits); the editor mints the new fork and opens it. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit. FINE TIMELINE CONTROL: set_layer_keyframes authors a custom script-free CSS @keyframes animation on ONE layer (opacity/translate/scale/rotate over the clip) that previews AND renders; nudge_layers moves one or more layers by a relative delta_start/delta_track (group-aware); ripple_edit inserts or closes time at at_time and shifts all downstream clips; trim_layer moves one edge (start|end) of a clip to a time, coupling media in-point on a left trim; set_layer_zindex restacks a layer (front|back|forward|backward) — stacking is the track index. STATIC LOOK: set_layer_visual also sets a constant opacity (0..1, e.g. a ghosted b-roll underlay — a FADE over time is set_layer_keyframes instead); set_layer_style also sets line_height (unitless leading) and letter_spacing (px tracking, negative tightens) for typography the presets can't reach. COMPOSITION-LEVEL: set_composition changes the whole canvas — canvas_width/canvas_height resize the frame, canvas_duration retargets total render length, background_color recolors the canvas (all written on the composition root, no layer_key)."),
4613
4871
  layer_key: z.string().optional().describe("Existing layer key from editor_context (required for remove/set/duplicate/split). MAY be either the element_id (data-hf-id) or the slug (data-hf-slug). For add_layer, optionally provide a stable id (starts with a letter, [A-Za-z0-9_-], <=64 chars) to reuse in later tool calls this turn."),
4872
+ fork_from: z.enum(["current", "default"]).optional().describe("For action_type=fork_composition: 'current' branches a new fork from the current working copy (keeps all edits); 'default' starts a fresh fork from the template's original default (discards current edits). Defaults to 'current'."),
4614
4873
  slug: z.string().optional().describe("snake_case slug for the layer. On add_layer, seeds data-hf-slug. On set_layer_identity, sets it. Slugs give viral DNA a stable handle for the element."),
4615
4874
  note: z.string().optional().describe("Human-facing note explaining the layer's role, referenced by viral DNA. On add_layer seeds data-hf-note. On set_layer_identity, sets it. Empty string removes."),
4616
4875
  layer_keys: z.array(z.string()).optional().describe("Multiple layer keys. Required for group_layers (>=2) and ungroup_layers. For duplicate_layer, layer_keys[1] optionally names the duplicate."),
@@ -4635,11 +4894,18 @@ function createEditorActionTool() {
4635
4894
  underline: z.boolean().optional().describe("Toggle underline on text layers."),
4636
4895
  text_align: z.string().optional().describe("CSS text-align value (left, center, right, justify)."),
4637
4896
  border_radius: z.number().optional().describe("Border radius in pixels."),
4897
+ opacity: z.number().optional().describe("set_layer_visual: constant layer opacity, 0 (invisible) to 1 (opaque). Baked as static inline style (seek-safe) — use it for a ghosted/dimmed underlay or a translucent overlay. For an opacity change OVER TIME (fade in/out) use set_layer_keyframes instead."),
4898
+ line_height: z.number().optional().describe("set_layer_style: unitless CSS line-height (leading) for text/caption layers, e.g. 1.1 tight, 1.5 airy."),
4899
+ letter_spacing: z.number().optional().describe("set_layer_style: CSS letter-spacing in px for text/caption layers (negative tightens, positive expands) — the lever for a condensed all-caps or wide-tracked look the font presets can't reach."),
4900
+ canvas_width: z.number().optional().describe("set_composition: new canvas WIDTH in pixels (e.g. 1080). Written to the composition root's data-width. Resizes the output frame."),
4901
+ canvas_height: z.number().optional().describe("set_composition: new canvas HEIGHT in pixels (e.g. 1920 for 9:16, 1080 for 1:1, or 1080 with width 1920 for 16:9). Written to data-height."),
4902
+ canvas_duration: z.number().optional().describe("set_composition: total render length in seconds. Written to the root's data-duration (the overall video length). Distinct from a single clip's duration."),
4903
+ background_color: z.string().optional().describe("set_composition: CSS color for the whole composition CANVAS background (behind all layers), e.g. '#000000' or 'white'. Distinct from a single layer's background."),
4638
4904
  color: z.string().optional().describe("CSS color for text foreground."),
4639
4905
  background: z.string().optional().describe("CSS color for background."),
4640
4906
  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)."),
4907
+ object_fit: z.string().optional().describe("CSS object-fit for image/video — how the media fills its layer box when its native aspect differs from the frame. 'cover' (default) fills the frame and crops the overflow (best for footage whose subject you keep centered or steer with object_position); 'contain' shows the WHOLE media with black letterbox/pillarbox bars (use only when nothing may be cropped — an infographic, whole screenshot, chart, logo); 'fill' stretches/distorts (avoid); 'none'; 'scale-down'. To fit a 16:9 landscape clip on a 9:16 vertical frame, prefer cover + a subject-aware object_position over contain unless the entire frame must be visible."),
4908
+ object_position: z.string().optional().describe("CSS object-position WHERE an object_fit:cover crop is anchored, so the subject stays in frame instead of being center-cropped out. Accepts keywords (center, left, right, top, bottom, 'top left', 'bottom right', ...) OR percentages/lengths ('30% 50%' holds the point 30% from the left and 50% down; '50% 20%' keeps the upper-middle). Landscape clip with the subject on the left → 'left' or '25% 50%'; tall screenshot with key content up top → 'top'. Only meaningful with object_fit cover/none."),
4643
4909
  ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
4644
4910
  ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
4645
4911
  transition: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. On set_transitions it is the junction preset applied at EVERY cut. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. Preset energy: crossfade/fade-black/fade-white/blur = calm, smoke = dreamy, slide/wipe = energetic, whip/flash = fast-paced social, zoom/circle-open = punchy."),
@@ -4757,9 +5023,16 @@ async function streamEditorChatAgent(input, send) {
4757
5023
  tracerRegistry
4758
5024
  }),
4759
5025
  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"
5026
+ // create_video is for making a BRAND-NEW video. It belongs on the
5027
+ // discover/brainstorm surface ("create me a video of…" / "replicate this
5028
+ // URL…") AND on a BLANK editor project (templateId "original", the
5029
+ // /editor/original/fork/new sentinel): a blank project has no template to
5030
+ // fork yet, so editor_action edits can't persist — the model must mint a
5031
+ // real composition first, and the dock already owns runCreateVideoFlow to
5032
+ // run the ingest→decompose→fork pipeline and drop an "Open in editor" link.
5033
+ // Real template_… compositions stay edit-only (editor_action) so the model
5034
+ // never spawns a new template when the user just wanted to edit this one.
5035
+ ...(input.template.templateId === "chat-brainstorm" || input.template.templateId === "original"
4763
5036
  ? { create_video: createVideoCreationTool() }
4764
5037
  : {})
4765
5038
  };
@@ -5165,6 +5438,11 @@ async function renderHyperframesStudioEditorPage(input) {
5165
5438
  ${renderChatDock("editor")}
5166
5439
  <script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
5167
5440
  <script>${RESKIN_CHROME_SCRIPT}</script>
5441
+ <!-- Files drawer of the editor dock = the reusable directory-explorer component,
5442
+ auto-mounts [data-rk-directory] (#rkAichatFilesMount). reskinDocument ships
5443
+ this bundle on full-chrome pages, but the editor builds its own document, so
5444
+ it must be loaded here too or the "Your files" panel mounts empty. -->
5445
+ <script type="module" src="/assets/file-directory-app.js"></script>
5168
5446
  <script type="module" src="/assets/editor/app.js"></script>
5169
5447
  </body>
5170
5448
  </html>`;
@@ -5311,7 +5589,14 @@ const reskinLibraryHandler = async (c, tab) => {
5311
5589
  email: customer.email,
5312
5590
  posts: [],
5313
5591
  schedule: { channels: [], connectHref: "/settings/channels?connect=flockposter" },
5314
- editorChat: null
5592
+ editorChat: null,
5593
+ // On a `vidfarm serve` box with a cloud upstream, offer the Local ⇄ Cloud
5594
+ // space switch in the explorer (reads /me/cloud/directory passthrough).
5595
+ cloudSpace: isUpstreamEnabled(),
5596
+ // ?path= deep-links the explorer into a folder (e.g. ?path=/approved/swipes).
5597
+ // Safe raw: the client's parseDirectoryPath only honors the known roots and
5598
+ // drops anything else back to the virtual root "/".
5599
+ initialFilesPath: (c.req.query("path") ?? "").trim() || undefined
5315
5600
  }));
5316
5601
  }
5317
5602
  const emailChannels = await getEmailPseudoChannels(customer.id, c);
@@ -5323,7 +5608,12 @@ const reskinLibraryHandler = async (c, tab) => {
5323
5608
  const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
5324
5609
  .filter((post) => (post.source ?? "legacy") === "hyperframes")
5325
5610
  .filter((post) => post.status === "ready" || post.status === "scheduled")
5326
- .map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
5611
+ .map(async (post) => {
5612
+ // Backfill the rendered MP4 for swipe-approved posts whose detached
5613
+ // finalize poll died on Lambda before it could attach the media.
5614
+ const reconciled = await reconcileReadyPostMedia(customer.id, post);
5615
+ return { ...serializeReadyPost(c, reconciled, { includePrivate: true }), template_id: reconciled.compositionId ?? null };
5616
+ }));
5327
5617
  const posts = await mergeUpstreamReadyPosts(localPosts);
5328
5618
  return c.html(renderReskinLibrary(tab, {
5329
5619
  userId: customer.id,
@@ -5466,9 +5756,17 @@ app.get("/chat-dock/boot", async (c) => {
5466
5756
  return c.json({ editorChat: null });
5467
5757
  return c.json({ editorChat: await buildChatBrainstormEditorChatBoot({ customer }) });
5468
5758
  });
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) => {
5759
+ // The creative tools are three twins under /tools: /tools/image (masked image
5760
+ // edits), /tools/video (start/end-frame + time-scoped video edits), and
5761
+ // /tools/clipper (subrange-clip a source video into the raws library). The bare
5762
+ // /tools redirects to the image editor, preserving any ?source_image_url /
5763
+ // ?tracer preload params. The legacy /inpaint/* paths 302 here for back-compat
5764
+ // so existing deep-links (and editor "Edit Media") keep working.
5765
+ app.get("/tools", (c) => {
5766
+ const search = new URL(c.req.url).search;
5767
+ return redirect(c, "/tools/image" + search);
5768
+ });
5769
+ app.get("/tools/image", async (c) => {
5472
5770
  const customer = await getOptionalPreferredBrowserCustomer(c);
5473
5771
  if (!customer) {
5474
5772
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5476,6 +5774,27 @@ app.get("/inpaint", async (c) => {
5476
5774
  }
5477
5775
  return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
5478
5776
  });
5777
+ app.get("/tools/video", async (c) => {
5778
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5779
+ if (!customer) {
5780
+ setLoginReturnPath(c, currentPathWithSearch(c));
5781
+ return redirect(c, "/login");
5782
+ }
5783
+ return c.html(renderReskinInpaintVideo({ accountId: customer.id, isLoggedIn: true }));
5784
+ });
5785
+ app.get("/tools/clipper", async (c) => {
5786
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5787
+ if (!customer) {
5788
+ setLoginReturnPath(c, currentPathWithSearch(c));
5789
+ return redirect(c, "/login");
5790
+ }
5791
+ return c.html(renderReskinInpaintClipper({ accountId: customer.id, isLoggedIn: true }));
5792
+ });
5793
+ // Legacy /inpaint/* → /tools/* back-compat redirects (preserve query string).
5794
+ app.get("/inpaint", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5795
+ app.get("/inpaint/image", (c) => redirect(c, "/tools/image" + new URL(c.req.url).search));
5796
+ app.get("/inpaint/video", (c) => redirect(c, "/tools/video" + new URL(c.req.url).search));
5797
+ app.get("/inpaint/clipper", (c) => redirect(c, "/tools/clipper" + new URL(c.req.url).search));
5479
5798
  app.get("/calendar", async (c) => {
5480
5799
  const customer = await requireBrowserCustomer(c);
5481
5800
  if (!customer) {
@@ -5604,7 +5923,7 @@ app.get("/reskin/discover/:mode", (c) => {
5604
5923
  return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
5605
5924
  });
5606
5925
  app.get("/reskin/chat", (c) => redirect(c, "/chat"));
5607
- app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
5926
+ app.get("/reskin/inpaint", (c) => redirect(c, "/tools/image"));
5608
5927
  app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
5609
5928
  app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
5610
5929
  app.get("/reskin/agency", (c) => redirect(c, "/agency"));
@@ -5612,12 +5931,18 @@ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
5612
5931
  app.get("/reskin/help", (c) => redirect(c, "/help"));
5613
5932
  app.get("/reskin/login", (c) => redirect(c, "/login"));
5614
5933
  app.get("/discover", async (c) => renderApprovedHomepage(c));
5934
+ // The sentinel composition/template id for a brand-new blank project. It never
5935
+ // collides with a real template (those are `template_<uuid>`). Pre-fork it is
5936
+ // served the canonical blank scaffold (buildBlankCompositionHtml); on first edit
5937
+ // the SPA POSTs it as the fork `source`, minting a per-user fork seeded from
5938
+ // that same scaffold — so "every new blank is a fork of the blank composition".
5939
+ const BLANK_COMPOSITION_ID = "original";
5615
5940
  // 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) {
5941
+ // CTAs point here. There is no template or fork yet: we boot the SPA into the
5942
+ // canonical blank composition (3 placeholder scenes), and the client mints a
5943
+ // fork_* seeded from it on the first save/generate/decompose, then
5944
+ // history.replaceState's the URL to the /editor/original/fork/:forkId form.
5945
+ async function renderBlankEditorPage(c, titleOverride) {
5621
5946
  const customer = await getOptionalPreferredBrowserCustomer(c);
5622
5947
  if (!customer) {
5623
5948
  setLoginReturnPath(c, currentPathWithSearch(c));
@@ -5625,7 +5950,7 @@ async function renderBlankEditorPage(c) {
5625
5950
  }
5626
5951
  const threadParam = c.req.query("thread");
5627
5952
  const blankComposition = {
5628
- title: "Untitled project",
5953
+ title: titleOverride?.trim() || "Untitled project",
5629
5954
  templateId: "original",
5630
5955
  slugId: "original",
5631
5956
  difficulty: "easy",
@@ -5643,6 +5968,26 @@ async function renderBlankEditorPage(c) {
5643
5968
  };
5644
5969
  return c.html(await renderHyperframesStudioEditorPage({ composition: blankComposition, customer }));
5645
5970
  }
5971
+ // Reload / direct-load of a blank project's own fork URL (/editor/original/fork/
5972
+ // <forkId>) after the SPA minted it. There is no template to resolve — we just
5973
+ // confirm the caller owns this blank fork, then render the same blank editor
5974
+ // shell (the SPA reads the forkId from the URL and the player loads the fork's
5975
+ // stored composition via ?fork=). Anything stale/foreign falls back to a fresh
5976
+ // blank so the user never lands on a 404.
5977
+ async function renderBlankForkEditorPage(c, forkParam) {
5978
+ const customer = await getOptionalPreferredBrowserCustomer(c);
5979
+ if (!customer) {
5980
+ setLoginReturnPath(c, currentPathWithSearch(c));
5981
+ return redirect(c, "/login");
5982
+ }
5983
+ if (forkParam) {
5984
+ const fork = await serverlessRecords.getCompositionFork(forkParam);
5985
+ if (fork && !fork.deletedAt && fork.customerId === customer.id && fork.templateId === BLANK_COMPOSITION_ID) {
5986
+ return renderBlankEditorPage(c, fork.title ?? undefined);
5987
+ }
5988
+ }
5989
+ return redirect(c, "/editor/original/fork/new");
5990
+ }
5646
5991
  // Shared handler for both the bare /editor/:templateId (resolver) route and the
5647
5992
  // canonical /editor/:templateId/fork/:forkId (path-form) route. `forkParam` is
5648
5993
  // the fork id from the URL path (path route) or null (bare route). We validate
@@ -5650,6 +5995,10 @@ async function renderBlankEditorPage(c) {
5650
5995
  // redirect to the canonical path form.
5651
5996
  async function renderEditorPage(c, templateId, forkParam) {
5652
5997
  const customer = await getOptionalPreferredBrowserCustomer(c);
5998
+ // Blank project forks carry the sentinel templateId, not a real template_*.
5999
+ // Route them to the blank-fork renderer instead of 404'ing.
6000
+ if (templateId === BLANK_COMPOSITION_ID)
6001
+ return renderBlankForkEditorPage(c, forkParam);
5653
6002
  if (!templateId.startsWith("template_"))
5654
6003
  return c.notFound();
5655
6004
  let template = await serverlessRecords.getTemplate(templateId);
@@ -5797,6 +6146,13 @@ app.get("/editor/:templateId", async (c) => {
5797
6146
  });
5798
6147
  app.get("/editor/:templateId/composition", async (c) => {
5799
6148
  const templateId = c.req.param("templateId");
6149
+ // Blank new project: the SPA fetches this to seed its client-side timeline
6150
+ // state. Serve the same canonical blank scaffold the player iframe gets (raw,
6151
+ // no runtime — the studio only parses the clips out of it) so client state and
6152
+ // the rendered player agree on element ids.
6153
+ if (templateId === BLANK_COMPOSITION_ID) {
6154
+ return c.html(buildBlankCompositionHtml(templateId), 200, { "cache-control": "no-store" });
6155
+ }
5800
6156
  if (!templateId.startsWith("template_"))
5801
6157
  return c.notFound();
5802
6158
  const template = await serverlessRecords.getTemplate(templateId);
@@ -5823,7 +6179,13 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute
5823
6179
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat/:id`, (c) => dispatchScopedGetToLegacyRoute(c, `/chat/${c.req.param("id")}`));
5824
6180
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
5825
6181
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
5826
- app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
6182
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6183
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6184
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
6185
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/tools/clipper`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/clipper"));
6186
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6187
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/image`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/image"));
6188
+ app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint/video`, (c) => dispatchScopedGetToLegacyRoute(c, "/tools/video"));
5827
6189
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar"));
5828
6190
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings"));
5829
6191
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs"));
@@ -6138,6 +6500,12 @@ app.post("/discover/swipe/:id/approve", async (c) => {
6138
6500
  status: "ready",
6139
6501
  compositionId: fork.id,
6140
6502
  tracer: `swipe:${record.id}`,
6503
+ // All approved swipe cuts collect into ONE canonical /approved/swipes folder.
6504
+ // Without this explicit path the tracer-slug default would mint a unique
6505
+ // per-swipe folder (swipe-swipe-<id>) — 1 post each — which is the clutter
6506
+ // seen on /library/approved. The tracer still carries the swipe id for
6507
+ // job-run correlation.
6508
+ folderPath: "swipes",
6141
6509
  title: record.tailoredTitle,
6142
6510
  caption: record.tailoredCaption,
6143
6511
  pinnedComment: record.tailoredPinnedComment,
@@ -6246,6 +6614,50 @@ async function finalizeSwipeApproveRender(input) {
6246
6614
  }
6247
6615
  }
6248
6616
  }
6617
+ // Self-heal a ready post that is still missing its rendered media. Swipe-approve
6618
+ // mints the post with EMPTY media and relies on the fire-and-forget
6619
+ // finalizeSwipeApproveRender poll to backfill the finished MP4 — but that
6620
+ // detached poll does NOT survive a serverless/Lambda response, so on prod the
6621
+ // render completes (the Step Functions worker is durable) yet the MP4 is never
6622
+ // attached, and the library shows "No preview" forever. Reconciling on read
6623
+ // closes that gap: for any ready hyperframes post with no media, resolve its
6624
+ // fork's render job and attach the completed MP4 when the render has finished.
6625
+ // No-ops (short-circuits) once media exists, so a post is reconciled at most
6626
+ // once. Best-effort: any failure leaves the post untouched.
6627
+ async function reconcileReadyPostMedia(customerId, post) {
6628
+ if (post.mediaAssets.length > 0)
6629
+ return post;
6630
+ if ((post.source ?? "legacy") !== "hyperframes" || !post.compositionId)
6631
+ return post;
6632
+ try {
6633
+ const fork = await serverlessRecords.getCompositionFork(post.compositionId);
6634
+ const renderJobId = fork?.currentRenderJobId;
6635
+ if (!renderJobId)
6636
+ return post;
6637
+ const job = await jobs.getJobAsync(renderJobId);
6638
+ const status = String(asRecord(job)?.status ?? "").toLowerCase();
6639
+ if (status !== "succeeded" && status !== "completed")
6640
+ return post;
6641
+ const url = extractOutputUrlFromJob(job);
6642
+ if (!url)
6643
+ return post;
6644
+ const slug = (post.title || "swipe-ad").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 40) || "swipe-ad";
6645
+ const asset = {
6646
+ id: `render-${renderJobId}`,
6647
+ url,
6648
+ fileName: `${slug}.mp4`,
6649
+ contentType: "video/mp4",
6650
+ kind: "video",
6651
+ role: "primary"
6652
+ };
6653
+ const updated = await serverlessRecords.setReadyPostMedia({ customerId, postId: post.id, mediaAssets: [asset] });
6654
+ return updated ?? { ...post, mediaAssets: [asset] };
6655
+ }
6656
+ catch (error) {
6657
+ console.error("ready-post media reconcile failed", post.id, error instanceof Error ? error.message : error);
6658
+ return post;
6659
+ }
6660
+ }
6249
6661
  function buildTemplateHomepageEntry(template) {
6250
6662
  // Undecomposed templates (no defaultForkId yet) still appear on /discover so
6251
6663
  // users can open them and mint the first fork via the Decompose modal.
@@ -6763,6 +7175,23 @@ app.delete(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
6763
7175
  const deleted = await serverlessRecords.deleteReadyPost(customer.id, post.id);
6764
7176
  return c.json({ post: deleted ? serializeReadyPost(c, deleted, { includePrivate: true }) : null });
6765
7177
  });
7178
+ // Move an approved post between /approved folders ({ folder_path }, ""=root).
7179
+ // Mirrors PATCH /raws/:clipId — the /library/approved explorer uses it to
7180
+ // reorganize posts and to materialize a folder when renaming a legacy group.
7181
+ app.patch(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId`, async (c) => {
7182
+ const customer = await requireBrowserCustomer(c);
7183
+ if (!customer) {
7184
+ return c.json({ error: "Unauthorized" }, 401);
7185
+ }
7186
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
7187
+ if (!post || post.status === "deleted" || post.customerId !== customer.id) {
7188
+ return c.json({ error: "Approved post not found." }, 404);
7189
+ }
7190
+ const body = (await c.req.json().catch(() => ({})));
7191
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
7192
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
7193
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
7194
+ });
6766
7195
  app.get(`${ACCOUNT_FRONTEND_PREFIX}/approved/posts/:postId/schedules`, async (c) => {
6767
7196
  const customer = await requireBrowserCustomer(c);
6768
7197
  if (!customer) {
@@ -7251,6 +7680,14 @@ app.get("/composition/current.html", async (c) => {
7251
7680
  const compositionId = (c.req.query("composition") ?? "").trim();
7252
7681
  if (!compositionId)
7253
7682
  return c.text("Missing composition", 400);
7683
+ // Blank new project (the /editor/original/fork/new sentinel, before its first
7684
+ // edit mints a fork). Serve the canonical blank scaffold — 3 placeholder image
7685
+ // scenes + "Scene N" captions — so the player paints an editable 12s timeline
7686
+ // instead of "Composition not found". Runtime is injected by the rewrite.
7687
+ if (compositionId === BLANK_COMPOSITION_ID) {
7688
+ const rewritten = rewriteHyperframesDraftCompositionHtml(buildBlankCompositionHtml(compositionId), "blank");
7689
+ return c.html(rewritten, 200, { "cache-control": "no-store" });
7690
+ }
7254
7691
  if (compositionId.startsWith("template_")) {
7255
7692
  const template = await serverlessRecords.getTemplate(compositionId);
7256
7693
  if (!template || !template.videoUrl)
@@ -7643,6 +8080,9 @@ async function snapshotForkVersion(input) {
7643
8080
  // Scene annotations (clip-replacement + recreation DNA) likewise travel with
7644
8081
  // every version — reusable decompose metadata, same as placement surfaces.
7645
8082
  const workingAnnotations = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "scene-annotations.json"));
8083
+ // Editor harness (technical editing direction) travels with every version too
8084
+ // — reusable decompose style metadata, same as placement / annotations.
8085
+ const workingHarness = await storage.readText(storage.compositionForkWorkingKey(input.fork.id, "editor-harness.json"));
7646
8086
  if (workingHtml) {
7647
8087
  await storage.putText(storage.compositionForkVersionKey(input.fork.id, version, "composition.html"), workingHtml, "text/html; charset=utf-8");
7648
8088
  }
@@ -7658,6 +8098,9 @@ async function snapshotForkVersion(input) {
7658
8098
  if (workingAnnotations) {
7659
8099
  await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "scene-annotations.json"), Buffer.from(workingAnnotations, "utf8"), "application/json");
7660
8100
  }
8101
+ if (workingHarness) {
8102
+ await storage.putBuffer(storage.compositionForkVersionKey(input.fork.id, version, "editor-harness.json"), Buffer.from(workingHarness, "utf8"), "application/json");
8103
+ }
7661
8104
  const versionRecord = await serverlessRecords.createForkVersion({
7662
8105
  forkId: input.fork.id,
7663
8106
  ownerCustomerId: input.fork.customerId,
@@ -7712,58 +8155,71 @@ function serializeFork(fork, caps) {
7712
8155
  : null
7713
8156
  };
7714
8157
  }
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);
8158
+ // ── Shared fork-creation helpers ────────────────────────────────────────────
8159
+ // A "new fork" always has one of two SOURCES, and every surface (the editor's
8160
+ // New-fork modal, the AI chat fork_composition action, and the devcli clone
8161
+ // command) funnels through these so the seed logic stays uniform:
8162
+ // • "default" fresh from the template's canonical default fork (start over
8163
+ // from the original template discard the current working edits).
8164
+ // • "current" branch from an existing working copy (clone this fork,
8165
+ // optionally pinned to a saved version).
8166
+ // Copy the canonical working files from a source fork (optionally a pinned
8167
+ // version) into a destination fork's working tree. Returns whether an HTML
8168
+ // body was seeded.
8169
+ async function seedForkWorkingFromFork(destForkId, sourceForkId, version) {
8170
+ const read = async (filename) => {
8171
+ if (version && Number.isFinite(version)) {
8172
+ const versioned = await storage.readText(storage.compositionForkVersionKey(sourceForkId, version, filename));
8173
+ if (versioned)
8174
+ return versioned;
8175
+ }
8176
+ return storage.readText(storage.compositionForkWorkingKey(sourceForkId, filename));
8177
+ };
8178
+ const html = await read("composition.html");
8179
+ const json = await read("composition.json");
8180
+ const cast = await read("cast.json");
8181
+ const placement = await read("product-placement.json");
8182
+ const annotations = await read("scene-annotations.json");
8183
+ const harness = await read("editor-harness.json");
8184
+ if (html) {
8185
+ await storage.putText(storage.compositionForkWorkingKey(destForkId, "composition.html"), html, "text/html; charset=utf-8");
7723
8186
  }
7724
- const template = await serverlessRecords.getTemplate(templateId);
7725
- if (!template)
7726
- return c.json({ ok: false, error: "Template not found" }, 404);
8187
+ if (json) {
8188
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "composition.json"), Buffer.from(json, "utf8"), "application/json");
8189
+ }
8190
+ if (cast) {
8191
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "cast.json"), Buffer.from(cast, "utf8"), "application/json");
8192
+ }
8193
+ if (placement) {
8194
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "product-placement.json"), Buffer.from(placement, "utf8"), "application/json");
8195
+ }
8196
+ if (annotations) {
8197
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "scene-annotations.json"), Buffer.from(annotations, "utf8"), "application/json");
8198
+ }
8199
+ if (harness) {
8200
+ await storage.putBuffer(storage.compositionForkWorkingKey(destForkId, "editor-harness.json"), Buffer.from(harness, "utf8"), "application/json");
8201
+ }
8202
+ return Boolean(html);
8203
+ }
8204
+ // Fork FRESH from a template's canonical default fork (from: "default"). Never
8205
+ // overwrites template.defaultForkId — that stays canonical so other users keep
8206
+ // getting it as their fallback. No version snapshot (matches a first fork).
8207
+ async function forkCompositionFromTemplateDefault(customerId, template, title) {
7727
8208
  const canonicalFork = template.defaultForkId
7728
8209
  ? await serverlessRecords.getCompositionFork(template.defaultForkId)
7729
8210
  : 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
8211
  const fork = await serverlessRecords.createCompositionFork({
7735
- customerId: customer.id,
8212
+ customerId,
7736
8213
  templateId: template.id,
7737
8214
  parentForkId: canonicalFork?.id ?? null,
7738
8215
  parentVersion: canonicalFork?.latestVersion ?? null,
7739
8216
  visibility: "private",
7740
8217
  title
7741
8218
  });
7742
- // Seed new fork with the canonical composition html/json if available so
7743
- // it starts from the same base as everyone else.
7744
8219
  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
- }
8220
+ // Seed with the canonical composition (html/json/cast/placement/annotations)
8221
+ // so it starts from the same base as everyone else.
8222
+ await seedForkWorkingFromFork(fork.id, canonicalFork.id, null);
7767
8223
  }
7768
8224
  else if (template.videoUrl) {
7769
8225
  // Template exists but has no canonical fork yet — seed with raw HTML.
@@ -7777,6 +8233,71 @@ app.post(COMPOSITIONS_PREFIX, async (c) => {
7777
8233
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rawHtml, "text/html; charset=utf-8");
7778
8234
  }
7779
8235
  await writeForkManifest(storage, fork, { kind: "working" });
8236
+ return fork;
8237
+ }
8238
+ // Fork from an EXISTING fork (from: "current"). Copies its working (or a pinned
8239
+ // version) files into a new private fork and snapshots v-next.
8240
+ async function forkCompositionFromExistingFork(customerId, sourceFork, parentVersion, title) {
8241
+ const cloned = await serverlessRecords.createCompositionFork({
8242
+ customerId,
8243
+ templateId: sourceFork.templateId,
8244
+ parentForkId: sourceFork.id,
8245
+ parentVersion: parentVersion ?? null,
8246
+ visibility: "private",
8247
+ title
8248
+ });
8249
+ await seedForkWorkingFromFork(cloned.id, sourceFork.id, parentVersion);
8250
+ await snapshotForkVersion({
8251
+ fork: cloned,
8252
+ callerCustomerId: customerId,
8253
+ reason: "clone",
8254
+ message: `Cloned from ${sourceFork.id}${parentVersion ? `@v${parentVersion}` : ""}`
8255
+ });
8256
+ return (await serverlessRecords.getCompositionFork(cloned.id));
8257
+ }
8258
+ // Mint a fresh per-user fork of the canonical blank composition. There is no
8259
+ // template record — the fork carries the sentinel templateId so it stays
8260
+ // isolated per customer — and its working copy is seeded with the same blank
8261
+ // scaffold the pre-fork editor shows, so the user's first edit lands on the 3
8262
+ // placeholder scenes and now persists. This is the "every new blank is a fork of
8263
+ // the blank composition" path (see BLANK_COMPOSITION_ID).
8264
+ async function forkBlankComposition(customerId, title) {
8265
+ const fork = await serverlessRecords.createCompositionFork({
8266
+ customerId,
8267
+ templateId: BLANK_COMPOSITION_ID,
8268
+ parentForkId: null,
8269
+ parentVersion: null,
8270
+ visibility: "private",
8271
+ title
8272
+ });
8273
+ await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), buildBlankCompositionHtml(fork.id), "text/html; charset=utf-8");
8274
+ await writeForkManifest(storage, fork, { kind: "working" });
8275
+ return (await serverlessRecords.getCompositionFork(fork.id));
8276
+ }
8277
+ app.post(COMPOSITIONS_PREFIX, async (c) => {
8278
+ const customer = await requireBrowserCustomer(c);
8279
+ if (!customer)
8280
+ return c.json({ ok: false, error: "Unauthorized" }, 401);
8281
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
8282
+ const templateId = readNonEmptyString(body.template_id) ?? readNonEmptyString(body.source);
8283
+ // Blank project: the SPA POSTs source="original" on the first edit. Mint a
8284
+ // fork seeded from the canonical blank scaffold instead of rejecting it, so
8285
+ // edits on a new project persist (previously blank edits were lost on reload).
8286
+ if (templateId === BLANK_COMPOSITION_ID) {
8287
+ const blankTitle = readNonEmptyString(body.title) ?? "Untitled project";
8288
+ const fork = await forkBlankComposition(customer.id, blankTitle);
8289
+ return c.json(serializeFork(fork, {
8290
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
8291
+ }), 201);
8292
+ }
8293
+ if (!templateId || !templateId.startsWith("template_")) {
8294
+ return c.json({ ok: false, error: "Missing or invalid template_id" }, 400);
8295
+ }
8296
+ const template = await serverlessRecords.getTemplate(templateId);
8297
+ if (!template)
8298
+ return c.json({ ok: false, error: "Template not found" }, 404);
8299
+ const title = readNonEmptyString(body.title) ?? template.title ?? `Template ${template.id}`;
8300
+ const fork = await forkCompositionFromTemplateDefault(customer.id, template, title);
7780
8301
  return c.json(serializeFork(fork, {
7781
8302
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
7782
8303
  }), 201);
@@ -8277,6 +8798,24 @@ app.post(`${INSPIRATIONS_PREFIX}/:inspirationId/decompose`, async (c) => {
8277
8798
  catch (placementPersistError) {
8278
8799
  console.error("inspiration product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
8279
8800
  }
8801
+ // Persist the editor harness on the canonical fork too, before the
8802
+ // snapshot, so it travels with v1 and seeds every downstream fork.
8803
+ try {
8804
+ await storage.putJson(storage.compositionForkWorkingKey(canonicalFork.id, "editor-harness.json"), {
8805
+ version: 1,
8806
+ savedAtMs: Date.now(),
8807
+ compositionId: canonicalFork.id,
8808
+ sourceUrl,
8809
+ title,
8810
+ provider: smart.provider,
8811
+ model: smart.model,
8812
+ durationSeconds: smart.durationSeconds,
8813
+ harness: smart.editorHarness
8814
+ });
8815
+ }
8816
+ catch (harnessPersistError) {
8817
+ console.error("inspiration editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
8818
+ }
8280
8819
  // Seed the scene-annotation pass "pending" on the canonical fork too,
8281
8820
  // before the snapshot, so the pending state travels with v1 and every
8282
8821
  // downstream fork can drive /scene-annotations-poll (or inherit the
@@ -8749,6 +9288,7 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8749
9288
  result: {
8750
9289
  summary: smart.summary,
8751
9290
  viralDna: smart.viralDna,
9291
+ editorHarness: smart.editorHarness,
8752
9292
  transcript: smart.transcript,
8753
9293
  durationSeconds: smart.durationSeconds,
8754
9294
  width: smart.width,
@@ -8783,6 +9323,27 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/auto-decompose`, async (c) => {
8783
9323
  catch (placementPersistError) {
8784
9324
  console.error("product-placement persist failed", placementPersistError instanceof Error ? placementPersistError.message : placementPersistError);
8785
9325
  }
9326
+ // Persist the EDITOR HARNESS as its own decompose artifact (mirrors
9327
+ // product-placement.json). Technical editing direction — read-only style
9328
+ // context injected into both the web /editor AI (via the editor_context
9329
+ // snapshot's editor_harness field, parsed from data-editor-harness in the
9330
+ // HTML) and the local devcli agent (via this synced file). Non-fatal.
9331
+ try {
9332
+ await storage.putJson(storage.compositionForkWorkingKey(fork.id, "editor-harness.json"), {
9333
+ version: 1,
9334
+ savedAtMs: Date.now(),
9335
+ compositionId,
9336
+ sourceUrl: analysisSourceUrl,
9337
+ title,
9338
+ provider: smart.provider,
9339
+ model: smart.model,
9340
+ durationSeconds: smart.durationSeconds,
9341
+ harness: smart.editorHarness
9342
+ });
9343
+ }
9344
+ catch (harnessPersistError) {
9345
+ console.error("editor-harness persist failed", harnessPersistError instanceof Error ? harnessPersistError.message : harnessPersistError);
9346
+ }
8786
9347
  // Seed the per-scene clip-annotation pass as "pending" and let the client
8787
9348
  // drive it via POST /scene-annotations-poll (same async model as cast /
8788
9349
  // ghostcut). The annotation is its own vision call — running it inline would
@@ -9171,7 +9732,11 @@ const pollForkSubtitleRemoval = async (c) => {
9171
9732
  captions: Array.isArray(persisted.result.captions) ? persisted.result.captions : [],
9172
9733
  // Placement surfaces live in their own artifact and aren't used
9173
9734
  // by the HTML rebuild; supply an empty list to satisfy the type.
9174
- placementSurfaces: []
9735
+ placementSurfaces: [],
9736
+ // Editor harness round-trips from the snapshot so the rebuilt
9737
+ // HTML keeps its data-editor-harness; default if a pre-harness
9738
+ // snapshot is being rebuilt.
9739
+ editorHarness: persisted.result.editorHarness ?? DEFAULT_EDITOR_HARNESS
9175
9740
  }
9176
9741
  });
9177
9742
  await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), rebuilt, "text/html; charset=utf-8");
@@ -9372,6 +9937,50 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/product-placement.json`, async (c) => {
9372
9937
  return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9373
9938
  }
9374
9939
  });
9940
+ // Read-only EDITOR HARNESS for a fork: the technical editing direction extracted
9941
+ // during decompose (pace, typography, b-roll usage, transitions, audio bed, and
9942
+ // the role each scene plays). This is the "how to edit like this" companion to
9943
+ // viral DNA's "why it works". The web /editor AI already receives it inline via
9944
+ // editor_context.editor_harness (parsed from data-editor-harness in the HTML);
9945
+ // this route is how the local devcli agent + http_request tool fetch it.
9946
+ // Optional-auth like /product-placement.json. Returns { status: "none" } when
9947
+ // the fork was never decomposed (or predates this pass).
9948
+ app.get(`${COMPOSITIONS_PREFIX}/:forkId/editor-harness.json`, async (c) => {
9949
+ const customer = await getBrowserCustomer(c);
9950
+ try {
9951
+ const access = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer?.id ?? null, shareToken: readShareToken(c) }, "view");
9952
+ const text = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "editor-harness.json"));
9953
+ if (!text) {
9954
+ return c.json({
9955
+ ok: true,
9956
+ status: "none",
9957
+ hint: "This fork has no editor-harness pass yet. Run POST /api/v1/compositions/:forkId/auto-decompose to generate it."
9958
+ });
9959
+ }
9960
+ let parsed;
9961
+ try {
9962
+ parsed = JSON.parse(text);
9963
+ }
9964
+ catch {
9965
+ return c.json({ ok: true, status: "none" });
9966
+ }
9967
+ const harness = (parsed.harness && typeof parsed.harness === "object") ? parsed.harness : null;
9968
+ return c.json({
9969
+ ok: true,
9970
+ status: harness ? "ready" : "empty",
9971
+ provider: parsed.provider ?? null,
9972
+ model: parsed.model ?? null,
9973
+ source_url: parsed.sourceUrl ?? null,
9974
+ duration_seconds: parsed.durationSeconds ?? null,
9975
+ harness: harness ?? DEFAULT_EDITOR_HARNESS
9976
+ });
9977
+ }
9978
+ catch (error) {
9979
+ if (error instanceof ForkNotFoundError || error instanceof ForkAccessDeniedError)
9980
+ return forkAccessErrorResponse(c, error);
9981
+ return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
9982
+ }
9983
+ });
9375
9984
  // Read-only scene-annotation context for a fork: the detected content FORMAT
9376
9985
  // plus one annotation per scene carrying the literal + viral DNA needed to
9377
9986
  // (a) match a REPLACEMENT clip from the library (each annotation ships a
@@ -10186,6 +10795,42 @@ app.patch(`${COMPOSITIONS_PREFIX}/:forkId/visibility`, async (c) => {
10186
10795
  return forkAccessErrorResponse(c, error);
10187
10796
  }
10188
10797
  });
10798
+ // Unified "new fork" endpoint. from="current" (default) branches from this
10799
+ // fork's working copy (or a pinned version); from="default" starts a fresh copy
10800
+ // from the template's canonical default fork, discarding the current edits.
10801
+ // This is the single REST surface behind the editor's New-fork modal, the AI
10802
+ // chat fork_composition action, and the devcli clone command.
10803
+ app.post(`${COMPOSITIONS_PREFIX}/:forkId/fork`, async (c) => {
10804
+ const customer = await requireBrowserCustomer(c);
10805
+ if (!customer)
10806
+ return c.json({ ok: false, error: "Login required to fork" }, 401);
10807
+ try {
10808
+ const sourceAccess = await assertForkAccess({ forkId: c.req.param("forkId"), customerId: customer.id, shareToken: readShareToken(c) }, "view");
10809
+ const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
10810
+ const from = readNonEmptyString(body.from) === "default" ? "default" : "current";
10811
+ if (from === "default") {
10812
+ const template = await serverlessRecords.getTemplate(sourceAccess.fork.templateId);
10813
+ if (!template)
10814
+ return c.json({ ok: false, error: "Template not found for this fork" }, 404);
10815
+ const title = readNonEmptyString(body.title) ?? template.title ?? sourceAccess.fork.title;
10816
+ const fresh = await forkCompositionFromTemplateDefault(customer.id, template, title);
10817
+ return c.json(serializeFork(fresh, {
10818
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10819
+ }), 201);
10820
+ }
10821
+ const parentVersionRaw = readNonEmptyString(body.parent_version);
10822
+ const parentVersion = parentVersionRaw ? Number.parseInt(parentVersionRaw, 10) : sourceAccess.fork.latestVersion || null;
10823
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10824
+ return c.json(serializeFork(cloned, {
10825
+ isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10826
+ }), 201);
10827
+ }
10828
+ catch (error) {
10829
+ return forkAccessErrorResponse(c, error);
10830
+ }
10831
+ });
10832
+ // Legacy alias: clone == fork from the current working copy. Retained for
10833
+ // existing callers; new callers should use POST /:forkId/fork { from }.
10189
10834
  app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
10190
10835
  const customer = await requireBrowserCustomer(c);
10191
10836
  if (!customer)
@@ -10195,50 +10840,8 @@ app.post(`${COMPOSITIONS_PREFIX}/:forkId/clone`, async (c) => {
10195
10840
  const body = asRecord(await c.req.json().catch(() => ({}))) ?? {};
10196
10841
  const parentVersionRaw = readNonEmptyString(body.parent_version);
10197
10842
  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, {
10843
+ const cloned = await forkCompositionFromExistingFork(customer.id, sourceAccess.fork, parentVersion ?? null, readNonEmptyString(body.title) ?? sourceAccess.fork.title);
10844
+ return c.json(serializeFork(cloned, {
10242
10845
  isOwner: true, canView: true, canEdit: true, canPublish: true, canShare: true, canDelete: true, role: "owner"
10243
10846
  }), 201);
10244
10847
  }
@@ -12006,6 +12609,15 @@ app.post("/settings/attachments", async (c) => {
12006
12609
  const attachmentId = randomUUID();
12007
12610
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName);
12008
12611
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
12612
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
12613
+ const thumb = await ensureAttachmentThumbnail({
12614
+ customerId: customer.id,
12615
+ attachmentId,
12616
+ storageKey,
12617
+ contentType,
12618
+ attachmentUrl: publicUrl,
12619
+ sourceUrl: publicUrl
12620
+ });
12009
12621
  await serverlessRecords.createUserAttachment({
12010
12622
  id: attachmentId,
12011
12623
  customerId: customer.id,
@@ -12013,7 +12625,9 @@ app.post("/settings/attachments", async (c) => {
12013
12625
  contentType,
12014
12626
  sizeBytes: buffer.byteLength,
12015
12627
  storageKey,
12016
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey)
12628
+ publicUrl,
12629
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
12630
+ thumbnailUrl: thumb?.thumbnailUrl ?? null
12017
12631
  });
12018
12632
  }
12019
12633
  return redirectToSettings(c, { notice: "Attachment upload complete.", accountId: customer.id });
@@ -13342,6 +13956,7 @@ function serializeReadyPost(c, post, input) {
13342
13956
  status: post.status,
13343
13957
  title: post.title,
13344
13958
  caption: post.caption,
13959
+ folder_path: normalizeFolderPath(post.folderPath ?? ""),
13345
13960
  pinned_comment: post.pinnedComment,
13346
13961
  media: post.mediaAssets.map((asset) => ({
13347
13962
  id: asset.id,
@@ -15991,6 +16606,10 @@ function primitiveAliasPathForKind(kind) {
15991
16606
  return `${PRIMITIVES_PREFIX}/images/render-html`;
15992
16607
  case "image_remove_background":
15993
16608
  return `${PRIMITIVES_PREFIX}/images/remove-background`;
16609
+ case "image_remove_background_greenscreen":
16610
+ return `${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`;
16611
+ case "media_overlay":
16612
+ return `${PRIMITIVES_PREFIX}/images/create-overlay`;
15994
16613
  case "video_generate":
15995
16614
  return `${PRIMITIVES_PREFIX}/videos/generate`;
15996
16615
  case "video_render_slides":
@@ -16651,6 +17270,14 @@ app.post(`${PRIMITIVES_PREFIX}/images/edit`, async (c) => createPrimitiveJob(c,
16651
17270
  app.post(`${PRIMITIVES_PREFIX}/images/inpaint`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_inpaint", operationName: "run" }));
16652
17271
  app.post(`${PRIMITIVES_PREFIX}/images/render-html`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_render_html", operationName: "run" }));
16653
17272
  app.post(`${PRIMITIVES_PREFIX}/images/remove-background`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background", operationName: "run" }));
17273
+ // Cheap local chroma-key background removal. Canonical images/* path plus a flat
17274
+ // plain-English alias matching the primitive's spoken name.
17275
+ app.post(`${PRIMITIVES_PREFIX}/images/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17276
+ app.post(`${PRIMITIVES_PREFIX}/remove-background-greenscreen`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:image_remove_background_greenscreen", operationName: "run" }));
17277
+ // Two-step "create media overlay": BYOK image gen on a forced key-color
17278
+ // background + local chroma key → transparent overlay.
17279
+ app.post(`${PRIMITIVES_PREFIX}/images/create-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
17280
+ app.post(`${PRIMITIVES_PREFIX}/create-media-overlay`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:media_overlay", operationName: "run" }));
16654
17281
  app.post(`${PRIMITIVES_PREFIX}/videos/generate`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_generate", operationName: "run" }));
16655
17282
  app.post(`${PRIMITIVES_PREFIX}/videos/download`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_download", operationName: "run" }));
16656
17283
  app.post(`${PRIMITIVES_PREFIX}/videos/render-slides`, async (c) => createPrimitiveJob(c, { primitiveId: "primitive:video_render_slides", operationName: "run" }));
@@ -16814,6 +17441,27 @@ app.delete(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
16814
17441
  return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
16815
17442
  }
16816
17443
  });
17444
+ // PATCH /api/v1/approved/posts/:postId { folder_path } — move a post between
17445
+ // /approved folders (""=root). Mirrors PATCH /raws/:clipId for the agent.
17446
+ app.patch(`${APPROVED_POSTS_PREFIX}/:postId`, requireAuth, async (c) => {
17447
+ const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
17448
+ if (proxied)
17449
+ return proxied;
17450
+ const post = await serverlessRecords.getReadyPost(String(c.req.param("postId")));
17451
+ if (!post) {
17452
+ return c.json({ error: "Approved post not found." }, 404);
17453
+ }
17454
+ try {
17455
+ const customer = requireReadyPostOwner(c, post);
17456
+ const body = (await c.req.json().catch(() => ({})));
17457
+ const folderPath = normalizeFolderPath(body.folder_path ?? "");
17458
+ const updated = await serverlessRecords.setReadyPostFolder(customer.id, post.id, folderPath);
17459
+ return c.json({ post: updated ? serializeReadyPost(c, updated, { includePrivate: true }) : null });
17460
+ }
17461
+ catch (error) {
17462
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
17463
+ }
17464
+ });
16817
17465
  app.get(`${APPROVED_POSTS_PREFIX}/:postId/download-zip`, async (c) => {
16818
17466
  const proxied = await maybeProxyUnknownReadyPostToUpstream(c);
16819
17467
  if (proxied)
@@ -17073,7 +17721,18 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17073
17721
  const attachmentId = randomUUID();
17074
17722
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
17075
17723
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
17724
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
17076
17725
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17726
+ // Durable poster for the file directory (image = itself, video = extracted
17727
+ // frame from the just-stored object, else none). Fail-soft — never blocks.
17728
+ const thumb = await ensureAttachmentThumbnail({
17729
+ customerId: customer.id,
17730
+ attachmentId,
17731
+ storageKey,
17732
+ contentType,
17733
+ attachmentUrl: publicUrl,
17734
+ sourceUrl: publicUrl
17735
+ });
17077
17736
  await serverlessRecords.createUserAttachment({
17078
17737
  id: attachmentId,
17079
17738
  customerId: customer.id,
@@ -17082,7 +17741,9 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17082
17741
  sizeBytes: buffer.byteLength,
17083
17742
  storageKey,
17084
17743
  folderPath,
17085
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey),
17744
+ publicUrl,
17745
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17746
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17086
17747
  notes,
17087
17748
  notesEmbedding: embedded?.embedding ?? null,
17088
17749
  notesEmbeddingModel: embedded?.model ?? null
@@ -17097,6 +17758,122 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
17097
17758
  return c.json({ error: error instanceof Error ? error.message : "Unable to upload attachment." }, 400);
17098
17759
  }
17099
17760
  });
17761
+ // POST /me/attachments/from-url — save a durable media URL (e.g. a finished
17762
+ // inpaint / generate job's output) into My Files at a chosen folder. Fetched
17763
+ // server-side so cross-origin S3 objects don't need browser CORS. Only trusted
17764
+ // app / storage origins are allowed (no arbitrary SSRF fetch).
17765
+ function isTrustedMediaOrigin(u) {
17766
+ try {
17767
+ if (u.protocol !== "https:" && u.protocol !== "http:")
17768
+ return false;
17769
+ const appOrigin = new URL(config.PUBLIC_BASE_URL).origin;
17770
+ if (u.origin === appOrigin)
17771
+ return true;
17772
+ if (config.AWS_S3_ENDPOINT) {
17773
+ try {
17774
+ if (u.origin === new URL(config.AWS_S3_ENDPOINT).origin)
17775
+ return true;
17776
+ }
17777
+ catch { /* noop */ }
17778
+ }
17779
+ const host = u.hostname.toLowerCase();
17780
+ if (host.endsWith(".amazonaws.com"))
17781
+ return true;
17782
+ if (host.endsWith(".cloudfront.net"))
17783
+ return true;
17784
+ return false;
17785
+ }
17786
+ catch {
17787
+ return false;
17788
+ }
17789
+ }
17790
+ app.post(`${USER_PREFIX}/me/attachments/from-url`, async (c) => {
17791
+ try {
17792
+ const customer = requireCustomer(c);
17793
+ const raw = (await c.req.json().catch(() => null));
17794
+ const src = typeof raw?.source_url === "string" ? raw.source_url.trim() : "";
17795
+ if (!src) {
17796
+ return c.json({ error: "source_url is required." }, 400);
17797
+ }
17798
+ let sourceUrl;
17799
+ try {
17800
+ sourceUrl = new URL(src, config.PUBLIC_BASE_URL);
17801
+ }
17802
+ catch {
17803
+ return c.json({ error: "source_url is not a valid URL." }, 400);
17804
+ }
17805
+ if (!isTrustedMediaOrigin(sourceUrl)) {
17806
+ return c.json({ error: "source_url must point to a vidfarm asset or job output." }, 400);
17807
+ }
17808
+ const folderPath = sanitizeStorageSubpath(typeof raw?.folder_path === "string" ? raw.folder_path : "");
17809
+ const notes = (typeof raw?.notes === "string" ? raw.notes : "").trim().slice(0, 4000) || null;
17810
+ const response = await fetch(sourceUrl, { method: "GET" });
17811
+ if (!response.ok) {
17812
+ return c.json({ error: `Could not fetch the media (${response.status}).` }, 400);
17813
+ }
17814
+ const arrayBuffer = await response.arrayBuffer();
17815
+ const buffer = Buffer.from(arrayBuffer);
17816
+ if (buffer.byteLength > 50 * 1024 * 1024) {
17817
+ return c.json({ error: "My Files uploads can be at most 50 MB." }, 400);
17818
+ }
17819
+ const responseType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
17820
+ // Prefer an explicit file_name; else derive from the URL path; else fall back.
17821
+ const urlName = decodeURIComponent((sourceUrl.pathname.split("/").pop() || "").trim());
17822
+ const requested = typeof raw?.file_name === "string" ? raw.file_name.trim() : "";
17823
+ let fileName = sanitizeFileName(requested || urlName || "media.bin");
17824
+ const contentType = responseType || inferAttachmentContentType(fileName);
17825
+ // Ensure the name carries an extension matching the content type so the file
17826
+ // directory renders it correctly (job outputs are often extensionless keys).
17827
+ if (!/\.[a-z0-9]+$/i.test(fileName)) {
17828
+ const extByType = {
17829
+ "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif",
17830
+ "video/mp4": "mp4", "video/webm": "webm", "audio/mpeg": "mp3", "audio/wav": "wav"
17831
+ };
17832
+ const ext = extByType[contentType.toLowerCase()];
17833
+ if (ext)
17834
+ fileName = `${fileName}.${ext}`;
17835
+ }
17836
+ if (!isAllowedAttachment(fileName, contentType)) {
17837
+ return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
17838
+ }
17839
+ const attachmentId = randomUUID();
17840
+ const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
17841
+ const stored = await storage.putBuffer(storageKey, buffer, contentType);
17842
+ const publicUrl = stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey);
17843
+ const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17844
+ const thumb = await ensureAttachmentThumbnail({
17845
+ customerId: customer.id,
17846
+ attachmentId,
17847
+ storageKey,
17848
+ contentType,
17849
+ attachmentUrl: publicUrl,
17850
+ sourceUrl: publicUrl
17851
+ });
17852
+ await serverlessRecords.createUserAttachment({
17853
+ id: attachmentId,
17854
+ customerId: customer.id,
17855
+ fileName,
17856
+ contentType,
17857
+ sizeBytes: buffer.byteLength,
17858
+ storageKey,
17859
+ folderPath,
17860
+ publicUrl,
17861
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17862
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17863
+ notes,
17864
+ notesEmbedding: embedded?.embedding ?? null,
17865
+ notesEmbeddingModel: embedded?.model ?? null
17866
+ });
17867
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
17868
+ if (!attachment) {
17869
+ return c.json({ error: "Attachment could not be saved." }, 500);
17870
+ }
17871
+ return c.json({ ok: true, attachment: serializeUserAttachment(c, attachment) }, 201);
17872
+ }
17873
+ catch (error) {
17874
+ return c.json({ error: error instanceof Error ? error.message : "Unable to save media to My Files." }, 400);
17875
+ }
17876
+ });
17100
17877
  app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17101
17878
  try {
17102
17879
  const customer = requireCustomer(c);
@@ -17116,6 +17893,17 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17116
17893
  }
17117
17894
  const notes = body.notes?.trim() || null;
17118
17895
  const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
17896
+ const publicUrl = storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey);
17897
+ // The bytes were PUT straight to storage (no local copy here), so the frame
17898
+ // extractor fetches them back from the object's own durable URL. Fail-soft.
17899
+ const thumb = await ensureAttachmentThumbnail({
17900
+ customerId: customer.id,
17901
+ attachmentId: body.attachment_id,
17902
+ storageKey: expectedStorageKey,
17903
+ contentType,
17904
+ attachmentUrl: publicUrl,
17905
+ sourceUrl: publicUrl
17906
+ });
17119
17907
  await serverlessRecords.createUserAttachment({
17120
17908
  id: body.attachment_id,
17121
17909
  customerId: customer.id,
@@ -17124,7 +17912,9 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
17124
17912
  sizeBytes: body.size_bytes,
17125
17913
  storageKey: expectedStorageKey,
17126
17914
  folderPath,
17127
- publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey),
17915
+ publicUrl,
17916
+ thumbnailStorageKey: thumb?.thumbnailStorageKey ?? null,
17917
+ thumbnailUrl: thumb?.thumbnailUrl ?? null,
17128
17918
  notes,
17129
17919
  notesEmbedding: embedded?.embedding ?? null,
17130
17920
  notesEmbeddingModel: embedded?.model ?? null
@@ -17525,6 +18315,36 @@ app.get(`${USER_PREFIX}/me/directory`, async (c) => {
17525
18315
  return c.json({ error: error instanceof Error ? error.message : "Unable to list directory." }, 400);
17526
18316
  }
17527
18317
  });
18318
+ // Cloud passthrough for a `vidfarm serve` box: browse the CLOUD (vidfarm.cc)
18319
+ // directory alongside the local one so the file explorer can offer /local vs
18320
+ // /cloud spaces (and `vidfarm directory --both`). Proxies to the same upstream
18321
+ // routes with the ambient cloud key. No upstream (e.g. running ON cloud) → 400
18322
+ // so the UI simply hides the /cloud tab.
18323
+ app.get(`${USER_PREFIX}/me/cloud/directory`, async (c) => {
18324
+ requireCustomer(c);
18325
+ if (!isUpstreamEnabled())
18326
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18327
+ const qs = new URLSearchParams();
18328
+ for (const key of ["path", "limit", "offset", "content_type"]) {
18329
+ const v = c.req.query(key);
18330
+ if (v)
18331
+ qs.set(key, v);
18332
+ }
18333
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory${qs.toString() ? `?${qs.toString()}` : ""}`);
18334
+ if (!res.ok || !res.json)
18335
+ return c.json({ error: "Cloud directory unavailable." }, 502);
18336
+ return c.json(res.json);
18337
+ });
18338
+ app.post(`${USER_PREFIX}/me/cloud/directory/search`, async (c) => {
18339
+ requireCustomer(c);
18340
+ if (!isUpstreamEnabled())
18341
+ return c.json({ error: "No cloud upstream configured on this box." }, 400);
18342
+ const body = await c.req.json().catch(() => ({}));
18343
+ const res = await upstreamJson(`${USER_PREFIX}/me/directory/search`, { method: "POST", body });
18344
+ if (!res.ok || !res.json)
18345
+ return c.json({ error: "Cloud search unavailable." }, 502);
18346
+ return c.json(res.json);
18347
+ });
17528
18348
  // POST /me/directory/search { query, path?, mode?, limit? }
17529
18349
  // Unified search combining semantic (vector), substring, and absolute-path
17530
18350
  // matching across the in-scope roots. `path` narrows scope to one root/folder;
@@ -17641,6 +18461,24 @@ app.post(`${USER_PREFIX}/me/directory/search`, async (c) => {
17641
18461
  }
17642
18462
  }
17643
18463
  }
18464
+ // ── /approved: no embeddings → substring (title/caption/tracer) + path ─────
18465
+ if (scopeRoots.includes("approved")) {
18466
+ const posts = (await serverlessRecords.listReadyPosts(customer.id))
18467
+ .filter((p) => p.status !== "deleted")
18468
+ .filter((p) => inScopeFolder(p.folderPath ?? ""));
18469
+ const ql = query.toLowerCase();
18470
+ for (const p of posts) {
18471
+ const item = readyPostToDirectoryItem(p);
18472
+ let score = 0;
18473
+ const hay = [p.title, p.caption, p.tracer].filter(Boolean).join(" ").toLowerCase();
18474
+ if (wantSubstring && hay.includes(ql))
18475
+ score += 0.5;
18476
+ if (wantPath && directoryPathContains(item.path, query))
18477
+ score += 0.6;
18478
+ if (score > 0)
18479
+ scored.push({ item, score, semantic: false });
18480
+ }
18481
+ }
17644
18482
  scored.sort((x, y) => y.score - x.score);
17645
18483
  const results = scored.slice(0, limit).map((s) => ({ ...s.item, score: s.score }));
17646
18484
  return c.json({ results, query, mode, scope: parsed.root ?? "all", semantic: semanticUsed });
@@ -17660,6 +18498,9 @@ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
17660
18498
  if (!parsed.root || !parsed.folderPath) {
17661
18499
  return c.json({ error: "Provide a folder path under a root, e.g. /files/brand-assets or /raws/demos." }, 400);
17662
18500
  }
18501
+ if (parsed.root === "projects") {
18502
+ return c.json({ error: "/projects is read-only — folders there are your composition forks. Create a project by forking a template in the editor." }, 400);
18503
+ }
17663
18504
  await serverlessRecords.createUserFileFolder(customer.id, directoryFolderScope(parsed.root), parsed.folderPath);
17664
18505
  return c.json({ ok: true, path: buildDirectoryPath(parsed.root, parsed.folderPath), root: parsed.root, folder_path: parsed.folderPath });
17665
18506
  }
@@ -17667,6 +18508,104 @@ app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
17667
18508
  return c.json({ error: error instanceof Error ? error.message : "Unable to create folder." }, 400);
17668
18509
  }
17669
18510
  });
18511
+ // POST /me/directory/rename { path, new_name, file_id? } — rename a file or a
18512
+ // folder in place. With file_id it's a FILE rename (display name only — the S3
18513
+ // key and view URL are untouched); without it, the trailing segment of `path`
18514
+ // is treated as a FOLDER and renamed (its files, and for /raws its nested
18515
+ // subfolders, move with it). Both the explorer UI and the chat agent call this,
18516
+ // so directory entries — character folders especially — stay tidy over time.
18517
+ app.post(`${USER_PREFIX}/me/directory/rename`, async (c) => {
18518
+ const customer = requireCustomer(c);
18519
+ try {
18520
+ const body = (await c.req.json().catch(() => ({})));
18521
+ const parsed = parseDirectoryPath(body.path ?? "");
18522
+ const rawName = String(body.new_name ?? "").trim();
18523
+ const fileId = String(body.file_id ?? "").trim();
18524
+ if (!parsed.root) {
18525
+ return c.json({ error: "Provide a path under a root (/files, /temp, /raws, /projects, /approved)." }, 400);
18526
+ }
18527
+ if (parsed.root === "projects") {
18528
+ return c.json({ error: "/projects is read-only — its folders are your composition forks and its files are stored composition assets. Rename a project by editing its title in the editor." }, 400);
18529
+ }
18530
+ if (!rawName) {
18531
+ return c.json({ error: "Provide new_name." }, 400);
18532
+ }
18533
+ // ── FILE rename (file_id given) — /files and /temp only ──────────────────
18534
+ if (fileId) {
18535
+ const newName = sanitizeFileName(rawName);
18536
+ if (!newName)
18537
+ return c.json({ error: "Provide a valid new file name." }, 400);
18538
+ if (parsed.root === "files") {
18539
+ const att = await serverlessRecords.getUserAttachment(customer.id, fileId);
18540
+ if (!att)
18541
+ return c.json({ error: "File not found." }, 404);
18542
+ if (!isAllowedAttachment(newName, att.contentType)) {
18543
+ return c.json({ error: `Unsupported file name: ${newName}` }, 400);
18544
+ }
18545
+ const updated = await serverlessRecords.renameUserAttachment(customer.id, fileId, newName);
18546
+ if (!updated)
18547
+ return c.json({ error: "File not found." }, 404);
18548
+ return c.json({
18549
+ ok: true,
18550
+ path: buildDirectoryPath("files", updated.folderPath, newName),
18551
+ file: serializeUserAttachment(c, updated)
18552
+ });
18553
+ }
18554
+ if (parsed.root === "temp") {
18555
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, fileId);
18556
+ if (!file)
18557
+ return c.json({ error: "File not found." }, 404);
18558
+ const updated = await serverlessRecords.renameUserTemporaryFile(customer.id, fileId, newName);
18559
+ if (!updated)
18560
+ return c.json({ error: "File not found." }, 404);
18561
+ return c.json({
18562
+ ok: true,
18563
+ path: buildDirectoryPath("temp", updated.folderPath, newName),
18564
+ file: serializeUserTemporaryFile(c, updated)
18565
+ });
18566
+ }
18567
+ return c.json({ error: "Raws and approved posts are named after their source/title and can't be renamed as files — rename their folder, or use move to reorganize them." }, 400);
18568
+ }
18569
+ // ── FOLDER rename (no file_id) — rename the trailing path segment ─────────
18570
+ const fromFolder = normalizeFolderPath(parsed.folderPath);
18571
+ if (!fromFolder) {
18572
+ return c.json({ error: "Provide a folder path (e.g. /files/characters/zara) or a file_id to rename a file." }, 400);
18573
+ }
18574
+ const newSegment = sanitizeStorageSubpath(rawName);
18575
+ if (!newSegment || newSegment.includes("/")) {
18576
+ return c.json({ error: "A folder name can't be empty or contain slashes." }, 400);
18577
+ }
18578
+ const segs = fromFolder.split("/");
18579
+ segs[segs.length - 1] = newSegment;
18580
+ const toFolder = normalizeFolderPath(segs.join("/"));
18581
+ if (parsed.root === "files") {
18582
+ await serverlessRecords.moveUserFileFolder(customer.id, "my_files", fromFolder, toFolder);
18583
+ await serverlessRecords.moveUserAttachmentFolder(customer.id, fromFolder, toFolder);
18584
+ }
18585
+ else if (parsed.root === "temp") {
18586
+ await serverlessRecords.moveUserFileFolder(customer.id, "temporary", fromFolder, toFolder);
18587
+ await serverlessRecords.moveUserTemporaryFolder(customer.id, fromFolder, toFolder);
18588
+ }
18589
+ else if (parsed.root === "approved") {
18590
+ await serverlessRecords.moveUserFileFolder(customer.id, "approved", fromFolder, toFolder);
18591
+ await serverlessRecords.moveReadyPostFolder(customer.id, fromFolder, toFolder);
18592
+ }
18593
+ else {
18594
+ await serverlessRecords.moveUserFileFolder(customer.id, "raws", fromFolder, toFolder);
18595
+ await clipRecords.moveClipsFolder(customer.id, fromFolder, toFolder);
18596
+ }
18597
+ return c.json({
18598
+ ok: true,
18599
+ path: buildDirectoryPath(parsed.root, toFolder),
18600
+ root: parsed.root,
18601
+ from_folder_path: fromFolder,
18602
+ to_folder_path: toFolder
18603
+ });
18604
+ }
18605
+ catch (error) {
18606
+ return c.json({ error: error instanceof Error ? error.message : "Rename failed." }, 400);
18607
+ }
18608
+ });
17670
18609
  app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
17671
18610
  try {
17672
18611
  const customer = requireCustomer(c);
@@ -18126,8 +19065,10 @@ function renderClipsPage(input) {
18126
19065
  : '<div class="clip-noimg">no thumb</div>';
18127
19066
  var vid = clip.view_url ? '<video muted loop playsinline preload="none" style="display:none;position:absolute;inset:0" src="'+esc(clip.view_url)+'"></video>' : '';
18128
19067
  var tags = topTags(clip.tags).map(function(t){ return '<span class="clip-tag">'+esc(t)+'</span>'; }).join('');
19068
+ // The full original video a hunt was mined from is badged distinctly.
19069
+ var sourceBadge = clip.is_source_original ? '<div class="clip-fullbadge" style="position:absolute;top:6px;left:6px;background:rgba(20,20,22,.82);color:#fff;font-size:11px;font-weight:600;padding:2px 7px;border-radius:6px;">Full video</div>' : '';
18129
19070
  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>'
19071
+ + '<div class="clip-thumb" onmouseleave="var v=this.querySelector(&quot;video&quot;); if(v){v.pause();v.style.display=&quot;none&quot;;}">'+media+vid+sourceBadge+'<div class="clip-range">'+range+' · '+clip.duration_sec.toFixed(1)+'s</div>'+score+'</div>'
18131
19072
  + '<div class="clip-body">'
18132
19073
  + '<div class="clip-desc">'+esc(clip.description||clip.tags&&clip.tags.transcript||'(untagged clip)')+'</div>'
18133
19074
  + '<div class="clip-tags">'+tags+'</div>'
@@ -18403,7 +19344,9 @@ async function serializeClip(clip) {
18403
19344
  ]);
18404
19345
  const { embedding, ...rest } = clip;
18405
19346
  void embedding; // never ship the raw vector to the client
18406
- return { ...rest, view_url, thumbnail_url };
19347
+ // Convenience flag alongside `role` so the raws UI can badge/sort the full
19348
+ // original video without reasoning about the union type.
19349
+ return { ...rest, view_url, thumbnail_url, is_source_original: clip.role === "source" };
18407
19350
  }
18408
19351
  function normalizeScanTier(value) {
18409
19352
  return value === "flash" ? "flash" : "flash-lite";
@@ -18613,11 +19556,27 @@ app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
18613
19556
  // pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
18614
19557
  // compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
18615
19558
  // the caller's own provider key (BYOK) and is never wallet-billed.
19559
+ // Turn a free-text folder name (e.g. "My Campaign 001") into the canonical
19560
+ // single-segment Raws folder slug ("my-campaign-001"). Returns null when the
19561
+ // input is empty so callers keep the source-derived default. Mirrors the slug
19562
+ // transform used elsewhere for auto-named raws folders.
19563
+ function slugifyRawFolder(value) {
19564
+ const slug = String(value ?? "")
19565
+ .toLowerCase()
19566
+ .replace(/[^a-z0-9]+/g, "-")
19567
+ .replace(/^-+|-+$/g, "")
19568
+ .slice(0, 60);
19569
+ return slug || null;
19570
+ }
18616
19571
  app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18617
19572
  const customer = requireCustomer(c);
18618
19573
  try {
18619
19574
  const body = (await c.req.json());
18620
19575
  const importOnly = body.import_only === true;
19576
+ // A caller-named destination folder (the /editor "Clip from Raw" modal sends
19577
+ // the folder name here) overrides the default source-title slug for every
19578
+ // clip this import/hunt produces. Null keeps the source-derived default.
19579
+ const folderOverride = slugifyRawFolder(body.folder_path);
18621
19580
  const guidancePrompt = body.prompt?.trim() || "";
18622
19581
  // Build the hunt spec: explicit structured fields win, the free "what to
18623
19582
  // clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
@@ -18653,6 +19612,11 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18653
19612
  huntSpec.max_clips = parsedPrompt.max_clips;
18654
19613
  if (!huntSpec.source_url && sourceUrl)
18655
19614
  huntSpec.source_url = sourceUrl;
19615
+ // Carry the named destination folder on the hunt spec — it rides the opaque
19616
+ // hunt_spec object end-to-end (cloud Step Functions, local runner, upstream
19617
+ // backup) so mined clips land in `/raws/<folder>` without a pipeline change.
19618
+ if (folderOverride)
19619
+ huntSpec.folder_path = folderOverride;
18656
19620
  // Fill the unspecified-prompt mining defaults (5–15s clips, vertical 9:16,
18657
19621
  // avoid on-screen text). Explicit user/prompt choices above already won;
18658
19622
  // this only fills the gaps. Skipped for import_only (no mining happens).
@@ -18750,6 +19714,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
18750
19714
  sourceVideoId,
18751
19715
  filename,
18752
19716
  tracer,
19717
+ folderPath: folderOverride,
18753
19718
  localSourcePath: importSourcePath && existsSync(importSourcePath) ? importSourcePath : null,
18754
19719
  sourceUrl: huntSpec.source_url ?? null
18755
19720
  });
@@ -19141,13 +20106,16 @@ async function importWholeVideoAsRaw(input) {
19141
20106
  taxonomy_version: ACTIVE_TAXONOMY_VERSION,
19142
20107
  tier: "flash-lite",
19143
20108
  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)),
20109
+ // A caller-named folder wins; otherwise default into a source-derived Raws
20110
+ // subfolder (canonical `/raws/<slug>`).
20111
+ folder_path: input.folderPath
20112
+ ? normalizeFolderPath(input.folderPath)
20113
+ : normalizeFolderPath(String(displayName || "")
20114
+ .toLowerCase()
20115
+ .replace(/\.[a-z0-9]+$/i, "")
20116
+ .replace(/[^a-z0-9]+/g, "-")
20117
+ .replace(/^-+|-+$/g, "")
20118
+ .slice(0, 60)),
19151
20119
  owner_id: input.ownerId,
19152
20120
  created_at: nowIso()
19153
20121
  };
@@ -19183,6 +20151,373 @@ async function importWholeVideoAsRaw(input) {
19183
20151
  rmSync(workRoot, { recursive: true, force: true });
19184
20152
  }
19185
20153
  }
20154
+ // ── /tools/clipper support: resolve a preview URL + save an exact subrange ─────
20155
+ // Resolve a pasted source URL to a DIRECT, playable media URL (plus a human
20156
+ // title / poster) WITHOUT downloading or re-hosting it — the clipper page plays
20157
+ // this URL in a <video> so the user can pick a subrange, and only the chosen
20158
+ // subrange is ever written to our S3. Direct .mp4/.mov links pass through as-is;
20159
+ // social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI downloader
20160
+ // the video_download primitive uses. This is a lightweight metadata lookup — the
20161
+ // billed download/trim happens later in importSubrangeAsRaw when a clip is saved.
20162
+ async function resolveSourcePreview(sourceUrl) {
20163
+ const url = (sourceUrl || "").trim();
20164
+ if (!url)
20165
+ throw new Error("A source video URL is required.");
20166
+ if (looksLikeDirectVideoUrl(url)) {
20167
+ const name = (url.split("/").pop() || "").split("?")[0] || null;
20168
+ return { mediaUrl: url, title: name, thumbnail: null, durationSec: null };
20169
+ }
20170
+ const apiKey = (config.RAPIDAPI_KEY || "").trim();
20171
+ if (!apiKey) {
20172
+ throw new Error("Can't resolve that URL for preview (RAPIDAPI_KEY unset) — paste a direct .mp4 URL instead.");
20173
+ }
20174
+ const lookupRes = await fetch(config.RAPIDAPI_VIDEO_DOWNLOAD_URL, {
20175
+ method: "POST",
20176
+ headers: {
20177
+ "x-rapidapi-key": apiKey,
20178
+ "x-rapidapi-host": config.RAPIDAPI_VIDEO_DOWNLOAD_HOST,
20179
+ "content-type": "application/x-www-form-urlencoded"
20180
+ },
20181
+ body: new URLSearchParams({ url })
20182
+ });
20183
+ if (!lookupRes.ok)
20184
+ throw new Error(`Video lookup failed with HTTP ${lookupRes.status}.`);
20185
+ const lookup = (await lookupRes.json());
20186
+ const chosen = pickBestVideoMedia(lookup.medias ?? []);
20187
+ if (!chosen?.url)
20188
+ throw new Error("That URL returned no playable MP4 to preview.");
20189
+ const durationRaw = typeof lookup.duration === "number" ? lookup.duration : Number(lookup.duration);
20190
+ return {
20191
+ mediaUrl: chosen.url,
20192
+ title: typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null,
20193
+ thumbnail: typeof lookup.thumbnail === "string" && lookup.thumbnail.trim() ? lookup.thumbnail.trim() : null,
20194
+ durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : null
20195
+ };
20196
+ }
20197
+ // Stream a direct media URL to a local file (no RapidAPI). Used when the clipper
20198
+ // hands back an already-resolved preview_url so we don't re-resolve it.
20199
+ async function downloadDirectToFile(mediaUrl, destPath) {
20200
+ mkdirSync(path.dirname(destPath), { recursive: true });
20201
+ const res = await fetch(mediaUrl);
20202
+ if (!res.ok || !res.body)
20203
+ throw new Error(`Source download failed with HTTP ${res.status}.`);
20204
+ await streamPipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
20205
+ }
20206
+ // Trim EXACTLY [startSec, endSec] out of a source video and save that single
20207
+ // subrange as one raw — the /tools/clipper equivalent of importWholeVideoAsRaw,
20208
+ // but frame-trimmed instead of whole. The whole source is fetched once to a
20209
+ // scratch dir, ffmpeg cuts the range, and only the trimmed mp4 (+ its thumbnail)
20210
+ // is written to durable storage. Runs inline; returns the finished clip so the
20211
+ // caller can surface it immediately.
20212
+ async function importSubrangeAsRaw(input) {
20213
+ const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
20214
+ const touchScan = async (patch) => {
20215
+ const scan = await clipRecords.getScan(input.ownerId, input.scanId);
20216
+ if (scan)
20217
+ await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
20218
+ };
20219
+ const touchSource = async (patch) => {
20220
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20221
+ if (source)
20222
+ await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
20223
+ };
20224
+ try {
20225
+ mkdirSync(workRoot, { recursive: true });
20226
+ let videoPath = input.localSourcePath && existsSync(input.localSourcePath) ? input.localSourcePath : null;
20227
+ let ingestedTitle = null;
20228
+ if (!videoPath && input.mediaUrl) {
20229
+ videoPath = path.join(workRoot, "ingest", "source.mp4");
20230
+ await downloadDirectToFile(input.mediaUrl, videoPath);
20231
+ }
20232
+ else if (!videoPath) {
20233
+ const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
20234
+ videoPath = ingested.videoPath;
20235
+ ingestedTitle = ingested.title;
20236
+ await recordRapidApiIngestCost({ ownerId: input.ownerId, scanId: input.scanId, tracer: input.tracer, sourceUrl: input.sourceUrl ?? "" });
20237
+ }
20238
+ const probe = await probeVideo(videoPath);
20239
+ const sourceDurationSec = probe.duration_sec || 0;
20240
+ // Clamp the requested range into the real source duration and enforce end>start.
20241
+ const start = Math.max(0, sourceDurationSec > 0 ? Math.min(input.startSec, sourceDurationSec - 0.05) : input.startSec);
20242
+ const rawEnd = sourceDurationSec > 0 ? Math.min(input.endSec, sourceDurationSec) : input.endSec;
20243
+ const end = Math.max(start + 0.05, rawEnd);
20244
+ const clipDurationSec = Number((end - start).toFixed(3));
20245
+ await touchSource({ status: "scanning", duration_sec: sourceDurationSec });
20246
+ const clipId = createIdV7("clip");
20247
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20248
+ const localClip = path.join(workRoot, `${clipId}.mp4`);
20249
+ const trimmed = await extractClip({
20250
+ videoPath,
20251
+ scene: { index: 0, start_time_sec: start, end_time_sec: end, duration_sec: clipDurationSec },
20252
+ outPath: localClip
20253
+ });
20254
+ if (!trimmed || !existsSync(localClip))
20255
+ throw new Error("Could not trim that subrange.");
20256
+ await storage.putFile(clipKey, localClip, "video/mp4");
20257
+ let thumbKey = "";
20258
+ const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
20259
+ const wroteThumb = await extractThumbnail({
20260
+ videoPath: localClip,
20261
+ scene: { index: 0, start_time_sec: 0, end_time_sec: clipDurationSec, duration_sec: clipDurationSec },
20262
+ outPath: thumbLocal
20263
+ }).catch(() => false);
20264
+ if (wroteThumb && existsSync(thumbLocal)) {
20265
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20266
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20267
+ }
20268
+ const baseName = (input.name || ingestedTitle || input.filename || "clip").trim() || "clip";
20269
+ const description = `${baseName} (${formatClockRange(start)}–${formatClockRange(end)})`;
20270
+ const folderPath = input.folderPath
20271
+ ? normalizeFolderPath(input.folderPath)
20272
+ : normalizeFolderPath(String(baseName).toLowerCase().replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60));
20273
+ const clip = {
20274
+ clip_id: clipId,
20275
+ source_video_id: input.sourceVideoId,
20276
+ source_filename: baseName,
20277
+ start_time_sec: 0,
20278
+ end_time_sec: clipDurationSec,
20279
+ duration_sec: clipDurationSec,
20280
+ file_path: clipKey,
20281
+ thumbnail_path: thumbKey,
20282
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20283
+ description,
20284
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20285
+ tier: "flash-lite",
20286
+ tracer: input.tracer,
20287
+ folder_path: folderPath,
20288
+ owner_id: input.ownerId,
20289
+ created_at: nowIso()
20290
+ };
20291
+ try {
20292
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20293
+ if (embedder?.embeddingModelId) {
20294
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20295
+ if (vector?.length) {
20296
+ clip.embedding = vector;
20297
+ clip.embedding_model = embedder.embeddingModelId;
20298
+ }
20299
+ }
20300
+ }
20301
+ catch (error) {
20302
+ devLog("clip_range.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
20303
+ }
20304
+ await clipRecords.putClip(clip);
20305
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20306
+ await touchSource({ status: "complete", clip_count: 1, filename: baseName });
20307
+ await touchScan({ status: "complete" });
20308
+ return { clipId, clipKey, thumbKey, durationSec: clipDurationSec, sourceStartSec: start, sourceEndSec: end, description, folderPath };
20309
+ }
20310
+ catch (error) {
20311
+ devLog("clip_range.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
20312
+ await touchSource({ status: "failed" }).catch(() => undefined);
20313
+ await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
20314
+ throw error;
20315
+ }
20316
+ finally {
20317
+ rmSync(workRoot, { recursive: true, force: true });
20318
+ }
20319
+ }
20320
+ // Compact H:MM:SS clock for a clip's human description/range label.
20321
+ function formatClockRange(sec) {
20322
+ const s = Math.max(0, Math.floor(sec));
20323
+ const h = Math.floor(s / 3600);
20324
+ const m = Math.floor((s % 3600) / 60);
20325
+ const ss = s % 60;
20326
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
20327
+ return `${h > 0 ? h + ":" : ""}${mm}:${String(ss).padStart(2, "0")}`;
20328
+ }
20329
+ // POST /raws/resolve-source — resolve a pasted URL to a direct preview media URL
20330
+ // (no download, no S3 write). The /tools/clipper page calls this on load so it
20331
+ // can play the source and let the user pick a subrange before anything is saved.
20332
+ app.post(`${RAWS_PREFIX}/resolve-source`, async (c) => {
20333
+ const customer = requireCustomer(c);
20334
+ try {
20335
+ const body = (await c.req.json().catch(() => ({})));
20336
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20337
+ if (!sourceUrl)
20338
+ return c.json({ error: "Provide source_url (a video URL)." }, 400);
20339
+ if (!/^https?:\/\//i.test(sourceUrl))
20340
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20341
+ void customer;
20342
+ const resolved = await resolveSourcePreview(sourceUrl);
20343
+ return c.json({
20344
+ source_url: sourceUrl,
20345
+ preview_url: resolved.mediaUrl,
20346
+ is_direct: looksLikeDirectVideoUrl(sourceUrl),
20347
+ title: resolved.title,
20348
+ duration_sec: resolved.durationSec,
20349
+ thumbnail_url: resolved.thumbnail
20350
+ });
20351
+ }
20352
+ catch (error) {
20353
+ return c.json({ error: error instanceof Error ? error.message : "Could not resolve that video URL." }, 502);
20354
+ }
20355
+ });
20356
+ // POST /raws/clip-range — trim EXACTLY [start_sec, end_sec] from a source and
20357
+ // save that single subrange as one raw. Reusable by the web clipper (which sends
20358
+ // the already-resolved preview_url), the devcli `clipper` command, and the AI
20359
+ // editor-chat. Runs inline and returns the finished clip.
20360
+ app.post(`${RAWS_PREFIX}/clip-range`, async (c) => {
20361
+ const customer = requireCustomer(c);
20362
+ try {
20363
+ const body = (await c.req.json().catch(() => ({})));
20364
+ const sourceUrl = readNonEmptyString(body.source_url) ?? readNonEmptyString(body.video_url) ?? readNonEmptyString(body.url);
20365
+ const mediaUrl = readNonEmptyString(body.preview_url) ?? readNonEmptyString(body.media_url);
20366
+ // Resolve an uploaded source (temp file / My Files attachment) to a local key.
20367
+ let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
20368
+ const tempFileId = readNonEmptyString(body.temp_file_id);
20369
+ const attachmentId = readNonEmptyString(body.attachment_id);
20370
+ if (!resolvedS3Key && tempFileId) {
20371
+ const file = await serverlessRecords.getUserTemporaryFile(customer.id, tempFileId);
20372
+ if (!file)
20373
+ return c.json({ error: "Temporary file not found." }, 404);
20374
+ resolvedS3Key = file.storageKey;
20375
+ }
20376
+ else if (!resolvedS3Key && attachmentId) {
20377
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
20378
+ if (!attachment)
20379
+ return c.json({ error: "Attachment not found in My Files." }, 404);
20380
+ resolvedS3Key = attachment.storageKey;
20381
+ }
20382
+ const localSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
20383
+ if (!sourceUrl && !mediaUrl && !(localSourcePath && existsSync(localSourcePath))) {
20384
+ return c.json({ error: "Provide source_url / preview_url (a video URL) or temp_file_id/attachment_id/s3_key of an upload." }, 400);
20385
+ }
20386
+ if ((sourceUrl && !/^https?:\/\//i.test(sourceUrl)) || (mediaUrl && !/^https?:\/\//i.test(mediaUrl))) {
20387
+ return c.json({ error: "That doesn't look like a valid video URL." }, 400);
20388
+ }
20389
+ // Accept seconds or milliseconds; require a positive-length range.
20390
+ const startSec = body.start_sec != null ? Number(body.start_sec) : body.start_ms != null ? Number(body.start_ms) / 1000 : 0;
20391
+ const endSec = body.end_sec != null ? Number(body.end_sec) : body.end_ms != null ? Number(body.end_ms) / 1000 : NaN;
20392
+ if (!Number.isFinite(startSec) || startSec < 0)
20393
+ return c.json({ error: "start_sec must be a non-negative number." }, 400);
20394
+ if (!Number.isFinite(endSec))
20395
+ return c.json({ error: "Provide end_sec (or duration via end_ms)." }, 400);
20396
+ if (endSec <= startSec)
20397
+ return c.json({ error: "end_sec must be greater than start_sec." }, 400);
20398
+ const tracer = readNonEmptyString(body.tracer) ?? `clipper:${customer.id}`;
20399
+ const folderOverride = slugifyRawFolder(body.folder_path) ?? slugifyRawFolder(tracer);
20400
+ const now = nowIso();
20401
+ const scanId = createIdV7("clipscan");
20402
+ const sourceVideoId = createIdV7("srcvid");
20403
+ const sourcePointer = mediaUrl ?? sourceUrl ?? resolvedS3Key ?? "";
20404
+ const filename = readNonEmptyString(body.filename) ?? readNonEmptyString(body.name) ?? ((sourcePointer.split("/").pop() || "source.mp4").split("?")[0] || "source.mp4");
20405
+ await clipRecords.putSource({ source_video_id: sourceVideoId, owner_id: customer.id, filename, raw_s3_uri: sourceUrl ?? sourcePointer, clip_count: 0, tier: "flash-lite", status: "pending", scan_id: scanId, tracer, created_at: now, updated_at: now });
20406
+ await clipRecords.putScan({ scan_id: scanId, owner_id: customer.id, source_video_id: sourceVideoId, status: "running", tier: "flash-lite", raw_s3_uri: sourceUrl ?? sourcePointer, tracer, created_at: now, updated_at: now });
20407
+ let result;
20408
+ try {
20409
+ result = await importSubrangeAsRaw({
20410
+ ownerId: customer.id,
20411
+ scanId,
20412
+ sourceVideoId,
20413
+ filename,
20414
+ tracer,
20415
+ folderPath: folderOverride,
20416
+ localSourcePath: localSourcePath && existsSync(localSourcePath) ? localSourcePath : null,
20417
+ mediaUrl: mediaUrl ?? null,
20418
+ sourceUrl: sourceUrl ?? null,
20419
+ startSec,
20420
+ endSec,
20421
+ name: readNonEmptyString(body.name) ?? null
20422
+ });
20423
+ }
20424
+ catch (error) {
20425
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 502);
20426
+ }
20427
+ const [viewUrl, thumbnailUrl] = await Promise.all([
20428
+ clipMediaUrl(result.clipKey),
20429
+ result.thumbKey ? clipMediaUrl(result.thumbKey) : Promise.resolve(null)
20430
+ ]);
20431
+ return c.json({
20432
+ clip_id: result.clipId,
20433
+ source_video_id: sourceVideoId,
20434
+ tracer,
20435
+ folder_path: result.folderPath,
20436
+ duration_sec: result.durationSec,
20437
+ source_start_sec: result.sourceStartSec,
20438
+ source_end_sec: result.sourceEndSec,
20439
+ description: result.description,
20440
+ view_url: viewUrl ?? `/clips/${result.clipId}/download`,
20441
+ thumbnail_url: thumbnailUrl
20442
+ });
20443
+ }
20444
+ catch (error) {
20445
+ return c.json({ error: error instanceof Error ? error.message : "Could not clip that subrange." }, 500);
20446
+ }
20447
+ });
20448
+ // Store the full-length ORIGINAL video as a durable role:"source" raw for a
20449
+ // serve-box hunt — the same durable-original behavior the cloud finalize adds,
20450
+ // but reusing the local storage driver. The source is still on disk at finalize,
20451
+ // so it's copied straight in at its ORIGINAL aspect (no 9:16 crop). It lands in
20452
+ // the SAME source-slug folder as its mined clips so /library/raws groups them.
20453
+ // Best-effort; the caller ignores failures so the hunt still completes.
20454
+ async function storeSourceOriginalRawLocal(input) {
20455
+ // Dedupe: never mint a second source-original for this source (re-run safety).
20456
+ const existing = await clipRecords.listClips(input.ownerId, { sourceVideoId: input.sourceVideoId, limit: 5000 });
20457
+ if (existing.some((c) => c.role === "source"))
20458
+ return;
20459
+ const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
20460
+ const tracer = source?.tracer;
20461
+ const displayName = source?.filename || input.filename;
20462
+ const clipId = createIdV7("clip");
20463
+ const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
20464
+ await storage.putFile(clipKey, input.videoPath, "video/mp4");
20465
+ let thumbKey = "";
20466
+ const thumbLocal = path.join(input.workRoot, `source-${clipId}.jpg`);
20467
+ const wroteThumb = await extractThumbnail({
20468
+ videoPath: input.videoPath,
20469
+ scene: { index: 0, start_time_sec: 0, end_time_sec: input.durationSec, duration_sec: input.durationSec },
20470
+ outPath: thumbLocal
20471
+ }).catch(() => false);
20472
+ if (wroteThumb && existsSync(thumbLocal)) {
20473
+ thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
20474
+ await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
20475
+ }
20476
+ const clip = {
20477
+ clip_id: clipId,
20478
+ source_video_id: input.sourceVideoId,
20479
+ source_filename: input.filename,
20480
+ start_time_sec: 0,
20481
+ end_time_sec: input.durationSec,
20482
+ duration_sec: input.durationSec,
20483
+ file_path: clipKey,
20484
+ thumbnail_path: thumbKey,
20485
+ tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
20486
+ description: displayName,
20487
+ taxonomy_version: ACTIVE_TAXONOMY_VERSION,
20488
+ tier: "flash-lite",
20489
+ role: "source",
20490
+ ...(tracer ? { tracer } : {}),
20491
+ // Group with the mined clips: reuse the folder they actually landed in (a
20492
+ // caller-named destination or the source slug) so the "Full video" raw sits
20493
+ // in the same folder. Falls back to the source-title slug when no mined clip
20494
+ // recorded one.
20495
+ folder_path: existing.find((c) => c.folder_path)?.folder_path ?? normalizeFolderPath(String(input.filename || "")
20496
+ .toLowerCase()
20497
+ .replace(/\.[a-z0-9]+$/i, "")
20498
+ .replace(/[^a-z0-9]+/g, "-")
20499
+ .replace(/^-+|-+$/g, "")
20500
+ .slice(0, 60)),
20501
+ owner_id: input.ownerId,
20502
+ created_at: nowIso()
20503
+ };
20504
+ // Best-effort embedding so the full original is findable by meaning too.
20505
+ try {
20506
+ const embedder = await buildClipLibraryEmbedder(input.ownerId);
20507
+ if (embedder?.embeddingModelId) {
20508
+ const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
20509
+ if (vector?.length) {
20510
+ clip.embedding = vector;
20511
+ clip.embedding_model = embedder.embeddingModelId;
20512
+ }
20513
+ }
20514
+ }
20515
+ catch (error) {
20516
+ devLog("clip_scan.source_original_embed_failed", { source_video_id: input.sourceVideoId, ...devErrorFields(error) }, "warn");
20517
+ }
20518
+ await clipRecords.putClip(clip);
20519
+ await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
20520
+ }
19186
20521
  // In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
19187
20522
  // Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
19188
20523
  // per-scene tag/embed → finalize — with local ffmpeg and the resolved
@@ -19236,6 +20571,21 @@ async function runClipScanInProcess(input) {
19236
20571
  // Local agents run one CLI process per call — keep parallelism modest.
19237
20572
  concurrency: input.client.provider === "local-agent" ? 2 : 3,
19238
20573
  onClip: async (clip) => {
20574
+ // Folder the mined clip: a caller-named destination (hunt_spec.folder_path,
20575
+ // e.g. the /editor "Clip from Raw" folder) wins; otherwise fall back to the
20576
+ // source-title slug so the local library groups by source like the cloud
20577
+ // pipeline does. Legacy clips without either stay unfoldered.
20578
+ if (!clip.folder_path) {
20579
+ const sourceSlug = String(input.filename || "")
20580
+ .toLowerCase()
20581
+ .replace(/\.[a-z0-9]+$/i, "")
20582
+ .replace(/[^a-z0-9]+/g, "-")
20583
+ .replace(/^-+|-+$/g, "")
20584
+ .slice(0, 60);
20585
+ const folder = spec.folder_path || sourceSlug;
20586
+ if (folder)
20587
+ clip.folder_path = folder;
20588
+ }
19239
20589
  // Persist through the storage service (local driver) as bare keys —
19240
20590
  // clipMediaUrl resolves clips/… + thumbs/… keys to fetchable URLs.
19241
20591
  const clipKey = `clips/${input.ownerId}/${clip.clip_id}.mp4`;
@@ -19259,6 +20609,20 @@ async function runClipScanInProcess(input) {
19259
20609
  devLog("clip_scan.local_scene_failed", { scan_id: input.scanId, scene_start: scene.start_time_sec, ...devErrorFields(error) }, "warn");
19260
20610
  }
19261
20611
  });
20612
+ // Keep a durable full-length copy of the original as its own role:"source"
20613
+ // raw, grouped with the mined clips — only when the hunt produced clips.
20614
+ // Fail-soft: a failure here must NOT fail the hunt. clip_count stays the
20615
+ // mined count (the source-original is an extra reference raw).
20616
+ if (stored > 0) {
20617
+ await storeSourceOriginalRawLocal({
20618
+ ownerId: input.ownerId,
20619
+ sourceVideoId: input.sourceVideoId,
20620
+ filename: input.filename,
20621
+ videoPath,
20622
+ durationSec: probe.duration_sec,
20623
+ workRoot
20624
+ }).catch((error) => devLog("clip_scan.source_original_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn"));
20625
+ }
19262
20626
  await touchSource({ status: "complete", clip_count: stored });
19263
20627
  await touchScan({ status: "complete" });
19264
20628
  }
@@ -19358,7 +20722,11 @@ async function mirrorUpstreamClipsToLocal(input) {
19358
20722
  }
19359
20723
  await clipRecords.putClip(clip);
19360
20724
  await vectorIndex.upsert(ownerId, clip);
19361
- stored++;
20725
+ // The upstream feed includes the source-original raw (role:"source"); mirror
20726
+ // it down like any clip but keep it OUT of the mined-clip count so clip_count
20727
+ // stays consistent with the cloud/serve pipelines.
20728
+ if (clip.role !== "source")
20729
+ stored++;
19362
20730
  }
19363
20731
  const source = await clipRecords.getSource(ownerId, scan.source_video_id);
19364
20732
  if (source)