diff --git a/client/src/components/media/PromptEnhancer.jsx b/client/src/components/media/PromptEnhancer.jsx
index 078c1ce3de..b0e6d77082 100644
--- a/client/src/components/media/PromptEnhancer.jsx
+++ b/client/src/components/media/PromptEnhancer.jsx
@@ -18,6 +18,12 @@ export default function PromptEnhancer({
negativePrompt = '',
setNegativePrompt,
renderConfig = {},
+ // Hard character cap the selected render backend enforces on the prompt it
+ // receives (reactor.inc fast-h3: 800, minus whatever a style preset prefixes).
+ // Passed to the refiner so the model writes inside the budget instead of
+ // handing back a richer prompt the renderer rejects outright. Omit when the
+ // backend has no cap.
+ maxPromptLength,
disabled = false,
}) {
const [isOpen, setIsOpen] = useState(false);
@@ -89,6 +95,7 @@ export default function PromptEnhancer({
model: selectedModel || undefined,
effort: effort || undefined,
renderConfig,
+ maxPromptLength: maxPromptLength > 0 ? maxPromptLength : undefined,
});
if (result?.prompt) {
@@ -96,7 +103,13 @@ export default function PromptEnhancer({
if (setNegativePrompt && result.negativePrompt != null) {
setNegativePrompt(result.negativePrompt);
}
- toast.success('Prompt enhanced!');
+ // Say so when the model overshot the backend's cap and the server had
+ // to cut the tail — otherwise the trim reads as the AI losing detail.
+ if (result.truncated) {
+ toast.warning(`Prompt enhanced, then trimmed to fit the ${maxPromptLength} character render limit`);
+ } else {
+ toast.success('Prompt enhanced!');
+ }
}
} catch {
// refineMediaPrompt routes through request() which already toasts on error
@@ -150,6 +163,12 @@ export default function PromptEnhancer({
AI Prompt Enhancer Settings
+ {maxPromptLength > 0 && (
+
+ The enhanced prompt will be kept within {maxPromptLength} characters — this render backend rejects a longer prompt.
+
+ )}
+
({
default: vi.fn(() => ({
@@ -22,6 +23,10 @@ vi.mock('../../services/api', () => ({
refineMediaPrompt: vi.fn(),
}));
+vi.mock('../ui/Toast', () => ({
+ default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), warning: vi.fn(), loading: vi.fn() }),
+}));
+
describe('PromptEnhancer', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -67,6 +72,38 @@ describe('PromptEnhancer', () => {
expect(setNegativePrompt).toHaveBeenCalledWith('low resolution');
});
+ // reactor.inc rejects a prompt over its cap outright, so the enhancer has to
+ // be told the budget rather than handing back a richer, unrenderable prompt.
+ it('forwards the render backend prompt cap and reports a trimmed result', async () => {
+ const setPrompt = vi.fn();
+ vi.mocked(api.refineMediaPrompt).mockResolvedValue({
+ prompt: 'an enhanced detailed prompt',
+ negativePrompt: '',
+ truncated: true,
+ });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /^enhance$/i }));
+
+ await waitFor(() => {
+ expect(api.refineMediaPrompt).toHaveBeenCalledWith(expect.objectContaining({ maxPromptLength: 785 }));
+ });
+ expect(setPrompt).toHaveBeenCalledWith('an enhanced detailed prompt');
+ expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining('785'));
+ expect(toast.success).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole('button', { name: /toggle ai prompt enhancement options/i }));
+ expect(screen.getByText(/kept within 785 characters/i)).toBeInTheDocument();
+ });
+
it('toggles settings panel to reveal provider, model and effort controls', () => {
render(
0 ? maxVideoPromptLength : undefined,
};
const data = await promptFromMedia(payload).catch(() => null);
setRunning(false);
if (!data) return;
setResult(data);
- toast.success('Prompts ready');
+ // Name the trim rather than letting a cut prompt read as a thin analysis.
+ if (data.videoPromptTruncated) {
+ toast.warning(`Prompts ready — the video prompt was trimmed to fit the ${maxVideoPromptLength} character render limit`);
+ } else {
+ toast.success('Prompts ready');
+ }
if (onResult) onResult(data);
};
diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx
index b84c59000c..e59da76bef 100644
--- a/client/src/pages/VideoGen.jsx
+++ b/client/src/pages/VideoGen.jsx
@@ -1013,6 +1013,19 @@ export default function VideoGen() {
);
const promptOverLimit = isReactor && submittedPromptLength > REACTOR_MAX_PROMPT_LENGTH;
+ // Budget the AI enhancer writes inside. It's the backend cap MINUS the style
+ // preset / universe prefix, because that prefix is part of what PortOS
+ // submits — enhancing to exactly 800 characters would still be rejected once
+ // the preset is prepended. `undefined` for a backend with no cap (and for the
+ // degenerate case where the prefix alone already fills the allowance, which
+ // the counter above is already flagging).
+ const enhancePromptBudget = useMemo(() => {
+ if (!isReactor) return undefined;
+ const overhead = submittedPromptLength - prompt.length;
+ const budget = REACTOR_MAX_PROMPT_LENGTH - Math.max(0, overhead);
+ return budget > 0 ? budget : undefined;
+ }, [isReactor, submittedPromptLength, prompt]);
+
// Only grok folds a negative prompt into its request (as an "Avoid:" line);
// fal's queue body and reactor's enqueue command have no such field, and a
// CFG-distilled local model ignores one. Hide the box rather than showing a
@@ -1381,6 +1394,7 @@ export default function VideoGen() {
negativePrompt={negativePromptSupported ? negativePrompt : ''}
setNegativePrompt={negativePromptSupported ? setNegativePrompt : undefined}
renderConfig={{ stylePreset: stylePreset?.id, mode, model: modelId }}
+ maxPromptLength={enhancePromptBudget}
/>
{mode === 'fflf' && keyframesSupported && (
@@ -1871,6 +1885,7 @@ export default function VideoGen() {
applyKind="video"
setPrompt={setPrompt}
setNegativePrompt={negativePromptSupported ? setNegativePrompt : undefined}
+ maxVideoPromptLength={enhancePromptBudget}
alwaysOpen
/>
diff --git a/client/src/pages/VideoGen.reactor.test.jsx b/client/src/pages/VideoGen.reactor.test.jsx
index a1e33e3ffa..cb9fb60d3b 100644
--- a/client/src/pages/VideoGen.reactor.test.jsx
+++ b/client/src/pages/VideoGen.reactor.test.jsx
@@ -80,6 +80,23 @@ describe('VideoGen reactor.inc lane', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /Add to queue/ })).toBeEnabled());
});
+ // Enhancing to exactly 800 characters still gets rejected once the style
+ // preset is prepended, so the enhancer's budget is the cap MINUS that prefix.
+ it('hands the AI enhancer a prompt budget net of the style prefix', async () => {
+ await renderVideoGenPage();
+ expect((await screen.findByTestId('prompt-enhancer')).dataset.maxPromptLength).toBe('');
+
+ await selectReactor();
+ fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'a fox watches the rain' } });
+ await waitFor(() => expect(screen.getByTestId('prompt-enhancer').dataset.maxPromptLength)
+ .toBe(String(REACTOR_MAX_PROMPT_LENGTH)));
+
+ // 'inky linework. ' — 15 characters of prefix the user never typed.
+ fireEvent.click(screen.getByRole('button', { name: 'Use universe style' }));
+ await waitFor(() => expect(screen.getByTestId('prompt-enhancer').dataset.maxPromptLength)
+ .toBe(String(REACTOR_MAX_PROMPT_LENGTH - 15)));
+ });
+
// fast-h3's enqueue command has no negative-prompt field, so the box only
// ever collected text nothing would submit.
it('drops the negative prompt the backend has no field for', async () => {
diff --git a/client/src/test/videoGenPageMocks.jsx b/client/src/test/videoGenPageMocks.jsx
index abe76209be..c62c595450 100644
--- a/client/src/test/videoGenPageMocks.jsx
+++ b/client/src/test/videoGenPageMocks.jsx
@@ -236,8 +236,16 @@ vi.mock('../components/ui/Toast', () => ({
// The prompt helpers stay observable rather than blanked: whether they remain
// usable while a render is already in flight is itself one of the assertions.
vi.mock('../components/media/PromptEnhancer', () => ({
- default: ({ disabled }) => (
- Enhance with AI
+ default: ({ disabled, maxPromptLength }) => (
+
+ Enhance with AI
+
),
}));
vi.mock('../components/media/PromptFromMedia', () => ({
diff --git a/server/lib/README.md b/server/lib/README.md
index f13857058f..b379e80784 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -422,7 +422,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `navManifest.js` | Single source of truth for nav (`⌘K` palette + voice). Add an entry when you add a page. |
| `noReplaceMove.js` | `moveWithoutReplace(from, to)` — publish a staged file into its final name WITHOUT ever clobbering an existing one. `fs.rename` silently replaces its destination, which is the wrong default for a derived artifact; this uses `link(2)` + `unlink(2)`, so an existing destination fails atomically with `MOVE_DEST_EXISTS` and both files survive. Refuses rather than degrading when the filesystem cannot express it (`MOVE_CROSS_DEVICE`, `MOVE_NO_REPLACE_UNSUPPORTED`) — a `stat`-then-`rename` fallback would be a race. Used by the rigging publication contract (`services/rigging/autoSkin.js`). |
| `personaTraitBlend.js` | Digital-twin persona trait-blending (M34 P7). Blends a persona's `traitAdjustments` against the base twin's communication profile + Big-Five into a "Communication Calibration" directive. Mirrored to `client/src/lib/`. |
-| `textUtils.js` | Pure dependency-free prose helpers. `countWords(text)` is the canonical whitespace-token count (`\S+`); `trimTo(value, max)` trims and bounds strings without coercing non-strings, and is safe for shared modules consumed by the browser; `escapeRegExp(value)` is the one to import instead of re-inlining the escape (a guard in `textUtils.test.js` fails the suite when a copy reappears in any non-test source under `server/`, or in ANY source under `client/src/`, tests and `.jsx` included — the escape half is mirrored on the client at `client/src/lib/textUtils.js`, which the browser imports since it cannot reach `server/lib`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:` labels). |
+| `textUtils.js` | Pure dependency-free prose helpers. `countWords(text)` is the canonical whitespace-token count (`\S+`); `trimTo(value, max)` trims and bounds strings without coercing non-strings, and is safe for shared modules consumed by the browser; `escapeRegExp(value)` is the one to import instead of re-inlining the escape (a guard in `textUtils.test.js` fails the suite when a copy reappears in any non-test source under `server/`, or in ANY source under `client/src/`, tests and `.jsx` included — the escape half is mirrored on the client at `client/src/lib/textUtils.js`, which the browser imports since it cannot reach `server/lib`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:` labels); `clampToCharLimit(text, max)` is `trimTo`'s reader-facing twin — it cuts on a sentence end (when one sits 60%+ into the allowance) or a word boundary rather than mid-word and returns `{ text, truncated }` so the caller can say the text was cut, which is what bounds an AI-enhanced render prompt to a backend's hard cap (reactor.inc fast-h3 rejects an over-length prompt outright). |
| `pipelineIssueOrder.js` | Pure renumber algorithm for pipeline issues. |
| `postAdaptive.js` | Pure POST adaptive-difficulty policy — nudges a math drill's primary knob (`steps`/`maxDigits`/`maxExponent`/`tolerancePct`) up/down within clamped bounds from recent scored performance. Opt-in via the config Adaptive toggle. |
| `postAppliedNumeracy.js` | Pure seeded Applied Numeracy pack — everyday percentage, ratio, unit, rate, and estimation scenarios plus server-authoritative numeric/fraction/unit scoring with explicit tolerance handling. |
diff --git a/server/lib/textUtils.js b/server/lib/textUtils.js
index 6940098e1d..773df9246a 100644
--- a/server/lib/textUtils.js
+++ b/server/lib/textUtils.js
@@ -36,6 +36,42 @@ export const trimTo = (value, max) => (
typeof value === 'string' ? value.trim().slice(0, max) : ''
);
+/**
+ * Bound a string to a hard character cap, cutting on a natural boundary.
+ *
+ * `trimTo` above slices blindly, which is right for a log field and wrong for
+ * text a human or a renderer reads: a mid-word cut reads as corruption, and in
+ * a render prompt it can change what the final phrase asks for. Cut at the last
+ * sentence end when one sits deep enough in the allowance (60%+, so an
+ * abbreviation or a decimal near the start can't throw the prompt away), else
+ * at the last word boundary, else at the cap.
+ *
+ * Returns `{ text, truncated }` so the caller can TELL the user the text was
+ * cut — a silent trim reads as the model losing detail on its own. A
+ * non-positive/non-finite `max` means "no cap" and passes the text through.
+ *
+ * Distinct from the two capping helpers that append a marker — `clampText`
+ * (`promptFencing.js`, `… [truncated]`) and `truncateForTelegram`
+ * (`telegramMessage.js`, `…`). A marker is right for text a reader sees and
+ * wrong for text handed BACK to a length-capped renderer, which would count
+ * it against the same cap.
+ *
+ * @param {unknown} text
+ * @param {number} max
+ * @returns {{ text: string, truncated: boolean }}
+ */
+export function clampToCharLimit(text, max) {
+ const value = typeof text === 'string' ? text : '';
+ if (!Number.isFinite(max) || max <= 0 || value.length <= max) return { text: value, truncated: false };
+ const cut = value.slice(0, max);
+ const sentenceEnd = Math.max(cut.lastIndexOf('.'), cut.lastIndexOf('!'), cut.lastIndexOf('?'));
+ const wordEnd = cut.lastIndexOf(' ');
+ const at = sentenceEnd >= max * 0.6 ? sentenceEnd + 1
+ : wordEnd > 0 ? wordEnd
+ : max;
+ return { text: cut.slice(0, at).trim(), truncated: true };
+}
+
/**
* Escape a string for literal use inside a RegExp.
*
diff --git a/server/lib/textUtils.test.js b/server/lib/textUtils.test.js
index 5b4a88dcb0..db96cdf945 100644
--- a/server/lib/textUtils.test.js
+++ b/server/lib/textUtils.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { countWords, escapeRegExp, trimTo } from './textUtils.js';
+import { clampToCharLimit, countWords, escapeRegExp, trimTo } from './textUtils.js';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -41,6 +41,26 @@ describe('trimTo', () => {
});
});
+describe('clampToCharLimit', () => {
+ it('passes text through when there is no cap or it already fits', () => {
+ expect(clampToCharLimit('a neon alley', 800)).toEqual({ text: 'a neon alley', truncated: false });
+ expect(clampToCharLimit('a neon alley', 0)).toEqual({ text: 'a neon alley', truncated: false });
+ expect(clampToCharLimit(null, 10)).toEqual({ text: '', truncated: false });
+ });
+
+ it('cuts on the last sentence end when one sits deep in the allowance', () => {
+ const { text, truncated } = clampToCharLimit('Wide shot of a rain-slick alley. Then the camera pushes in hard.', 45);
+ expect(truncated).toBe(true);
+ expect(text).toBe('Wide shot of a rain-slick alley.');
+ });
+
+ it('falls back to a word boundary rather than cutting mid-word', () => {
+ // No sentence end deep enough in the allowance — a mid-word cut would
+ // change what the tail asks the renderer for.
+ expect(clampToCharLimit('alpha bravo charlie delta', 14)).toEqual({ text: 'alpha bravo', truncated: true });
+ });
+});
+
describe('escapeRegExp', () => {
it('escapes every RegExp metacharacter and nothing else', () => {
expect(escapeRegExp('a.c')).toBe('a\\.c');
diff --git a/server/routes/mediaJobs.js b/server/routes/mediaJobs.js
index 026dd09bed..0b05ba1c6d 100644
--- a/server/routes/mediaJobs.js
+++ b/server/routes/mediaJobs.js
@@ -53,6 +53,13 @@ const refinePromptSchema = z.object({
const v = (s ?? '').trim();
return v.length > 0 ? v : undefined;
}),
+ // Hard character cap the SELECTED render backend enforces on the prompt it
+ // receives — reactor.inc's fast-h3 rejects a prompt over 800 characters
+ // outright instead of truncating it, so an enhancement that ignores the cap
+ // produces a prompt that cannot be rendered. The caller sends the budget
+ // (cap minus whatever a style preset prefixes), the refiner instructs the
+ // model with it and clamps the answer. Omitted when the backend has no cap.
+ maxPromptLength: z.number().int().positive().max(8000).optional(),
renderConfig: z.record(z.any())
.refine((obj) => {
// JSON.stringify throws on BigInt / circular refs. z.record(z.any())
@@ -89,6 +96,11 @@ const promptFromMediaSchema = z.object({
const v = (s ?? '').trim();
return v.length > 0 ? v : undefined;
}),
+ // Same cap as `refinePromptSchema.maxPromptLength`, but scoped to the VIDEO
+ // prompt: the caller sends it when the video backend it is composing for
+ // rejects an over-length prompt (reactor.inc fast-h3). The image prompt has
+ // no equivalent cap on any current backend.
+ maxVideoPromptLength: z.number().int().positive().max(8000).optional(),
}).superRefine((data, ctx) => {
// A gallery video resolves by history id (the gallery flow) OR by on-disk
// filename (a mood-board video item's `video:` ref — #4188).
diff --git a/server/services/mediaPromptFromMedia.js b/server/services/mediaPromptFromMedia.js
index ad9783687d..c67b7da7ef 100644
--- a/server/services/mediaPromptFromMedia.js
+++ b/server/services/mediaPromptFromMedia.js
@@ -18,6 +18,7 @@ import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { ServerError } from '../lib/errorHandler.js';
+import { clampToCharLimit } from '../lib/textUtils.js';
import { extractJson } from '../lib/jsonExtract.js';
import { PATHS, resolveGalleryImage } from '../lib/fileUtils.js';
import { extractEvaluationFrames, safeUnder } from '../lib/ffmpeg.js';
@@ -54,7 +55,7 @@ export const PROMPT_FROM_MEDIA_TARGETS = Object.freeze(['image', 'video']);
* must fill; `mediaKind` + `frameCount` tell it whether it's looking at a
* still or a chronological frame set.
*/
-export function buildPromptFromMediaPrompt({ targets, mediaKind, frameCount }) {
+export function buildPromptFromMediaPrompt({ targets, mediaKind, frameCount, maxVideoPromptLength }) {
const wantImage = targets.includes('image');
const wantVideo = targets.includes('video');
const lookingAt = mediaKind === 'video'
@@ -89,6 +90,13 @@ export function buildPromptFromMediaPrompt({ targets, mediaKind, frameCount }) {
}
if (wantVideo) {
rules.push('- videoPrompt: a moving-image prompt. Include subject action, camera move, pacing, and how light or atmosphere changes across the clip.');
+ // The video backend the caller is aiming at may CAP its prompt and reject
+ // anything longer outright (reactor.inc fast-h3: 800 characters), so a
+ // richer description than the cap allows is unusable rather than merely
+ // long. The clamp on the way out is the backstop.
+ if (Number.isFinite(maxVideoPromptLength) && maxVideoPromptLength > 0) {
+ rules.push(`- videoPrompt must be AT MOST ${maxVideoPromptLength} characters (characters, not words) — the target renderer REJECTS a longer prompt instead of trimming it. Spend the budget on the highest-value visible detail and motion; drop filler.`);
+ }
if (mediaKind === 'video' && frameCount > 1) {
rules.push('- The frames are chronological. Infer motion from what changes between them; do not describe each frame separately.');
}
@@ -300,6 +308,7 @@ export async function promptFromMedia({
providerId,
model,
effort,
+ maxVideoPromptLength,
}) {
const wanted = [...new Set((Array.isArray(targets) ? targets : []).filter((t) => PROMPT_FROM_MEDIA_TARGETS.includes(t)))];
if (!wanted.length) {
@@ -324,6 +333,7 @@ export async function promptFromMedia({
targets: wanted,
mediaKind,
frameCount: screenshots.length,
+ maxVideoPromptLength,
});
const { text, model: ranModel, ranProvider } = await runVision({
@@ -343,8 +353,15 @@ export async function promptFromMedia({
throw new ServerError(e.message, { status: 502, code: 'PROMPT_FROM_MEDIA_BAD_JSON' });
}
+ // Same contract as the prompt refiner: instruct, then clamp, then SAY the
+ // prompt was cut — a silent trim reads as the model losing detail.
+ const { text: videoPrompt, truncated: videoPromptTruncated } = clampToCharLimit(
+ parsed.videoPrompt, maxVideoPromptLength,
+ );
+
return {
...parsed,
+ ...(parsed.videoPrompt ? { videoPrompt, videoPromptTruncated } : {}),
mediaKind,
frameCount: screenshots.length,
targets: wanted,
diff --git a/server/services/mediaPromptFromMedia.test.js b/server/services/mediaPromptFromMedia.test.js
index e3ee98fa89..d633ce048f 100644
--- a/server/services/mediaPromptFromMedia.test.js
+++ b/server/services/mediaPromptFromMedia.test.js
@@ -113,6 +113,12 @@ describe('buildPromptFromMediaPrompt', () => {
expect(prompt).toContain('5 frames');
expect(prompt).toContain('chronological');
});
+
+ it('states the video prompt cap only when the caller has one', () => {
+ const capped = buildPromptFromMediaPrompt({ targets: ['video'], mediaKind: 'video', frameCount: 5, maxVideoPromptLength: 800 });
+ expect(capped).toContain('AT MOST 800 characters');
+ expect(buildPromptFromMediaPrompt({ targets: ['video'], mediaKind: 'video', frameCount: 5 })).not.toContain('AT MOST');
+ });
});
describe('parsePromptFromMediaJson', () => {
@@ -163,6 +169,36 @@ describe('promptFromMedia', () => {
expect(result.providerId).toBe('openai');
});
+ // reactor.inc rejects an over-length prompt outright, so a vivid analysis of
+ // a clip is unrenderable, not merely long, once it passes the cap.
+ it('clamps a video prompt that overshoots the caller cap and reports the trim', async () => {
+ providers.getProviderById.mockResolvedValue(API_PROVIDER);
+ promptRunner.runPromptThroughProvider.mockResolvedValue({
+ text: JSON.stringify({
+ imagePrompt: 'a painted wizard in moonlight',
+ videoPrompt: `${'The camera dollies through the rain-slick alley. '.repeat(30)}End beat.`,
+ rationale: 'Moonlit.',
+ }),
+ model: 'gpt-4o',
+ provider: API_PROVIDER,
+ });
+
+ const result = await promptFromMedia({
+ sourceKind: 'image',
+ filename: 'still.png',
+ targets: ['image', 'video'],
+ providerId: 'openai',
+ model: 'gpt-4o',
+ maxVideoPromptLength: 800,
+ });
+
+ expect(result.videoPrompt.length).toBeLessThanOrEqual(800);
+ expect(result.videoPromptTruncated).toBe(true);
+ // The cap is the VIDEO backend's; the image prompt is untouched by it.
+ expect(result.imagePrompt).toBe('a painted wizard in moonlight');
+ expect(promptRunner.runPromptThroughProvider.mock.calls[0][0].prompt).toContain('AT MOST 800 characters');
+ });
+
it('samples gallery-video frames and uses the CLI vision path for Codex', async () => {
providers.getProviderById.mockResolvedValue(CLI_PROVIDER);
visionCli.describeImagesFromPaths.mockResolvedValue({ text: JSON_BOTH });
diff --git a/server/services/mediaPromptRefiner.js b/server/services/mediaPromptRefiner.js
index f5483c489d..1131748550 100644
--- a/server/services/mediaPromptRefiner.js
+++ b/server/services/mediaPromptRefiner.js
@@ -1,5 +1,6 @@
import { ServerError } from '../lib/errorHandler.js';
import { findBalancedBlocks, tryParseWithRepair } from '../lib/jsonExtract.js';
+import { clampToCharLimit } from '../lib/textUtils.js';
import { resolveEffectiveModel, runPromptThroughProvider } from './promptRunner.js';
import { getProviderById } from './providers.js';
@@ -62,8 +63,16 @@ function extractRefinementJson(raw) {
throw new Error(`Invalid JSON in AI response${lastErr ? `: ${lastErr.message}` : ''}`);
}
-export function buildMediaPromptRefinePrompt({ kind, prompt, negativePrompt, feedback, renderConfig = {} }) {
+export function buildMediaPromptRefinePrompt({ kind, prompt, negativePrompt, feedback, renderConfig = {}, maxPromptLength }) {
const kindLabel = kind === 'video' ? 'video' : 'image';
+ // Some render backends cap the prompt and REJECT anything longer instead of
+ // truncating it (reactor.inc fast-h3: 800 characters), so an unbounded
+ // "make it more vivid" enhancement reliably produces a prompt that cannot
+ // be rendered. Give the model the budget it has to write inside;
+ // `clampToCharLimit` below is the backstop for when it overshoots anyway.
+ const lengthRule = Number.isFinite(maxPromptLength) && maxPromptLength > 0
+ ? `\n\nHARD LENGTH LIMIT: the "prompt" field must be AT MOST ${maxPromptLength} characters long (characters, not words) — the renderer REJECTS a longer prompt outright rather than trimming it, so an over-length answer is unusable. Budget the space: keep the highest-value concrete visual detail, drop filler and redundant quality boosters, and stop before the limit rather than writing a longer prompt you expect to be cut. This limit applies to the "prompt" field only.`
+ : '';
if (!feedback || !feedback.trim()) {
return `You are a senior prompt engineer for generative ${kindLabel} renders.
@@ -83,7 +92,7 @@ Rules:
- Enhance visual descriptions (lighting, camera angle, atmosphere, textures, details, mood).
- Do not introduce unrelated characters, brands, or conflicting subjects unless requested.
- Keep useful existing style constraints.
-- The "prompt" field must NEVER equal the schema placeholder text — it must be the actual enhanced prompt.
+- The "prompt" field must NEVER equal the schema placeholder text — it must be the actual enhanced prompt.${lengthRule}
ORIGINAL POSITIVE PROMPT:
${prompt || '(empty)'}
@@ -115,7 +124,7 @@ Rules:
- Keep useful existing style constraints unless the user explicitly rejects them.
- Move things the user dislikes into the negative prompt when that improves control.
- If the user asks for a different style, make the positive prompt clearly say what to move toward and the negative prompt clearly say what to avoid.
-- The "prompt" field must NEVER equal the schema placeholder text — it must be the actual rewritten render prompt.
+- The "prompt" field must NEVER equal the schema placeholder text — it must be the actual rewritten render prompt.${lengthRule}
ORIGINAL POSITIVE PROMPT:
${prompt || '(empty)'}
@@ -160,6 +169,7 @@ export async function refineMediaPrompt({
model,
effort,
renderConfig = {},
+ maxPromptLength,
}) {
// Let real failures (providers.json unreadable, toolkit not initialized)
// bubble through the centralized error handler as 5xx. getProviderById
@@ -192,6 +202,7 @@ export async function refineMediaPrompt({
negativePrompt: trimString(negativePrompt),
feedback: trimString(feedback, 3000),
renderConfig,
+ maxPromptLength,
});
const { text, runId } = await runRefinePrompt(provider, selectedModel, llmPrompt, effort);
@@ -217,12 +228,23 @@ export async function refineMediaPrompt({
throw new ServerError('LLM returned an empty prompt', { status: 502, code: 'PROMPT_REFINE_EMPTY_PROMPT' });
}
+ // The model is TOLD the cap (see `lengthRule`), but a model that overshoots
+ // by a few characters would hand the user a prompt the renderer rejects
+ // outright — reactor.inc's fast-h3 refuses an over-length prompt rather than
+ // trimming it. Report the clamp rather than swallowing it, so a shortened
+ // prompt doesn't read as the model losing detail on its own.
+ const { text: boundedPrompt, truncated } = clampToCharLimit(refinedPrompt, maxPromptLength);
+ if (truncated) {
+ console.warn(`✂️ media-prompt-refine [${provider.id}/${selectedModel || 'default'}] trimmed enhanced prompt ${refinedPrompt.length}→${boundedPrompt.length} chars (limit ${maxPromptLength})`);
+ }
+
return {
- prompt: refinedPrompt,
+ prompt: boundedPrompt,
negativePrompt: trimString(parsed.negativePrompt),
rationale: trimString(parsed.rationale, MAX_REASON_LEN),
changes: cleanChanges(parsed.changes),
providerId: provider.id,
model: selectedModel,
+ truncated,
};
}
diff --git a/server/services/mediaPromptRefiner.test.js b/server/services/mediaPromptRefiner.test.js
index d1aaeabed6..1cbc6d3331 100644
--- a/server/services/mediaPromptRefiner.test.js
+++ b/server/services/mediaPromptRefiner.test.js
@@ -23,6 +23,7 @@ vi.mock('./runner.js', async (importOriginal) => {
const providers = await import('./providers.js');
const runner = await import('./runner.js');
const { buildMediaPromptRefinePrompt, refineMediaPrompt } = await import('./mediaPromptRefiner.js');
+const { REACTOR_MAX_PROMPT_LENGTH } = await import('../lib/reactorVideoClip.js');
beforeEach(() => {
vi.clearAllMocks();
@@ -389,4 +390,66 @@ ${JSON.stringify({ prompt: 'painted owl portrait', negativePrompt: 'blurry', rat
expect(result.prompt).toBe('enhanced prompt description');
});
+
+ it('tells the model the backend prompt cap and clamps an over-length answer', async () => {
+ providers.getProviderById.mockResolvedValue({
+ id: 'openai', type: 'api', enabled: true, defaultModel: 'gpt-test',
+ });
+ // A realistic failure: the model enriches a short reactor prompt into
+ // something well past fast-h3's 800-character cap, which the API rejects
+ // outright rather than trimming.
+ const overLong = `${'A neon-drenched alley in the rain. '.repeat(40)}Final beat.`;
+ expect(overLong.length).toBeGreaterThan(REACTOR_MAX_PROMPT_LENGTH);
+ mockRunnerSuccess(runner.executeApiRun, JSON.stringify({
+ prompt: overLong,
+ negativePrompt: 'blurry',
+ rationale: 'Added lighting.',
+ changes: ['Added lighting'],
+ }));
+
+ const result = await refineMediaPrompt({
+ kind: 'video',
+ prompt: 'a neon alley',
+ providerId: 'openai',
+ maxPromptLength: REACTOR_MAX_PROMPT_LENGTH,
+ });
+
+ expect(result.prompt.length).toBeLessThanOrEqual(REACTOR_MAX_PROMPT_LENGTH);
+ expect(result.truncated).toBe(true);
+
+ const sentToModel = runner.executeApiRun.mock.calls[0][0].prompt;
+ expect(sentToModel).toContain(`AT MOST ${REACTOR_MAX_PROMPT_LENGTH} characters`);
+ });
+
+ it('leaves a within-limit prompt untouched and reports no truncation', async () => {
+ providers.getProviderById.mockResolvedValue({
+ id: 'openai', type: 'api', enabled: true, defaultModel: 'gpt-test',
+ });
+ mockRunnerSuccess(runner.executeApiRun, JSON.stringify({
+ prompt: 'a neon-drenched alley in the rain, handheld camera, sodium streetlights',
+ negativePrompt: '',
+ rationale: 'Added lighting.',
+ changes: [],
+ }));
+
+ const result = await refineMediaPrompt({
+ kind: 'video',
+ prompt: 'a neon alley',
+ providerId: 'openai',
+ maxPromptLength: 800,
+ });
+
+ expect(result.prompt).toBe('a neon-drenched alley in the rain, handheld camera, sodium streetlights');
+ expect(result.truncated).toBe(false);
+ });
+
+ it('omits the length rule when the backend has no prompt cap', () => {
+ const prompt = buildMediaPromptRefinePrompt({
+ kind: 'video',
+ prompt: 'a futuristic city at night',
+ feedback: 'more rain',
+ });
+
+ expect(prompt).not.toContain('HARD LENGTH LIMIT');
+ });
});