From 76b418c1f1aa40e4dac292b33ddb0d58add2ac2b Mon Sep 17 00:00:00 2001 From: AINative Admin Date: Thu, 8 Oct 2026 08:46:17 -0700 Subject: [PATCH] fix(build): independent style/mood rotation for auto-media prompts (#1018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildBrandPrompt sent one fixed, generic style phrase ("modern, clean, professional photography or illustration, high visual quality") for every company, every run — a fintech company and a children's toy company got identical style language. Subject grounding and the composition/framing variation axis (#909) were already real and working; this was the remaining gap. Adds STYLE_DESCRIPTORS, a second rotating style/mood axis analogous to VARIATION_DESCRIPTORS but independently indexed (styleIndex vs variantIndex) so a company can land on, say, composition-variant 2 + style-variant 0 rather than one combined rotation of fixed pairs. Threaded the same way lastVariant already is: MediaRoutine gains lastStyleVariant, coerceRoutine/saveRoutine persist it, and runMediaRoutines picks the next style index via the existing pickNextVariant helper and advances it forward alongside (not conflated with) the composition index. --- __tests__/lib/build/media-routine.test.ts | 40 ++++++ __tests__/lib/build/media-schedule.test.ts | 139 +++++++++++++++++++++ lib/build/media-routine.ts | 12 +- lib/build/media-schedule.ts | 79 +++++++++++- 4 files changed, 264 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/build/media-routine.test.ts b/__tests__/lib/build/media-routine.test.ts index 835e2bc6..09490b82 100644 --- a/__tests__/lib/build/media-routine.test.ts +++ b/__tests__/lib/build/media-routine.test.ts @@ -111,6 +111,46 @@ describe('runMediaRoutines', () => { expect(sentBody.prompt).not.toBe(buildBrandPrompt('image', { companyName: 'Acme' }, 0)) }) + // #1018: the nightly-loop runner must ALSO pick a next style/mood index + // (independent from lastVariant) and persist it forward as its own field — + // otherwise every recurring run still carries the same fixed style phrase + // even though composition now varies. + it('picks a different style index than lastStyleVariant and persists it forward, independently of lastVariant (#1018)', async () => { + process.env.BUILD_MEDIA_ENABLED = 'true' + const rows = [routineRow({ lastRunAt: '2026-01-01T00:00:00Z', frequency: 'daily', lastVariant: 0, lastStyleVariant: 0 })] + const fetchMock = vi.fn() + .mockResolvedValueOnce(OK({ data: rows }) as any) // listMedia + .mockResolvedValueOnce(OK({ url: 'http://x/g.png' }) as any) // generate + .mockResolvedValueOnce(OK({ ok: true }) as any) // saveAsset + .mockResolvedValueOnce(OK({ ok: true }) as any) // saveRoutine advance + vi.stubGlobal('fetch', fetchMock) + await runMediaRoutines('a::b', { companyName: 'Acme' }) + const advanceBody = JSON.parse(fetchMock.mock.calls.at(-1)![1].body) + expect(advanceBody.row_data.lastStyleVariant).not.toBe(0) + expect(typeof advanceBody.row_data.lastStyleVariant).toBe('number') + // lastVariant (composition) advanced too, but the two need not be equal — + // they are independent rotations, not one combined pairing. + expect(typeof advanceBody.row_data.lastVariant).toBe('number') + }) + + it('bakes the chosen style index into the actual generation prompt sent to core, alongside the composition variant (#1018)', async () => { + process.env.BUILD_MEDIA_ENABLED = 'true' + const rows = [routineRow({ lastRunAt: '2026-01-01T00:00:00Z', frequency: 'daily', lastVariant: 0, lastStyleVariant: 0, mediaKind: 'image' })] + const fetchMock = vi.fn() + .mockResolvedValueOnce(OK({ data: rows }) as any) + .mockResolvedValueOnce(OK({ url: 'http://x/g.png' }) as any) + .mockResolvedValueOnce(OK({ ok: true }) as any) + .mockResolvedValueOnce(OK({ ok: true }) as any) + vi.stubGlobal('fetch', fetchMock) + await runMediaRoutines('a::b', { companyName: 'Acme' }) + const generateCall = fetchMock.mock.calls[1] + const sentBody = JSON.parse(String(generateCall[1].body)) + const { buildBrandPrompt } = await import('@/lib/build/media-schedule') + // Must not match the prompt that lastVariant=0/lastStyleVariant=0 would + // have produced if neither axis actually advanced. + expect(sentBody.prompt).not.toBe(buildBrandPrompt('image', { companyName: 'Acme' }, 0, 0)) + }) + it("advances a 'once' routine to disabled after it fires", async () => { process.env.BUILD_MEDIA_ENABLED = 'true' const rows = [routineRow({ frequency: 'once', lastRunAt: undefined })] // never run → due diff --git a/__tests__/lib/build/media-schedule.test.ts b/__tests__/lib/build/media-schedule.test.ts index 47a17cf9..1b25cfaa 100644 --- a/__tests__/lib/build/media-schedule.test.ts +++ b/__tests__/lib/build/media-schedule.test.ts @@ -22,6 +22,7 @@ import { runMediaGeneration, pollVideoStatus, VARIATION_DESCRIPTORS, + STYLE_DESCRIPTORS, pickNextVariant, type MediaRoutine, type MediaAsset, @@ -234,6 +235,85 @@ describe('buildBrandPrompt', () => { expect(new Set(prompts).size).toBe(VARIATION_DESCRIPTORS.length) }) }) + + // #1018: every company's "on-brand" generated image/video shared the same + // overarching visual style because the STYLE line was one fixed, generic + // phrase for every company, every run — independent of the (already-fixed) + // composition axis and subject grounding. STYLE_DESCRIPTORS is a SECOND, + // independently-indexed rotation (styleIndex), analogous to + // VARIATION_DESCRIPTORS/variantIndex but never conflated with it. + describe('style axis (#1018 — every company must not share the same fixed style phrase)', () => { + it('exposes a small, stable set of rotating style descriptors', () => { + expect(Array.isArray(STYLE_DESCRIPTORS)).toBe(true) + expect(STYLE_DESCRIPTORS.length).toBeGreaterThanOrEqual(4) + for (const d of STYLE_DESCRIPTORS) { + expect(typeof d).toBe('string') + expect(d.length).toBeGreaterThan(5) + } + }) + + it('never sends the old fixed, generic style phrase for every company', () => { + const p = buildBrandPrompt('image', { companyName: 'Acme' }, 0, 0) + expect(p).not.toContain('modern, clean, professional photography or illustration, high visual quality') + }) + + it('the same brand + different style indices produce different prompt text', () => { + const brand = { companyName: 'Acme', idea: 'inventory bot' } + const prompts = STYLE_DESCRIPTORS.map((_, i) => buildBrandPrompt('image', brand, 0, i)) + const unique = new Set(prompts) + expect(unique.size).toBe(STYLE_DESCRIPTORS.length) + }) + + it('an out-of-range or missing style index defaults safely (never throws, never undefined)', () => { + const brand = { companyName: 'Acme' } + expect(() => buildBrandPrompt('image', brand, 0, undefined)).not.toThrow() + expect(buildBrandPrompt('image', brand, 0, undefined)).not.toContain('undefined') + expect(() => buildBrandPrompt('image', brand, 0, 999)).not.toThrow() + expect(buildBrandPrompt('image', brand, 0, 999)).not.toContain('undefined') + expect(() => buildBrandPrompt('image', brand, 0, -1)).not.toThrow() + expect(buildBrandPrompt('image', brand, 0, -1)).not.toContain('undefined') + }) + + it('preserves the real-business-grounding and no-text instructions in every style variant', () => { + const brand = { companyName: 'Beacon', idea: 'a lighthouse-as-a-service safety beacon network' } + for (let i = 0; i < STYLE_DESCRIPTORS.length; i++) { + const p = buildBrandPrompt('image', brand, 0, i) + expect(p).toContain('a lighthouse-as-a-service safety beacon network') + expect(p).toMatch(/ground every visual choice/i) + expect(p).toMatch(/not a generic, unrelated stock scene/i) + expect(p).toMatch(/do not render|no text overlays|zero on-image text/i) + } + }) + + it('still never leaks the literal "on-brand marketing asset" phrase in any style variant', () => { + for (let i = 0; i < STYLE_DESCRIPTORS.length; i++) { + const p = buildBrandPrompt('image', { companyName: 'Beacon' }, 0, i) + expect(p.toLowerCase()).not.toContain('on-brand marketing asset') + } + }) + + it('works identically for video prompts', () => { + const prompts = STYLE_DESCRIPTORS.map((_, i) => buildBrandPrompt('video', { companyName: 'Acme' }, 0, i)) + expect(new Set(prompts).size).toBe(STYLE_DESCRIPTORS.length) + }) + + it('rotates independently from the composition axis — composition-variant 2 + style-variant 0 is reachable and differs from composition-variant 0 + style-variant 0', () => { + const brand = { companyName: 'Acme', idea: 'inventory bot' } + const a = buildBrandPrompt('image', brand, 0, 0) + const b = buildBrandPrompt('image', brand, 2, 0) + const c = buildBrandPrompt('image', brand, 0, 2) + expect(a).not.toBe(b) // composition differs, style fixed + expect(a).not.toBe(c) // style differs, composition fixed + expect(b).not.toBe(c) + }) + + it('a missing styleIndex does not affect the composition axis output (back-compat with 3-arg callers)', () => { + const brand = { companyName: 'Acme' } + const threeArg = buildBrandPrompt('image', brand, 1) + const fourArgDefaultStyle = buildBrandPrompt('image', brand, 1, 0) + expect(threeArg).toBe(fourArgDefaultStyle) + }) + }) }) describe('pickNextVariant (#909)', () => { @@ -274,6 +354,20 @@ describe('pickNextVariant (#909)', () => { expect(pickNextVariant(0, 1)).toBe(0) expect(pickNextVariant(undefined, 1)).toBe(0) }) + + // #1018: pickNextVariant is reused as-is for the independent style axis — + // same helper, different list length / different persisted index. + it('works identically against STYLE_DESCRIPTORS for the independent style axis', () => { + for (let i = 0; i < 20; i++) { + const v = pickNextVariant(undefined, STYLE_DESCRIPTORS.length) + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThan(STYLE_DESCRIPTORS.length) + } + for (let prev = 0; prev < STYLE_DESCRIPTORS.length; prev++) { + const next = pickNextVariant(prev, STYLE_DESCRIPTORS.length) + expect(next).not.toBe(prev) + } + }) }) describe('buildGenerationRequest', () => { @@ -340,6 +434,22 @@ describe('coerceRoutine / coerceAsset', () => { const r = coerceRoutine({ row_data: { id: 'x', rowKind: 'routine', mediaKind: 'image', frequency: 'weekly', enabled: true, createdAt: 't', lastVariant: 'not-a-number' } }) expect(r?.lastVariant === undefined || Number.isInteger(r?.lastVariant)).toBe(true) }) + + // #1018: lastStyleVariant persists the rotating STYLE-axis index, an + // independent field from lastVariant (composition) — same coercion rigor. + it('coerces a persisted lastStyleVariant independently from lastVariant', () => { + const r = coerceRoutine({ row_data: { id: 'x', rowKind: 'routine', mediaKind: 'image', frequency: 'weekly', enabled: true, createdAt: 't', lastVariant: 1, lastStyleVariant: 3 } }) + expect(r?.lastVariant).toBe(1) + expect(r?.lastStyleVariant).toBe(3) + }) + it('lastStyleVariant is undefined when never set (no fabrication)', () => { + const r = coerceRoutine({ row_data: { id: 'x', rowKind: 'routine', mediaKind: 'image', frequency: 'weekly', enabled: true, createdAt: 't' } }) + expect(r?.lastStyleVariant).toBeUndefined() + }) + it('coerces a malformed lastStyleVariant defensively (never NaN, never a crash)', () => { + const r = coerceRoutine({ row_data: { id: 'x', rowKind: 'routine', mediaKind: 'image', frequency: 'weekly', enabled: true, createdAt: 't', lastStyleVariant: 'not-a-number' } }) + expect(r?.lastStyleVariant === undefined || Number.isInteger(r?.lastStyleVariant)).toBe(true) + }) it('rejects non-routine / missing id', () => { expect(coerceRoutine({ rowKind: 'asset' })).toBeNull() expect(coerceRoutine({ rowKind: 'routine' })).toBeNull() @@ -392,6 +502,17 @@ describe('I/O: saveRoutine / saveAsset / listMedia / runMediaGeneration', () => const r = await saveRoutine('a::b', { mediaKind: 'image', frequency: 'weekly' }) expect(r?.lastVariant).toBeUndefined() }) + it('saveRoutine persists an explicit lastStyleVariant independently from lastVariant (#1018)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => OK({ ok: true }) as any)) + const r = await saveRoutine('a::b', { mediaKind: 'image', frequency: 'weekly', lastVariant: 1, lastStyleVariant: 2 }) + expect(r?.lastVariant).toBe(1) + expect(r?.lastStyleVariant).toBe(2) + }) + it('saveRoutine leaves lastStyleVariant undefined when not provided (no fabrication)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => OK({ ok: true }) as any)) + const r = await saveRoutine('a::b', { mediaKind: 'image', frequency: 'weekly' }) + expect(r?.lastStyleVariant).toBeUndefined() + }) it('saveAsset persists and returns the asset', async () => { vi.stubGlobal('fetch', vi.fn(async () => OK({ ok: true }) as any)) const a = await saveAsset('a::b', { mediaKind: 'image', url: 'http://x/p.png', prompt: 'p' }) @@ -595,6 +716,24 @@ describe('I/O: saveRoutine / saveAsset / listMedia / runMediaGeneration', () => expect(res.status).toBe('generated') }) + // #1018: runMediaGeneration must also thread an explicit, INDEPENDENT style + // index into the generated prompt, alongside (not instead of) the + // composition variant index. + it('runMediaGeneration threads an explicit style index into the generated prompt, independent of the variant index', async () => { + process.env.BUILD_MEDIA_ENABLED = 'true' + const fetchMock = vi.fn() + .mockResolvedValueOnce(OK({ url: 'http://x/gen.mp4' }) as any) + .mockResolvedValueOnce(OK({}) as any) + .mockResolvedValueOnce(OK({ ok: true }) as any) + vi.stubGlobal('fetch', fetchMock) + await runMediaGeneration('a::b', 'video', { companyName: 'Acme' }, 1, 2) + const firstCall = fetchMock.mock.calls[0] + const sentBody = JSON.parse(String(firstCall[1].body)) + expect(sentBody.prompt).toBe(buildBrandPrompt('video', { companyName: 'Acme' }, 1, 2)) + // And it must differ from the variant-only (style defaulted to 0) prompt. + expect(sentBody.prompt).not.toBe(buildBrandPrompt('video', { companyName: 'Acme' }, 1)) + }) + // #884: every distinct failure branch used to collapse to the same bare // { status: 'failed' } with ZERO logging anywhere, making every failure // mode indistinguishable from every other. Each branch must now log the diff --git a/lib/build/media-routine.ts b/lib/build/media-routine.ts index 4783415f..9008acab 100644 --- a/lib/build/media-routine.ts +++ b/lib/build/media-routine.ts @@ -22,6 +22,7 @@ import { mediaGenerationConfigured, pickNextVariant, VARIATION_DESCRIPTORS, + STYLE_DESCRIPTORS, type BrandContext, } from '@/lib/build/media-schedule' @@ -54,18 +55,25 @@ export async function runMediaRoutines( // actual core call carries a different scene/composition than the // routine's last run — never repeating routine.lastVariant. const variantIndex = pickNextVariant(routine.lastVariant, VARIATION_DESCRIPTORS.length) - const result = await runMediaGeneration(scopeKey, routine.mediaKind, brand, variantIndex) + // #1018: pick the next STYLE/mood index the same way, but as a fully + // independent rotation from `variantIndex` — a company can land on + // composition-variant 2 + style-variant 0 rather than being locked + // into one combined rotation of fixed pairs. + const styleIndex = pickNextVariant(routine.lastStyleVariant, STYLE_DESCRIPTORS.length) + const result = await runMediaGeneration(scopeKey, routine.mediaKind, brand, variantIndex, styleIndex) if (result.status !== 'generated') continue generated += 1 // Advance the routine forward. A 'once' routine disables after it fires; // recurring routines keep enabled with a fresh lastRunAt so nextRunAt moves. - // lastVariant also advances so the NEXT run never repeats this one. + // lastVariant/lastStyleVariant also advance so the NEXT run never repeats + // either axis from this run. await saveRoutine(scopeKey, { mediaKind: routine.mediaKind, frequency: routine.frequency, enabled: routine.frequency !== 'once', lastRunAt: new Date().toISOString(), lastVariant: variantIndex, + lastStyleVariant: styleIndex, }) } catch { /* per-routine failure is non-fatal — keep going */ diff --git a/lib/build/media-schedule.ts b/lib/build/media-schedule.ts index 50569d71..e03e1d4d 100644 --- a/lib/build/media-schedule.ts +++ b/lib/build/media-schedule.ts @@ -80,6 +80,15 @@ export interface MediaRoutine { * Undefined until the routine has actually fired once. */ lastVariant?: number + /** + * Index into {@link STYLE_DESCRIPTORS} used by the most recent run (#1018). + * A SEPARATE rotation from `lastVariant` — composition/framing and + * style/mood are independent axes, so a company can land on, say, + * composition-variant 2 + style-variant 0 rather than being locked into one + * combined rotation of fixed pairs. Persisted and advanced the same way as + * `lastVariant`. Undefined until the routine has actually fired once. + */ + lastStyleVariant?: number } /** A generated media asset owned by the company. */ @@ -184,6 +193,34 @@ export const VARIATION_DESCRIPTORS = [ 'Composition: an abstract, conceptual visual representation of the core idea or value this business delivers, rather than a literal scene.', ] as const +/** + * Rotating style/mood descriptors (#1018) — a SECOND, independent variation + * axis from {@link VARIATION_DESCRIPTORS}. #909 fixed run-to-run repetition + * for a single company (composition/framing), and the subject-matter + * grounding in {@link buildBrandPrompt} was already real and working — but + * the STYLE instruction was one fixed, generic phrase + * ("modern, clean, professional photography or illustration, high visual + * quality") sent for EVERY company, every run. A fintech company and a + * children's toy company got identical style language, so even with varied + * composition and grounded subject matter, every company's asset read as + * "the same overarching theme." + * + * Each descriptor is an instruction about STYLE/MOOD/AESTHETIC only — never + * subject matter — so, like the composition axis, it layers on top of (never + * replaces) the real-business-grounding and no-on-image-text instructions in + * {@link buildBrandPrompt}. Picked via the SAME {@link pickNextVariant} + * helper, but with its OWN persisted index (`lastStyleVariant`) so the two + * axes rotate independently rather than being locked into one combined + * rotation of fixed pairs — a company can land on composition-variant 2 + + * style-variant 0 in the same run. + */ +export const STYLE_DESCRIPTORS = [ + 'Style: warm and approachable — soft natural lighting, inviting and human, favoring an editorial-photography feel over anything slick or corporate.', + 'Style: bold and energetic — high contrast, vivid saturated color, a dynamic sense of motion and momentum.', + 'Style: minimalist and refined — clean negative space, a restrained palette, precise and uncluttered, premium rather than busy.', + 'Style: grounded and technical — crisp, detail-oriented, documentary-like clarity that foregrounds how the product or process actually works.', +] as const + /** * Pick the next variation index (#909), guaranteeing it never repeats the * immediately-prior variant for the same company. Pure + deterministic-safe @@ -233,8 +270,24 @@ export function pickNextVariant(prev: number | undefined | null, total: number): * appended AFTER all the grounding/no-text instructions so it can never * weaken them. An out-of-range or missing index safely falls back to index 0 * rather than throwing or producing "undefined" in the prompt text. + * + * #1018: the STYLE line used to be one fixed, generic phrase + * ("modern, clean, professional photography or illustration, high visual + * quality") sent for every company, every run — never varying by industry or + * brand personality. `styleIndex` (optional, same back-compat default of 0) + * independently selects a rotating style/mood descriptor from + * {@link STYLE_DESCRIPTORS}, REPLACING that static line (it was never a + * grounding instruction, just inert filler) rather than adding alongside it. + * It is a SEPARATE index from `variantIndex` — composition and style rotate + * independently, not as one combined pairing — validated and defaulted the + * exact same defensive way. */ -export function buildBrandPrompt(mediaKind: MediaKind, brand: BrandContext, variantIndex?: number): string { +export function buildBrandPrompt( + mediaKind: MediaKind, + brand: BrandContext, + variantIndex?: number, + styleIndex?: number, +): string { const name = (brand.companyName || 'the company').trim() const tagline = (brand.tagline || '').trim() const idea = (brand.idea || '').trim() @@ -248,6 +301,11 @@ export function buildBrandPrompt(mediaKind: MediaKind, brand: BrandContext, vari ? variantIndex : 0 const variation = VARIATION_DESCRIPTORS[safeIndex] + const safeStyleIndex = + typeof styleIndex === 'number' && Number.isInteger(styleIndex) && styleIndex >= 0 && styleIndex < STYLE_DESCRIPTORS.length + ? styleIndex + : 0 + const style = STYLE_DESCRIPTORS[safeStyleIndex] const parts = [ `Create ${noun} for a real company. What this company actually does: ${subject}.`, 'The image must depict THIS business — its real product, service, or the problem it', @@ -256,7 +314,7 @@ export function buildBrandPrompt(mediaKind: MediaKind, brand: BrandContext, vari '(e.g. fashion/apparel imagery) unless the company itself is in that industry.', tagline ? `Brand tagline (for tone only — do not render this text in the image): "${tagline}".` : '', color ? `Use the brand accent color ${color} prominently in the palette.` : '', - 'Style: modern, clean, professional photography or illustration, high visual quality.', + style, 'Do NOT render any words, letters, logos, or captions in the image — no text overlays', 'of any kind, including the company name, tagline, or any instructional phrasing from', 'this prompt. This must be a purely visual asset with zero on-image text.', @@ -353,6 +411,7 @@ export function coerceRoutine(raw: any, scopeKey = ''): MediaRoutine | null { createdAt: String(r.createdAt || new Date().toISOString()), lastRunAt: r.lastRunAt ? String(r.lastRunAt) : undefined, lastVariant: Number.isInteger(r.lastVariant) ? Number(r.lastVariant) : undefined, + lastStyleVariant: Number.isInteger(r.lastStyleVariant) ? Number(r.lastStyleVariant) : undefined, } } @@ -414,7 +473,14 @@ async function zerodbRequest(method: string, path: string, body?: unknown, retri */ export async function saveRoutine( scopeKey: string, - input: { mediaKind: MediaKind; frequency: MediaFrequency; enabled?: boolean; lastRunAt?: string; lastVariant?: number }, + input: { + mediaKind: MediaKind + frequency: MediaFrequency + enabled?: boolean + lastRunAt?: string + lastVariant?: number + lastStyleVariant?: number + }, ): Promise { if (!scopeKey) return null const now = new Date().toISOString() @@ -428,6 +494,7 @@ export async function saveRoutine( createdAt: now, lastRunAt: input.lastRunAt, lastVariant: Number.isInteger(input.lastVariant) ? input.lastVariant : undefined, + lastStyleVariant: Number.isInteger(input.lastStyleVariant) ? input.lastStyleVariant : undefined, } await ensureTable() const result = await zerodbRequest( @@ -717,15 +784,19 @@ export type MediaFailureReason = (typeof MEDIA_FAILURE_REASONS)[number] * every other failure mode. Each branch now console.errors the real signal * (response status/body, poll outcome, or caught error) and tags the result * with a specific `reason` so this is actually diagnosable. + * + * #1018: `styleIndex` (optional, independent from `variantIndex`) selects the + * rotating style/mood descriptor — see {@link buildBrandPrompt}. */ export async function runMediaGeneration( scopeKey: string, mediaKind: MediaKind, brand: BrandContext, variantIndex?: number, + styleIndex?: number, ): Promise<{ status: 'disabled' | 'failed' | 'generated'; asset?: MediaAsset; reason?: MediaFailureReason }> { if (!mediaGenerationConfigured()) return { status: 'disabled' } - const prompt = buildBrandPrompt(mediaKind, brand, variantIndex) + const prompt = buildBrandPrompt(mediaKind, brand, variantIndex, styleIndex) const { path, body } = buildGenerationRequest(mediaKind, prompt) try { const res = await fetch(`${CORE_API}${path}`, {