From 48d5d0ff9ec728b64c4c06fe6597e296ec6d449a Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 6 Sep 2026 01:40:15 +0000 Subject: [PATCH] feat(media): keep AI-enhanced prompts inside the render backend's character cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reactor.inc's fast-h3 rejects a prompt over 800 characters outright rather than truncating it, so "Enhance with AI" reliably produced a richer prompt the render then refused — and the same for "Prompt from media", which writes into the same field. Both AI paths now receive the backend's budget, state it in the LLM prompt as a hard limit, and clamp the answer on a sentence or word boundary if the model overshoots anyway. The clamp is reported rather than silent, so a trimmed prompt reads as "trimmed to fit the render limit" instead of the AI losing detail on its own. The budget VideoGen sends is the cap MINUS the style-preset prefix, since that prefix is part of what PortOS submits — enhancing to exactly 800 characters would still be rejected once the preset is prepended. Adds clampToCharLimit() to server/lib/textUtils.js: unlike the existing marker-appending helpers (clampText, truncateForTelegram), it appends nothing, because a marker would count against the same renderer cap. --- .../src/components/media/PromptEnhancer.jsx | 21 ++++++- .../components/media/PromptEnhancer.test.jsx | 37 +++++++++++ .../src/components/media/PromptFromMedia.jsx | 12 +++- client/src/pages/VideoGen.jsx | 15 +++++ client/src/pages/VideoGen.reactor.test.jsx | 17 +++++ client/src/test/videoGenPageMocks.jsx | 12 +++- server/lib/README.md | 2 +- server/lib/textUtils.js | 36 +++++++++++ server/lib/textUtils.test.js | 22 ++++++- server/routes/mediaJobs.js | 12 ++++ server/services/mediaPromptFromMedia.js | 19 +++++- server/services/mediaPromptFromMedia.test.js | 36 +++++++++++ server/services/mediaPromptRefiner.js | 30 +++++++-- server/services/mediaPromptRefiner.test.js | 63 +++++++++++++++++++ 14 files changed, 323 insertions(+), 11 deletions(-) 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'); + }); });