Skip to content

Commit c755291

Browse files
committed
feat(ui): the recipe preview shows one sample and the last step at full strength
1 parent 61ed104 commit c755291

6 files changed

Lines changed: 165 additions & 85 deletions

File tree

docs/content/ui.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,15 @@ it and the footer carries the first one beside a shut *Save recipe*. Those are t
267267
shape, not a kernel rule the client is mirroring: the server still answers 422 to a body the
268268
form did not build.
269269

270-
*Preview* renders three sample assets through `POST /projects/{id}/preprocessing-preview`,
271-
the export's own kernel path over a one-asset manifest: the first three train-fold members
272-
of the newest release with a split — variants are the train fold's — or the project's first
273-
three assets when no release has one. Three columns: the asset as it is, after the resize
274-
step alone, and the first augmented variant; a stage the recipe does not have says so
275-
(*No resize step*) rather than repeating the original. Each cell is one request keyed on
270+
*Preview* renders one sample asset through `POST /projects/{id}/preprocessing-preview`,
271+
the export's own kernel path over a one-asset manifest: the first train-fold member of the
272+
newest release with a split — variants are the train fold's — or the project's first asset
273+
when no release has one. Three columns: the asset as it is, after the resize step alone, and
274+
the last augmentation step ticked, headed by its name (*After horizontal flip*) and asked for
275+
in `showcase` mode — the draws fixed at the step's declared strength, so the cell shows what
276+
the step does rather than one seeded draw of it; an export still takes the seeded path. A
277+
stage the recipe does not have says so (*No resize step*) rather than repeating the
278+
original. Each cell is one request keyed on
276279
the spec it renders, and the spec settles for 400 ms before a cell asks, so typing `640`
277280
does not pay for `6` and `64`. The cell is the member dialog's own picture-with-labels
278281
mechanism (`patterns/StaticAnnotationOverlay.tsx`): the rendered image, and the response's

frontend/app/cycle/cycle.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,8 +1569,9 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
15691569
await expect(page.getByTestId("augment-variants")).toHaveValue("1");
15701570
await expect(page.getByTestId("recipe-step-augment")).toHaveAttribute("data-state", "complete");
15711571

1572-
// The preview: three cells of the first row, each one a real render of a
1573-
// frame this walk ingested — the original, the letterbox, and variant 1.
1572+
// The preview: three cells of the one row, each one a real render of a
1573+
// frame this walk ingested — the original, the letterbox, and the flip
1574+
// shown at full strength.
15741575
// Generous, because the preview debounces the draft and then goes through
15751576
// Pillow three times.
15761577
for (const cell of ["original", "resize", "augment"]) {

frontend/ui-core/src/patterns/RecipeEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export function RecipeEditor({
324324
testId="recipe-step-preview"
325325
aside={
326326
<span className="text-xs text-muted-foreground" data-testid="preview-aside">
327-
3 sample assets · seeded
327+
1 sample asset
328328
</span>
329329
}
330330
last

frontend/ui-core/src/screens/PreprocessingTab.tsx

Lines changed: 81 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
*
1616
* `POST /projects/{id}/preprocessing-preview` renders one asset through a spec
1717
* on the same kernel path an export takes, so what the cells show is what the
18-
* archive would hold. Three sample assets, and the choice is the release's: the
19-
* first three train-fold members of the newest release with a split, because
20-
* variants are written for the train fold only; without one, the first three
21-
* assets of the project. The columns are the stages — the asset as it is, after
22-
* the resize step alone, and the first augmented variant — each a request
23-
* keyed on the spec it renders, so a keystroke re-renders only what it changed.
18+
* archive would hold. One sample asset, and the choice is the release's: the
19+
* first train-fold member of the newest release with a split, because variants
20+
* are written for the train fold only; without one, the project's first asset.
21+
* The columns are the stages — the asset as it is, after the resize step alone,
22+
* and the last augmentation step chosen, in `showcase` mode: the draws fixed at
23+
* the step's declared strength, so the cell shows what the step does rather
24+
* than one seeded draw of it. Each cell is a request keyed on the spec it
25+
* renders, so a keystroke re-renders only what it changed.
2426
*
2527
* `PreviewCell` hands the rendered image and its placed annotations to the
2628
* static overlay pattern, so a label is drawn where the export would write it.
@@ -62,11 +64,13 @@ import {
6264
type Release,
6365
} from "./queries";
6466
import {
67+
AUGMENT_OPS,
6568
canonicalSpec,
6669
draftFromSpec,
6770
draftToSpec,
6871
EMPTY_DRAFT,
6972
sameSpec,
73+
type AugmentStepSpec,
7074
type RecipeDraft,
7175
type RecipeSpec,
7276
} from "./recipeDraft";
@@ -366,21 +370,25 @@ function Editor({
366370
);
367371
}
368372

369-
/** The asset ids the preview renders: the train fold's first three, or the project's. */
370-
function useSampleAssets(projectId: string, datasetId: string | undefined): readonly string[] | undefined {
373+
/**
374+
* The asset the preview renders: the train fold's first member, or the
375+
* project's first asset; `null` when the project has none, `undefined` while
376+
* the choice is still being read.
377+
*/
378+
function useSampleAsset(projectId: string, datasetId: string | undefined): string | null | undefined {
371379
const releases = useReleases(datasetId);
372380
const newest = newestRelease(releases.data?.items);
373381
const withSplit = newest !== undefined && newest.split !== null && newest.split !== undefined;
374382
const assignment = useReleaseAssignment(withSplit ? newest.id : undefined);
375-
const project = useProjectAssets(projectId, 3);
383+
const project = useProjectAssets(projectId, 1);
376384
if (datasetId !== undefined && releases.data === undefined && !releases.isError) return undefined;
377385
if (withSplit) {
378386
if (assignment.data === undefined && !assignment.isError) return undefined;
379-
const train = assignment.data?.train.slice(0, 3) ?? [];
380-
if (train.length > 0) return train;
387+
const train = assignment.data?.train[0];
388+
if (train !== undefined) return train;
381389
}
382390
if (project.data === undefined) return undefined;
383-
return project.data.items.slice(0, 3).map((asset) => asset.id);
391+
return project.data.items[0]?.id ?? null;
384392
}
385393

386394
function newestRelease(items: readonly Release[] | undefined): Release | undefined {
@@ -416,17 +424,20 @@ function PreviewGrid({
416424
readonly classes: readonly LabelClass[] | undefined;
417425
readonly onReady: (ready: boolean) => void;
418426
}): JSX.Element {
419-
const samples = useSampleAssets(projectId, datasetId);
427+
const sample = useSampleAsset(projectId, datasetId);
420428
const settled = useSettledSpec(spec);
429+
const resizeSteps = settled === null ? [] : settled.steps.filter((step) => step.kind === "resize");
421430
const resizeOnly: RecipeSpec | null =
422-
settled === null
431+
settled === null ? null : { target: settled.target ?? null, steps: resizeSteps, variants_per_asset: 0 };
432+
const hasResize = resizeSteps.length > 0;
433+
const lastAugment = settled === null ? undefined : lastAugmentStep(settled);
434+
const showcase: RecipeSpec | null =
435+
settled === null || lastAugment === undefined
423436
? null
424-
: { target: settled.target ?? null, steps: settled.steps.filter((step) => step.kind === "resize"), variants_per_asset: 0 };
425-
const hasResize = resizeOnly !== null && resizeOnly.steps.length > 0;
426-
const hasAugment = settled !== null && settled.variants_per_asset > 0;
437+
: { target: settled.target ?? null, steps: [...resizeSteps, lastAugment], variants_per_asset: 1 };
427438

428-
if (samples === undefined) return <LoadingState rows={1} />;
429-
if (samples.length === 0) {
439+
if (sample === undefined) return <LoadingState rows={1} />;
440+
if (sample === null) {
430441
return (
431442
<p className="text-sm text-muted-foreground" data-testid="preview-empty">
432443
Nothing to preview yet — the project has no images. The recipe can still be saved.
@@ -439,54 +450,64 @@ function PreviewGrid({
439450
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
440451
<span>Original</span>
441452
<span>After resize</span>
442-
<span>After augmentation</span>
453+
<span data-testid="preview-augment-heading">
454+
{lastAugment === undefined ? "After augmentation" : `After ${opLabel(lastAugment.op)}`}
455+
</span>
443456
</div>
444-
{samples.map((assetId, index) => (
445-
<div
446-
key={assetId}
447-
className="grid grid-cols-3 items-center gap-2"
448-
data-testid={`preview-row-${index}`}
449-
title={`Sample ${assetId.slice(0, 8)}`}
450-
>
457+
<div
458+
className="grid grid-cols-3 items-center gap-2"
459+
data-testid="preview-row-0"
460+
title={`Sample ${sample.slice(0, 8)}`}
461+
>
462+
<PreviewCell
463+
projectId={projectId}
464+
assetId={sample}
465+
variant={0}
466+
spec={NO_TRANSFORM}
467+
classes={classes}
468+
testId="preview-0-original"
469+
onReady={onReady}
470+
/>
471+
{hasResize ? (
451472
<PreviewCell
452473
projectId={projectId}
453-
assetId={assetId}
474+
assetId={sample}
454475
variant={0}
455-
spec={NO_TRANSFORM}
476+
spec={resizeOnly}
477+
classes={classes}
478+
testId="preview-0-resize"
479+
/>
480+
) : (
481+
<Placeholder text="No resize step" testId="preview-0-resize" />
482+
)}
483+
{showcase !== null ? (
484+
<PreviewCell
485+
projectId={projectId}
486+
assetId={sample}
487+
variant={1}
488+
spec={showcase}
456489
classes={classes}
457-
testId={`preview-${index}-original`}
458-
{...(index === 0 ? { onReady } : {})}
490+
testId="preview-0-augment"
491+
showcase
459492
/>
460-
{hasResize ? (
461-
<PreviewCell
462-
projectId={projectId}
463-
assetId={assetId}
464-
variant={0}
465-
spec={resizeOnly}
466-
classes={classes}
467-
testId={`preview-${index}-resize`}
468-
/>
469-
) : (
470-
<Placeholder text="No resize step" testId={`preview-${index}-resize`} />
471-
)}
472-
{hasAugment ? (
473-
<PreviewCell
474-
projectId={projectId}
475-
assetId={assetId}
476-
variant={1}
477-
spec={settled}
478-
classes={classes}
479-
testId={`preview-${index}-augment`}
480-
/>
481-
) : (
482-
<Placeholder text="No augmentation" testId={`preview-${index}-augment`} />
483-
)}
484-
</div>
485-
))}
493+
) : (
494+
<Placeholder text="No augmentation" testId="preview-0-augment" />
495+
)}
496+
</div>
486497
</div>
487498
);
488499
}
489500

501+
/** The augmentation the preview shows: the last one the draft holds, in the editor's own order. */
502+
function lastAugmentStep(spec: RecipeSpec): AugmentStepSpec | undefined {
503+
const augments = spec.steps.filter((step): step is AugmentStepSpec => step.kind === "augment");
504+
return augments[augments.length - 1];
505+
}
506+
507+
function opLabel(op: AugmentStepSpec["op"]): string {
508+
return (AUGMENT_OPS.find((one) => one.op === op)?.label ?? op).toLowerCase();
509+
}
510+
490511
/**
491512
* One asset through one spec. The rendered image with its labels drawn where
492513
* the export would write them, or a placeholder while it has not arrived, or
@@ -507,6 +528,7 @@ function PreviewCell({
507528
classes,
508529
testId,
509530
onReady,
531+
showcase = false,
510532
}: {
511533
readonly projectId: string;
512534
readonly assetId: string;
@@ -515,13 +537,15 @@ function PreviewCell({
515537
readonly classes: readonly LabelClass[] | undefined;
516538
readonly testId: string;
517539
readonly onReady?: (ready: boolean) => void;
540+
readonly showcase?: boolean;
518541
}): JSX.Element {
519542
const preview = usePreprocessingPreview(
520543
projectId,
521544
assetId,
522545
variant,
523546
spec,
524547
spec === null ? "" : canonicalSpec(spec),
548+
showcase,
525549
);
526550
const ready = preview.data !== undefined;
527551
useEffect(() => {

frontend/ui-core/src/screens/preprocessing.test.tsx

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -614,32 +614,44 @@ describe("deleting a recipe", () => {
614614
});
615615

616616
describe("the preview", () => {
617-
it("samples the project's first three assets when no release has a split, and renders each stage", async () => {
617+
it("samples the project's first asset when no release has a split, and renders each stage", async () => {
618618
baseline([recipeRow("yolo-640", LETTERBOX)]);
619619
render(mount(<PreprocessingTab projectId={PROJECT} datasetId={DATASET} />));
620620

621621
const grid = await screen.findByTestId("preview-grid");
622622
const rows = within(grid).getAllByTestId(/^preview-row-/);
623-
expect(rows).toHaveLength(3);
623+
expect(rows).toHaveLength(1);
624624
// Three cells and nothing else: the row spends its width on the images.
625-
for (const row of rows) expect(row.children).toHaveLength(3);
626-
expect(screen.getByTestId("preview-aside").textContent).toBe("3 sample assets · seeded");
625+
expect(rows[0]?.children).toHaveLength(3);
626+
expect(screen.getByTestId("preview-aside").textContent).toBe("1 sample asset");
627+
// The third column names the last augmentation the draft holds.
628+
expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After brightness and contrast");
627629

628630
await waitFor(() =>
629631
expect(screen.getByTestId("preview-0-augment").getAttribute("data-state")).toBe("rendered"),
630632
);
631633
// The original is the asset through no transform; the resize column the
632-
// resize step alone; the augmentation column variant 1 of the whole spec.
634+
// resize step alone; the augmentation column the resize and the last
635+
// augmentation step only, one variant, shown at its declared strength.
633636
const previews = sent.filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview"));
634-
const forFirst = previews.filter((r) => bodyOf(r)["asset_id"] === ASSET_A).map(bodyOf);
635-
const specs = forFirst.map((body) => ({
637+
const requests = previews.map(bodyOf).map((body) => ({
636638
variant: body["variant"],
637-
steps: (body["spec"] as RecipeSpec).steps.map((step) => step.kind),
639+
showcase: body["showcase"],
640+
variants: (body["spec"] as RecipeSpec).variants_per_asset,
641+
steps: (body["spec"] as RecipeSpec).steps.map((step) =>
642+
step.kind === "augment" ? `augment:${step.op}` : step.kind,
643+
),
638644
}));
639-
expect(specs).toContainEqual({ variant: 0, steps: [] });
640-
expect(specs).toContainEqual({ variant: 0, steps: ["resize"] });
641-
expect(specs).toContainEqual({ variant: 1, steps: ["resize", "augment", "augment"] });
642-
expect(previews.some((r) => bodyOf(r)["asset_id"] === ASSET_D)).toBe(false);
645+
expect(requests).toContainEqual({ variant: 0, showcase: false, variants: 0, steps: [] });
646+
expect(requests).toContainEqual({ variant: 0, showcase: false, variants: 0, steps: ["resize"] });
647+
expect(requests).toContainEqual({
648+
variant: 1,
649+
showcase: true,
650+
variants: 1,
651+
steps: ["resize", "augment:brightness_contrast"],
652+
});
653+
expect(requests).toHaveLength(3);
654+
expect(new Set(previews.map((r) => bodyOf(r)["asset_id"]))).toEqual(new Set([ASSET_A]));
643655
const original = screen.getByTestId("preview-0-original");
644656
const image = original.querySelector("img");
645657
expect(image?.getAttribute("src")).toBe("data:image/png;base64,aGVsbG8=");
@@ -650,6 +662,37 @@ describe("the preview", () => {
650662
expect(screen.getByTestId("recipe-step-preview").getAttribute("data-state")).toBe("complete");
651663
});
652664

665+
it("names the step the third column shows, and follows the draft's last augmentation", async () => {
666+
baseline([recipeRow("yolo-640", LETTERBOX)]);
667+
render(mount(<PreprocessingTab projectId={PROJECT} datasetId={DATASET} />));
668+
await screen.findByTestId("preview-grid");
669+
670+
await userEvent.click(screen.getByTestId("augment-brightness_contrast"));
671+
await waitFor(() =>
672+
expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After horizontal flip"),
673+
);
674+
await waitFor(() =>
675+
expect(
676+
sent
677+
.filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview"))
678+
.map(bodyOf)
679+
.some(
680+
(body) =>
681+
body["showcase"] === true &&
682+
(body["spec"] as RecipeSpec).steps.map((step) => (step.kind === "augment" ? step.op : step.kind)).join(",") ===
683+
"resize,hflip",
684+
),
685+
).toBe(true),
686+
);
687+
688+
// The last augmentation unticked: no variant to show, and the column says so.
689+
await userEvent.click(screen.getByTestId("augment-hflip"));
690+
await waitFor(() =>
691+
expect(screen.getByTestId("preview-0-augment").textContent).toContain("No augmentation"),
692+
);
693+
expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After augmentation");
694+
});
695+
653696
it("samples the newest release's train fold when it has a split", async () => {
654697
on("GET", /\/releases$/, {
655698
status: 200,
@@ -680,18 +723,19 @@ describe("the preview", () => {
680723

681724
await screen.findByTestId("preview-grid");
682725
await waitFor(() =>
683-
expect(screen.getByTestId("preview-1-original").getAttribute("data-state")).toBe("rendered"),
726+
expect(screen.getByTestId("preview-0-original").getAttribute("data-state")).toBe("rendered"),
684727
);
685728
const sampled = new Set(
686729
sent
687730
.filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview"))
688731
.map((r) => bodyOf(r)["asset_id"]),
689732
);
690-
expect(sampled).toEqual(new Set([ASSET_C, ASSET_D]));
733+
expect(sampled).toEqual(new Set([ASSET_C]));
691734
// No resize step and no augmentation: the two stages say so rather than
692735
// repeating the original.
693736
expect(screen.getByTestId("preview-0-resize").textContent).toContain("No resize step");
694737
expect(screen.getByTestId("preview-0-augment").textContent).toContain("No augmentation");
738+
expect(screen.getByTestId("preview-augment-heading").textContent).toBe("After augmentation");
695739
});
696740

697741
it("shows a refused rendering as prose in the cell", async () => {

0 commit comments

Comments
 (0)