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