@mevdragon/vidfarm-devcli 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/vidfarm-media/SKILL.md +43 -6
- package/SKILL.director.md +37 -23
- package/SKILL.platform.md +6 -6
- package/demo/dist/app.js +69 -69
- package/demo/dist/favicon.ico +0 -0
- package/dist/src/app.js +1832 -213
- package/dist/src/cli.js +238 -17
- package/dist/src/devcli/clip-store.js +29 -2
- package/dist/src/devcli/clips.js +116 -73
- package/dist/src/devcli/composition-edit.js +262 -0
- package/dist/src/devcli/timeline-edit.js +283 -0
- package/dist/src/editor-chat.js +29 -6
- 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 +744 -0
- package/dist/src/frontend/template-editor-chat.js +22 -19
- package/dist/src/landing-page.js +24 -7
- package/dist/src/page-shell.js +25 -1
- package/dist/src/reskin/agency-page.js +214 -170
- package/dist/src/reskin/calendar-page.js +503 -187
- package/dist/src/reskin/chat-page.js +739 -299
- package/dist/src/reskin/discover-page.js +1450 -279
- package/dist/src/reskin/document.js +1392 -16
- package/dist/src/reskin/help-page.js +139 -100
- package/dist/src/reskin/index-page.js +1 -1
- package/dist/src/reskin/inpaint-page.js +547 -0
- package/dist/src/reskin/job-runs-page.js +355 -127
- package/dist/src/reskin/library-page.js +1188 -317
- package/dist/src/reskin/login-page.js +124 -87
- package/dist/src/reskin/pricing-page.js +197 -166
- package/dist/src/reskin/settings-page.js +566 -187
- package/dist/src/reskin/theme.js +434 -17
- package/dist/src/services/clip-curation/gemini.js +5 -0
- package/dist/src/services/clip-curation/hunt.js +79 -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 +14 -1
- package/dist/src/services/clip-search.js +43 -13
- package/dist/src/services/file-directory.js +114 -0
- package/dist/src/services/storage.js +24 -1
- 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/src/assets/favicon.ico +0 -0
- package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/app.js
CHANGED
|
@@ -30,11 +30,13 @@ import { renderReskinLibrary } from "./reskin/library-page.js";
|
|
|
30
30
|
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
|
-
import {
|
|
33
|
+
import { renderReskinInpaint } from "./reskin/inpaint-page.js";
|
|
34
34
|
import { renderReskinAgency } from "./reskin/agency-page.js";
|
|
35
35
|
import { renderReskinPricing } from "./reskin/pricing-page.js";
|
|
36
36
|
import { renderReskinLogin } from "./reskin/login-page.js";
|
|
37
37
|
import { renderReskinHelp } from "./reskin/help-page.js";
|
|
38
|
+
import { renderChatDock, RESKIN_CHROME_SCRIPT } from "./reskin/document.js";
|
|
39
|
+
import { RESKIN_CSS, RESKIN_FONT_LINKS } from "./reskin/theme.js";
|
|
38
40
|
import { POPULAR_INSPIRATION_GROUPS } from "./frontend/homepage-shared.js";
|
|
39
41
|
import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
|
|
40
42
|
import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
|
|
@@ -68,7 +70,8 @@ import { loadSpeechAudioLocally, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./devc
|
|
|
68
70
|
import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
|
|
69
71
|
import { clipRecords, makeUserPreset } from "./services/clip-records.js";
|
|
70
72
|
import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
|
|
71
|
-
import {
|
|
73
|
+
import { ATTACHMENTS_FOLDER_SCOPE, buildDirectoryPath, DIRECTORY_ROOTS, DIRECTORY_ROOT_KEYS, folderItem, immediateChildFolders, normalizeFolderPath, parseDirectoryPath } from "./services/file-directory.js";
|
|
74
|
+
import { applyHuntSpecDefaults, BUILTIN_PRESETS, buildClipEmbeddingText, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, ACTIVE_TAXONOMY_VERSION, estimateScanCostFromDuration, extractThumbnail, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, pickBestVideoMedia, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo } from "./services/clip-curation/index.js";
|
|
72
75
|
import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
|
|
73
76
|
import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
|
|
74
77
|
import { createPrimitiveJobContext } from "./primitive-context.js";
|
|
@@ -101,7 +104,10 @@ const COMPOSITIONS_PREFIX = `${API_PREFIX}/compositions`;
|
|
|
101
104
|
const INSPIRATIONS_PREFIX = `${API_PREFIX}/inspirations`;
|
|
102
105
|
const VIDEOS_PREFIX = `${API_PREFIX}/videos`;
|
|
103
106
|
const PRIMITIVES_PREFIX = `${API_PREFIX}/primitives`;
|
|
104
|
-
const
|
|
107
|
+
const RAWS_PREFIX = "/raws";
|
|
108
|
+
// Back-compat: the clip library was renamed "raws". Old /clips callers are
|
|
109
|
+
// transparently redispatched to /raws (see the alias registered below).
|
|
110
|
+
const CLIPS_COMPAT_PREFIX = "/clips";
|
|
105
111
|
// Temp folder ("My Files → temporary") hard-TTL. Keep in sync with the
|
|
106
112
|
// tag-scoped S3 lifecycle rule (30 days) in the CDK stacks and the clip-scan
|
|
107
113
|
// Lambda's TEMP_SOURCE_TTL_DAYS — records, sweeps, and object lifecycle must
|
|
@@ -843,7 +849,7 @@ function consumeLoginReturnPath(c) {
|
|
|
843
849
|
function resolvePostLoginRedirect(c, customer) {
|
|
844
850
|
const returnPath = consumeLoginReturnPath(c) ?? sanitizeLoginReturnPath(c.req.query("return_to"));
|
|
845
851
|
if (!returnPath) {
|
|
846
|
-
return withAccountQuery("/settings", customer.id);
|
|
852
|
+
return withAccountQuery("/settings/profile", customer.id);
|
|
847
853
|
}
|
|
848
854
|
const parsed = new URL(returnPath, "https://vidfarm.local");
|
|
849
855
|
if (parsed.pathname === "/settings" || parsed.pathname.startsWith("/settings/")) {
|
|
@@ -858,6 +864,10 @@ function setSettingsFlash(c, input) {
|
|
|
858
864
|
function clearSettingsFlash(c) {
|
|
859
865
|
appendCookie(c, `${SETTINGS_FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${cookieSecureAttribute()}`);
|
|
860
866
|
}
|
|
867
|
+
const SETTINGS_TABS = ["profile", "wallet", "channels", "developer"];
|
|
868
|
+
function isSettingsTab(value) {
|
|
869
|
+
return value === "profile" || value === "wallet" || value === "channels" || value === "developer";
|
|
870
|
+
}
|
|
861
871
|
function consumeSettingsFlash(c) {
|
|
862
872
|
const cookies = parseCookieHeader(c.req.header("cookie"));
|
|
863
873
|
const raw = cookies.get(SETTINGS_FLASH_COOKIE);
|
|
@@ -1045,19 +1055,15 @@ function parseTracerList(searchParams) {
|
|
|
1045
1055
|
return output.slice(0, 24);
|
|
1046
1056
|
}
|
|
1047
1057
|
function redirectToSettings(c, input) {
|
|
1048
|
-
|
|
1049
|
-
|
|
1058
|
+
// Notice/error still travel in a short-lived flash cookie; the tab now travels
|
|
1059
|
+
// in the path (/settings/<tab>) so the URL matches the panel that's shown.
|
|
1060
|
+
if (input?.notice || input?.error) {
|
|
1061
|
+
setSettingsFlash(c, { notice: input.notice, error: input.error, tab: input.tab });
|
|
1050
1062
|
}
|
|
1051
|
-
const params = new URLSearchParams();
|
|
1052
1063
|
const accountId = input?.accountId ?? getRequestedAccountId(c);
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
}
|
|
1056
|
-
if (input?.refreshChannels) {
|
|
1057
|
-
params.set("refresh", "channels");
|
|
1058
|
-
}
|
|
1059
|
-
const suffix = params.size ? `?${params.toString()}` : "";
|
|
1060
|
-
return redirect(c, `${accountId ? withAccountQuery("/settings", accountId) : "/settings"}${suffix}`, 303);
|
|
1064
|
+
const tab = isSettingsTab(input?.tab) ? input.tab : "profile";
|
|
1065
|
+
const suffix = input?.refreshChannels ? "?refresh=channels" : "";
|
|
1066
|
+
return redirect(c, `${withAccountQuery(`/settings/${tab}`, accountId)}${suffix}`, 303);
|
|
1061
1067
|
}
|
|
1062
1068
|
async function validateFlockPosterApiKey(apiKey) {
|
|
1063
1069
|
await fetchFlockPosterIntegrations(apiKey);
|
|
@@ -2889,6 +2895,16 @@ function sanitizeStorageSubpath(value) {
|
|
|
2889
2895
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
|
2890
2896
|
.join("/");
|
|
2891
2897
|
}
|
|
2898
|
+
// Temp files auto-organize by date: an upload with no explicit folder lands in
|
|
2899
|
+
// today's YYYY-MM-DD subfolder (canonical `/temp/2026-07-08/…`), so the scratch
|
|
2900
|
+
// space stays browsable instead of a flat dump. Applied at the write points
|
|
2901
|
+
// (presign + direct upload); finalize trusts the folder the presign echoed back.
|
|
2902
|
+
function todayDateFolder() {
|
|
2903
|
+
return new Date().toISOString().slice(0, 10);
|
|
2904
|
+
}
|
|
2905
|
+
function withTempDateFolder(folderPath) {
|
|
2906
|
+
return folderPath || todayDateFolder();
|
|
2907
|
+
}
|
|
2892
2908
|
function inferAttachmentContentType(fileName) {
|
|
2893
2909
|
switch (path.extname(fileName).toLowerCase()) {
|
|
2894
2910
|
case ".png":
|
|
@@ -2966,6 +2982,107 @@ function serializeUserTemporaryFile(c, file) {
|
|
|
2966
2982
|
function buildFolderList(items) {
|
|
2967
2983
|
return Array.from(new Set(items.map((item) => item.folderPath ?? "").filter(Boolean))).sort();
|
|
2968
2984
|
}
|
|
2985
|
+
// ── Unified file directory: convert each backend's record into the one
|
|
2986
|
+
// canonical DirectoryItem shape so /files, /temp, /raws browse uniformly. ─────
|
|
2987
|
+
function attachmentToDirectoryItem(c, a) {
|
|
2988
|
+
const s = serializeUserAttachment(c, a);
|
|
2989
|
+
const folderPath = normalizeFolderPath(a.folderPath);
|
|
2990
|
+
return {
|
|
2991
|
+
path: buildDirectoryPath("files", folderPath, a.fileName),
|
|
2992
|
+
root: "files",
|
|
2993
|
+
folderPath,
|
|
2994
|
+
name: a.fileName,
|
|
2995
|
+
kind: "file",
|
|
2996
|
+
id: a.id,
|
|
2997
|
+
contentType: a.contentType,
|
|
2998
|
+
viewUrl: s.viewUrl,
|
|
2999
|
+
sizeBytes: a.sizeBytes,
|
|
3000
|
+
meta: { notes: s.notes, hasNotesEmbedding: s.hasNotesEmbedding, createdAt: a.createdAt }
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
function temporaryFileToDirectoryItem(c, f) {
|
|
3004
|
+
const s = serializeUserTemporaryFile(c, f);
|
|
3005
|
+
const folderPath = normalizeFolderPath(f.folderPath);
|
|
3006
|
+
return {
|
|
3007
|
+
path: buildDirectoryPath("temp", folderPath, f.fileName),
|
|
3008
|
+
root: "temp",
|
|
3009
|
+
folderPath,
|
|
3010
|
+
name: f.fileName,
|
|
3011
|
+
kind: "file",
|
|
3012
|
+
id: f.id,
|
|
3013
|
+
contentType: f.contentType,
|
|
3014
|
+
viewUrl: s.viewUrl,
|
|
3015
|
+
sizeBytes: f.sizeBytes,
|
|
3016
|
+
meta: { expiresAt: f.expiresAt, createdAt: f.createdAt, s3Url: s.s3Url }
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
// Takes the OUTPUT of serializeClip (view/thumbnail URLs already resolved).
|
|
3020
|
+
function clipSerializedToDirectoryItem(s) {
|
|
3021
|
+
const folderPath = normalizeFolderPath(s.folder_path);
|
|
3022
|
+
const name = s.source_filename || s.description || "clip";
|
|
3023
|
+
const duration = s.duration_sec;
|
|
3024
|
+
return {
|
|
3025
|
+
path: buildDirectoryPath("raws", folderPath, name),
|
|
3026
|
+
root: "raws",
|
|
3027
|
+
folderPath,
|
|
3028
|
+
name,
|
|
3029
|
+
kind: "file",
|
|
3030
|
+
id: s.clip_id,
|
|
3031
|
+
contentType: "video/mp4",
|
|
3032
|
+
viewUrl: s.view_url || undefined,
|
|
3033
|
+
thumbUrl: s.thumbnail_url || undefined,
|
|
3034
|
+
durationSec: typeof duration === "number" ? duration : undefined,
|
|
3035
|
+
meta: { description: s.description, tags: s.tags, created_at: s.created_at, source_video_id: s.source_video_id }
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* List one folder of one directory root, returning immediate child folders + the
|
|
3040
|
+
* files directly in this folder as canonical DirectoryItems. The single place
|
|
3041
|
+
* that dispatches a canonical path to its backend (attachments / temp / raws).
|
|
3042
|
+
*/
|
|
3043
|
+
async function listDirectoryFolder(c, customerId, root, folderPath) {
|
|
3044
|
+
const cur = normalizeFolderPath(folderPath);
|
|
3045
|
+
if (root === "files") {
|
|
3046
|
+
const atts = await serverlessRecords.listUserAttachments(customerId);
|
|
3047
|
+
const explicit = await serverlessRecords.listUserFileFolders(customerId, ATTACHMENTS_FOLDER_SCOPE);
|
|
3048
|
+
const childNames = immediateChildFolders([...explicit, ...atts.map((a) => a.folderPath)], cur);
|
|
3049
|
+
const files = atts
|
|
3050
|
+
.filter((a) => normalizeFolderPath(a.folderPath) === cur)
|
|
3051
|
+
.map((a) => attachmentToDirectoryItem(c, a));
|
|
3052
|
+
return { folders: childNames.map((n) => folderItem("files", cur, n)), files };
|
|
3053
|
+
}
|
|
3054
|
+
if (root === "temp") {
|
|
3055
|
+
const list = await serverlessRecords.listUserTemporaryFiles(customerId);
|
|
3056
|
+
const explicit = await serverlessRecords.listUserFileFolders(customerId, "temporary");
|
|
3057
|
+
const childNames = immediateChildFolders([...explicit, ...list.map((f) => f.folderPath)], cur);
|
|
3058
|
+
const files = list
|
|
3059
|
+
.filter((f) => normalizeFolderPath(f.folderPath) === cur)
|
|
3060
|
+
.map((f) => temporaryFileToDirectoryItem(c, f));
|
|
3061
|
+
return { folders: childNames.map((n) => folderItem("temp", cur, n)), files };
|
|
3062
|
+
}
|
|
3063
|
+
// raws
|
|
3064
|
+
const clips = await clipRecords.listClips(customerId, { limit: 5000 });
|
|
3065
|
+
const explicitRawFolders = await serverlessRecords.listUserFileFolders(customerId, "raws");
|
|
3066
|
+
const childNames = immediateChildFolders([...explicitRawFolders, ...clips.map((clip) => clip.folder_path)], cur);
|
|
3067
|
+
const here = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === cur);
|
|
3068
|
+
const files = (await Promise.all(here.map(serializeClip))).map(clipSerializedToDirectoryItem);
|
|
3069
|
+
return { folders: childNames.map((n) => folderItem("raws", cur, n)), files };
|
|
3070
|
+
}
|
|
3071
|
+
// The user_file_folder scope backing each directory root's empty folders.
|
|
3072
|
+
function directoryFolderScope(root) {
|
|
3073
|
+
return root === "files" ? ATTACHMENTS_FOLDER_SCOPE : root === "temp" ? "temporary" : "raws";
|
|
3074
|
+
}
|
|
3075
|
+
// The three roots presented as folders at the virtual root ("/").
|
|
3076
|
+
function directoryRootFolders() {
|
|
3077
|
+
return DIRECTORY_ROOTS.map((r) => ({
|
|
3078
|
+
path: `/${r.key}`,
|
|
3079
|
+
root: r.key,
|
|
3080
|
+
folderPath: "",
|
|
3081
|
+
name: r.label,
|
|
3082
|
+
kind: "folder",
|
|
3083
|
+
meta: { description: r.description }
|
|
3084
|
+
}));
|
|
3085
|
+
}
|
|
2969
3086
|
function isFormFile(value) {
|
|
2970
3087
|
return Boolean(value
|
|
2971
3088
|
&& typeof value === "object"
|
|
@@ -3142,6 +3259,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3142
3259
|
{ 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? }." },
|
|
3143
3260
|
{ 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? }." },
|
|
3144
3261
|
{ method: "POST", path: `${PRIMITIVES_PREFIX}/images/render-html`, summary: "Primitive HTML still-image render job. Body must be { tracer, payload: { html, css?, width?, height?, background_color?, output_format? }, webhook_url? }." },
|
|
3262
|
+
{ method: "POST", path: `${PRIMITIVES_PREFIX}/images/remove-background`, summary: "Primitive background-removal job. Returns a transparent-background PNG cut-out of the subject (subject isolation for overlays, product cut-outs, sprite cards, caption matting). Body must be { tracer, payload: { source_image_url }, webhook_url? }. BILLS THE WALLET (RapidAPI) — unlike the BYOK images/generate and images/edit routes." },
|
|
3145
3263
|
{ 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." },
|
|
3146
3264
|
{ 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." },
|
|
3147
3265
|
{ 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? }." },
|
|
@@ -3259,11 +3377,11 @@ async function buildClipsEditorChatBoot(input) {
|
|
|
3259
3377
|
const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md") || null;
|
|
3260
3378
|
const availableProviders = (await normalizeEditorChatProviderRows(input.customer.id)).map((entry) => entry.provider);
|
|
3261
3379
|
const docsRoutes = [
|
|
3262
|
-
{ method: "GET", path: `${
|
|
3263
|
-
{ method: "POST", path: `${
|
|
3264
|
-
{ method: "POST", path: `${
|
|
3265
|
-
{ method: "GET", path: `${
|
|
3266
|
-
{ method: "GET", path: `${
|
|
3380
|
+
{ method: "GET", path: `${RAWS_PREFIX}/feed`, summary: "List the caller's reusable clip library (optional ?q, ?source, ?limit)." },
|
|
3381
|
+
{ method: "POST", path: `${RAWS_PREFIX}/search`, summary: "Hybrid structured + semantic clip search. Body is { query } (natural language) or { criteria } (structured filter)." },
|
|
3382
|
+
{ method: "POST", path: `${RAWS_PREFIX}/scan`, summary: "Import a source video and mine it into clips. Body accepts source_url (a public/durable video URL), raw_s3_uri, or s3_key, plus an optional prompt describing which scenes to keep (e.g. 'people with food, no on-screen text, vertical, 5-10s')." },
|
|
3383
|
+
{ method: "GET", path: `${RAWS_PREFIX}/scan/:scanId`, summary: "Poll clip-scan status for an in-progress import." },
|
|
3384
|
+
{ method: "GET", path: `${RAWS_PREFIX}/presets`, summary: "List the caller's saved clip-search presets." }
|
|
3267
3385
|
];
|
|
3268
3386
|
return {
|
|
3269
3387
|
apiUrl: resolveEditorChatApiUrl(),
|
|
@@ -3606,6 +3724,10 @@ function normalizeEditorChatPath(input) {
|
|
|
3606
3724
|
PRIMITIVES_PREFIX,
|
|
3607
3725
|
APPROVED_POSTS_PREFIX,
|
|
3608
3726
|
`${USER_PREFIX}/me/jobs`,
|
|
3727
|
+
// Unified file directory: browse/search /files, /temp, /raws by canonical
|
|
3728
|
+
// path (GET /me/directory, POST /me/directory/search). browse_files uses the
|
|
3729
|
+
// same endpoints; this lets the agent hit them directly via http_request too.
|
|
3730
|
+
`${USER_PREFIX}/me/directory`,
|
|
3609
3731
|
// Read-only catalog discovery so the copilot can answer "which templates
|
|
3610
3732
|
// suit my <offer>?": the template feed (supports ?q=&limit=) and the source
|
|
3611
3733
|
// video / inspiration catalog (supports ?q=&limit=&mine=). Ingest lives at
|
|
@@ -3627,7 +3749,11 @@ function normalizeEditorChatPath(input) {
|
|
|
3627
3749
|
// and NEVER runs GhostCut on the long-form source — "no text" is a
|
|
3628
3750
|
// scene-selection filter; caption removal stays a per-finished-clip
|
|
3629
3751
|
// primitive (/api/v1/primitives/videos/remove-captions).
|
|
3630
|
-
|
|
3752
|
+
RAWS_PREFIX,
|
|
3753
|
+
// Legacy /clips alias (server redispatches /clips/* → /raws/*). Allowed so a
|
|
3754
|
+
// prompt that still says /clips/scan works identically to /raws/scan and the
|
|
3755
|
+
// allowlist doesn't drift from the deployed Lambda copy.
|
|
3756
|
+
CLIPS_COMPAT_PREFIX,
|
|
3631
3757
|
// Public skill knowledge: the vendored skill packs (load_skill fetches
|
|
3632
3758
|
// these too) and per-template SKILL.md manifests. Read-only markdown.
|
|
3633
3759
|
"/skill/",
|
|
@@ -4010,19 +4136,24 @@ function browseFilesTextContentType(fileName) {
|
|
|
4010
4136
|
}
|
|
4011
4137
|
function createBrowseFilesTool(input) {
|
|
4012
4138
|
return tool({
|
|
4013
|
-
description: "Browse AND write the user's
|
|
4139
|
+
description: "Browse AND write the user's file directory — a navigable tree with THREE roots: /files (durable My Files: uploaded assets like mp4, mov, png, jpg, mp3, pdf, md, txt, csv, plus text context you save — vector-searchable via notes), /temp (scratch space auto-foldered by date YYYY-MM-DD, auto-deleted after 30 days), and /raws (the reusable clip/source-footage library, foldered by source). Every folder can have nested subfolders and every file has a canonical copyable path like /raws/product-demos/clip.mp4.\n\nNAVIGATE BY PATH: pass `path` to list or search any location — path='/' lists the three roots; path='/raws' or '/files/brand-assets' lists that folder's subfolders + files. Use action=list to enumerate a folder (folders[] + files[], each a canonical item with path, name, kind, view_url). (Legacy: action=list with folder_path and no path still lists /files only.)\n\nSEARCH: action=search with `query` (plain language) and optional `path` scope finds files by MEANING across roots — it combines semantic vector match, substring, and absolute-path match (control with `mode`: auto|semantic|substring|path). Prefer search when the user references an asset vaguely or you don't know which root/folder it's in; scope with path='/raws' to search only footage, path='/files' for durable assets, or path='/' for everything.\n\nMOVE: action=move with `file_id` (a raw's id) and `path` (target folder, e.g. /raws/demos) reorganizes a raw between /raws folders. Use action=search to find files by MEANING when you don't know where something lives: pass query (plain language, e.g. 'character reference sheet for the mascot' or 'logo on transparent background') and it matches keywords AND vector-embedded metadata notes across every file's name, folder, and notes — prefer search over paging through list when My Files is large or the user references an asset vaguely. Use action=read to fetch one file by file_id (preferred; copy it from a prior list/search result) or by file_name: it returns the file's durable view_url plus metadata, and for text files (md, txt, csv, srt, vtt, json) it also returns the full text_content inline so you can read it. For images, video, audio, and PDFs it returns view_url only — reference that URL directly (drop it into composition layers, or pass it into primitive routes like images/edit source_image_url, videos/generate input_references, or videos/download); you cannot read their raw bytes as text. Use action=write to SAVE a text file into My Files — pass file_name (e.g. About.md), content (the full text), optional folder_path to namescope it under a product/offer folder, and optional notes. This is how you persist Getting Started context the user can reuse later: a product About.md or Interview.md, and awareness-levels.md / persuasive-angles.md / ad-hooks.md distilled from brainstorm output. write saves text files (md, txt, csv, json, srt, vtt) via content, or IMPORTS any media asset into the library by passing its durable URL as source_url — e.g. save a finished image job's output as character_sprite_card.png so it persists in My Files instead of living only in a job result. write returns the new file_id and view_url. Use action=annotate to set metadata notes on ANY existing file (including binary assets): notes say what the file is, who/what it depicts, and when to use it, and they are vector-embedded so future sessions can search their way back to the asset. Annotate every asset worth finding again — especially character references: keep a character_sprite_card.png plus an about_character.md per recurring character in that character's folder, annotate both, and pass the sprite card's view_url into generation reference inputs (images prompt_attachments / videos input_references) for character consistency. Always list or search before you read so you use a real file_id; never invent file ids or view URLs.",
|
|
4014
4140
|
inputSchema: z.object({
|
|
4015
|
-
action: z.enum(["list", "read", "write", "annotate", "search"]).describe("list = enumerate files
|
|
4016
|
-
|
|
4141
|
+
action: z.enum(["list", "read", "write", "annotate", "search", "move"]).describe("list = enumerate a folder's subfolders + files (pass path for any root); read = fetch one file's view_url and, for text files, its contents; write = save a text file into /files; annotate = set the metadata notes on one existing /files entry; search = find files by meaning/name/path across roots; move = move a raw between /raws folders."),
|
|
4142
|
+
path: z.string().optional().describe("Canonical directory path to list or search: '/' for the three roots, '/raws' or '/files/brand-assets' for a folder. For move, the target folder (e.g. /raws/demos). Preferred over folder_path for cross-root navigation."),
|
|
4143
|
+
mode: z.enum(["auto", "semantic", "substring", "path"]).optional().describe("For search: which signals to combine. auto (default) = semantic vector + substring + absolute-path; or force one."),
|
|
4144
|
+
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."),
|
|
4017
4145
|
file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
|
|
4018
4146
|
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."),
|
|
4019
4147
|
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."),
|
|
4020
4148
|
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."),
|
|
4021
4149
|
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."),
|
|
4022
4150
|
query: z.string().optional().describe("For search: what you're looking for, in plain language. Required for search; ignored otherwise."),
|
|
4151
|
+
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."),
|
|
4152
|
+
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."),
|
|
4153
|
+
limit: z.number().int().optional().describe("For list/search: max items to return (list default 200, max 500; search default 30, max 50)."),
|
|
4023
4154
|
explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
|
|
4024
4155
|
}),
|
|
4025
|
-
execute: async ({ action, folder_path, file_id, file_name, content, source_url, notes, query, explanation }) => {
|
|
4156
|
+
execute: async ({ action, path, mode, folder_path, file_id, file_name, content, source_url, notes, query, content_type, offset, limit, explanation }) => {
|
|
4026
4157
|
const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
|
|
4027
4158
|
const headers = new Headers();
|
|
4028
4159
|
headers.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
@@ -4031,7 +4162,67 @@ function createBrowseFilesTool(input) {
|
|
|
4031
4162
|
timeoutController.abort(new Error("Browse files request timed out after 30 seconds."));
|
|
4032
4163
|
}, 30_000);
|
|
4033
4164
|
const normalizedFolder = (folder_path ?? "").trim().replace(/^\/+|\/+$/g, "");
|
|
4165
|
+
// Default list/search to the WHOLE directory ('/') when neither a canonical
|
|
4166
|
+
// path nor a legacy folder_path is given — otherwise these silently fell
|
|
4167
|
+
// back to a /files-only search and the user's /raws + /temp were invisible.
|
|
4168
|
+
const canonicalPath = (path ?? "").trim()
|
|
4169
|
+
|| ((action === "list" || action === "search") && !normalizedFolder ? "/" : "");
|
|
4170
|
+
const contentTypeParam = (content_type ?? "").trim();
|
|
4034
4171
|
try {
|
|
4172
|
+
// ── unified directory navigation (any of /files, /temp, /raws) ──
|
|
4173
|
+
if (action === "list" && canonicalPath) {
|
|
4174
|
+
const url = new URL(`${USER_PREFIX}/me/directory`, config.PUBLIC_BASE_URL);
|
|
4175
|
+
url.searchParams.set("path", canonicalPath);
|
|
4176
|
+
if (contentTypeParam)
|
|
4177
|
+
url.searchParams.set("content_type", contentTypeParam);
|
|
4178
|
+
if (offset !== undefined && Number.isFinite(offset))
|
|
4179
|
+
url.searchParams.set("offset", String(Math.max(0, Math.trunc(offset))));
|
|
4180
|
+
if (limit !== undefined && Number.isFinite(limit))
|
|
4181
|
+
url.searchParams.set("limit", String(Math.max(1, Math.trunc(limit))));
|
|
4182
|
+
const r = await fetch(url, { method: "GET", headers, signal: mergeAbortSignals(input.abortSignal, timeoutController.signal) });
|
|
4183
|
+
const body = await r.json().catch(() => null);
|
|
4184
|
+
return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
|
|
4185
|
+
}
|
|
4186
|
+
if (action === "search" && canonicalPath) {
|
|
4187
|
+
const url = new URL(`${USER_PREFIX}/me/directory/search`, config.PUBLIC_BASE_URL);
|
|
4188
|
+
const searchHeaders = new Headers(headers);
|
|
4189
|
+
searchHeaders.set("content-type", "application/json");
|
|
4190
|
+
const searchLimit = limit !== undefined && Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 30;
|
|
4191
|
+
const r = await fetch(url, {
|
|
4192
|
+
method: "POST",
|
|
4193
|
+
headers: searchHeaders,
|
|
4194
|
+
body: JSON.stringify({
|
|
4195
|
+
query: (query ?? "").trim(),
|
|
4196
|
+
path: canonicalPath,
|
|
4197
|
+
mode: mode ?? "auto",
|
|
4198
|
+
limit: searchLimit,
|
|
4199
|
+
...(contentTypeParam ? { content_type: contentTypeParam } : {})
|
|
4200
|
+
}),
|
|
4201
|
+
signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
|
|
4202
|
+
});
|
|
4203
|
+
const body = await r.json().catch(() => null);
|
|
4204
|
+
return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
|
|
4205
|
+
}
|
|
4206
|
+
if (action === "move") {
|
|
4207
|
+
const id = (file_id ?? "").trim();
|
|
4208
|
+
const seg = canonicalPath.split("/").map((s) => s.trim()).filter(Boolean);
|
|
4209
|
+
const root = seg[0];
|
|
4210
|
+
if (!id || !root)
|
|
4211
|
+
return { action, explanation: explanation ?? null, status: 400, error: "move requires file_id and a target path like /raws/demos." };
|
|
4212
|
+
if (root !== "raws")
|
|
4213
|
+
return { action, explanation: explanation ?? null, status: 400, error: "move is currently supported only within /raws." };
|
|
4214
|
+
const url = new URL(`${RAWS_PREFIX}/${encodeURIComponent(id)}`, config.PUBLIC_BASE_URL);
|
|
4215
|
+
const moveHeaders = new Headers(headers);
|
|
4216
|
+
moveHeaders.set("content-type", "application/json");
|
|
4217
|
+
const r = await fetch(url, {
|
|
4218
|
+
method: "PATCH",
|
|
4219
|
+
headers: moveHeaders,
|
|
4220
|
+
body: JSON.stringify({ folder_path: seg.slice(1).join("/") }),
|
|
4221
|
+
signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
|
|
4222
|
+
});
|
|
4223
|
+
const body = await r.json().catch(() => null);
|
|
4224
|
+
return { action, explanation: explanation ?? null, status: r.status, ...(body || {}) };
|
|
4225
|
+
}
|
|
4035
4226
|
if (action === "write") {
|
|
4036
4227
|
const name = (file_name ?? "").trim();
|
|
4037
4228
|
if (!name) {
|
|
@@ -4412,8 +4603,13 @@ function createEditorActionTool() {
|
|
|
4412
4603
|
"export_composition",
|
|
4413
4604
|
"generate_layer",
|
|
4414
4605
|
"set_captions",
|
|
4415
|
-
"set_transitions"
|
|
4416
|
-
|
|
4606
|
+
"set_transitions",
|
|
4607
|
+
"set_layer_keyframes",
|
|
4608
|
+
"nudge_layers",
|
|
4609
|
+
"ripple_edit",
|
|
4610
|
+
"set_layer_zindex",
|
|
4611
|
+
"trim_layer"
|
|
4612
|
+
]).describe("Which mutation to perform. Use generate_layer to AI-generate a video/image and auto-place it on the timeline (fill a gap or replace a scene). Use export_composition to trigger the same Export button available in the editor UI — this queues an AWS Lambda render and the finished MP4 URL will surface in the next editor_context as last_export_url. Use set_captions to lay down a full run of ANIMATED word-by-word captions (TikTok/CapCut style): pass captions[] cues (or text + start/duration to auto-page), plus caption_style; it replaces any existing animated caption layers. To restyle existing captions without changing text/timing, use set_layer_style with caption_* fields on each caption layer. Use set_transitions to configure scene transitions across the WHOLE timeline in one call: transition = the junction preset applied at every cut (every scene clip except each track's first; 'none' clears), transition_intro = the first clip's entrance, transition_outro = the last clip's exit. FINE TIMELINE CONTROL: set_layer_keyframes authors a custom script-free CSS @keyframes animation on ONE layer (opacity/translate/scale/rotate over the clip) that previews AND renders; nudge_layers moves one or more layers by a relative delta_start/delta_track (group-aware); ripple_edit inserts or closes time at at_time and shifts all downstream clips; trim_layer moves one edge (start|end) of a clip to a time, coupling media in-point on a left trim; set_layer_zindex restacks a layer (front|back|forward|backward) — stacking is the track index."),
|
|
4417
4613
|
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."),
|
|
4418
4614
|
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."),
|
|
4419
4615
|
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."),
|
|
@@ -4483,6 +4679,22 @@ function createEditorActionTool() {
|
|
|
4483
4679
|
resolution: z.string().optional().describe("generate_layer (video): optional resolution (480p|720p|1080p|1K|2K|4K)."),
|
|
4484
4680
|
generate_audio: z.boolean().optional().describe("generate_layer (video): whether the model should generate an audio track."),
|
|
4485
4681
|
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."),
|
|
4682
|
+
keyframes: z.array(z.object({
|
|
4683
|
+
offset: z.number().describe("Position along the animation as a 0..1 fraction (0 = clip start, 1 = end)."),
|
|
4684
|
+
opacity: z.number().optional().describe("Opacity 0..1 at this stop."),
|
|
4685
|
+
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)."),
|
|
4686
|
+
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)."),
|
|
4687
|
+
scale: z.number().optional().describe("Uniform scale factor (1 = natural size, 1.2 = 20% larger)."),
|
|
4688
|
+
rotate: z.number().optional().describe("Rotation in degrees.")
|
|
4689
|
+
})).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}]."),
|
|
4690
|
+
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."),
|
|
4691
|
+
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."),
|
|
4692
|
+
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."),
|
|
4693
|
+
delta_track: z.number().int().optional().describe("nudge_layers: lanes to shift the layer(s) by (negative = down/behind)."),
|
|
4694
|
+
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."),
|
|
4695
|
+
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."),
|
|
4696
|
+
to_time: z.number().optional().describe("trim_layer: the new composition-time position (seconds) for the chosen edge."),
|
|
4697
|
+
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."),
|
|
4486
4698
|
explanation: z.string().describe("One-sentence reason shown in the UI.")
|
|
4487
4699
|
}),
|
|
4488
4700
|
execute: async (args) => {
|
|
@@ -4750,6 +4962,24 @@ function renderHyperframesTimelineRuler(durationSeconds) {
|
|
|
4750
4962
|
}).join("");
|
|
4751
4963
|
return `<div class="hf-timeline-ruler" aria-hidden="true">${marks}</div>`;
|
|
4752
4964
|
}
|
|
4965
|
+
// A not-yet-decomposed project is the raw single-video base: one <video> that
|
|
4966
|
+
// is BOTH the playback source AND its own timeline layer (data-layer-kind).
|
|
4967
|
+
// In the EDITOR its own audio is the point, so strip the baked-in `muted`
|
|
4968
|
+
// attribute when serving the composition to the editor stage (the editor plays
|
|
4969
|
+
// on a user gesture, so muted-autoplay is not needed). This is scoped to the
|
|
4970
|
+
// editor's composition.html load ONLY — card/swipe previews load a different
|
|
4971
|
+
// path and keep `muted` so their silent autoplay-loop still works. Decomposed
|
|
4972
|
+
// compositions emit a data-vf-playback-source WITHOUT data-layer-kind, so they
|
|
4973
|
+
// are left untouched.
|
|
4974
|
+
function unmuteRawPlaybackSourceVideo(html) {
|
|
4975
|
+
return html.replace(/<video\b[^>]*>/gi, (tag) => {
|
|
4976
|
+
if (!/data-vf-playback-source\s*=\s*["']?true/i.test(tag))
|
|
4977
|
+
return tag;
|
|
4978
|
+
if (!/data-layer-kind\s*=\s*["']?video/i.test(tag))
|
|
4979
|
+
return tag;
|
|
4980
|
+
return tag.replace(/\smuted(?=(\s|\/?>))/i, "");
|
|
4981
|
+
});
|
|
4982
|
+
}
|
|
4753
4983
|
function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
4754
4984
|
const baseHref = `/${normalizeDraftRelativePath(draftPath)}/`;
|
|
4755
4985
|
const baseTag = `<base href="${escapeAttribute(baseHref)}">`;
|
|
@@ -4825,6 +5055,72 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4825
5055
|
}
|
|
4826
5056
|
return rewritten;
|
|
4827
5057
|
}
|
|
5058
|
+
// Reskin overrides layered on top of the editor's own /assets/editor/app.css.
|
|
5059
|
+
// RESKIN_CSS scopes its element rules under .rk-root, so the only thing it changes
|
|
5060
|
+
// globally is the --rk-* token set + box-sizing. The editor chrome stays on its
|
|
5061
|
+
// original DARK theme; these rules only pin the reused reskin chat dock as a
|
|
5062
|
+
// permanent left column (the one farmville element we keep here).
|
|
5063
|
+
const EDITOR_FARMVILLE_CSS = `
|
|
5064
|
+
html,body{margin:0;background:#050604;color:#fffbe6}
|
|
5065
|
+
#root{min-height:100vh}
|
|
5066
|
+
|
|
5067
|
+
/* The reused farmville chat dock, pinned flush as a permanent LEFT column
|
|
5068
|
+
(desktop). Width stays natural: 440px for the clean chat column, widening to
|
|
5069
|
+
overlay the editor's left edge only while the Files/History drawer is open. */
|
|
5070
|
+
@media (min-width:1025px){
|
|
5071
|
+
.rk-aichat.rk-aichat--editor{
|
|
5072
|
+
left:0 !important;top:0 !important;bottom:0 !important;right:auto !important;
|
|
5073
|
+
height:100vh !important;max-height:100vh !important;
|
|
5074
|
+
margin:0 !important;border-radius:0 !important;border-right:1px solid var(--rk-border) !important;
|
|
5075
|
+
box-shadow:var(--rk-shadow-lg) !important;transform:none !important;z-index:40
|
|
5076
|
+
}
|
|
5077
|
+
/* Make room for the docked chat: shift the SPA workbench right + drop its
|
|
5078
|
+
now-empty first grid column (the old embedded chat lived there). */
|
|
5079
|
+
.vidfarm-workbench{margin-left:440px !important;grid-template-columns:minmax(0,1fr) !important}
|
|
5080
|
+
}
|
|
5081
|
+
.rk-aichat,.rk-fab{font-family:var(--rk-font-body)}
|
|
5082
|
+
|
|
5083
|
+
/* ── Dark-mode the reused farmville chat dock so it matches the editor's dark
|
|
5084
|
+
chrome. The reskin /reskin pages keep the light warm theme; this remap is
|
|
5085
|
+
scoped to the editor dock only. Almost every dock surface reads a --rk-*
|
|
5086
|
+
token, so remapping the token set here re-themes the panel wholesale; the
|
|
5087
|
+
few hardcoded #fff backgrounds from the shared sheet are overridden below. */
|
|
5088
|
+
.rk-aichat.rk-aichat--editor{
|
|
5089
|
+
--rk-white:#12160d;
|
|
5090
|
+
--rk-surface:#0c0f08;--rk-bg:#0c0f08;--rk-bg-section:#0a0d07;
|
|
5091
|
+
--rk-n-50:#0a0d07;--rk-n-100:#191d12;--rk-n-200:#262b1a;--rk-n-300:#333a24;
|
|
5092
|
+
--rk-n-400:#6b7057;--rk-n-500:#8f9472;--rk-n-600:#b7bd97;--rk-n-700:#dcddc4;--rk-n-800:#eceada;
|
|
5093
|
+
--rk-ink:#fffbe6;--rk-text:#e7e6cd;--rk-text-muted:#9a9d80;--rk-text-faint:#71745c;
|
|
5094
|
+
--rk-border:rgba(255,251,230,.10);--rk-border-strong:rgba(255,251,230,.20);
|
|
5095
|
+
--rk-gold-tint:rgba(252,185,0,.14);--rk-gold-tint2:rgba(252,185,0,.22);
|
|
5096
|
+
--rk-shadow-xs:0 1px 2px 0 rgba(0,0,0,.4);
|
|
5097
|
+
background:#0c0f08;
|
|
5098
|
+
}
|
|
5099
|
+
/* surfaces hardcoded #fff in the shared sheet → dark cards on the editor dock */
|
|
5100
|
+
.rk-aichat--editor .rk-aichat-msg.is-ai,
|
|
5101
|
+
.rk-aichat--editor .rk-aichat-composer,
|
|
5102
|
+
.rk-aichat--editor .rk-aichat-tool,
|
|
5103
|
+
.rk-aichat--editor .rk-aichat-back,
|
|
5104
|
+
.rk-aichat--editor .rk-aichat-fstate button,
|
|
5105
|
+
.rk-aichat--editor .rk-aichat-drop,
|
|
5106
|
+
.rk-aichat--editor .rk-aichat-toolnote,
|
|
5107
|
+
.rk-aichat--editor .rk-aichat-http,
|
|
5108
|
+
.rk-aichat--editor .rk-input,
|
|
5109
|
+
.rk-aichat--editor .rk-aichat-loadmore{background:#141810}
|
|
5110
|
+
.rk-aichat--editor .rk-aichat-back:hover{background:#1b2013}
|
|
5111
|
+
.rk-aichat--editor .rk-aichat-loadmore:hover{background:#1b2013}
|
|
5112
|
+
.rk-aichat--editor .rk-input{color:var(--rk-ink)}
|
|
5113
|
+
.rk-aichat--editor .rk-aichat-frow:hover,
|
|
5114
|
+
.rk-aichat--editor .rk-aichat-hist-body .rk-aichat-frow.is-active{background:#1b2013}
|
|
5115
|
+
.rk-aichat--editor .rk-aichat-err{background:#141810;color:#ff9b8f}
|
|
5116
|
+
.rk-aichat--editor .rk-aichat-toolnote.is-ok{color:#7ee2a8}
|
|
5117
|
+
.rk-aichat--editor .rk-aichat-toolnote.is-ok::before{background:#33d17a}
|
|
5118
|
+
.rk-aichat--editor .rk-aichat-toolnote.is-err{color:#ff9b8f}
|
|
5119
|
+
.rk-aichat--editor .rk-aichat-toolnote.is-err::before{background:#ff6a5a}
|
|
5120
|
+
|
|
5121
|
+
/* Hide the SPA's own embedded chat (replaced by the farmville dock). */
|
|
5122
|
+
.chat-dock,.chat-dock-fab{display:none !important}
|
|
5123
|
+
`;
|
|
4828
5124
|
async function renderHyperframesStudioEditorPage(input) {
|
|
4829
5125
|
const accountId = input.customer?.id ?? null;
|
|
4830
5126
|
const compositionId = input.composition.templateId ?? input.composition.slugId;
|
|
@@ -4833,6 +5129,8 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4833
5129
|
slugId: input.composition.slugId,
|
|
4834
5130
|
templateId: input.composition.templateId,
|
|
4835
5131
|
title: input.composition.title,
|
|
5132
|
+
// "raw" = the user's own new/blank project — never auto-prompt Decompose.
|
|
5133
|
+
origin: input.composition.origin ?? null,
|
|
4836
5134
|
originalUrl: input.composition.originalUrl ?? null,
|
|
4837
5135
|
songName: input.composition.songName ?? null,
|
|
4838
5136
|
songUrl: input.composition.songUrl ?? null,
|
|
@@ -4854,17 +5152,19 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4854
5152
|
<head>
|
|
4855
5153
|
<meta charset="UTF-8" />
|
|
4856
5154
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
4857
|
-
<title>${escapeHtml(input.composition.title)} | Studio</title>
|
|
4858
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
4859
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
5155
|
+
<title>${escapeHtml(input.composition.title)} | VidFarm Studio</title>
|
|
4860
5156
|
<link rel="icon" href="/assets/favicon.ico" />
|
|
4861
5157
|
<link rel="stylesheet" href="/assets/editor/app.css" />
|
|
4862
|
-
|
|
5158
|
+
${RESKIN_FONT_LINKS}
|
|
5159
|
+
<style>${RESKIN_CSS}</style>
|
|
5160
|
+
<style>${EDITOR_FARMVILLE_CSS}</style>
|
|
4863
5161
|
<script src="/assets/sentry-config.js"></script>
|
|
4864
5162
|
</head>
|
|
4865
|
-
<body>
|
|
5163
|
+
<body data-rk-editor-dock>
|
|
4866
5164
|
<div id="root"></div>
|
|
5165
|
+
${renderChatDock("editor")}
|
|
4867
5166
|
<script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
|
|
5167
|
+
<script>${RESKIN_CHROME_SCRIPT}</script>
|
|
4868
5168
|
<script type="module" src="/assets/editor/app.js"></script>
|
|
4869
5169
|
</body>
|
|
4870
5170
|
</html>`;
|
|
@@ -4911,30 +5211,444 @@ app.get("/", async (c) => {
|
|
|
4911
5211
|
// Each page is a standalone document with its own CSS; renders sample data so
|
|
4912
5212
|
// it's viewable without auth. Ported pages are wired here one at a time.
|
|
4913
5213
|
app.get("/reskin", (c) => c.html(renderReskinIndex()));
|
|
4914
|
-
|
|
4915
|
-
//
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
5214
|
+
// Settings lives at /settings/<tab> (profile · wallet · channels · developer).
|
|
5215
|
+
// Tabs still switch client-side without a reload (the page renders every panel
|
|
5216
|
+
// and toggles visibility) but each has its own canonical URL so links,
|
|
5217
|
+
// bookmarks and back/forward land on the right tab.
|
|
5218
|
+
async function renderSettingsTabPage(c, tabOverride) {
|
|
5219
|
+
const customer = await requireBrowserCustomer(c);
|
|
5220
|
+
if (!customer) {
|
|
5221
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5222
|
+
return redirect(c, "/login");
|
|
5223
|
+
}
|
|
5224
|
+
const flash = consumeSettingsFlash(c);
|
|
5225
|
+
const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
|
|
5226
|
+
const activeTab = tabOverride
|
|
5227
|
+
?? (isSettingsTab(flash?.tab)
|
|
5228
|
+
? flash.tab
|
|
5229
|
+
: isSettingsTab(c.req.query("tab"))
|
|
5230
|
+
? c.req.query("tab")
|
|
5231
|
+
: "profile");
|
|
5232
|
+
const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
|
|
5233
|
+
const emailChannels = await getEmailPseudoChannels(customer.id, c);
|
|
5234
|
+
const [wallet, walletEvents] = await Promise.all([
|
|
5235
|
+
fetchBillingWalletSummary(customer.id).catch((error) => ({
|
|
5236
|
+
balanceUsd: 0, totalFundsAddedUsd: 0, totalChargeUsd: 0, totalBaseCostUsd: 0,
|
|
5237
|
+
totalEventCount: 0, updatedAtMs: 0,
|
|
5238
|
+
unavailableReason: error instanceof Error ? error.message : "Unable to load wallet."
|
|
5239
|
+
})),
|
|
5240
|
+
fetchBillingWalletEvents({ customerId: customer.id, limit: 50 }).catch((error) => ({
|
|
5241
|
+
events: [],
|
|
5242
|
+
nextCursor: null,
|
|
5243
|
+
unavailableReason: error instanceof Error ? error.message : "Unable to load billing history."
|
|
5244
|
+
}))
|
|
5245
|
+
]);
|
|
5246
|
+
return c.html(renderReskinSettings({
|
|
5247
|
+
userId: customer.id,
|
|
5248
|
+
currentAccountId: customer.id,
|
|
5249
|
+
name: customer.name,
|
|
5250
|
+
notice: flash?.notice ?? null,
|
|
5251
|
+
error: flash?.error ?? null,
|
|
5252
|
+
activeTab,
|
|
5253
|
+
openFlockPosterDialog: c.req.query("connect") === "flockposter" || (activeTab === "channels" && !flockPosterApiKey),
|
|
5254
|
+
email: customer.email,
|
|
5255
|
+
isPaidPlan: customer.isPaidPlan,
|
|
5256
|
+
isDeveloper: customer.isDeveloper,
|
|
5257
|
+
about: customer.about,
|
|
5258
|
+
groupchatUrl: customer.groupchatUrl,
|
|
5259
|
+
flockposterApiKey: flockPosterApiKey,
|
|
5260
|
+
emailChannelIconUrl: await getEmailChannelIconUrl(c),
|
|
5261
|
+
emailChannels,
|
|
5262
|
+
vidfarmApiKey: await getVisibleApiKey(customer.id),
|
|
5263
|
+
billingUrl: config.VIDFARM_BILLING_URL?.trim() || null,
|
|
5264
|
+
directorSkill,
|
|
5265
|
+
wallet,
|
|
5266
|
+
walletEvents,
|
|
5267
|
+
flockposterChannels: [],
|
|
5268
|
+
flockposterChannelsError: null,
|
|
5269
|
+
providerKeys: (await listProviderKeysWithSecretsForCustomer(customer.id)).map((entry) => ({
|
|
5270
|
+
id: String(entry.id),
|
|
5271
|
+
provider: String(entry.provider),
|
|
5272
|
+
label: entry.label ? String(entry.label) : null,
|
|
5273
|
+
secret: readStoredSecret(String(entry.secret)),
|
|
5274
|
+
status: String(entry.status),
|
|
5275
|
+
created_at: String(entry.created_at),
|
|
5276
|
+
last_used_at: entry.last_used_at ? String(entry.last_used_at) : null
|
|
5277
|
+
})),
|
|
5278
|
+
attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
|
|
5279
|
+
}));
|
|
5280
|
+
}
|
|
5281
|
+
// Bare /settings redirects to its canonical first tab; legacy ?tab= / ?connect=
|
|
5282
|
+
// links are preserved by folding them into the path/query of the target.
|
|
5283
|
+
app.get("/settings", (c) => {
|
|
5284
|
+
const q = c.req.query("tab");
|
|
5285
|
+
const tab = isSettingsTab(q) ? q : "profile";
|
|
5286
|
+
const connect = c.req.query("connect");
|
|
5287
|
+
const path = connect ? `/settings/${tab}?connect=${encodeURIComponent(connect)}` : `/settings/${tab}`;
|
|
5288
|
+
return redirect(c, withAccountQuery(path, getRequestedAccountId(c)));
|
|
5289
|
+
});
|
|
5290
|
+
app.get("/settings/profile", (c) => renderSettingsTabPage(c, "profile"));
|
|
5291
|
+
app.get("/settings/wallet", (c) => renderSettingsTabPage(c, "wallet"));
|
|
5292
|
+
app.get("/settings/channels", (c) => renderSettingsTabPage(c, "channels"));
|
|
5293
|
+
app.get("/settings/developer", (c) => renderSettingsTabPage(c, "developer"));
|
|
5294
|
+
// Library hosts tabs at distinct URLs: Approved projects, mined Raws, and the
|
|
5295
|
+
// full-page Files directory explorer (plus a right-floated Logs button → /library/logs).
|
|
5296
|
+
app.get("/library", (c) => redirect(c, "/library/approved"));
|
|
5297
|
+
const reskinLibraryHandler = async (c, tab) => {
|
|
5298
|
+
const customer = await getPreferredBrowserCustomer(c);
|
|
5299
|
+
if (!customer) {
|
|
5300
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5301
|
+
return redirect(c, "/login");
|
|
5302
|
+
}
|
|
5303
|
+
// Files is the self-contained directory explorer (client-fetches /me/directory),
|
|
5304
|
+
// so it needs none of the posts/channels payload — skip that comparatively
|
|
5305
|
+
// expensive work for it.
|
|
5306
|
+
if (tab === "files") {
|
|
5307
|
+
return c.html(renderReskinLibrary(tab, {
|
|
5308
|
+
userId: customer.id,
|
|
5309
|
+
currentAccountId: customer.id,
|
|
5310
|
+
name: customer.name,
|
|
5311
|
+
email: customer.email,
|
|
5312
|
+
posts: [],
|
|
5313
|
+
schedule: { channels: [], connectHref: "/settings/channels?connect=flockposter" },
|
|
5314
|
+
editorChat: null
|
|
5315
|
+
}));
|
|
5316
|
+
}
|
|
5317
|
+
const emailChannels = await getEmailPseudoChannels(customer.id, c);
|
|
5318
|
+
const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
|
|
5319
|
+
const scheduleChannels = [
|
|
5320
|
+
...emailChannels.map((channel) => ({ id: channel.id, name: channel.name, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl })),
|
|
5321
|
+
...flockPoster.channels.map((channel) => ({ id: channel.id, name: channel.title, title: channel.title, handle: channel.handle, platform: channel.platform, status: channel.status, avatarUrl: channel.avatarUrl }))
|
|
5322
|
+
];
|
|
5323
|
+
const localPosts = await Promise.all((await serverlessRecords.listReadyPosts(customer.id))
|
|
5324
|
+
.filter((post) => (post.source ?? "legacy") === "hyperframes")
|
|
5325
|
+
.filter((post) => post.status === "ready" || post.status === "scheduled")
|
|
5326
|
+
.map(async (post) => ({ ...serializeReadyPost(c, post, { includePrivate: true }), template_id: post.compositionId ?? null })));
|
|
5327
|
+
const posts = await mergeUpstreamReadyPosts(localPosts);
|
|
5328
|
+
return c.html(renderReskinLibrary(tab, {
|
|
5329
|
+
userId: customer.id,
|
|
5330
|
+
currentAccountId: customer.id,
|
|
5331
|
+
name: customer.name,
|
|
5332
|
+
email: customer.email,
|
|
5333
|
+
posts,
|
|
5334
|
+
schedule: { channels: scheduleChannels, connectHref: "/settings/channels?connect=flockposter" },
|
|
5335
|
+
editorChat: null
|
|
5336
|
+
}));
|
|
5337
|
+
};
|
|
5338
|
+
app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
|
|
5339
|
+
app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
|
|
5340
|
+
app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs, floated-right button (not a tab)
|
|
5341
|
+
app.get("/library/files", (c) => reskinLibraryHandler(c, "files")); // full-page file-directory explorer
|
|
5342
|
+
app.get("/library/clips", (c) => redirect(c, "/library/raws")); // renamed Clips → Raws
|
|
5343
|
+
// Discover has three modes, each on its own path: /discover (feed default),
|
|
5344
|
+
// /discover/swipe, /discover/categories. These exact page paths sit alongside
|
|
5345
|
+
// the JSON APIs at /discover/feed, /discover/popular and /discover/swipe/*
|
|
5346
|
+
// (which are all deeper/other paths, so there's no collision).
|
|
5347
|
+
// Shared feed builder — the single source of both the JSON /discover/feed
|
|
5348
|
+
// response and the SSR seed inlined into the reskin Feed page's boot. Feed
|
|
5349
|
+
// browses INSPIRATIONS (the caller's OWN inspirations plus the curated
|
|
5350
|
+
// popularGroup catalog), never the global public pool; raw own-projects are
|
|
5351
|
+
// excluded (they live in the Library). NOTE: for a logged-in caller this
|
|
5352
|
+
// finalizes still-downloading inspirations as a side effect — the feed poll
|
|
5353
|
+
// doubles as the completion check.
|
|
5354
|
+
async function collectDiscoverFeedEntries(customer, opts) {
|
|
5355
|
+
const { query, limit } = opts;
|
|
5356
|
+
const entries = [];
|
|
5357
|
+
const seenInspirations = new Set();
|
|
5358
|
+
const pushInspiration = (inspiration) => {
|
|
5359
|
+
if (seenInspirations.has(inspiration.id))
|
|
5360
|
+
return;
|
|
5361
|
+
if ((inspiration.origin ?? "inspiration") === "raw")
|
|
5362
|
+
return;
|
|
5363
|
+
seenInspirations.add(inspiration.id);
|
|
5364
|
+
entries.push(buildInspirationFeedEntry(inspiration));
|
|
5365
|
+
};
|
|
5366
|
+
if (customer) {
|
|
5367
|
+
// The caller's own inspirations rank first (finalized → "ready" here).
|
|
5368
|
+
const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id));
|
|
5369
|
+
const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
|
|
5370
|
+
finalized
|
|
5371
|
+
.filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
|
|
5372
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
5373
|
+
.forEach(pushInspiration);
|
|
5374
|
+
}
|
|
5375
|
+
// Curated/official catalog: public, ready inspirations flagged popularGroup.
|
|
5376
|
+
const curated = (await serverlessRecords.listInspirationsFeed({ limit, query }))
|
|
5377
|
+
.filter((inspiration) => typeof inspiration.popularGroup === "string" && inspiration.popularGroup.trim().length > 0);
|
|
5378
|
+
curated.forEach(pushInspiration);
|
|
5379
|
+
// Cloud passthrough (vidfarm serve): surface the upstream Feed behind the
|
|
5380
|
+
// local one, deduped on inspirationId (→ templateId). Fail-soft.
|
|
5381
|
+
if (isUpstreamEnabled()) {
|
|
5382
|
+
const search = new URLSearchParams({ limit: String(limit) });
|
|
5383
|
+
if (query)
|
|
5384
|
+
search.set("q", query);
|
|
5385
|
+
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
5386
|
+
const seen = new Set(entries.map((entry) => entry.inspirationId || entry.templateId));
|
|
5387
|
+
for (const entry of upstreamFeed) {
|
|
5388
|
+
const key = entry && (entry.inspirationId || entry.templateId);
|
|
5389
|
+
if (entry && typeof key === "string" && key && !seen.has(key) && entry.origin !== "raw") {
|
|
5390
|
+
entries.push(entry);
|
|
5391
|
+
seen.add(key);
|
|
5392
|
+
}
|
|
5393
|
+
}
|
|
5394
|
+
}
|
|
5395
|
+
return entries.slice(0, limit);
|
|
5396
|
+
}
|
|
5397
|
+
const renderDiscoverMode = async (c, mode) => {
|
|
5398
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5399
|
+
let feedInitial;
|
|
5400
|
+
let feedFetchedAt;
|
|
5401
|
+
if (mode === "feed") {
|
|
5402
|
+
// SSR-seed the first page of the default feed into the boot so the client
|
|
5403
|
+
// paints instantly, skipping the redundant follow-up fetch. Fail-soft: on
|
|
5404
|
+
// any error we omit the seed and the client cold-fetches as before. The
|
|
5405
|
+
// limit mirrors the client's un-parameterized fetch (clampPageLimit → 100).
|
|
5406
|
+
try {
|
|
5407
|
+
feedInitial = await collectDiscoverFeedEntries(customer, { query: null, limit: clampPageLimit(Number.NaN, 100, 200) });
|
|
5408
|
+
feedFetchedAt = new Date().toISOString();
|
|
5409
|
+
}
|
|
5410
|
+
catch {
|
|
5411
|
+
feedInitial = undefined;
|
|
5412
|
+
}
|
|
5413
|
+
}
|
|
5414
|
+
return c.html(renderReskinDiscover(mode, {
|
|
5415
|
+
accountId: customer?.id,
|
|
5416
|
+
isLoggedIn: Boolean(customer),
|
|
5417
|
+
name: customer?.name ?? null,
|
|
5418
|
+
email: customer?.email ?? null,
|
|
5419
|
+
feedInitial,
|
|
5420
|
+
feedFetchedAt
|
|
5421
|
+
}));
|
|
5422
|
+
};
|
|
5423
|
+
app.get("/discover", async (c) => {
|
|
5424
|
+
// Every mode has its own canonical path — Feed included — so redirect the bare
|
|
5425
|
+
// /discover (and old ?view= links) onto it. Feed then lives at /discover/feed,
|
|
5426
|
+
// which content-negotiates: browser navigations get the page, fetch/API get
|
|
5427
|
+
// the JSON feed (see the /discover/feed handler below).
|
|
5428
|
+
const view = c.req.query("view");
|
|
5429
|
+
const account = c.req.query(ACCOUNT_QUERY_PARAM);
|
|
5430
|
+
const suffix = account ? `?${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(account)}` : "";
|
|
5431
|
+
if (view === "swipe" || view === "categories") {
|
|
5432
|
+
return redirect(c, `/discover/${view}${suffix}`);
|
|
5433
|
+
}
|
|
5434
|
+
return redirect(c, `/discover/feed${suffix}`);
|
|
5435
|
+
});
|
|
5436
|
+
app.get("/discover/swipe", (c) => renderDiscoverMode(c, "swipe"));
|
|
5437
|
+
app.get("/discover/categories", (c) => renderDiscoverMode(c, "categories"));
|
|
5438
|
+
// Brainstorm chat. /chat is a fresh conversation; /chat/:id deep-links to a
|
|
5439
|
+
// saved thread (the client auto-opens it and keeps the URL in sync as the user
|
|
5440
|
+
// switches / starts conversations).
|
|
5441
|
+
const renderChatBrainstorm = async (c, initialThreadId) => {
|
|
5442
|
+
const customer = await getPreferredBrowserCustomer(c);
|
|
5443
|
+
if (!customer) {
|
|
5444
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5445
|
+
return redirect(c, "/login");
|
|
5446
|
+
}
|
|
5447
|
+
return c.html(renderReskinChat({
|
|
5448
|
+
userId: customer.id,
|
|
5449
|
+
currentAccountId: customer.id,
|
|
5450
|
+
name: customer.name,
|
|
5451
|
+
email: customer.email,
|
|
5452
|
+
editorChat: await buildChatBrainstormEditorChatBoot({ customer })
|
|
5453
|
+
}, { initialThreadId: initialThreadId ?? null }));
|
|
5454
|
+
};
|
|
5455
|
+
app.get("/chat", (c) => renderChatBrainstorm(c));
|
|
5456
|
+
app.get("/chat/:id", (c) => renderChatBrainstorm(c, c.req.param("id")));
|
|
5457
|
+
// Boot for the global pop-panel AI chat (renderChatDock, on every reskin page).
|
|
5458
|
+
// The dock is shared chrome so we can't thread a boot through every route —
|
|
5459
|
+
// instead it fetches this lazily on first open (cookie-authed, same-origin) to
|
|
5460
|
+
// get the real editor-chat endpoint + api key + brainstorm template context.
|
|
5461
|
+
// Returns { editorChat: null } for signed-out visitors so the dock degrades to
|
|
5462
|
+
// a friendly "sign in to chat" state instead of erroring.
|
|
5463
|
+
app.get("/chat-dock/boot", async (c) => {
|
|
5464
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5465
|
+
if (!customer)
|
|
5466
|
+
return c.json({ editorChat: null });
|
|
5467
|
+
return c.json({ editorChat: await buildChatBrainstormEditorChatBoot({ customer }) });
|
|
5468
|
+
});
|
|
5469
|
+
// /reskin/inpaint — the "Create Image" masked-image editor (reached from the
|
|
5470
|
+
// chat page's studio-mode select). Auth-gated like the other tools.
|
|
5471
|
+
app.get("/inpaint", async (c) => {
|
|
5472
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5473
|
+
if (!customer) {
|
|
5474
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5475
|
+
return redirect(c, "/login");
|
|
5476
|
+
}
|
|
5477
|
+
return c.html(renderReskinInpaint({ accountId: customer.id, isLoggedIn: true }));
|
|
5478
|
+
});
|
|
5479
|
+
app.get("/calendar", async (c) => {
|
|
5480
|
+
const customer = await requireBrowserCustomer(c);
|
|
5481
|
+
if (!customer) {
|
|
5482
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5483
|
+
return redirect(c, "/login");
|
|
5484
|
+
}
|
|
5485
|
+
const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
|
|
5486
|
+
const emailChannels = await getEmailPseudoChannels(customer.id, c);
|
|
5487
|
+
return c.html(renderReskinCalendar({
|
|
5488
|
+
userId: customer.id,
|
|
5489
|
+
currentAccountId: customer.id,
|
|
5490
|
+
name: customer.name,
|
|
5491
|
+
email: customer.email,
|
|
5492
|
+
channels: emailChannels.map((channel) => ({
|
|
5493
|
+
id: channel.id,
|
|
5494
|
+
name: channel.title,
|
|
5495
|
+
handle: channel.handle,
|
|
5496
|
+
platform: channel.platform,
|
|
5497
|
+
avatarUrl: channel.avatarUrl,
|
|
5498
|
+
platformIconUrl: channel.platformIconUrl
|
|
5499
|
+
})),
|
|
5500
|
+
posts: [],
|
|
5501
|
+
loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
|
|
5502
|
+
hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
|
|
5503
|
+
connectChannelsHref: "/settings/channels?connect=flockposter"
|
|
5504
|
+
}));
|
|
5505
|
+
});
|
|
5506
|
+
// Job runs were renamed and folded into the Library "Logs" tab.
|
|
5507
|
+
app.get("/job-runs", (c) => redirect(c, "/library/logs"));
|
|
5508
|
+
app.get("/agency", async (c) => {
|
|
5509
|
+
const customer = await requireBrowserCustomer(c);
|
|
5510
|
+
return c.html(renderReskinAgency({
|
|
5511
|
+
userId: customer?.id ?? null,
|
|
5512
|
+
currentAccountId: customer?.id ?? null,
|
|
5513
|
+
name: customer?.name ?? null,
|
|
5514
|
+
email: customer?.email ?? null,
|
|
5515
|
+
loggedIn: Boolean(customer)
|
|
5516
|
+
}));
|
|
5517
|
+
});
|
|
5518
|
+
app.get("/pricing", async (c) => {
|
|
5519
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5520
|
+
return c.html(renderReskinPricing({ email: customer?.email ?? "" }));
|
|
5521
|
+
});
|
|
5522
|
+
app.get("/login", (c) => c.html(renderReskinLogin({ mode: getLoginMode(c.req.query("mode")), routePrefix: "/login" })));
|
|
5523
|
+
app.post("/login/password", async (c) => {
|
|
5524
|
+
const mode = getLoginMode(c.req.query("mode"));
|
|
5525
|
+
const routePrefix = "/login";
|
|
5526
|
+
const parsed = passwordLoginSchema.safeParse(await parseFormBody(c));
|
|
5527
|
+
if (!parsed.success)
|
|
5528
|
+
return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email and password." }), 400);
|
|
5529
|
+
try {
|
|
5530
|
+
const result = await auth.authenticateWithPassword(parsed.data.email, parsed.data.password);
|
|
5531
|
+
if (result.status === "pricing")
|
|
5532
|
+
return redirect(c, "/pricing");
|
|
5533
|
+
setBrowserSession(c, result.customer);
|
|
5534
|
+
return redirect(c, resolvePostLoginRedirect(c, result.customer));
|
|
5535
|
+
}
|
|
5536
|
+
catch (error) {
|
|
5537
|
+
return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to login." }), 401);
|
|
5538
|
+
}
|
|
5539
|
+
});
|
|
5540
|
+
app.post("/login/otp/request", async (c) => {
|
|
5541
|
+
const mode = getLoginMode(c.req.query("mode"));
|
|
5542
|
+
const routePrefix = "/login";
|
|
5543
|
+
const parsed = otpRequestSchema.safeParse(await parseFormBody(c));
|
|
5544
|
+
if (!parsed.success)
|
|
5545
|
+
return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter a valid email address." }), 400);
|
|
5546
|
+
try {
|
|
5547
|
+
const result = await auth.requestOtp(parsed.data.email);
|
|
5548
|
+
const message = result.delivery === "console" && result.code
|
|
5549
|
+
? `Local dev mode: OTP is ${result.code}.`
|
|
5550
|
+
: "OTP sent. Enter the code to continue.";
|
|
5551
|
+
return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, message }));
|
|
5552
|
+
}
|
|
5553
|
+
catch (error) {
|
|
5554
|
+
return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, error: error instanceof Error ? error.message : "Unable to send OTP." }), 502);
|
|
5555
|
+
}
|
|
5556
|
+
});
|
|
5557
|
+
app.post("/login/otp/verify", async (c) => {
|
|
5558
|
+
const mode = getLoginMode(c.req.query("mode"));
|
|
5559
|
+
const routePrefix = "/login";
|
|
5560
|
+
const parsed = otpVerifySchema.safeParse(await parseFormBody(c));
|
|
5561
|
+
if (!parsed.success)
|
|
5562
|
+
return c.html(renderReskinLogin({ mode, routePrefix, error: "Enter the email and 6-digit OTP." }), 400);
|
|
5563
|
+
try {
|
|
5564
|
+
const result = await auth.verifyOtpForBrowserLogin(parsed.data.email, parsed.data.code, parsed.data.name);
|
|
5565
|
+
if (result.status === "pricing") {
|
|
5566
|
+
clearBrowserSession(c, result.customer.id);
|
|
5567
|
+
return redirect(c, "/pricing");
|
|
5568
|
+
}
|
|
5569
|
+
setBrowserSession(c, result.customer);
|
|
5570
|
+
return redirect(c, resolvePostLoginRedirect(c, result.customer));
|
|
5571
|
+
}
|
|
5572
|
+
catch (error) {
|
|
5573
|
+
return c.html(renderReskinLogin({ mode, routePrefix, email: parsed.data.email, otpSent: true, error: error instanceof Error ? error.message : "Unable to verify OTP." }), 401);
|
|
5574
|
+
}
|
|
5575
|
+
});
|
|
5576
|
+
app.post("/logout", async (c) => {
|
|
5577
|
+
const formBody = await parseFormBody(c);
|
|
5578
|
+
const customer = await requireBrowserCustomer(c, formBody);
|
|
5579
|
+
if (customer) {
|
|
5580
|
+
clearBrowserSession(c, customer.id);
|
|
5581
|
+
}
|
|
5582
|
+
return redirect(c, "/login");
|
|
5583
|
+
});
|
|
5584
|
+
app.get("/help", async (c) => {
|
|
5585
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5586
|
+
return c.html(renderReskinHelp({
|
|
5587
|
+
account: { isLoggedIn: Boolean(customer), userId: customer?.id ?? null, displayName: customer?.name ?? null, email: customer?.email ?? null },
|
|
5588
|
+
expand: c.req.query("expand") ?? null
|
|
5589
|
+
}));
|
|
5590
|
+
});
|
|
5591
|
+
// The reskin IS the app now — every old /reskin/* URL 301/302s to its promoted
|
|
5592
|
+
// canonical route so existing bookmarks/links keep working. The /reskin gallery
|
|
5593
|
+
// (above) is kept as a dev reference; its cards link to the canonical routes.
|
|
5594
|
+
app.get("/reskin/settings", (c) => redirect(c, "/settings"));
|
|
5595
|
+
app.get("/reskin/library", (c) => redirect(c, "/library/approved"));
|
|
5596
|
+
app.get("/reskin/library/approved", (c) => redirect(c, "/library/approved"));
|
|
5597
|
+
app.get("/reskin/library/raws", (c) => redirect(c, "/library/raws"));
|
|
5598
|
+
app.get("/reskin/library/logs", (c) => redirect(c, "/library/logs"));
|
|
5599
|
+
app.get("/reskin/library/clips", (c) => redirect(c, "/library/raws"));
|
|
5600
|
+
app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
|
|
5601
|
+
app.get("/reskin/discover", (c) => redirect(c, "/discover"));
|
|
4922
5602
|
app.get("/reskin/discover/:mode", (c) => {
|
|
4923
|
-
const
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
app.get("/reskin/
|
|
4929
|
-
app.get("/reskin/
|
|
4930
|
-
app.get("/reskin/
|
|
4931
|
-
app.get("/reskin/
|
|
4932
|
-
app.get("/reskin/
|
|
4933
|
-
app.get("/reskin/login", (c) => c
|
|
4934
|
-
app.get("/reskin/help", (c) => c.html(renderReskinHelp()));
|
|
5603
|
+
const m = c.req.param("mode");
|
|
5604
|
+
return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
|
|
5605
|
+
});
|
|
5606
|
+
app.get("/reskin/chat", (c) => redirect(c, "/chat"));
|
|
5607
|
+
app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
|
|
5608
|
+
app.get("/reskin/calendar", (c) => redirect(c, "/calendar"));
|
|
5609
|
+
app.get("/reskin/job-runs", (c) => redirect(c, "/library/logs"));
|
|
5610
|
+
app.get("/reskin/agency", (c) => redirect(c, "/agency"));
|
|
5611
|
+
app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
|
|
5612
|
+
app.get("/reskin/help", (c) => redirect(c, "/help"));
|
|
5613
|
+
app.get("/reskin/login", (c) => redirect(c, "/login"));
|
|
4935
5614
|
app.get("/discover", async (c) => renderApprovedHomepage(c));
|
|
4936
|
-
|
|
4937
|
-
|
|
5615
|
+
// Blank / new-project editor. The "New project" button and the empty-library
|
|
5616
|
+
// CTAs point here. There is no template or fork yet: we boot the SPA into an
|
|
5617
|
+
// empty composition, and the client mints a real template_* + fork_* on the
|
|
5618
|
+
// first save/generate/decompose, then history.replaceState's the URL to the
|
|
5619
|
+
// canonical /editor/:templateId/fork/:forkId form.
|
|
5620
|
+
async function renderBlankEditorPage(c) {
|
|
5621
|
+
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5622
|
+
if (!customer) {
|
|
5623
|
+
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
5624
|
+
return redirect(c, "/login");
|
|
5625
|
+
}
|
|
5626
|
+
const threadParam = c.req.query("thread");
|
|
5627
|
+
const blankComposition = {
|
|
5628
|
+
title: "Untitled project",
|
|
5629
|
+
templateId: "original",
|
|
5630
|
+
slugId: "original",
|
|
5631
|
+
difficulty: "easy",
|
|
5632
|
+
viralDna: "",
|
|
5633
|
+
previewUrl: null,
|
|
5634
|
+
approvedAt: null,
|
|
5635
|
+
sourceType: "tiktok",
|
|
5636
|
+
durationSeconds: null,
|
|
5637
|
+
originalUrl: null,
|
|
5638
|
+
songName: null,
|
|
5639
|
+
songUrl: null,
|
|
5640
|
+
// Raw origin: a brand-new project, so the SPA never auto-opens Decompose.
|
|
5641
|
+
origin: "raw",
|
|
5642
|
+
initialThreadId: threadParam ?? null
|
|
5643
|
+
};
|
|
5644
|
+
return c.html(await renderHyperframesStudioEditorPage({ composition: blankComposition, customer }));
|
|
5645
|
+
}
|
|
5646
|
+
// Shared handler for both the bare /editor/:templateId (resolver) route and the
|
|
5647
|
+
// canonical /editor/:templateId/fork/:forkId (path-form) route. `forkParam` is
|
|
5648
|
+
// the fork id from the URL path (path route) or null (bare route). We validate
|
|
5649
|
+
// it, and when it's missing/stale/uneditable we resolve a per-user fork and
|
|
5650
|
+
// redirect to the canonical path form.
|
|
5651
|
+
async function renderEditorPage(c, templateId, forkParam) {
|
|
4938
5652
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
4939
5653
|
if (!templateId.startsWith("template_"))
|
|
4940
5654
|
return c.notFound();
|
|
@@ -4947,7 +5661,7 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4947
5661
|
await seedFromUpstream({
|
|
4948
5662
|
customerId: customer.id,
|
|
4949
5663
|
templateId,
|
|
4950
|
-
forkId:
|
|
5664
|
+
forkId: forkParam ?? undefined,
|
|
4951
5665
|
shareToken: readShareToken(c) ?? undefined
|
|
4952
5666
|
});
|
|
4953
5667
|
template = await serverlessRecords.getTemplate(templateId);
|
|
@@ -4958,7 +5672,6 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
4958
5672
|
// template id doesn't leak to other accounts.
|
|
4959
5673
|
if (template.visibility === "private" && template.customerId !== customer?.id)
|
|
4960
5674
|
return c.notFound();
|
|
4961
|
-
const forkParam = c.req.query("fork");
|
|
4962
5675
|
const threadParam = c.req.query("thread");
|
|
4963
5676
|
// Validate the caller-provided fork. Stale bookmarks / share links keep
|
|
4964
5677
|
// pointing at forks we've since hard-deleted, and if we blindly pass the
|
|
@@ -5035,11 +5748,11 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5035
5748
|
// source of truth for "which chat is active" via localStorage. Injecting
|
|
5036
5749
|
// the server's most-recent thread into the URL blows away a fresh
|
|
5037
5750
|
// client-side "New chat" the user just started but hasn't persisted.
|
|
5038
|
-
const path = `/editor/${encodeURIComponent(template.id)}
|
|
5751
|
+
const path = `/editor/${encodeURIComponent(template.id)}/fork/${encodeURIComponent(targetForkId)}`;
|
|
5039
5752
|
return redirect(c, path);
|
|
5040
5753
|
}
|
|
5041
5754
|
// No fork exists yet for this template + user — redirect to the bare
|
|
5042
|
-
// /editor/{templateId} URL so any stale
|
|
5755
|
+
// /editor/{templateId} URL so any stale fork segment gets stripped and the
|
|
5043
5756
|
// Decompose modal opens fresh (client-side flow mints a new fork on
|
|
5044
5757
|
// first save/decompose).
|
|
5045
5758
|
if (forkParam) {
|
|
@@ -5066,6 +5779,21 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5066
5779
|
initialThreadId: threadParam ?? null
|
|
5067
5780
|
};
|
|
5068
5781
|
return c.html(await renderHyperframesStudioEditorPage({ composition: syntheticComposition, customer }));
|
|
5782
|
+
}
|
|
5783
|
+
// Canonical path-form editor route. Sentinel /editor/original/fork/new opens a
|
|
5784
|
+
// blank new project; everything else validates/resolves the fork in the path.
|
|
5785
|
+
app.get("/editor/:templateId/fork/:forkId", async (c) => {
|
|
5786
|
+
const templateId = c.req.param("templateId");
|
|
5787
|
+
const forkId = c.req.param("forkId");
|
|
5788
|
+
if (templateId === "original" && forkId === "new") {
|
|
5789
|
+
return renderBlankEditorPage(c);
|
|
5790
|
+
}
|
|
5791
|
+
return renderEditorPage(c, templateId, forkId);
|
|
5792
|
+
});
|
|
5793
|
+
// Bare editor route: resolves a per-user fork and redirects to the path form.
|
|
5794
|
+
app.get("/editor/:templateId", async (c) => {
|
|
5795
|
+
const templateId = c.req.param("templateId");
|
|
5796
|
+
return renderEditorPage(c, templateId, null);
|
|
5069
5797
|
});
|
|
5070
5798
|
app.get("/editor/:templateId/composition", async (c) => {
|
|
5071
5799
|
const templateId = c.req.param("templateId");
|
|
@@ -5092,6 +5820,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}`, (c) => redirect(c, withAccountQuery("/libr
|
|
|
5092
5820
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/discover"));
|
|
5093
5821
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
|
|
5094
5822
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat`, (c) => dispatchScopedGetToLegacyRoute(c, "/chat"));
|
|
5823
|
+
app.get(`${ACCOUNT_FRONTEND_PREFIX}/chat/:id`, (c) => dispatchScopedGetToLegacyRoute(c, `/chat/${c.req.param("id")}`));
|
|
5095
5824
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
|
|
5096
5825
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
|
|
5097
5826
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
|
|
@@ -5101,8 +5830,24 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyR
|
|
|
5101
5830
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs/history`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs/history"));
|
|
5102
5831
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar/feed`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar/feed"));
|
|
5103
5832
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/flockposter`, (c) => dispatchScopedGetToLegacyRoute(c, "/settings/flockposter"));
|
|
5833
|
+
// Per-tab settings pages under an account scope (registered AFTER the static
|
|
5834
|
+
// /settings/flockposter route so that stays reachable).
|
|
5835
|
+
app.get(`${ACCOUNT_FRONTEND_PREFIX}/settings/:tab`, (c) => {
|
|
5836
|
+
const tab = c.req.param("tab");
|
|
5837
|
+
return dispatchScopedGetToLegacyRoute(c, `/settings/${isSettingsTab(tab) ? tab : "profile"}`);
|
|
5838
|
+
});
|
|
5104
5839
|
app.post(`${ACCOUNT_FRONTEND_PREFIX}/settings/wallet/funding-link`, (c) => dispatchScopedPostToLegacyRoute(c, "/settings/wallet/funding-link"));
|
|
5105
5840
|
app.get("/discover/feed", async (c) => {
|
|
5841
|
+
// Content negotiation: a browser NAVIGATION to /discover/feed (Accept:
|
|
5842
|
+
// text/html) renders the Feed page — this is the canonical Feed URL now that
|
|
5843
|
+
// /discover redirects here. Everything else (the in-page loader, which sends
|
|
5844
|
+
// Accept: application/json, plus agents/devcli/upstream passthrough) falls
|
|
5845
|
+
// through to the JSON feed below. The two never collide because the page's
|
|
5846
|
+
// own fetch is explicitly application/json.
|
|
5847
|
+
const acceptHeader = (c.req.header("accept") || "").toLowerCase();
|
|
5848
|
+
if (acceptHeader.includes("text/html") && !acceptHeader.includes("application/json")) {
|
|
5849
|
+
return renderDiscoverMode(c, "feed");
|
|
5850
|
+
}
|
|
5106
5851
|
const limit = clampPageLimit(Number(c.req.query("limit") || ""), 100, 200);
|
|
5107
5852
|
// Optional keyword search so callers (UI, agents, devcli) can ask "which
|
|
5108
5853
|
// templates suit my <offer>?" and get a ranked subset instead of the full
|
|
@@ -5110,66 +5855,12 @@ app.get("/discover/feed", async (c) => {
|
|
|
5110
5855
|
// decompose-derived promotions/keywords/summary.
|
|
5111
5856
|
const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
|
|
5112
5857
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5113
|
-
const
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
// Finalizing pending inspirations here is what mints the private template
|
|
5117
|
-
// as soon as the download job completes — the feed poll doubles as the
|
|
5118
|
-
// completion check.
|
|
5119
|
-
const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id))
|
|
5120
|
-
.filter((inspiration) => inspiration.visibility === "private");
|
|
5121
|
-
const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
|
|
5122
|
-
const ownTemplates = (await serverlessRecords.listTemplatesForCustomer(customer.id))
|
|
5123
|
-
.filter((template) => template.visibility === "private")
|
|
5124
|
-
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
5125
|
-
const pending = finalized
|
|
5126
|
-
.filter((inspiration) => !inspiration.templateId)
|
|
5127
|
-
.filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
|
|
5128
|
-
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
5129
|
-
for (const inspiration of pending) {
|
|
5130
|
-
templateEntries.push(buildPendingInspirationHomepageEntry(inspiration));
|
|
5131
|
-
}
|
|
5132
|
-
for (const template of ownTemplates.filter((template) => recordMatchesSearchQuery(template, query))) {
|
|
5133
|
-
const entry = buildTemplateHomepageEntry(template);
|
|
5134
|
-
if (entry)
|
|
5135
|
-
templateEntries.push(entry);
|
|
5136
|
-
}
|
|
5137
|
-
}
|
|
5138
|
-
const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
|
|
5139
|
-
for (const template of templates) {
|
|
5140
|
-
// Curated "Popular" picks live only under the Popular toggle
|
|
5141
|
-
// (GET /discover/popular) — keep them out of the continuously-grown Feed
|
|
5142
|
-
// so the two views stay distinct.
|
|
5143
|
-
if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
|
|
5144
|
-
continue;
|
|
5145
|
-
const entry = buildTemplateHomepageEntry(template);
|
|
5146
|
-
if (entry)
|
|
5147
|
-
templateEntries.push(entry);
|
|
5148
|
-
}
|
|
5149
|
-
// Cloud passthrough (vidfarm serve): the local records are just the working
|
|
5150
|
-
// set of seeded templates, so surface the upstream (cloud) catalog behind
|
|
5151
|
-
// them. Local entries win on id conflicts — a seeded template also exists
|
|
5152
|
-
// upstream and the local copy reflects any local edits. Fail-soft: an
|
|
5153
|
-
// unreachable upstream degrades to the local-only feed.
|
|
5154
|
-
if (isUpstreamEnabled()) {
|
|
5155
|
-
const search = new URLSearchParams({ limit: String(limit) });
|
|
5156
|
-
if (query)
|
|
5157
|
-
search.set("q", query);
|
|
5158
|
-
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
5159
|
-
const seen = new Set(templateEntries.map((entry) => entry.templateId));
|
|
5160
|
-
for (const entry of upstreamFeed) {
|
|
5161
|
-
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
|
|
5162
|
-
templateEntries.push(entry);
|
|
5163
|
-
seen.add(entry.templateId);
|
|
5164
|
-
}
|
|
5165
|
-
}
|
|
5166
|
-
}
|
|
5167
|
-
// The public feed is identical for every caller, but a logged-in response
|
|
5168
|
-
// interleaves the caller's private templates — never let a shared cache
|
|
5169
|
-
// serve that to someone else.
|
|
5858
|
+
const entries = await collectDiscoverFeedEntries(customer, { query, limit });
|
|
5859
|
+
// The Feed interleaves the caller's own inspirations, so a logged-in response
|
|
5860
|
+
// must never be served from a shared cache.
|
|
5170
5861
|
c.header("cache-control", customer ? "private, max-age=10" : "public, max-age=60");
|
|
5171
5862
|
return c.json({
|
|
5172
|
-
templates:
|
|
5863
|
+
templates: entries,
|
|
5173
5864
|
next_cursor: null
|
|
5174
5865
|
});
|
|
5175
5866
|
});
|
|
@@ -5318,8 +6009,40 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5318
6009
|
function play() {
|
|
5319
6010
|
try { if (window.__player) { window.__player.play(); } } catch (e) {}
|
|
5320
6011
|
}
|
|
6012
|
+
// The runtime paints video through a single backing <video> whose CSS
|
|
6013
|
+
// background is near-black, so until a frame decodes the card shows solid
|
|
6014
|
+
// black behind the captions. Drive that element directly as a
|
|
6015
|
+
// belt-and-suspenders fallback: a muted backing video may autoplay even
|
|
6016
|
+
// if the runtime's own play() was swallowed, and forcing a load()/frame
|
|
6017
|
+
// decode guarantees the first frame paints instead of the black
|
|
6018
|
+
// background — many source clips fade in from black, so we also nudge a
|
|
6019
|
+
// hair off 0 to avoid parking on a dark intro frame.
|
|
6020
|
+
var backingVideo = document.querySelector('[data-vf-playback-source]');
|
|
6021
|
+
var framePainted = false;
|
|
6022
|
+
function kickBackingVideo() {
|
|
6023
|
+
if (!backingVideo) return;
|
|
6024
|
+
try { backingVideo.muted = true; backingVideo.playsInline = true; } catch (e) {}
|
|
6025
|
+
try { if (backingVideo.readyState < 2) { backingVideo.load(); } } catch (e) {}
|
|
6026
|
+
try { var p = backingVideo.play(); if (p && p.catch) { p.catch(function () {}); } } catch (e) {}
|
|
6027
|
+
}
|
|
6028
|
+
if (backingVideo) {
|
|
6029
|
+
backingVideo.addEventListener('loadeddata', function () {
|
|
6030
|
+
kickBackingVideo();
|
|
6031
|
+
if (!framePainted) {
|
|
6032
|
+
framePainted = true;
|
|
6033
|
+
// Only nudge if the runtime hasn't already advanced playback.
|
|
6034
|
+
try {
|
|
6035
|
+
if (backingVideo.currentTime < 0.05 && (!window.__player || !window.__player.isPlaying())) {
|
|
6036
|
+
backingVideo.currentTime = 0.12;
|
|
6037
|
+
}
|
|
6038
|
+
} catch (e) {}
|
|
6039
|
+
}
|
|
6040
|
+
});
|
|
6041
|
+
backingVideo.addEventListener('canplay', kickBackingVideo);
|
|
6042
|
+
}
|
|
5321
6043
|
applySound(false);
|
|
5322
6044
|
play();
|
|
6045
|
+
kickBackingVideo();
|
|
5323
6046
|
var lastWant = false;
|
|
5324
6047
|
var stalled = 0;
|
|
5325
6048
|
var mutedFallback = false;
|
|
@@ -5328,7 +6051,7 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5328
6051
|
var want = wantSound();
|
|
5329
6052
|
if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
|
|
5330
6053
|
applySound(want && !mutedFallback);
|
|
5331
|
-
if (!window.__player || !window.__player.getDuration) { return; }
|
|
6054
|
+
if (!window.__player || !window.__player.getDuration) { kickBackingVideo(); return; }
|
|
5332
6055
|
var t = window.__player.getTime(), d = window.__player.getDuration();
|
|
5333
6056
|
if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
|
|
5334
6057
|
else if (!window.__player.isPlaying()) {
|
|
@@ -5338,6 +6061,7 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5338
6061
|
// the preview never freezes on a frame.
|
|
5339
6062
|
if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
|
|
5340
6063
|
play();
|
|
6064
|
+
kickBackingVideo();
|
|
5341
6065
|
} else { stalled = 0; }
|
|
5342
6066
|
} catch (e) {}
|
|
5343
6067
|
}, 400);
|
|
@@ -5847,6 +6571,36 @@ app.post("/discover/templates", async (c) => {
|
|
|
5847
6571
|
});
|
|
5848
6572
|
return c.json(serializeInspiration(inspiration), 202);
|
|
5849
6573
|
});
|
|
6574
|
+
// Remix (decompose on demand): mint the template for a Feed inspiration the
|
|
6575
|
+
// moment the user chooses to open it. The Feed browses lightweight inspirations
|
|
6576
|
+
// with no template, so this is where a template is first created — for the
|
|
6577
|
+
// caller's own inspirations and for curated/public ones alike (a non-owner then
|
|
6578
|
+
// gets their own fork when the editor opens template_<id>). Idempotent: an
|
|
6579
|
+
// already-decomposed inspiration just returns its existing template id.
|
|
6580
|
+
app.post("/discover/inspirations/:id/decompose", async (c) => {
|
|
6581
|
+
const customer = await requireBrowserCustomer(c);
|
|
6582
|
+
if (!customer)
|
|
6583
|
+
return c.json({ ok: false, error: "Log in to remix an inspiration." }, 401);
|
|
6584
|
+
const inspiration = await serverlessRecords.getInspiration(c.req.param("id"));
|
|
6585
|
+
if (!inspiration)
|
|
6586
|
+
return c.json({ ok: false, error: "Inspiration not found." }, 404);
|
|
6587
|
+
// Only the owner may open a PRIVATE inspiration; public/curated ones are
|
|
6588
|
+
// remixable by anyone (the editor forks per-user on open).
|
|
6589
|
+
if (inspiration.visibility === "private" && inspiration.customerId !== customer.id) {
|
|
6590
|
+
return c.notFound();
|
|
6591
|
+
}
|
|
6592
|
+
if (inspiration.templateId) {
|
|
6593
|
+
return c.json({ ok: true, templateId: inspiration.templateId });
|
|
6594
|
+
}
|
|
6595
|
+
if (inspiration.status !== "ready" || !inspiration.videoUrl) {
|
|
6596
|
+
return c.json({ ok: false, error: "This inspiration is still processing — try again in a moment." }, 409);
|
|
6597
|
+
}
|
|
6598
|
+
const decomposed = await mintTemplateForInspiration(inspiration);
|
|
6599
|
+
if (!decomposed.templateId) {
|
|
6600
|
+
return c.json({ ok: false, error: "Could not prepare this inspiration for editing." }, 500);
|
|
6601
|
+
}
|
|
6602
|
+
return c.json({ ok: true, templateId: decomposed.templateId });
|
|
6603
|
+
});
|
|
5850
6604
|
// Delete a private discover entry the caller owns. Accepts either the minted
|
|
5851
6605
|
// template id (template_...) or the raw inspiration id (inspiration_..., i.e.
|
|
5852
6606
|
// a still-processing/failed card) and removes both records. Public catalog
|
|
@@ -5900,6 +6654,51 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
5900
6654
|
// on its owner's /discover as a placeholder card so the Add Template flow has
|
|
5901
6655
|
// immediate feedback. It has no template yet, so the editor CTA is disabled
|
|
5902
6656
|
// client-side via status.
|
|
6657
|
+
// Feed card for a single INSPIRATION (Feed browses inspirations, not templates).
|
|
6658
|
+
// Lightweight: no template is required. If the inspiration has already minted a
|
|
6659
|
+
// template (templateId set), the card opens the editor directly; otherwise the
|
|
6660
|
+
// card previews the source video and its CTA decomposes on demand (preview →
|
|
6661
|
+
// Remix). Covers ready / downloading / failed states in one shape.
|
|
6662
|
+
function buildInspirationFeedEntry(inspiration) {
|
|
6663
|
+
const isReady = inspiration.status === "ready";
|
|
6664
|
+
const isFailed = inspiration.status === "failed";
|
|
6665
|
+
const title = (inspiration.sourceHost === "upload"
|
|
6666
|
+
? (inspiration.title || inspiration.trendTagline)
|
|
6667
|
+
: (inspiration.trendTagline || inspiration.title)) || inspiration.sourceHost || "New inspiration";
|
|
6668
|
+
// Prefer the raw source video so the Feed + TikTok viewer actually play; fall
|
|
6669
|
+
// back to the poster thumbnail (image) when the video URL isn't available yet.
|
|
6670
|
+
const previewUrl = inspiration.videoUrl || inspiration.thumbnailUrl || "";
|
|
6671
|
+
const viralDna = isFailed
|
|
6672
|
+
? (inspiration.errorMessage || "Video ingest failed. Try adding it again.")
|
|
6673
|
+
: isReady
|
|
6674
|
+
? (inspiration.notes || inspiration.searchSummary || inspiration.trendTagline
|
|
6675
|
+
|| `A proven viral ${inspiration.sourceHost || "social"} post. Preview it, then remix it into an ad for your product.`)
|
|
6676
|
+
: (inspiration.notes || (inspiration.sourceHost === "upload"
|
|
6677
|
+
? "Processing your uploaded video. This card unlocks automatically once it's ready."
|
|
6678
|
+
: "Downloading the source video. This card unlocks automatically once it's ready."));
|
|
6679
|
+
return {
|
|
6680
|
+
title,
|
|
6681
|
+
// Real template id when already decomposed; "" means "decompose on open".
|
|
6682
|
+
templateId: inspiration.templateId ?? "",
|
|
6683
|
+
inspirationId: inspiration.id,
|
|
6684
|
+
slugId: inspiration.templateId ?? inspiration.id,
|
|
6685
|
+
difficulty: "easy",
|
|
6686
|
+
viralDna,
|
|
6687
|
+
previewUrl,
|
|
6688
|
+
approvedAt: inspiration.createdAt,
|
|
6689
|
+
sourceType: inspiration.sourceHost || "social",
|
|
6690
|
+
durationSeconds: inspiration.durationSeconds ?? null,
|
|
6691
|
+
promotions: inspiration.promotions ?? null,
|
|
6692
|
+
keywords: inspiration.keywords ?? null,
|
|
6693
|
+
summary: inspiration.searchSummary ?? null,
|
|
6694
|
+
visibility: inspiration.visibility,
|
|
6695
|
+
origin: inspiration.origin ?? "inspiration",
|
|
6696
|
+
popularGroup: inspiration.popularGroup ?? null,
|
|
6697
|
+
popularRank: inspiration.popularRank ?? null,
|
|
6698
|
+
notes: inspiration.notes,
|
|
6699
|
+
status: isFailed ? "failed" : isReady ? "ready" : "processing"
|
|
6700
|
+
};
|
|
6701
|
+
}
|
|
5903
6702
|
function buildPendingInspirationHomepageEntry(inspiration) {
|
|
5904
6703
|
return {
|
|
5905
6704
|
// Uploads: explicit title (or file name, stored at submit time) outranks
|
|
@@ -6294,6 +7093,8 @@ app.get("/assets/sentry-config.js", (c) => {
|
|
|
6294
7093
|
});
|
|
6295
7094
|
app.get("/assets/homepage-client-app.js", (c) => serveRootJsBundle(c, "public/assets/homepage-client-app.js"));
|
|
6296
7095
|
app.get("/assets/page-runtime-client-app.js", (c) => serveRootJsBundle(c, "public/assets/page-runtime-client-app.js"));
|
|
7096
|
+
app.get("/assets/file-directory-app.js", (c) => serveRootJsBundle(c, "public/assets/file-directory-app.js"));
|
|
7097
|
+
app.get("/assets/discover-client-app.js", (c) => serveRootJsBundle(c, "public/assets/discover-client-app.js"));
|
|
6297
7098
|
function serveRootJsBundle(c, relativePath) {
|
|
6298
7099
|
const filePath = resolveRootFileCandidates(relativePath).find((candidate) => existsSync(candidate));
|
|
6299
7100
|
if (!filePath) {
|
|
@@ -7019,7 +7820,11 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/composition.html`, async (c) => {
|
|
|
7019
7820
|
const html = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "composition.html"));
|
|
7020
7821
|
if (!html)
|
|
7021
7822
|
return c.json({ ok: false, error: "Composition missing" }, 404);
|
|
7022
|
-
|
|
7823
|
+
// The editor stage loads the composition here (then injects its own runtime),
|
|
7824
|
+
// so unmute the not-yet-decomposed raw single-video base so its own audio
|
|
7825
|
+
// plays. Older stored raw bases baked in a `muted` attribute; strip it on the
|
|
7826
|
+
// way out. Decomposed compositions are left untouched (see helper).
|
|
7827
|
+
return c.body(unmuteRawPlaybackSourceVideo(html), 200, { "content-type": "text/html; charset=utf-8" });
|
|
7023
7828
|
}
|
|
7024
7829
|
catch (error) {
|
|
7025
7830
|
return forkAccessErrorResponse(c, error);
|
|
@@ -9583,6 +10388,101 @@ function serializeInspiration(inspiration) {
|
|
|
9583
10388
|
updated_at: inspiration.updatedAt
|
|
9584
10389
|
};
|
|
9585
10390
|
}
|
|
10391
|
+
// Mint the template for a READY inspiration (idempotent): creates a template
|
|
10392
|
+
// inheriting the inspiration's visibility + origin and links it back via
|
|
10393
|
+
// templateId. Returns the inspiration with templateId set — or unchanged if it
|
|
10394
|
+
// already has one / isn't ready / has no source video. Shared by the eager
|
|
10395
|
+
// (private, on-download) path and the lazy Remix path (POST
|
|
10396
|
+
// /discover/inspirations/:id/decompose).
|
|
10397
|
+
async function mintTemplateForInspiration(inspiration) {
|
|
10398
|
+
if (inspiration.templateId || inspiration.status !== "ready" || !inspiration.videoUrl)
|
|
10399
|
+
return inspiration;
|
|
10400
|
+
const template = await serverlessRecords.createTemplate({
|
|
10401
|
+
inspirationId: inspiration.id,
|
|
10402
|
+
customerId: inspiration.customerId,
|
|
10403
|
+
originalUrl: inspiration.originalUrl,
|
|
10404
|
+
sourceHost: inspiration.sourceHost,
|
|
10405
|
+
// No host/"Untitled" fallback: a template without an explicit title or
|
|
10406
|
+
// tagline displays as its template id on /discover.
|
|
10407
|
+
title: (inspiration.sourceHost === "upload"
|
|
10408
|
+
? (inspiration.title || inspiration.trendTagline)
|
|
10409
|
+
: (inspiration.trendTagline || inspiration.title)) || null,
|
|
10410
|
+
thumbnailUrl: inspiration.thumbnailUrl,
|
|
10411
|
+
videoUrl: inspiration.videoUrl,
|
|
10412
|
+
durationSeconds: inspiration.durationSeconds,
|
|
10413
|
+
songName: inspiration.songName,
|
|
10414
|
+
songUrl: inspiration.songUrl,
|
|
10415
|
+
visibility: inspiration.visibility === "private" ? "private" : "public",
|
|
10416
|
+
origin: inspiration.origin ?? "inspiration",
|
|
10417
|
+
notes: inspiration.notes
|
|
10418
|
+
});
|
|
10419
|
+
// Seed the template's canonical DEFAULT fork with the raw single-video
|
|
10420
|
+
// timeline so opening the editor lands on an editable project immediately —
|
|
10421
|
+
// the whole clip as ONE element across the trackpad. Because origin stays
|
|
10422
|
+
// "inspiration" the editor still auto-prompts Decompose; decomposing rebuilds
|
|
10423
|
+
// this same default fork in place, so the multi-scene version becomes the new
|
|
10424
|
+
// default everyone forks off. Non-fatal: on failure the template just falls
|
|
10425
|
+
// back to the on-demand raw seed in POST /api/v1/compositions.
|
|
10426
|
+
try {
|
|
10427
|
+
await seedRawDefaultForkForTemplate(template);
|
|
10428
|
+
}
|
|
10429
|
+
catch (error) {
|
|
10430
|
+
console.error("seed raw default fork failed", error instanceof Error ? error.message : error);
|
|
10431
|
+
}
|
|
10432
|
+
return (await serverlessRecords.updateInspiration({
|
|
10433
|
+
inspirationId: inspiration.id,
|
|
10434
|
+
patch: { templateId: template.id }
|
|
10435
|
+
})) ?? { ...inspiration, templateId: template.id };
|
|
10436
|
+
}
|
|
10437
|
+
// Create + stamp a template's canonical DEFAULT fork holding the raw
|
|
10438
|
+
// single-video composition (the full source clip as one timeline element).
|
|
10439
|
+
// Idempotent: returns the existing default fork id if one is already stamped,
|
|
10440
|
+
// or null if the template has no source video to seed from. Owned privately by
|
|
10441
|
+
// the template owner — other users never touch it through permissions; POST
|
|
10442
|
+
// /api/v1/compositions seeds their forks by copying its composition html.
|
|
10443
|
+
async function seedRawDefaultForkForTemplate(template) {
|
|
10444
|
+
if (template.defaultForkId)
|
|
10445
|
+
return template.defaultForkId;
|
|
10446
|
+
const sourceUrl = template.videoUrl?.trim();
|
|
10447
|
+
if (!sourceUrl)
|
|
10448
|
+
return null;
|
|
10449
|
+
const title = template.title ?? `Template ${template.id}`;
|
|
10450
|
+
const fork = await serverlessRecords.createCompositionFork({
|
|
10451
|
+
customerId: template.customerId,
|
|
10452
|
+
templateId: template.id,
|
|
10453
|
+
parentForkId: null,
|
|
10454
|
+
parentVersion: null,
|
|
10455
|
+
visibility: "private",
|
|
10456
|
+
title
|
|
10457
|
+
});
|
|
10458
|
+
const html = buildRawTikTokCompositionHtml({
|
|
10459
|
+
compositionId: fork.id,
|
|
10460
|
+
sourceUrl,
|
|
10461
|
+
durationSeconds: template.durationSeconds ?? 12,
|
|
10462
|
+
title,
|
|
10463
|
+
origin: template.origin ?? "inspiration"
|
|
10464
|
+
});
|
|
10465
|
+
await storage.putText(storage.compositionForkWorkingKey(fork.id, "composition.html"), html, "text/html; charset=utf-8");
|
|
10466
|
+
await writeForkManifest(storage, fork, { kind: "working" });
|
|
10467
|
+
// Snapshot v1 (reason "clone" bypasses the version ceiling) so version
|
|
10468
|
+
// history + revert work from the very first raw base.
|
|
10469
|
+
try {
|
|
10470
|
+
await snapshotForkVersion({
|
|
10471
|
+
fork,
|
|
10472
|
+
callerCustomerId: template.customerId,
|
|
10473
|
+
reason: "clone",
|
|
10474
|
+
message: "Raw single-video base"
|
|
10475
|
+
});
|
|
10476
|
+
}
|
|
10477
|
+
catch (error) {
|
|
10478
|
+
console.error("seed raw default fork snapshot failed", error instanceof Error ? error.message : error);
|
|
10479
|
+
}
|
|
10480
|
+
const stamp = await serverlessRecords.stampDefaultForkIfUnset({
|
|
10481
|
+
templateId: template.id,
|
|
10482
|
+
forkId: fork.id
|
|
10483
|
+
});
|
|
10484
|
+
return stamp.template?.defaultForkId ?? fork.id;
|
|
10485
|
+
}
|
|
9586
10486
|
async function finalizeInspirationIfReady(inspiration) {
|
|
9587
10487
|
if (inspiration.status !== "downloading" || !inspiration.downloadJobId)
|
|
9588
10488
|
return inspiration;
|
|
@@ -9612,38 +10512,12 @@ async function finalizeInspirationIfReady(inspiration) {
|
|
|
9612
10512
|
songUrl: songUrl ?? null
|
|
9613
10513
|
}
|
|
9614
10514
|
})) ?? inspiration;
|
|
9615
|
-
//
|
|
9616
|
-
//
|
|
9617
|
-
//
|
|
9618
|
-
//
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
inspirationId: ready.id,
|
|
9622
|
-
customerId: ready.customerId,
|
|
9623
|
-
originalUrl: ready.originalUrl,
|
|
9624
|
-
sourceHost: ready.sourceHost,
|
|
9625
|
-
// No host/"Untitled" fallback: a template without an explicit title or
|
|
9626
|
-
// tagline displays as its template id on /discover. For uploads the
|
|
9627
|
-
// manifest title IS the caller's explicit title, so it outranks the
|
|
9628
|
-
// tagline; URL ingests keep tagline-first (manifest title is just the
|
|
9629
|
-
// social post's own caption).
|
|
9630
|
-
title: (ready.sourceHost === "upload"
|
|
9631
|
-
? (ready.title || ready.trendTagline)
|
|
9632
|
-
: (ready.trendTagline || ready.title)) || null,
|
|
9633
|
-
thumbnailUrl: ready.thumbnailUrl,
|
|
9634
|
-
videoUrl: ready.videoUrl,
|
|
9635
|
-
durationSeconds: ready.durationSeconds,
|
|
9636
|
-
songName: ready.songName,
|
|
9637
|
-
songUrl: ready.songUrl,
|
|
9638
|
-
visibility: "private",
|
|
9639
|
-
origin: ready.origin ?? "inspiration",
|
|
9640
|
-
notes: ready.notes
|
|
9641
|
-
});
|
|
9642
|
-
ready = (await serverlessRecords.updateInspiration({
|
|
9643
|
-
inspirationId: ready.id,
|
|
9644
|
-
patch: { templateId: template.id }
|
|
9645
|
-
})) ?? ready;
|
|
9646
|
-
}
|
|
10515
|
+
// EVERY ready inspiration eagerly mints a template + a raw single-video
|
|
10516
|
+
// default fork, so every card opens the editor directly onto an editable
|
|
10517
|
+
// project (the whole clip as one timeline element) with the Decompose
|
|
10518
|
+
// prompt still available. The Feed browses inspirations (deduped on
|
|
10519
|
+
// inspirationId||templateId) so a template per upload doesn't flood it.
|
|
10520
|
+
ready = await mintTemplateForInspiration(ready);
|
|
9647
10521
|
return ready;
|
|
9648
10522
|
}
|
|
9649
10523
|
if (job.status === "failed" || job.status === "cancelled") {
|
|
@@ -10582,7 +11456,7 @@ app.get("/library", async (c) => {
|
|
|
10582
11456
|
posts,
|
|
10583
11457
|
schedule: {
|
|
10584
11458
|
channels: scheduleChannels,
|
|
10585
|
-
connectHref: withAccountQuery("/settings?
|
|
11459
|
+
connectHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
|
|
10586
11460
|
},
|
|
10587
11461
|
editorChat: await buildLibraryEditorChatBoot({
|
|
10588
11462
|
customer,
|
|
@@ -10753,7 +11627,7 @@ app.get("/calendar", async (c) => {
|
|
|
10753
11627
|
posts: [],
|
|
10754
11628
|
loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
|
|
10755
11629
|
hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
|
|
10756
|
-
connectChannelsHref: withAccountQuery("/settings?
|
|
11630
|
+
connectChannelsHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
|
|
10757
11631
|
}));
|
|
10758
11632
|
});
|
|
10759
11633
|
app.get("/agency", async (c) => {
|
|
@@ -11063,7 +11937,7 @@ app.get("/email-channels/confirm", async (c) => {
|
|
|
11063
11937
|
<h1 style="overflow-wrap: anywhere;">${escapeHtml(title)}</h1>
|
|
11064
11938
|
<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>
|
|
11065
11939
|
<div class="toolbar">
|
|
11066
|
-
<a class="button" href="/settings
|
|
11940
|
+
<a class="button" href="/settings/channels">Go to settings</a>
|
|
11067
11941
|
<a class="button secondary" href="/discover">Back to Discover</a>
|
|
11068
11942
|
</div>
|
|
11069
11943
|
</section>
|
|
@@ -12134,9 +13008,21 @@ app.use(`${USER_PREFIX}/me/*`, requireAuth);
|
|
|
12134
13008
|
// is the public HTML gallery page (like /discover vs /discover/feed). Hono's
|
|
12135
13009
|
// `/clips/*` also matches the bare `/clips`, so let that one path through
|
|
12136
13010
|
// unauthenticated.
|
|
12137
|
-
const isClipsGalleryPage = (c) => c.req.path ===
|
|
12138
|
-
app.use(`${
|
|
12139
|
-
app.use(`${
|
|
13011
|
+
const isClipsGalleryPage = (c) => c.req.path === RAWS_PREFIX || c.req.path === `${RAWS_PREFIX}/`;
|
|
13012
|
+
app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : requireAuth(c, next)));
|
|
13013
|
+
app.use(`${RAWS_PREFIX}/*`, (c, next) => (isClipsGalleryPage(c) ? next() : captureApiCallHistory(c, next)));
|
|
13014
|
+
// Deprecated /clips/* → /raws/* alias: transparently redispatch legacy paths so
|
|
13015
|
+
// existing skills, editor-chat prompts, and the devcli keep working during the
|
|
13016
|
+
// clips→raws rename. Remove once all callers emit /raws.
|
|
13017
|
+
const rawsCompatAlias = (c) => {
|
|
13018
|
+
const url = new URL(c.req.url);
|
|
13019
|
+
url.pathname = RAWS_PREFIX + url.pathname.slice(CLIPS_COMPAT_PREFIX.length);
|
|
13020
|
+
// Redispatch through the app with just the rewritten request — do NOT forward
|
|
13021
|
+
// c.executionCtx (its getter throws in the Node adapter when absent).
|
|
13022
|
+
return app.fetch(new Request(url.toString(), c.req.raw));
|
|
13023
|
+
};
|
|
13024
|
+
app.all(CLIPS_COMPAT_PREFIX, rawsCompatAlias);
|
|
13025
|
+
app.all(`${CLIPS_COMPAT_PREFIX}/*`, rawsCompatAlias);
|
|
12140
13026
|
app.use(AGENCY_PREFIX, requireAuth);
|
|
12141
13027
|
app.use(`${AGENCY_PREFIX}/*`, requireAuth);
|
|
12142
13028
|
app.use(`${API_PREFIX}/rate-limit-status`, requireAuth);
|
|
@@ -13171,38 +14057,58 @@ function countReadyPostMediaMatches(job, post) {
|
|
|
13171
14057
|
return matches;
|
|
13172
14058
|
}
|
|
13173
14059
|
async function resolveReadyPostEditorHref(post, accountId) {
|
|
14060
|
+
// "Edit Post" must land in the NEW /editor experience, never the retired
|
|
14061
|
+
// /templates/:id/editor/docs editor. The /editor routes live at the top
|
|
14062
|
+
// level and read the agency sub-account from the ?account= query param (there
|
|
14063
|
+
// is no /u/:account/editor route), so account context is attached that way.
|
|
14064
|
+
const withAccount = (path) => {
|
|
14065
|
+
if (!accountId)
|
|
14066
|
+
return path;
|
|
14067
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
14068
|
+
return `${path}${sep}${ACCOUNT_QUERY_PARAM}=${encodeURIComponent(accountId)}`;
|
|
14069
|
+
};
|
|
14070
|
+
const editorForkHref = (templateId, forkId) => withAccount(`/editor/${encodeURIComponent(templateId)}/fork/${encodeURIComponent(forkId)}`);
|
|
14071
|
+
// Bare editor: the route resolves (or mints) the caller's own fork.
|
|
14072
|
+
const editorTemplateHref = (templateId) => withAccount(`/editor/${encodeURIComponent(templateId)}`);
|
|
14073
|
+
// Fresh blank project in the new editor — used when nothing editable is
|
|
14074
|
+
// linked to this post (e.g. a post approved straight from a primitive render
|
|
14075
|
+
// with no composition fork or tracer).
|
|
14076
|
+
const blankEditorHref = () => withAccount("/editor/original/fork/new");
|
|
14077
|
+
// 1) Preferred: deep-link to the exact composition fork the post came from.
|
|
14078
|
+
const compositionId = post.compositionId?.trim();
|
|
14079
|
+
if (compositionId) {
|
|
14080
|
+
const fork = await serverlessRecords.getCompositionFork(compositionId);
|
|
14081
|
+
if (fork && !fork.deletedAt && fork.templateId) {
|
|
14082
|
+
return editorForkHref(fork.templateId, fork.id);
|
|
14083
|
+
}
|
|
14084
|
+
// Some create paths stash a serverless template id in compositionId rather
|
|
14085
|
+
// than a fork id — open the bare editor for that template.
|
|
14086
|
+
if (compositionId.startsWith("template_")) {
|
|
14087
|
+
return editorTemplateHref(compositionId);
|
|
14088
|
+
}
|
|
14089
|
+
}
|
|
14090
|
+
// 2) Fall back to the tracer's most-relevant render job, but only when that
|
|
14091
|
+
// job points at a serverless (template_) template the new editor can open.
|
|
14092
|
+
// Legacy catalog jobs (plain-UUID templateIds) are skipped on purpose so we
|
|
14093
|
+
// never route back into the old editor.
|
|
13174
14094
|
const tracer = post.tracer?.trim();
|
|
13175
|
-
|
|
13176
|
-
const
|
|
13177
|
-
|
|
13178
|
-
|
|
13179
|
-
|
|
13180
|
-
|
|
13181
|
-
|
|
14095
|
+
if (tracer) {
|
|
14096
|
+
const matchingJob = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
|
|
14097
|
+
.filter((job) => job.templateId.startsWith("template_"))
|
|
14098
|
+
.map((job) => ({
|
|
14099
|
+
job,
|
|
14100
|
+
score: (countReadyPostMediaMatches(job, post) * 1000)
|
|
14101
|
+
+ (job.parentJobId ? 0 : 120)
|
|
14102
|
+
+ (job.status === "succeeded" ? 30 : 0)
|
|
14103
|
+
}))
|
|
14104
|
+
.sort((a, b) => (b.score - a.score
|
|
14105
|
+
|| 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;
|
|
14106
|
+
if (matchingJob && await serverlessRecords.getTemplate(matchingJob.templateId)) {
|
|
14107
|
+
return editorTemplateHref(matchingJob.templateId);
|
|
13182
14108
|
}
|
|
13183
|
-
const suffix = params.size ? `?${params.toString()}` : "";
|
|
13184
|
-
return `/templates/${encodeURIComponent(templateId)}/editor/docs${suffix}`;
|
|
13185
|
-
};
|
|
13186
|
-
if (!tracer) {
|
|
13187
|
-
const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
|
|
13188
|
-
return firstTemplate ? buildDocsHref(firstTemplate.id) : withAccountQuery("/templates", accountId);
|
|
13189
14109
|
}
|
|
13190
|
-
|
|
13191
|
-
|
|
13192
|
-
const matchingJob = candidates
|
|
13193
|
-
.map((job) => ({
|
|
13194
|
-
job,
|
|
13195
|
-
score: (countReadyPostMediaMatches(job, post) * 1000)
|
|
13196
|
-
+ (job.parentJobId ? 0 : 120)
|
|
13197
|
-
+ (job.status === "succeeded" ? 30 : 0)
|
|
13198
|
-
}))
|
|
13199
|
-
.sort((a, b) => (b.score - a.score
|
|
13200
|
-
|| 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;
|
|
13201
|
-
if (matchingJob) {
|
|
13202
|
-
return buildDocsHref(matchingJob.templateId, tracer);
|
|
13203
|
-
}
|
|
13204
|
-
const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
|
|
13205
|
-
return firstTemplate ? buildDocsHref(firstTemplate.id, tracer) : withAccountQuery("/templates", accountId);
|
|
14110
|
+
// 3) Nothing editable is linked — open a fresh project in the new editor.
|
|
14111
|
+
return blankEditorHref();
|
|
13206
14112
|
}
|
|
13207
14113
|
async function renderReadyPostPage(c, post, viewer) {
|
|
13208
14114
|
const visiblePost = serializeReadyPost(c, post, { password: viewer.hasPasswordAccess ? viewer.password : null });
|
|
@@ -13232,7 +14138,7 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
13232
14138
|
error: scheduleChannels.error,
|
|
13233
14139
|
feedEndpoint: viewer.canManage && viewer.customer ? withAccountQuery("/calendar/feed", viewer.customer.id) : null,
|
|
13234
14140
|
scheduleEndpoint: viewer.canManage && viewer.customer ? scopedApprovedPostSchedulePath(viewer.customer.id, post.id) : null,
|
|
13235
|
-
connectHref: viewer.customer ? withAccountQuery("/settings?
|
|
14141
|
+
connectHref: viewer.customer ? withAccountQuery("/settings/channels?connect=flockposter", viewer.customer.id) : "/settings/channels?connect=flockposter",
|
|
13236
14142
|
userId: viewer.customer?.id ?? null
|
|
13237
14143
|
};
|
|
13238
14144
|
const shareUrl = viewer.canView
|
|
@@ -14052,6 +14958,282 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
14052
14958
|
gap: 8px;
|
|
14053
14959
|
margin-top: 4px;
|
|
14054
14960
|
}
|
|
14961
|
+
|
|
14962
|
+
/* ─────────────────────────────────────────────────────────────────────
|
|
14963
|
+
farmville-saas-ux restyle (honey-gold accent, Hanken Grotesk display,
|
|
14964
|
+
soft rounded white cards). SCOPED to .ready-post-shell so it never
|
|
14965
|
+
leaks into other legacy pages, and placed last so it wins over both the
|
|
14966
|
+
page-shell base CSS and the ready-post rules above. Structure + JS are
|
|
14967
|
+
untouched — this is a pure visual reskin via token remap + component
|
|
14968
|
+
overrides. Hanken Grotesk is loaded by a <link> in the page body.
|
|
14969
|
+
───────────────────────────────────────────────────────────────────── */
|
|
14970
|
+
/* page canvas → clean warm-neutral farmville surface. Drops the old
|
|
14971
|
+
beige/tan gradient + technical grid overlay baked into the base shell
|
|
14972
|
+
<body> (which the .ready-post-shell scope can't reach). A whisper of
|
|
14973
|
+
honey glow up top keeps the "sunny afternoon" warmth without the grid. */
|
|
14974
|
+
body:has(.ready-post-shell) {
|
|
14975
|
+
background:
|
|
14976
|
+
radial-gradient(1150px 520px at 50% -12%, rgba(255, 199, 56, 0.10), transparent 62%),
|
|
14977
|
+
linear-gradient(180deg, #fdfcf9 0%, #fbfaf6 46%, #f6f4ee 100%);
|
|
14978
|
+
background-attachment: fixed;
|
|
14979
|
+
}
|
|
14980
|
+
body:has(.ready-post-shell)::before {
|
|
14981
|
+
content: none;
|
|
14982
|
+
}
|
|
14983
|
+
@media (max-width: 1024px) {
|
|
14984
|
+
body:has(.ready-post-shell) {
|
|
14985
|
+
background-attachment: scroll;
|
|
14986
|
+
}
|
|
14987
|
+
}
|
|
14988
|
+
.ready-post-shell {
|
|
14989
|
+
--accent: #ffc738;
|
|
14990
|
+
font-family: "Inter", "Manrope", sans-serif;
|
|
14991
|
+
--accent-deep: #171717;
|
|
14992
|
+
--accent-soft: rgba(255, 199, 56, 0.22);
|
|
14993
|
+
--ink: #171717;
|
|
14994
|
+
--muted: #6f6f70;
|
|
14995
|
+
--line: #ededed;
|
|
14996
|
+
--line-strong: #e0e0e0;
|
|
14997
|
+
--bg: #fbfaf6;
|
|
14998
|
+
--paper: #ffffff;
|
|
14999
|
+
--paper-strong: #ffffff;
|
|
15000
|
+
--panel-muted: #faf9f6;
|
|
15001
|
+
--radius-lg: 24px;
|
|
15002
|
+
--radius-md: 18px;
|
|
15003
|
+
--radius-sm: 12px;
|
|
15004
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 18px 40px -20px rgba(0, 0, 0, 0.12);
|
|
15005
|
+
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 26px -16px rgba(0, 0, 0, 0.14);
|
|
15006
|
+
}
|
|
15007
|
+
.ready-post-shell h1,
|
|
15008
|
+
.ready-post-shell h2,
|
|
15009
|
+
.ready-post-shell .ready-post-schedule-head h2 {
|
|
15010
|
+
font-family: "Hanken Grotesk", "Manrope", sans-serif;
|
|
15011
|
+
font-weight: 800;
|
|
15012
|
+
letter-spacing: -0.02em;
|
|
15013
|
+
color: #171717;
|
|
15014
|
+
}
|
|
15015
|
+
/* buttons → honey-gold pill / white ghost / red danger */
|
|
15016
|
+
.ready-post-shell .button,
|
|
15017
|
+
.ready-post-shell button,
|
|
15018
|
+
.ready-post-shell .link-button,
|
|
15019
|
+
.ready-post-shell .cta-button {
|
|
15020
|
+
border: 1px solid transparent;
|
|
15021
|
+
border-radius: 999px;
|
|
15022
|
+
background: #ffc738;
|
|
15023
|
+
color: #171717;
|
|
15024
|
+
font-family: "Hanken Grotesk", "Manrope", sans-serif;
|
|
15025
|
+
font-weight: 700;
|
|
15026
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
15027
|
+
}
|
|
15028
|
+
@media (hover: hover) {
|
|
15029
|
+
.ready-post-shell .button:hover,
|
|
15030
|
+
.ready-post-shell button:hover,
|
|
15031
|
+
.ready-post-shell .link-button:hover {
|
|
15032
|
+
background: #fcb900;
|
|
15033
|
+
border-color: transparent;
|
|
15034
|
+
transform: translateY(-1px);
|
|
15035
|
+
box-shadow: 0 10px 22px -8px rgba(252, 185, 0, 0.55);
|
|
15036
|
+
}
|
|
15037
|
+
}
|
|
15038
|
+
.ready-post-shell .button.secondary,
|
|
15039
|
+
.ready-post-shell button.secondary,
|
|
15040
|
+
.ready-post-shell .link-button.secondary {
|
|
15041
|
+
background: #ffffff;
|
|
15042
|
+
color: #171717;
|
|
15043
|
+
border: 1px solid #e5e5e5;
|
|
15044
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
15045
|
+
}
|
|
15046
|
+
@media (hover: hover) {
|
|
15047
|
+
.ready-post-shell .button.secondary:hover,
|
|
15048
|
+
.ready-post-shell button.secondary:hover {
|
|
15049
|
+
background: #ffffff;
|
|
15050
|
+
border-color: #d4d4d4;
|
|
15051
|
+
box-shadow: 0 4px 12px -6px rgba(0, 0, 0, 0.12);
|
|
15052
|
+
}
|
|
15053
|
+
}
|
|
15054
|
+
.ready-post-shell .button.danger,
|
|
15055
|
+
.ready-post-shell button.danger,
|
|
15056
|
+
.ready-post-shell .ready-post-danger {
|
|
15057
|
+
background: #ef4444;
|
|
15058
|
+
color: #fff;
|
|
15059
|
+
border-color: transparent;
|
|
15060
|
+
}
|
|
15061
|
+
.ready-post-shell button[disabled] {
|
|
15062
|
+
opacity: 0.5;
|
|
15063
|
+
}
|
|
15064
|
+
/* inputs → soft farmville field, gold focus ring */
|
|
15065
|
+
.ready-post-shell input,
|
|
15066
|
+
.ready-post-shell select,
|
|
15067
|
+
.ready-post-shell textarea {
|
|
15068
|
+
border: 1px solid #e5e5e5;
|
|
15069
|
+
border-radius: 12px;
|
|
15070
|
+
background: #ffffff;
|
|
15071
|
+
color: #171717;
|
|
15072
|
+
box-shadow: none;
|
|
15073
|
+
}
|
|
15074
|
+
.ready-post-shell textarea {
|
|
15075
|
+
border-radius: 16px;
|
|
15076
|
+
}
|
|
15077
|
+
.ready-post-shell input:focus,
|
|
15078
|
+
.ready-post-shell select:focus,
|
|
15079
|
+
.ready-post-shell textarea:focus {
|
|
15080
|
+
border-color: #ffc738;
|
|
15081
|
+
box-shadow: 0 0 0 3px rgba(255, 199, 56, 0.28);
|
|
15082
|
+
}
|
|
15083
|
+
/* tracer / status chip */
|
|
15084
|
+
.ready-post-shell .pill {
|
|
15085
|
+
background: #fff5e4;
|
|
15086
|
+
color: #8a6d1f;
|
|
15087
|
+
border: 1px solid #ffe9cc;
|
|
15088
|
+
border-radius: 999px;
|
|
15089
|
+
font-weight: 700;
|
|
15090
|
+
}
|
|
15091
|
+
/* cards / panels → white farmville card */
|
|
15092
|
+
.ready-post-shell .ready-post-card,
|
|
15093
|
+
.ready-post-shell .ready-post-panel {
|
|
15094
|
+
background: #ffffff;
|
|
15095
|
+
border: 1px solid #ededed;
|
|
15096
|
+
border-radius: 24px;
|
|
15097
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 30px -18px rgba(0, 0, 0, 0.14);
|
|
15098
|
+
}
|
|
15099
|
+
/* copy + menu buttons → neutral ghost */
|
|
15100
|
+
.ready-post-shell .ready-post-copy,
|
|
15101
|
+
.ready-post-shell .ready-post-actions [data-menu-button] {
|
|
15102
|
+
background: #ffffff;
|
|
15103
|
+
border: 1px solid #e5e5e5;
|
|
15104
|
+
color: #525252;
|
|
15105
|
+
box-shadow: none;
|
|
15106
|
+
}
|
|
15107
|
+
.ready-post-shell .ready-post-copy[data-copied="true"] {
|
|
15108
|
+
background: #dcfce7;
|
|
15109
|
+
border-color: #86efac;
|
|
15110
|
+
color: #15803d;
|
|
15111
|
+
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
|
|
15112
|
+
}
|
|
15113
|
+
.ready-post-shell .ready-post-menu {
|
|
15114
|
+
border: 1px solid #ededed;
|
|
15115
|
+
border-radius: 16px;
|
|
15116
|
+
background: #ffffff;
|
|
15117
|
+
box-shadow: 0 20px 40px -16px rgba(0, 0, 0, 0.22);
|
|
15118
|
+
}
|
|
15119
|
+
/* video/slides mode toggle → farmville pill toggle */
|
|
15120
|
+
.ready-post-shell .ready-post-media-mode-toggle button.active {
|
|
15121
|
+
background: #ffc738;
|
|
15122
|
+
color: #171717;
|
|
15123
|
+
}
|
|
15124
|
+
/* login modal card */
|
|
15125
|
+
.ready-post-shell .ready-post-login-modal-card {
|
|
15126
|
+
border: 1px solid #ededed;
|
|
15127
|
+
border-radius: 24px;
|
|
15128
|
+
background: #ffffff;
|
|
15129
|
+
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.3);
|
|
15130
|
+
}
|
|
15131
|
+
/* schedule modal → farmville surface + gold selected day */
|
|
15132
|
+
.ready-post-shell .ready-post-schedule-modal {
|
|
15133
|
+
border-radius: 24px;
|
|
15134
|
+
}
|
|
15135
|
+
/* ── de-saturate: structural buttons must NOT inherit the blanket gold ──
|
|
15136
|
+
The broad gold button rule above is meant for primary
|
|
15137
|
+
CTAs only, but it also painted calendar days, the channel-picker trigger,
|
|
15138
|
+
channel rows and kebab-menu items gold (a wall of yellow). Restore their
|
|
15139
|
+
neutral surfaces here (placed last so they win); only true primaries and
|
|
15140
|
+
genuinely-selected states stay honey-gold. */
|
|
15141
|
+
.ready-post-shell .ready-post-calendar-day {
|
|
15142
|
+
background: #ffffff;
|
|
15143
|
+
color: #3a3a3c;
|
|
15144
|
+
border: 1px solid #ececec;
|
|
15145
|
+
box-shadow: none;
|
|
15146
|
+
font-weight: 700;
|
|
15147
|
+
}
|
|
15148
|
+
.ready-post-shell .ready-post-calendar-day.is-muted {
|
|
15149
|
+
background: #faf9f6;
|
|
15150
|
+
color: #b5b5b7;
|
|
15151
|
+
}
|
|
15152
|
+
.ready-post-shell .ready-post-calendar-day.has-posts {
|
|
15153
|
+
background: rgba(83, 114, 89, 0.10);
|
|
15154
|
+
border-color: rgba(83, 114, 89, 0.30);
|
|
15155
|
+
}
|
|
15156
|
+
.ready-post-shell .ready-post-calendar-day.has-many-posts {
|
|
15157
|
+
background: rgba(255, 199, 56, 0.16);
|
|
15158
|
+
border-color: rgba(255, 199, 56, 0.42);
|
|
15159
|
+
}
|
|
15160
|
+
@media (hover: hover) {
|
|
15161
|
+
.ready-post-shell .ready-post-calendar-day:hover {
|
|
15162
|
+
background: #fff8e8;
|
|
15163
|
+
border-color: #ffc738;
|
|
15164
|
+
transform: none;
|
|
15165
|
+
box-shadow: none;
|
|
15166
|
+
}
|
|
15167
|
+
}
|
|
15168
|
+
.ready-post-shell .ready-post-calendar-day.is-selected,
|
|
15169
|
+
.ready-post-shell .ready-post-calendar-day.is-selected:hover {
|
|
15170
|
+
background: #ffc738;
|
|
15171
|
+
color: #171717;
|
|
15172
|
+
border-color: transparent;
|
|
15173
|
+
}
|
|
15174
|
+
.ready-post-shell .ready-post-calendar-day.is-today {
|
|
15175
|
+
border-color: #ffc738;
|
|
15176
|
+
}
|
|
15177
|
+
/* channel-picker trigger → neutral field, not a gold pill */
|
|
15178
|
+
.ready-post-shell .ready-post-channel-trigger {
|
|
15179
|
+
background: #ffffff;
|
|
15180
|
+
color: #171717;
|
|
15181
|
+
border: 1px solid #e5e5e5;
|
|
15182
|
+
box-shadow: none;
|
|
15183
|
+
font-weight: 600;
|
|
15184
|
+
}
|
|
15185
|
+
.ready-post-shell .ready-post-channel-trigger.is-placeholder {
|
|
15186
|
+
color: #6f6f70;
|
|
15187
|
+
}
|
|
15188
|
+
@media (hover: hover) {
|
|
15189
|
+
.ready-post-shell .ready-post-channel-trigger:hover {
|
|
15190
|
+
background: #ffffff;
|
|
15191
|
+
border-color: #d4d4d4;
|
|
15192
|
+
transform: none;
|
|
15193
|
+
box-shadow: none;
|
|
15194
|
+
}
|
|
15195
|
+
}
|
|
15196
|
+
/* channel rows + kebab-menu items → transparent, gold tint only on hover/selected */
|
|
15197
|
+
.ready-post-shell .ready-post-channel-option,
|
|
15198
|
+
.ready-post-shell .ready-post-menu a,
|
|
15199
|
+
.ready-post-shell .ready-post-menu button {
|
|
15200
|
+
background: transparent;
|
|
15201
|
+
color: #171717;
|
|
15202
|
+
border: 0;
|
|
15203
|
+
box-shadow: none;
|
|
15204
|
+
font-weight: 600;
|
|
15205
|
+
}
|
|
15206
|
+
@media (hover: hover) {
|
|
15207
|
+
.ready-post-shell .ready-post-channel-option:hover,
|
|
15208
|
+
.ready-post-shell .ready-post-menu a:hover,
|
|
15209
|
+
.ready-post-shell .ready-post-menu button:hover {
|
|
15210
|
+
background: #fff5e4;
|
|
15211
|
+
transform: none;
|
|
15212
|
+
box-shadow: none;
|
|
15213
|
+
}
|
|
15214
|
+
}
|
|
15215
|
+
.ready-post-shell .ready-post-channel-option.is-selected {
|
|
15216
|
+
background: #fff5e4;
|
|
15217
|
+
}
|
|
15218
|
+
.ready-post-shell .ready-post-menu .ready-post-danger {
|
|
15219
|
+
color: #dc2626;
|
|
15220
|
+
font-weight: 700;
|
|
15221
|
+
}
|
|
15222
|
+
@media (hover: hover) {
|
|
15223
|
+
.ready-post-shell .ready-post-menu .ready-post-danger:hover {
|
|
15224
|
+
background: #fef2f2;
|
|
15225
|
+
}
|
|
15226
|
+
}
|
|
15227
|
+
/* popover close chip stays a neutral ghost */
|
|
15228
|
+
.ready-post-shell .ready-post-calendar-popover-close {
|
|
15229
|
+
background: #ffffff;
|
|
15230
|
+
color: #6f6f70;
|
|
15231
|
+
border: 1px solid #e5e5e5;
|
|
15232
|
+
box-shadow: none;
|
|
15233
|
+
}
|
|
15234
|
+
.ready-post-shell .ready-post-locked {
|
|
15235
|
+
font-family: "Hanken Grotesk", "Manrope", sans-serif;
|
|
15236
|
+
}
|
|
14055
15237
|
`;
|
|
14056
15238
|
const readyPostBody = viewer.canView ? `
|
|
14057
15239
|
<section class="ready-post-frame">
|
|
@@ -14093,10 +15275,19 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
14093
15275
|
</footer>
|
|
14094
15276
|
</div>
|
|
14095
15277
|
</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>`;
|
|
15278
|
+
const socialImage = thumbnail?.url ?? slides[0]?.url;
|
|
15279
|
+
const socialDescription = post.caption?.trim() || undefined;
|
|
14096
15280
|
return c.html(renderPageShell({
|
|
14097
15281
|
title: post.title ?? "Approved post",
|
|
14098
15282
|
shellClass: "ready-post-shell",
|
|
15283
|
+
description: socialDescription,
|
|
15284
|
+
social: {
|
|
15285
|
+
image: socialImage,
|
|
15286
|
+
url: shareUrl,
|
|
15287
|
+
type: primaryVideo ? "video.other" : "website"
|
|
15288
|
+
},
|
|
14099
15289
|
body: `
|
|
15290
|
+
<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">
|
|
14100
15291
|
${readyPostBody}
|
|
14101
15292
|
<script id="ready-post-data" type="application/json">${escapeJsonForHtml(serialized)}</script>
|
|
14102
15293
|
<script id="ready-post-zip-data" type="application/json">${escapeJsonForHtml({
|
|
@@ -16113,7 +17304,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/presign`, async (c) => {
|
|
|
16113
17304
|
const body = userTemporaryFilePresignSchema.parse(await c.req.json());
|
|
16114
17305
|
const fileName = sanitizeFileName(body.file_name);
|
|
16115
17306
|
const contentType = body.content_type?.trim() || inferAttachmentContentType(fileName);
|
|
16116
|
-
const folderPath = sanitizeStorageSubpath(body.folder_path);
|
|
17307
|
+
const folderPath = withTempDateFolder(sanitizeStorageSubpath(body.folder_path));
|
|
16117
17308
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
16118
17309
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
16119
17310
|
}
|
|
@@ -16171,7 +17362,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
16171
17362
|
}
|
|
16172
17363
|
const fileName = sanitizeFileName(file.name || "upload.bin");
|
|
16173
17364
|
const contentType = file.type || inferAttachmentContentType(fileName);
|
|
16174
|
-
const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
|
|
17365
|
+
const folderPath = withTempDateFolder(sanitizeStorageSubpath(String(formData.get("folder_path") ?? "")));
|
|
16175
17366
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
16176
17367
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
16177
17368
|
}
|
|
@@ -16293,6 +17484,189 @@ app.delete(`${USER_PREFIX}/me/temporary-files/:fileId`, async (c) => {
|
|
|
16293
17484
|
await serverlessRecords.deleteUserTemporaryFile(customer.id, file.id);
|
|
16294
17485
|
return c.json({ ok: true, file_id: file.id });
|
|
16295
17486
|
});
|
|
17487
|
+
// ── Unified file directory ───────────────────────────────────────────────────
|
|
17488
|
+
// The first-class, navigable view over all three file backends as one tree:
|
|
17489
|
+
// /files → My Files attachments /temp → temp scratch /raws → clip library
|
|
17490
|
+
// Both the reskin explorer UI and the AI chat agent browse through here, so a
|
|
17491
|
+
// path like /raws/product-demos is a real, copyable, agent-addressable location.
|
|
17492
|
+
// GET /me/directory?path=/raws/demos → folders[] + files[] under that path.
|
|
17493
|
+
// path "/" (or omitted) lists the three roots as folders.
|
|
17494
|
+
app.get(`${USER_PREFIX}/me/directory`, async (c) => {
|
|
17495
|
+
const customer = requireCustomer(c);
|
|
17496
|
+
const parsed = parseDirectoryPath(c.req.query("path") ?? "");
|
|
17497
|
+
if (!parsed.root) {
|
|
17498
|
+
return c.json({ path: "/", root: null, folders: directoryRootFolders(), files: [], next_offset: null });
|
|
17499
|
+
}
|
|
17500
|
+
const limit = clampPageLimit(Number(c.req.query("limit") || "200"), 500);
|
|
17501
|
+
const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
|
|
17502
|
+
// Exact shot-kind filter (raws only): comma-separated OR set of content_type tags.
|
|
17503
|
+
const contentTypes = (c.req.query("content_type") || "")
|
|
17504
|
+
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
17505
|
+
try {
|
|
17506
|
+
const { folders, files } = await listDirectoryFolder(c, customer.id, parsed.root, parsed.folderPath);
|
|
17507
|
+
const filtered = (parsed.root === "raws" && contentTypes.length)
|
|
17508
|
+
? files.filter((f) => {
|
|
17509
|
+
const tags = (f.meta && f.meta.tags) || null;
|
|
17510
|
+
const ct = Array.isArray(tags?.content_type) ? tags?.content_type : [];
|
|
17511
|
+
return ct.some((x) => contentTypes.includes(String(x).toLowerCase()));
|
|
17512
|
+
})
|
|
17513
|
+
: files;
|
|
17514
|
+
const page = filtered.slice(offset, offset + limit);
|
|
17515
|
+
const nextOffset = filtered.length > offset + limit ? offset + limit : null;
|
|
17516
|
+
return c.json({
|
|
17517
|
+
path: buildDirectoryPath(parsed.root, parsed.folderPath),
|
|
17518
|
+
root: parsed.root,
|
|
17519
|
+
folders,
|
|
17520
|
+
files: page,
|
|
17521
|
+
next_offset: nextOffset
|
|
17522
|
+
});
|
|
17523
|
+
}
|
|
17524
|
+
catch (error) {
|
|
17525
|
+
return c.json({ error: error instanceof Error ? error.message : "Unable to list directory." }, 400);
|
|
17526
|
+
}
|
|
17527
|
+
});
|
|
17528
|
+
// POST /me/directory/search { query, path?, mode?, limit? }
|
|
17529
|
+
// Unified search combining semantic (vector), substring, and absolute-path
|
|
17530
|
+
// matching across the in-scope roots. `path` narrows scope to one root/folder;
|
|
17531
|
+
// `mode` ∈ auto|semantic|substring|path (default auto = all three signals).
|
|
17532
|
+
function directoryPathContains(itemPath, query) {
|
|
17533
|
+
return itemPath.toLowerCase().includes(query.toLowerCase());
|
|
17534
|
+
}
|
|
17535
|
+
app.post(`${USER_PREFIX}/me/directory/search`, async (c) => {
|
|
17536
|
+
const customer = requireCustomer(c);
|
|
17537
|
+
try {
|
|
17538
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
17539
|
+
const query = (body.query ?? "").trim();
|
|
17540
|
+
if (!query)
|
|
17541
|
+
return c.json({ error: "Provide a search query." }, 400);
|
|
17542
|
+
const limit = Math.max(1, Math.min(50, Number(body.limit) || 20));
|
|
17543
|
+
// Exact shot-kind filter applied to the /raws branch only.
|
|
17544
|
+
const contentTypes = (body.content_type || "")
|
|
17545
|
+
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
17546
|
+
const parsed = parseDirectoryPath(body.path ?? "");
|
|
17547
|
+
const scopeRoots = parsed.root ? [parsed.root] : DIRECTORY_ROOT_KEYS;
|
|
17548
|
+
const scopeFolder = normalizeFolderPath(parsed.folderPath);
|
|
17549
|
+
const inScopeFolder = (fp) => !scopeFolder || (normalizeFolderPath(fp) + "/").indexOf(scopeFolder + "/") === 0;
|
|
17550
|
+
const mode = ["semantic", "substring", "path"].includes(String(body.mode)) ? body.mode : "auto";
|
|
17551
|
+
const wantSemantic = mode === "auto" || mode === "semantic";
|
|
17552
|
+
const wantSubstring = mode === "auto" || mode === "substring";
|
|
17553
|
+
const wantPath = mode === "auto" || mode === "path";
|
|
17554
|
+
const scored = [];
|
|
17555
|
+
let semanticUsed = false;
|
|
17556
|
+
// ── /files: notes embedding (semantic) + name/folder/notes substring ──────
|
|
17557
|
+
if (scopeRoots.includes("files")) {
|
|
17558
|
+
const atts = (await serverlessRecords.listUserAttachments(customer.id)).filter((a) => inScopeFolder(a.folderPath));
|
|
17559
|
+
let queryEmbedding;
|
|
17560
|
+
if (wantSemantic) {
|
|
17561
|
+
try {
|
|
17562
|
+
const embedder = await buildFileNotesEmbedder(customer.id);
|
|
17563
|
+
if (embedder)
|
|
17564
|
+
queryEmbedding = await embedder.embedQuery(query);
|
|
17565
|
+
}
|
|
17566
|
+
catch (error) {
|
|
17567
|
+
devLog("directory_search.files_embed_failed", devErrorFields(error));
|
|
17568
|
+
}
|
|
17569
|
+
}
|
|
17570
|
+
if (queryEmbedding)
|
|
17571
|
+
semanticUsed = true;
|
|
17572
|
+
for (const a of atts) {
|
|
17573
|
+
const item = attachmentToDirectoryItem(c, a);
|
|
17574
|
+
let score = 0;
|
|
17575
|
+
let sem = false;
|
|
17576
|
+
if (queryEmbedding && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0) {
|
|
17577
|
+
const sim = cosineSimilarity(a.notesEmbedding, queryEmbedding);
|
|
17578
|
+
if (sim > 0) {
|
|
17579
|
+
score += sim;
|
|
17580
|
+
sem = true;
|
|
17581
|
+
}
|
|
17582
|
+
}
|
|
17583
|
+
if (wantSubstring && attachmentKeywordMatch(a, query))
|
|
17584
|
+
score += 0.5;
|
|
17585
|
+
if (wantPath && directoryPathContains(item.path, query))
|
|
17586
|
+
score += 0.6;
|
|
17587
|
+
if (score > 0)
|
|
17588
|
+
scored.push({ item, score, semantic: sem });
|
|
17589
|
+
}
|
|
17590
|
+
}
|
|
17591
|
+
// ── /temp: no embeddings → substring + absolute-path only ─────────────────
|
|
17592
|
+
if (scopeRoots.includes("temp")) {
|
|
17593
|
+
const files = (await serverlessRecords.listUserTemporaryFiles(customer.id)).filter((f) => inScopeFolder(f.folderPath));
|
|
17594
|
+
for (const f of files) {
|
|
17595
|
+
const item = temporaryFileToDirectoryItem(c, f);
|
|
17596
|
+
let score = 0;
|
|
17597
|
+
if (wantSubstring && attachmentKeywordMatch(f, query))
|
|
17598
|
+
score += 0.5;
|
|
17599
|
+
if (wantPath && directoryPathContains(item.path, query))
|
|
17600
|
+
score += 0.6;
|
|
17601
|
+
if (score > 0)
|
|
17602
|
+
scored.push({ item, score, semantic: false });
|
|
17603
|
+
}
|
|
17604
|
+
}
|
|
17605
|
+
// ── /raws: semantic-first clip search, or keyword/path filter otherwise ────
|
|
17606
|
+
if (scopeRoots.includes("raws")) {
|
|
17607
|
+
if (wantSemantic) {
|
|
17608
|
+
let queryEmbedding;
|
|
17609
|
+
try {
|
|
17610
|
+
const embedder = await buildMatchEmbedder(customer.id);
|
|
17611
|
+
if (embedder)
|
|
17612
|
+
[queryEmbedding] = await embedder.embedQueries([query]);
|
|
17613
|
+
}
|
|
17614
|
+
catch (error) {
|
|
17615
|
+
devLog("directory_search.raws_embed_failed", devErrorFields(error));
|
|
17616
|
+
}
|
|
17617
|
+
if (queryEmbedding)
|
|
17618
|
+
semanticUsed = true;
|
|
17619
|
+
const hits = await searchUserClips(customer.id, {
|
|
17620
|
+
criteria: { semantic_text: query, ...(contentTypes.length ? { content_type: contentTypes } : {}) },
|
|
17621
|
+
queryEmbedding,
|
|
17622
|
+
limit: limit * 3
|
|
17623
|
+
});
|
|
17624
|
+
for (const h of hits) {
|
|
17625
|
+
if (!inScopeFolder(h.clip.folder_path))
|
|
17626
|
+
continue;
|
|
17627
|
+
const item = clipSerializedToDirectoryItem(await serializeClip(h.clip));
|
|
17628
|
+
scored.push({ item, score: h.similarity ?? h.score ?? 0.1, semantic: Boolean(queryEmbedding) });
|
|
17629
|
+
}
|
|
17630
|
+
}
|
|
17631
|
+
else {
|
|
17632
|
+
const clips = (await clipRecords.listClips(customer.id, { limit: 5000 }))
|
|
17633
|
+
.filter((cl) => inScopeFolder(cl.folder_path))
|
|
17634
|
+
.filter((cl) => !contentTypes.length || (cl.tags?.content_type ?? []).some((ct) => contentTypes.includes(String(ct).toLowerCase())));
|
|
17635
|
+
for (const cl of clips) {
|
|
17636
|
+
const kw = wantSubstring && clipKeywordMatch(cl, query);
|
|
17637
|
+
const item = clipSerializedToDirectoryItem(await serializeClip(cl));
|
|
17638
|
+
const pm = wantPath && directoryPathContains(item.path, query);
|
|
17639
|
+
if (kw || pm)
|
|
17640
|
+
scored.push({ item, score: (kw ? 0.5 : 0) + (pm ? 0.6 : 0), semantic: false });
|
|
17641
|
+
}
|
|
17642
|
+
}
|
|
17643
|
+
}
|
|
17644
|
+
scored.sort((x, y) => y.score - x.score);
|
|
17645
|
+
const results = scored.slice(0, limit).map((s) => ({ ...s.item, score: s.score }));
|
|
17646
|
+
return c.json({ results, query, mode, scope: parsed.root ?? "all", semantic: semanticUsed });
|
|
17647
|
+
}
|
|
17648
|
+
catch (error) {
|
|
17649
|
+
return c.json({ error: error instanceof Error ? error.message : "Directory search failed." }, 400);
|
|
17650
|
+
}
|
|
17651
|
+
});
|
|
17652
|
+
// POST /me/directory/folders { path } — create an empty folder at a canonical
|
|
17653
|
+
// path (e.g. /files/brand-assets, /raws/demos). Persisted as a user_file_folder
|
|
17654
|
+
// record scoped to the root, so it shows up in listings before any file lands.
|
|
17655
|
+
app.post(`${USER_PREFIX}/me/directory/folders`, async (c) => {
|
|
17656
|
+
const customer = requireCustomer(c);
|
|
17657
|
+
try {
|
|
17658
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
17659
|
+
const parsed = parseDirectoryPath(body.path ?? "");
|
|
17660
|
+
if (!parsed.root || !parsed.folderPath) {
|
|
17661
|
+
return c.json({ error: "Provide a folder path under a root, e.g. /files/brand-assets or /raws/demos." }, 400);
|
|
17662
|
+
}
|
|
17663
|
+
await serverlessRecords.createUserFileFolder(customer.id, directoryFolderScope(parsed.root), parsed.folderPath);
|
|
17664
|
+
return c.json({ ok: true, path: buildDirectoryPath(parsed.root, parsed.folderPath), root: parsed.root, folder_path: parsed.folderPath });
|
|
17665
|
+
}
|
|
17666
|
+
catch (error) {
|
|
17667
|
+
return c.json({ error: error instanceof Error ? error.message : "Unable to create folder." }, 400);
|
|
17668
|
+
}
|
|
17669
|
+
});
|
|
16296
17670
|
app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
|
|
16297
17671
|
try {
|
|
16298
17672
|
const customer = requireCustomer(c);
|
|
@@ -17045,7 +18419,7 @@ function clipKeywordMatch(clip, q) {
|
|
|
17045
18419
|
// GET /clips — the Clips gallery, rendered in the same shell as the rest of the
|
|
17046
18420
|
// frontend and reached via the Library page's "Finished / Clips" toggle. The
|
|
17047
18421
|
// client fetches /clips/feed with the session cookie (mirrors /discover vs /feed).
|
|
17048
|
-
app.get(
|
|
18422
|
+
app.get(RAWS_PREFIX, async (c) => {
|
|
17049
18423
|
const customer = await getPreferredBrowserCustomer(c);
|
|
17050
18424
|
if (!customer) {
|
|
17051
18425
|
return redirect(c, "/login");
|
|
@@ -17058,21 +18432,76 @@ app.get(CLIPS_PREFIX, async (c) => {
|
|
|
17058
18432
|
editorChat: await buildClipsEditorChatBoot({ customer })
|
|
17059
18433
|
}));
|
|
17060
18434
|
});
|
|
17061
|
-
// GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q,
|
|
17062
|
-
|
|
18435
|
+
// GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q,
|
|
18436
|
+
// ?content_type, ?limit, ?offset). ?content_type is an exact tag filter
|
|
18437
|
+
// (comma-separated = OR) over each raw's content_type tags — the /library/raws
|
|
18438
|
+
// content-type chips use it for real structured filtering, distinct from ?q
|
|
18439
|
+
// semantic/keyword search. ?offset pages the (descending) result set for the
|
|
18440
|
+
// chat file browser; the response carries next_offset (null when exhausted).
|
|
18441
|
+
app.get(`${RAWS_PREFIX}/feed`, async (c) => {
|
|
17063
18442
|
const customer = requireCustomer(c);
|
|
17064
18443
|
const limit = clampPageLimit(Number(c.req.query("limit") || "60"), 200);
|
|
18444
|
+
const offset = Math.max(0, Math.trunc(Number(c.req.query("offset") || "0")) || 0);
|
|
17065
18445
|
const q = c.req.query("q")?.trim();
|
|
18446
|
+
const contentTypes = (c.req.query("content_type") || "")
|
|
18447
|
+
.split(",")
|
|
18448
|
+
.map((s) => s.trim().toLowerCase())
|
|
18449
|
+
.filter(Boolean);
|
|
18450
|
+
// ?folder= scopes the feed to one virtual Raws subfolder (exact match, or ""
|
|
18451
|
+
// for the raws root) — the directory browser navigates with it.
|
|
18452
|
+
const folderParam = c.req.query("folder");
|
|
18453
|
+
const folderScoped = folderParam != null;
|
|
18454
|
+
const folder = normalizeFolderPath(folderParam ?? "");
|
|
18455
|
+
const narrowed = Boolean(q) || contentTypes.length > 0 || folderScoped;
|
|
18456
|
+
// Unfiltered: load only up to the page window (+1 to detect a next page),
|
|
18457
|
+
// capped so a huge library never pulls unbounded. Filtered paths must scan a
|
|
18458
|
+
// wider set before slicing, since the filter runs in memory.
|
|
18459
|
+
const loadLimit = narrowed ? 5000 : Math.min(offset + limit + 1, 2000);
|
|
17066
18460
|
let clips = await clipRecords.listClips(customer.id, {
|
|
17067
18461
|
sourceVideoId: c.req.query("source") || undefined,
|
|
17068
|
-
limit:
|
|
18462
|
+
limit: loadLimit
|
|
17069
18463
|
});
|
|
18464
|
+
// Derive the folder tree from every loaded clip BEFORE the folder scope trims
|
|
18465
|
+
// the set (so the browser can see sibling folders). Complete whenever the load
|
|
18466
|
+
// scanned the whole library (all narrowed paths do).
|
|
18467
|
+
const folders = buildFolderList(clips.map((clip) => ({ folderPath: clip.folder_path })));
|
|
18468
|
+
if (contentTypes.length) {
|
|
18469
|
+
clips = clips.filter((clip) => (clip.tags.content_type ?? []).some((ct) => contentTypes.includes(ct)));
|
|
18470
|
+
}
|
|
18471
|
+
if (folderScoped) {
|
|
18472
|
+
clips = clips.filter((clip) => normalizeFolderPath(clip.folder_path) === folder);
|
|
18473
|
+
}
|
|
17070
18474
|
if (q)
|
|
17071
|
-
clips = clips.filter((clip) => clipKeywordMatch(clip, q))
|
|
17072
|
-
|
|
18475
|
+
clips = clips.filter((clip) => clipKeywordMatch(clip, q));
|
|
18476
|
+
const page = clips.slice(offset, offset + limit);
|
|
18477
|
+
const nextOffset = clips.length > offset + limit ? offset + limit : null;
|
|
18478
|
+
return c.json({ clips: await Promise.all(page.map(serializeClip)), folders, next_offset: nextOffset });
|
|
18479
|
+
});
|
|
18480
|
+
// PATCH /raws/:clipId — move a clip between virtual Raws folders (or to the
|
|
18481
|
+
// root with folder_path: ""). Registered before other /raws/:param routes so a
|
|
18482
|
+
// literal like "feed"/"search" never matches as a clip id (those are GET/POST).
|
|
18483
|
+
app.patch(`${RAWS_PREFIX}/:clipId`, async (c) => {
|
|
18484
|
+
const customer = requireCustomer(c);
|
|
18485
|
+
try {
|
|
18486
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
18487
|
+
if (typeof body.folder_path !== "string") {
|
|
18488
|
+
return c.json({ error: "Provide folder_path (\"\" moves to the raws root)." }, 400);
|
|
18489
|
+
}
|
|
18490
|
+
const clipId = c.req.param("clipId");
|
|
18491
|
+
const existing = await clipRecords.getClip(customer.id, clipId);
|
|
18492
|
+
if (!existing)
|
|
18493
|
+
return c.json({ error: "Raw not found." }, 404);
|
|
18494
|
+
const folderPath = normalizeFolderPath(body.folder_path);
|
|
18495
|
+
await clipRecords.updateClipFolder(customer.id, clipId, folderPath);
|
|
18496
|
+
const updated = await clipRecords.getClip(customer.id, clipId);
|
|
18497
|
+
return c.json({ ok: true, clip: updated ? await serializeClip(updated) : null });
|
|
18498
|
+
}
|
|
18499
|
+
catch (error) {
|
|
18500
|
+
return c.json({ error: error instanceof Error ? error.message : "Unable to move raw." }, 400);
|
|
18501
|
+
}
|
|
17073
18502
|
});
|
|
17074
18503
|
// POST /clips/search — hybrid structured + semantic search.
|
|
17075
|
-
app.post(`${
|
|
18504
|
+
app.post(`${RAWS_PREFIX}/search`, async (c) => {
|
|
17076
18505
|
const customer = requireCustomer(c);
|
|
17077
18506
|
try {
|
|
17078
18507
|
const body = (await c.req.json().catch(() => ({})));
|
|
@@ -17120,7 +18549,7 @@ function normalizeMatchScene(s, index) {
|
|
|
17120
18549
|
}
|
|
17121
18550
|
// POST /clips/match — find the top library clips that could REPLACE one scene.
|
|
17122
18551
|
// Body: a scene annotation, or {criteria, embedding_text|text, limit}.
|
|
17123
|
-
app.post(`${
|
|
18552
|
+
app.post(`${RAWS_PREFIX}/match`, async (c) => {
|
|
17124
18553
|
const customer = requireCustomer(c);
|
|
17125
18554
|
try {
|
|
17126
18555
|
const body = (await c.req.json().catch(() => ({})));
|
|
@@ -17143,7 +18572,7 @@ app.post(`${CLIPS_PREFIX}/match`, async (c) => {
|
|
|
17143
18572
|
// POST /clips/match-scenes — batch: take a decomposed video's scene annotations
|
|
17144
18573
|
// and return the top replacement-clip matches per scene (one library read).
|
|
17145
18574
|
// Body: { scenes | annotations: SceneAnnotation[], limit? }.
|
|
17146
|
-
app.post(`${
|
|
18575
|
+
app.post(`${RAWS_PREFIX}/match-scenes`, async (c) => {
|
|
17147
18576
|
const customer = requireCustomer(c);
|
|
17148
18577
|
try {
|
|
17149
18578
|
const body = (await c.req.json());
|
|
@@ -17184,10 +18613,11 @@ app.post(`${CLIPS_PREFIX}/match-scenes`, async (c) => {
|
|
|
17184
18613
|
// pipeline runs the hunt and GET /clips/scan/:scanId polls it. Billing is AWS
|
|
17185
18614
|
// compute only (clip_scan_lambda + step_functions_standard) — the AI spend is
|
|
17186
18615
|
// the caller's own provider key (BYOK) and is never wallet-billed.
|
|
17187
|
-
app.post(`${
|
|
18616
|
+
app.post(`${RAWS_PREFIX}/scan`, async (c) => {
|
|
17188
18617
|
const customer = requireCustomer(c);
|
|
17189
18618
|
try {
|
|
17190
18619
|
const body = (await c.req.json());
|
|
18620
|
+
const importOnly = body.import_only === true;
|
|
17191
18621
|
const guidancePrompt = body.prompt?.trim() || "";
|
|
17192
18622
|
// Build the hunt spec: explicit structured fields win, the free "what to
|
|
17193
18623
|
// clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
|
|
@@ -17198,7 +18628,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
17198
18628
|
if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
|
|
17199
18629
|
return c.json({ error: "That doesn't look like a valid video URL." }, 400);
|
|
17200
18630
|
}
|
|
17201
|
-
|
|
18631
|
+
let huntSpec = normalizeHuntSpec({
|
|
17202
18632
|
...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
|
|
17203
18633
|
...(body.ranges != null ? { ranges: body.ranges } : {}),
|
|
17204
18634
|
...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
|
|
@@ -17219,8 +18649,15 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
17219
18649
|
huntSpec.aspect = parsedPrompt.aspect;
|
|
17220
18650
|
if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
|
|
17221
18651
|
huntSpec.avoid_text = true;
|
|
18652
|
+
if (!huntSpec.max_clips && parsedPrompt.max_clips)
|
|
18653
|
+
huntSpec.max_clips = parsedPrompt.max_clips;
|
|
17222
18654
|
if (!huntSpec.source_url && sourceUrl)
|
|
17223
18655
|
huntSpec.source_url = sourceUrl;
|
|
18656
|
+
// Fill the unspecified-prompt mining defaults (5–15s clips, vertical 9:16,
|
|
18657
|
+
// avoid on-screen text). Explicit user/prompt choices above already won;
|
|
18658
|
+
// this only fills the gaps. Skipped for import_only (no mining happens).
|
|
18659
|
+
if (!importOnly)
|
|
18660
|
+
huntSpec = applyHuntSpecDefaults(huntSpec);
|
|
17224
18661
|
// Uploaded-source hunts: resolve a temp-folder file (the canonical home for
|
|
17225
18662
|
// hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
|
|
17226
18663
|
let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
|
|
@@ -17295,6 +18732,33 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
17295
18732
|
pipeline: input.pipeline,
|
|
17296
18733
|
pipeline_deployed: cloudPipeline
|
|
17297
18734
|
}, 202);
|
|
18735
|
+
// ── Import individual video (no clip mining) ─────────────────────────────
|
|
18736
|
+
// The Import Source modal's unchecked "Clip raws" box: just pull the source
|
|
18737
|
+
// into the raws library as ONE whole-video raw. No BYOK key, no AI tagging,
|
|
18738
|
+
// no wallet bill. Runs inline (awaited) so it's reliable on Lambda, then
|
|
18739
|
+
// returns a "complete" scan the poller sees immediately.
|
|
18740
|
+
if (importOnly) {
|
|
18741
|
+
const importSourcePath = resolvedS3Key ? storage.getLocalPath(resolvedS3Key) : null;
|
|
18742
|
+
if (resolvedS3Key && (!importSourcePath || !existsSync(importSourcePath)) && !huntSpec.source_url) {
|
|
18743
|
+
return c.json({ error: "That upload isn't reachable for a direct import — pass source_url instead." }, 400);
|
|
18744
|
+
}
|
|
18745
|
+
await putScanRecords({ scanStatus: "running" });
|
|
18746
|
+
try {
|
|
18747
|
+
await importWholeVideoAsRaw({
|
|
18748
|
+
ownerId: customer.id,
|
|
18749
|
+
scanId,
|
|
18750
|
+
sourceVideoId,
|
|
18751
|
+
filename,
|
|
18752
|
+
tracer,
|
|
18753
|
+
localSourcePath: importSourcePath && existsSync(importSourcePath) ? importSourcePath : null,
|
|
18754
|
+
sourceUrl: huntSpec.source_url ?? null
|
|
18755
|
+
});
|
|
18756
|
+
}
|
|
18757
|
+
catch (error) {
|
|
18758
|
+
return c.json({ error: error instanceof Error ? error.message : "Could not import that video." }, 502);
|
|
18759
|
+
}
|
|
18760
|
+
return scanResponse({ status: "complete", pipeline: "local" });
|
|
18761
|
+
}
|
|
17298
18762
|
if (cloudPipeline) {
|
|
17299
18763
|
// ── Deployed pipeline (Lambda + Step Functions): BYOK API keys only ──
|
|
17300
18764
|
if (choice === "agent") {
|
|
@@ -17503,7 +18967,7 @@ app.post(`${CLIPS_PREFIX}/scan`, async (c) => {
|
|
|
17503
18967
|
// GET /clips/scan-options — what the Import Source modal can run a hunt with:
|
|
17504
18968
|
// which BYOK providers have saved keys, whether a local agent CLI is available
|
|
17505
18969
|
// (vidfarm serve boxes only), what "auto" resolves to, and where the hunt runs.
|
|
17506
|
-
app.get(`${
|
|
18970
|
+
app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
|
|
17507
18971
|
const customer = requireCustomer(c);
|
|
17508
18972
|
const keys = await loadCustomerVisionKeys(customer.id);
|
|
17509
18973
|
const cloudPipeline = Boolean(config.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN);
|
|
@@ -17524,7 +18988,11 @@ app.get(`${CLIPS_PREFIX}/scan-options`, async (c) => {
|
|
|
17524
18988
|
});
|
|
17525
18989
|
function looksLikeDirectVideoUrl(url) {
|
|
17526
18990
|
try {
|
|
17527
|
-
|
|
18991
|
+
const u = new URL(url);
|
|
18992
|
+
// Match a video extension in the path OR a query value (signed/CDN links
|
|
18993
|
+
// often carry the real filename as `?file=clip.mp4` with a path-less route).
|
|
18994
|
+
return /\.(mp4|mov|m4v|webm|mkv)(\?|$)/i.test(u.pathname) ||
|
|
18995
|
+
/\.(mp4|mov|m4v|webm|mkv)(&|$)/i.test(u.search);
|
|
17528
18996
|
}
|
|
17529
18997
|
catch {
|
|
17530
18998
|
return false;
|
|
@@ -17533,10 +19001,13 @@ function looksLikeDirectVideoUrl(url) {
|
|
|
17533
19001
|
// Local URL ingest for serve-box hunts: direct video links download straight;
|
|
17534
19002
|
// social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI
|
|
17535
19003
|
// downloader the video_download primitive uses (needs RAPIDAPI_KEY locally).
|
|
19004
|
+
// Returns the local path plus the source's human title (when RapidAPI gives one)
|
|
19005
|
+
// so callers can name the raw/folder something better than a bare URL slug.
|
|
17536
19006
|
async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
17537
19007
|
if (!sourceUrl)
|
|
17538
19008
|
throw new Error("Clip scan needs a source video.");
|
|
17539
19009
|
let mediaUrl = sourceUrl;
|
|
19010
|
+
let title = null;
|
|
17540
19011
|
if (!looksLikeDirectVideoUrl(sourceUrl)) {
|
|
17541
19012
|
const apiKey = (config.RAPIDAPI_KEY || "").trim();
|
|
17542
19013
|
if (!apiKey) {
|
|
@@ -17554,11 +19025,14 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
|
17554
19025
|
if (!lookupRes.ok)
|
|
17555
19026
|
throw new Error(`Video download lookup failed with HTTP ${lookupRes.status}.`);
|
|
17556
19027
|
const lookup = (await lookupRes.json());
|
|
17557
|
-
|
|
17558
|
-
|
|
19028
|
+
// Pick the HIGHEST-resolution playable MP4 (not just the first one) so the
|
|
19029
|
+
// ingested source — and every clip cut from it — is as high-quality as the
|
|
19030
|
+
// provider offers.
|
|
19031
|
+
const chosen = pickBestVideoMedia(lookup.medias ?? []);
|
|
17559
19032
|
if (!chosen?.url)
|
|
17560
19033
|
throw new Error("Video download lookup returned no playable MP4 media URL.");
|
|
17561
19034
|
mediaUrl = chosen.url;
|
|
19035
|
+
title = typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null;
|
|
17562
19036
|
}
|
|
17563
19037
|
const localPath = path.join(workDir, "ingest", "source.mp4");
|
|
17564
19038
|
mkdirSync(path.dirname(localPath), { recursive: true });
|
|
@@ -17566,7 +19040,148 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
|
17566
19040
|
if (!res.ok || !res.body)
|
|
17567
19041
|
throw new Error(`Source video download failed with HTTP ${res.status}.`);
|
|
17568
19042
|
await streamPipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
|
|
17569
|
-
return localPath;
|
|
19043
|
+
return { videoPath: localPath, title };
|
|
19044
|
+
}
|
|
19045
|
+
// Record the RapidAPI social-download cost for an in-process URL ingest (the
|
|
19046
|
+
// whole-video import path bypasses the clip-scan Lambda, which is where this
|
|
19047
|
+
// third-party cost is normally billed). Mirrors the Lambda's cost center +
|
|
19048
|
+
// idempotency key so a retry never double-bills. Non-fatal.
|
|
19049
|
+
async function recordRapidApiIngestCost(input) {
|
|
19050
|
+
// Direct .mp4 links never touch RapidAPI, so there's nothing to bill.
|
|
19051
|
+
if (!input.sourceUrl || looksLikeDirectVideoUrl(input.sourceUrl))
|
|
19052
|
+
return;
|
|
19053
|
+
try {
|
|
19054
|
+
await billing.record({
|
|
19055
|
+
customerId: input.ownerId,
|
|
19056
|
+
jobId: input.scanId,
|
|
19057
|
+
tracer: input.tracer ?? null,
|
|
19058
|
+
templateId: "clips/scan",
|
|
19059
|
+
type: "cpu_estimate",
|
|
19060
|
+
costUsd: config.RAPIDAPI_VIDEO_DOWNLOAD_BASE_COST_USD,
|
|
19061
|
+
costCenterSlug: "rapidapi_video_download",
|
|
19062
|
+
idempotencyKey: `rapidapi_video_download:${input.scanId}`,
|
|
19063
|
+
occurredAtMs: Date.now(),
|
|
19064
|
+
metadata: { operation: "clip_import_ingest", source_url: input.sourceUrl }
|
|
19065
|
+
});
|
|
19066
|
+
}
|
|
19067
|
+
catch (error) {
|
|
19068
|
+
devLog("clip_import.ingest_billing_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
|
|
19069
|
+
}
|
|
19070
|
+
}
|
|
19071
|
+
// Import a whole video as a SINGLE raw — the plain "import individual video"
|
|
19072
|
+
// path behind the Import Source modal's unchecked "Clip raws" box. Ingests the
|
|
19073
|
+
// source (direct link or RapidAPI social download), probes its duration, cuts
|
|
19074
|
+
// one thumbnail, and writes ONE clip record spanning the entire video. No BYOK
|
|
19075
|
+
// key, no AI tagging, no wallet bill. Awaited by the caller so it completes
|
|
19076
|
+
// inside the request (reliable on Lambda, which freezes detached work).
|
|
19077
|
+
async function importWholeVideoAsRaw(input) {
|
|
19078
|
+
const workRoot = path.join(config.VIDFARM_DATA_DIR, "clip-scans", input.scanId);
|
|
19079
|
+
const touchScan = async (patch) => {
|
|
19080
|
+
const scan = await clipRecords.getScan(input.ownerId, input.scanId);
|
|
19081
|
+
if (scan)
|
|
19082
|
+
await clipRecords.putScan({ ...scan, ...patch, updated_at: nowIso() });
|
|
19083
|
+
};
|
|
19084
|
+
const touchSource = async (patch) => {
|
|
19085
|
+
const source = await clipRecords.getSource(input.ownerId, input.sourceVideoId);
|
|
19086
|
+
if (source)
|
|
19087
|
+
await clipRecords.putSource({ ...source, ...patch, updated_at: nowIso() });
|
|
19088
|
+
};
|
|
19089
|
+
try {
|
|
19090
|
+
mkdirSync(workRoot, { recursive: true });
|
|
19091
|
+
let videoPath = input.localSourcePath;
|
|
19092
|
+
let ingestedTitle = null;
|
|
19093
|
+
if (!videoPath) {
|
|
19094
|
+
const ingested = await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot);
|
|
19095
|
+
videoPath = ingested.videoPath;
|
|
19096
|
+
ingestedTitle = ingested.title;
|
|
19097
|
+
// URL ingest hits RapidAPI (same third-party cost the clip-scan Lambda
|
|
19098
|
+
// bills on its own ingest); the whole-video path skips the Lambda, so
|
|
19099
|
+
// record the download here or it goes unbilled. Non-fatal on failure.
|
|
19100
|
+
await recordRapidApiIngestCost({
|
|
19101
|
+
ownerId: input.ownerId,
|
|
19102
|
+
scanId: input.scanId,
|
|
19103
|
+
tracer: input.tracer,
|
|
19104
|
+
sourceUrl: input.sourceUrl ?? ""
|
|
19105
|
+
});
|
|
19106
|
+
}
|
|
19107
|
+
const probe = await probeVideo(videoPath);
|
|
19108
|
+
const durationSec = probe.duration_sec || 0;
|
|
19109
|
+
await touchSource({ status: "scanning", duration_sec: durationSec });
|
|
19110
|
+
// A RapidAPI title beats a URL-slug filename for the raw's description and
|
|
19111
|
+
// its source-folder name in the /library/raws explorer.
|
|
19112
|
+
const displayName = ingestedTitle || input.filename;
|
|
19113
|
+
const clipId = createIdV7("clip");
|
|
19114
|
+
const clipKey = `clips/${input.ownerId}/${clipId}.mp4`;
|
|
19115
|
+
// Stream the source to storage instead of readFileSync-into-Buffer — whole
|
|
19116
|
+
// videos can be hundreds of MB and would otherwise pin the entire file in
|
|
19117
|
+
// memory on the request Lambda.
|
|
19118
|
+
await storage.putFile(clipKey, videoPath, "video/mp4");
|
|
19119
|
+
let thumbKey = "";
|
|
19120
|
+
const thumbLocal = path.join(workRoot, `${clipId}.jpg`);
|
|
19121
|
+
const wroteThumb = await extractThumbnail({
|
|
19122
|
+
videoPath,
|
|
19123
|
+
scene: { index: 0, start_time_sec: 0, end_time_sec: durationSec, duration_sec: durationSec },
|
|
19124
|
+
outPath: thumbLocal
|
|
19125
|
+
}).catch(() => false);
|
|
19126
|
+
if (wroteThumb && existsSync(thumbLocal)) {
|
|
19127
|
+
thumbKey = `thumbs/${input.ownerId}/${clipId}.jpg`;
|
|
19128
|
+
await storage.putFile(thumbKey, thumbLocal, "image/jpeg");
|
|
19129
|
+
}
|
|
19130
|
+
const clip = {
|
|
19131
|
+
clip_id: clipId,
|
|
19132
|
+
source_video_id: input.sourceVideoId,
|
|
19133
|
+
source_filename: displayName,
|
|
19134
|
+
start_time_sec: 0,
|
|
19135
|
+
end_time_sec: durationSec,
|
|
19136
|
+
duration_sec: durationSec,
|
|
19137
|
+
file_path: clipKey,
|
|
19138
|
+
thumbnail_path: thumbKey,
|
|
19139
|
+
tags: { content_type: [], subject: [], action: [], emotion: [], setting: [], composition: [], motion: [], energy: "", audio_type: [], transcript: "" },
|
|
19140
|
+
description: displayName,
|
|
19141
|
+
taxonomy_version: ACTIVE_TAXONOMY_VERSION,
|
|
19142
|
+
tier: "flash-lite",
|
|
19143
|
+
tracer: input.tracer,
|
|
19144
|
+
// Default into a source-derived Raws subfolder (canonical `/raws/<slug>`).
|
|
19145
|
+
folder_path: normalizeFolderPath(String(displayName || "")
|
|
19146
|
+
.toLowerCase()
|
|
19147
|
+
.replace(/\.[a-z0-9]+$/i, "")
|
|
19148
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
19149
|
+
.replace(/^-+|-+$/g, "")
|
|
19150
|
+
.slice(0, 60)),
|
|
19151
|
+
owner_id: input.ownerId,
|
|
19152
|
+
created_at: nowIso()
|
|
19153
|
+
};
|
|
19154
|
+
// Best-effort semantic-search embedding of the title/description so an
|
|
19155
|
+
// imported whole video is findable by meaning (not just visible in its
|
|
19156
|
+
// folder). No AI tagging call, no wallet bill — fail-soft when the owner
|
|
19157
|
+
// has no embed-capable key (keyword-only, as before).
|
|
19158
|
+
try {
|
|
19159
|
+
const embedder = await buildClipLibraryEmbedder(input.ownerId);
|
|
19160
|
+
if (embedder?.embeddingModelId) {
|
|
19161
|
+
const [vector] = await embedder.embedDocuments([buildClipEmbeddingText(clip.tags, clip.description)]);
|
|
19162
|
+
if (vector?.length) {
|
|
19163
|
+
clip.embedding = vector;
|
|
19164
|
+
clip.embedding_model = embedder.embeddingModelId;
|
|
19165
|
+
}
|
|
19166
|
+
}
|
|
19167
|
+
}
|
|
19168
|
+
catch (error) {
|
|
19169
|
+
devLog("clip_import.embed_failed", { scan_id: input.scanId, ...devErrorFields(error) }, "warn");
|
|
19170
|
+
}
|
|
19171
|
+
await clipRecords.putClip(clip);
|
|
19172
|
+
await vectorIndex.upsert(input.ownerId, clip).catch(() => undefined);
|
|
19173
|
+
await touchSource({ status: "complete", clip_count: 1, filename: displayName });
|
|
19174
|
+
await touchScan({ status: "complete" });
|
|
19175
|
+
}
|
|
19176
|
+
catch (error) {
|
|
19177
|
+
devLog("clip_import.failed", { scan_id: input.scanId, ...devErrorFields(error) }, "error");
|
|
19178
|
+
await touchSource({ status: "failed" }).catch(() => undefined);
|
|
19179
|
+
await touchScan({ status: "failed", error: error instanceof Error ? error.message : String(error) }).catch(() => undefined);
|
|
19180
|
+
throw error;
|
|
19181
|
+
}
|
|
19182
|
+
finally {
|
|
19183
|
+
rmSync(workRoot, { recursive: true, force: true });
|
|
19184
|
+
}
|
|
17570
19185
|
}
|
|
17571
19186
|
// In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
|
|
17572
19187
|
// Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
|
|
@@ -17587,7 +19202,7 @@ async function runClipScanInProcess(input) {
|
|
|
17587
19202
|
};
|
|
17588
19203
|
try {
|
|
17589
19204
|
mkdirSync(workRoot, { recursive: true });
|
|
17590
|
-
const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot));
|
|
19205
|
+
const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot)).videoPath;
|
|
17591
19206
|
const probe = await probeVideo(videoPath);
|
|
17592
19207
|
await touchSource({ status: "scanning", duration_sec: probe.duration_sec });
|
|
17593
19208
|
const spec = input.huntSpec;
|
|
@@ -17604,6 +19219,9 @@ async function runClipScanInProcess(input) {
|
|
|
17604
19219
|
? { aspect: spec.aspect, focus: normalizeCropFocus(spec.crop_focus) }
|
|
17605
19220
|
: undefined
|
|
17606
19221
|
};
|
|
19222
|
+
// Aim for a distinct, density-bounded set (default ~10 clips / 10 min) over
|
|
19223
|
+
// the effective (windowed) source duration.
|
|
19224
|
+
const targetClipCount = resolveTargetClipCount(effectiveHuntDurationSec(probe.duration_sec, spec.windows), spec);
|
|
17607
19225
|
let stored = 0;
|
|
17608
19226
|
await scanVideo({
|
|
17609
19227
|
ctx,
|
|
@@ -17611,6 +19229,7 @@ async function runClipScanInProcess(input) {
|
|
|
17611
19229
|
probe,
|
|
17612
19230
|
windows: spec.windows,
|
|
17613
19231
|
durationBand: spec.duration_band ?? null,
|
|
19232
|
+
targetClipCount,
|
|
17614
19233
|
sourceVideoId: input.sourceVideoId,
|
|
17615
19234
|
sourceFilename: input.filename,
|
|
17616
19235
|
ownerId: input.ownerId,
|
|
@@ -17748,7 +19367,7 @@ async function mirrorUpstreamClipsToLocal(input) {
|
|
|
17748
19367
|
await clipRecords.putScan({ ...(current ?? scan), status: "complete", updated_at: nowIso() });
|
|
17749
19368
|
}
|
|
17750
19369
|
// GET /clips/scan/:scanId — poll scan status.
|
|
17751
|
-
app.get(`${
|
|
19370
|
+
app.get(`${RAWS_PREFIX}/scan/:scanId`, async (c) => {
|
|
17752
19371
|
const customer = requireCustomer(c);
|
|
17753
19372
|
let scan = await clipRecords.getScan(customer.id, c.req.param("scanId"));
|
|
17754
19373
|
if (!scan)
|
|
@@ -17786,17 +19405,17 @@ app.get(`${CLIPS_PREFIX}/scan/:scanId`, async (c) => {
|
|
|
17786
19405
|
return c.json({ scan, source });
|
|
17787
19406
|
});
|
|
17788
19407
|
// GET /clips/sources — scanned source videos.
|
|
17789
|
-
app.get(`${
|
|
19408
|
+
app.get(`${RAWS_PREFIX}/sources`, async (c) => {
|
|
17790
19409
|
const customer = requireCustomer(c);
|
|
17791
19410
|
return c.json({ sources: await clipRecords.listSources(customer.id) });
|
|
17792
19411
|
});
|
|
17793
19412
|
// GET /clips/presets — built-in + saved presets.
|
|
17794
|
-
app.get(`${
|
|
19413
|
+
app.get(`${RAWS_PREFIX}/presets`, async (c) => {
|
|
17795
19414
|
const customer = requireCustomer(c);
|
|
17796
19415
|
return c.json({ presets: await clipRecords.listPresets(customer.id) });
|
|
17797
19416
|
});
|
|
17798
19417
|
// POST /clips/presets — save a custom preset from criteria or a NL query.
|
|
17799
|
-
app.post(`${
|
|
19418
|
+
app.post(`${RAWS_PREFIX}/presets`, async (c) => {
|
|
17800
19419
|
const customer = requireCustomer(c);
|
|
17801
19420
|
try {
|
|
17802
19421
|
const body = (await c.req.json());
|
|
@@ -17824,7 +19443,7 @@ app.post(`${CLIPS_PREFIX}/presets`, async (c) => {
|
|
|
17824
19443
|
}
|
|
17825
19444
|
});
|
|
17826
19445
|
// DELETE /clips/presets/:presetId — delete a saved (non-builtin) preset.
|
|
17827
|
-
app.delete(`${
|
|
19446
|
+
app.delete(`${RAWS_PREFIX}/presets/:presetId`, async (c) => {
|
|
17828
19447
|
const customer = requireCustomer(c);
|
|
17829
19448
|
const id = c.req.param("presetId");
|
|
17830
19449
|
if (id.startsWith("preset_") && BUILTIN_CLIP_PRESET_IDS.has(id)) {
|
|
@@ -17834,7 +19453,7 @@ app.delete(`${CLIPS_PREFIX}/presets/:presetId`, async (c) => {
|
|
|
17834
19453
|
return c.json({ ok: true });
|
|
17835
19454
|
});
|
|
17836
19455
|
// GET /clips/:clipId/download — presigned redirect to the clip mp4.
|
|
17837
|
-
app.get(`${
|
|
19456
|
+
app.get(`${RAWS_PREFIX}/:clipId/download`, async (c) => {
|
|
17838
19457
|
const customer = requireCustomer(c);
|
|
17839
19458
|
const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
|
|
17840
19459
|
if (!clip)
|
|
@@ -17845,7 +19464,7 @@ app.get(`${CLIPS_PREFIX}/:clipId/download`, async (c) => {
|
|
|
17845
19464
|
return redirect(c, url, 302);
|
|
17846
19465
|
});
|
|
17847
19466
|
// GET /clips/:clipId — one clip (+ presigned view/thumbnail urls).
|
|
17848
|
-
app.get(`${
|
|
19467
|
+
app.get(`${RAWS_PREFIX}/:clipId`, async (c) => {
|
|
17849
19468
|
const customer = requireCustomer(c);
|
|
17850
19469
|
const clip = await clipRecords.getClip(customer.id, c.req.param("clipId"));
|
|
17851
19470
|
if (!clip)
|
|
@@ -17853,7 +19472,7 @@ app.get(`${CLIPS_PREFIX}/:clipId`, async (c) => {
|
|
|
17853
19472
|
return c.json({ clip: await serializeClip(clip) });
|
|
17854
19473
|
});
|
|
17855
19474
|
// DELETE /clips/:clipId — remove a clip record (+ vector).
|
|
17856
|
-
app.delete(`${
|
|
19475
|
+
app.delete(`${RAWS_PREFIX}/:clipId`, async (c) => {
|
|
17857
19476
|
const customer = requireCustomer(c);
|
|
17858
19477
|
await clipRecords.deleteClip(customer.id, c.req.param("clipId"));
|
|
17859
19478
|
return c.json({ ok: true });
|