diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 3dffb4d..2a96fbf 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as follows from "../follows.js"; import type * as generatedImages from "../generatedImages.js"; import type * as http from "../http.js"; import type * as lib_crypto from "../lib/crypto.js"; +import type * as lib_dirtberryCrop from "../lib/dirtberryCrop.js"; import type * as lib_groq from "../lib/groq.js"; import type * as lib_index from "../lib/index.js"; import type * as lib_nsfwDetection from "../lib/nsfwDetection.js"; @@ -73,6 +74,7 @@ declare const fullApi: ApiFromModules<{ generatedImages: typeof generatedImages; http: typeof http; "lib/crypto": typeof lib_crypto; + "lib/dirtberryCrop": typeof lib_dirtberryCrop; "lib/groq": typeof lib_groq; "lib/index": typeof lib_index; "lib/nsfwDetection": typeof lib_nsfwDetection; diff --git a/convex/batchProcessor.ts b/convex/batchProcessor.ts index e742f48..baacfec 100644 --- a/convex/batchProcessor.ts +++ b/convex/batchProcessor.ts @@ -20,6 +20,9 @@ import { internalAction } from "./_generated/server" import { buildPollinationsUrl, classifyApiError, + cropDirtberryImageBuffer, + getDirtberrySourceDimensions, + isDirtberryModel, generateR2Key, generateThumbnailKey, uploadMediaWithThumbnail, @@ -130,6 +133,8 @@ export const processBatchItem = internalAction({ } try { + const shouldCropDirtberry = isDirtberryModel(batchJob.generationParams.model) + const dirtberrySourceDimensions = shouldCropDirtberry ? getDirtberrySourceDimensions() : null // Pollinations API only accepts seeds up to int32 max (2147483647) const INT32_MAX = 2147483647 const rawSeed = batchJob.generationParams.seed ?? Math.floor(Math.random() * INT32_MAX) @@ -140,8 +145,8 @@ export const processBatchItem = internalAction({ prompt: batchJob.generationParams.prompt, negativePrompt: batchJob.generationParams.negativePrompt, model: batchJob.generationParams.model, - width: batchJob.generationParams.width, - height: batchJob.generationParams.height, + width: dirtberrySourceDimensions?.width ?? batchJob.generationParams.width, + height: dirtberrySourceDimensions?.height ?? batchJob.generationParams.height, seed, enhance: batchJob.generationParams.enhance, private: batchJob.generationParams.private, @@ -191,12 +196,42 @@ export const processBatchItem = internalAction({ const imageBuffer = Buffer.from(await response.arrayBuffer()) const contentType = response.headers.get("content-type") || "image/jpeg" + let uploadBuffer = imageBuffer + let outputWidth = batchJob.generationParams.width ?? 1024 + let outputHeight = batchJob.generationParams.height ?? 1024 + + // Crop Dirtberry outputs before upload/persistence so every surface + // (canvas, gallery, downloads, lightbox) uses the same native asset. + if (shouldCropDirtberry) { + try { + const cropped = await cropDirtberryImageBuffer(imageBuffer) + uploadBuffer = Buffer.from(cropped.buffer) + outputWidth = cropped.width + outputHeight = cropped.height + + if (cropped.wasCropped) { + console.log( + `${logger} Applied Dirtberry crop (${cropped.processor}): ${cropped.inputWidth}x${cropped.inputHeight} -> ${cropped.width}x${cropped.height} (trim=${cropped.trimPixels}px)` + ) + } else { + console.log( + `${logger} Dirtberry crop skipped: source=${cropped.inputWidth}x${cropped.inputHeight} (image too small to trim safely)` + ) + } + } catch (cropError) { + console.error( + `${logger} Dirtberry crop failed, falling back to original image:`, + cropError + ) + } + } + // Upload to R2 (and thumbnail for images — videos defer secondary assets) const r2Key = generateR2Key(batchJob.ownerId, contentType) console.log(`${logger} Uploading to R2: ${r2Key}`) const { media: uploadResult, thumbnail: thumbnailResult } = await uploadMediaWithThumbnail( - imageBuffer, + uploadBuffer, r2Key, contentType ) @@ -217,16 +252,23 @@ export const processBatchItem = internalAction({ previewR2Key: undefined, previewUrl: undefined, prompt: batchJob.generationParams.prompt, - width: batchJob.generationParams.width ?? 1024, - height: batchJob.generationParams.height ?? 1024, + width: outputWidth, + height: outputHeight, model: batchJob.generationParams.model ?? "flux", seed, contentType, sizeBytes: uploadResult.sizeBytes, - generationParams: { - ...batchJob.generationParams, - seed, - }, + generationParams: shouldCropDirtberry + ? { + ...batchJob.generationParams, + seed, + width: outputWidth, + height: outputHeight, + } + : { + ...batchJob.generationParams, + seed, + }, visibility: batchJob.generationParams.private ? "unlisted" : "public", }) diff --git a/convex/lib/dirtberryCrop.ts b/convex/lib/dirtberryCrop.ts new file mode 100644 index 0000000..3b65962 --- /dev/null +++ b/convex/lib/dirtberryCrop.ts @@ -0,0 +1,130 @@ +"use node" + +export const DIRTBERRY_TRIM_FRACTION = 0.03 +export const DIRTBERRY_SOURCE_WIDTH = 832 +export const DIRTBERRY_SOURCE_HEIGHT = 1216 +export const DIRTBERRY_OUTPUT_HEIGHT = + DIRTBERRY_SOURCE_HEIGHT - Math.round(DIRTBERRY_SOURCE_HEIGHT * DIRTBERRY_TRIM_FRACTION) * 2 + +const JIMP_ENCODABLE_MIME_TYPES = new Set([ + "image/bmp", + "image/x-ms-bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/tiff", +] as const) + +type JimpEncodableMime = "image/bmp" | "image/x-ms-bmp" | "image/gif" | "image/jpeg" | "image/png" | "image/tiff" + +type DirtberryCropRegion = { + top: number + height: number +} + +export type DirtberryCropResult = { + buffer: Buffer + width: number + height: number + wasCropped: boolean + inputWidth: number + inputHeight: number + trimPixels: number + processor: "jimp" | "none" +} + +export function getDirtberrySourceDimensions(): { width: number; height: number } { + return { + width: DIRTBERRY_SOURCE_WIDTH, + height: DIRTBERRY_SOURCE_HEIGHT, + } +} + +export function isDirtberryModel(model?: string): boolean { + const normalized = model?.trim().toLowerCase() + if (!normalized) { + return false + } + return normalized === "dirtberry" || normalized.includes("dirtberry") +} + +/** + * Calculate Dirtberry's watermark-removal crop region. + * Trims 3% from top and bottom when the resulting crop is valid. + */ +export function calculateDirtberryCropRegion(height: number): DirtberryCropRegion | null { + if (!Number.isFinite(height) || height <= 0) return null + + const trimPixels = Math.round(height * DIRTBERRY_TRIM_FRACTION) + const croppedHeight = height - trimPixels * 2 + + if (trimPixels <= 0 || croppedHeight <= 0) { + return null + } + + return { + top: trimPixels, + height: croppedHeight, + } +} + +/** + * Crop Dirtberry images to remove corner watermarks before persistence. + * Returns original data if crop is not applicable. + */ +export async function cropDirtberryImageBuffer( + imageBuffer: Buffer +): Promise { + // Use jimp only (pure JS) so Convex linux-arm64 deployments don't fail + // on native sharp module loading. + const { Jimp } = await import("jimp") + const image = await Jimp.read(imageBuffer) + const width = image.bitmap.width + const height = image.bitmap.height + + if (!width || !height) { + throw new Error("Unable to read Dirtberry image dimensions") + } + + const region = calculateDirtberryCropRegion(height) + if (!region) { + return { + buffer: imageBuffer, + width, + height, + wasCropped: false, + inputWidth: width, + inputHeight: height, + trimPixels: 0, + processor: "none", + } + } + + image.crop({ + x: 0, + y: region.top, + w: width, + h: region.height, + }) + + const inputMime = image.mime + const outputMime: JimpEncodableMime = + inputMime && JIMP_ENCODABLE_MIME_TYPES.has(inputMime as JimpEncodableMime) + ? (inputMime as JimpEncodableMime) + : "image/jpeg" + const croppedBuffer = + outputMime === "image/jpeg" + ? Buffer.from(await image.getBuffer(outputMime, { quality: 100 })) + : Buffer.from(await image.getBuffer(outputMime)) + + return { + buffer: croppedBuffer, + width, + height: region.height, + wasCropped: true, + inputWidth: width, + inputHeight: height, + trimPixels: region.top, + processor: "jimp", + } +} diff --git a/convex/lib/index.ts b/convex/lib/index.ts index 88576ed..03eafaa 100644 --- a/convex/lib/index.ts +++ b/convex/lib/index.ts @@ -22,6 +22,19 @@ export { type ErrorClassification, } from "./pollinations" +// Dirtberry processing utilities +export { + DIRTBERRY_TRIM_FRACTION, + DIRTBERRY_SOURCE_WIDTH, + DIRTBERRY_SOURCE_HEIGHT, + DIRTBERRY_OUTPUT_HEIGHT, + getDirtberrySourceDimensions, + isDirtberryModel, + calculateDirtberryCropRegion, + cropDirtberryImageBuffer, + type DirtberryCropResult, +} from "./dirtberryCrop" + // R2 storage utilities export { generateR2Key, diff --git a/convex/singleGenerationProcessor.ts b/convex/singleGenerationProcessor.ts index 2d0c5d1..9d5a07a 100644 --- a/convex/singleGenerationProcessor.ts +++ b/convex/singleGenerationProcessor.ts @@ -19,6 +19,9 @@ import { internalAction } from "./_generated/server" import { buildPollinationsUrl, classifyApiError, + cropDirtberryImageBuffer, + getDirtberrySourceDimensions, + isDirtberryModel, generateR2Key, generateThumbnailKey, uploadMediaWithThumbnail, @@ -119,6 +122,8 @@ export const processGeneration = internalAction({ try { const params = generation.generationParams + const shouldCropDirtberry = isDirtberryModel(params.model) + const dirtberrySourceDimensions = shouldCropDirtberry ? getDirtberrySourceDimensions() : null // Pollinations API only accepts seeds up to int32 max (2147483647) const INT32_MAX = 2147483647 const rawSeed = params.seed ?? Math.floor(Math.random() * INT32_MAX) @@ -134,8 +139,8 @@ export const processGeneration = internalAction({ prompt: params.prompt, negativePrompt: params.negativePrompt, model: params.model, - width: params.width, - height: params.height, + width: dirtberrySourceDimensions?.width ?? params.width, + height: dirtberrySourceDimensions?.height ?? params.height, seed, enhance: params.enhance, private: params.private, @@ -198,12 +203,42 @@ export const processGeneration = internalAction({ const imageBuffer = Buffer.from(await response.arrayBuffer()) const contentType = response.headers.get("content-type") || "image/jpeg" + let uploadBuffer = imageBuffer + let outputWidth = params.width ?? 1024 + let outputHeight = params.height ?? 1024 + + // Crop Dirtberry outputs before upload/persistence so every surface + // (canvas, gallery, downloads, lightbox) uses the same native asset. + if (shouldCropDirtberry) { + try { + const cropped = await cropDirtberryImageBuffer(imageBuffer) + uploadBuffer = Buffer.from(cropped.buffer) + outputWidth = cropped.width + outputHeight = cropped.height + + if (cropped.wasCropped) { + console.log( + `${logger} Applied Dirtberry crop (${cropped.processor}): ${cropped.inputWidth}x${cropped.inputHeight} -> ${cropped.width}x${cropped.height} (trim=${cropped.trimPixels}px)` + ) + } else { + console.log( + `${logger} Dirtberry crop skipped: source=${cropped.inputWidth}x${cropped.inputHeight} (image too small to trim safely)` + ) + } + } catch (cropError) { + console.error( + `${logger} Dirtberry crop failed, falling back to original image:`, + cropError + ) + } + } + // Upload to R2 (and thumbnail for images — videos defer secondary assets) const r2Key = generateR2Key(generation.ownerId, contentType) console.log(`${logger} Uploading to R2: ${r2Key}`) const { media: uploadResult, thumbnail: thumbnailResult } = await uploadMediaWithThumbnail( - imageBuffer, + uploadBuffer, r2Key, contentType ) @@ -240,16 +275,23 @@ export const processGeneration = internalAction({ previewR2Key: undefined, previewUrl: undefined, prompt: params.prompt, - width: params.width ?? 1024, - height: params.height ?? 1024, + width: outputWidth, + height: outputHeight, model: params.model ?? "flux", seed, contentType, sizeBytes: uploadResult.sizeBytes, - generationParams: { - ...params, - seed, - }, + generationParams: shouldCropDirtberry + ? { + ...params, + seed, + width: outputWidth, + height: outputHeight, + } + : { + ...params, + seed, + }, visibility: params.private ? "unlisted" : "public", }) diff --git a/hooks/queries/use-generate-image.ts b/hooks/queries/use-generate-image.ts index 929921d..c964d63 100644 --- a/hooks/queries/use-generate-image.ts +++ b/hooks/queries/use-generate-image.ts @@ -361,11 +361,16 @@ export function useGenerateImage( callbacksRef.current.onSettled?.(undefined, err, entry.params) entry.reject?.(err) } else if (generatedImage) { + const entryParams = entry.params as GeneratedImage["params"] const image: GeneratedImage = { id: generatedImage._id, url: generatedImage.url, prompt: generatedImage.prompt, - params: entry.params as GeneratedImage["params"], + params: { + ...entryParams, + width: generatedImage.width ?? entryParams.width, + height: generatedImage.height ?? entryParams.height, + }, timestamp: generatedImage.createdAt, r2Key: generatedImage.r2Key, sizeBytes: generatedImage.sizeBytes, diff --git a/hooks/use-generation-settings.ts b/hooks/use-generation-settings.ts index 1763b42..5da817a 100644 --- a/hooks/use-generation-settings.ts +++ b/hooks/use-generation-settings.ts @@ -306,10 +306,17 @@ export function useGenerationSettings(): UseGenerationSettingsReturn { // For fixed-size models (dimensionsEnabled: false), use standard dimensions if (!newConstraints.dimensionsEnabled && ratios.length > 0) { const firstRatio = ratios[0] - // Use standard dimensions for this ratio at the tier - const standardDims = getStandardDimensionsWithFallback(firstRatio.value, tierToUse) - setWidth(standardDims.width) - setHeight(standardDims.height) + // Single-tier fixed models define exact output dimensions in the model preset. + // Multi-tier fixed models (e.g., video) still use tier-based standard dimensions. + const supportedTierCount = newConstraints.supportedTiers?.length ?? 1 + if (supportedTierCount === 1) { + setWidth(firstRatio.width) + setHeight(firstRatio.height) + } else { + const standardDims = getStandardDimensionsWithFallback(firstRatio.value, tierToUse) + setWidth(standardDims.width) + setHeight(standardDims.height) + } setAspectRatio(firstRatio.value) } else { // For other models, try to preserve aspect ratio with standard dimensions diff --git a/lib/config/models.ts b/lib/config/models.ts index 3850d2c..edfdc4c 100644 --- a/lib/config/models.ts +++ b/lib/config/models.ts @@ -200,6 +200,13 @@ const DALLE3_STANDARD_ASPECT_RATIOS: readonly AspectRatioOption[] = ( ] as const ).map(withAspectRatioTags); +/** Dirtberry fixed portrait-only preset */ +const DIRTBERRY_ASPECT_RATIOS: readonly AspectRatioOption[] = ( + [ + { label: "Portrait", value: "9:16", width: 832, height: 1144, icon: "rectangle-vertical", category: "portrait" }, + ] as const +).map(withAspectRatioTags); + /** GPT 1.5 uses the same DALL-E 3 standard aspect ratios (1024², 1792×1024, 1024×1792) */ const GPTIMAGE_LARGE_ASPECT_RATIOS = DALLE3_STANDARD_ASPECT_RATIOS; @@ -523,6 +530,35 @@ export const MODEL_REGISTRY: Record = { modelPricing: IMAGE_MODEL_PRICING["grok-imagine"], }, + dirtberry: { + id: "dirtberry", + displayName: "Dirtberry", + type: "image", + icon: "sparkles", + logo: "/icon.png", + description: "Quick realistic image generation via api.airforce", + constraints: { + maxPixels: Infinity, + minPixels: 0, + minDimension: 832, + maxDimension: 1144, + step: 1, + defaultDimensions: { width: 832, height: 1144 }, + dimensionsEnabled: false, + // Seed not supported: api.airforce buildRequestBody only sends model, prompt, n, size. + // See: image.pollinations.ai/src/models/airforceModel.ts + supportsSeed: false, + supportedTiers: ["hd"], + outputCertainty: "exact", + dimensionWarning: "Dimensions are fixed for this model", + }, + aspectRatios: DIRTBERRY_ASPECT_RATIOS, + supportsNegativePrompt: false, + supportsReferenceImage: false, + // Dirtberry is kept safe-only in Bloom Studio (no NSFW/unrestricted flag) + modelPricing: IMAGE_MODEL_PRICING["dirtberry"], + }, + "gptimage-large": { id: "gptimage-large", displayName: "GPT 1.5", diff --git a/lib/schemas/pollinations-pricing.schema.ts b/lib/schemas/pollinations-pricing.schema.ts index 9ce096c..31b8455 100644 --- a/lib/schemas/pollinations-pricing.schema.ts +++ b/lib/schemas/pollinations-pricing.schema.ts @@ -349,6 +349,21 @@ export const IMAGE_MODEL_PRICING: Record = { perImage: 0.0025, // completionImageTokens from API }, }, + + /** + * Dirtberry - Quick realistic image generation via api.airforce + * Good efficiency: ~1000 images per pollen (same as FLUX.2 Dev) + */ + dirtberry: { + modelId: "dirtberry", + type: "image", + approximatePerPollen: 1000, + supportsReferenceImage: false, + isAlpha: true, + imagePricing: { + perImage: 0.001, // completionImageTokens from API (~free tier) + }, + }, } as const; // ============================================================================ diff --git a/lib/schemas/pollinations.schema.ts b/lib/schemas/pollinations.schema.ts index c689b13..4b9ee97 100644 --- a/lib/schemas/pollinations.schema.ts +++ b/lib/schemas/pollinations.schema.ts @@ -19,6 +19,8 @@ export const KnownImageModelSchema = z.enum([ "klein-large", "imagen-4", "grok-imagine", + "flux-2-dev", + "dirtberry", ]); export const ImageModelSchema = z.union([KnownImageModelSchema, z.string()]);