Skip to content

Commit 3ea60c1

Browse files
feat(image-gen): GPU-path speed/quality guidance + never generate at a garbage resolution
On a non-NPU device (GPU/mnn path) a full SD1.5 model is a hard trade the user must steer, and the old UI let them fall into both traps: 8 steps → muddy; 512x512 → ~18 min on a mid-tier GPU; 128x128 → 43s but GARBAGE (SD1.5 is incoherent below ~256 — it's not a "smaller image", it's broken). - New utils/imageGenAdvice: pure, data-driven rule (branch on backend as data) — advises only on the mnn path: raise steps (<20), lower size (>256, for speed), or raise size (<256, which is garbage not smaller). NPU/CoreML get no advice (fast + fixed-shape). - New ImageGenAdviceCard in the image settings: reads settings live, renders the tips as the user moves the Steps/Size sliders. Design tokens + Feather icons + brand voice. - Image Size slider floor raised 128 → 256 (128/192 only ever produced garbage for these models); description states the 256-fast / 512-detailed-but-slow trade. - Generation floors width/height to 256 (Math.max) so a STALE persisted sub-256 setting can't reach the pipeline — the user never sees garbage, only "reduced size". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 49439e9 commit 3ea60c1

5 files changed

Lines changed: 159 additions & 4 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React from 'react';
2+
import { View, Text } from 'react-native';
3+
import Icon from 'react-native-vector-icons/Feather';
4+
import { useTheme, useThemedStyles } from '../../theme';
5+
import type { ThemeColors } from '../../theme';
6+
import { TYPOGRAPHY, SPACING } from '../../constants';
7+
import { useAppStore } from '../../stores';
8+
import { getImageGenAdvice } from '../../utils/imageGenAdvice';
9+
10+
/**
11+
* Advisory shown only for the GPU (mnn) image path — where the device has no compatible
12+
* NPU, so a full SD1.5 model is a real speed/quality trade the user must steer:
13+
* - under 20 steps looks muddy,
14+
* - a large size is very slow on a mid-tier GPU.
15+
* It reads settings live (so it appears/updates as the user moves the Steps/Size sliders)
16+
* and derives what to show from the single pure rule in utils/imageGenAdvice.
17+
*
18+
* Design: tokens only (COLORS/SPACING/TYPOGRAPHY), Feather vector icons, weights <=400.
19+
*/
20+
export const ImageGenAdviceCard: React.FC = () => {
21+
const styles = useThemedStyles(createStyles);
22+
const { colors } = useTheme();
23+
const { settings, downloadedImageModels, activeImageModelId } = useAppStore();
24+
const backend = downloadedImageModels.find(m => m.id === activeImageModelId)?.backend;
25+
26+
const advice = getImageGenAdvice({
27+
backend,
28+
steps: settings.imageSteps ?? 0,
29+
width: settings.imageWidth ?? 0,
30+
});
31+
if (!advice.show) return null;
32+
33+
return (
34+
<View style={styles.card} testID="image-gen-advice">
35+
<View style={styles.headerRow}>
36+
<Icon name="cpu" size={14} color={colors.primary} style={styles.leadIcon} />
37+
<Text style={styles.title}>Tips for your device</Text>
38+
</View>
39+
<Text style={styles.intro}>
40+
This device generates on the GPU (no compatible NPU), so quality and speed depend on these settings.
41+
</Text>
42+
{advice.raiseSteps && (
43+
<View style={styles.tipRow} testID="image-gen-advice-steps">
44+
<Icon name="arrow-up-circle" size={13} color={colors.textSecondary} style={styles.tipIcon} />
45+
<Text style={styles.tip}>Use 20 or more steps for good quality. Fewer steps look muddy.</Text>
46+
</View>
47+
)}
48+
{advice.lowerSize && (
49+
<View style={styles.tipRow} testID="image-gen-advice-size">
50+
<Icon name="minimize-2" size={13} color={colors.textSecondary} style={styles.tipIcon} />
51+
<Text style={styles.tip}>Try 256 for much faster generation with coherent results.</Text>
52+
</View>
53+
)}
54+
{advice.raiseSize && (
55+
<View style={styles.tipRow} testID="image-gen-advice-raise-size">
56+
<Icon name="alert-triangle" size={13} color={colors.textSecondary} style={styles.tipIcon} />
57+
<Text style={styles.tip}>Use at least 256. Below that the model produces garbled images, not smaller ones.</Text>
58+
</View>
59+
)}
60+
</View>
61+
);
62+
};
63+
64+
const createStyles = (colors: ThemeColors) => ({
65+
card: {
66+
borderRadius: 12,
67+
borderWidth: 1,
68+
borderColor: colors.border,
69+
backgroundColor: colors.surfaceLight,
70+
padding: SPACING.md,
71+
marginTop: SPACING.sm,
72+
},
73+
headerRow: {
74+
flexDirection: 'row' as const,
75+
alignItems: 'center' as const,
76+
gap: SPACING.xs,
77+
},
78+
leadIcon: { marginTop: 1 },
79+
title: {
80+
...TYPOGRAPHY.label,
81+
color: colors.primary,
82+
},
83+
intro: {
84+
...TYPOGRAPHY.bodySmall,
85+
color: colors.textSecondary,
86+
marginTop: SPACING.xs,
87+
},
88+
tipRow: {
89+
flexDirection: 'row' as const,
90+
alignItems: 'flex-start' as const,
91+
gap: SPACING.xs,
92+
marginTop: SPACING.sm,
93+
},
94+
tipIcon: { marginTop: 2 },
95+
tip: {
96+
...TYPOGRAPHY.bodySmall,
97+
color: colors.text,
98+
flex: 1,
99+
},
100+
});

src/components/GenerationSettingsModal/ImageGenerationSection.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useAppStore } from '../../stores';
77
import { hardwareService } from '../../services';
88
import { createStyles } from './styles';
99
import { ImageQualityBasicSliders, ImageQualityAdvancedSliders } from './ImageQualitySliders';
10+
import { ImageGenAdviceCard } from './ImageGenAdviceCard';
1011

1112
// ─── Image Model Picker ───────────────────────────────────────────────────────
1213

@@ -325,6 +326,8 @@ export const ImageGenerationSection: React.FC = () => {
325326

326327
<ImageQualityBasicSliders />
327328

329+
<ImageGenAdviceCard />
330+
328331
<AdvancedToggle isExpanded={showAdvanced} onPress={() => setShowAdvanced(!showAdvanced)} testID="modal-image-advanced-toggle" />
329332

330333
{showAdvanced && <ImageAdvancedSection />}

src/components/GenerationSettingsModal/ImageQualitySliders.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ export const ImageQualityBasicSliders: React.FC = () => {
4242
<SliderSetting
4343
testID="image-size"
4444
label="Image Size"
45-
description="Output resolution (smaller = faster, larger = more detail)"
45+
description="Output resolution. 256 is fastest with coherent results; 512 is most detailed but slow on GPU-only devices."
4646
value={settings.imageWidth ?? 256}
47-
min={128} max={512} step={64}
47+
min={256} max={512} step={64}
4848
formatValue={(v) => `${v}x${v}`}
4949
onChange={(value) => updateSettings({ imageWidth: value, imageHeight: value })}
5050
/>

src/services/imageGenerationService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,11 @@ class ImageGenerationService {
447447

448448
const steps = params.steps || settings.imageSteps || 8;
449449
const guidanceScale = params.guidanceScale || settings.imageGuidanceScale || 2.0;
450-
const imageWidth = settings.imageWidth || 256;
451-
const imageHeight = settings.imageHeight || 256;
450+
// Floor to 256: SD-class models render garbage (incoherent, not "smaller") below 256,
451+
// so a stale sub-256 setting must never reach the pipeline. The slider min is also 256;
452+
// this guards the persisted-value + programmatic paths so the user never sees garbage.
453+
const imageWidth = Math.max(256, settings.imageWidth || 256);
454+
const imageHeight = Math.max(256, settings.imageHeight || 256);
452455

453456
const enhancedPrompt = await this._enhancePrompt(params, steps);
454457
logger.log('[ImageGen] enhanceImagePrompts setting:', settings.enhanceImagePrompts);

src/utils/imageGenAdvice.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Advice for the GPU (mnn) image-generation path — the slow, quality-sensitive one used
3+
* when the device has no compatible NPU. On a mid-tier GPU a full SD1.5 model is a hard
4+
* speed/quality trade the user has to steer manually:
5+
* - too few steps look muddy (undercooked denoise),
6+
* - a large size is very slow (observed: 512x512 @ 22 steps ~= 18 min on a Snapdragon 765G).
7+
* So when the active model runs on the GPU path, nudge the user toward the settings that
8+
* matter. The NPU (qnn) and CoreML (ANE) paths are fast + fixed-shape, so no advice.
9+
*
10+
* Pure + data-driven: it branches on the backend AS DATA (not a Platform/device check),
11+
* so it's unit-testable and the same rule drives the card on every surface.
12+
*/
13+
export interface ImageGenAdvice {
14+
/** Surface the advisory card at all (any tip applies). */
15+
show: boolean;
16+
/** Steps are below the quality floor for the GPU path. */
17+
raiseSteps: boolean;
18+
/** Resolution is above the sweet spot → slow on a mid-tier GPU. */
19+
lowerSize: boolean;
20+
/** Resolution is below what SD1.5 can render coherently → garbage output. */
21+
raiseSize: boolean;
22+
}
23+
24+
/** Below this, GPU-path output is visibly undercooked (muddy). */
25+
export const QUALITY_STEP_FLOOR = 20;
26+
/**
27+
* The usable sweet-spot resolution for SD1.5 on the GPU path. SD1.5 is trained at 512;
28+
* 256 stays coherent while generating far faster, but BELOW 256 the model produces
29+
* garbage (observed on-device: 128x128 was fast but incoherent). So 256 is both the
30+
* "smaller = faster" target when the user is at 512, and the floor below which quality
31+
* collapses.
32+
*/
33+
export const SWEET_SPOT_SIZE = 256;
34+
35+
export function getImageGenAdvice(opts: {
36+
backend?: string | null;
37+
steps: number;
38+
width: number;
39+
}): ImageGenAdvice {
40+
// Only the mnn (GPU/CPU) path is slow + step/size-sensitive. qnn (NPU) / coreml (ANE)
41+
// are fast and fixed-resolution, so tuning there isn't the same trade-off.
42+
if (opts.backend !== 'mnn') {
43+
return { show: false, raiseSteps: false, lowerSize: false, raiseSize: false };
44+
}
45+
const raiseSteps = opts.steps < QUALITY_STEP_FLOOR;
46+
const lowerSize = opts.width > SWEET_SPOT_SIZE;
47+
const raiseSize = opts.width > 0 && opts.width < SWEET_SPOT_SIZE;
48+
return { show: raiseSteps || lowerSize || raiseSize, raiseSteps, lowerSize, raiseSize };
49+
}

0 commit comments

Comments
 (0)