Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion client/src/components/media/PromptEnhancer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -89,14 +95,21 @@ export default function PromptEnhancer({
model: selectedModel || undefined,
effort: effort || undefined,
renderConfig,
maxPromptLength: maxPromptLength > 0 ? maxPromptLength : undefined,
});

if (result?.prompt) {
setPrompt(result.prompt);
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
Expand Down Expand Up @@ -150,6 +163,12 @@ export default function PromptEnhancer({
<span>AI Prompt Enhancer Settings</span>
</div>

{maxPromptLength > 0 && (
<p className="text-[11px] text-gray-500 leading-snug">
The enhanced prompt will be kept within {maxPromptLength} characters — this render backend rejects a longer prompt.
</p>
)}

<ProviderModelSelector
providers={providers}
selectedProviderId={selectedProviderId}
Expand Down
37 changes: 37 additions & 0 deletions client/src/components/media/PromptEnhancer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import PromptEnhancer from './PromptEnhancer';
import * as api from '../../services/api';
import toast from '../ui/Toast';

vi.mock('../../hooks/useProviderModels', () => ({
default: vi.fn(() => ({
Expand All @@ -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();
Expand Down Expand Up @@ -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(
<PromptEnhancer
kind="video"
prompt="a simple cat"
setPrompt={setPrompt}
maxPromptLength={785}
/>
);

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(
<PromptEnhancer
Expand Down
12 changes: 11 additions & 1 deletion client/src/components/media/PromptFromMedia.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export default function PromptFromMedia({
setPrompt,
setNegativePrompt,
applyKind,
// Hard character cap the video backend the host is composing for enforces
// (reactor.inc fast-h3: 800). Forwarded so the generated video prompt is
// written inside the budget rather than coming back unrenderable.
maxVideoPromptLength,
initialSource = null,
disabled = false,
alwaysOpen = false,
Expand Down Expand Up @@ -162,12 +166,18 @@ export default function PromptFromMedia({
providerId: selectedProviderId,
model: selectedModel || undefined,
effort: effort || undefined,
maxVideoPromptLength: maxVideoPromptLength > 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);
};

Expand Down
15 changes: 15 additions & 0 deletions client/src/pages/VideoGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -1871,6 +1885,7 @@ export default function VideoGen() {
applyKind="video"
setPrompt={setPrompt}
setNegativePrompt={negativePromptSupported ? setNegativePrompt : undefined}
maxVideoPromptLength={enhancePromptBudget}
alwaysOpen
/>
</div>
Expand Down
17 changes: 17 additions & 0 deletions client/src/pages/VideoGen.reactor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 10 additions & 2 deletions client/src/test/videoGenPageMocks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="prompt-enhancer" data-disabled={disabled ? '1' : '0'}>Enhance with AI</div>
default: ({ disabled, maxPromptLength }) => (
<div
data-testid="prompt-enhancer"
data-disabled={disabled ? '1' : '0'}
// The backend's prompt cap, net of the style prefix — the enhancer has to
// write inside it or the render is rejected for a prompt the user never typed.
data-max-prompt-length={maxPromptLength ?? ''}
>
Enhance with AI
</div>
),
}));
vi.mock('../components/media/PromptFromMedia', () => ({
Expand Down
2 changes: 1 addition & 1 deletion server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<model>` 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:<model>` 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. |
Expand Down
36 changes: 36 additions & 0 deletions server/lib/textUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
22 changes: 21 additions & 1 deletion server/lib/textUtils.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions server/routes/mediaJobs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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:<filename>` ref — #4188).
Expand Down
Loading