@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.
- package/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +77 -75
- package/dist/src/app.js +1513 -145
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +396 -8
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +33 -17
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +283 -3
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- 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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4214
|
-
const 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
|
-
|
|
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
|
|
4642
|
-
object_position: z.string().optional().describe("CSS object-position
|
|
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
|
-
//
|
|
4761
|
-
//
|
|
4762
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
5470
|
-
//
|
|
5471
|
-
|
|
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, "/
|
|
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
|
|
5617
|
-
//
|
|
5618
|
-
// first save/generate/decompose, then
|
|
5619
|
-
//
|
|
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}/
|
|
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
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
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
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
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
|
|
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
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
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
|
|
10199
|
-
|
|
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
|
|
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
|
|
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
|
|
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("video"); if(v){v.pause();v.style.display="none";}">'+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("video"); if(v){v.pause();v.style.display="none";}">'+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
|
-
|
|
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
|
-
//
|
|
19145
|
-
|
|
19146
|
-
|
|
19147
|
-
.
|
|
19148
|
-
|
|
19149
|
-
|
|
19150
|
-
|
|
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
|
-
|
|
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)
|