@mevdragon/vidfarm-devcli 0.12.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.
@@ -0,0 +1,205 @@
1
+ // devcli `vidfarm transitions …` — first-class scene transitions for a
2
+ // composition.html on disk. Pure local DOM edit (linkedom), no cloud, no
3
+ // wallet; a running `vidfarm serve` live-morphs the change into open editor
4
+ // tabs and `publish` pushes it like any browser edit. Same semantics as the
5
+ // editor's timeline chips and the AI's set_transitions action: junction
6
+ // transitions live as data-transition on the INCOMING clip (the stored
7
+ // timeline stays butt-cut — the runtime/publish normalizer grants the
8
+ // overlap), exit transitions as data-transition-out on the departing clip.
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { existsSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { parseArgs } from "node:util";
13
+ import { applyTransitionsAcross, clearAllTransitions, readTransitions, setLayerTransitions } from "./composition-edit.js";
14
+ import { TRANSITION_OUT_PRESETS, TRANSITION_PRESETS } from "../hyperframes/composition.js";
15
+ export const TRANSITIONS_HELP = `vidfarm transitions — scene transitions between clips (local file write)
16
+
17
+ transitions apply <dir|composition.html> Apply across the whole timeline in one pass
18
+ --preset <p> Junction preset applied at EVERY cut (every scene clip
19
+ except each track's first). "none" clears junctions.
20
+ --duration <sec> Transition length 0.1-2.5 (default 0.5)
21
+ --intro <p> Entrance for the very FIRST clip (e.g. fade-black opens from black)
22
+ --outro <p> Exit for the very LAST clip (e.g. fade-black ends on black)
23
+ --outro-duration <sec> Outro length (defaults to --duration)
24
+ --json
25
+
26
+ transitions set <dir|composition.html> --layer <key> Configure one clip
27
+ --in <p> Entrance preset ("none" clears)
28
+ --in-duration <sec>
29
+ --out <p> Exit preset ("none" clears)
30
+ --out-duration <sec>
31
+ --json
32
+
33
+ transitions list <dir|composition.html> [--json] Show transitions on every scene clip
34
+ transitions clear <dir|composition.html> Remove all transitions
35
+
36
+ Entrance presets: ${TRANSITION_PRESETS.join(" | ")}
37
+ Exit presets: ${TRANSITION_OUT_PRESETS.join(" | ")}
38
+
39
+ The incoming clip owns the junction transition; the previous butt-cut clip is held on
40
+ screen beneath it automatically in preview, serve live-morph, local render, and cloud
41
+ render — never retime clips to "make room". Exit transitions play over the clip's last
42
+ seconds via an inline delay, re-derived automatically after retimes.
43
+ `;
44
+ export async function runTransitionsCommand(argv) {
45
+ const sub = argv[0];
46
+ const rest = argv.slice(1);
47
+ switch (sub) {
48
+ case "apply":
49
+ return runApply(rest);
50
+ case "set":
51
+ return runSet(rest);
52
+ case "list":
53
+ return runList(rest);
54
+ case "clear":
55
+ return runClear(rest);
56
+ case undefined:
57
+ case "help":
58
+ case "--help":
59
+ case "-h":
60
+ console.log(TRANSITIONS_HELP);
61
+ return;
62
+ default:
63
+ console.error(`Unknown transitions subcommand: ${sub}\n`);
64
+ console.log(TRANSITIONS_HELP);
65
+ process.exitCode = 1;
66
+ }
67
+ }
68
+ // Same dir-or-file resolution as `vidfarm place` / `vidfarm captions`.
69
+ function resolveCompositionHtml(target) {
70
+ if (!target)
71
+ throw new Error("Provide the composition dir or composition.html path.");
72
+ const abs = path.resolve(process.cwd(), target);
73
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
74
+ const inside = path.join(abs, "composition.html");
75
+ if (!existsSync(inside))
76
+ throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
77
+ return inside;
78
+ }
79
+ if (!existsSync(abs))
80
+ throw new Error(`No such composition file or dir: ${abs}.`);
81
+ return abs;
82
+ }
83
+ function num(values, key) {
84
+ const raw = values[key];
85
+ if (typeof raw !== "string" || !raw.trim())
86
+ return undefined;
87
+ const parsed = Number(raw);
88
+ if (!Number.isFinite(parsed))
89
+ throw new Error(`--${key} must be a number.`);
90
+ return parsed;
91
+ }
92
+ function runApply(argv) {
93
+ const { values, positionals } = parseArgs({
94
+ args: argv,
95
+ allowPositionals: true,
96
+ options: {
97
+ preset: { type: "string" },
98
+ duration: { type: "string" },
99
+ intro: { type: "string" },
100
+ outro: { type: "string" },
101
+ "outro-duration": { type: "string" },
102
+ json: { type: "boolean" }
103
+ }
104
+ });
105
+ const file = resolveCompositionHtml(positionals[0]);
106
+ const html = readFileSync(file, "utf8");
107
+ const result = applyTransitionsAcross(html, {
108
+ junction: typeof values.preset === "string" ? values.preset : undefined,
109
+ duration: num(values, "duration"),
110
+ intro: typeof values.intro === "string" ? values.intro : undefined,
111
+ outro: typeof values.outro === "string" ? values.outro : undefined,
112
+ outroDuration: num(values, "outro-duration")
113
+ });
114
+ writeFileSync(file, result.html, "utf8");
115
+ if (values.json) {
116
+ console.log(JSON.stringify({ file, junctions: result.junctions, intro: result.intro, outro: result.outro }, null, 2));
117
+ return;
118
+ }
119
+ const parts = [];
120
+ if (typeof values.preset === "string") {
121
+ parts.push(values.preset === "none" ? `cleared junction transitions on ${result.junctions} clips` : `${values.preset} at ${result.junctions} junctions`);
122
+ }
123
+ if (typeof values.intro === "string")
124
+ parts.push(values.intro === "none" ? "intro cleared" : `intro ${values.intro}`);
125
+ if (typeof values.outro === "string")
126
+ parts.push(values.outro === "none" ? "outro cleared" : `outro ${values.outro}`);
127
+ console.log(`Set scene transitions: ${parts.join(", ")}.`);
128
+ console.log(`Wrote ${file}`);
129
+ }
130
+ function runSet(argv) {
131
+ const { values, positionals } = parseArgs({
132
+ args: argv,
133
+ allowPositionals: true,
134
+ options: {
135
+ layer: { type: "string" },
136
+ in: { type: "string" },
137
+ "in-duration": { type: "string" },
138
+ out: { type: "string" },
139
+ "out-duration": { type: "string" },
140
+ json: { type: "boolean" }
141
+ }
142
+ });
143
+ const file = resolveCompositionHtml(positionals[0]);
144
+ if (typeof values.layer !== "string" || !values.layer.trim()) {
145
+ throw new Error("transitions set requires --layer <layerKey|slug>.");
146
+ }
147
+ const html = readFileSync(file, "utf8");
148
+ const result = setLayerTransitions(html, values.layer, {
149
+ transition: typeof values.in === "string" ? values.in : undefined,
150
+ transitionDuration: num(values, "in-duration"),
151
+ transitionOut: typeof values.out === "string" ? values.out : undefined,
152
+ transitionOutDuration: num(values, "out-duration")
153
+ });
154
+ writeFileSync(file, result.html, "utf8");
155
+ if (values.json) {
156
+ console.log(JSON.stringify({ file, layerKey: result.layerKey, in: values.in ?? null, out: values.out ?? null }, null, 2));
157
+ return;
158
+ }
159
+ const parts = [];
160
+ if (typeof values.in === "string")
161
+ parts.push(`in=${values.in}`);
162
+ if (typeof values.out === "string")
163
+ parts.push(`out=${values.out}`);
164
+ console.log(`Updated transitions on ${result.layerKey} (${parts.join(" ")}).`);
165
+ console.log(`Wrote ${file}`);
166
+ }
167
+ function runList(argv) {
168
+ const { values, positionals } = parseArgs({
169
+ args: argv,
170
+ allowPositionals: true,
171
+ options: { json: { type: "boolean" } }
172
+ });
173
+ const file = resolveCompositionHtml(positionals[0]);
174
+ const entries = readTransitions(readFileSync(file, "utf8"));
175
+ if (values.json) {
176
+ console.log(JSON.stringify(entries, null, 2));
177
+ return;
178
+ }
179
+ if (!entries.length) {
180
+ console.log("No scene clips found.");
181
+ return;
182
+ }
183
+ for (const entry of entries) {
184
+ const inLabel = entry.transition ? `${entry.transition} (${entry.transition_duration ?? 0.5}s)` : "-";
185
+ const outLabel = entry.transition_out ? `${entry.transition_out} (${entry.transition_out_duration ?? 0.5}s)` : "-";
186
+ console.log(`${entry.start.toFixed(2).padStart(8)}s L${entry.track} ${entry.layerKey.padEnd(28)} in: ${inLabel.padEnd(22)} out: ${outLabel} ${entry.label ?? ""}`);
187
+ }
188
+ }
189
+ function runClear(argv) {
190
+ const { values, positionals } = parseArgs({
191
+ args: argv,
192
+ allowPositionals: true,
193
+ options: { json: { type: "boolean" } }
194
+ });
195
+ const file = resolveCompositionHtml(positionals[0]);
196
+ const result = clearAllTransitions(readFileSync(file, "utf8"));
197
+ writeFileSync(file, result.html, "utf8");
198
+ if (values.json) {
199
+ console.log(JSON.stringify({ file, cleared: result.cleared }, null, 2));
200
+ return;
201
+ }
202
+ console.log(`Cleared transitions on ${result.cleared} clips.`);
203
+ console.log(`Wrote ${file}`);
204
+ }
205
+ //# sourceMappingURL=transitions.js.map
@@ -64,7 +64,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
64
64
  "If the user asks for each output to have its own chat thread, call frontend_action with action=create_thread for each tracer before or alongside the corresponding POST request.",
65
65
  "If multiple tracers are available, mention which tracer you are using when it matters.",
66
66
  "If the user asks to add, remove, switch tracers, or create a separate chat thread for a tracer, use the frontend_action tool so the page state updates directly.",
67
- "If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, or split layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, and set_captions.",
67
+ "If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, or split layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, set_captions, and set_transitions.",
68
68
  "If the user asks to export, render, publish, or produce the final MP4 for this composition (phrases like 'export it', 'render this', 'kick off the export', 'make the mp4'), call editor_action with action_type=export_composition and a brief explanation. This is the same action as the editor's Export button — it queues the composition on production AWS Lambda. Do NOT try to hit any /api/v1/compositions/:id/export route via http_request for this; use the editor_action tool. After calling export_composition, tell the user the export has started and that the finished MP4 URL will appear once the render succeeds; do not fabricate a URL or claim success until a later turn shows last_export_url populated in editor_context.",
69
69
  "The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
70
70
  "Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
@@ -79,8 +79,8 @@ export function buildTemplateEditorChatSystemPrompt(input) {
79
79
  "When placing generated character media on the timeline, prefer generate_layer (it applies the placement rules for you): to drop the character into a blank moment use intent='fill_gap' with start/duration on a timeline_gaps span; to swap them into an existing scene use intent='replace_layer' with replace_layer_key (its timing and full-canvas geometry are copied automatically). Always set aspect_ratio to the canvas (e.g. 9:16 for vertical) so the clip fills the frame. Only when you already have a finished media URL should you fall back to add_layer with explicit collision-free start/duration and full-canvas geometry (x=0, y=0, width=100, height=100) for a timeline-proxy/full-canvas replacement.",
80
80
  "Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
81
81
  "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
82
- "SCENE TRANSITIONS: every visual clip supports a first-class transition at its start the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window, in both the preview and the final render. Apply it with editor_action set_layer_media and the transition field ON THE INCOMING CLIP the clip AFTER the cut, never the one before it (presets: crossfade, fade-black, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-open; 'none' removes it), optionally transition_duration (0.1-2.5s, default 0.5; ~0.3 snappy, ~1.0 slow cinematic). When the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, apply a transition to EVERY scene clip except the first in one pass (one set_layer_media call per clip). Match the preset to the template's energy: crossfade/fade-black read calm and cinematic, slide/wipe read energetic (vary directions across cuts), zoom-in/zoom-out/circle-open read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition directly on add_layer or generate_layer so a newly placed scene arrives with its entrance. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition.",
83
- "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar: FIRST call video_context to get the transcript's timestamped segments, then call editor_action action_type=set_captions with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions.",
82
+ "SCENE TRANSITIONS: every visual clip supports first-class transitions — an ENTRANCE at its start (transition: the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window) and an EXIT at its end (transition_out: the clip animates AWAY over its last transition_out_duration seconds), in both the preview and the final render. Entrance presets: crossfade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, whip-left/right, wipe-left/right/up/down, zoom-in, zoom-out, circle-open. Exit presets: fade, fade-black, fade-white, flash, smoke, blur, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-close. 'none' removes either. PREFER the bulk verb: when the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, ONE editor_action action_type=set_transitions call does the whole timeline — transition = the junction preset applied at every cut (every scene clip except each track's first), optional transition_duration, optional transition_intro (first clip's entrance, e.g. fade-black to open from black), optional transition_outro (last clip's exit, e.g. fade-black to end on black) with transition_out_duration. For a SINGLE cut, set transition with set_layer_media ON THE INCOMING CLIP (the clip AFTER the cut, never the one before it); for a single clip's exit (before a gap, or a deliberate dip-to-black) set transition_out on the OUTGOING clip. Match the preset to the template's energy: crossfade/fade-black/fade-white/blur read calm and cinematic, smoke reads dreamy, slide/wipe read energetic (vary directions across cuts), whip/flash read fast-paced social, zoom/circle read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition/transition_out directly on add_layer or generate_layer so a newly placed scene arrives with its motion. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition. Transitions also appear as clickable chips at every cut on the editor timeline, so tell users they can fine-tune from there too.",
83
+ "ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
84
84
  "Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
85
85
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
86
86
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -90,8 +90,9 @@ export function buildTemplateEditorChatSystemPrompt(input) {
90
90
  "CONTEXT AVAILABLE FOR USE — this composition can expose TWO video sources plus a text/scene context: (1) the ORIGINAL video — the raw uploaded source, with any baked-in subtitles/captions; (2) the DECOMPOSED video — a processed, caption-free copy Vidfarm produces asynchronously (subtitles removed) that the composition timeline renders from; and (3) the decomposed video context (transcript + per-scene visual descriptions + viral DNA) available via the video_context tool. The original and the decomposed video are NOT the same URL, and you should not carry both URLs in default context. When the user asks to reuse, re-render, or feed the composition's video into a primitive route (image extract, ai video edit, media/dedupe, video trim, video probe, etc.), call GET /api/v1/compositions/:forkId/remove-video-captions FIRST to read { status, original_source_url, mirrored_url } and pick correctly — here original_source_url is the ORIGINAL video and mirrored_url is the DECOMPOSED video: prefer the DECOMPOSED video (mirrored_url) when status=\"done\" and the downstream call should be caption-free (thumbnails, style references, re-cut hooks); prefer the ORIGINAL video (original_source_url) when the user explicitly wants the raw upload (branded intros, provenance, when captions are the subject matter). If status is \"pending\" and the user is not blocked on subtitle removal, use the ORIGINAL video and mention that the decomposed (caption-free) video is still processing. If status is \"none\" or \"failed\", there is no decomposed video yet — use the ORIGINAL video only. Do not call POST /remove-video-captions-poll unless the user explicitly asks to advance the subtitle-removal job. To strip burned-in captions from any OTHER video URL (not this fork's decompose flow), use the POST /api/v1/primitives/videos/remove-captions primitive instead.",
91
91
  "The OPTIONAL video_context tool returns this fork's decomposed video context: the source video's verbatim audio transcript (full text plus timestamped segments), a literal visual description of every scene, per-scene transcript excerpts, and viral DNA. Call it with the fork id from editor_context whenever the user asks what the source video says or shows, when writing/translating captions, hooks, or dubs that must match the spoken audio, or when you need scene-by-scene grounding before planning timeline edits. It is read-only and non-billing, so prefer it over guessing from frames or memory. If it returns status=\"none\", the fork was never smart-decomposed — offer to run POST /api/v1/compositions/:forkId/auto-decompose first. The same data is available via GET /api/v1/compositions/:forkId/video-context.json through http_request (useful for other forks).",
92
92
  "TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid). ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
93
- "MY FILES: the user has a personal file library ('My Files') of uploaded assets — videos (mp4/mov/webm), images (png/jpg/jpeg/gif/webp/svg), audio (mp3/wav/m4a/aac), and documents (pdf/md/txt/csv) — organized into virtual folders. Use the browse_files tool to explore AND write it: call action=list (optionally with folder_path) to see what files and folders exist, action=read with a file_id (copied from a prior list) to fetch one file, and action=write with file_name + content (+ optional folder_path) to SAVE a text file. When the user references their own footage, brand assets, logos, music, a brief, or a script ('use my product photo', 'the video I uploaded', 'my brand logo', 'the script in my files'), browse_files list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids or view URLs — always list to discover the real ones first.",
94
- "SAVING CONTEXT TO MY FILES: browse_files action=write persists durable, reusable context the user (and future chats/agents) can read back. write only accepts text files (md/txt/csv/json/srt/vtt) save generated images/video/audio via primitive routes and reference their view_url instead, never as browse_files content. Use write to capture the Getting Started artifacts as Markdown: a product About.md (basic offer/product context) or a deeper Interview.md, plus awareness-levels.md, persuasive-angles.md, and ad-hooks.md distilled from the matching brainstorm outputs. When a brainstorm job returns strategy the user wants to keep, offer to save it (or save it) as the corresponding .md so it is not lost when the chat ends. Namescope every write under the right product/offer folder (see MY FILES IS MULTI-OFFER) — e.g. folder_path='acme-skincare', file_name='About.md'. Before overwriting an existing file of the same name in the same folder, confirm with the user (writing the same name replaces it).",
93
+ "MY FILES: the user has a personal file library ('My Files') of uploaded assets — videos (mp4/mov/webm), images (png/jpg/jpeg/gif/webp/svg), audio (mp3/wav/m4a/aac), and documents (pdf/md/txt/csv) — organized into virtual folders. Use the browse_files tool to explore AND write it: call action=list (optionally with folder_path) to see what files and folders exist, action=search with a plain-language query to find files by MEANING (keyword + vector match over every file's name, folder, and metadata notes — prefer it over paging list when the library is large or the user references an asset vaguely), action=read with a file_id (copied from a prior list/search) to fetch one file, action=write with file_name + content (or source_url) to SAVE a file, and action=annotate with file_id + notes to attach metadata notes to any existing file. When the user references their own footage, brand assets, logos, music, a brief, or a script ('use my product photo', 'the video I uploaded', 'my brand logo', 'the script in my files'), browse_files search or list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids or view URLs — always list or search to discover the real ones first.",
94
+ "SAVING CONTEXT TO MY FILES: browse_files action=write persists durable, reusable context the user (and future chats/agents) can read back. write saves text files (md/txt/csv/json/srt/vtt) via content, and IMPORTS media into the library via source_url (a durable URL from a finished primitive job or existing asset) use source_url to persist a generated asset the user will want again (a character sprite card, a recurring background, a logo variant) instead of leaving it stranded in a job result. Use write to capture the Getting Started artifacts as Markdown: a product About.md (basic offer/product context) or a deeper Interview.md, plus awareness-levels.md, persuasive-angles.md, and ad-hooks.md distilled from the matching brainstorm outputs. When a brainstorm job returns strategy the user wants to keep, offer to save it (or save it) as the corresponding .md so it is not lost when the chat ends. Namescope every write under the right product/offer folder (see MY FILES IS MULTI-OFFER) — e.g. folder_path='acme-skincare', file_name='About.md'. Pass notes on write (or annotate afterwards) for any asset worth finding again: notes describe what the file is, who/what it depicts, and when to use it, and they are vector-embedded so action=search finds the file by meaning in future sessions. Before overwriting an existing file of the same name in the same folder, confirm with the user (writing the same name replaces it).",
95
+ "CHARACTER CONSISTENCY: when the user has a recurring character — a mascot, spokesperson, avatar, or product character that should look the same across videos — persist the character to My Files so every future generation can reference it, namescoped under the offer's folder (e.g. 'acme-skincare/characters/zara/'): (1) about_character.md — name, role, personality, physical description (face, build, wardrobe, colors), voice/tone, and do/don'ts, saved via write with content; (2) character_sprite_card.png — ONE reference-sheet image showing the character consistently (full body front/side/back plus a face close-up, neutral background). If the user has no sprite card, offer to generate one with POST /api/v1/primitives/images/generate (image gen is cheap; pass their best character photos as prompt_attachments if they exist, and ask the image for a 'character reference sheet / sprite card' layout), then save the finished job's URL into My Files via write with source_url + file_name='character_sprite_card.png'. Annotate BOTH files with notes naming the character so browse_files search finds them from any phrasing ('our mascot', 'the fox character'). From then on, character consistency = always pass the sprite card's view_url as the reference input on generation: prompt_attachments for images/generate and images/edit reference_attachments, input_references for videos/generate (or generate_layer's prompt_attachments/input_references), and pull wording from about_character.md into the prompt. Before generating ANY recurring character, search My Files for an existing sprite card first — never regenerate a character from memory when a reference exists.",
95
96
  "GETTING STARTED / ONBOARDING: only run this flow when the user signals they don't know where to start (e.g. 'getting started', 'I don't know where to begin', 'help me set up') — if they already know what they want, just do that; never force onboarding. When they do want it, walk them through, saving each artifact to My Files (browse_files write, namescoped to their product/offer folder) as you go: (1) capture product/offer context — a quick About.md, or a fuller Interview.md if they want depth (use /brainstorm/coldstart to drive the interview questions); (2) figure out customer awareness level (Eugene Schwartz) — if unknown, note that ads for every level should be tested — via /brainstorm/awareness_stages, and save awareness-levels.md; (3) persuasive angles via /brainstorm/angles → persuasive-angles.md; (4) hooks via /brainstorm/hooks → ad-hooks.md; (5) ask whether they have brand assets (logos/mascots/themes — suggest a /brand-assets folder, e.g. /brand-assets/logo.png) or product demos / screen recordings (suggest a /product-demos folder), and browse_files list to see if they already uploaded any; (6) ask roughly what they want to spend per video and map it to the cost spectrum (see COST AWARENESS) so you know which approach to default to and whether AI video generation is on the table; (7) recommend templates that fit their offer by searching the catalog (GET /discover/feed?q=<offer>) and reading each result's promotions/keywords/summary, then offer to fork and modify the best fit to their offer. Users can skip straight to any step — e.g. 'just find me a good template for X' should jump to step 7 without the full interview.",
96
97
  "COST AWARENESS: video cost spans a wide spectrum and the APPROACH sets the price — always default to the cheapest approach that meets the user's goal and make the tradeoff explicit. Bands: (a) FREE — reuse an already-decomposed template, swap captions/images/existing MP4s (from My Files, the user's computer, or a web search), and render on a local `vidfarm serve` box (in-process, no charge); (b) ~$0.001-$0.03 — same reuse but cloud render (~$0.01-$0.10); (c) ~$1 — AI-generate a few scenes for specificity; (d) $10+ — heavy AI generation (many/long AI clips, custom characters). Reusing existing footage or footage the user films is cheapest; AI-generating characters/scenes is most expensive. IMAGE generation is cheap — use it freely without asking. AI VIDEO generation is expensive — ASK the user's permission before generating video (/videos/generate or the generate_layer video path), and prefer reuse/local/image options unless they've okayed the spend or budget. Decompose (auto-decompose smart) is a one-time ~$0.10 per NEW source — prefer forking already-decomposed catalog templates to avoid it. Surface cost whenever the user asks about it, when a budget was set, or before kicking off an expensive video-gen job.",
97
98
  "TEMPLATE / INSPIRATION DISCOVERY: when the user asks which templates, formats, or source videos suit a product or offer ('which templates are best to promote my weight loss app?', 'find me formats for a SaaS free trial', 'what should I use for my restaurant?'), search the catalog with http_request. Call GET /discover/feed?q=<their offer in their own words>&limit=20 to get ranked TEMPLATES, and GET /api/v1/videos?q=<offer>&limit=20 to get ranked source INSPIRATIONS. Each result carries decompose-derived metadata — `promotions` (the offer/product categories that format can sell), `keywords`, and a `summary`/`viralDna` blurb — so read those to judge fit, then recommend the best 3-6 with a one-line reason each (name the promotion or hook that matches their offer) and include their templateId/editor link. The q match is a coarse keyword prefilter, so also reason semantically over promotions/keywords/summary rather than trusting rank order blindly, and if a query returns nothing, retry with broader or synonym terms (e.g. 'fitness', 'transformation', 'before after' for a weight-loss app) before saying there are no matches. These are read-only, non-billing GETs — prefer them over guessing template ids from memory.",
@@ -378,7 +378,7 @@ function TemplateCard(input) {
378
378
  ? _jsx(LazyVideo, { src: template.previewUrl, controls: false })
379
379
  : _jsx("img", { className: "preview", src: template.previewUrl, alt: `${template.title} preview`, loading: "lazy", decoding: "async" })
380
380
  : _jsx("div", { className: "preview preview-empty", children: template.status === "processing" ? "Downloading…" : "No preview" });
381
- return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
381
+ return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.origin === "raw" ? (_jsx("div", { className: "pill-tag origin-pill", title: "Your own project \u2014 created from a prompt or your raw clips, not an imported inspiration", children: "Your project" })) : null, template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
382
382
  }
383
383
  function PreviewModal(input) {
384
384
  const { onClose, template } = input;
@@ -2272,6 +2272,7 @@ export function TemplateEditorChat({ boot }) {
2272
2272
  const [hasHydrated, setHasHydrated] = useState(false);
2273
2273
  const [isDropTarget, setIsDropTarget] = useState(false);
2274
2274
  const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
2275
+ const [isMobileChatExpanded, setIsMobileChatExpanded] = useState(false);
2275
2276
  const [showHistoryPanel, setShowHistoryPanel] = useState(false);
2276
2277
  const [showBranchesPanel, setShowBranchesPanel] = useState(false);
2277
2278
  const [branches, setBranches] = useState(null);
@@ -3624,10 +3625,12 @@ export function TemplateEditorChat({ boot }) {
3624
3625
  }
3625
3626
  if (!storageKey)
3626
3627
  throw new Error("upload did not return a storage key.");
3628
+ // origin:"raw" — a prompt-generated video is the user's own new project,
3629
+ // not an imported inspiration; the editor must not auto-prompt Decompose.
3627
3630
  const addRes = await fetch(new URL("/discover/templates", origin), {
3628
3631
  method: "POST",
3629
3632
  headers: jsonHeaders,
3630
- body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title })
3633
+ body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title, origin: "raw" })
3631
3634
  });
3632
3635
  const add = await readJson(addRes);
3633
3636
  if (!addRes.ok)
@@ -4121,20 +4124,20 @@ export function TemplateEditorChat({ boot }) {
4121
4124
  setLastError(null);
4122
4125
  setOpenThreadMenu(null);
4123
4126
  setShowHistoryPanel(false);
4124
- if (isBrainstormSurface) {
4125
- setIsMobileChatOpen(false);
4126
- }
4127
4127
  }, children: [_jsx("span", { className: "vf-editor-chat-thread-chip-title", children: thread.title }), thread.tracers.length ? (_jsxs("span", { className: "vf-editor-chat-thread-chip-tracers", children: [thread.tracers.slice(0, 2).join(", "), thread.tracers.length > 2 ? ` +${thread.tracers.length - 2}` : ""] })) : null] }), _jsx("div", { className: "vf-editor-chat-thread-menu-wrap", children: _jsx("button", { type: "button", className: "vf-editor-chat-thread-chip-menu", "aria-label": `Actions for ${thread.title}`, onClick: (event) => {
4128
4128
  event.stopPropagation();
4129
4129
  toggleThreadMenu(thread.id, event.currentTarget);
4130
4130
  }, children: "..." }) })] }, thread.id))) : (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Start a chat to create your first conversation thread." })) }));
4131
- return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", "data-layout": isBrainstormSurface ? "brainstorm" : "default", children: [_jsx("button", { type: "button", className: "vf-editor-chat-mobile-fab", "aria-label": "Open chat", "aria-expanded": isMobileChatOpen, onClick: () => setIsMobileChatOpen(true), children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), lastError ? (_jsx("div", { className: "vf-editor-chat-error", role: "status", children: lastError })) : null, _jsxs("div", { className: `${isBrainstormSurface ? "vf-editor-chat-body vf-editor-chat-brainstorm-layout" : "vf-editor-chat-body"}${showPersistentSidebar ? " has-sidebar" : ""}`, "data-history-open": showHistoryPanel || showBranchesPanel ? "true" : "false", children: [showPersistentSidebar ? (_jsxs("aside", { className: "vf-editor-chat-history-pane", children: [_jsx("div", { className: "vf-editor-chat-history-header", children: _jsxs("div", { className: "vf-editor-chat-history-header-main", children: [_jsxs("a", { className: "vf-editor-chat-back-button", href: "/discover", "aria-label": "Back to Discover", children: [_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }), "Discover"] }), _jsx("div", { className: "vf-editor-chat-history-kicker", children: "Chat history" })] }) }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread vf-editor-chat-new-thread-sidebar", onClick: createNewThreadFromClick, children: "New chat" }), threadListContent] })) : null, _jsxs("div", { className: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsx("a", { className: "vf-editor-chat-back-button vf-editor-chat-back-button--header", href: "/discover", "aria-label": "Back to Discover", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon", "aria-label": showHistoryPanel ? "Back to chat" : "Open chat history", "aria-pressed": showHistoryPanel, onClick: () => {
4131
+ return (_jsxs("div", { className: "vf-editor-chat-shell", "data-mobile-open": isMobileChatOpen ? "true" : "false", "data-mobile-expanded": isMobileChatExpanded ? "true" : "false", "data-layout": isBrainstormSurface ? "brainstorm" : "default", children: [_jsx("button", { type: "button", className: "vf-editor-chat-mobile-fab", "aria-label": "Open chat", "aria-expanded": isMobileChatOpen, onClick: () => {
4132
+ setIsMobileChatExpanded(false);
4133
+ setIsMobileChatOpen(true);
4134
+ }, children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), lastError ? (_jsx("div", { className: "vf-editor-chat-error", role: "status", children: lastError })) : null, _jsxs("div", { className: `${isBrainstormSurface ? "vf-editor-chat-body vf-editor-chat-brainstorm-layout" : "vf-editor-chat-body"}${showPersistentSidebar ? " has-sidebar" : ""}`, "data-history-open": showHistoryPanel || showBranchesPanel ? "true" : "false", children: [showPersistentSidebar ? (_jsxs("aside", { className: "vf-editor-chat-history-pane", children: [_jsx("div", { className: "vf-editor-chat-history-header", children: _jsxs("div", { className: "vf-editor-chat-history-header-main", children: [_jsxs("a", { className: "vf-editor-chat-back-button", href: "/discover", "aria-label": "Back to Discover", children: [_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }), "Discover"] }), _jsx("div", { className: "vf-editor-chat-history-kicker", children: "Chat history" })] }) }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread vf-editor-chat-new-thread-sidebar", onClick: createNewThreadFromClick, children: "New chat" }), threadListContent] })) : null, _jsxs("div", { className: "vf-editor-chat-thread", children: [_jsxs("div", { className: "vf-editor-chat-header", children: [_jsxs("div", { className: "vf-editor-chat-header-row", children: [_jsx("a", { className: "vf-editor-chat-back-button vf-editor-chat-back-button--header", href: "/discover", "aria-label": "Back to Discover", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) }) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon", "aria-label": showHistoryPanel ? "Back to chat" : "Open chat history", "aria-pressed": showHistoryPanel, onClick: () => {
4132
4135
  setShowHistoryPanel((prev) => !prev);
4133
4136
  setShowBranchesPanel(false);
4134
4137
  }, children: showHistoryPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6v4l2.6 1.6" })] })) }), _jsx("button", { type: "button", className: "vf-editor-chat-history-icon vf-editor-chat-branches-icon", "aria-label": showBranchesPanel ? "Back to chat" : "Open template forks", "aria-pressed": showBranchesPanel, onClick: () => {
4135
4138
  setShowBranchesPanel((prev) => !prev);
4136
4139
  setShowHistoryPanel(false);
4137
- }, children: showBranchesPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "5.5", cy: "4.5", r: "1.9" }), _jsx("circle", { cx: "5.5", cy: "15.5", r: "1.9" }), _jsx("circle", { cx: "14.5", cy: "8", r: "1.9" }), _jsx("path", { d: "M5.5 6.4v7.2" }), _jsx("path", { d: "M5.5 13.6c0-3.2 2.4-3.6 4.4-3.9 2-.3 4.6-.5 4.6-3.7" })] })) }), _jsxs("div", { className: "vf-editor-chat-header-titles", children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Vidfarm" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: showHistoryPanel ? "Chat history" : showBranchesPanel ? "Template forks" : boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" }), _jsx("button", { type: "button", className: "vf-editor-chat-minimize", "aria-label": "Minimize chat", onClick: () => setIsMobileChatOpen(false), children: "\u00D7" })] }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
4140
+ }, children: showBranchesPanel ? (_jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.9", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 4-6 6 6 6" }) })) : (_jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "5.5", cy: "4.5", r: "1.9" }), _jsx("circle", { cx: "5.5", cy: "15.5", r: "1.9" }), _jsx("circle", { cx: "14.5", cy: "8", r: "1.9" }), _jsx("path", { d: "M5.5 6.4v7.2" }), _jsx("path", { d: "M5.5 13.6c0-3.2 2.4-3.6 4.4-3.9 2-.3 4.6-.5 4.6-3.7" })] })) }), _jsxs("div", { className: "vf-editor-chat-header-titles", children: [_jsx("div", { className: "vf-editor-chat-header-title", children: "Vidfarm" }), _jsx("div", { className: "vf-editor-chat-header-subtitle", children: showHistoryPanel ? "Chat history" : showBranchesPanel ? "Template forks" : boot.template.templateTitle })] }), _jsx("button", { type: "button", className: "vf-editor-chat-new-thread", onClick: createNewThreadFromClick, children: "New chat" }), _jsx("button", { type: "button", className: "vf-editor-chat-expand", "aria-label": isMobileChatExpanded ? "Shrink chat" : "Expand chat", "aria-pressed": isMobileChatExpanded, onClick: () => setIsMobileChatExpanded((prev) => !prev), children: isMobileChatExpanded ? (_jsxs("svg", { viewBox: "0 0 20 20", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M16.5 8.5h-5v-5" }), _jsx("path", { d: "M11.5 8.5 17 3" }), _jsx("path", { d: "M3.5 11.5h5v5" }), _jsx("path", { d: "M8.5 11.5 3 17" })] })) : (_jsxs("svg", { viewBox: "0 0 20 20", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M11.5 3.5h5v5" }), _jsx("path", { d: "M16.5 3.5 11 9" }), _jsx("path", { d: "M8.5 16.5h-5v-5" }), _jsx("path", { d: "M3.5 16.5 9 11" })] })) }), _jsx("button", { type: "button", className: "vf-editor-chat-minimize", "aria-label": "Minimize chat", onClick: () => setIsMobileChatOpen(false), children: "\u00D7" })] }), openThreadMenu && openThreadMenuThread ? createPortal(_jsxs("div", { className: "vf-editor-chat-thread-menu", role: "menu", style: {
4138
4141
  top: openThreadMenu.top,
4139
4142
  left: openThreadMenu.left
4140
4143
  }, onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: () => copyThreadId(openThreadMenuThread.id), children: "Copy Chat ID" }), _jsx("button", { type: "button", onClick: () => editThreadTracers(openThreadMenuThread.id), children: "Edit tracers" }), _jsx("button", { type: "button", onClick: () => removeThreadFromView(openThreadMenuThread.id), children: "Remove from view" }), _jsx("button", { type: "button", onClick: () => archiveThread(openThreadMenuThread.id), children: "Archive thread" }), _jsx("button", { type: "button", "data-danger": "true", onClick: () => deleteThread(openThreadMenuThread.id), children: "Delete" })] }), document.body) : null] }), _jsx("div", { className: "vf-editor-chat-viewport", ref: viewportRef, children: showHistoryPanel ? (_jsx("div", { className: "vf-editor-chat-history-panel", children: threadListContent })) : showBranchesPanel ? (_jsxs("div", { className: "vf-editor-chat-history-panel vf-editor-chat-branches-panel", children: [_jsxs("div", { className: "vf-editor-chat-branches-header", children: [_jsxs("div", { className: "vf-editor-chat-branches-header-main", children: [_jsx("div", { className: "vf-editor-chat-branches-kicker", children: "Forks" }), _jsxs("div", { className: "vf-editor-chat-branches-title", children: [branches?.length ?? 0, " fork", (branches?.length ?? 0) === 1 ? "" : "s", " of this template"] })] }), _jsx("button", { type: "button", className: "vf-editor-chat-branches-refresh", onClick: () => { void loadBranches({ force: true }); }, disabled: branchesLoading, "aria-label": "Refresh forks", children: branchesLoading ? "Loading…" : "Refresh" })] }), branchesError ? (_jsx("div", { className: "vf-editor-chat-thread-empty", role: "status", children: branchesError })) : branchesLoading && !branches ? (_jsx("div", { className: "vf-editor-chat-thread-empty", children: "Loading forks\u2026" })) : branches && branches.length ? (_jsx("div", { className: "vf-editor-chat-thread-list", children: branches.map((fork) => {
@@ -366,6 +366,13 @@ export function renderHomepage(input) {
366
366
  font-weight: 800;
367
367
  }
368
368
 
369
+ .origin-pill {
370
+ border-color: rgba(46, 160, 100, 0.35);
371
+ background: rgba(46, 160, 100, 0.1);
372
+ color: #1f7a4d;
373
+ font-weight: 800;
374
+ }
375
+
369
376
  .processing-pill {
370
377
  border-color: rgba(88, 132, 196, 0.4);
371
378
  background: rgba(88, 132, 196, 0.12);
@@ -949,54 +956,8 @@ export function renderHomepage(input) {
949
956
  display: none !important;
950
957
  }
951
958
 
952
- /* Logged-in Discover: the brainstorm chat must NOT hijack the whole
953
- screen like it does on /chat restore the editor-style FAB dock
954
- (closed by default, full-screen thread only while open). These
955
- override the [data-layout="brainstorm"] rules in the shared shell
956
- styles, which load before this block. */
957
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"] {
958
- pointer-events: none;
959
- }
960
-
961
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"][data-mobile-open="true"] {
962
- pointer-events: auto;
963
- }
964
-
965
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-mobile-fab {
966
- display: inline-flex;
967
- }
968
-
969
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"][data-mobile-open="true"] .vf-editor-chat-mobile-fab {
970
- opacity: 0;
971
- transform: scale(0.86);
972
- pointer-events: none;
973
- }
974
-
975
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-thread {
976
- position: fixed;
977
- inset: 0;
978
- z-index: 1;
979
- display: none;
980
- width: 100%;
981
- height: 100svh;
982
- border-radius: 0;
983
- pointer-events: auto;
984
- }
985
-
986
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"][data-mobile-open="true"] .vf-editor-chat-thread {
987
- display: grid;
988
- grid-template-rows: auto minmax(0, 1fr) auto;
989
- }
990
-
991
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-minimize {
992
- display: inline-flex;
993
- }
994
-
995
- /* Already on Discover — the /chat back-to-Discover header link would
996
- point at the current page. */
997
- .discover-frame .vf-editor-chat-shell[data-layout="brainstorm"] .vf-editor-chat-back-button--header {
998
- display: none;
999
- }
959
+ /* The shared shell styles now default brainstorm surfaces to the
960
+ FAB + popup dock, so Discover needs no chat overrides here. */
1000
961
 
1001
962
  /* iOS zooms the page when focusing sub-16px inputs; modals must not
1002
963
  trigger that. */