@mevdragon/vidfarm-devcli 0.18.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 +41 -4
- package/SKILL.director.md +17 -3
- package/SKILL.platform.md +3 -3
- package/demo/dist/app.js +69 -69
- package/demo/dist/favicon.ico +0 -0
- package/dist/src/app.js +1550 -187
- package/dist/src/cli.js +227 -7
- package/dist/src/devcli/clip-store.js +29 -2
- package/dist/src/devcli/clips.js +52 -9
- 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 +1 -1
- package/dist/src/reskin/calendar-page.js +2 -1
- package/dist/src/reskin/chat-page.js +319 -72
- package/dist/src/reskin/discover-page.js +486 -31
- package/dist/src/reskin/document.js +1171 -384
- package/dist/src/reskin/help-page.js +1 -0
- package/dist/src/reskin/inpaint-page.js +10 -4
- package/dist/src/reskin/library-page.js +918 -220
- package/dist/src/reskin/login-page.js +6 -6
- package/dist/src/reskin/pricing-page.js +2 -0
- package/dist/src/reskin/settings-page.js +55 -10
- package/dist/src/reskin/theme.js +337 -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
|
@@ -35,6 +35,8 @@ 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";
|
|
@@ -846,7 +849,7 @@ function consumeLoginReturnPath(c) {
|
|
|
846
849
|
function resolvePostLoginRedirect(c, customer) {
|
|
847
850
|
const returnPath = consumeLoginReturnPath(c) ?? sanitizeLoginReturnPath(c.req.query("return_to"));
|
|
848
851
|
if (!returnPath) {
|
|
849
|
-
return withAccountQuery("/settings", customer.id);
|
|
852
|
+
return withAccountQuery("/settings/profile", customer.id);
|
|
850
853
|
}
|
|
851
854
|
const parsed = new URL(returnPath, "https://vidfarm.local");
|
|
852
855
|
if (parsed.pathname === "/settings" || parsed.pathname.startsWith("/settings/")) {
|
|
@@ -861,6 +864,10 @@ function setSettingsFlash(c, input) {
|
|
|
861
864
|
function clearSettingsFlash(c) {
|
|
862
865
|
appendCookie(c, `${SETTINGS_FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${cookieSecureAttribute()}`);
|
|
863
866
|
}
|
|
867
|
+
const SETTINGS_TABS = ["profile", "wallet", "channels", "developer"];
|
|
868
|
+
function isSettingsTab(value) {
|
|
869
|
+
return value === "profile" || value === "wallet" || value === "channels" || value === "developer";
|
|
870
|
+
}
|
|
864
871
|
function consumeSettingsFlash(c) {
|
|
865
872
|
const cookies = parseCookieHeader(c.req.header("cookie"));
|
|
866
873
|
const raw = cookies.get(SETTINGS_FLASH_COOKIE);
|
|
@@ -1048,19 +1055,15 @@ function parseTracerList(searchParams) {
|
|
|
1048
1055
|
return output.slice(0, 24);
|
|
1049
1056
|
}
|
|
1050
1057
|
function redirectToSettings(c, input) {
|
|
1051
|
-
|
|
1052
|
-
|
|
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 });
|
|
1053
1062
|
}
|
|
1054
|
-
const params = new URLSearchParams();
|
|
1055
1063
|
const accountId = input?.accountId ?? getRequestedAccountId(c);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
}
|
|
1059
|
-
if (input?.refreshChannels) {
|
|
1060
|
-
params.set("refresh", "channels");
|
|
1061
|
-
}
|
|
1062
|
-
const suffix = params.size ? `?${params.toString()}` : "";
|
|
1063
|
-
return redirect(c, `${accountId ? withAccountQuery("/settings", accountId) : "/settings"}${suffix}`, 303);
|
|
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);
|
|
1064
1067
|
}
|
|
1065
1068
|
async function validateFlockPosterApiKey(apiKey) {
|
|
1066
1069
|
await fetchFlockPosterIntegrations(apiKey);
|
|
@@ -2892,6 +2895,16 @@ function sanitizeStorageSubpath(value) {
|
|
|
2892
2895
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
|
2893
2896
|
.join("/");
|
|
2894
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
|
+
}
|
|
2895
2908
|
function inferAttachmentContentType(fileName) {
|
|
2896
2909
|
switch (path.extname(fileName).toLowerCase()) {
|
|
2897
2910
|
case ".png":
|
|
@@ -2969,6 +2982,107 @@ function serializeUserTemporaryFile(c, file) {
|
|
|
2969
2982
|
function buildFolderList(items) {
|
|
2970
2983
|
return Array.from(new Set(items.map((item) => item.folderPath ?? "").filter(Boolean))).sort();
|
|
2971
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
|
+
}
|
|
2972
3086
|
function isFormFile(value) {
|
|
2973
3087
|
return Boolean(value
|
|
2974
3088
|
&& typeof value === "object"
|
|
@@ -3145,6 +3259,7 @@ function buildPrimitiveEditorDocsRoutes() {
|
|
|
3145
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? }." },
|
|
3146
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? }." },
|
|
3147
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." },
|
|
3148
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." },
|
|
3149
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." },
|
|
3150
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? }." },
|
|
@@ -3609,6 +3724,10 @@ function normalizeEditorChatPath(input) {
|
|
|
3609
3724
|
PRIMITIVES_PREFIX,
|
|
3610
3725
|
APPROVED_POSTS_PREFIX,
|
|
3611
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`,
|
|
3612
3731
|
// Read-only catalog discovery so the copilot can answer "which templates
|
|
3613
3732
|
// suit my <offer>?": the template feed (supports ?q=&limit=) and the source
|
|
3614
3733
|
// video / inspiration catalog (supports ?q=&limit=&mine=). Ingest lives at
|
|
@@ -3631,6 +3750,10 @@ function normalizeEditorChatPath(input) {
|
|
|
3631
3750
|
// scene-selection filter; caption removal stays a per-finished-clip
|
|
3632
3751
|
// primitive (/api/v1/primitives/videos/remove-captions).
|
|
3633
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,
|
|
3634
3757
|
// Public skill knowledge: the vendored skill packs (load_skill fetches
|
|
3635
3758
|
// these too) and per-template SKILL.md manifests. Read-only markdown.
|
|
3636
3759
|
"/skill/",
|
|
@@ -4013,19 +4136,24 @@ function browseFilesTextContentType(fileName) {
|
|
|
4013
4136
|
}
|
|
4014
4137
|
function createBrowseFilesTool(input) {
|
|
4015
4138
|
return tool({
|
|
4016
|
-
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.",
|
|
4017
4140
|
inputSchema: z.object({
|
|
4018
|
-
action: z.enum(["list", "read", "write", "annotate", "search"]).describe("list = enumerate files
|
|
4019
|
-
|
|
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."),
|
|
4020
4145
|
file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
|
|
4021
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."),
|
|
4022
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."),
|
|
4023
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."),
|
|
4024
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."),
|
|
4025
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)."),
|
|
4026
4154
|
explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
|
|
4027
4155
|
}),
|
|
4028
|
-
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 }) => {
|
|
4029
4157
|
const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
|
|
4030
4158
|
const headers = new Headers();
|
|
4031
4159
|
headers.set("vidfarm-api-key", input.vidfarmApiKey);
|
|
@@ -4034,7 +4162,67 @@ function createBrowseFilesTool(input) {
|
|
|
4034
4162
|
timeoutController.abort(new Error("Browse files request timed out after 30 seconds."));
|
|
4035
4163
|
}, 30_000);
|
|
4036
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();
|
|
4037
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
|
+
}
|
|
4038
4226
|
if (action === "write") {
|
|
4039
4227
|
const name = (file_name ?? "").trim();
|
|
4040
4228
|
if (!name) {
|
|
@@ -4415,8 +4603,13 @@ function createEditorActionTool() {
|
|
|
4415
4603
|
"export_composition",
|
|
4416
4604
|
"generate_layer",
|
|
4417
4605
|
"set_captions",
|
|
4418
|
-
"set_transitions"
|
|
4419
|
-
|
|
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."),
|
|
4420
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."),
|
|
4421
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."),
|
|
4422
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."),
|
|
@@ -4486,6 +4679,22 @@ function createEditorActionTool() {
|
|
|
4486
4679
|
resolution: z.string().optional().describe("generate_layer (video): optional resolution (480p|720p|1080p|1K|2K|4K)."),
|
|
4487
4680
|
generate_audio: z.boolean().optional().describe("generate_layer (video): whether the model should generate an audio track."),
|
|
4488
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."),
|
|
4489
4698
|
explanation: z.string().describe("One-sentence reason shown in the UI.")
|
|
4490
4699
|
}),
|
|
4491
4700
|
execute: async (args) => {
|
|
@@ -4753,6 +4962,24 @@ function renderHyperframesTimelineRuler(durationSeconds) {
|
|
|
4753
4962
|
}).join("");
|
|
4754
4963
|
return `<div class="hf-timeline-ruler" aria-hidden="true">${marks}</div>`;
|
|
4755
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
|
+
}
|
|
4756
4983
|
function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
4757
4984
|
const baseHref = `/${normalizeDraftRelativePath(draftPath)}/`;
|
|
4758
4985
|
const baseTag = `<base href="${escapeAttribute(baseHref)}">`;
|
|
@@ -4828,6 +5055,72 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
|
|
|
4828
5055
|
}
|
|
4829
5056
|
return rewritten;
|
|
4830
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
|
+
`;
|
|
4831
5124
|
async function renderHyperframesStudioEditorPage(input) {
|
|
4832
5125
|
const accountId = input.customer?.id ?? null;
|
|
4833
5126
|
const compositionId = input.composition.templateId ?? input.composition.slugId;
|
|
@@ -4836,6 +5129,8 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4836
5129
|
slugId: input.composition.slugId,
|
|
4837
5130
|
templateId: input.composition.templateId,
|
|
4838
5131
|
title: input.composition.title,
|
|
5132
|
+
// "raw" = the user's own new/blank project — never auto-prompt Decompose.
|
|
5133
|
+
origin: input.composition.origin ?? null,
|
|
4839
5134
|
originalUrl: input.composition.originalUrl ?? null,
|
|
4840
5135
|
songName: input.composition.songName ?? null,
|
|
4841
5136
|
songUrl: input.composition.songUrl ?? null,
|
|
@@ -4857,17 +5152,19 @@ async function renderHyperframesStudioEditorPage(input) {
|
|
|
4857
5152
|
<head>
|
|
4858
5153
|
<meta charset="UTF-8" />
|
|
4859
5154
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
4860
|
-
<title>${escapeHtml(input.composition.title)} | Studio</title>
|
|
4861
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
4862
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
5155
|
+
<title>${escapeHtml(input.composition.title)} | VidFarm Studio</title>
|
|
4863
5156
|
<link rel="icon" href="/assets/favicon.ico" />
|
|
4864
5157
|
<link rel="stylesheet" href="/assets/editor/app.css" />
|
|
4865
|
-
|
|
5158
|
+
${RESKIN_FONT_LINKS}
|
|
5159
|
+
<style>${RESKIN_CSS}</style>
|
|
5160
|
+
<style>${EDITOR_FARMVILLE_CSS}</style>
|
|
4866
5161
|
<script src="/assets/sentry-config.js"></script>
|
|
4867
5162
|
</head>
|
|
4868
|
-
<body>
|
|
5163
|
+
<body data-rk-editor-dock>
|
|
4869
5164
|
<div id="root"></div>
|
|
5165
|
+
${renderChatDock("editor")}
|
|
4870
5166
|
<script id="hf-boot" type="application/json">${escapeJsonForHtml(boot)}</script>
|
|
5167
|
+
<script>${RESKIN_CHROME_SCRIPT}</script>
|
|
4871
5168
|
<script type="module" src="/assets/editor/app.js"></script>
|
|
4872
5169
|
</body>
|
|
4873
5170
|
</html>`;
|
|
@@ -4914,7 +5211,11 @@ app.get("/", async (c) => {
|
|
|
4914
5211
|
// Each page is a standalone document with its own CSS; renders sample data so
|
|
4915
5212
|
// it's viewable without auth. Ported pages are wired here one at a time.
|
|
4916
5213
|
app.get("/reskin", (c) => c.html(renderReskinIndex()));
|
|
4917
|
-
|
|
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) {
|
|
4918
5219
|
const customer = await requireBrowserCustomer(c);
|
|
4919
5220
|
if (!customer) {
|
|
4920
5221
|
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
@@ -4922,11 +5223,12 @@ app.get("/settings", async (c) => {
|
|
|
4922
5223
|
}
|
|
4923
5224
|
const flash = consumeSettingsFlash(c);
|
|
4924
5225
|
const flockPosterApiKey = customer.flockposterApiKey?.trim() || null;
|
|
4925
|
-
const activeTab =
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
5226
|
+
const activeTab = tabOverride
|
|
5227
|
+
?? (isSettingsTab(flash?.tab)
|
|
5228
|
+
? flash.tab
|
|
5229
|
+
: isSettingsTab(c.req.query("tab"))
|
|
5230
|
+
? c.req.query("tab")
|
|
5231
|
+
: "profile");
|
|
4930
5232
|
const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
|
|
4931
5233
|
const emailChannels = await getEmailPseudoChannels(customer.id, c);
|
|
4932
5234
|
const [wallet, walletEvents] = await Promise.all([
|
|
@@ -4975,8 +5277,22 @@ app.get("/settings", async (c) => {
|
|
|
4975
5277
|
})),
|
|
4976
5278
|
attachments: (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment))
|
|
4977
5279
|
}));
|
|
4978
|
-
}
|
|
4979
|
-
//
|
|
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).
|
|
4980
5296
|
app.get("/library", (c) => redirect(c, "/library/approved"));
|
|
4981
5297
|
const reskinLibraryHandler = async (c, tab) => {
|
|
4982
5298
|
const customer = await getPreferredBrowserCustomer(c);
|
|
@@ -4984,6 +5300,20 @@ const reskinLibraryHandler = async (c, tab) => {
|
|
|
4984
5300
|
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
4985
5301
|
return redirect(c, "/login");
|
|
4986
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
|
+
}
|
|
4987
5317
|
const emailChannels = await getEmailPseudoChannels(customer.id, c);
|
|
4988
5318
|
const flockPoster = await getFlockPosterChannels(customer.flockposterApiKey?.trim() || null);
|
|
4989
5319
|
const scheduleChannels = [
|
|
@@ -5001,23 +5331,114 @@ const reskinLibraryHandler = async (c, tab) => {
|
|
|
5001
5331
|
name: customer.name,
|
|
5002
5332
|
email: customer.email,
|
|
5003
5333
|
posts,
|
|
5004
|
-
schedule: { channels: scheduleChannels, connectHref: "/settings?
|
|
5334
|
+
schedule: { channels: scheduleChannels, connectHref: "/settings/channels?connect=flockposter" },
|
|
5005
5335
|
editorChat: null
|
|
5006
5336
|
}));
|
|
5007
5337
|
};
|
|
5008
5338
|
app.get("/library/approved", (c) => reskinLibraryHandler(c, "approved"));
|
|
5009
5339
|
app.get("/library/raws", (c) => reskinLibraryHandler(c, "raws"));
|
|
5010
|
-
app.get("/library/logs", (c) => reskinLibraryHandler(c, "logs")); // job-runs,
|
|
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
|
|
5011
5342
|
app.get("/library/clips", (c) => redirect(c, "/library/raws")); // renamed Clips → Raws
|
|
5012
|
-
// Discover has three modes
|
|
5013
|
-
//
|
|
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
|
+
};
|
|
5014
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).
|
|
5015
5428
|
const view = c.req.query("view");
|
|
5016
|
-
const
|
|
5017
|
-
const
|
|
5018
|
-
|
|
5019
|
-
});
|
|
5020
|
-
|
|
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) => {
|
|
5021
5442
|
const customer = await getPreferredBrowserCustomer(c);
|
|
5022
5443
|
if (!customer) {
|
|
5023
5444
|
setLoginReturnPath(c, currentPathWithSearch(c));
|
|
@@ -5029,7 +5450,21 @@ app.get("/chat", async (c) => {
|
|
|
5029
5450
|
name: customer.name,
|
|
5030
5451
|
email: customer.email,
|
|
5031
5452
|
editorChat: await buildChatBrainstormEditorChatBoot({ customer })
|
|
5032
|
-
}));
|
|
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 }) });
|
|
5033
5468
|
});
|
|
5034
5469
|
// /reskin/inpaint — the "Create Image" masked-image editor (reached from the
|
|
5035
5470
|
// chat page's studio-mode select). Auth-gated like the other tools.
|
|
@@ -5065,7 +5500,7 @@ app.get("/calendar", async (c) => {
|
|
|
5065
5500
|
posts: [],
|
|
5066
5501
|
loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
|
|
5067
5502
|
hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
|
|
5068
|
-
connectChannelsHref: "/settings?
|
|
5503
|
+
connectChannelsHref: "/settings/channels?connect=flockposter"
|
|
5069
5504
|
}));
|
|
5070
5505
|
});
|
|
5071
5506
|
// Job runs were renamed and folded into the Library "Logs" tab.
|
|
@@ -5166,7 +5601,7 @@ app.get("/reskin/clips", (c) => redirect(c, "/library/raws"));
|
|
|
5166
5601
|
app.get("/reskin/discover", (c) => redirect(c, "/discover"));
|
|
5167
5602
|
app.get("/reskin/discover/:mode", (c) => {
|
|
5168
5603
|
const m = c.req.param("mode");
|
|
5169
|
-
return redirect(c, m === "swipe" || m === "categories" ? `/discover
|
|
5604
|
+
return redirect(c, m === "swipe" || m === "categories" ? `/discover/${m}` : "/discover");
|
|
5170
5605
|
});
|
|
5171
5606
|
app.get("/reskin/chat", (c) => redirect(c, "/chat"));
|
|
5172
5607
|
app.get("/reskin/inpaint", (c) => redirect(c, "/inpaint"));
|
|
@@ -5177,8 +5612,43 @@ app.get("/reskin/pricing", (c) => redirect(c, "/pricing"));
|
|
|
5177
5612
|
app.get("/reskin/help", (c) => redirect(c, "/help"));
|
|
5178
5613
|
app.get("/reskin/login", (c) => redirect(c, "/login"));
|
|
5179
5614
|
app.get("/discover", async (c) => renderApprovedHomepage(c));
|
|
5180
|
-
|
|
5181
|
-
|
|
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) {
|
|
5182
5652
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5183
5653
|
if (!templateId.startsWith("template_"))
|
|
5184
5654
|
return c.notFound();
|
|
@@ -5191,7 +5661,7 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5191
5661
|
await seedFromUpstream({
|
|
5192
5662
|
customerId: customer.id,
|
|
5193
5663
|
templateId,
|
|
5194
|
-
forkId:
|
|
5664
|
+
forkId: forkParam ?? undefined,
|
|
5195
5665
|
shareToken: readShareToken(c) ?? undefined
|
|
5196
5666
|
});
|
|
5197
5667
|
template = await serverlessRecords.getTemplate(templateId);
|
|
@@ -5202,7 +5672,6 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5202
5672
|
// template id doesn't leak to other accounts.
|
|
5203
5673
|
if (template.visibility === "private" && template.customerId !== customer?.id)
|
|
5204
5674
|
return c.notFound();
|
|
5205
|
-
const forkParam = c.req.query("fork");
|
|
5206
5675
|
const threadParam = c.req.query("thread");
|
|
5207
5676
|
// Validate the caller-provided fork. Stale bookmarks / share links keep
|
|
5208
5677
|
// pointing at forks we've since hard-deleted, and if we blindly pass the
|
|
@@ -5279,11 +5748,11 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5279
5748
|
// source of truth for "which chat is active" via localStorage. Injecting
|
|
5280
5749
|
// the server's most-recent thread into the URL blows away a fresh
|
|
5281
5750
|
// client-side "New chat" the user just started but hasn't persisted.
|
|
5282
|
-
const path = `/editor/${encodeURIComponent(template.id)}
|
|
5751
|
+
const path = `/editor/${encodeURIComponent(template.id)}/fork/${encodeURIComponent(targetForkId)}`;
|
|
5283
5752
|
return redirect(c, path);
|
|
5284
5753
|
}
|
|
5285
5754
|
// No fork exists yet for this template + user — redirect to the bare
|
|
5286
|
-
// /editor/{templateId} URL so any stale
|
|
5755
|
+
// /editor/{templateId} URL so any stale fork segment gets stripped and the
|
|
5287
5756
|
// Decompose modal opens fresh (client-side flow mints a new fork on
|
|
5288
5757
|
// first save/decompose).
|
|
5289
5758
|
if (forkParam) {
|
|
@@ -5310,6 +5779,21 @@ app.get("/editor/:templateId", async (c) => {
|
|
|
5310
5779
|
initialThreadId: threadParam ?? null
|
|
5311
5780
|
};
|
|
5312
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);
|
|
5313
5797
|
});
|
|
5314
5798
|
app.get("/editor/:templateId/composition", async (c) => {
|
|
5315
5799
|
const templateId = c.req.param("templateId");
|
|
@@ -5336,6 +5820,7 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}`, (c) => redirect(c, withAccountQuery("/libr
|
|
|
5336
5820
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/discover`, (c) => redirectToPublicPath(c, "/discover"));
|
|
5337
5821
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/help`, (c) => redirectToPublicPath(c, "/help"));
|
|
5338
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")}`));
|
|
5339
5824
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/library`, (c) => dispatchScopedGetToLegacyRoute(c, "/library"));
|
|
5340
5825
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/clips`, (c) => dispatchScopedGetToLegacyRoute(c, "/clips"));
|
|
5341
5826
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/inpaint`, (c) => dispatchScopedGetToLegacyRoute(c, "/inpaint"));
|
|
@@ -5345,8 +5830,24 @@ app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs`, (c) => dispatchScopedGetToLegacyR
|
|
|
5345
5830
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/job-runs/history`, (c) => dispatchScopedGetToLegacyRoute(c, "/job-runs/history"));
|
|
5346
5831
|
app.get(`${ACCOUNT_FRONTEND_PREFIX}/calendar/feed`, (c) => dispatchScopedGetToLegacyRoute(c, "/calendar/feed"));
|
|
5347
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
|
+
});
|
|
5348
5839
|
app.post(`${ACCOUNT_FRONTEND_PREFIX}/settings/wallet/funding-link`, (c) => dispatchScopedPostToLegacyRoute(c, "/settings/wallet/funding-link"));
|
|
5349
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
|
+
}
|
|
5350
5851
|
const limit = clampPageLimit(Number(c.req.query("limit") || ""), 100, 200);
|
|
5351
5852
|
// Optional keyword search so callers (UI, agents, devcli) can ask "which
|
|
5352
5853
|
// templates suit my <offer>?" and get a ranked subset instead of the full
|
|
@@ -5354,66 +5855,12 @@ app.get("/discover/feed", async (c) => {
|
|
|
5354
5855
|
// decompose-derived promotions/keywords/summary.
|
|
5355
5856
|
const query = (c.req.query("q") || c.req.query("query") || "").trim() || null;
|
|
5356
5857
|
const customer = await getOptionalPreferredBrowserCustomer(c);
|
|
5357
|
-
const
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
// Finalizing pending inspirations here is what mints the private template
|
|
5361
|
-
// as soon as the download job completes — the feed poll doubles as the
|
|
5362
|
-
// completion check.
|
|
5363
|
-
const ownInspirations = (await serverlessRecords.listInspirationsForCustomer(customer.id))
|
|
5364
|
-
.filter((inspiration) => inspiration.visibility === "private");
|
|
5365
|
-
const finalized = await Promise.all(ownInspirations.map(finalizeInspirationIfReady));
|
|
5366
|
-
const ownTemplates = (await serverlessRecords.listTemplatesForCustomer(customer.id))
|
|
5367
|
-
.filter((template) => template.visibility === "private")
|
|
5368
|
-
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
5369
|
-
const pending = finalized
|
|
5370
|
-
.filter((inspiration) => !inspiration.templateId)
|
|
5371
|
-
.filter((inspiration) => recordMatchesSearchQuery(inspiration, query))
|
|
5372
|
-
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
5373
|
-
for (const inspiration of pending) {
|
|
5374
|
-
templateEntries.push(buildPendingInspirationHomepageEntry(inspiration));
|
|
5375
|
-
}
|
|
5376
|
-
for (const template of ownTemplates.filter((template) => recordMatchesSearchQuery(template, query))) {
|
|
5377
|
-
const entry = buildTemplateHomepageEntry(template);
|
|
5378
|
-
if (entry)
|
|
5379
|
-
templateEntries.push(entry);
|
|
5380
|
-
}
|
|
5381
|
-
}
|
|
5382
|
-
const templates = await serverlessRecords.listTemplatesFeed({ limit, query });
|
|
5383
|
-
for (const template of templates) {
|
|
5384
|
-
// Curated "Popular" picks live only under the Popular toggle
|
|
5385
|
-
// (GET /discover/popular) — keep them out of the continuously-grown Feed
|
|
5386
|
-
// so the two views stay distinct.
|
|
5387
|
-
if (typeof template.popularGroup === "string" && template.popularGroup.trim().length > 0)
|
|
5388
|
-
continue;
|
|
5389
|
-
const entry = buildTemplateHomepageEntry(template);
|
|
5390
|
-
if (entry)
|
|
5391
|
-
templateEntries.push(entry);
|
|
5392
|
-
}
|
|
5393
|
-
// Cloud passthrough (vidfarm serve): the local records are just the working
|
|
5394
|
-
// set of seeded templates, so surface the upstream (cloud) catalog behind
|
|
5395
|
-
// them. Local entries win on id conflicts — a seeded template also exists
|
|
5396
|
-
// upstream and the local copy reflects any local edits. Fail-soft: an
|
|
5397
|
-
// unreachable upstream degrades to the local-only feed.
|
|
5398
|
-
if (isUpstreamEnabled()) {
|
|
5399
|
-
const search = new URLSearchParams({ limit: String(limit) });
|
|
5400
|
-
if (query)
|
|
5401
|
-
search.set("q", query);
|
|
5402
|
-
const upstreamFeed = (await upstreamJson(`/discover/feed?${search.toString()}`)).json?.templates ?? [];
|
|
5403
|
-
const seen = new Set(templateEntries.map((entry) => entry.templateId));
|
|
5404
|
-
for (const entry of upstreamFeed) {
|
|
5405
|
-
if (entry && typeof entry.templateId === "string" && !seen.has(entry.templateId) && !entry.popularGroup) {
|
|
5406
|
-
templateEntries.push(entry);
|
|
5407
|
-
seen.add(entry.templateId);
|
|
5408
|
-
}
|
|
5409
|
-
}
|
|
5410
|
-
}
|
|
5411
|
-
// The public feed is identical for every caller, but a logged-in response
|
|
5412
|
-
// interleaves the caller's private templates — never let a shared cache
|
|
5413
|
-
// serve that to someone else.
|
|
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.
|
|
5414
5861
|
c.header("cache-control", customer ? "private, max-age=10" : "public, max-age=60");
|
|
5415
5862
|
return c.json({
|
|
5416
|
-
templates:
|
|
5863
|
+
templates: entries,
|
|
5417
5864
|
next_cursor: null
|
|
5418
5865
|
});
|
|
5419
5866
|
});
|
|
@@ -5562,8 +6009,40 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5562
6009
|
function play() {
|
|
5563
6010
|
try { if (window.__player) { window.__player.play(); } } catch (e) {}
|
|
5564
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
|
+
}
|
|
5565
6043
|
applySound(false);
|
|
5566
6044
|
play();
|
|
6045
|
+
kickBackingVideo();
|
|
5567
6046
|
var lastWant = false;
|
|
5568
6047
|
var stalled = 0;
|
|
5569
6048
|
var mutedFallback = false;
|
|
@@ -5572,7 +6051,7 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5572
6051
|
var want = wantSound();
|
|
5573
6052
|
if (want !== lastWant) { lastWant = want; mutedFallback = false; stalled = 0; }
|
|
5574
6053
|
applySound(want && !mutedFallback);
|
|
5575
|
-
if (!window.__player || !window.__player.getDuration) { return; }
|
|
6054
|
+
if (!window.__player || !window.__player.getDuration) { kickBackingVideo(); return; }
|
|
5576
6055
|
var t = window.__player.getTime(), d = window.__player.getDuration();
|
|
5577
6056
|
if (d > 0 && t >= d - 0.08) { window.__player.seek(0, { keepPlaying: true }); }
|
|
5578
6057
|
else if (!window.__player.isPlaying()) {
|
|
@@ -5582,6 +6061,7 @@ function injectSwipePreviewFit(doc, cardId) {
|
|
|
5582
6061
|
// the preview never freezes on a frame.
|
|
5583
6062
|
if (stalled >= 3 && want && !mutedFallback) { mutedFallback = true; applySound(false); }
|
|
5584
6063
|
play();
|
|
6064
|
+
kickBackingVideo();
|
|
5585
6065
|
} else { stalled = 0; }
|
|
5586
6066
|
} catch (e) {}
|
|
5587
6067
|
}, 400);
|
|
@@ -6091,6 +6571,36 @@ app.post("/discover/templates", async (c) => {
|
|
|
6091
6571
|
});
|
|
6092
6572
|
return c.json(serializeInspiration(inspiration), 202);
|
|
6093
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
|
+
});
|
|
6094
6604
|
// Delete a private discover entry the caller owns. Accepts either the minted
|
|
6095
6605
|
// template id (template_...) or the raw inspiration id (inspiration_..., i.e.
|
|
6096
6606
|
// a still-processing/failed card) and removes both records. Public catalog
|
|
@@ -6144,6 +6654,51 @@ app.delete("/discover/templates/:entryId", async (c) => {
|
|
|
6144
6654
|
// on its owner's /discover as a placeholder card so the Add Template flow has
|
|
6145
6655
|
// immediate feedback. It has no template yet, so the editor CTA is disabled
|
|
6146
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
|
+
}
|
|
6147
6702
|
function buildPendingInspirationHomepageEntry(inspiration) {
|
|
6148
6703
|
return {
|
|
6149
6704
|
// Uploads: explicit title (or file name, stored at submit time) outranks
|
|
@@ -6538,6 +7093,8 @@ app.get("/assets/sentry-config.js", (c) => {
|
|
|
6538
7093
|
});
|
|
6539
7094
|
app.get("/assets/homepage-client-app.js", (c) => serveRootJsBundle(c, "public/assets/homepage-client-app.js"));
|
|
6540
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"));
|
|
6541
7098
|
function serveRootJsBundle(c, relativePath) {
|
|
6542
7099
|
const filePath = resolveRootFileCandidates(relativePath).find((candidate) => existsSync(candidate));
|
|
6543
7100
|
if (!filePath) {
|
|
@@ -7263,7 +7820,11 @@ app.get(`${COMPOSITIONS_PREFIX}/:forkId/composition.html`, async (c) => {
|
|
|
7263
7820
|
const html = await storage.readText(storage.compositionForkWorkingKey(access.fork.id, "composition.html"));
|
|
7264
7821
|
if (!html)
|
|
7265
7822
|
return c.json({ ok: false, error: "Composition missing" }, 404);
|
|
7266
|
-
|
|
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" });
|
|
7267
7828
|
}
|
|
7268
7829
|
catch (error) {
|
|
7269
7830
|
return forkAccessErrorResponse(c, error);
|
|
@@ -9827,6 +10388,101 @@ function serializeInspiration(inspiration) {
|
|
|
9827
10388
|
updated_at: inspiration.updatedAt
|
|
9828
10389
|
};
|
|
9829
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
|
+
}
|
|
9830
10486
|
async function finalizeInspirationIfReady(inspiration) {
|
|
9831
10487
|
if (inspiration.status !== "downloading" || !inspiration.downloadJobId)
|
|
9832
10488
|
return inspiration;
|
|
@@ -9856,38 +10512,12 @@ async function finalizeInspirationIfReady(inspiration) {
|
|
|
9856
10512
|
songUrl: songUrl ?? null
|
|
9857
10513
|
}
|
|
9858
10514
|
})) ?? inspiration;
|
|
9859
|
-
//
|
|
9860
|
-
//
|
|
9861
|
-
//
|
|
9862
|
-
//
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
inspirationId: ready.id,
|
|
9866
|
-
customerId: ready.customerId,
|
|
9867
|
-
originalUrl: ready.originalUrl,
|
|
9868
|
-
sourceHost: ready.sourceHost,
|
|
9869
|
-
// No host/"Untitled" fallback: a template without an explicit title or
|
|
9870
|
-
// tagline displays as its template id on /discover. For uploads the
|
|
9871
|
-
// manifest title IS the caller's explicit title, so it outranks the
|
|
9872
|
-
// tagline; URL ingests keep tagline-first (manifest title is just the
|
|
9873
|
-
// social post's own caption).
|
|
9874
|
-
title: (ready.sourceHost === "upload"
|
|
9875
|
-
? (ready.title || ready.trendTagline)
|
|
9876
|
-
: (ready.trendTagline || ready.title)) || null,
|
|
9877
|
-
thumbnailUrl: ready.thumbnailUrl,
|
|
9878
|
-
videoUrl: ready.videoUrl,
|
|
9879
|
-
durationSeconds: ready.durationSeconds,
|
|
9880
|
-
songName: ready.songName,
|
|
9881
|
-
songUrl: ready.songUrl,
|
|
9882
|
-
visibility: "private",
|
|
9883
|
-
origin: ready.origin ?? "inspiration",
|
|
9884
|
-
notes: ready.notes
|
|
9885
|
-
});
|
|
9886
|
-
ready = (await serverlessRecords.updateInspiration({
|
|
9887
|
-
inspirationId: ready.id,
|
|
9888
|
-
patch: { templateId: template.id }
|
|
9889
|
-
})) ?? ready;
|
|
9890
|
-
}
|
|
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);
|
|
9891
10521
|
return ready;
|
|
9892
10522
|
}
|
|
9893
10523
|
if (job.status === "failed" || job.status === "cancelled") {
|
|
@@ -10826,7 +11456,7 @@ app.get("/library", async (c) => {
|
|
|
10826
11456
|
posts,
|
|
10827
11457
|
schedule: {
|
|
10828
11458
|
channels: scheduleChannels,
|
|
10829
|
-
connectHref: withAccountQuery("/settings?
|
|
11459
|
+
connectHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
|
|
10830
11460
|
},
|
|
10831
11461
|
editorChat: await buildLibraryEditorChatBoot({
|
|
10832
11462
|
customer,
|
|
@@ -10997,7 +11627,7 @@ app.get("/calendar", async (c) => {
|
|
|
10997
11627
|
posts: [],
|
|
10998
11628
|
loadEndpoint: withAccountQuery("/calendar/feed", customer.id),
|
|
10999
11629
|
hasFlockPosterKey: Boolean(flockPosterApiKey || emailChannels.length),
|
|
11000
|
-
connectChannelsHref: withAccountQuery("/settings?
|
|
11630
|
+
connectChannelsHref: withAccountQuery("/settings/channels?connect=flockposter", customer.id)
|
|
11001
11631
|
}));
|
|
11002
11632
|
});
|
|
11003
11633
|
app.get("/agency", async (c) => {
|
|
@@ -11307,7 +11937,7 @@ app.get("/email-channels/confirm", async (c) => {
|
|
|
11307
11937
|
<h1 style="overflow-wrap: anywhere;">${escapeHtml(title)}</h1>
|
|
11308
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>
|
|
11309
11939
|
<div class="toolbar">
|
|
11310
|
-
<a class="button" href="/settings
|
|
11940
|
+
<a class="button" href="/settings/channels">Go to settings</a>
|
|
11311
11941
|
<a class="button secondary" href="/discover">Back to Discover</a>
|
|
11312
11942
|
</div>
|
|
11313
11943
|
</section>
|
|
@@ -13427,38 +14057,58 @@ function countReadyPostMediaMatches(job, post) {
|
|
|
13427
14057
|
return matches;
|
|
13428
14058
|
}
|
|
13429
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.
|
|
13430
14094
|
const tracer = post.tracer?.trim();
|
|
13431
|
-
|
|
13432
|
-
const
|
|
13433
|
-
|
|
13434
|
-
|
|
13435
|
-
|
|
13436
|
-
|
|
13437
|
-
|
|
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);
|
|
13438
14108
|
}
|
|
13439
|
-
const suffix = params.size ? `?${params.toString()}` : "";
|
|
13440
|
-
return `/templates/${encodeURIComponent(templateId)}/editor/docs${suffix}`;
|
|
13441
|
-
};
|
|
13442
|
-
if (!tracer) {
|
|
13443
|
-
const firstTemplate = templateRegistry.list().find((template) => !template.id.startsWith("primitive:"));
|
|
13444
|
-
return firstTemplate ? buildDocsHref(firstTemplate.id) : withAccountQuery("/templates", accountId);
|
|
13445
|
-
}
|
|
13446
|
-
const candidates = (await jobs.listJobsAsync({ customerId: post.customerId, tracer, limit: 100 }))
|
|
13447
|
-
.filter((job) => !job.templateId.startsWith("primitive:") && Boolean(templateRegistry.get(job.templateId)));
|
|
13448
|
-
const matchingJob = candidates
|
|
13449
|
-
.map((job) => ({
|
|
13450
|
-
job,
|
|
13451
|
-
score: (countReadyPostMediaMatches(job, post) * 1000)
|
|
13452
|
-
+ (job.parentJobId ? 0 : 120)
|
|
13453
|
-
+ (job.status === "succeeded" ? 30 : 0)
|
|
13454
|
-
}))
|
|
13455
|
-
.sort((a, b) => (b.score - a.score
|
|
13456
|
-
|| Date.parse(b.job.completedAt ?? b.job.updatedAt ?? b.job.createdAt) - Date.parse(a.job.completedAt ?? a.job.updatedAt ?? a.job.createdAt)))[0]?.job ?? null;
|
|
13457
|
-
if (matchingJob) {
|
|
13458
|
-
return buildDocsHref(matchingJob.templateId, tracer);
|
|
13459
14109
|
}
|
|
13460
|
-
|
|
13461
|
-
return
|
|
14110
|
+
// 3) Nothing editable is linked — open a fresh project in the new editor.
|
|
14111
|
+
return blankEditorHref();
|
|
13462
14112
|
}
|
|
13463
14113
|
async function renderReadyPostPage(c, post, viewer) {
|
|
13464
14114
|
const visiblePost = serializeReadyPost(c, post, { password: viewer.hasPasswordAccess ? viewer.password : null });
|
|
@@ -13488,7 +14138,7 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
13488
14138
|
error: scheduleChannels.error,
|
|
13489
14139
|
feedEndpoint: viewer.canManage && viewer.customer ? withAccountQuery("/calendar/feed", viewer.customer.id) : null,
|
|
13490
14140
|
scheduleEndpoint: viewer.canManage && viewer.customer ? scopedApprovedPostSchedulePath(viewer.customer.id, post.id) : null,
|
|
13491
|
-
connectHref: viewer.customer ? withAccountQuery("/settings?
|
|
14141
|
+
connectHref: viewer.customer ? withAccountQuery("/settings/channels?connect=flockposter", viewer.customer.id) : "/settings/channels?connect=flockposter",
|
|
13492
14142
|
userId: viewer.customer?.id ?? null
|
|
13493
14143
|
};
|
|
13494
14144
|
const shareUrl = viewer.canView
|
|
@@ -14308,6 +14958,282 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
14308
14958
|
gap: 8px;
|
|
14309
14959
|
margin-top: 4px;
|
|
14310
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
|
+
}
|
|
14311
15237
|
`;
|
|
14312
15238
|
const readyPostBody = viewer.canView ? `
|
|
14313
15239
|
<section class="ready-post-frame">
|
|
@@ -14349,10 +15275,19 @@ async function renderReadyPostPage(c, post, viewer) {
|
|
|
14349
15275
|
</footer>
|
|
14350
15276
|
</div>
|
|
14351
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;
|
|
14352
15280
|
return c.html(renderPageShell({
|
|
14353
15281
|
title: post.title ?? "Approved post",
|
|
14354
15282
|
shellClass: "ready-post-shell",
|
|
15283
|
+
description: socialDescription,
|
|
15284
|
+
social: {
|
|
15285
|
+
image: socialImage,
|
|
15286
|
+
url: shareUrl,
|
|
15287
|
+
type: primaryVideo ? "video.other" : "website"
|
|
15288
|
+
},
|
|
14355
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">
|
|
14356
15291
|
${readyPostBody}
|
|
14357
15292
|
<script id="ready-post-data" type="application/json">${escapeJsonForHtml(serialized)}</script>
|
|
14358
15293
|
<script id="ready-post-zip-data" type="application/json">${escapeJsonForHtml({
|
|
@@ -16369,7 +17304,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/presign`, async (c) => {
|
|
|
16369
17304
|
const body = userTemporaryFilePresignSchema.parse(await c.req.json());
|
|
16370
17305
|
const fileName = sanitizeFileName(body.file_name);
|
|
16371
17306
|
const contentType = body.content_type?.trim() || inferAttachmentContentType(fileName);
|
|
16372
|
-
const folderPath = sanitizeStorageSubpath(body.folder_path);
|
|
17307
|
+
const folderPath = withTempDateFolder(sanitizeStorageSubpath(body.folder_path));
|
|
16373
17308
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
16374
17309
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
16375
17310
|
}
|
|
@@ -16427,7 +17362,7 @@ app.post(`${USER_PREFIX}/me/temporary-files/upload`, async (c) => {
|
|
|
16427
17362
|
}
|
|
16428
17363
|
const fileName = sanitizeFileName(file.name || "upload.bin");
|
|
16429
17364
|
const contentType = file.type || inferAttachmentContentType(fileName);
|
|
16430
|
-
const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
|
|
17365
|
+
const folderPath = withTempDateFolder(sanitizeStorageSubpath(String(formData.get("folder_path") ?? "")));
|
|
16431
17366
|
if (!isAllowedAttachment(fileName, contentType)) {
|
|
16432
17367
|
return c.json({ error: `Unsupported temporary file type: ${fileName}` }, 400);
|
|
16433
17368
|
}
|
|
@@ -16549,6 +17484,189 @@ app.delete(`${USER_PREFIX}/me/temporary-files/:fileId`, async (c) => {
|
|
|
16549
17484
|
await serverlessRecords.deleteUserTemporaryFile(customer.id, file.id);
|
|
16550
17485
|
return c.json({ ok: true, file_id: file.id });
|
|
16551
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
|
+
});
|
|
16552
17670
|
app.post(`${USER_PREFIX}/me/developer/preview-media/presign`, async (c) => {
|
|
16553
17671
|
try {
|
|
16554
17672
|
const customer = requireCustomer(c);
|
|
@@ -17314,18 +18432,73 @@ app.get(RAWS_PREFIX, async (c) => {
|
|
|
17314
18432
|
editorChat: await buildClipsEditorChatBoot({ customer })
|
|
17315
18433
|
}));
|
|
17316
18434
|
});
|
|
17317
|
-
// GET /clips/feed — the caller's clip library as JSON (optional ?source, ?q,
|
|
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).
|
|
17318
18441
|
app.get(`${RAWS_PREFIX}/feed`, async (c) => {
|
|
17319
18442
|
const customer = requireCustomer(c);
|
|
17320
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);
|
|
17321
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);
|
|
17322
18460
|
let clips = await clipRecords.listClips(customer.id, {
|
|
17323
18461
|
sourceVideoId: c.req.query("source") || undefined,
|
|
17324
|
-
limit:
|
|
18462
|
+
limit: loadLimit
|
|
17325
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
|
+
}
|
|
17326
18474
|
if (q)
|
|
17327
|
-
clips = clips.filter((clip) => clipKeywordMatch(clip, q))
|
|
17328
|
-
|
|
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
|
+
}
|
|
17329
18502
|
});
|
|
17330
18503
|
// POST /clips/search — hybrid structured + semantic search.
|
|
17331
18504
|
app.post(`${RAWS_PREFIX}/search`, async (c) => {
|
|
@@ -17444,6 +18617,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
|
|
|
17444
18617
|
const customer = requireCustomer(c);
|
|
17445
18618
|
try {
|
|
17446
18619
|
const body = (await c.req.json());
|
|
18620
|
+
const importOnly = body.import_only === true;
|
|
17447
18621
|
const guidancePrompt = body.prompt?.trim() || "";
|
|
17448
18622
|
// Build the hunt spec: explicit structured fields win, the free "what to
|
|
17449
18623
|
// clip for" prompt fills the gaps (duration band, vertical/aspect, no-text).
|
|
@@ -17454,7 +18628,7 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
|
|
|
17454
18628
|
if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) {
|
|
17455
18629
|
return c.json({ error: "That doesn't look like a valid video URL." }, 400);
|
|
17456
18630
|
}
|
|
17457
|
-
|
|
18631
|
+
let huntSpec = normalizeHuntSpec({
|
|
17458
18632
|
...(body.hunt_spec && typeof body.hunt_spec === "object" ? body.hunt_spec : {}),
|
|
17459
18633
|
...(body.ranges != null ? { ranges: body.ranges } : {}),
|
|
17460
18634
|
...(body.target_duration_sec != null ? { target_duration_sec: body.target_duration_sec } : {}),
|
|
@@ -17475,8 +18649,15 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
|
|
|
17475
18649
|
huntSpec.aspect = parsedPrompt.aspect;
|
|
17476
18650
|
if (!huntSpec.avoid_text && parsedPrompt.avoid_text)
|
|
17477
18651
|
huntSpec.avoid_text = true;
|
|
18652
|
+
if (!huntSpec.max_clips && parsedPrompt.max_clips)
|
|
18653
|
+
huntSpec.max_clips = parsedPrompt.max_clips;
|
|
17478
18654
|
if (!huntSpec.source_url && sourceUrl)
|
|
17479
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);
|
|
17480
18661
|
// Uploaded-source hunts: resolve a temp-folder file (the canonical home for
|
|
17481
18662
|
// hunt originals — 30-day TTL) or a My Files attachment to its S3 key.
|
|
17482
18663
|
let resolvedS3Key = readNonEmptyString(body.s3_key) ?? undefined;
|
|
@@ -17551,6 +18732,33 @@ app.post(`${RAWS_PREFIX}/scan`, async (c) => {
|
|
|
17551
18732
|
pipeline: input.pipeline,
|
|
17552
18733
|
pipeline_deployed: cloudPipeline
|
|
17553
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
|
+
}
|
|
17554
18762
|
if (cloudPipeline) {
|
|
17555
18763
|
// ── Deployed pipeline (Lambda + Step Functions): BYOK API keys only ──
|
|
17556
18764
|
if (choice === "agent") {
|
|
@@ -17780,7 +18988,11 @@ app.get(`${RAWS_PREFIX}/scan-options`, async (c) => {
|
|
|
17780
18988
|
});
|
|
17781
18989
|
function looksLikeDirectVideoUrl(url) {
|
|
17782
18990
|
try {
|
|
17783
|
-
|
|
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);
|
|
17784
18996
|
}
|
|
17785
18997
|
catch {
|
|
17786
18998
|
return false;
|
|
@@ -17789,10 +19001,13 @@ function looksLikeDirectVideoUrl(url) {
|
|
|
17789
19001
|
// Local URL ingest for serve-box hunts: direct video links download straight;
|
|
17790
19002
|
// social URLs (TikTok/YouTube/IG/X) resolve through the same RapidAPI
|
|
17791
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.
|
|
17792
19006
|
async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
17793
19007
|
if (!sourceUrl)
|
|
17794
19008
|
throw new Error("Clip scan needs a source video.");
|
|
17795
19009
|
let mediaUrl = sourceUrl;
|
|
19010
|
+
let title = null;
|
|
17796
19011
|
if (!looksLikeDirectVideoUrl(sourceUrl)) {
|
|
17797
19012
|
const apiKey = (config.RAPIDAPI_KEY || "").trim();
|
|
17798
19013
|
if (!apiKey) {
|
|
@@ -17810,11 +19025,14 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
|
17810
19025
|
if (!lookupRes.ok)
|
|
17811
19026
|
throw new Error(`Video download lookup failed with HTTP ${lookupRes.status}.`);
|
|
17812
19027
|
const lookup = (await lookupRes.json());
|
|
17813
|
-
|
|
17814
|
-
|
|
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 ?? []);
|
|
17815
19032
|
if (!chosen?.url)
|
|
17816
19033
|
throw new Error("Video download lookup returned no playable MP4 media URL.");
|
|
17817
19034
|
mediaUrl = chosen.url;
|
|
19035
|
+
title = typeof lookup.title === "string" && lookup.title.trim() ? lookup.title.trim() : null;
|
|
17818
19036
|
}
|
|
17819
19037
|
const localPath = path.join(workDir, "ingest", "source.mp4");
|
|
17820
19038
|
mkdirSync(path.dirname(localPath), { recursive: true });
|
|
@@ -17822,7 +19040,148 @@ async function ingestClipSourceUrlLocally(sourceUrl, workDir) {
|
|
|
17822
19040
|
if (!res.ok || !res.body)
|
|
17823
19041
|
throw new Error(`Source video download failed with HTTP ${res.status}.`);
|
|
17824
19042
|
await streamPipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
|
|
17825
|
-
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
|
+
}
|
|
17826
19185
|
}
|
|
17827
19186
|
// In-process clip hunt for `vidfarm serve` boxes (no Step Functions worker).
|
|
17828
19187
|
// Mirrors the clip-scan Lambda pipeline — ingest → probe/detect → refine →
|
|
@@ -17843,7 +19202,7 @@ async function runClipScanInProcess(input) {
|
|
|
17843
19202
|
};
|
|
17844
19203
|
try {
|
|
17845
19204
|
mkdirSync(workRoot, { recursive: true });
|
|
17846
|
-
const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot));
|
|
19205
|
+
const videoPath = input.localSourcePath ?? (await ingestClipSourceUrlLocally(input.sourceUrl ?? "", workRoot)).videoPath;
|
|
17847
19206
|
const probe = await probeVideo(videoPath);
|
|
17848
19207
|
await touchSource({ status: "scanning", duration_sec: probe.duration_sec });
|
|
17849
19208
|
const spec = input.huntSpec;
|
|
@@ -17860,6 +19219,9 @@ async function runClipScanInProcess(input) {
|
|
|
17860
19219
|
? { aspect: spec.aspect, focus: normalizeCropFocus(spec.crop_focus) }
|
|
17861
19220
|
: undefined
|
|
17862
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);
|
|
17863
19225
|
let stored = 0;
|
|
17864
19226
|
await scanVideo({
|
|
17865
19227
|
ctx,
|
|
@@ -17867,6 +19229,7 @@ async function runClipScanInProcess(input) {
|
|
|
17867
19229
|
probe,
|
|
17868
19230
|
windows: spec.windows,
|
|
17869
19231
|
durationBand: spec.duration_band ?? null,
|
|
19232
|
+
targetClipCount,
|
|
17870
19233
|
sourceVideoId: input.sourceVideoId,
|
|
17871
19234
|
sourceFilename: input.filename,
|
|
17872
19235
|
ownerId: input.ownerId,
|