@mevdragon/vidfarm-devcli 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +77 -75
- package/dist/src/app.js +1513 -145
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +396 -8
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +33 -17
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +283 -3
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- package/public/assets/placeholders/scene-placeholder.png +0 -0
package/dist/src/reskin/theme.js
CHANGED
|
@@ -84,7 +84,11 @@ export const RESKIN_CSS = `
|
|
|
84
84
|
/* layout */
|
|
85
85
|
.rk-container{width:min(var(--rk-container),100%);margin:0 auto;padding:0 var(--rk-gutter)}
|
|
86
86
|
.rk-container-wide{width:min(85rem,100%);margin:0 auto;padding:0 var(--rk-gutter)}
|
|
87
|
-
.rk-
|
|
87
|
+
/* vertical-only so it never clobbers the horizontal gutter that .rk-container
|
|
88
|
+
/ .rk-container-wide set via padding-left/right (a full padding shorthand
|
|
89
|
+
here would zero the side gutter, leaving content flush to the viewport
|
|
90
|
+
edges, most visible on mobile) */
|
|
91
|
+
.rk-page{padding-top:24px;padding-bottom:80px}
|
|
88
92
|
.rk-page-head{display:grid;gap:4px;margin-bottom:18px}
|
|
89
93
|
.rk-h1{font-size:var(--rk-text-3xl);letter-spacing:-.03em}
|
|
90
94
|
.rk-sub{font-size:var(--rk-text-base);color:var(--rk-text-muted)}
|
|
@@ -116,11 +120,10 @@ export const RESKIN_CSS = `
|
|
|
116
120
|
transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
|
|
117
121
|
.rk-sidebar-item:hover{background:var(--rk-n-100);color:var(--rk-ink)}
|
|
118
122
|
.rk-sidebar-item.is-active{background:var(--rk-gold-tint);color:var(--rk-ink)}
|
|
119
|
-
/* signed-in identity — subtle, small, pinned to the
|
|
120
|
-
* tell which account you're in at a glance
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.rk-sidebar-account{margin-top:8px;padding:8px 13px 80px;border-top:1px solid var(--rk-border);
|
|
123
|
+
/* signed-in identity — subtle, small, pinned to the very bottom of the sidebar
|
|
124
|
+
* so you can tell which account you're in at a glance. The chat FAB hovers
|
|
125
|
+
* ABOVE this block (see .rk-signed-in .rk-fab) rather than sharing the spot. */
|
|
126
|
+
.rk-sidebar-account{margin-top:8px;padding:8px 13px 2px;border-top:1px solid var(--rk-border);
|
|
124
127
|
color:var(--rk-n-500);line-height:1.3;overflow:hidden}
|
|
125
128
|
.rk-sidebar-account-name{font-size:12px;font-weight:600;color:var(--rk-n-600);
|
|
126
129
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -135,6 +138,10 @@ export const RESKIN_CSS = `
|
|
|
135
138
|
border:0;cursor:grab;background:var(--rk-gold-500);color:var(--rk-ink);display:grid;place-items:center;
|
|
136
139
|
box-shadow:var(--rk-shadow-lg);touch-action:none;user-select:none;-webkit-user-select:none;
|
|
137
140
|
transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),opacity var(--rk-dur) var(--rk-ease)}
|
|
141
|
+
/* signed in: the name/email block sits at the sidebar foot, so lift the FAB's
|
|
142
|
+
* default resting spot to hover just above it (never overlapping the identity).
|
|
143
|
+
* Only applies to the CSS default — a dragged FAB uses its saved inline top. */
|
|
144
|
+
.rk-signed-in .rk-fab{bottom:calc(84px + env(safe-area-inset-bottom))}
|
|
138
145
|
.rk-fab:hover{transform:translateY(-2px);box-shadow:0 6px 18px -6px rgba(252,185,0,.6),var(--rk-shadow-lg)}
|
|
139
146
|
.rk-fab.is-dragging{cursor:grabbing;transition:none;transform:scale(1.08)}
|
|
140
147
|
.rk-fab.is-open{opacity:0;pointer-events:none;transform:scale(.6)}
|
|
@@ -231,6 +238,12 @@ export const RESKIN_CSS = `
|
|
|
231
238
|
a folder Open button, a per-row "…" kebab menu, and a copy-path toast.
|
|
232
239
|
Reused verbatim by the chat dock (attach mode) and /library/raws (standalone). */
|
|
233
240
|
.rk-dir{display:flex;flex-direction:column;min-height:0;flex:1}
|
|
241
|
+
.rk-dir-space{flex:none;display:inline-flex;gap:2px;margin:2px 12px 8px;padding:2px;
|
|
242
|
+
background:var(--rk-n-100,#f1ede6);border:1px solid var(--rk-border);border-radius:var(--rk-r-full);align-self:flex-start}
|
|
243
|
+
.rk-dir-space-btn{font-family:inherit;font-size:12px;font-weight:600;color:var(--rk-n-500,#7a7266);
|
|
244
|
+
background:transparent;border:0;border-radius:var(--rk-r-full);padding:4px 14px;cursor:pointer;
|
|
245
|
+
transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
|
|
246
|
+
.rk-dir-space-btn.is-on{background:var(--rk-surface,#fff);color:var(--rk-ink);box-shadow:var(--rk-shadow-sm,0 1px 2px rgba(0,0,0,.06))}
|
|
234
247
|
.rk-dir-search{flex:none;padding:0 12px 8px}
|
|
235
248
|
.rk-dir-search-input{width:100%;font-family:inherit;font-size:12.5px;color:var(--rk-ink);
|
|
236
249
|
background:var(--rk-surface,#fff);border:1px solid var(--rk-border);border-radius:var(--rk-r-full);padding:7px 12px;
|
|
@@ -302,8 +315,12 @@ export const RESKIN_CSS = `
|
|
|
302
315
|
.rk-aichat-tool:hover{background:var(--rk-n-100);color:var(--rk-ink)}
|
|
303
316
|
.rk-aichat-tool svg{width:15px;height:15px}
|
|
304
317
|
.rk-aichat-tool.is-on{background:var(--rk-gold-tint);border-color:var(--rk-gold-600);color:var(--rk-ink)}
|
|
305
|
-
|
|
306
|
-
|
|
318
|
+
/* overflow-x:hidden + a capped single column (minmax(0,1fr), NOT the default
|
|
319
|
+
auto track which grows to content) keep the log scrolling ONLY vertically —
|
|
320
|
+
a wide child (long tool note / URL / file path) can no longer widen the grid
|
|
321
|
+
and force a horizontal scrollbar in the narrow dock column. */
|
|
322
|
+
.rk-aichat-log{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:16px;display:grid;grid-template-columns:minmax(0,1fr);gap:10px;align-content:start;background:var(--rk-n-50)}
|
|
323
|
+
.rk-aichat-msg{max-width:85%;min-width:0;padding:10px 14px;border-radius:var(--rk-r-2xl);font-size:13.5px;line-height:1.5;overflow-wrap:anywhere;word-break:break-word}
|
|
307
324
|
.rk-aichat-msg.is-ai{background:#fff;border:1px solid var(--rk-border);color:var(--rk-text);justify-self:start;
|
|
308
325
|
border-bottom-left-radius:var(--rk-r-sm);box-shadow:var(--rk-shadow-xs)}
|
|
309
326
|
.rk-aichat-msg.is-user{background:var(--rk-gold-tint2);color:var(--rk-ink);justify-self:end;border-bottom-right-radius:var(--rk-r-sm)}
|
|
@@ -320,6 +337,11 @@ export const RESKIN_CSS = `
|
|
|
320
337
|
.rk-aichat-chip-label{max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;
|
|
321
338
|
font-size:11.5px;font-weight:600;text-align:left;text-decoration:underline;text-underline-offset:2px;text-decoration-color:transparent}
|
|
322
339
|
.rk-aichat-chip-label:hover{text-decoration-color:currentColor}
|
|
340
|
+
.rk-aichat-chip.is-pending{opacity:.75}
|
|
341
|
+
.rk-aichat-chip.is-pending .rk-aichat-chip-label{text-decoration:none;cursor:default}
|
|
342
|
+
.rk-aichat-chip-spin{width:11px;height:11px;flex:none;border-radius:50%;border:2px solid currentColor;
|
|
343
|
+
border-top-color:transparent;animation:rk-aichat-chip-spin .7s linear infinite}
|
|
344
|
+
@keyframes rk-aichat-chip-spin{to{transform:rotate(360deg)}}
|
|
323
345
|
/* composer = one bordered "text area" box: the textarea fills the top and the
|
|
324
346
|
attach + send controls sit inside it along the bottom row. */
|
|
325
347
|
.rk-aichat-composer{display:flex;flex-direction:column;gap:8px;margin:12px;padding:10px 10px 9px;flex:none;background:#fff;
|
|
@@ -341,8 +363,8 @@ export const RESKIN_CSS = `
|
|
|
341
363
|
.rk-aichat-attach svg{width:18px;height:18px}
|
|
342
364
|
|
|
343
365
|
/* ── live agentic turn: status → actions (tool notes + HTTP cards) → text ── */
|
|
344
|
-
.rk-aichat-turn{display:grid;gap:8px;justify-items:start;width:100
|
|
345
|
-
.rk-aichat-stream{white-space:pre-wrap}
|
|
366
|
+
.rk-aichat-turn{display:grid;gap:8px;justify-items:start;width:100%;min-width:0}
|
|
367
|
+
.rk-aichat-stream{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-width:100%}
|
|
346
368
|
.rk-aichat-stream[hidden]{display:none}
|
|
347
369
|
.rk-aichat-actions{display:grid;gap:6px;width:100%}
|
|
348
370
|
.rk-aichat-actions:empty{display:none}
|
|
@@ -356,7 +378,7 @@ export const RESKIN_CSS = `
|
|
|
356
378
|
@keyframes rk-aichat-blink{0%,80%,100%{opacity:.28;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
|
|
357
379
|
.rk-aichat-toolnote{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;color:var(--rk-text-muted);
|
|
358
380
|
font-family:var(--rk-font-mono);background:#fff;border:1px solid var(--rk-border);
|
|
359
|
-
border-radius:var(--rk-r-lg);padding:5px 10px;width:fit-content}
|
|
381
|
+
border-radius:var(--rk-r-lg);padding:5px 10px;width:fit-content;max-width:100%;overflow-wrap:anywhere;word-break:break-word}
|
|
360
382
|
.rk-aichat-toolnote::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--rk-gold-500);flex:none}
|
|
361
383
|
.rk-aichat-loadmore{width:100%;margin-top:6px;padding:9px 12px;font-size:12.5px;font-weight:600;
|
|
362
384
|
color:var(--rk-text);background:var(--rk-white);border:1px solid var(--rk-border);
|
|
@@ -521,6 +543,9 @@ export const RESKIN_CSS = `
|
|
|
521
543
|
/* horizontal strip: kill the foot border + top offset so the account block
|
|
522
544
|
sits centered in the row instead of showing a stray hairline above it */
|
|
523
545
|
.rk-sidebar-account{padding:8px 13px 2px;border-top:0;margin-top:0}
|
|
546
|
+
/* strip/burger layout: the account block is no longer at the sidebar foot,
|
|
547
|
+
so drop the FAB back to its normal bottom-left resting spot */
|
|
548
|
+
.rk-signed-in .rk-fab{bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
524
549
|
.rk-content{margin-left:0}
|
|
525
550
|
.rk-chat-left .rk-sidebar{visibility:visible}
|
|
526
551
|
}
|
|
@@ -164,6 +164,10 @@ function resolveCostCenterSlug(input) {
|
|
|
164
164
|
|| explicitSlug === "video_download_lambda"
|
|
165
165
|
|| explicitSlug === "video_ingest_lambda"
|
|
166
166
|
|| explicitSlug === "rapidapi_remove_background"
|
|
167
|
+
// Local sharp chroma-key background removal + the 2-step media-overlay
|
|
168
|
+
// primitive (BYOK image gen + chroma key): platform compute/convenience.
|
|
169
|
+
|| explicitSlug === "greenscreen_chroma_key"
|
|
170
|
+
|| explicitSlug === "media_overlay"
|
|
167
171
|
|| explicitSlug === "primitive_media_lambda"
|
|
168
172
|
|| explicitSlug === "job_runner_lambda"
|
|
169
173
|
|| explicitSlug === "step_functions_standard"
|
|
@@ -393,6 +393,8 @@ export function normalizeHuntSpec(raw) {
|
|
|
393
393
|
spec.clips_per_10min = Math.min(200, perTen);
|
|
394
394
|
if (typeof rec.source_url === "string" && rec.source_url.trim())
|
|
395
395
|
spec.source_url = rec.source_url.trim();
|
|
396
|
+
if (typeof rec.folder_path === "string" && rec.folder_path.trim())
|
|
397
|
+
spec.folder_path = rec.folder_path.trim().slice(0, 500);
|
|
396
398
|
return spec;
|
|
397
399
|
}
|
|
398
400
|
// ── misc ────────────────────────────────────────────────────────────────────
|
|
@@ -115,6 +115,34 @@ export class ClipRecordsService {
|
|
|
115
115
|
ExpressionAttributeValues: { ":fp": folderPath }
|
|
116
116
|
}));
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Rename/move a whole Raws folder: re-point every clip whose folder_path is
|
|
120
|
+
* `fromFolder` OR nested beneath it onto `toFolder` (prefix-preserving), so a
|
|
121
|
+
* folder rename carries its subfolders too. Returns the count moved.
|
|
122
|
+
* Paths are compared normalized (trimmed, no leading/trailing slashes).
|
|
123
|
+
*/
|
|
124
|
+
async moveClipsFolder(owner, fromFolder, toFolder) {
|
|
125
|
+
const norm = (v) => String(v ?? "").split("/").map((s) => s.trim()).filter(Boolean).join("/");
|
|
126
|
+
const from = norm(fromFolder);
|
|
127
|
+
const to = norm(toFolder);
|
|
128
|
+
if (!from || from === to)
|
|
129
|
+
return 0;
|
|
130
|
+
const clips = await this.listClips(owner, { limit: 5000 });
|
|
131
|
+
let moved = 0;
|
|
132
|
+
for (const clip of clips) {
|
|
133
|
+
const fp = norm(clip.folder_path);
|
|
134
|
+
let next = null;
|
|
135
|
+
if (fp === from)
|
|
136
|
+
next = to;
|
|
137
|
+
else if (fp.startsWith(from + "/"))
|
|
138
|
+
next = to + fp.slice(from.length);
|
|
139
|
+
if (next != null) {
|
|
140
|
+
await this.updateClipFolder(owner, clip.clip_id, next);
|
|
141
|
+
moved += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return moved;
|
|
145
|
+
}
|
|
118
146
|
// ── Presets ──────────────────────────────────────────────────────────────
|
|
119
147
|
async listPresets(owner) {
|
|
120
148
|
const res = await dynamodb.send(new QueryCommand({
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// /files/<subfolders…>/<name> → My Files attachments (users/<id>/files/…)
|
|
9
9
|
// /temp/<YYYY-MM-DD>/<name> → Temp files (users/<id>/temporary/…)
|
|
10
10
|
// /raws/<subfolders…>/<name> → Raws / clip library (clips/<owner>/…)
|
|
11
|
+
// /projects/<forkId>/<subpath…> → Composition forks (compositions/forks/<forkId>/…)
|
|
11
12
|
//
|
|
12
13
|
// It's a *virtual* layer: physical S3 keys are untouched. The canonical path is
|
|
13
14
|
// what the UI copies, what the chat agent navigates, and what search annotates.
|
|
@@ -17,18 +18,20 @@
|
|
|
17
18
|
// Pure logic, no runtime deps, so both `src/app.ts` (serve) and
|
|
18
19
|
// `infra/lambda/editor-chat.ts` (cloud) import it, and the frontend explorer
|
|
19
20
|
// client mirrors the same constants.
|
|
20
|
-
export const DIRECTORY_ROOT_KEYS = ["files", "temp", "raws"];
|
|
21
|
+
export const DIRECTORY_ROOT_KEYS = ["files", "temp", "raws", "projects", "approved"];
|
|
21
22
|
export const DIRECTORY_ROOTS = [
|
|
22
23
|
{ key: "files", label: "My Files", description: "Durable personal files — brand assets, characters, saved context. Vector-searchable via notes." },
|
|
23
24
|
{ key: "temp", label: "Temp", description: "Scratch space, auto-organized by date (YYYY-MM-DD) and auto-deleted after 30 days." },
|
|
24
|
-
{ key: "raws", label: "Raws", description: "The reusable clip / source-footage library, organized into folders by source." }
|
|
25
|
+
{ key: "raws", label: "Raws", description: "The reusable clip / source-footage library, organized into folders by source." },
|
|
26
|
+
{ key: "projects", label: "Projects", description: "Your video-editing projects — one folder per fork, holding that composition's working files (composition.html/json, cast, media) and saved versions. Read-only: browse to reference or study another fork." },
|
|
27
|
+
{ key: "approved", label: "Approved", description: "Finished, ready-to-post cuts, organized into folders (defaults to a folder per tracer/campaign)." }
|
|
25
28
|
];
|
|
26
29
|
// The attachments backend historically scopes its folders under the internal
|
|
27
30
|
// key "my_files"; the canonical public root name is "files". These two helpers
|
|
28
31
|
// bridge the canonical name and that backend scope so callers never hardcode it.
|
|
29
32
|
export const ATTACHMENTS_FOLDER_SCOPE = "my_files";
|
|
30
33
|
export function isDirectoryRoot(value) {
|
|
31
|
-
return value === "files" || value === "temp" || value === "raws";
|
|
34
|
+
return value === "files" || value === "temp" || value === "raws" || value === "projects" || value === "approved";
|
|
32
35
|
}
|
|
33
36
|
export function directoryRootLabel(root) {
|
|
34
37
|
const info = DIRECTORY_ROOTS.find((r) => r.key === root);
|
|
@@ -1628,6 +1628,197 @@ function normalizeViralDna(value) {
|
|
|
1628
1628
|
keywords: asStringArray(record.keywords).map((item) => item.toLowerCase())
|
|
1629
1629
|
};
|
|
1630
1630
|
}
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
// Editor-harness pass — technical editing direction (the "how to edit like
|
|
1633
|
+
// this" companion to viral DNA's "why it works"). Isolated vision call in the
|
|
1634
|
+
// decompose burst (never the caption/scene call) so it can't dilute
|
|
1635
|
+
// timeline-layer quality; reuses the SAME sparse frames already sampled + the
|
|
1636
|
+
// ffmpeg cut timings for grounded pacing. Non-fatal: callers wrap in .catch().
|
|
1637
|
+
// ---------------------------------------------------------------------------
|
|
1638
|
+
export const DEFAULT_EDITOR_HARNESS = {
|
|
1639
|
+
format: "other",
|
|
1640
|
+
one_liner: "",
|
|
1641
|
+
aspect_ratio: "",
|
|
1642
|
+
pacing: { cut_rhythm: "medium", avg_scene_seconds: 0, cuts_per_10s: 0, energy_curve: "" },
|
|
1643
|
+
typography: { caption_style: "", placement: "", font_character: "", emphasis: "", text_density: "none" },
|
|
1644
|
+
broll: { reliance: "none", sourcing: "", shot_kinds: [], cadence: "" },
|
|
1645
|
+
transitions: { default: "cut", intro: "", outro: "", usage: "" },
|
|
1646
|
+
audio: { voiceover: "", music: "", sfx: "", captions_from: "" },
|
|
1647
|
+
scenes: [],
|
|
1648
|
+
important_scenes: [],
|
|
1649
|
+
editing_bias: [],
|
|
1650
|
+
do: [],
|
|
1651
|
+
dont: []
|
|
1652
|
+
};
|
|
1653
|
+
const HARNESS_CUT_RHYTHMS = ["fast", "medium", "slow", "varied"];
|
|
1654
|
+
const HARNESS_TEXT_DENSITY = ["heavy", "moderate", "minimal", "none"];
|
|
1655
|
+
const HARNESS_BROLL_RELIANCE = ["heavy", "moderate", "light", "none"];
|
|
1656
|
+
const HARNESS_SCENE_IMPORTANCE = ["critical", "high", "normal"];
|
|
1657
|
+
function buildEditorHarnessPrompt(input) {
|
|
1658
|
+
const frameListing = input.frameTimestamps.length > 0
|
|
1659
|
+
? `\nFRAMES SUPPLIED (in order, each captioned with its capture timestamp):\n${input.frameTimestamps.map((t, i) => ` frame ${i + 1} @ ${t.toFixed(2)}s`).join("\n")}\n`
|
|
1660
|
+
: "";
|
|
1661
|
+
const cutListing = input.sceneCuts.length > 0
|
|
1662
|
+
? `\nDETECTED HARD CUTS (seconds, from the editing timeline — use these to ground pacing/cut_rhythm/cuts_per_10s/avg_scene_seconds): ${input.sceneCuts.map((c) => c.toFixed(2)).join(", ")}\n`
|
|
1663
|
+
: "\n(No hard cuts detected — likely a single continuous take or slow crossfades. Judge pacing from the frames.)\n";
|
|
1664
|
+
const promptHint = input.userPrompt && input.userPrompt.trim().length > 0
|
|
1665
|
+
? `\nUSER GOAL (bias the harness toward recreating THIS intent): ${input.userPrompt.trim()}\n`
|
|
1666
|
+
: "";
|
|
1667
|
+
const roleVocab = SCENE_ROLE_VOCAB.join(" | ");
|
|
1668
|
+
const formatVocab = CONTENT_FORMATS.join(" | ");
|
|
1669
|
+
return `You are Vidfarm's EDITING DIRECTOR. You are studying a short-form video to write an EDITOR HARNESS — a compact, technical brief that lets another AI editor recreate this video's STYLE (not its exact content) in one clean pass on a timeline editor (scenes, captions, audio, transitions, keyframes).
|
|
1670
|
+
|
|
1671
|
+
This is DIFFERENT from "viral DNA" (why the video works / what to preserve). The harness is HOW to edit: pace, typography, b-roll usage, transitions, audio bed, and the role each scene plays. Be concrete and directive — every field should be something an editor can act on, not vibes.
|
|
1672
|
+
|
|
1673
|
+
Video duration: ${input.durationSeconds.toFixed(2)} seconds.
|
|
1674
|
+
${frameListing}${cutListing}${promptHint}
|
|
1675
|
+
Return strictly valid JSON matching this schema:
|
|
1676
|
+
{
|
|
1677
|
+
"format": string, // one of: ${formatVocab}
|
|
1678
|
+
"one_liner": string, // ONE sentence: how to edit to match this style
|
|
1679
|
+
"aspect_ratio": string, // "9:16" | "1:1" | "16:9" (infer from the frames)
|
|
1680
|
+
"pacing": {
|
|
1681
|
+
"cut_rhythm": "fast" | "medium" | "slow" | "varied",
|
|
1682
|
+
"avg_scene_seconds": number, // typical shot length in seconds
|
|
1683
|
+
"cuts_per_10s": number, // ground on the detected cuts + duration
|
|
1684
|
+
"energy_curve": string // e.g. "front-loaded hook then steady"
|
|
1685
|
+
},
|
|
1686
|
+
"typography": {
|
|
1687
|
+
"caption_style": string, // karaoke | word-pop | spotlight | stack-up | neon | bounce | none | free-form label
|
|
1688
|
+
"placement": string, // e.g. "center-lower" | "top" | "full-bleed" | "none"
|
|
1689
|
+
"font_character": string, // e.g. "bold condensed all-caps sans"
|
|
1690
|
+
"emphasis": string, // e.g. "active-word highlight in yellow" | "none"
|
|
1691
|
+
"text_density": "heavy" | "moderate" | "minimal" | "none"
|
|
1692
|
+
},
|
|
1693
|
+
"broll": {
|
|
1694
|
+
"reliance": "heavy" | "moderate" | "light" | "none",
|
|
1695
|
+
"sourcing": string, // generated | stock | screen-recording | talking-head | mixed
|
|
1696
|
+
"shot_kinds": [string, ...], // e.g. b_roll, product_shot, screen_recording, reaction, establishing, text_graphic
|
|
1697
|
+
"cadence": string // e.g. "new visual ~every 2s under VO"
|
|
1698
|
+
},
|
|
1699
|
+
"transitions": {
|
|
1700
|
+
"default": string, // junction at most cuts: cut | crossfade | whip | slide | zoom | dissolve | ...
|
|
1701
|
+
"intro": string, // first-clip entrance
|
|
1702
|
+
"outro": string, // last-clip exit
|
|
1703
|
+
"usage": string // one line on how transitions are used
|
|
1704
|
+
},
|
|
1705
|
+
"audio": {
|
|
1706
|
+
"voiceover": string, // e.g. "first-person narration" | "none"
|
|
1707
|
+
"music": string, // e.g. "upbeat lofi bed, low volume" | "none"
|
|
1708
|
+
"sfx": string, // e.g. "whoosh on cuts" | "none"
|
|
1709
|
+
"captions_from": string // "voiceover" | "on-screen text" | "none"
|
|
1710
|
+
},
|
|
1711
|
+
"scenes": [{
|
|
1712
|
+
"role": string, // ${roleVocab}
|
|
1713
|
+
"timestamp": string, // human range, e.g. "0:00-0:03"
|
|
1714
|
+
"start": number, // numeric seconds where the beat begins
|
|
1715
|
+
"importance": "critical" | "high" | "normal",
|
|
1716
|
+
"must_keep": boolean, // true if swapping/removing this beat kills the style
|
|
1717
|
+
"note": string, // what happens in this beat
|
|
1718
|
+
"edit_bias": string // how to edit this beat while staying on-style
|
|
1719
|
+
}],
|
|
1720
|
+
"important_scenes": [string, ...],// roles or timestamps that carry the virality — protect when editing
|
|
1721
|
+
"editing_bias": [string, ...], // 3-6 top directives an editor MUST follow to stay on-style
|
|
1722
|
+
"do": [string, ...], // 2-5 concrete DOs
|
|
1723
|
+
"dont": [string, ...] // 2-5 concrete DON'Ts
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
RULES:
|
|
1727
|
+
- Ground pacing numbers on the detected cuts + duration when cuts are supplied; otherwise estimate from the frames. Round avg_scene_seconds and cuts_per_10s to at most 2 decimals.
|
|
1728
|
+
- "scenes": 3-8 entries covering the whole runtime in order, each tied to a real moment you see. Reserve importance "critical" for the 1-3 beats that actually carry the style/retention (usually the hook + payoff).
|
|
1729
|
+
- Prefer the caption_style / transition / shot_kind vocabulary above so the editor can map straight to presets; a free-form label is fine when nothing fits.
|
|
1730
|
+
- Keep every string tight and directive. Never invent audio you cannot infer — say "none"/"unknown" instead.
|
|
1731
|
+
- Output ONLY the JSON object.`;
|
|
1732
|
+
}
|
|
1733
|
+
function normalizeEditorHarness(raw) {
|
|
1734
|
+
const rec = asPlainRecord(raw);
|
|
1735
|
+
const oneOf = (value, allowed, fallback) => {
|
|
1736
|
+
const s = normalizeSlug(readAnnotationString(value));
|
|
1737
|
+
return allowed.includes(s) ? s : fallback;
|
|
1738
|
+
};
|
|
1739
|
+
const num = (value) => {
|
|
1740
|
+
const n = Number(value);
|
|
1741
|
+
return Number.isFinite(n) && n >= 0 ? Number(n.toFixed(2)) : 0;
|
|
1742
|
+
};
|
|
1743
|
+
const formatSlug = normalizeSlug(readAnnotationString(rec.format));
|
|
1744
|
+
const format = CONTENT_FORMATS.includes(formatSlug)
|
|
1745
|
+
? formatSlug
|
|
1746
|
+
: "other";
|
|
1747
|
+
const pacing = asPlainRecord(rec.pacing);
|
|
1748
|
+
const typography = asPlainRecord(rec.typography);
|
|
1749
|
+
const broll = asPlainRecord(rec.broll);
|
|
1750
|
+
const transitions = asPlainRecord(rec.transitions);
|
|
1751
|
+
const audio = asPlainRecord(rec.audio);
|
|
1752
|
+
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : [];
|
|
1753
|
+
const scenes = scenesRaw
|
|
1754
|
+
.map((s) => {
|
|
1755
|
+
const sr = asPlainRecord(s);
|
|
1756
|
+
return {
|
|
1757
|
+
role: normalizeSlug(readAnnotationString(sr.role)) || "context",
|
|
1758
|
+
timestamp: readAnnotationString(sr.timestamp),
|
|
1759
|
+
start: num(sr.start),
|
|
1760
|
+
importance: oneOf(sr.importance, HARNESS_SCENE_IMPORTANCE, "normal"),
|
|
1761
|
+
must_keep: sr.must_keep === true,
|
|
1762
|
+
note: readAnnotationString(sr.note),
|
|
1763
|
+
edit_bias: readAnnotationString(sr.edit_bias)
|
|
1764
|
+
};
|
|
1765
|
+
})
|
|
1766
|
+
.filter((s) => s.note.length > 0 || s.edit_bias.length > 0)
|
|
1767
|
+
.slice(0, 12);
|
|
1768
|
+
return {
|
|
1769
|
+
format,
|
|
1770
|
+
one_liner: readAnnotationString(rec.one_liner),
|
|
1771
|
+
aspect_ratio: readAnnotationString(rec.aspect_ratio),
|
|
1772
|
+
pacing: {
|
|
1773
|
+
cut_rhythm: oneOf(pacing.cut_rhythm, HARNESS_CUT_RHYTHMS, "medium"),
|
|
1774
|
+
avg_scene_seconds: num(pacing.avg_scene_seconds),
|
|
1775
|
+
cuts_per_10s: num(pacing.cuts_per_10s),
|
|
1776
|
+
energy_curve: readAnnotationString(pacing.energy_curve)
|
|
1777
|
+
},
|
|
1778
|
+
typography: {
|
|
1779
|
+
caption_style: readAnnotationString(typography.caption_style),
|
|
1780
|
+
placement: readAnnotationString(typography.placement),
|
|
1781
|
+
font_character: readAnnotationString(typography.font_character),
|
|
1782
|
+
emphasis: readAnnotationString(typography.emphasis),
|
|
1783
|
+
text_density: oneOf(typography.text_density, HARNESS_TEXT_DENSITY, "none")
|
|
1784
|
+
},
|
|
1785
|
+
broll: {
|
|
1786
|
+
reliance: oneOf(broll.reliance, HARNESS_BROLL_RELIANCE, "none"),
|
|
1787
|
+
sourcing: readAnnotationString(broll.sourcing),
|
|
1788
|
+
shot_kinds: readAnnotationStringList(broll.shot_kinds, 8),
|
|
1789
|
+
cadence: readAnnotationString(broll.cadence)
|
|
1790
|
+
},
|
|
1791
|
+
transitions: {
|
|
1792
|
+
default: readAnnotationString(transitions.default) || "cut",
|
|
1793
|
+
intro: readAnnotationString(transitions.intro),
|
|
1794
|
+
outro: readAnnotationString(transitions.outro),
|
|
1795
|
+
usage: readAnnotationString(transitions.usage)
|
|
1796
|
+
},
|
|
1797
|
+
audio: {
|
|
1798
|
+
voiceover: readAnnotationString(audio.voiceover),
|
|
1799
|
+
music: readAnnotationString(audio.music),
|
|
1800
|
+
sfx: readAnnotationString(audio.sfx),
|
|
1801
|
+
captions_from: readAnnotationString(audio.captions_from)
|
|
1802
|
+
},
|
|
1803
|
+
scenes,
|
|
1804
|
+
important_scenes: readAnnotationStringList(rec.important_scenes, 8),
|
|
1805
|
+
editing_bias: readAnnotationStringList(rec.editing_bias, 8),
|
|
1806
|
+
do: readAnnotationStringList(rec.do, 6),
|
|
1807
|
+
dont: readAnnotationStringList(rec.dont, 6)
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
async function runEditorHarnessPass(input) {
|
|
1811
|
+
if (input.frames.length === 0)
|
|
1812
|
+
return DEFAULT_EDITOR_HARNESS;
|
|
1813
|
+
const prompt = buildEditorHarnessPrompt({
|
|
1814
|
+
durationSeconds: input.durationSeconds,
|
|
1815
|
+
frameTimestamps: input.frames.map((f) => f.timestamp),
|
|
1816
|
+
sceneCuts: input.sceneCuts,
|
|
1817
|
+
userPrompt: input.userPrompt ?? null
|
|
1818
|
+
});
|
|
1819
|
+
const attempt = await callVisionWithFallback({ prompt, frames: input.frames, keys: input.keys });
|
|
1820
|
+
return normalizeEditorHarness(attempt.result);
|
|
1821
|
+
}
|
|
1631
1822
|
function shortElementId() {
|
|
1632
1823
|
return `element_${randomUUID().slice(0, 8)}`;
|
|
1633
1824
|
}
|
|
@@ -2025,7 +2216,7 @@ export async function smartDecomposeVideo(input) {
|
|
|
2025
2216
|
// non-fatal. The placement call is DELIBERATELY a separate request from the
|
|
2026
2217
|
// caption/scene vision call so it can never dilute timeline-layer quality —
|
|
2027
2218
|
// it just reuses the same already-sampled sparse frames (no extra ffmpeg).
|
|
2028
|
-
const [attempt, ocrFrames, transcript, placementSurfaces] = await Promise.all([
|
|
2219
|
+
const [attempt, ocrFrames, transcript, placementSurfaces, editorHarness] = await Promise.all([
|
|
2029
2220
|
callVisionWithFallback({
|
|
2030
2221
|
prompt,
|
|
2031
2222
|
frames,
|
|
@@ -2049,6 +2240,18 @@ export async function smartDecomposeVideo(input) {
|
|
|
2049
2240
|
}).catch((err) => {
|
|
2050
2241
|
console.error("smart decompose placement pass failed", err instanceof Error ? err.message : err);
|
|
2051
2242
|
return [];
|
|
2243
|
+
}),
|
|
2244
|
+
// Editor harness — technical editing direction. Isolated call in the same
|
|
2245
|
+
// burst (reuses these frames + the detected cuts); non-fatal.
|
|
2246
|
+
runEditorHarnessPass({
|
|
2247
|
+
frames,
|
|
2248
|
+
keys: input.keys,
|
|
2249
|
+
durationSeconds: meta.durationSeconds,
|
|
2250
|
+
sceneCuts,
|
|
2251
|
+
userPrompt: input.userPrompt ?? null
|
|
2252
|
+
}).catch((err) => {
|
|
2253
|
+
console.error("smart decompose editor-harness pass failed", err instanceof Error ? err.message : err);
|
|
2254
|
+
return DEFAULT_EDITOR_HARNESS;
|
|
2052
2255
|
})
|
|
2053
2256
|
]);
|
|
2054
2257
|
devLog("hyperframes.smart_decompose.ocr_completed", {
|
|
@@ -2147,12 +2350,15 @@ export async function smartDecomposeVideo(input) {
|
|
|
2147
2350
|
caption_count: finalCaptions.length,
|
|
2148
2351
|
captions_snapped: snapped.snappedCount,
|
|
2149
2352
|
scenes_snapped: snappedScenes.snappedCount,
|
|
2150
|
-
placement_surface_count: finalPlacementSurfaces.length
|
|
2353
|
+
placement_surface_count: finalPlacementSurfaces.length,
|
|
2354
|
+
editor_harness_format: editorHarness.format,
|
|
2355
|
+
editor_harness_scene_count: editorHarness.scenes.length
|
|
2151
2356
|
});
|
|
2152
2357
|
return {
|
|
2153
2358
|
summary: typeof record.summary === "string" ? record.summary : "",
|
|
2154
2359
|
viralDna,
|
|
2155
2360
|
placementSurfaces: finalPlacementSurfaces,
|
|
2361
|
+
editorHarness,
|
|
2156
2362
|
transcript,
|
|
2157
2363
|
durationSeconds: meta.durationSeconds,
|
|
2158
2364
|
width: meta.width,
|
|
@@ -2228,6 +2434,7 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2228
2434
|
const audioId = shortElementId();
|
|
2229
2435
|
const audioEl = `<audio class="clip" id="${audioId}" data-hf-id="${audioId}" data-hf-slug="source_audio" data-layer-mode="both" data-layer-kind="audio" data-viral-note="Original TikTok audio track." data-start="0" data-duration="${totalDuration}" data-end="${totalDuration}" data-track-index="1" data-label="Original audio" data-media-start="0" data-playback-start="0" data-timeline-role="music" data-volume="1" src="${escapeAttr(sourceUrl)}" preload="metadata"></audio>`;
|
|
2230
2436
|
const viralDnaJson = escapeAttr(JSON.stringify(result.viralDna ?? DEFAULT_VIRAL_DNA));
|
|
2437
|
+
const editorHarnessJson = escapeAttr(JSON.stringify(result.editorHarness ?? DEFAULT_EDITOR_HARNESS));
|
|
2231
2438
|
return `<!doctype html>
|
|
2232
2439
|
<html lang="en">
|
|
2233
2440
|
<head>
|
|
@@ -2241,7 +2448,7 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2241
2448
|
</style>
|
|
2242
2449
|
</head>
|
|
2243
2450
|
<body>
|
|
2244
|
-
<div data-composition-id="${escapeAttr(compositionId)}" data-source-video="${escapeAttr(sourceUrl)}" data-width="${result.width || 720}" data-height="${result.height || 1280}" data-duration="${totalDuration}" data-source-title="${escapeAttr(title)}" data-viral-dna="${viralDnaJson}">
|
|
2451
|
+
<div data-composition-id="${escapeAttr(compositionId)}" data-source-video="${escapeAttr(sourceUrl)}" data-width="${result.width || 720}" data-height="${result.height || 1280}" data-duration="${totalDuration}" data-source-title="${escapeAttr(title)}" data-viral-dna="${viralDnaJson}" data-editor-harness="${editorHarnessJson}">
|
|
2245
2452
|
<video data-vf-playback-source="true" src="${escapeAttr(sourceUrl)}" muted playsinline preload="auto" style="position:absolute;left:0%;top:0%;width:100%;height:100%;z-index:0;object-fit:cover;background:#050604"></video>
|
|
2246
2453
|
${sceneEls}
|
|
2247
2454
|
${audioEl}
|
|
@@ -2250,6 +2457,79 @@ export function buildSmartDecomposedHtml(input) {
|
|
|
2250
2457
|
</body>
|
|
2251
2458
|
</html>`;
|
|
2252
2459
|
}
|
|
2460
|
+
// The public placeholder tile every blank scene starts on — a gray diagonal-
|
|
2461
|
+
// striped 720×1280 image with a dashed text box (uploaded once by
|
|
2462
|
+
// scripts/upload-scene-placeholder.ts). Hardcoded to the prod bucket URL (a
|
|
2463
|
+
// public object that resolves from any stage) so the blank composition renders
|
|
2464
|
+
// identically everywhere. Mirrors SCENE_PLACEHOLDER_IMAGE_URL in the SPA editor.
|
|
2465
|
+
export const SCENE_PLACEHOLDER_IMAGE_URL = "https://vidfarmprodstack-vidfarmbucket335ee12f-0vsvtd5earqy.s3.us-east-1.amazonaws.com/primitives/placeholders/scene-placeholder.png";
|
|
2466
|
+
const BLANK_PROJECT_SCENE_COUNT = 3;
|
|
2467
|
+
const BLANK_PROJECT_SCENE_DURATION = 4;
|
|
2468
|
+
// The canonical, reusable "new blank project" composition. This is the single
|
|
2469
|
+
// composition.html every fresh /editor/original/fork/new opens on, and the base
|
|
2470
|
+
// every blank project forks from (POST /api/v1/compositions seeds a new fork by
|
|
2471
|
+
// copying this). It mirrors blankProjectSemanticLayers() in the SPA editor: a
|
|
2472
|
+
// continuous placeholder image track (3 butt-cut 4s scenes on track 0) plus a
|
|
2473
|
+
// "Scene 1/2/3" caption on track 3, giving the user an editable 12s scaffold
|
|
2474
|
+
// instead of an empty canvas. Like the other builders it emits clips only — the
|
|
2475
|
+
// runtime is injected on serve by rewriteHyperframesDraftCompositionHtml, and z
|
|
2476
|
+
// is normalized to track index on the way out.
|
|
2477
|
+
export function buildBlankCompositionHtml(compositionId) {
|
|
2478
|
+
const totalDuration = BLANK_PROJECT_SCENE_COUNT * BLANK_PROJECT_SCENE_DURATION;
|
|
2479
|
+
const sceneEls = [];
|
|
2480
|
+
const captionEls = [];
|
|
2481
|
+
for (let index = 0; index < BLANK_PROJECT_SCENE_COUNT; index += 1) {
|
|
2482
|
+
const sceneNumber = index + 1;
|
|
2483
|
+
const start = index * BLANK_PROJECT_SCENE_DURATION;
|
|
2484
|
+
const end = start + BLANK_PROJECT_SCENE_DURATION;
|
|
2485
|
+
// Placeholder image scene — full canvas, track 0. Together the three form
|
|
2486
|
+
// one continuous 12s media track the user replaces with real footage.
|
|
2487
|
+
const imageId = `scene-${sceneNumber}-image`;
|
|
2488
|
+
sceneEls.push(`<img class="clip" id="${imageId}" data-hf-id="${imageId}" data-hf-slug="scene_${sceneNumber}" data-layer-mode="both" data-layer-kind="image" data-viral-note="Placeholder scene backdrop — replace with your own media." data-start="${start}" data-duration="${BLANK_PROJECT_SCENE_DURATION}" data-end="${end}" data-track-index="0" data-label="Scene ${sceneNumber}" src="${escapeAttr(SCENE_PLACEHOLDER_IMAGE_URL)}" alt="" style="position:absolute;left:0%;top:0%;width:100%;height:100%;z-index:0;opacity:1;border-radius:0px;overflow:hidden;object-fit:cover;" />`);
|
|
2489
|
+
// "Scene N" label sitting in the tile's blank text box — white on a solid
|
|
2490
|
+
// black highlight so it reads over both the placeholder and swapped-in media.
|
|
2491
|
+
const caption = {
|
|
2492
|
+
slug: `scene_${sceneNumber}_label`,
|
|
2493
|
+
text: `Scene ${sceneNumber}`,
|
|
2494
|
+
start,
|
|
2495
|
+
duration: BLANK_PROJECT_SCENE_DURATION,
|
|
2496
|
+
x: 13.5,
|
|
2497
|
+
y: 44,
|
|
2498
|
+
width: 73,
|
|
2499
|
+
height: 12,
|
|
2500
|
+
font_size: 34,
|
|
2501
|
+
color: "#ffffff",
|
|
2502
|
+
background: "#000000",
|
|
2503
|
+
background_style: "highlight-solid",
|
|
2504
|
+
font_family: "TikTok Sans",
|
|
2505
|
+
font_weight: 700,
|
|
2506
|
+
viral_note: "Placeholder scene label — edit or delete once you add real media."
|
|
2507
|
+
};
|
|
2508
|
+
const captionId = `scene-${sceneNumber}-caption`;
|
|
2509
|
+
const innerStyle = captionInlineTextStyle(caption);
|
|
2510
|
+
captionEls.push(`<div class="clip" id="${captionId}" data-hf-id="${captionId}" data-hf-slug="${escapeAttr(caption.slug)}" data-layer-mode="publish" data-layer-kind="caption" data-viral-note="${escapeAttr(caption.viral_note ?? "")}" data-start="${start}" data-duration="${BLANK_PROJECT_SCENE_DURATION}" data-end="${end}" data-track-index="3" data-label="${escapeAttr(caption.text)}" data-text-background-style="${caption.background_style}" data-text-background-color="${escapeAttr(caption.background)}" data-font-family="${escapeAttr(caption.font_family)}" style="position:absolute;left:${caption.x}%;top:${caption.y}%;width:${caption.width}%;height:${caption.height}%;z-index:3;display:flex;align-items:center;justify-content:center;padding:3px;box-sizing:border-box;text-align:center;color:${caption.color};background:transparent;font-family:'${caption.font_family}', 'TikTok Sans', sans-serif;font-weight:${caption.font_weight};font-size:${caption.font_size}px;line-height:1.2;overflow:visible;"><span data-vf-text-lines="true"><span data-vf-text-inline="true" style="${innerStyle}">${escapeHtml(caption.text)}</span></span></div>`);
|
|
2511
|
+
}
|
|
2512
|
+
// data-vf-origin="raw": a brand-new project, so the editor never auto-prompts
|
|
2513
|
+
// the Decompose modal (there is no source video to decompose).
|
|
2514
|
+
return `<!doctype html>
|
|
2515
|
+
<html lang="en">
|
|
2516
|
+
<head>
|
|
2517
|
+
<meta charset="UTF-8" />
|
|
2518
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2519
|
+
<style>
|
|
2520
|
+
@import url("https://fonts.googleapis.com/css2?family=Abel&family=Montserrat:wght@600;700&family=Source+Code+Pro:wght@700&family=TikTok+Sans:wght@400;600;700;800;900&family=Yesteryear&display=swap");
|
|
2521
|
+
html, body { width:100%; height:100%; margin:0; overflow:hidden; background:#050604; }
|
|
2522
|
+
[data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:#050604; }
|
|
2523
|
+
</style>
|
|
2524
|
+
</head>
|
|
2525
|
+
<body>
|
|
2526
|
+
<div data-composition-id="${escapeAttr(compositionId)}" data-vf-origin="raw" data-width="720" data-height="1280" data-duration="${totalDuration}" data-source-title="Untitled project">
|
|
2527
|
+
${sceneEls.join("\n ")}
|
|
2528
|
+
${captionEls.join("\n ")}
|
|
2529
|
+
</div>
|
|
2530
|
+
</body>
|
|
2531
|
+
</html>`;
|
|
2532
|
+
}
|
|
2253
2533
|
function normalizeSlug(value) {
|
|
2254
2534
|
return String(value ?? "")
|
|
2255
2535
|
.toLowerCase()
|
|
@@ -625,6 +625,12 @@ class ServerlessRecordsServiceImpl {
|
|
|
625
625
|
title: input.title ?? null,
|
|
626
626
|
caption: input.caption ?? "",
|
|
627
627
|
pinnedComment: input.pinnedComment ?? null,
|
|
628
|
+
// Self-file into a tracer-derived Approved subfolder (canonical
|
|
629
|
+
// `/approved/<slug>`), mirroring how raws default into a source folder. An
|
|
630
|
+
// explicit folderPath (incl. "" for the root) always wins.
|
|
631
|
+
folderPath: input.folderPath !== undefined
|
|
632
|
+
? normalizeFolder(input.folderPath ?? "")
|
|
633
|
+
: (input.tracer ? slugFolderSegment(input.tracer) : ""),
|
|
628
634
|
mediaAssets: input.mediaAssets ?? [],
|
|
629
635
|
sharePasswordHash: input.sharePasswordHash ?? null,
|
|
630
636
|
zipStorageKey: input.zipStorageKey ?? null,
|
|
@@ -654,6 +660,23 @@ class ServerlessRecordsServiceImpl {
|
|
|
654
660
|
const updated = await this.putRecord("ready_post", { ...post, status: "deleted", deletedAt: nowIso(), updatedAt: nowIso() });
|
|
655
661
|
return updated;
|
|
656
662
|
}
|
|
663
|
+
// Move one approved post into a folder (canonical /approved/<folderPath>); ""
|
|
664
|
+
// returns it to the root. Mirrors updateClipFolder for raws.
|
|
665
|
+
async setReadyPostFolder(customerId, postId, folderPath) {
|
|
666
|
+
const post = await this.getReadyPost(postId);
|
|
667
|
+
if (!post || post.customerId !== customerId)
|
|
668
|
+
return null;
|
|
669
|
+
return this.putRecord("ready_post", {
|
|
670
|
+
...post,
|
|
671
|
+
folderPath: normalizeFolder(folderPath),
|
|
672
|
+
updatedAt: nowIso()
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
// Rename an approved folder by re-pointing every post inside it (the folder-rename
|
|
676
|
+
// primitive for the /approved directory root).
|
|
677
|
+
async moveReadyPostFolder(customerId, fromFolder, toFolder) {
|
|
678
|
+
await this.moveCollectionFolder("ready_post", customerId, fromFolder, toFolder);
|
|
679
|
+
}
|
|
657
680
|
async setReadyPostMedia(input) {
|
|
658
681
|
const post = await this.getReadyPost(input.postId);
|
|
659
682
|
if (!post || post.customerId !== input.customerId)
|
|
@@ -861,6 +884,8 @@ class ServerlessRecordsServiceImpl {
|
|
|
861
884
|
storageKey: input.storageKey,
|
|
862
885
|
folderPath: normalizeFolder(input.folderPath),
|
|
863
886
|
publicUrl: input.publicUrl ?? null,
|
|
887
|
+
thumbnailStorageKey: input.thumbnailStorageKey ?? null,
|
|
888
|
+
thumbnailUrl: input.thumbnailUrl ?? null,
|
|
864
889
|
notes: input.notes ?? null,
|
|
865
890
|
notesEmbedding: input.notesEmbedding ?? null,
|
|
866
891
|
notesEmbeddingModel: input.notesEmbeddingModel ?? null,
|
|
@@ -870,6 +895,12 @@ class ServerlessRecordsServiceImpl {
|
|
|
870
895
|
async updateUserAttachmentNotes(customerId, attachmentId, patch) {
|
|
871
896
|
return this.mergeByIdForCustomer("user_attachment", customerId, attachmentId, patch);
|
|
872
897
|
}
|
|
898
|
+
// Rename a My Files entry. Only the record's display name changes — the S3
|
|
899
|
+
// storageKey (and therefore the durable view URL) is untouched, so this is a
|
|
900
|
+
// cheap metadata-only rename that keeps every existing link working.
|
|
901
|
+
async renameUserAttachment(customerId, attachmentId, fileName) {
|
|
902
|
+
return this.mergeByIdForCustomer("user_attachment", customerId, attachmentId, { fileName });
|
|
903
|
+
}
|
|
873
904
|
async listUserAttachments(customerId) {
|
|
874
905
|
return this.listByCustomer("user_attachment", customerId);
|
|
875
906
|
}
|
|
@@ -913,6 +944,10 @@ class ServerlessRecordsServiceImpl {
|
|
|
913
944
|
if (existing)
|
|
914
945
|
await this.deleteById("user_temporary_file", fileId);
|
|
915
946
|
}
|
|
947
|
+
// Rename a temp file (display name only; storageKey/view URL untouched).
|
|
948
|
+
async renameUserTemporaryFile(customerId, fileId, fileName) {
|
|
949
|
+
return this.mergeByIdForCustomer("user_temporary_file", customerId, fileId, { fileName });
|
|
950
|
+
}
|
|
916
951
|
async deleteExpiredUserTemporaryFiles() {
|
|
917
952
|
const now = nowIso();
|
|
918
953
|
const files = (await this.scanCollection("user_temporary_file"))
|
|
@@ -1445,6 +1480,14 @@ function normalizeFolder(value) {
|
|
|
1445
1480
|
return "";
|
|
1446
1481
|
return value.trim().replace(/^\/+|\/+$/g, "");
|
|
1447
1482
|
}
|
|
1483
|
+
// Slugify a single folder-name segment (e.g. a tracer → an /approved subfolder).
|
|
1484
|
+
function slugFolderSegment(value) {
|
|
1485
|
+
return String(value ?? "")
|
|
1486
|
+
.toLowerCase()
|
|
1487
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1488
|
+
.replace(/^-+|-+$/g, "")
|
|
1489
|
+
.slice(0, 60);
|
|
1490
|
+
}
|
|
1448
1491
|
function isExpiredUserTemporaryFileRecord(record) {
|
|
1449
1492
|
return typeof record.id === "string"
|
|
1450
1493
|
&& record.id.length > 0
|