Skip to content
Closed
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ configured.

- **Versioned prompt library** — immutable versions, restore-as-new-head,
duplicate, archive (never delete), full-text + tag/status filters.
- **H3 camera-motion chips** — one-click camera cues (Pan left/right, Push in,
Pull out, Tracking shot, Static shot) insert at the prompt cursor in the
generation composer, preserving surrounding text; the cue-augmented prompt is
still validated through the H3 policy before submission.
- **Pure template engine** — `{{variable}}` parsing/rendering with name
validation (letters, numbers, `_`, `.`, `-`), duplicate normalization, and
rejection of blank/unresolved variables.
Expand Down Expand Up @@ -117,6 +121,33 @@ curl -X PUT localhost:3001/api/debug/mock -H 'Content-Type: application/json' \

---

## Camera-movement preset chips (composer)

MiniMax's H3 guide recommends camera-motion cues (pan, push/pull, tracking,
static). The generation composer offers them as keyboard-reachable chips so you
don't have to remember or retype the phrasing:

- Open a prompt version → **Generate from head** to reach the composer.
- The **Prompt** card lists the chips: **Pan left, Pan right, Push in, Pull out,
Tracking shot, Static shot**. Each is a real button (`Tab` to reach, `Enter`/
`Space` to activate) with a visible focus ring and a tooltip describing the
motion.
- Activating a chip inserts its token at the current cursor (or replaces the
current selection) without disturbing the surrounding text; before you place
the cursor it appends at the end. You can then edit the prompt freely.
- While the prompt is untouched it mirrors the rendered template (filling a
variable live-updates it). The first chip insert or manual edit freezes it as
the source of truth; **Reset to rendered** re-syncs it from the variables.
- The exact text shown is what is generated, sent as a `prompt` override and
still validated through the existing H3 request policy (the 7000-character
limit, duration, ratio, and media rules) before submission.

The preset labels and inserted tokens live in one pure, tested module
(`packages/shared/src/cameraPresets.ts`) so they are not duplicated across the
UI.

---

## Using the real MiniMax H3 API

Real mode is selected by configuration and **fails visibly** when the key is
Expand Down
100 changes: 99 additions & 1 deletion packages/client/src/features/Composer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NavProvider } from '../nav.js';
import { Composer } from './Composer.js';
Expand Down Expand Up @@ -128,3 +128,101 @@ describe('Composer', () => {
);
});
});

describe('Composer camera-movement preset chips', () => {
it('renders the six presets as keyboard-reachable buttons with names', async () => {
renderComposer();
await waitFor(() => expect(screen.getByLabelText('subject')).toBeInTheDocument());

const group = screen.getByRole('group', { name: /camera movement presets/i });
const chips = within(group).getAllByRole('button');
expect(chips).toHaveLength(6);
// Each chip is a real <button> (keyboard reachable) with a meaningful name.
expect(chips.map((c) => c.textContent)).toEqual([
'Pan left',
'Pan right',
'Push in',
'Pull out',
'Tracking shot',
'Static shot',
]);
for (const chip of chips) {
expect(chip.tagName).toBe('BUTTON');
expect(chip).toHaveAttribute('type', 'button');
}
});

it('appends a camera cue at the end when the cursor has not been placed', async () => {
const user = userEvent.setup();
renderComposer();
await user.type(await screen.findByLabelText('subject'), 'a car');
await user.click(screen.getByRole('button', { name: 'Pan left' }));

const prompt = screen.getByLabelText('Rendered prompt') as HTMLTextAreaElement;
expect(prompt.value).toBe('A film of a car pan left');
});

it('inserts a camera cue at the cursor in the middle of the prompt', async () => {
renderComposer();
await userEvent.type(await screen.findByLabelText('subject'), 'a car');

const prompt = screen.getByLabelText('Rendered prompt') as HTMLTextAreaElement;
prompt.focus();
prompt.setSelectionRange(6, 6); // caret after "A film"
fireEvent.click(screen.getByRole('button', { name: 'Tracking shot' }));

expect(prompt.value).toBe('A film tracking shot of a car');
});

it('replaces the current selection with the camera cue', async () => {
renderComposer();
await userEvent.type(await screen.findByLabelText('subject'), 'a car');

const prompt = screen.getByLabelText('Rendered prompt') as HTMLTextAreaElement;
prompt.focus();
prompt.setSelectionRange(0, 6); // select "A film"
fireEvent.click(screen.getByRole('button', { name: 'Static shot' }));

expect(prompt.value).toBe('static shot of a car');
});

it('preserves surrounding text across multiple consecutive cues', async () => {
renderComposer();
await userEvent.type(await screen.findByLabelText('subject'), 'a car');

const prompt = screen.getByLabelText('Rendered prompt') as HTMLTextAreaElement;
prompt.focus();
prompt.setSelectionRange(6, 6); // after "A film"
fireEvent.click(screen.getByRole('button', { name: 'Pan left' }));
// After an insert the caret sits right after the inserted token, so a second
// cue is added directly after it without clobbering the rest of the prompt.
fireEvent.click(screen.getByRole('button', { name: 'Push in' }));

expect(prompt.value).toBe('A film pan left push in of a car');
});

it('sends the cue-augmented prompt as the generated prompt', async () => {
const user = userEvent.setup();
createGeneration.mockResolvedValue({ job: { id: 'job-1' }, reused: false });
renderComposer();
await user.type(await screen.findByLabelText('subject'), 'a car');
await user.click(screen.getByRole('button', { name: 'Push in' }));
await user.click(screen.getByRole('button', { name: /Generate video/i }));

await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1));
const payload = createGeneration.mock.calls[0]![0] as { prompt: string };
expect(payload.prompt).toBe('A film of a car push in');
});

it('reset restores the freshly rendered prompt', async () => {
renderComposer();
await userEvent.type(await screen.findByLabelText('subject'), 'a car');

fireEvent.click(screen.getByRole('button', { name: 'Pan left' }));
const prompt = screen.getByLabelText('Rendered prompt') as HTMLTextAreaElement;
expect(prompt.value).toBe('A film of a car pan left');

fireEvent.click(screen.getByRole('button', { name: /reset to rendered/i }));
expect(prompt.value).toBe('A film of a car');
});
});
152 changes: 132 additions & 20 deletions packages/client/src/features/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
/** Generation composer: render variables, pick H3 parameters, and submit a
* protected generation request. Launched from a prompt version. */
* protected generation request. Launched from a prompt version.
*
* The composer also offers H3 camera-movement preset chips (see
* `@h3/shared` `cameraPresets`). Activating a chip inserts the preset's token at
* the current prompt cursor without disturbing the surrounding text. The edited
* prompt is sent as a rendered-prompt override so it is still validated through
* the existing H3 request policy (character limit etc.) before submission. */

import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
CAMERA_PRESETS,
findMissingVariables,
H3_ADAPTIVE_RATIO,
H3_CONCRETE_RATIOS,
H3_MAX_DURATION_SECONDS,
H3_MIN_DURATION_SECONDS,
H3_RATIOS,
H3_RESOLUTION,
insertTokenAtSelection,
mediaMode,
renderTemplate,
UnresolvedVariableError,
type CameraPreset,
type ProviderName,
} from '@h3/shared';
import type { CreateGenerationRequest } from '@h3/shared';
Expand All @@ -39,6 +48,19 @@ export function Composer({
const [variables, setVariables] = useState<string[]>([]);
const [values, setValues] = useState<Record<string, string>>({});

// Editable rendered prompt. While the user has not touched it, it mirrors the
// rendered template (so filling a variable live-updates it). Once a camera cue
// is inserted or the text is hand-edited, it is frozen as the source of truth
// so surrounding edits and inserted tokens are preserved.
const [promptOverride, setPromptOverride] = useState('');
const [promptTouched, setPromptTouched] = useState(false);
const promptRef = useRef<HTMLTextAreaElement | null>(null);
// True once the user has focused/placed the cursor in the prompt; before that,
// a chip appends at the end rather than at an uninitialized (0) cursor.
const promptInteractedRef = useRef(false);
// Restored to the DOM after a chip insert so the caret lands after the token.
const pendingSelectionRef = useRef<{ start: number; end: number } | null>(null);

const [duration, setDuration] = useState(6);
const [aspectRatio, setAspectRatio] = useState<string>(H3_CONCRETE_RATIOS[0]);
const [firstFrame, setFirstFrame] = useState('');
Expand Down Expand Up @@ -87,16 +109,38 @@ export function Composer({
[content, values],
);

const preview = useMemo(() => {
// Rendered template (variables substituted). Shown editable in the UI and sent
// as the prompt override. Separated from `missing` so a render error (e.g. an
// unresolved variable) does not also disable other derived state.
const rendered = useMemo<{ text: string | null; error: string | null }>(() => {
try {
return renderTemplate(content, values);
return { text: renderTemplate(content, values), error: null };
} catch (e) {
return e instanceof UnresolvedVariableError
? `Missing variable: ${e.variable}`
: 'Preview unavailable.';
const msg =
e instanceof UnresolvedVariableError
? `Missing variable: ${e.variable}`
: 'Preview unavailable.';
return { text: null, error: msg };
}
}, [content, values]);

// The prompt the user actually sees and submits. Untouched → live render;
// touched → the frozen, possibly cue-augmented override.
const effectivePrompt = promptTouched ? promptOverride : (rendered.text ?? '');

// Restore the caret to the end of an inserted token once the new value is in
// the DOM. Only acts immediately after a chip insert (pendingSelection set).
useEffect(() => {
const el = promptRef.current;
const sel = pendingSelectionRef.current;
if (el && sel) {
el.selectionStart = sel.start;
el.selectionEnd = sel.end;
pendingSelectionRef.current = null;
el.focus();
}
}, [effectivePrompt]);

// Conditional H3 ratio behavior, exposed honestly in the UI:
// - text-to-video requires a concrete ratio;
// - first/last-frame mode is adaptive;
Expand All @@ -121,13 +165,36 @@ export function Composer({

const canSubmit = missing.length === 0 && !submitting;

function insertCameraPreset(preset: CameraPreset) {
const el = promptRef.current;
const text = effectivePrompt;
// Before the user has placed the cursor, append at the end (intuitive
// default) instead of an uninitialized 0 cursor.
const fallback = text.length;
const start = el && promptInteractedRef.current ? el.selectionStart : fallback;
const end = el && promptInteractedRef.current ? el.selectionEnd : fallback;
const result = insertTokenAtSelection(text, start, end, preset.token);
pendingSelectionRef.current = { start: result.selectionStart, end: result.selectionEnd };
setPromptOverride(result.text);
setPromptTouched(true);
}
Comment on lines +168 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not freeze an empty prompt override before template rendering succeeds.

If a user selects a preset before filling subject, effectivePrompt is ''. Lines 176-179 then freeze only the camera token. After the user fills subject, submission sends that token and discards the rendered template.

  • packages/client/src/features/Composer.tsx#L168-L180: If rendered.text is null, do not set promptOverride or promptTouched.
  • packages/client/src/features/Composer.tsx#L306-L346: Disable preset buttons and prompt editing until rendered.text exists.
  • packages/client/src/features/Composer.test.tsx#L155-L228: Add a regression test that selects a preset before filling subject, then verifies the final prompt retains A film of a car.
📍 Affects 2 files
  • packages/client/src/features/Composer.tsx#L168-L180 (this comment)
  • packages/client/src/features/Composer.tsx#L306-L346
  • packages/client/src/features/Composer.test.tsx#L155-L228
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/features/Composer.tsx` around lines 168 - 180, In
packages/client/src/features/Composer.tsx lines 168-180, update
insertCameraPreset so it returns without setting promptOverride or promptTouched
when rendered.text is null; in lines 306-346, disable preset buttons and prompt
editing until rendered.text exists. In
packages/client/src/features/Composer.test.tsx lines 155-228, add a regression
test covering preset selection before subject entry and verify the submitted
prompt retains “A film of a car”.


function resetPrompt() {
setPromptTouched(false);
setPromptOverride('');
promptInteractedRef.current = false;
}

async function submit() {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
const body: CreateGenerationRequest = {
promptVersionId: versionId,
values,
// The rendered prompt (with any inserted camera cues) is the exact text
// generated. The server still enforces the H3 character limit on it.
prompt: effectivePrompt,
durationSeconds: duration,
aspectRatio: effectiveRatio as CreateGenerationRequest['aspectRatio'],
resolution: H3_RESOLUTION,
Expand Down Expand Up @@ -220,20 +287,65 @@ export function Composer({
</div>

<div className="card">
<div className="section-title">Rendered prompt</div>
<pre
className="mono"
style={{
whiteSpace: 'pre-wrap',
margin: 0,
background: 'var(--bg-elev)',
padding: 12,
borderRadius: 8,
border: '1px solid var(--border)',
}}
<div className="row between">
<div className="section-title" style={{ margin: 0 }}>
Prompt
</div>
{promptTouched ? (
<button
type="button"
className="btn ghost sm"
onClick={resetPrompt}
title="Replace the prompt with the freshly rendered template"
>
Reset to rendered
</button>
) : null}
</div>

<div
className="chips"
role="group"
aria-label="Camera movement presets"
>
{preview}
</pre>
{CAMERA_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
className="chip"
onClick={() => insertCameraPreset(preset)}
title={preset.description}
>
{preset.label}
</button>
))}
</div>

<Field
label="Rendered prompt"
htmlFor="c-prompt"
hint="Camera cues insert at the cursor. This exact text is generated and still validated before submission."
>
<textarea
id="c-prompt"
ref={promptRef}
value={effectivePrompt}
placeholder="Fill in the variables to render the prompt…"
onChange={(e) => {
setPromptOverride(e.target.value);
setPromptTouched(true);
promptInteractedRef.current = true;
}}
onFocus={() => {
promptInteractedRef.current = true;
}}
onSelect={() => {
promptInteractedRef.current = true;
}}
style={{ minHeight: 150 }}
/>
</Field>
{rendered.error ? <ErrorBanner message={rendered.error} /> : null}
</div>
</div>

Expand Down
Loading