From 9569ce09bbe273a013895e66b7e708c65e11a26a Mon Sep 17 00:00:00 2001 From: Simplereally Date: Sat, 24 Jan 2026 21:22:21 +1100 Subject: [PATCH 1/4] feat(models): add Klein, Klein-Large, and Wan models with updated tests --- biome.json | 15 +++ convex/lib/pollinations.ts | 2 +- lib/config/api.config.ts | 1 + lib/config/models.test.ts | 48 +++++++- lib/config/models.ts | 122 +++++++++++++++++++++ lib/schemas/pollinations-pricing.schema.ts | 33 +++++- lib/schemas/pollinations.schema.ts | 4 +- 7 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c8cf796 --- /dev/null +++ b/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": ["convex/_generated/**", ".next/**", "node_modules/**"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true + } +} diff --git a/convex/lib/pollinations.ts b/convex/lib/pollinations.ts index 3a67378..5953ac0 100644 --- a/convex/lib/pollinations.ts +++ b/convex/lib/pollinations.ts @@ -14,7 +14,7 @@ export const POLLINATIONS_BASE_URL = "https://gen.pollinations.ai" /** Video model IDs - these are the only models that support duration, aspectRatio, audio, and lastFrameImage */ -const VIDEO_MODELS = ["veo", "seedance", "seedance-pro"] as const +const VIDEO_MODELS = ["veo", "seedance", "seedance-pro", "wan"] as const // ============================================================ // Types diff --git a/lib/config/api.config.ts b/lib/config/api.config.ts index 9fe7390..468ab32 100644 --- a/lib/config/api.config.ts +++ b/lib/config/api.config.ts @@ -54,6 +54,7 @@ export const API_CONSTRAINTS = { veo: { min: 4, max: 8 }, seedance: { min: 2, max: 10 }, "seedance-pro": { min: 2, max: 10 }, + wan: { min: 2, max: 15 }, } satisfies Record, } as const; diff --git a/lib/config/models.test.ts b/lib/config/models.test.ts index 978eded..3b48e0e 100644 --- a/lib/config/models.test.ts +++ b/lib/config/models.test.ts @@ -30,6 +30,8 @@ describe("Model Registry", () => { "nanobanana", "seedream-pro", "nanobanana-pro", + "klein", + "klein-large", ] for (const modelId of expectedImageModels) { @@ -39,7 +41,7 @@ describe("Model Registry", () => { }) it("should contain all expected video models", () => { - const expectedVideoModels = ["seedance-pro", "seedance", "veo"] + const expectedVideoModels = ["seedance-pro", "seedance", "veo", "wan"] for (const modelId of expectedVideoModels) { expect(MODEL_REGISTRY[modelId]).toBeDefined() @@ -61,6 +63,9 @@ describe("Model Registry", () => { expect(MODEL_REGISTRY["seedance-pro"].displayName).toBe("Seedance Pro") expect(MODEL_REGISTRY["seedance"].displayName).toBe("Seedance") expect(MODEL_REGISTRY["veo"].displayName).toBe("Veo 3.1") + expect(MODEL_REGISTRY["wan"].displayName).toBe("Wan 2.6") + expect(MODEL_REGISTRY["klein"].displayName).toBe("FLUX.2 Klein 4B") + expect(MODEL_REGISTRY["klein-large"].displayName).toBe("FLUX.2 Klein 9B") }) it("should have valid constraints for all models", () => { @@ -150,21 +155,23 @@ describe("Model Registry", () => { describe("Model Lists", () => { it("should have all model IDs", () => { - expect(ALL_MODEL_IDS.length).toBe(13) + expect(ALL_MODEL_IDS.length).toBe(16) }) it("should have correct image model IDs", () => { - expect(IMAGE_MODEL_IDS.length).toBe(10) + expect(IMAGE_MODEL_IDS.length).toBe(12) expect(IMAGE_MODEL_IDS).toContain("zimage") expect(IMAGE_MODEL_IDS).toContain("gptimage") expect(IMAGE_MODEL_IDS).toContain("flux") + expect(IMAGE_MODEL_IDS).toContain("klein") expect(IMAGE_MODEL_IDS).not.toContain("veo") }) it("should have correct video model IDs", () => { - expect(VIDEO_MODEL_IDS.length).toBe(3) + expect(VIDEO_MODEL_IDS.length).toBe(4) expect(VIDEO_MODEL_IDS).toContain("veo") expect(VIDEO_MODEL_IDS).toContain("seedance") + expect(VIDEO_MODEL_IDS).toContain("wan") expect(VIDEO_MODEL_IDS).not.toContain("zimage") }) }) @@ -323,6 +330,24 @@ describe("Model Constraints", () => { } }) }) + + describe("Klein (Flux.2)", () => { + it("should have 4MP pixel limit", () => { + const model = getModel("klein")! + expect(model.constraints.maxPixels).toBe(4_000_000) + expect(model.constraints.maxDimension).toBe(2560) + }) + + it("should have step 16", () => { + const model = getModel("klein-large")! + expect(model.constraints.step).toBe(16) + }) + + it("should support HD and 2K tiers", () => { + const model = getModel("klein")! + expect(model.constraints.supportedTiers).toEqual(["hd", "2k"]) + }) + }) }) describe("Aspect Ratio Presets", () => { @@ -457,6 +482,21 @@ describe("Video Model Properties", () => { }) }) + describe("Wan", () => { + it("should have duration constraints 2-15s", () => { + const model = getModel("wan")! + expect(model.durationConstraints?.min).toBe(2) + expect(model.durationConstraints?.max).toBe(15) + expect(model.durationConstraints?.defaultDuration).toBe(5) + }) + + it("should support audio and reference image", () => { + const model = getModel("wan")! + expect(model.supportsAudio).toBe(true) + expect(model.supportsReferenceImage).toBe(true) + }) + }) + describe("Video aspect ratios", () => { it("should only support 16:9 and 9:16 for video models", () => { const veoRatios = getModelAspectRatios("veo")! diff --git a/lib/config/models.ts b/lib/config/models.ts index ce84f26..46d4845 100644 --- a/lib/config/models.ts +++ b/lib/config/models.ts @@ -269,6 +269,46 @@ const NANOBANANA_ASPECT_RATIOS: readonly AspectRatioOption[] = ( ] as const ).map(withAspectRatioTags); +/** + * FLUX.2 Klein aspect ratios - Optimized for 4MP pixel budget + * + * BFL enforces: + * - max 4,000,000 pixels (4 MP) per image + * - width/height must be multiples of 16 + * - minimum 64ร—64 + * - step-distilled: fixed 4 inference steps (not user-adjustable) + * + * | Ratio | Width | Height | Pixels | + * |-------|-------|--------|-----------| + * | 1:1 | 1984 | 1984 | 3,936,256 | + * | 16:9 | 2560 | 1440 | 3,686,400 | + * | 9:16 | 1440 | 2560 | 3,686,400 | + * | 4:3 | 2304 | 1728 | 3,981,312 | + * | 3:4 | 1728 | 2304 | 3,981,312 | + * | 3:2 | 2432 | 1600 | 3,891,200 | + * | 2:3 | 1600 | 2432 | 3,891,200 | + * | 4:5 | 1776 | 2224 | 3,950,624 | + * | 5:4 | 2224 | 1776 | 3,949,824 | + * | 21:9 | 2912 | 1248 | 3,634,176 | + * | 9:21 | 1248 | 2912 | 3,634,176 | + */ +const FLUX_KLEIN_ASPECT_RATIOS: readonly AspectRatioOption[] = ( + [ + { label: "Square", value: "1:1", width: 1984, height: 1984, icon: "square", category: "square" }, + { label: "Landscape", value: "16:9", width: 2560, height: 1440, icon: "rectangle-horizontal", category: "landscape" }, + { label: "Portrait", value: "9:16", width: 1440, height: 2560, icon: "rectangle-vertical", category: "portrait" }, + { label: "Photo", value: "4:3", width: 2304, height: 1728, icon: "image", category: "landscape" }, + { label: "Portrait Photo", value: "3:4", width: 1728, height: 2304, icon: "frame", category: "portrait" }, + { label: "Photo Wide", value: "3:2", width: 2432, height: 1600, icon: "image", category: "landscape" }, + { label: "Photo Tall", value: "2:3", width: 1600, height: 2432, icon: "frame", category: "portrait" }, + { label: "Social", value: "4:5", width: 1776, height: 2224, icon: "smartphone", category: "portrait" }, + { label: "Social Wide", value: "5:4", width: 2224, height: 1776, icon: "monitor", category: "landscape" }, + { label: "Ultrawide", value: "21:9", width: 2912, height: 1248, icon: "monitor", category: "ultrawide" }, + { label: "Ultra Tall", value: "9:21", width: 1248, height: 2912, icon: "smartphone", category: "ultrawide" }, + { label: "Custom", value: "custom", width: 1024, height: 1024, icon: "sliders", category: "square" }, + ] as const +).map(withAspectRatioTags); + /** * Nano Banana Pro aspect ratios - Uses tiered output dimensions (1K / 2K / 4K) * @@ -482,6 +522,58 @@ export const MODEL_REGISTRY: Record = { modelPricing: IMAGE_MODEL_PRICING["flux"], }, + klein: { + id: "klein", + displayName: "FLUX.2 Klein 4B", + type: "image", + icon: "zap", + logo: "/image-models/flux.svg", + description: "Step-distilled 4B model, ultra-fast 4-step generation, high-resolution up to 4MP", + constraints: { + maxPixels: 4_000_000, + minPixels: 4_096, + minDimension: 64, + maxDimension: 2560, + step: 16, + defaultDimensions: { width: 1024, height: 1024 }, + dimensionsEnabled: true, + maxSeed: 2_147_483_647, + supportedTiers: ["hd", "2k"], + outputCertainty: "likely", + dimensionWarning: "Dimensions rounded to multiples of 16", + }, + aspectRatios: FLUX_KLEIN_ASPECT_RATIOS, + supportsNegativePrompt: false, + supportsReferenceImage: true, + modelPricing: IMAGE_MODEL_PRICING["klein"], + }, + + "klein-large": { + id: "klein-large", + displayName: "FLUX.2 Klein 9B", + type: "image", + icon: "zap", + logo: "/image-models/flux.svg", + description: "Step-distilled 9B model, higher quality 4-step generation, up to 4MP resolution", + constraints: { + maxPixels: 4_000_000, + minPixels: 4_096, + minDimension: 64, + maxDimension: 2560, + step: 16, + defaultDimensions: { width: 1024, height: 1024 }, + dimensionsEnabled: true, + maxSeed: 2_147_483_647, + supportedTiers: ["hd", "2k"], + outputCertainty: "likely", + dimensionWarning: "Dimensions rounded to multiples of 16", + }, + aspectRatios: FLUX_KLEIN_ASPECT_RATIOS, + supportsNegativePrompt: false, + supportsReferenceImage: true, + modelPricing: IMAGE_MODEL_PRICING["klein-large"], + }, + "seedance-pro": { id: "seedance-pro", displayName: "Seedance Pro", @@ -541,6 +633,36 @@ export const MODEL_REGISTRY: Record = { modelPricing: VIDEO_MODEL_PRICING["veo"], }, + wan: { + id: "wan", + displayName: "Wan 2.6", + type: "video", + icon: "video", + logo: "/image-models/alibaba.svg", + description: "Image-to-video generation, flexible duration 2-15s, native audio support, resolution tiers", + constraints: { + maxPixels: Infinity, + minPixels: 0, + minDimension: 480, + maxDimension: 1920, + step: 16, + defaultDimensions: { width: 1280, height: 720 }, + dimensionsEnabled: false, + maxSeed: 2_147_483_647, + supportedTiers: ["sd", "hd"], + }, + aspectRatios: VIDEO_ASPECT_RATIOS, + supportsNegativePrompt: false, + supportsAudio: true, + supportsReferenceImage: true, + durationConstraints: { + min: 2, + max: 15, + defaultDuration: 5, + }, + modelPricing: VIDEO_MODEL_PRICING["wan"], + }, + // ======================================================================== // Standard Models // ======================================================================== diff --git a/lib/schemas/pollinations-pricing.schema.ts b/lib/schemas/pollinations-pricing.schema.ts index 0c95fc0..f12f8f2 100644 --- a/lib/schemas/pollinations-pricing.schema.ts +++ b/lib/schemas/pollinations-pricing.schema.ts @@ -286,6 +286,26 @@ export const IMAGE_MODEL_PRICING: Record = { imageOutputPerMillion: 120.0, }, }, + + klein: { + modelId: "klein", + type: "image", + approximatePerPollen: 150, + supportsReferenceImage: true, + imagePricing: { + perImage: 0.008, + }, + }, + + "klein-large": { + modelId: "klein-large", + type: "image", + approximatePerPollen: 85, + supportsReferenceImage: true, + imagePricing: { + perImage: 0.012, + }, + }, } as const; // ============================================================================ @@ -337,13 +357,24 @@ export const VIDEO_MODEL_PRICING: Record = { veo: { modelId: "veo", type: "video", - approximatePerPollen: 1, // ~1 video per pollen (highly variable based on duration) + approximatePerPollen: 1, supportsReferenceImage: true, isAlpha: true, videoPricing: { perSecond: 0.15, }, }, + + wan: { + modelId: "wan", + type: "video", + approximatePerPollen: 5, + supportsReferenceImage: true, + isAlpha: true, + videoPricing: { + perSecond: 0.025, + }, + }, } as const; // ============================================================================ diff --git a/lib/schemas/pollinations.schema.ts b/lib/schemas/pollinations.schema.ts index ecb4923..3ae1783 100644 --- a/lib/schemas/pollinations.schema.ts +++ b/lib/schemas/pollinations.schema.ts @@ -15,12 +15,14 @@ export const KnownImageModelSchema = z.enum([ "nanobanana", "seedream-pro", "nanobanana-pro", + "klein", + "klein-large", ]); export const ImageModelSchema = z.union([KnownImageModelSchema, z.string()]); // Video models -export const VideoModelSchema = z.enum(["veo", "seedance", "seedance-pro"]); +export const VideoModelSchema = z.enum(["veo", "seedance", "seedance-pro", "wan"]); // Combined model schema export const GenerationModelSchema = z.union([ImageModelSchema, VideoModelSchema]); From 548a35efa0529b29d364875c932bb617c5203bc9 Mon Sep 17 00:00:00 2001 From: Simplereally Date: Sat, 24 Jan 2026 21:41:40 +1100 Subject: [PATCH 2/4] chore: fix tests and linting issues --- components/gallery/history-filters.test.tsx | 2 +- components/gallery/image-history.test.tsx | 2 +- components/images/image-lightbox.test.tsx | 4 +- .../controls/reference-image-picker.test.tsx | 2 +- .../reference-images-browser-modal.test.tsx | 2 +- .../video-reference-image-picker.test.tsx | 2 +- components/ui/media-player.test.tsx | 2 +- convex/lib/stripeHelpers.test.ts | 2 +- hooks/use-image-display.test.ts | 2 +- lib/pollen-auth/context.test.tsx | 5 ++- lib/storage/r2-client.test.ts | 37 +++++++++++++------ lib/storage/retry.test.ts | 3 ++ 12 files changed, 41 insertions(+), 24 deletions(-) diff --git a/components/gallery/history-filters.test.tsx b/components/gallery/history-filters.test.tsx index 017e809..97207e1 100644 --- a/components/gallery/history-filters.test.tsx +++ b/components/gallery/history-filters.test.tsx @@ -47,7 +47,7 @@ if (!global.PointerEvent) { this.isPrimary = params.isPrimary || false } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + global.PointerEvent = PointerEvent as any } diff --git a/components/gallery/image-history.test.tsx b/components/gallery/image-history.test.tsx index f958e25..5f4e028 100644 --- a/components/gallery/image-history.test.tsx +++ b/components/gallery/image-history.test.tsx @@ -22,7 +22,7 @@ vi.mock("@/hooks/mutations/use-delete-image", () => ({ // Mock Next.js Image vi.mock("next/image", () => ({ default: ({ src, alt, ...props }: React.ImgHTMLAttributes & { fill?: boolean }) => { - // eslint-disable-next-line @next/next/no-img-element + return {alt} }, diff --git a/components/images/image-lightbox.test.tsx b/components/images/image-lightbox.test.tsx index f595881..a03ab02 100644 --- a/components/images/image-lightbox.test.tsx +++ b/components/images/image-lightbox.test.tsx @@ -134,7 +134,7 @@ vi.mock("@/components/ui/media-player", () => ({ if (isVideo) { return