mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(remix): say at click time which remix modes we can verify (#4939)
* feat(remix): say at click time which remix modes we can verify A prompt-reuse remix feeds no image to the job, so the server resolves no sourceImageIds and the remix gallery correctly refuses the free submission. The submit modal already explains that (RemixGallerySubmitModal renders freeUnavailableReason outside the free/paid block, deliberately). What is missing is earlier: the three remix options look alike at the moment of choosing, so the difference is only discoverable after generating. Mark the two that feed the image itself. On the options that HAVE the property rather than the one that lacks it — reusing a prompt is a legitimate remix and the menu should not read as warning someone off it. It says we can verify, not that the submission will be free: whether free is on offer is five more rungs in freeSubmissionOffer, and a menu that promised it would be overruled at the modal. remixClaimState is split out of remixClaimHolds so the claim's outcome — holds, carrier, why not, and the score where the prompt is the carrier — has one derivation. The predicate is now a wrapper over it. A surface that computed its own similarity would be a second copy of the 0.75 threshold, and the copy that drifts is the one telling someone their remix still counts while the submit is about to drop it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(remix): assert the reason, not just the boolean Review round. The comment on remixClaimState said the drift notice renders it. There is no drift notice — it moved to the following PR — so the comment asserted a consumer that does not exist. Corrected to state the instruction that is actually load-bearing: this is the only derivation, reuse it rather than recomputing the threshold. Every existing test asserted through the holds boolean, so carrier, reason and score could take any value and stay green. That is the half the next PR branches on. The pair that matters is drifted against uncarried: both are holds:false and nothing else separates them, so reporting drifted for a cleared prompt box would tell someone they had changed their mind at the moment they cleared it to retype. Asserted field by field rather than with toMatchObject, which truncates to "expected { holds: false, ...(3) } to match object { holds: false, ...(3) }" and never names the value that was wrong. Verified by reverting: swapping the reason on the cleared-prompt branch now fails with "expected 'drifted' to be 'uncarried'". Three comment blocks trimmed to the fact they carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(remix): cover the three branches a mutation passed through Second review round found the first one incomplete. Three branches had no assertion at the state level, so mutating them printed nothing: the no-remix reason, the branch where the remix seeded no prompt at all — which is a different branch from the form's prompt being cleared, and carries nothing rather than the prompt — and the media branch's reason. The drifted score assertion was vacuous by fixture rather than by shape. The two prompts share no token after cleaning, so every term in the similarity is zero and the score is exactly 0; toBeLessThan(0.75) is then true of any bounded wrong answer, including a constant. Replaced with an ordering over three fixtures whose scores were measured rather than assumed: 0.7601 for two tags changed, 0.2969 for four of eleven left, 0 for disjoint. A constant score now fails with "expected 0.1 to be greater than 0.1". Comments cut, not trimmed. "A server-side check is coming" was the same unfalsifiable forward claim as the one this round already corrected, for a PR that does not exist. The block comment over the tests restated what the test names say and what remix-claim.ts states three lines from the branch it describes, and the note defending the assertion style was the fix round arguing with a reviewer inside the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(remix): drop the comment defending the assertion, keep the calibration The note above the ordering assertions described the previous version of the test rather than this one, and claimed the null fallback guarded a case that cannot occur at that call site — all three scores come from the branch that always returns a number. The test name already says what the assertions say. The fixture docs keep what a reader cannot recover by eye, that the overlap is calibrated rather than incidental, and lose the measured decimals. Those are pinned by no assertion, so a retuned similarity would leave them wrong with everything still green. The numbers are in the PR body, which is dated by nature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(remix): assert the expired branch carries nothing Third review round, third branch whose mutation printed nothing: the expired claim asserted its reason, its holds and its score, and not its carrier. Verified silent by running it — 15 passed with carrier changed to 'prompt' — and now fails with "expected 'prompt' to be null". Two comments corrected rather than trimmed. The fixture docs said eleven tags where the seed has nine; eleven is its token count, which is what the similarity works on, but the sentence says tags. The state doc claimed expired and uncarried are not caused by the person, which is false for the branch where they cleared the prompt box themselves — the inline comment two lines below says exactly that. The predicate's own doc restated its signature and went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(remix): assert the whole state on every branch Three review rounds each found one more field unasserted on one more branch, a different field each time. Assertions written per branch pin what their author was thinking about, so a fourth round would have sampled the same hole rather than closed it. toEqual over the whole object on every branch, table-driven. A field-level mutation cannot survive it, because there is no field that no assertion mentions. Verified against all five survivors the rounds found, plus a sixth chosen on a branch none of them touched: all six red, the sixth printing carrier prompt against media. score is expect.any(Number) where the prompt carries the claim. Its value is pinned by the ordering beside it, which rules out a constant and a wrong ranking and does not rule out a monotone-but-wrong score. That is the ceiling of an ordering property rather than a gap here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<RemixKind, RemixOption> = {
|
||||
@@ -30,12 +35,14 @@ const kindLabels: Record<RemixKind, RemixOption> = {
|
||||
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 (
|
||||
<Text size="xs" c="green.6" lh={1.2} className="mt-1 flex items-center gap-1">
|
||||
<IconRosetteDiscountCheck size={13} stroke={1.9} />
|
||||
Uses this image, so we can verify the remix
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionIcon({ option }: { option: RemixOption }) {
|
||||
return (
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={option.color}>
|
||||
@@ -63,6 +84,7 @@ function OptionLabel({ option }: { option: RemixOption }) {
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{option.description}
|
||||
</Text>
|
||||
{option.verifiable && <VerifiedHint />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof remixClaimState>[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);
|
||||
});
|
||||
});
|
||||
|
||||
+44
-11
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user