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
230 changes: 140 additions & 90 deletions client/src/components/creative-commission/commissionForm.js

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions client/src/components/creative-commission/commissionForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ABILITY_OPTIONS, GENERATION_FIELDS_BY_ABILITY, GENERATION_DEFAULTS_BY_ABILITY,
generationToForm, mergeGenerationForAbility, generationToPayload,
backendFieldsForAbility, RENDER_BACKEND_AUTO,
IMAGE_BACKEND_OPTIONS, VIDEO_BACKEND_OPTIONS,
} from './commissionForm.js';

describe('commissionForm helpers', () => {
Expand Down Expand Up @@ -198,6 +199,17 @@ describe('commissionForm helpers', () => {
expect(mergeGenerationForAbility('music', { imageCount: 4 })).toEqual({ lengthSeconds: 30 });
});

it('mergeGenerationForAbility seeds a key the switch introduces with the FRESH default, not the legacy reading', () => {
// image → video: durationMode never existed on this generation, so it
// seeds #4494's 'auto' (what a blank video form gets). Only a REAL record
// with the key missing reads as legacy 'manual' — that is generationToForm's
// contract, and the two must not collapse into one another.
expect(mergeGenerationForAbility('video', { quality: 'high', aspectRatio: '9:16', imageCount: 2 }).durationMode).toBe('auto');
expect(generationToForm('video', { quality: 'high', aspectRatio: '9:16' }).durationMode).toBe('manual');
// A value already present survives the switch untouched.
expect(mergeGenerationForAbility('music-video', { durationMode: 'manual', targetDurationSeconds: 20 }).durationMode).toBe('manual');
});

it('generationToPayload emits only the ability keys and coerces numbers', () => {
// number inputs arrive as strings from the DOM.
expect(generationToPayload('image', { quality: 'standard', aspectRatio: '1:1', imageCount: '3' }))
Expand All @@ -213,6 +225,32 @@ describe('commissionForm helpers', () => {
expect(toForm({ targetAbility: 'video', generation: { targetDurationSeconds: 20 } }).generation.durationMode).toBe('manual');
});

it('generationToForm itself resolves an absent durationMode to its legacy reading', () => {
// Distinct from the toForm-level case above: this pins generationToForm's
// OWN contract (used directly by mergeGenerationForAbility on an ability
// switch too) — a regression that made it fall back to
// GENERATION_DEFAULTS_BY_ABILITY's plain 'auto' instead of the spec's
// legacyAbsent 'manual' would slip past a toForm-only test.
expect(generationToForm('video', {}).durationMode).toBe('manual');
expect(generationToForm('music-video', { quality: 'high' }).durationMode).toBe('manual');
// A key with no `legacyAbsent` (aspectRatio) is unaffected: it still falls
// back to the plain default either way.
expect(generationToForm('video', {}).aspectRatio).toBe('16:9');
});

it('treats a SAVED record with a missing generation object as legacy, not fresh', () => {
// A record with an `id` has already been persisted — it is never the
// blank/new-commission case, even if its `generation` object happens to
// be absent or null (which a real API response never actually sends,
// but toForm() must not silently mis-seed one that did).
expect(toForm({ id: 'c-1', targetAbility: 'video' }).generation.durationMode).toBe('manual');
expect(toForm({ id: 'c-1', targetAbility: 'video', generation: null }).generation.durationMode).toBe('manual');
// The blank/new case is unaffected: no id AND no generation still seeds
// the fresh default, including when other fields (a pre-filled brief)
// are already present.
expect(toForm({ brief: { intent: 'x' } }).generation.durationMode).toBe('auto');
});

it('toPayload round-trips a non-video commission', () => {
const form = toForm({ name: 'Daily Stills', targetAbility: 'image', brief: { intent: 'x' }, generation: { imageCount: 2 } });
const payload = toPayload(form);
Expand Down Expand Up @@ -267,6 +305,25 @@ describe('commissionForm helpers', () => {
expect(f.generation.imageModelId).toBe('example-model');
});

it('round-trips a record pinned to a backend the form used to have no option for (#6816)', () => {
// agy (image) and fal/reactor (video) all post-date the hand-copied
// IMAGE_BACKEND_OPTIONS/VIDEO_BACKEND_OPTIONS arrays this form used to
// carry; a record already pinned to one rendered a <select> with no
// matching option. Deriving the options from the server enum fixes this
// for every mode at once, not just these three by name.
const image = toForm({ targetAbility: 'image', brief: { intent: 'x' }, generation: { imageMode: 'agy' } });
expect(image.generation.imageMode).toBe('agy');
expect(IMAGE_BACKEND_OPTIONS.some(([v]) => v === 'agy')).toBe(true);
expect(toPayload(image).generation.imageMode).toBe('agy');

for (const videoMode of ['fal', 'reactor']) {
const video = toForm({ targetAbility: 'video', brief: { intent: 'x' }, generation: { videoMode } });
expect(video.generation.videoMode).toBe(videoMode);
expect(VIDEO_BACKEND_OPTIONS.some(([v]) => v === videoMode)).toBe(true);
expect(toPayload(video).generation.videoMode).toBe(videoMode);
}
});

it('sends the model id for a model-bearing backend', () => {
const payload = generationToPayload('image', {
quality: 'standard', aspectRatio: '16:9', imageCount: 1, imageMode: 'local', imageModelId: 'example-model',
Expand Down
3 changes: 2 additions & 1 deletion server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `catalogValidation.js` | Creative ingredients catalog route schemas (scraps, ingredients, links, relations, tags, revisions, sync envelope). |
| `cosValidation.js` | Chief-of-Staff task/job/loop/learning schemas, the Code-Review settings slice, and the task-metadata sanitizer. `createCosTaskSchema`'s metadata-bound fields are carried onto the task by `services/cosTaskIntake.js` (parity-checked by its test). The Review-Loop reviewer vocabulary lives in `reviewerConfig.js` (#5702) and is re-exported flat from here. |
| `creativeBriefLimits.js` | `COMMISSION_NAME_MAX` / `COMMISSION_INTENT_MAX` / `COMMISSION_STYLE_SPEC_MAX` / `COMMISSION_BRIEF_TAG_MAX` and `CREATIVE_DIRECTOR_GOAL_MAX` — the creative brief caps `creativeCommissionValidation.js` and `creativeDirectorValidation.js` enforce and the browser forms use as a silent input `maxLength`. A pure leaf (the schemas pull `zod`) so `client/src/components/creative-commission/commissionForm.js` and `client/src/lib/creativeDirectorPlan.js` re-export one definition instead of copying the numbers. |
| `creativeCommissionValidation.js` | Creative Commission (Autonomous Creation Engine) create/update + brief/schedule/generation schemas. The brief field caps it enforces come from `creativeBriefLimits.js`. |
| `creativeCommissionSpec.js` | `GENERATION_KEY_DEFS` / `ABILITY_GENERATION_SPEC` / `resolvedDefault` and the quality / aspect-ratio / image-mode / video-mode enums — the generation-param spec `creativeCommissionValidation.js` builds its Zod schema from and the ability adapters sanitize against. A pure leaf (like `creativeBriefLimits.js`) so `client/src/components/creative-commission/commissionForm.js` derives its fields/bounds/defaults from it directly instead of hand-copying them (#6816). |
| `creativeCommissionValidation.js` | Creative Commission (Autonomous Creation Engine) create/update + brief/schedule/generation schemas. The brief field caps it enforces come from `creativeBriefLimits.js`; the generation-param spec comes from `creativeCommissionSpec.js`. |
| `creativeDirectorValidation.js` | Creative Director project/treatment/scene, inert Video draft preferences, and Create-Suite importer schemas. The directive `goal` cap it enforces comes from `creativeBriefLimits.js`. |
| `digitalTwinValidation.js` | Digital twin document/category schemas. |
| `eidoverseValidation.js` | Eidoverse world-projection schemas — the `EIDOVERSE_PROJECTION_SOURCE_KEYS` allowlist, the V1/V2 projection recipe union (includes/assets/terrain/scale/districts/environment, with the `..`-escape guard on every asset path), and the world augment/say/config-patch route bodies (incl. the 8KB augment-argument cap). Split out of `validation.js` (#5698), which re-exports it. |
Expand Down
132 changes: 132 additions & 0 deletions server/lib/creativeCommissionSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Creative Commission generation spec — a pure, dependency-free leaf (#6816).
*
* The per-generation-key descriptors (type/bounds/values/default) and their
* per-ability grouping used to live inside `creativeCommissionValidation.js`,
* which pulls `zod` — so the browser form could only mirror them by hand.
* That hand-copy already drifted three ways before this split: the
* `durationMode` default (client `'auto'` vs server `'manual'`), the video
* backend enum (client missing fal/reactor, #6213/#6214), and the image
* backend enum (client missing agy, #4901). Splitting the DATA out from the
* Zod SCHEMA BUILDER lets the server schema, the ability adapters, and the
* client form all derive from this one object — the way `creativeBriefLimits.js`
* already does for the brief field caps.
*
* Zod-free and Node-builtin-free by contract, like `creativeBriefLimits.js`:
* `commissionForm.js` imports this module directly, so anything added here
* must stay importable by the browser bundle.
*/

import { QUEUEABLE_IMAGE_MODES, VIDEO_GEN_MODES } from './generationModes.js';
import { RENDER_TARGET_BACKEND_AUTO } from './renderTargets.js';

export const CREATIVE_COMMISSION_QUALITIES = Object.freeze(['draft', 'standard', 'high']);
export const CREATIVE_COMMISSION_ASPECT_RATIOS = Object.freeze(['16:9', '9:16', '1:1']);

// `AUTO` is the default and the no-op: it means "resolve at fire time the way
// this install already would" (settings.imageGen.mode / the local video
// default), so an existing commission that never sets the field behaves
// exactly as before. Re-exported from the render-target leaf (#3231) so the
// two "no pin" protocol values can never diverge.
export const COMMISSION_RENDER_BACKEND_AUTO = RENDER_TARGET_BACKEND_AUTO;

// Image backends a commission may pin: the queueable image modes (local /
// codex / grok / agy — `external` never queues) plus the auto sentinel.
// Derived from QUEUEABLE_IMAGE_MODES so a new backend needs no edit here.
export const CREATIVE_COMMISSION_IMAGE_MODES = Object.freeze([
COMMISSION_RENDER_BACKEND_AUTO, ...QUEUEABLE_IMAGE_MODES,
]);

// Video backends a commission may pin: local (MLX runtimes), grok, fal, or
// reactor, plus auto. Derived from VIDEO_GEN_MODES so a new backend needs no
// edit here — this enum is exactly what drifted client-side when fal (#6213)
// and reactor (#6214) shipped with no matching form edit.
export const CREATIVE_COMMISSION_VIDEO_MODES = Object.freeze([
COMMISSION_RENDER_BACKEND_AUTO, ...VIDEO_GEN_MODES,
]);

// A model id is a free string (the media-models registry is user-editable, so
// an enum here would reject a legitimately-installed model). Bounded like the
// existing `generation.model`.
export const COMMISSION_RENDER_MODEL_MAX = 64;

// Per-KEY generation descriptor — the SINGLE SOURCE OF TRUTH for a generation
// param's type, bounds, and default, consumed by the server's Zod schema
// (`creativeCommissionValidation.js`), the ability adapters' data-driven
// sanitizer (`services/creativeCommissions/abilityAdapters.js`), AND the
// client form (`commissionForm.js`) — no more hand-copying any of it.
//
// `default` seeds a BRAND-NEW commission/form (a product/UX choice). A
// descriptor may additionally carry `legacyAbsent`: what an EXISTING stored
// record with the key missing ENTIRELY means, when that differs from
// `default`. `durationMode` is the one key where they diverge — the form
// seeds a fresh commission to `'auto'` (#4494), but a record written before
// this key existed always rendered a fixed length, so an absent key on a real
// record must still resolve to `'manual'`. Use `resolvedDefault()` below
// wherever an ABSENT OR INVALID stored value needs a fallback; reserve a bare
// `.default` read for seeding a genuinely new/blank record.
//
// `type: 'id'` is a nullable free-string model id: absent/blank normalizes to
// `null` (= "the install's default model"), which is why its `default` is
// null rather than a string. Distinct from the `enum`/`int` numeric-or-member
// kinds so the Zod builder and the adapter coercion both stay data-driven.
export const GENERATION_KEY_DEFS = Object.freeze({
quality: { type: 'enum', values: CREATIVE_COMMISSION_QUALITIES, default: 'standard' },
aspectRatio: { type: 'enum', values: CREATIVE_COMMISSION_ASPECT_RATIOS, default: '16:9' },
targetDurationSeconds: { type: 'int', min: 5, max: 600, default: 10 },
durationMode: {
type: 'enum', values: ['auto', 'manual'], default: 'auto', legacyAbsent: 'manual',
},
imageCount: { type: 'int', min: 1, max: 6, default: 1 },
lengthSeconds: { type: 'int', min: 5, max: 600, default: 30 },
episodeCount: { type: 'int', min: 1, max: 6, default: 1 },
// Render-backend pin (#3135) — `auto` = no pin (today's behavior).
imageMode: { type: 'enum', values: CREATIVE_COMMISSION_IMAGE_MODES, default: COMMISSION_RENDER_BACKEND_AUTO },
videoMode: { type: 'enum', values: CREATIVE_COMMISSION_VIDEO_MODES, default: COMMISSION_RENDER_BACKEND_AUTO },
// Optional model id, only meaningful when the matching mode is pinned to a
// backend that HAS a model knob (local diffusion / local video runtimes; the
// cloud CLIs pick their own model). null = the install default.
imageModelId: { type: 'id', max: COMMISSION_RENDER_MODEL_MAX, default: null },
videoModelId: { type: 'id', max: COMMISSION_RENDER_MODEL_MAX, default: null },
});

// The fallback for an ABSENT or INVALID stored value: a descriptor's
// `legacyAbsent` when it has one (a compatibility reading only `durationMode`
// needs today), else its `default`. Every store-side coercion (the ability
// adapters' sanitizer) and every client-side projection of a REAL record must
// resolve an absent/invalid value through this — never `.default` directly —
// so the two readings can't drift back apart. Seeding a brand-new blank form
// is the one exception: it reads `.default` directly, because there is no
// record yet for `legacyAbsent` to describe.
export function resolvedDefault(def) {
return def.legacyAbsent ?? def.default;
}

// Which keys each output type carries (the universal `model` is added
// separately by the schema — every type accepts an optional engine/model
// override). The backend pins (#3135) are scoped to the abilities that
// actually enqueue that kind of render: `imageMode` on `image`, `videoMode` on
// `video`, and BOTH on `music-video` (its plan renders a video, and the
// planner may render stills for it too). `music` and `series` carry neither —
// a series' per-issue renders are pinned on the pipeline series/stage
// records, not here.
const ABILITY_GENERATION_KEYS = Object.freeze({
video: ['quality', 'aspectRatio', 'targetDurationSeconds', 'durationMode', 'videoMode', 'videoModelId'],
image: ['quality', 'aspectRatio', 'imageCount', 'imageMode', 'imageModelId'],
music: ['lengthSeconds'],
'music-video': ['quality', 'aspectRatio', 'targetDurationSeconds', 'durationMode', 'videoMode', 'videoModelId', 'imageMode', 'imageModelId'],
series: ['episodeCount'],
});

// Derived per-ability { keys, defaults } view — the shape the store sanitizer,
// the client form, and tests consume. `defaults` is the LEGACY-AWARE
// resolution (`resolvedDefault`, not a bare `.default`): it's what an
// ability's sanitizer resolves an absent/invalid raw generation object to,
// and what the client projects a real (possibly pre-existing) record's
// missing key as.
export const ABILITY_GENERATION_SPEC = Object.freeze(
Object.fromEntries(Object.entries(ABILITY_GENERATION_KEYS).map(([ability, keys]) => [ability, {
keys,
defaults: Object.fromEntries(keys.map((k) => [k, resolvedDefault(GENERATION_KEY_DEFS[k])])),
}])),
);
Loading