@mevdragon/vidfarm-devcli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +43 -4
  4. package/SKILL.director.md +190 -18
  5. package/SKILL.platform.md +8 -4
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/demo/dist/favicon.ico +0 -0
  9. package/dist/src/app.js +3031 -300
  10. package/dist/src/cli.js +1549 -69
  11. package/dist/src/config.js +7 -0
  12. package/dist/src/devcli/clip-store.js +29 -2
  13. package/dist/src/devcli/clips.js +55 -9
  14. package/dist/src/devcli/composition-edit.js +658 -8
  15. package/dist/src/devcli/local-backend.js +123 -0
  16. package/dist/src/devcli/migrate-local.js +140 -0
  17. package/dist/src/devcli/sync.js +311 -0
  18. package/dist/src/devcli/timeline-edit.js +490 -0
  19. package/dist/src/editor-chat.js +56 -17
  20. package/dist/src/frontend/discover-client.js +130 -0
  21. package/dist/src/frontend/discover-store.js +23 -0
  22. package/dist/src/frontend/file-directory.js +995 -0
  23. package/dist/src/frontend/homepage-view.js +3 -3
  24. package/dist/src/frontend/template-editor-chat.js +28 -22
  25. package/dist/src/landing-page.js +24 -7
  26. package/dist/src/page-shell.js +26 -2
  27. package/dist/src/primitive-registry.js +409 -4
  28. package/dist/src/reskin/agency-page.js +1 -1
  29. package/dist/src/reskin/calendar-page.js +2 -1
  30. package/dist/src/reskin/chat-page.js +420 -85
  31. package/dist/src/reskin/discover-page.js +731 -39
  32. package/dist/src/reskin/document.js +1311 -387
  33. package/dist/src/reskin/help-page.js +1 -0
  34. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  35. package/dist/src/reskin/inpaint-page.js +2459 -446
  36. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  37. package/dist/src/reskin/library-page.js +1168 -228
  38. package/dist/src/reskin/login-page.js +6 -6
  39. package/dist/src/reskin/pricing-page.js +2 -0
  40. package/dist/src/reskin/settings-page.js +55 -10
  41. package/dist/src/reskin/theme.js +365 -20
  42. package/dist/src/services/billing.js +4 -0
  43. package/dist/src/services/clip-curation/gemini.js +5 -0
  44. package/dist/src/services/clip-curation/hunt.js +81 -1
  45. package/dist/src/services/clip-curation/index.js +2 -1
  46. package/dist/src/services/clip-curation/local-agent.js +4 -3
  47. package/dist/src/services/clip-curation/media-select.js +85 -0
  48. package/dist/src/services/clip-curation/query.js +5 -1
  49. package/dist/src/services/clip-curation/refine.js +50 -20
  50. package/dist/src/services/clip-curation/scan.js +10 -3
  51. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  52. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  53. package/dist/src/services/clip-records.js +42 -1
  54. package/dist/src/services/clip-search.js +43 -13
  55. package/dist/src/services/file-directory.js +117 -0
  56. package/dist/src/services/hyperframes.js +283 -3
  57. package/dist/src/services/serverless-records.js +43 -0
  58. package/dist/src/services/storage.js +47 -2
  59. package/dist/src/services/upstream.js +5 -5
  60. package/dist/src/template-editor-shell.js +16 -2
  61. package/package.json +1 -1
  62. package/public/assets/discover-client-app.js +1 -0
  63. package/public/assets/file-directory-app.js +2 -0
  64. package/public/assets/homepage-client-app.js +12 -12
  65. package/public/assets/page-runtime-client-app.js +24 -24
  66. package/public/assets/placeholders/scene-placeholder.png +0 -0
  67. package/src/assets/favicon.ico +0 -0
  68. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,117 @@
1
+ // Shared canonical file-directory path model.
2
+ //
3
+ // The user's files live in three *separate* backends — My Files attachments,
4
+ // Temp files, and the Raws (clip) library — each with its own DynamoDB records
5
+ // and S3 prefix. This module is the ONE place that defines the unified virtual
6
+ // directory that presents them as three navigable roots:
7
+ //
8
+ // /files/<subfolders…>/<name> → My Files attachments (users/<id>/files/…)
9
+ // /temp/<YYYY-MM-DD>/<name> → Temp files (users/<id>/temporary/…)
10
+ // /raws/<subfolders…>/<name> → Raws / clip library (clips/<owner>/…)
11
+ // /projects/<forkId>/<subpath…> → Composition forks (compositions/forks/<forkId>/…)
12
+ //
13
+ // It's a *virtual* layer: physical S3 keys are untouched. The canonical path is
14
+ // what the UI copies, what the chat agent navigates, and what search annotates.
15
+ // A file's real handle is still its backend id (attachment/temp/clip id) — the
16
+ // path is the human-readable navigation + display layer on top.
17
+ //
18
+ // Pure logic, no runtime deps, so both `src/app.ts` (serve) and
19
+ // `infra/lambda/editor-chat.ts` (cloud) import it, and the frontend explorer
20
+ // client mirrors the same constants.
21
+ export const DIRECTORY_ROOT_KEYS = ["files", "temp", "raws", "projects", "approved"];
22
+ export const DIRECTORY_ROOTS = [
23
+ { key: "files", label: "My Files", description: "Durable personal files — brand assets, characters, saved context. Vector-searchable via notes." },
24
+ { key: "temp", label: "Temp", description: "Scratch space, auto-organized by date (YYYY-MM-DD) and auto-deleted after 30 days." },
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)." }
28
+ ];
29
+ // The attachments backend historically scopes its folders under the internal
30
+ // key "my_files"; the canonical public root name is "files". These two helpers
31
+ // bridge the canonical name and that backend scope so callers never hardcode it.
32
+ export const ATTACHMENTS_FOLDER_SCOPE = "my_files";
33
+ export function isDirectoryRoot(value) {
34
+ return value === "files" || value === "temp" || value === "raws" || value === "projects" || value === "approved";
35
+ }
36
+ export function directoryRootLabel(root) {
37
+ const info = DIRECTORY_ROOTS.find((r) => r.key === root);
38
+ return info ? info.label : root;
39
+ }
40
+ /**
41
+ * Normalize a relative folder path: trim each segment, drop empties and any
42
+ * leading/trailing slashes, collapse repeats. `"/a//b/ "` → `"a/b"`.
43
+ */
44
+ export function normalizeFolderPath(value) {
45
+ return String(value ?? "")
46
+ .split("/")
47
+ .map((s) => s.trim())
48
+ .filter(Boolean)
49
+ .join("/");
50
+ }
51
+ /**
52
+ * Parse a canonical directory path into its root + relative folder path.
53
+ *
54
+ * A path is always interpreted as a FOLDER location (what to list / scope to):
55
+ * every segment after the root is treated as folder path. Files are addressed
56
+ * by backend id, not resolved from the trailing segment, so there's no
57
+ * file-vs-folder ambiguity to guess at here.
58
+ *
59
+ * "/raws/product-demos" → { root:"raws", folderPath:"product-demos" }
60
+ * "/" or "" → { root:null, folderPath:"" } (virtual root)
61
+ */
62
+ export function parseDirectoryPath(input) {
63
+ const segments = String(input ?? "")
64
+ .split("/")
65
+ .map((s) => s.trim())
66
+ .filter(Boolean);
67
+ if (segments.length === 0)
68
+ return { root: null, folderPath: "", segments: [] };
69
+ const [head, ...rest] = segments;
70
+ if (!isDirectoryRoot(head))
71
+ return { root: null, folderPath: "", segments };
72
+ return { root: head, folderPath: rest.join("/"), segments: rest };
73
+ }
74
+ /**
75
+ * Build a canonical directory path from parts. `name` omitted → a folder path.
76
+ *
77
+ * buildDirectoryPath("files", "logos", "logo.png") → "/files/logos/logo.png"
78
+ * buildDirectoryPath("raws", "demos") → "/raws/demos"
79
+ * buildDirectoryPath("temp") → "/temp"
80
+ */
81
+ export function buildDirectoryPath(root, folderPath, name) {
82
+ const parts = [root, normalizeFolderPath(folderPath)];
83
+ if (name)
84
+ parts.push(String(name).trim());
85
+ return "/" + parts.filter(Boolean).join("/");
86
+ }
87
+ /** Build a folder DirectoryItem for a child folder name under a root/parent. */
88
+ export function folderItem(root, parentFolder, childName) {
89
+ const folderPath = normalizeFolderPath(parentFolder ? `${parentFolder}/${childName}` : childName);
90
+ return {
91
+ path: buildDirectoryPath(root, folderPath),
92
+ root,
93
+ folderPath,
94
+ name: childName,
95
+ kind: "folder"
96
+ };
97
+ }
98
+ /**
99
+ * Given a flat list of items' folderPaths (+ any explicit empty-folder records)
100
+ * and the current folder, return the immediate CHILD folder names. Mirrors the
101
+ * client explorer's `immediateFolders`, kept here so server and client agree.
102
+ */
103
+ export function immediateChildFolders(folderPaths, currentFolder) {
104
+ const cur = normalizeFolderPath(currentFolder);
105
+ const prefix = cur ? cur + "/" : "";
106
+ const names = new Set();
107
+ for (const raw of folderPaths) {
108
+ const n = normalizeFolderPath(raw);
109
+ if (!n || (cur && n === cur) || n.indexOf(prefix) !== 0)
110
+ continue;
111
+ const child = n.slice(prefix.length).split("/")[0];
112
+ if (child)
113
+ names.add(child);
114
+ }
115
+ return Array.from(names).sort();
116
+ }
117
+ //# sourceMappingURL=file-directory.js.map
@@ -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
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, createReadStream, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, PutObjectTaggingCommand, S3Client } from "@aws-sdk/client-s3";
4
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -13,6 +13,19 @@ function isS3NotFoundError(error) {
13
13
  const httpStatusCode = typeof metadata?.httpStatusCode === "number" ? metadata.httpStatusCode : undefined;
14
14
  return name === "NoSuchKey" || code === "NoSuchKey" || name === "NotFound" || httpStatusCode === 404;
15
15
  }
16
+ // Media objects live at stable, content-addressed-ish keys and are the
17
+ // heavy bytes browsers re-fetch on every /discover visit today (S3 objects
18
+ // currently ship no Cache-Control). Give media a 1-day fresh window with a
19
+ // week-long stale-while-revalidate tail so it caches hard but still self-heals
20
+ // if a key is ever overwritten in place. NON-media (json records, composition
21
+ // html, text) stays uncached — those are mutable and must never go stale.
22
+ function cacheControlForContentType(contentType) {
23
+ const ct = (contentType || "").toLowerCase();
24
+ if (ct.startsWith("video/") || ct.startsWith("image/") || ct.startsWith("audio/")) {
25
+ return "public, max-age=86400, stale-while-revalidate=604800";
26
+ }
27
+ return undefined;
28
+ }
16
29
  export class StorageService {
17
30
  localRoot = path.join(config.VIDFARM_DATA_DIR, "storage");
18
31
  s3 = config.STORAGE_DRIVER === "s3"
@@ -98,7 +111,8 @@ export class StorageService {
98
111
  Bucket: config.AWS_S3_BUCKET,
99
112
  Key: key,
100
113
  Body: value,
101
- ContentType: contentType
114
+ ContentType: contentType,
115
+ CacheControl: cacheControlForContentType(contentType)
102
116
  }));
103
117
  return { key, url: this.getPublicUrl(key) };
104
118
  }
@@ -113,6 +127,30 @@ export class StorageService {
113
127
  * no client header contract), which the bucket's tag-scoped lifecycle rule
114
128
  * expires after 30 days. No-op on the local filesystem driver.
115
129
  */
130
+ /**
131
+ * Store a file that already lives on disk WITHOUT reading it fully into memory.
132
+ * Streams the bytes to S3 (ContentLength from the file's size, so no multipart
133
+ * dependency) or copies on the local driver. Use for large media (whole-video
134
+ * raw imports, render outputs) where `putBuffer(readFileSync(...))` would pin
135
+ * the entire file in a Buffer and risk OOM on memory-bounded Lambdas.
136
+ */
137
+ async putFile(key, localPath, contentType = "application/octet-stream") {
138
+ if (this.s3 && config.AWS_S3_BUCKET) {
139
+ await this.s3.send(new PutObjectCommand({
140
+ Bucket: config.AWS_S3_BUCKET,
141
+ Key: key,
142
+ Body: createReadStream(localPath),
143
+ ContentLength: statSync(localPath).size,
144
+ ContentType: contentType,
145
+ CacheControl: cacheControlForContentType(contentType)
146
+ }));
147
+ return { key, url: this.getPublicUrl(key) };
148
+ }
149
+ const filePath = this.resolveLocalPath(key);
150
+ mkdirSync(path.dirname(filePath), { recursive: true });
151
+ copyFileSync(localPath, filePath);
152
+ return { key, url: this.getPublicUrl(key) };
153
+ }
116
154
  async putObjectTags(key, tags) {
117
155
  if (!this.s3 || !config.AWS_S3_BUCKET)
118
156
  return;
@@ -331,6 +369,13 @@ export function joinStorageKey(...parts) {
331
369
  .filter(Boolean)
332
370
  .join("/");
333
371
  }
372
+ // Shared process-wide StorageService instance. StorageService binds its driver
373
+ // (s3 vs local fs) + data dir from `config` at construction, so importing this
374
+ // singleton must happen AFTER any env that flips RECORDS_DRIVER/STORAGE_DRIVER/
375
+ // VIDFARM_DATA_DIR has been set (the in-process devcli local path arranges that
376
+ // via withLocalBackend before it imports any service). app.ts, the extracted
377
+ // directory/clip-scan services, and the devcli all share this one instance.
378
+ export const storage = new StorageService();
334
379
  function normalizeStorageKey(key) {
335
380
  const normalized = key.replace(/^\/+/, "");
336
381
  if (!normalized ||
@@ -117,9 +117,9 @@ function seedAuthHeaders(shareToken) {
117
117
  headers["vidfarm-share-token"] = shareToken;
118
118
  return headers;
119
119
  }
120
- // /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
120
+ // /editor/<template_id> issues a 302 → /editor/<template_id>/fork/<id>
121
121
  // whenever the caller (or the template's default fork) has one. Follow the
122
- // redirect once by hand so we can read the fork id off the query string.
122
+ // redirect once by hand so we can read the fork id off the path.
123
123
  async function resolveUpstreamForkId(input) {
124
124
  try {
125
125
  const res = await fetch(`${input.host}/editor/${encodeURIComponent(input.templateId)}`, {
@@ -132,9 +132,9 @@ async function resolveUpstreamForkId(input) {
132
132
  const location = res.headers.get("location");
133
133
  if (location) {
134
134
  const target = new URL(location, input.host);
135
- const fork = target.searchParams.get("fork");
136
- if (fork)
137
- return fork;
135
+ const match = target.pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/);
136
+ if (match)
137
+ return decodeURIComponent(match[1]);
138
138
  }
139
139
  }
140
140
  }