@hugpy/video-intelligence-ui 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-C_av7KDW.js → index-xEPYADt9.js} +44 -44
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/stations/oracle/OracleStation.tsx +7 -7
- package/src/stations/oracle/PerformanceComposer.tsx +19 -14
- package/src/stations/oracle/oracleTypes.ts +6 -0
- package/src/stations/oracle/useOracle.ts +13 -2
- package/src/stations/studio/StudioMovieComposer.tsx +21 -20
- package/src/video/spreadAssist.ts +1 -0
package/dist/index.html
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
overflow-x: hidden;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="/video/assets/index-
|
|
52
|
+
<script type="module" crossorigin src="/video/assets/index-xEPYADt9.js"></script>
|
|
53
53
|
<link rel="stylesheet" crossorigin href="/video/assets/index-DMoT8G1q.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hugpy/video-intelligence-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hugpy video-intelligence console: image crop, audio crop, frames/models and generate stations over the hugpy-video job bus; a standalone Vite + Tailwind v4 arm served at /video",
|
|
@@ -42,7 +42,7 @@ function RunPicker({ runs }: { runs: RunManifestApi }) {
|
|
|
42
42
|
<section className="vi-oracle-panel" aria-label="Runs">
|
|
43
43
|
<h3>
|
|
44
44
|
Runs
|
|
45
|
-
<span className="vi-oracle-sub">
|
|
45
|
+
<span className="vi-oracle-sub">Video production jobs — select a run to follow its progress</span>
|
|
46
46
|
</h3>
|
|
47
47
|
<div className="vi-oracle-row">
|
|
48
48
|
<button type="button" className="vi-btn vi-btn-sm" onClick={jobs.refresh}>
|
|
@@ -82,7 +82,7 @@ function RunPicker({ runs }: { runs: RunManifestApi }) {
|
|
|
82
82
|
))}
|
|
83
83
|
{!jobs.jobs.length && !jobs.gap ? (
|
|
84
84
|
<span className="vi-knob-hint">
|
|
85
|
-
No
|
|
85
|
+
No video production jobs in the feed. Submit a request above, or paste an
|
|
86
86
|
older job id.
|
|
87
87
|
</span>
|
|
88
88
|
) : null}
|
|
@@ -119,10 +119,10 @@ function RunHeader({ runs }: { runs: RunManifestApi }) {
|
|
|
119
119
|
{m ? (
|
|
120
120
|
<>
|
|
121
121
|
<div className="vi-oracle-row">
|
|
122
|
-
<Tag kind={m.ok ? "ok" : "alarm"}>{m.ok ? "ok" : "not ok"}</Tag>
|
|
122
|
+
<Tag kind={(m.ok ?? job?.result?.ok) ? "ok" : "alarm"}>{(m.ok ?? job?.result?.ok) ? "ok" : "not ok"}</Tag>
|
|
123
123
|
{m.stopped_after ? <Tag kind="warn">stopped after {m.stopped_after}</Tag> : null}
|
|
124
124
|
{job?.status ? <StateChip state={job.status} /> : null}
|
|
125
|
-
{m.dag ? <Tag kind="accent">ran on the DAG</Tag> : <Tag>linear recipe</Tag>}
|
|
125
|
+
{m.dag ? <Tag kind="accent">ran on the DAG</Tag> : <Tag>{m.kind === "studio_movie" ? "sequential production" : "linear recipe"}</Tag>}
|
|
126
126
|
{job?.result?.error ? (
|
|
127
127
|
<Tag kind="alarm">
|
|
128
128
|
{job.result.error.code}: {job.result.error.message}
|
|
@@ -132,7 +132,7 @@ function RunHeader({ runs }: { runs: RunManifestApi }) {
|
|
|
132
132
|
<dl className="vi-oracle-kv" style={{ marginTop: "0.5rem" }}>
|
|
133
133
|
<dt>run id</dt>
|
|
134
134
|
<dd>
|
|
135
|
-
<code>{m.run_id}</code>
|
|
135
|
+
<code>{m.run_id ?? m.job_id}</code>
|
|
136
136
|
</dd>
|
|
137
137
|
<dt>registry version</dt>
|
|
138
138
|
<dd>
|
|
@@ -143,9 +143,9 @@ function RunHeader({ runs }: { runs: RunManifestApi }) {
|
|
|
143
143
|
<code>{short(m.goal_digest)}</code> · <code>{short(m.snapshot_digest)}</code> · <code>{short(m.lock_digest)}</code>
|
|
144
144
|
</dd>
|
|
145
145
|
<dt>segments</dt>
|
|
146
|
-
<dd>{m.segment_digests?.length ?? 0}</dd>
|
|
146
|
+
<dd>{m.segments_completed !== undefined ? `${m.segments_completed} / ${m.segments_total ?? "?"}` : m.segment_digests?.length ?? 0}</dd>
|
|
147
147
|
<dt>video</dt>
|
|
148
|
-
<dd>{m.video_ref ? <code
|
|
148
|
+
<dd>{(m.video_ref ?? m.assembly?.movie) ? <code>{short(m.video_ref ?? m.assembly?.movie, 56)}</code> : "none accepted"}</dd>
|
|
149
149
|
<dt>receipts</dt>
|
|
150
150
|
<dd>
|
|
151
151
|
{(m.receipts ?? []).length ? (
|
|
@@ -7,15 +7,16 @@ type EnqueueResult = { job_id?: string; error?: string };
|
|
|
7
7
|
type PerformanceProbe = { ready: boolean; bound?: string[]; unbound?: Array<{ seam: string; requirement: string }> };
|
|
8
8
|
type Stage = "authority" | "snapshot" | "audio" | "lock" | "segments" | "keyframes" | "clips" | "assembly";
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/** Submit a requested output; retain the explicit performance workflow when needed. */
|
|
11
11
|
export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string) => void }) {
|
|
12
|
+
const [performance, setPerformance] = useState(false);
|
|
12
13
|
const [prompt, setPrompt] = useState("");
|
|
13
14
|
const [dialogue, setDialogue] = useState("");
|
|
14
15
|
const [speaker, setSpeaker] = useState("narrator");
|
|
15
16
|
const [voiceId, setVoiceId] = useState("narrator");
|
|
16
17
|
const [rawRef, setRawRef] = useState("");
|
|
17
18
|
const [quality, setQuality] = useState("balanced");
|
|
18
|
-
const [stopAfter, setStopAfter] = useState<Stage | "">("
|
|
19
|
+
const [stopAfter, setStopAfter] = useState<Stage | "">("");
|
|
19
20
|
const [tts, setTts] = useState(3);
|
|
20
21
|
const [keyframes, setKeyframes] = useState(3);
|
|
21
22
|
const [clips, setClips] = useState(3);
|
|
@@ -28,6 +29,7 @@ export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string
|
|
|
28
29
|
const [probeGap, setProbeGap] = useState("");
|
|
29
30
|
|
|
30
31
|
useEffect(() => {
|
|
32
|
+
if (!performance) return;
|
|
31
33
|
let active = true;
|
|
32
34
|
void (async () => {
|
|
33
35
|
const result = await request<PerformanceProbe>(`${hugpyConfig.apiBase}/video/performance/probe`, {
|
|
@@ -38,15 +40,15 @@ export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string
|
|
|
38
40
|
else setProbeGap(describeAppError(errorOf(result)));
|
|
39
41
|
})();
|
|
40
42
|
return () => { active = false; };
|
|
41
|
-
}, []);
|
|
43
|
+
}, [performance]);
|
|
42
44
|
|
|
43
45
|
async function enqueue() {
|
|
44
46
|
const lines = dialogue.split("\n").map((text) => text.trim()).filter(Boolean);
|
|
45
|
-
if (!prompt.trim() || !lines.length || !speaker.trim() || !voiceId.trim() || !rawRef.trim()) {
|
|
46
|
-
setMessage("Add the goal, at least one dialogue line, speaker, voice ID, and raw request reference.");
|
|
47
|
+
if (!prompt.trim() || (performance && (!lines.length || !speaker.trim() || !voiceId.trim() || !rawRef.trim()))) {
|
|
48
|
+
setMessage(performance ? "Add the goal, at least one dialogue line, speaker, voice ID, and raw request reference." : "Describe the video you want.");
|
|
47
49
|
return;
|
|
48
50
|
}
|
|
49
|
-
const body = {
|
|
51
|
+
const body = performance ? {
|
|
50
52
|
goal: { objective: prompt.trim(), raw_prompt: prompt, quality, planner_mode: "local_only" },
|
|
51
53
|
dialogue: { locked: true, lines: lines.map((text, i) => ({ line_id: `line-${i + 1}`, speaker: speaker.trim(), text })) },
|
|
52
54
|
casting: [[speaker.trim(), { voice_id: voiceId.trim(), kind: "synthetic" }]],
|
|
@@ -58,12 +60,12 @@ export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string
|
|
|
58
60
|
negative_prompt: negative.trim() || null,
|
|
59
61
|
max_seconds: maxSeconds ? Number(maxSeconds) : null,
|
|
60
62
|
stop_after: stopAfter || null,
|
|
61
|
-
};
|
|
63
|
+
} : { prompt: prompt.trim(), output: "video" };
|
|
62
64
|
setBusy(true);
|
|
63
65
|
setMessage("");
|
|
64
|
-
const result = await request<EnqueueResult>(`${hugpyConfig.apiBase}/video/jobs/performance`, {
|
|
66
|
+
const result = await request<EnqueueResult>(`${hugpyConfig.apiBase}${performance ? "/video/jobs/performance" : "/oracle/route"}`, {
|
|
65
67
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
66
|
-
meta: { specKey: "oracle", operation: "oracle.
|
|
68
|
+
meta: { specKey: "oracle", operation: "oracle.production.enqueue" },
|
|
67
69
|
});
|
|
68
70
|
setBusy(false);
|
|
69
71
|
if (!result.ok) {
|
|
@@ -79,12 +81,14 @@ export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string
|
|
|
79
81
|
onEnqueued(jobId);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
return <section className="vi-oracle-panel vi-oracle-composer" aria-label="New
|
|
83
|
-
<h3>New
|
|
84
|
+
return <section className="vi-oracle-panel vi-oracle-composer" aria-label="New video request">
|
|
85
|
+
<h3>New video request <span className="vi-oracle-sub">Oracle plans the shots and settings using available models</span></h3>
|
|
84
86
|
<div className="vi-oracle-fields">
|
|
85
87
|
<label className="vi-oracle-field vi-oracle-wide">Goal / visual direction
|
|
86
88
|
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} placeholder="Describe the film, characters, setting and action" />
|
|
87
89
|
</label>
|
|
90
|
+
<label className="vi-oracle-field vi-oracle-wide"><span><input type="checkbox" checked={performance} onChange={(e) => setPerformance(e.target.checked)} /> Use the explicit dialogue performance workflow</span></label>
|
|
91
|
+
{performance && <>
|
|
88
92
|
<label className="vi-oracle-field vi-oracle-wide">Locked dialogue · one line per utterance
|
|
89
93
|
<textarea value={dialogue} onChange={(e) => setDialogue(e.target.value)} rows={3} placeholder="The words to speak" />
|
|
90
94
|
</label>
|
|
@@ -107,13 +111,14 @@ export function PerformanceComposer({ onEnqueued }: { onEnqueued: (jobId: string
|
|
|
107
111
|
<label className="vi-oracle-field">Max seconds <input type="number" min={1} value={maxSeconds} onChange={(e) => setMaxSeconds(e.target.value)} placeholder="No limit" /></label>
|
|
108
112
|
<label className="vi-oracle-field">Seed salt <input type="number" min={0} value={seed} onChange={(e) => setSeed(Number(e.target.value))} /></label>
|
|
109
113
|
<label className="vi-oracle-field vi-oracle-wide">Negative prompt <input value={negative} onChange={(e) => setNegative(e.target.value)} placeholder="Optional exclusions" /></label>
|
|
114
|
+
</>}
|
|
110
115
|
</div>
|
|
111
|
-
<div className="vi-oracle-note" role="status">
|
|
116
|
+
{performance && <div className="vi-oracle-note" role="status">
|
|
112
117
|
{probe ? <>
|
|
113
118
|
<strong>{probe.ready ? "Full recipe wired" : "Staged runs available; full recipe has gaps"}</strong>
|
|
114
119
|
{probe.unbound?.length ? <ul className="vi-oracle-limits">{probe.unbound.map((gap) => <li key={gap.seam}><code>{gap.seam}</code>: {gap.requirement}</li>)}</ul> : null}
|
|
115
120
|
</> : probeGap ? <>Could not read performance capability probe: {probeGap}</> : "Checking performance capabilities…"}
|
|
116
|
-
</div>
|
|
117
|
-
<div className="vi-oracle-row"><button type="button" className="vi-btn vi-btn-accent" disabled={busy} onClick={() => void enqueue()}>{busy ? "
|
|
121
|
+
</div>}
|
|
122
|
+
<div className="vi-oracle-row"><button type="button" className="vi-btn vi-btn-accent" disabled={busy} onClick={() => void enqueue()}>{busy ? "Planning and queuing…" : performance ? "Queue performance" : "Produce video"}</button>{message && <span role="status">{message}</span>}</div>
|
|
118
123
|
</section>;
|
|
119
124
|
}
|
|
@@ -244,6 +244,12 @@ export interface ManifestReceipt {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
export interface RunManifest {
|
|
247
|
+
kind?: string;
|
|
248
|
+
title?: string;
|
|
249
|
+
job_id?: string;
|
|
250
|
+
segments_completed?: number;
|
|
251
|
+
segments_total?: number;
|
|
252
|
+
assembly?: { movie?: string | null; total_frames?: number };
|
|
247
253
|
run_id: string;
|
|
248
254
|
ok: boolean;
|
|
249
255
|
goal_digest?: string;
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
29
|
|
|
30
|
+
import { subscribeJobStream } from "../../video/jobStream";
|
|
30
31
|
import { hugpyConfig, videoJobUrl } from "../../config";
|
|
31
32
|
import {
|
|
32
33
|
describeAppError,
|
|
@@ -329,7 +330,7 @@ function parseJobRows(raw: unknown): PerformanceJobRow[] {
|
|
|
329
330
|
// The bus kind for the oracle's audio-first runner is ("oracle","performance")
|
|
330
331
|
// → "performance" / "oracle.performance" / "video_performance" depending on
|
|
331
332
|
// the bridge; match loosely on the word rather than on one spelling.
|
|
332
|
-
if (!/performance/i.test(name)) continue;
|
|
333
|
+
if (!/performance|studio_movie/i.test(name)) continue;
|
|
333
334
|
const id = String(row.job_id ?? row.id ?? "");
|
|
334
335
|
if (!id) continue;
|
|
335
336
|
const prog = isRecord(row.progress) ? row.progress : null;
|
|
@@ -352,7 +353,7 @@ export function usePerformanceJobs(): { jobs: PerformanceJobRow[]; gap: Gap | nu
|
|
|
352
353
|
meta: { specKey: STATION, operation: "oracle.runs.list" },
|
|
353
354
|
});
|
|
354
355
|
if (!r.ok) {
|
|
355
|
-
setGap(gapOf(errorOf(r), "
|
|
356
|
+
setGap(gapOf(errorOf(r), "video production jobs"));
|
|
356
357
|
return;
|
|
357
358
|
}
|
|
358
359
|
setGap(null);
|
|
@@ -405,7 +406,17 @@ export function useRunManifest(): RunManifestApi {
|
|
|
405
406
|
}, []);
|
|
406
407
|
|
|
407
408
|
useEffect(() => {
|
|
409
|
+
setJob(null);
|
|
408
410
|
void load(jobId);
|
|
411
|
+
if (!jobId) return;
|
|
412
|
+
const unsubscribe = subscribeJobStream((event) => {
|
|
413
|
+
if (event.job_id !== jobId || !event.job) return;
|
|
414
|
+
++seq.current;
|
|
415
|
+
setJob(event.job as unknown as JobView);
|
|
416
|
+
setLoading(false);
|
|
417
|
+
setGap(null);
|
|
418
|
+
});
|
|
419
|
+
return () => { ++seq.current; unsubscribe(); };
|
|
409
420
|
}, [jobId, load]);
|
|
410
421
|
|
|
411
422
|
const manifest = useMemo<RunManifest | null>(() => {
|
|
@@ -728,6 +728,18 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
728
728
|
// the warning is worth the one extra click. Session-persisted like every other authored
|
|
729
729
|
// field here.
|
|
730
730
|
const [movieTitle, setMovieTitle] = useSessionState("studio.movie.title", "");
|
|
731
|
+
const autoTitleRef = useSessionRef<string>("studio.movie.autoTitle", "");
|
|
732
|
+
const applyGeneratedTitle = useCallback((prompt: string, title?: string) => {
|
|
733
|
+
const derived = title?.trim() || prompt.split(/[.,;\n]/)[0].trim()
|
|
734
|
+
.split(/\s+/).slice(0, 7).join(" ").slice(0, 48);
|
|
735
|
+
if (!derived) return;
|
|
736
|
+
const previousAutoTitle = autoTitleRef.current;
|
|
737
|
+
setMovieTitle((current) => {
|
|
738
|
+
if (current.trim() && current.trim() !== previousAutoTitle) return current;
|
|
739
|
+
autoTitleRef.current = derived;
|
|
740
|
+
return derived;
|
|
741
|
+
});
|
|
742
|
+
}, [setMovieTitle, autoTitleRef]);
|
|
731
743
|
// Movie-level MODEL PIN + sampler overrides (operator ask 2026-07-13: the Settings tab
|
|
732
744
|
// wants "the model selection" + a wider range of options). All BLANK-first ⇒ router
|
|
733
745
|
// default: modelId "" = the capability router picks by budget/resolution (an unroutable
|
|
@@ -1361,9 +1373,10 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1361
1373
|
if (first.height != null && !manualSettings.current.includes("height")) setHeight(first.height);
|
|
1362
1374
|
if (first.fps != null && !manualSettings.current.includes("fps")) setFps(first.fps);
|
|
1363
1375
|
}
|
|
1376
|
+
applyGeneratedTitle(plan.segments[0]?.prompt || p, plan.title);
|
|
1364
1377
|
setProducerNote([plan.title, plan.logline].filter(Boolean).join(" — "));
|
|
1365
1378
|
}, [premise, movieQuery, producing, goals, fps, assist.assistModel, startImage,
|
|
1366
|
-
setWidth, setHeight, setFps]);
|
|
1379
|
+
setWidth, setHeight, setFps, applyGeneratedTitle]);
|
|
1367
1380
|
|
|
1368
1381
|
const setSegmentCount = useCallback(
|
|
1369
1382
|
(n: number) => {
|
|
@@ -1477,7 +1490,6 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1477
1490
|
// The last AUTO-derived movie title. A name the operator typed is sacred; a
|
|
1478
1491
|
// name this ref minted may be replaced by the next Generate (operator
|
|
1479
1492
|
// 2026-08-13: "purely a product of the same generation method").
|
|
1480
|
-
const autoTitleRef = useRef("");
|
|
1481
1493
|
const planGoalPrompt = useCallback(async (
|
|
1482
1494
|
i: number, text: string,
|
|
1483
1495
|
coordinated?: { seed?: number; frames?: number; joint_mode?: string },
|
|
@@ -1540,29 +1552,14 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1540
1552
|
setGoalField(i, { negative: cur ? `${cur}, ${neg}` : neg });
|
|
1541
1553
|
},
|
|
1542
1554
|
);
|
|
1543
|
-
|
|
1544
|
-
// movie has none — or when the current one was itself auto-derived.
|
|
1545
|
-
const t = movieTitle.trim();
|
|
1546
|
-
if (t === "" || t === autoTitleRef.current) {
|
|
1547
|
-
const derived = fresh
|
|
1548
|
-
.split(/[.,;\n]/)[0]
|
|
1549
|
-
.trim()
|
|
1550
|
-
.split(/\s+/)
|
|
1551
|
-
.slice(0, 7)
|
|
1552
|
-
.join(" ")
|
|
1553
|
-
.slice(0, 48);
|
|
1554
|
-
if (derived) {
|
|
1555
|
-
autoTitleRef.current = derived;
|
|
1556
|
-
setMovieTitle(derived);
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1555
|
+
applyGeneratedTitle(fresh);
|
|
1559
1556
|
})();
|
|
1560
1557
|
// The text is about to change, so the old classification no longer describes it.
|
|
1561
1558
|
intentText.current[g.key] = "";
|
|
1562
1559
|
setIntents((prev) => ({ ...prev, [g.key]: null }));
|
|
1563
1560
|
},
|
|
1564
1561
|
[assist, goals, rowContext, setGoalField, movieTitle, setMovieTitle,
|
|
1565
|
-
settingsMode, planGoalPrompt],
|
|
1562
|
+
settingsMode, planGoalPrompt, applyGeneratedTitle],
|
|
1566
1563
|
);
|
|
1567
1564
|
|
|
1568
1565
|
// ── negative generation (§1b) — an exclusion list for THIS shot ──
|
|
@@ -1598,7 +1595,7 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1598
1595
|
});
|
|
1599
1596
|
if (!body) return;
|
|
1600
1597
|
setSpreadNotice(null);
|
|
1601
|
-
const res = await assist.runSpread(body);
|
|
1598
|
+
const res = await assist.runSpread({ ...body, fps });
|
|
1602
1599
|
if (!res) return;
|
|
1603
1600
|
|
|
1604
1601
|
const bySeg = new Map(res.segments.map((s) => [s.segment_id, s]));
|
|
@@ -1635,6 +1632,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1635
1632
|
};
|
|
1636
1633
|
}),
|
|
1637
1634
|
);
|
|
1635
|
+
const firstGenerated = applied.length ? bySeg.get(applied[0]) : undefined;
|
|
1636
|
+
if (firstGenerated) applyGeneratedTitle(firstGenerated.prompt);
|
|
1638
1637
|
let oracleNotice: string | null = null;
|
|
1639
1638
|
if (settingsMode === "oracle") {
|
|
1640
1639
|
for (const key of applied) {
|
|
@@ -1685,6 +1684,8 @@ export function StudioMovieComposer({ settingsHost }: StudioMovieComposerProps =
|
|
|
1685
1684
|
steeringSeed,
|
|
1686
1685
|
settingsMode,
|
|
1687
1686
|
planGoalPrompt,
|
|
1687
|
+
applyGeneratedTitle,
|
|
1688
|
+
fps,
|
|
1688
1689
|
]);
|
|
1689
1690
|
|
|
1690
1691
|
// ── GROUP ACTION: negatives for the selected rows ──
|
|
@@ -71,6 +71,7 @@ export interface AssistContextWire {
|
|
|
71
71
|
/** The `mode:"spread"` request body (§1a), minus `model` (the hook adds that). */
|
|
72
72
|
export interface SpreadRequestWire {
|
|
73
73
|
mode: "spread";
|
|
74
|
+
fps?: number;
|
|
74
75
|
movie_query?: string;
|
|
75
76
|
style_bible?: Record<string, string>;
|
|
76
77
|
fixed_segments: SegmentRefWire[];
|