@mevdragon/vidfarm-devcli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6831,7 +6831,9 @@ export function renderLibraryPage(input) {
6831
6831
 
6832
6832
  .library-frame { padding-left: 432px; }
6833
6833
  .library-body, .library-shell, .library-list-shell { min-height: 0; }
6834
- .library-shell { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; }
6834
+ /* minmax(0,1fr) column: without it the implicit track grows to the
6835
+ toolbar's min-content width and the whole page overflows on phones. */
6836
+ .library-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 12px; }
6835
6837
  .library-toolbar {
6836
6838
  display: grid;
6837
6839
  grid-template-columns: auto minmax(200px, 1fr) auto auto;
@@ -7036,11 +7038,15 @@ export function renderLibraryPage(input) {
7036
7038
  ${renderReadyPostScheduleStyles()}
7037
7039
  @media (max-width: 1180px) {
7038
7040
  .library-frame { padding-left: 0; }
7039
- .editor-right-rail { position: relative; width: 100%; height: 560px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
7040
7041
  .library-post { grid-template-columns: auto 248px minmax(0, 1fr); }
7041
7042
  .library-post-actions { grid-column: 3; justify-content: flex-start; }
7042
7043
  .library-post[data-collapsed="true"] .library-post-actions { grid-column: auto; }
7043
7044
  }
7045
+ @media (min-width: 1025px) and (max-width: 1180px) {
7046
+ /* Rail stacks inline below the content in this band only; at ≤1024px
7047
+ the shared shell turns the rail into the FAB + popup overlay. */
7048
+ .editor-right-rail { position: relative; width: 100%; height: 560px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
7049
+ }
7044
7050
  @media (min-width: 1025px) and (max-width: 1180px) {
7045
7051
  /* The chat rail stacks below the frame at 1180px but the shell only unlocks
7046
7052
  document scroll at 1024px; unlock it here too so stacked content stays reachable. */
@@ -7129,18 +7135,7 @@ export function renderLibraryPage(input) {
7129
7135
  grid-row: 2;
7130
7136
  }
7131
7137
  .library-frame { padding-left: 0; }
7132
- .editor-right-rail {
7133
- position: fixed !important;
7134
- inset: 0;
7135
- z-index: 110;
7136
- width: 100%;
7137
- height: 100svh;
7138
- min-height: 0;
7139
- margin: 0;
7140
- border-radius: 0;
7141
- overflow: visible;
7142
- pointer-events: none;
7143
- }
7138
+ /* Rail overlay + FAB + popup at ≤1024px come from the shared shell styles. */
7144
7139
  .library-body { padding-top: 12px; }
7145
7140
  .library-shell { min-width: 0; }
7146
7141
  .library-list-shell { overflow: visible; padding-right: 0; }
package/dist/src/app.js CHANGED
@@ -23,7 +23,7 @@ import { writeForkManifest } from "./services/fork-manifest.js";
23
23
  import { renderHelpPage } from "./help-page.js";
24
24
  import { renderHomepage } from "./homepage.js";
25
25
  import { COMPOSITION_RUNTIME_SCRIPT_BODY } from "./composition-runtime.js";
26
- import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER } from "./hyperframes/composition.js";
26
+ import { CAPTION_ANIM_CSS, CAPTION_STYLE_MARKER, KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, TRANSITION_CSS, TRANSITION_STYLE_MARKER, upgradeLegacyTransitionCss } from "./hyperframes/composition.js";
27
27
  import { normalizeCompositionZIndexHtml, sanitizeCompositionHtml } from "./services/composition-sanitize.js";
28
28
  import { applyMarkupUsd } from "./services/billing-pricing.js";
29
29
  import { primitiveRegistry } from "./primitive-registry.js";
@@ -54,7 +54,7 @@ import { loadSpeechAudioLocally, MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES } from "./devc
54
54
  import { DescribeExecutionCommand, SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn";
55
55
  import { clipRecords, makeUserPreset } from "./services/clip-records.js";
56
56
  import { matchScenesToClips, searchUserClips, vectorIndex } from "./services/clip-search.js";
57
- import { BUILTIN_PRESETS, buildEffectiveGuidance, ClipModelClient, detectLocalAgent, effectiveHuntDurationSec, estimateScanCostFromDuration, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, probeVideo, resolveDurationBand, scanVideo } from "./services/clip-curation/index.js";
57
+ import { BUILTIN_PRESETS, buildEffectiveGuidance, ClipModelClient, cosineSimilarity, detectLocalAgent, effectiveHuntDurationSec, estimateScanCostFromDuration, hasFfmpeg, LocalAgentClipClient, normalizeCropFocus, normalizeHuntSpec, parseClipHuntPrompt, probeVideo, resolveDurationBand, scanVideo } from "./services/clip-curation/index.js";
58
58
  import { analyzeCastMembers, annotateScenesFromSource, buildSmartDecomposedHtml, HyperframesService, smartDecomposeVideo, stampCompositionIdentity } from "./services/hyperframes.js";
59
59
  import { buildPendingSceneAnnotationsState } from "./services/scene-annotations.js";
60
60
  import { createPrimitiveJobContext } from "./primitive-context.js";
@@ -401,7 +401,16 @@ const userAttachmentFinalizeSchema = z.object({
401
401
  content_type: z.string().trim().min(1).max(255),
402
402
  size_bytes: z.coerce.number().int().min(1).max(1024 * 1024 * 1024),
403
403
  storage_key: z.string().min(1).max(2000),
404
- folder_path: z.string().trim().max(500).optional()
404
+ folder_path: z.string().trim().max(500).optional(),
405
+ notes: z.string().trim().max(4000).optional()
406
+ });
407
+ const userAttachmentNotesSchema = z.object({
408
+ notes: z.string().max(4000).nullable().optional()
409
+ });
410
+ const userAttachmentSearchSchema = z.object({
411
+ query: z.string().min(1).max(2000),
412
+ folder_path: z.string().trim().max(500).optional(),
413
+ limit: z.coerce.number().int().min(1).max(100).optional()
405
414
  });
406
415
  const userFileFolderSchema = z.object({
407
416
  folder_path: z.string().trim().min(1).max(500)
@@ -2917,6 +2926,9 @@ function serializeUserAttachment(c, attachment) {
2917
2926
  sizeBytes: attachment.sizeBytes,
2918
2927
  createdAt: attachment.createdAt,
2919
2928
  folderPath: attachment.folderPath ?? "",
2929
+ notes: attachment.notes ?? null,
2930
+ // Flag only — the raw vector never ships to the client (mirrors serializeClip).
2931
+ hasNotesEmbedding: Boolean(attachment.notesEmbedding && attachment.notesEmbedding.length > 0),
2920
2932
  viewUrl: attachment.publicUrl || storage.getPublicUrl(attachment.storageKey) || buildUserAttachmentViewUrl(c, attachment.storageKey)
2921
2933
  };
2922
2934
  }
@@ -3975,16 +3987,19 @@ function browseFilesTextContentType(fileName) {
3975
3987
  }
3976
3988
  function createBrowseFilesTool(input) {
3977
3989
  return tool({
3978
- description: "Browse AND write the user's My Files filesystem — their personal library of uploaded assets (mp4, mov, webm, png, jpg, jpeg, gif, webp, svg, mp3, wav, m4a, aac, pdf, md, txt, csv, and more) organized into virtual folders. Use action=list to enumerate files and virtual folders so you can discover what assets exist; pass folder_path to scope the listing to one folder (omit or empty for the root). Use action=read to fetch one file by file_id (preferred; copy it from a prior list 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), and optional folder_path to namescope it under a product/offer folder. 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 only accepts text files (md, txt, csv, json, srt, vtt); to save generated images/video/audio use the primitive routes and reference their durable URLs. write returns the new file_id and view_url. Always list before you read so you use a real file_id; never invent file ids or view URLs.",
3990
+ description: "Browse AND write the user's My Files filesystem — their personal library of uploaded assets (mp4, mov, webm, png, jpg, jpeg, gif, webp, svg, mp3, wav, m4a, aac, pdf, md, txt, csv, and more) organized into virtual folders. Use action=list to enumerate files and virtual folders so you can discover what assets exist; pass folder_path to scope the listing to one folder (omit or empty for the root). 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.",
3979
3991
  inputSchema: z.object({
3980
- action: z.enum(["list", "read", "write"]).describe("list = enumerate files and folders; read = fetch one file's view_url and, for text files, its contents; write = save a text file into My Files."),
3981
- folder_path: z.string().optional().describe("Folder to scope a list to, the folder of the file to read, or the folder to write into. Omit or leave empty for the root."),
3982
- file_id: z.string().optional().describe("Attachment id to read, copied from a prior list result. Preferred over file_name."),
3983
- file_name: z.string().optional().describe("File name to read when you do not have the id, or the name to save under for write (e.g. About.md). For read it is matched within folder_path when one is provided."),
3984
- content: z.string().optional().describe("For write: the full text content of the file to save (e.g. Markdown for About.md). Required for write; ignored otherwise."),
3985
- explanation: z.string().optional().describe("Short reason for the browse or save.")
3992
+ action: z.enum(["list", "read", "write", "annotate", "search"]).describe("list = enumerate files and folders; read = fetch one file's view_url and, for text files, its contents; write = save a text file into My Files; annotate = set the metadata notes on one existing file; search = find files by meaning across names, folders, and notes."),
3993
+ folder_path: z.string().optional().describe("Folder to scope a list or search to, the folder of the file to read, or the folder to write into. Omit or leave empty for the root."),
3994
+ file_id: z.string().optional().describe("Attachment id to read or annotate, copied from a prior list/search result. Preferred over file_name."),
3995
+ 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."),
3996
+ 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."),
3997
+ 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."),
3998
+ 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."),
3999
+ query: z.string().optional().describe("For search: what you're looking for, in plain language. Required for search; ignored otherwise."),
4000
+ explanation: z.string().optional().describe("Short reason for the browse, save, annotation, or search.")
3986
4001
  }),
3987
- execute: async ({ action, folder_path, file_id, file_name, content, explanation }) => {
4002
+ execute: async ({ action, folder_path, file_id, file_name, content, source_url, notes, query, explanation }) => {
3988
4003
  const listUrl = new URL(`${USER_PREFIX}/me/attachments`, config.PUBLIC_BASE_URL);
3989
4004
  const headers = new Headers();
3990
4005
  headers.set("vidfarm-api-key", input.vidfarmApiKey);
@@ -3997,22 +4012,59 @@ function createBrowseFilesTool(input) {
3997
4012
  if (action === "write") {
3998
4013
  const name = (file_name ?? "").trim();
3999
4014
  if (!name) {
4000
- return { action, explanation: explanation ?? null, status: 400, error: "write requires file_name (e.g. About.md)." };
4015
+ return { action, explanation: explanation ?? null, status: 400, error: "write requires file_name (e.g. About.md, or character_sprite_card.png with source_url)." };
4001
4016
  }
4002
- if (typeof content !== "string") {
4003
- return { action, explanation: explanation ?? null, status: 400, error: "write requires content (the full text of the file)." };
4017
+ const importSrc = (source_url ?? "").trim();
4018
+ let fileBlob;
4019
+ if (importSrc) {
4020
+ // Import a durable media URL (e.g. a finished primitive job's output)
4021
+ // into My Files. The api-key header only travels to our own origin.
4022
+ let sourceUrl;
4023
+ try {
4024
+ sourceUrl = new URL(importSrc, config.PUBLIC_BASE_URL);
4025
+ }
4026
+ catch {
4027
+ return { action, explanation: explanation ?? null, status: 400, error: `source_url is not a valid URL: ${importSrc.slice(0, 200)}` };
4028
+ }
4029
+ const sourceHeaders = new Headers();
4030
+ if (sourceUrl.origin === new URL(config.PUBLIC_BASE_URL).origin) {
4031
+ sourceHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
4032
+ }
4033
+ const sourceResponse = await fetch(sourceUrl, {
4034
+ method: "GET",
4035
+ headers: sourceHeaders,
4036
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4037
+ });
4038
+ if (!sourceResponse.ok) {
4039
+ return { action, explanation: explanation ?? null, status: sourceResponse.status, error: `Could not fetch source_url (${sourceResponse.status}). Use a durable URL from a finished job result or an existing asset.` };
4040
+ }
4041
+ const sourceBytes = await sourceResponse.arrayBuffer();
4042
+ if (sourceBytes.byteLength > 50 * 1024 * 1024) {
4043
+ return { action, explanation: explanation ?? null, status: 400, error: "source_url asset is larger than the 50 MB My Files limit." };
4044
+ }
4045
+ const sourceType = (sourceResponse.headers.get("content-type") ?? "").split(";")[0].trim();
4046
+ fileBlob = new Blob([sourceBytes], sourceType ? { type: sourceType } : undefined);
4004
4047
  }
4005
- if (!isBrowseFilesTextFile(name, "")) {
4006
- return { action, explanation: explanation ?? null, status: 400, error: `write only supports text files (md, txt, csv, json, srt, vtt). "${name}" is not one; save generated media via primitive routes and reference their view_url instead.` };
4048
+ else {
4049
+ if (typeof content !== "string") {
4050
+ return { action, explanation: explanation ?? null, status: 400, error: "write requires content (the full text of the file) or source_url (a durable media URL to import)." };
4051
+ }
4052
+ if (!isBrowseFilesTextFile(name, "")) {
4053
+ return { action, explanation: explanation ?? null, status: 400, error: `write with content only supports text files (md, txt, csv, json, srt, vtt). To save media like "${name}", pass its durable URL as source_url instead.` };
4054
+ }
4055
+ fileBlob = new Blob([content], { type: browseFilesTextContentType(name) });
4007
4056
  }
4008
4057
  const uploadUrl = new URL(`${USER_PREFIX}/me/attachments/upload`, config.PUBLIC_BASE_URL);
4009
4058
  const uploadHeaders = new Headers();
4010
4059
  uploadHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
4011
4060
  const form = new FormData();
4012
- form.append("file", new Blob([content], { type: browseFilesTextContentType(name) }), name);
4061
+ form.append("file", fileBlob, name);
4013
4062
  if (normalizedFolder) {
4014
4063
  form.append("folder_path", normalizedFolder);
4015
4064
  }
4065
+ if (typeof notes === "string" && notes.trim()) {
4066
+ form.append("notes", notes.trim());
4067
+ }
4016
4068
  const writeResponse = await fetch(uploadUrl, {
4017
4069
  method: "POST",
4018
4070
  headers: uploadHeaders,
@@ -4047,11 +4099,65 @@ function createBrowseFilesTool(input) {
4047
4099
  content_type: saved.contentType,
4048
4100
  size_bytes: saved.sizeBytes,
4049
4101
  folder_path: saved.folderPath ?? "",
4102
+ notes: saved.notes ?? null,
4050
4103
  view_url: saved.viewUrl,
4051
4104
  created_at: saved.createdAt
4052
4105
  }
4053
4106
  };
4054
4107
  }
4108
+ if (action === "search") {
4109
+ const q = (query ?? "").trim();
4110
+ if (!q) {
4111
+ return { action, explanation: explanation ?? null, status: 400, error: "search requires query (what you're looking for, in plain language)." };
4112
+ }
4113
+ const searchUrl = new URL(`${USER_PREFIX}/me/attachments/search`, config.PUBLIC_BASE_URL);
4114
+ const searchHeaders = new Headers();
4115
+ searchHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
4116
+ searchHeaders.set("Content-Type", "application/json");
4117
+ const searchResponse = await fetch(searchUrl, {
4118
+ method: "POST",
4119
+ headers: searchHeaders,
4120
+ body: JSON.stringify({ query: q, ...(normalizedFolder ? { folder_path: normalizedFolder } : {}), limit: 20 }),
4121
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4122
+ });
4123
+ const searchText = await searchResponse.text();
4124
+ let searchJson = null;
4125
+ try {
4126
+ searchJson = searchText ? JSON.parse(searchText) : null;
4127
+ }
4128
+ catch {
4129
+ searchJson = null;
4130
+ }
4131
+ if (!searchResponse.ok || !Array.isArray(searchJson?.results)) {
4132
+ return {
4133
+ action,
4134
+ explanation: explanation ?? null,
4135
+ status: searchResponse.status,
4136
+ error: `My Files search failed (${searchResponse.status}). ${(searchJson?.error ?? searchText).slice(0, 500)}`
4137
+ };
4138
+ }
4139
+ return {
4140
+ action,
4141
+ explanation: explanation ?? null,
4142
+ status: 200,
4143
+ query: q,
4144
+ folder_path: normalizedFolder,
4145
+ semantic: Boolean(searchJson.semantic),
4146
+ result_count: searchJson.results.length,
4147
+ results: searchJson.results.map((file) => ({
4148
+ file_id: file.id,
4149
+ file_name: file.fileName,
4150
+ content_type: file.contentType,
4151
+ size_bytes: file.sizeBytes,
4152
+ folder_path: file.folderPath ?? "",
4153
+ notes: file.notes ?? null,
4154
+ similarity: file.similarity ?? null,
4155
+ keyword_match: Boolean(file.keyword_match),
4156
+ view_url: file.viewUrl,
4157
+ created_at: file.createdAt
4158
+ }))
4159
+ };
4160
+ }
4055
4161
  const listResponse = await fetch(listUrl, {
4056
4162
  method: "GET",
4057
4163
  headers,
@@ -4086,6 +4192,7 @@ function createBrowseFilesTool(input) {
4086
4192
  content_type: file.contentType,
4087
4193
  size_bytes: file.sizeBytes,
4088
4194
  folder_path: file.folderPath ?? "",
4195
+ notes: file.notes ?? null,
4089
4196
  view_url: file.viewUrl,
4090
4197
  created_at: file.createdAt
4091
4198
  }))
@@ -4104,7 +4211,56 @@ function createBrowseFilesTool(input) {
4104
4211
  status: 404,
4105
4212
  error: file_id || file_name
4106
4213
  ? "No matching file in My Files. Call action=list first to copy an exact file_id."
4107
- : "read requires file_id (preferred) or file_name."
4214
+ : `${action} requires file_id (preferred) or file_name.`
4215
+ };
4216
+ }
4217
+ if (action === "annotate") {
4218
+ if (typeof notes !== "string") {
4219
+ return { action, explanation: explanation ?? null, status: 400, error: "annotate requires notes (the metadata to attach; empty string clears)." };
4220
+ }
4221
+ const annotateUrl = new URL(`${USER_PREFIX}/me/attachments/${encodeURIComponent(match.id)}`, config.PUBLIC_BASE_URL);
4222
+ const annotateHeaders = new Headers();
4223
+ annotateHeaders.set("vidfarm-api-key", input.vidfarmApiKey);
4224
+ annotateHeaders.set("Content-Type", "application/json");
4225
+ const annotateResponse = await fetch(annotateUrl, {
4226
+ method: "PATCH",
4227
+ headers: annotateHeaders,
4228
+ body: JSON.stringify({ notes }),
4229
+ signal: mergeAbortSignals(input.abortSignal, timeoutController.signal)
4230
+ });
4231
+ const annotateText = await annotateResponse.text();
4232
+ let annotateJson = null;
4233
+ try {
4234
+ annotateJson = annotateText ? JSON.parse(annotateText) : null;
4235
+ }
4236
+ catch {
4237
+ annotateJson = null;
4238
+ }
4239
+ if (!annotateResponse.ok || !annotateJson?.attachment) {
4240
+ return {
4241
+ action,
4242
+ explanation: explanation ?? null,
4243
+ status: annotateResponse.status,
4244
+ error: `Could not annotate file (${annotateResponse.status}). ${(annotateJson?.error ?? annotateText).slice(0, 500)}`
4245
+ };
4246
+ }
4247
+ const annotated = annotateJson.attachment;
4248
+ return {
4249
+ action,
4250
+ explanation: explanation ?? null,
4251
+ status: 200,
4252
+ annotated: true,
4253
+ notes_embedded: Boolean(annotateJson.notes_embedded),
4254
+ file: {
4255
+ file_id: annotated.id,
4256
+ file_name: annotated.fileName,
4257
+ content_type: annotated.contentType,
4258
+ size_bytes: annotated.sizeBytes,
4259
+ folder_path: annotated.folderPath ?? "",
4260
+ notes: annotated.notes ?? null,
4261
+ view_url: annotated.viewUrl,
4262
+ created_at: annotated.createdAt
4263
+ }
4108
4264
  };
4109
4265
  }
4110
4266
  const fileInfo = {
@@ -4113,6 +4269,7 @@ function createBrowseFilesTool(input) {
4113
4269
  content_type: match.contentType,
4114
4270
  size_bytes: match.sizeBytes,
4115
4271
  folder_path: match.folderPath ?? "",
4272
+ notes: match.notes ?? null,
4116
4273
  view_url: match.viewUrl,
4117
4274
  created_at: match.createdAt
4118
4275
  };
@@ -4231,8 +4388,9 @@ function createEditorActionTool() {
4231
4388
  "replace_composition_html",
4232
4389
  "export_composition",
4233
4390
  "generate_layer",
4234
- "set_captions"
4235
- ]).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."),
4391
+ "set_captions",
4392
+ "set_transitions"
4393
+ ]).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."),
4236
4394
  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."),
4237
4395
  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."),
4238
4396
  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."),
@@ -4265,8 +4423,12 @@ function createEditorActionTool() {
4265
4423
  object_position: z.string().optional().describe("CSS object-position for image/video (e.g., center, top, bottom left)."),
4266
4424
  ken_burns: z.enum(["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right", "none"]).optional().describe("Ken Burns motion for still-image layers: a slow pan/zoom spanning the clip's full duration. Apply with set_layer_media on an existing image, or seed it on add_layer/generate_layer when kind/media_type is image. 'none' removes the effect. When the user says 'animate the images' / 'ken burns', apply it to every still image, varying presets across adjacent clips (zoom-in, pan-left, zoom-out, ...) for a documentary feel."),
4267
4425
  ken_burns_intensity: z.number().optional().describe("Ken Burns strength: extra zoom / pan travel as a fraction of the frame, 0.04-0.5 (default 0.18; ~0.1 subtle, ~0.3 dramatic). Only meaningful alongside ken_burns."),
4268
- transition: z.enum(["crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. When the user asks for 'transitions between scenes' / 'smooth cuts', apply one to EVERY scene clip except the first, varying or matching presets to the template's energy (crossfade/fade-black = calm, slide/wipe = energetic, zoom/circle-open = punchy)."),
4269
- transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition."),
4426
+ transition: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("Scene transition INTO this clip at its start, from the previous clip on the same track. Set it on the INCOMING clip (the one after the cut) with set_layer_media, or seed it on add_layer/generate_layer. On set_transitions it is the junction preset applied at EVERY cut. Works on video and image scene clips; 'none' removes it. The previous butt-cut clip is automatically held on screen beneath the incoming one for the transition window — do not retime anything yourself. Preset energy: crossfade/fade-black/fade-white/blur = calm, smoke = dreamy, slide/wipe = energetic, whip/flash = fast-paced social, zoom/circle-open = punchy."),
4427
+ transition_duration: z.number().optional().describe("Transition length in seconds, 0.1-2.5 (default 0.5; ~0.3 snappy, ~1.0 slow cinematic). Only meaningful alongside transition/transition_intro."),
4428
+ transition_out: z.enum(["fade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-close", "none"]).optional().describe("Exit transition OUT of this clip at its end (fade to black, slide away, ...). Set it with set_layer_media on the clip that should animate away, or seed it on add_layer/generate_layer. The animation plays over the clip's last transition_out_duration seconds; no retiming needed. Most cuts only need the incoming clip's transition — use transition_out for ending a video (fade to black), for clips followed by a gap, or for deliberate dip-to-black moments (outgoing fade-black + incoming fade-black)."),
4429
+ transition_out_duration: z.number().optional().describe("Exit transition length in seconds, 0.1-2.5 (default 0.5). Only meaningful alongside transition_out/transition_outro."),
4430
+ transition_intro: z.enum(["crossfade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "whip-left", "whip-right", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open", "none"]).optional().describe("set_transitions only: entrance for the very FIRST scene clip (fade in from black = fade-black). 'none' clears it."),
4431
+ transition_outro: z.enum(["fade", "fade-black", "fade-white", "flash", "smoke", "blur", "slide-left", "slide-right", "slide-up", "slide-down", "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-close", "none"]).optional().describe("set_transitions only: exit for the very LAST scene clip (fade to black = fade-black). 'none' clears it."),
4270
4432
  caption_style: z.enum(["spotlight", "karaoke", "word-pop", "stack-up", "neon", "bounce"]).optional().describe("Animated caption preset look: spotlight (active word gets a highlight pill, bold outline text), karaoke (words fill with color as spoken), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and stay), neon (active word glow pulse), bounce (active word bounces). Applies animation + colors + background treatment in one go; individual caption_*/color/background/background_style fields override specifics."),
4271
4433
  caption_animation: z.enum(["highlight-pop", "karaoke-fill", "word-pop", "cumulative-reveal", "glow-pulse", "bounce-wave", "none"]).optional().describe("Word-by-word caption animation, if you want to set it directly instead of via caption_style. Works on set_captions, add_layer (kind=text), and set_layer_style. 'none' removes the animation and flattens the layer back to static text."),
4272
4434
  caption_active_color: z.string().optional().describe("CSS color the active word takes while it is spoken (karaoke fill color, highlight text color, glow color)."),
@@ -4691,7 +4853,11 @@ function rewriteHyperframesDraftCompositionHtml(html, draftPath) {
4691
4853
  }
4692
4854
  }
4693
4855
  // And the scene-transition keyframes: toggling data-transition on a clip
4694
- // must animate immediately on older stored compositions.
4856
+ // must animate immediately on older stored compositions. Compositions that
4857
+ // embed the V1 block get it swapped for the current one in place; if the
4858
+ // swap misses (drifted block), the marker check below appends V2 after it,
4859
+ // which wins by source order.
4860
+ rewritten = upgradeLegacyTransitionCss(rewritten);
4695
4861
  if (!rewritten.includes(TRANSITION_STYLE_MARKER)) {
4696
4862
  const transitionTag = `<style data-vf-transitions="true">${TRANSITION_CSS}</style>`;
4697
4863
  if (/<\/head>/i.test(rewritten)) {
@@ -14772,6 +14938,52 @@ app.get(`${USER_PREFIX}/me/generated-media`, async (c) => {
14772
14938
  next_cursor: page.nextCursor
14773
14939
  });
14774
14940
  });
14941
+ // Embedder for My Files notes — the same BYOK seam clip search uses. Prefer the
14942
+ // provider matching the embedding model the file library was built with (from
14943
+ // the newest annotated attachment) so query vectors stay cosine-comparable; a
14944
+ // fresh library takes the first embed-capable key (gemini → openai; openrouter
14945
+ // can't embed). Null (no key) fail-softs notes search to keyword-only.
14946
+ async function buildFileNotesEmbedder(customerId) {
14947
+ const attachments = await serverlessRecords.listUserAttachments(customerId);
14948
+ const libModel = attachments.find((a) => a.notesEmbeddingModel && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0)?.notesEmbeddingModel ?? null;
14949
+ const providers = libModel ? [libModel.startsWith("text-embedding") ? "openai" : "gemini"] : ["gemini", "openai"];
14950
+ for (const provider of providers) {
14951
+ const apiKey = await resolveCustomerProviderKey(customerId, provider);
14952
+ if (!apiKey)
14953
+ continue;
14954
+ const client = new ClipModelClient({ provider, apiKey });
14955
+ if (client.canEmbed)
14956
+ return client;
14957
+ }
14958
+ return null;
14959
+ }
14960
+ // What we embed for a My Files entry: folder + file name + the notes, so
14961
+ // "the sprite card for our mascot" is findable from any phrasing. Fail-soft:
14962
+ // no key or an embed error still saves the notes (keyword-searchable).
14963
+ async function embedAttachmentNotes(customerId, input) {
14964
+ if (!input.notes.trim())
14965
+ return null;
14966
+ try {
14967
+ const embedder = await buildFileNotesEmbedder(customerId);
14968
+ if (!embedder?.embeddingModelId)
14969
+ return null;
14970
+ const text = [input.folderPath, input.fileName, input.notes].filter(Boolean).join("\n");
14971
+ const embedding = await embedder.embedQuery(text);
14972
+ return embedding && embedding.length > 0 ? { embedding, model: embedder.embeddingModelId } : null;
14973
+ }
14974
+ catch (error) {
14975
+ devLog("attachment_notes.embed_failed", devErrorFields(error));
14976
+ return null;
14977
+ }
14978
+ }
14979
+ function attachmentKeywordMatch(attachment, q) {
14980
+ const hay = `${attachment.fileName} ${attachment.folderPath ?? ""} ${attachment.notes ?? ""}`.toLowerCase();
14981
+ return q
14982
+ .toLowerCase()
14983
+ .split(/\s+/)
14984
+ .filter(Boolean)
14985
+ .every((term) => hay.includes(term));
14986
+ }
14775
14987
  app.get(`${USER_PREFIX}/me/attachments`, async (c) => {
14776
14988
  const customer = requireCustomer(c);
14777
14989
  const attachments = (await serverlessRecords.listUserAttachments(customer.id)).map((attachment) => serializeUserAttachment(c, attachment));
@@ -14850,6 +15062,7 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
14850
15062
  const fileName = sanitizeFileName(file.name || "upload.bin");
14851
15063
  const contentType = file.type || inferAttachmentContentType(fileName);
14852
15064
  const folderPath = sanitizeStorageSubpath(String(formData.get("folder_path") ?? ""));
15065
+ const notes = String(formData.get("notes") ?? "").trim().slice(0, 4000) || null;
14853
15066
  if (!isAllowedAttachment(fileName, contentType)) {
14854
15067
  return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
14855
15068
  }
@@ -14861,6 +15074,7 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
14861
15074
  const attachmentId = randomUUID();
14862
15075
  const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName, folderPath);
14863
15076
  const stored = await storage.putBuffer(storageKey, buffer, contentType);
15077
+ const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
14864
15078
  await serverlessRecords.createUserAttachment({
14865
15079
  id: attachmentId,
14866
15080
  customerId: customer.id,
@@ -14869,7 +15083,10 @@ app.post(`${USER_PREFIX}/me/attachments/upload`, async (c) => {
14869
15083
  sizeBytes: buffer.byteLength,
14870
15084
  storageKey,
14871
15085
  folderPath,
14872
- publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey)
15086
+ publicUrl: stored.url || storage.getPublicUrl(storageKey) || buildUserAttachmentViewUrl(c, storageKey),
15087
+ notes,
15088
+ notesEmbedding: embedded?.embedding ?? null,
15089
+ notesEmbeddingModel: embedded?.model ?? null
14873
15090
  });
14874
15091
  const attachment = await serverlessRecords.getUserAttachment(customer.id, attachmentId);
14875
15092
  if (!attachment) {
@@ -14898,6 +15115,8 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
14898
15115
  if (!isAllowedAttachment(fileName, contentType)) {
14899
15116
  return c.json({ error: `Unsupported attachment type: ${fileName}` }, 400);
14900
15117
  }
15118
+ const notes = body.notes?.trim() || null;
15119
+ const embedded = notes ? await embedAttachmentNotes(customer.id, { fileName, folderPath, notes }) : null;
14901
15120
  await serverlessRecords.createUserAttachment({
14902
15121
  id: body.attachment_id,
14903
15122
  customerId: customer.id,
@@ -14906,7 +15125,10 @@ app.post(`${USER_PREFIX}/me/attachments`, async (c) => {
14906
15125
  sizeBytes: body.size_bytes,
14907
15126
  storageKey: expectedStorageKey,
14908
15127
  folderPath,
14909
- publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey)
15128
+ publicUrl: storage.getPublicUrl(expectedStorageKey) || buildUserAttachmentViewUrl(c, expectedStorageKey),
15129
+ notes,
15130
+ notesEmbedding: embedded?.embedding ?? null,
15131
+ notesEmbeddingModel: embedded?.model ?? null
14910
15132
  });
14911
15133
  const attachment = await serverlessRecords.getUserAttachment(customer.id, body.attachment_id);
14912
15134
  if (!attachment) {
@@ -14940,6 +15162,89 @@ app.patch(`${USER_PREFIX}/me/attachments/folders`, async (c) => {
14940
15162
  await serverlessRecords.moveUserAttachmentFolder(customer.id, fromFolder, toFolder);
14941
15163
  return c.json({ ok: true, from_folder_path: fromFolder, to_folder_path: toFolder });
14942
15164
  });
15165
+ // PATCH /me/attachments/:attachmentId — set/clear the metadata notes on one My
15166
+ // Files entry and (re)embed them for vector search. Notes are what make assets
15167
+ // findable by MEANING later — e.g. annotate a character_sprite_card.png with
15168
+ // who the character is so /me/attachments/search finds it from any phrasing.
15169
+ // (Registered after the /folders PATCH so "folders" never matches as an id.)
15170
+ app.patch(`${USER_PREFIX}/me/attachments/:attachmentId`, async (c) => {
15171
+ const customer = requireCustomer(c);
15172
+ try {
15173
+ const body = userAttachmentNotesSchema.parse(await c.req.json());
15174
+ const attachment = await serverlessRecords.getUserAttachment(customer.id, c.req.param("attachmentId"));
15175
+ if (!attachment) {
15176
+ return c.json({ error: "Attachment not found." }, 404);
15177
+ }
15178
+ const notes = (body.notes ?? "").trim() || null;
15179
+ const embedded = notes
15180
+ ? await embedAttachmentNotes(customer.id, { fileName: attachment.fileName, folderPath: attachment.folderPath, notes })
15181
+ : null;
15182
+ const updated = await serverlessRecords.updateUserAttachmentNotes(customer.id, attachment.id, {
15183
+ notes,
15184
+ notesEmbedding: embedded?.embedding ?? null,
15185
+ notesEmbeddingModel: embedded?.model ?? null
15186
+ });
15187
+ if (!updated) {
15188
+ return c.json({ error: "Attachment not found." }, 404);
15189
+ }
15190
+ return c.json({ ok: true, attachment: serializeUserAttachment(c, updated), notes_embedded: Boolean(embedded) });
15191
+ }
15192
+ catch (error) {
15193
+ return c.json({ error: error instanceof Error ? error.message : "Unable to update attachment notes." }, 400);
15194
+ }
15195
+ });
15196
+ // POST /me/attachments/search — find My Files entries by meaning, mirroring
15197
+ // /clips/search: keyword filter over name/folder/notes, semantically re-ranked
15198
+ // via the notes embeddings; when nothing keyword-matches, relax to a pure
15199
+ // semantic pass. Embeddings are BYOK — no saved key fail-softs to keyword-only.
15200
+ app.post(`${USER_PREFIX}/me/attachments/search`, async (c) => {
15201
+ const customer = requireCustomer(c);
15202
+ try {
15203
+ const body = userAttachmentSearchSchema.parse(await c.req.json());
15204
+ const query = body.query.trim();
15205
+ if (!query) {
15206
+ return c.json({ error: "Provide a search query." }, 400);
15207
+ }
15208
+ const folderPath = sanitizeStorageSubpath(body.folder_path);
15209
+ const limit = Math.max(1, Math.min(100, body.limit ?? 20));
15210
+ const all = await serverlessRecords.listUserAttachments(customer.id);
15211
+ const scoped = folderPath ? all.filter((a) => (a.folderPath ?? "") === folderPath) : all;
15212
+ let queryEmbedding;
15213
+ try {
15214
+ const embedder = await buildFileNotesEmbedder(customer.id);
15215
+ if (embedder)
15216
+ queryEmbedding = await embedder.embedQuery(query);
15217
+ }
15218
+ catch (error) {
15219
+ devLog("attachment_search.embed_failed", devErrorFields(error));
15220
+ }
15221
+ const keywordIds = new Set(scoped.filter((a) => attachmentKeywordMatch(a, query)).map((a) => a.id));
15222
+ const pool = keywordIds.size > 0 ? scoped.filter((a) => keywordIds.has(a.id)) : scoped;
15223
+ const hits = pool
15224
+ .map((a) => ({
15225
+ attachment: a,
15226
+ keyword: keywordIds.has(a.id),
15227
+ similarity: queryEmbedding && Array.isArray(a.notesEmbedding) && a.notesEmbedding.length > 0
15228
+ ? cosineSimilarity(a.notesEmbedding, queryEmbedding)
15229
+ : undefined
15230
+ }))
15231
+ .filter((h) => h.keyword || (h.similarity ?? 0) > 0)
15232
+ .sort((x, y) => ((y.similarity ?? 0) + (y.keyword ? 0.2 : 0)) - ((x.similarity ?? 0) + (x.keyword ? 0.2 : 0)))
15233
+ .slice(0, limit);
15234
+ return c.json({
15235
+ results: hits.map((h) => ({
15236
+ ...serializeUserAttachment(c, h.attachment),
15237
+ similarity: h.similarity ?? null,
15238
+ keyword_match: h.keyword
15239
+ })),
15240
+ semantic: Boolean(queryEmbedding),
15241
+ folder_path: folderPath || null
15242
+ });
15243
+ }
15244
+ catch (error) {
15245
+ return c.json({ error: error instanceof Error ? error.message : "My Files search failed." }, 400);
15246
+ }
15247
+ });
14943
15248
  app.delete(`${USER_PREFIX}/me/attachments/folders`, async (c) => {
14944
15249
  const customer = requireCustomer(c);
14945
15250
  const body = userFileFolderSchema.parse(await c.req.json());
@@ -15519,20 +15824,31 @@ function renderClipsPage(input) {
15519
15824
  .clips-import-note[data-tone="success"] { color: #3f7d55; }
15520
15825
  .clips-import-actions { display: flex; justify-content: flex-end; gap: 10px; }
15521
15826
 
15522
- @media (max-width: 1180px) {
15827
+ @media (min-width: 1025px) and (max-width: 1180px) {
15828
+ /* Rail stacks inline below the content in this band only; at ≤1024px
15829
+ the shared shell turns the rail into the FAB + popup overlay. */
15523
15830
  .clips-frame { padding-left: 0; }
15524
15831
  .editor-right-rail { position: relative; inset: auto; width: 100%; height: 540px; margin-top: 14px; border-radius: 24px; overflow: hidden; }
15525
15832
  .editor-chat-panel { border-right: 0; border-radius: 24px; }
15526
- }
15527
- @media (min-width: 1025px) and (max-width: 1180px) {
15528
15833
  html, body { height: auto; min-height: 100%; overflow: auto; overflow-x: hidden; }
15529
15834
  .app-shell { height: auto; min-height: 100svh; }
15530
15835
  .page-stack, .frame { height: auto; min-height: 0; overflow: visible; }
15531
15836
  .clips-scroll { overflow: visible; padding-right: 0; }
15532
15837
  }
15533
15838
  @media (max-width: 1024px) {
15839
+ .clips-frame { padding-left: 0; }
15534
15840
  .clips-shell { grid-template-rows: auto auto; }
15535
15841
  .clips-scroll { overflow: visible; padding-right: 0; }
15842
+ .clips-toolbar-row { gap: 8px; }
15843
+ .clips-search { flex: 1 1 100%; min-width: 0; }
15844
+ .clips-search input { min-width: 0; font-size: 16px; }
15845
+ .clips-import-form input,
15846
+ .clips-import-form textarea,
15847
+ .clips-import-form select { font-size: 16px; }
15848
+ }
15849
+ @media (max-width: 560px) {
15850
+ .clips-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
15851
+ .clip-body { padding: 10px; }
15536
15852
  }
15537
15853
  `,
15538
15854
  body: `