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
42 changes: 42 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,44 @@ 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.
- Chips are **disabled until every variable is filled** (a cue inserted into an
unresolved prompt would freeze it to only the camera token). Each chip is also
keyboard reachable with a visible focus ring and an accessible description of
the motion it inserts.
- 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.
- If you change a variable *after* the prompt was frozen, generation is blocked
with a clear message because the frozen text would no longer match the recorded
values. **Reset to rendered** re-syncs the prompt to the current values (then
re-apply any camera cues) to submit a consistent prompt.
- 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. Server-side, the
immutable version is *also* validated with the supplied `values` even when an
override is present, so an unresolved variable or template error always fails
before any job or provider call.

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
174 changes: 173 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,175 @@ 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');
});

it('disables preset chips while template variables are unresolved', 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);
// A chip clicked before variables are filled would freeze the prompt to only
// the camera token, so the chips must be unusable until variables resolve.
for (const chip of chips) {
expect(chip).toBeDisabled();
}

// Filling the variable re-enables every chip.
await userEvent.type(screen.getByLabelText('subject'), 'a car');
await waitFor(() => {
for (const chip of within(group).getAllByRole('button')) {
expect(chip).toBeEnabled();
}
});
});

it('exposes each preset description through aria-describedby', 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');
// Every chip points at a real description element (not a title-only tooltip).
for (const chip of chips) {
const describedBy = chip.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
const description = document.getElementById(describedBy!);
expect(description?.textContent?.trim().length).toBeGreaterThan(0);
}
});

it('blocks a stale prompt (variable changed after a cue) until reset re-syncs it', async () => {
const user = userEvent.setup();
createGeneration.mockResolvedValue({ job: { id: 'job-stale' }, reused: false });
renderComposer();
await user.type(await screen.findByLabelText('subject'), 'a car');

// Insert a cue → freezes the override against the current values.
await user.click(screen.getByRole('button', { name: 'Pan left' }));
const submit = screen.getByRole('button', { name: /Generate video/i });
expect(submit).toBeEnabled();
expect(screen.getByLabelText('Rendered prompt')).toHaveValue('A film of a car pan left');

// Change the variable → the frozen prompt text is now stale.
const subject = screen.getByLabelText('subject');
await user.clear(subject);
await user.type(subject, 'a dog');
await waitFor(() => expect(submit).toBeDisabled());
expect(
screen.getByText(/no longer matches the recorded values/i),
).toBeInTheDocument();

// Reset re-syncs the prompt to the current variable values.
await user.click(screen.getByRole('button', { name: /reset to rendered/i }));
await waitFor(() => expect(submit).toBeEnabled());
expect(screen.getByLabelText('Rendered prompt')).toHaveValue('A film of a dog');

// The submitted prompt and recorded values are now consistent.
await user.click(submit);
await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1));
const payload = createGeneration.mock.calls[0]![0] as {
prompt: string;
values: Record<string, string>;
};
expect(payload.prompt).toBe('A film of a dog');
expect(payload.values).toEqual({ subject: 'a dog' });
});
});
Loading