diff --git a/src/components/Image/Remix/RemixMenu.tsx b/src/components/Image/Remix/RemixMenu.tsx index 02b08f5fe0..7d88f43294 100644 --- a/src/components/Image/Remix/RemixMenu.tsx +++ b/src/components/Image/Remix/RemixMenu.tsx @@ -1,6 +1,6 @@ import { Menu, Text, ThemeIcon } from '@mantine/core'; import { useEffect, useRef } from 'react'; -import { IconBrush, IconMovie, IconWand } from '@tabler/icons-react'; +import { IconBrush, IconMovie, IconRosetteDiscountCheck, IconWand } from '@tabler/icons-react'; import type { RemixKind } from '~/shared/constants/remix.constants'; import { useTrackEvent } from '~/components/TrackView/track.utils'; import { @@ -22,6 +22,11 @@ type RemixOption = { icon: typeof IconWand; /** A colour per option so the three read as distinct things, not a list. */ color: string; + /** + * Set on the options that verify, not on the one that lacks it — so reusing a + * prompt doesn't read as a discouraged choice. + */ + verifiable: boolean; }; const kindLabels: Record = { @@ -30,12 +35,14 @@ const kindLabels: Record = { description: 'Change this image with a prompt', icon: IconWand, color: 'violet', + verifiable: true, }, video: { label: 'Animate', description: 'Turn this image into a video', icon: IconMovie, color: 'blue', + verifiable: true, }, }; @@ -44,8 +51,22 @@ const reuseOption: RemixOption = { description: "Start from this image's settings", icon: IconBrush, color: 'teal', + verifiable: false, }; +/** + * States what this earns, not a promise the submission will be free — + * `freeSubmissionOffer` decides that later and can override it. + */ +function VerifiedHint() { + return ( + + + Uses this image, so we can verify the remix + + ); +} + function OptionIcon({ option }: { option: RemixOption }) { return ( @@ -63,6 +84,7 @@ function OptionLabel({ option }: { option: RemixOption }) { {option.description} + {option.verifiable && } ); } diff --git a/src/utils/__tests__/remix-claim.test.ts b/src/utils/__tests__/remix-claim.test.ts index 67a16075aa..6626d28057 100644 --- a/src/utils/__tests__/remix-claim.test.ts +++ b/src/utils/__tests__/remix-claim.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { REMIX_CLAIM_TTL, useRemixStore } from '~/store/remix.store'; -import { remixClaimHolds, resolveRemixOfId } from '~/utils/remix-claim'; +import { remixClaimHolds, remixClaimState, resolveRemixOfId } from '~/utils/remix-claim'; const SOURCE_ID = 11217158; @@ -74,3 +74,94 @@ describe('remixClaimHolds', () => { expect(resolveRemixOfId({})).toBeUndefined(); }); }); + +/** Four of the seed's nine tags survive — a real remix that moved on, scoring below the 0.75 cutoff. */ +const PARTIAL_PROMPT = + '1girl, 1boy, creampie, bed, forest, waterfall, sunlight, castle, epic fantasy'; + +/** Two of the seed's nine tags changed — a real edit that still scores at or above the 0.75 cutoff. */ +const NEAR_PROMPT = + 'netorare, cuckold pov, 1girl, 1boy, creampie, bed, bedroom, day, plain background'; + +describe('remixClaimState', () => { + const state = (form: Parameters[1]) => + remixClaimState(useRemixStore.getState().data, form); + + /** + * The WHOLE object on every branch, not the fields that seemed interesting. + * Three review rounds each found one more field nobody had asserted on one + * more branch — a different field each time — because assertions written + * per-branch pin what their author was thinking about. `toEqual` pins every + * field whether or not anyone thought about it. + * + * `score` is `expect.any(Number)` where the prompt carries the claim: its + * VALUE is pinned by the ordering test below, which is the only thing that + * survives a retuned similarity. + */ + it.each([ + { + when: 'there is no remix at all', + arrange: () => undefined, + form: { prompt: SEEDED_PROMPT }, + then: { holds: false, carrier: null, reason: 'none', score: null }, + }, + { + when: 'the claim is older than its TTL', + arrange: () => seedRemix(SEEDED_PROMPT, Date.now() - REMIX_CLAIM_TTL - 1), + form: { prompt: SEEDED_PROMPT }, + then: { holds: false, carrier: null, reason: 'expired', score: null }, + }, + { + when: 'the remix seeded no prompt to compare against', + arrange: () => seedRemix(' '), + form: { prompt: SEEDED_PROMPT }, + then: { holds: false, carrier: null, reason: 'uncarried', score: null }, + }, + { + when: 'the person cleared the prompt box', + arrange: () => seedRemix(), + form: { prompt: ' ' }, + then: { holds: false, carrier: 'prompt', reason: 'uncarried', score: null }, + }, + { + when: 'the prompt was rewritten past the cutoff', + arrange: () => seedRemix(), + form: { prompt: PARTIAL_PROMPT }, + then: { holds: false, carrier: 'prompt', reason: 'drifted', score: expect.any(Number) }, + }, + { + when: 'the prompt changed but still scores above the cutoff', + arrange: () => seedRemix(), + form: { prompt: NEAR_PROMPT }, + then: { holds: true, carrier: 'prompt', reason: null, score: expect.any(Number) }, + }, + { + when: 'the form still holds the source media', + arrange: () => seedRemix(), + form: { prompt: 'pan left', video: { url: 'https://x/1.mp4' } }, + then: { holds: true, carrier: 'media', reason: null, score: null }, + }, + ])('$when', ({ arrange, form, then }) => { + arrange(); + expect(state(form)).toEqual(then); + }); + + /** + * What the ordering closes and what it does not: it rules out a score that + * collapses to a constant or ranks the fixtures wrongly. A monotone-but-wrong + * score — raw cosine, any order-preserving scaling — still passes. That is the + * ceiling of an ordering property, not a gap in this instance. + */ + it('ranks a nearer prompt above a further one, and a disjoint one at the floor', () => { + seedRemix(); + const near = state({ prompt: NEAR_PROMPT }).score ?? -1; + const partial = state({ prompt: PARTIAL_PROMPT }).score ?? -1; + const disjoint = state({ prompt: UNRELATED_PROMPT }).score ?? -1; + + expect(near).toBeGreaterThanOrEqual(0.75); + expect(near).toBeGreaterThan(partial); + expect(partial).toBeGreaterThan(disjoint); + expect(partial).toBeLessThan(0.75); + expect(disjoint).toBe(0); + }); +}); diff --git a/src/utils/remix-claim.ts b/src/utils/remix-claim.ts index 5820895713..bc776709c8 100644 --- a/src/utils/remix-claim.ts +++ b/src/utils/remix-claim.ts @@ -9,6 +9,17 @@ export type RemixClaimFormState = { video?: unknown; }; +/** + * `score` is set only on the prompt branch — `null` there means not applicable, + * never zero. + */ +export type RemixClaimState = { + holds: boolean; + carrier: 'media' | 'prompt' | null; + reason: 'none' | 'expired' | 'uncarried' | 'drifted' | null; + score: number | null; +}; + /** * Does the form still contain what the remix put there? * @@ -22,22 +33,44 @@ export type RemixClaimFormState = { * prompt with its source, so it broke the link exactly where the derivation was * most literal (see `track.schema.ts`) — it is reinstated here only on the * branch where the prompt IS the carrier. + * + * 🔴 The only derivation of this rule — reuse it, don't recompute the threshold + * elsewhere. A second copy that drifts from this one could tell someone their + * remix still counts when the submit drops it. */ +export function remixClaimState( + data: RemixData | null, + form: RemixClaimFormState +): RemixClaimState { + if (!data) return { holds: false, carrier: null, reason: 'none', score: null }; + if (!isRemixDataFresh(data)) + return { holds: false, carrier: null, reason: 'expired', score: null }; + + // Media-consuming workflows carry the derivation in the media; what verifies + // it there is `remix-provenance.store`, keyed by the image's current url. + if (form.images?.length || form.video) + return { holds: true, carrier: 'media', reason: null, score: null }; + + const seeded = data.originalParams.prompt; + if (typeof seeded !== 'string' || !seeded.trim()) + return { holds: false, carrier: null, reason: 'uncarried', score: null }; + + // An empty prompt is someone mid-edit, not someone who has drifted. Scoring it + // would report a confident 0 at the moment the box is cleared to retype. + if (!form.prompt?.trim()) + return { holds: false, carrier: 'prompt', reason: 'uncarried', score: null }; + + const { similar, adjustedCosine } = promptSimilarity(seeded, form.prompt); + return similar + ? { holds: true, carrier: 'prompt', reason: null, score: adjustedCosine } + : { holds: false, carrier: 'prompt', reason: 'drifted', score: adjustedCosine }; +} + export function remixClaimHolds( data: RemixData | null, form: RemixClaimFormState ): data is RemixData { - if (!isRemixDataFresh(data)) return false; - - // Media-consuming workflows carry the derivation in the media; what verifies - // it there is `remix-provenance.store`, keyed by the image's current url. - if (form.images?.length || form.video) return true; - - const seeded = data.originalParams.prompt; - if (typeof seeded !== 'string' || !seeded.trim()) return false; - if (!form.prompt?.trim()) return false; - - return promptSimilarity(seeded, form.prompt).similar; + return remixClaimState(data, form).holds; } /** The `remixOfId` a submission may carry, or undefined when the claim is dead. */