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