From ebd56c6cfeab4a496b6fbe664e04351913203275 Mon Sep 17 00:00:00 2001 From: Simplereally <120893410+Simplereally@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:15:23 +1000 Subject: [PATCH 1/3] Restore app shell and add ltx-2 support --- app/auth/pollinations/callback/page.test.tsx | 1 + app/auth/pollinations/callback/page.tsx | 28 +++-- app/layout.tsx | 40 +++---- app/page.tsx | 107 +++++++----------- components/studio/upgrade-modal.test.tsx | 3 +- convex/lib/pollinations.test.ts | 15 +++ convex/lib/pollinations.ts | 23 ++-- hooks/queries/use-image-models.test.ts | 1 + lib/config/api.config.ts | 1 + lib/config/models.test.ts | 52 ++++++++- lib/config/models.ts | 40 +++++++ lib/models/model-seo-slugs.test.ts | 7 +- lib/models/model-seo-slugs.ts | 7 ++ .../pollinations-pricing.schema.test.ts | 10 ++ lib/schemas/pollinations-pricing.schema.ts | 16 +++ lib/schemas/pollinations.schema.test.ts | 1 + lib/schemas/pollinations.schema.ts | 6 +- package.json | 3 + proxy.ts | 14 +-- 19 files changed, 235 insertions(+), 140 deletions(-) diff --git a/app/auth/pollinations/callback/page.test.tsx b/app/auth/pollinations/callback/page.test.tsx index 96cb928..829d6a7 100644 --- a/app/auth/pollinations/callback/page.test.tsx +++ b/app/auth/pollinations/callback/page.test.tsx @@ -38,6 +38,7 @@ const mockSetApiKey = vi.fn().mockResolvedValue({ success: true }); vi.mock("convex/react", () => ({ useMutation: () => mockSetApiKey, + useConvexAuth: () => ({ isAuthenticated: true, isLoading: false }), })); vi.mock("@/convex/_generated/api", () => ({ diff --git a/app/auth/pollinations/callback/page.tsx b/app/auth/pollinations/callback/page.tsx index 2810f25..bf9a15e 100644 --- a/app/auth/pollinations/callback/page.tsx +++ b/app/auth/pollinations/callback/page.tsx @@ -22,7 +22,7 @@ import { useEffect, useState, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Loader2, CheckCircle, XCircle, ArrowRight } from "lucide-react"; -import { useMutation } from "convex/react"; +import { useConvexAuth, useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; @@ -111,6 +111,7 @@ export default function PollinationsCallbackPage() { const searchParams = useSearchParams(); const [state, setState] = useState("processing"); const [redirectCountdown, setRedirectCountdown] = useState(3); + const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); const setApiKey = useMutation(api.users.setPollinationsApiKey); /** @@ -170,22 +171,25 @@ export default function PollinationsCallbackPage() { } }, [extractKeyFromHash, setApiKey]); - // Process the callback on mount - // NOTE: The 100ms delay is a defensive measure for browser timing quirks. - // Some browsers may not immediately populate `window.location.hash` in the - // same event loop tick after a redirect from an external OAuth provider. - // This ensures the hash fragment is parsed correctly after the page loads. - // - // Trade-off: If the hash is legitimately missing (e.g., user cancelled auth), - // there's a 100ms delay before showing the "Authorization Cancelled" error. - // Testing has shown this delay is necessary for reliable OAuth flows in Safari - // and some mobile browsers. + // Process the callback once Convex auth is ready. + // We must wait for isAuthLoading to be false before calling the mutation, + // because after the external OAuth redirect (full page reload), the Clerk + // auth token needs time to re-initialize and reach the Convex client. + // The 100ms delay handles browser quirks where window.location.hash + // isn't populated in the same tick after a redirect. useEffect(() => { + if (isAuthLoading) return; + const timer = setTimeout(() => { + if (!isAuthenticated) { + setState("error_save_failed"); + return; + } + void processCallback(); }, 100); return () => clearTimeout(timer); - }, [processCallback]); + }, [isAuthLoading, isAuthenticated, processCallback]); // Redirect countdown for success state // Note: We must NOT call router.push inside setRedirectCountdown, as this would diff --git a/app/layout.tsx b/app/layout.tsx index b2d2b99..2d4954d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,7 +6,6 @@ import { Toaster } from "@/components/ui/sonner" import { Analytics } from "@vercel/analytics/next" import { SpeedInsights } from "@vercel/speed-insights/next" import type { Metadata, Viewport } from "next" -import { headers } from "next/headers" import { Bricolage_Grotesque, Geist, Geist_Mono } from "next/font/google" import type React from "react" import { JsonLd } from "@/components/seo/json-ld" @@ -122,14 +121,11 @@ export const metadata: Metadata = { * @param props - Component props * @param props.children - The child components to render within the layout */ -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const requestHeaders = await headers() - const usePublicMaintenanceShell = requestHeaders.get("x-bloom-public-shell") === "maintenance" - return ( @@ -153,28 +149,18 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > - {usePublicMaintenanceShell ? ( - <> - {/* Temporary maintenance shell for the landing page. - Keep this path out of the Clerk/Convex app shell so `/` - can SSR without auth/profile/provider code touching Convex. */} - {children} - - - ) : ( - - - - -
- {children} - - - - - - - )} + + + + +
+ {children} + + + + + + diff --git a/app/page.tsx b/app/page.tsx index 1f5ef95..fc2563e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,34 @@ +import { CtaSection } from "@/components/landing/cta-section"; +import { FeaturesSection } from "@/components/landing/features-section"; +// import { FloatingGallery } from "@/components/landing/floating-gallery"; import { Footer } from "@/components/layout/footer"; +import { GLBackground } from "@/components/landing/gl-background"; +import { HeroSection } from "@/components/landing/hero-section"; +import { LandingHeader } from "@/components/landing/landing-header"; +// import { LivingStrip } from "@/components/landing/living-strip"; +import { ModelsSection } from "@/components/landing/models-section"; +import { ShowcaseSection } from "@/components/landing/showcase-section"; +import { ValuePropSection } from "@/components/landing/value-prop-section"; import { JsonLd } from "@/components/seo/json-ld"; -import Link from "next/link"; /** - * Temporary maintenance landing page. + * Landing Page - Server Component + * + * Premium landing page with hero, features showcase, model gallery, + * and compelling value proposition. Inspired by Leonardo.ai's layout + * but with unique creative elements. + * + * ARCHITECTURE FOR SEO: + * - This page is a SERVER COMPONENT, meaning all static content + * (Hero, Features, Models, CTA, Footer) is rendered on the server + * and included in the initial HTML response for optimal SEO. + * + * - Interactive/client-only parts are isolated into Client Components: + * - LandingHeader: Uses hooks for scroll detection and auth state + * - GLBackground: Dynamically loads WebGL canvas with ssr:false + * + * - This separation ensures search engines receive fully-rendered + * static content while users get the enhanced interactive experience. */ export default function LandingPage() { return ( @@ -23,70 +48,26 @@ export default function LandingPage() { description: "Cheap and powerful AI image and video generator studio.", }} /> -
-
-
- - Bloom Studio - -
- Service Interruption -
-
-
+ {/* Client Component: WebGL background (ssr: false, won't impact SEO) */} + -
-
-
-
- Bloom Studio is currently inaccessible. -
-
-

- Bloom Studio is currently in maintenance mode. -

-

- The application is temporarily unavailable while we work on restoring service. - Thank you for your patience. -

-
-
- - Contact Support - - - Read FAQ - -
-
+ {/* Client Component: Interactive header with scroll/auth */} + - -
-
+ {/* Static content - Server Rendered for SEO */} +
+ {/* */} + + {/* LivingStrip removed as requested */} + + + + + {/**/} + + + {/* Footer */}
diff --git a/components/studio/upgrade-modal.test.tsx b/components/studio/upgrade-modal.test.tsx index 089fdf1..e7fcfa0 100644 --- a/components/studio/upgrade-modal.test.tsx +++ b/components/studio/upgrade-modal.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ACTIVE_MODEL_COUNT } from "@/lib/config/models"; import { UpgradeModal } from "./upgrade-modal"; // Mock dependencies @@ -57,7 +58,7 @@ describe("UpgradeModal", () => { expect( screen.getByText("generations / day"), ).toBeInTheDocument(); - expect(screen.getByText("10")).toBeInTheDocument(); + expect(screen.getByText(String(ACTIVE_MODEL_COUNT))).toBeInTheDocument(); expect(screen.getByText("AI models included")).toBeInTheDocument(); expect(screen.getByText("Daily")).toBeInTheDocument(); }); diff --git a/convex/lib/pollinations.test.ts b/convex/lib/pollinations.test.ts index 5491c72..8f73510 100644 --- a/convex/lib/pollinations.test.ts +++ b/convex/lib/pollinations.test.ts @@ -34,6 +34,21 @@ describe("buildPollinationsUrl", () => { expect(parsed.searchParams.get("image")).toBe("https://example.com/first.jpg") expect(parsed.searchParams.get("image_urls")).toBeNull() }) + + it("does not encode last frame image for non-interpolation video models like ltx-2", () => { + const url = buildPollinationsUrl({ + prompt: "test prompt", + model: "ltx-2", + image: "https://example.com/first.jpg", + lastFrameImage: "https://example.com/second.jpg", + duration: 5, + aspectRatio: "16:9", + }) + + const parsed = new URL(url) + + expect(parsed.searchParams.get("image")).toBe("https://example.com/first.jpg") + }) }) // ============================================================ diff --git a/convex/lib/pollinations.ts b/convex/lib/pollinations.ts index c0675ba..84927c8 100644 --- a/convex/lib/pollinations.ts +++ b/convex/lib/pollinations.ts @@ -14,8 +14,8 @@ export const POLLINATIONS_BASE_URL = "https://gen.pollinations.ai" export const POLLINATIONS_FETCH_TIMEOUT_MS = 10 * 60 * 1000 -/** Video model IDs - these are the only models that support duration, aspectRatio, audio, and lastFrameImage */ -const VIDEO_MODELS = ["veo", "seedance", "seedance-pro", "wan", "grok-video"] as const +/** Video model IDs - these are the only models that accept video-specific query params */ +const VIDEO_MODELS = ["veo", "seedance", "seedance-pro", "wan", "ltx-2", "grok-video"] as const // ============================================================ // Types @@ -104,20 +104,11 @@ export function buildPollinationsUrl(params: PollinationsUrlParams): string { // Video-specific parameters - only include for video models const isVideoModel = params.model && VIDEO_MODELS.includes(params.model as typeof VIDEO_MODELS[number]) if (isVideoModel) { - // Reference image(s): handle different formats for different video models - if (params.model === "grok-video") { - if (params.image) { - queryParams.append("image", params.image) - } - } else { - // Other video models: for models that support interpolation (two reference images), - // join both URLs with "|" in a single `image` param so the Pollinations gateway - // splits them into the upstream `image_urls` array. - if (params.image && params.lastFrameImage) { - queryParams.append("image", `${params.image}|${params.lastFrameImage}`) - } else if (params.image) { - queryParams.append("image", params.image) - } + // Reference image(s): only Veo currently supports interpolation with start+end frames. + if (params.model === "veo" && params.image && params.lastFrameImage) { + queryParams.append("image", `${params.image}|${params.lastFrameImage}`) + } else if (params.image) { + queryParams.append("image", params.image) } if (params.duration !== undefined && params.duration > 0) { diff --git a/hooks/queries/use-image-models.test.ts b/hooks/queries/use-image-models.test.ts index cd21694..f1d5e33 100644 --- a/hooks/queries/use-image-models.test.ts +++ b/hooks/queries/use-image-models.test.ts @@ -87,6 +87,7 @@ describe("useImageModels", () => { expect(result.current.models).toEqual(expectedVideoModels) expect(result.current.models.every(m => m.type === "video")).toBe(true) expect(result.current.models.every(m => !m.isLegacy)).toBe(true) + expect(result.current.models.map(m => m.id)).toContain("ltx-2") // Verify alphabetical ordering const displayNames = result.current.models.map(m => m.displayName) diff --git a/lib/config/api.config.ts b/lib/config/api.config.ts index 7594d1f..a962458 100644 --- a/lib/config/api.config.ts +++ b/lib/config/api.config.ts @@ -55,6 +55,7 @@ export const API_CONSTRAINTS = { seedance: { min: 2, max: 10 }, "seedance-pro": { min: 2, max: 10 }, wan: { min: 2, max: 15 }, + "ltx-2": { min: 1, max: 10 }, "grok-video": { min: 1, max: 10 }, } satisfies Record, } as const; diff --git a/lib/config/models.test.ts b/lib/config/models.test.ts index 38d5aff..cd7dd5a 100644 --- a/lib/config/models.test.ts +++ b/lib/config/models.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { ALL_MODEL_IDS, ACTIVE_IMAGE_MODEL_IDS, + ACTIVE_VIDEO_MODEL_IDS, IMAGE_MODEL_IDS, LEGACY_MODEL_IDS, MODEL_REGISTRY, @@ -38,6 +39,7 @@ describe("Model Registry", () => { "imagen-4", "grok-imagine", "flux-2-dev", + "dirtberry", ] for (const modelId of expectedImageModels) { @@ -47,7 +49,7 @@ describe("Model Registry", () => { }) it("should contain all expected video models", () => { - const expectedVideoModels = ["seedance-pro", "seedance", "veo", "wan", "grok-video"] + const expectedVideoModels = ["seedance-pro", "seedance", "veo", "wan", "ltx-2", "grok-video"] for (const modelId of expectedVideoModels) { expect(MODEL_REGISTRY[modelId]).toBeDefined() @@ -70,6 +72,7 @@ describe("Model Registry", () => { 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["ltx-2"].displayName).toBe("LTX-2") expect(MODEL_REGISTRY["klein"].displayName).toBe("FLUX.2 Klein 4B") expect(MODEL_REGISTRY["klein-large"].displayName).toBe("FLUX.2 Klein 9B") expect(MODEL_REGISTRY["imagen-4"].displayName).toBe("Imagen 4") @@ -199,15 +202,17 @@ describe("Model Registry", () => { expect(IMAGE_MODEL_IDS).toContain("imagen-4") expect(IMAGE_MODEL_IDS).toContain("grok-imagine") expect(IMAGE_MODEL_IDS).toContain("dirtberry") + expect(IMAGE_MODEL_IDS).not.toContain("dirtberry-pro") expect(IMAGE_MODEL_IDS).toContain("flux-2-dev") expect(IMAGE_MODEL_IDS).not.toContain("veo") }) it("should have correct video model IDs", () => { - expect(VIDEO_MODEL_IDS.length).toBe(5) + expect(VIDEO_MODEL_IDS.length).toBe(6) expect(VIDEO_MODEL_IDS).toContain("veo") expect(VIDEO_MODEL_IDS).toContain("seedance") expect(VIDEO_MODEL_IDS).toContain("wan") + expect(VIDEO_MODEL_IDS).toContain("ltx-2") expect(VIDEO_MODEL_IDS).toContain("grok-video") expect(VIDEO_MODEL_IDS).not.toContain("zimage") }) @@ -218,6 +223,10 @@ describe("Model Registry", () => { expect(ACTIVE_IMAGE_MODEL_IDS).toContain("flux-2-dev") }) + it("should include ltx-2 in ACTIVE_VIDEO_MODEL_IDS (not legacy)", () => { + expect(ACTIVE_VIDEO_MODEL_IDS).toContain("ltx-2") + }) + it("should NOT include flux-2-dev in LEGACY_MODEL_IDS", () => { expect(LEGACY_MODEL_IDS).not.toContain("flux-2-dev") }) @@ -891,10 +900,46 @@ describe("Video Model Properties", () => { }) }) + describe("LTX-2", () => { + it("should have duration constraints 1-10s", () => { + const model = getModel("ltx-2")! + expect(model.durationConstraints?.min).toBe(1) + expect(model.durationConstraints?.max).toBe(10) + expect(model.durationConstraints?.defaultDuration).toBe(5) + }) + + it("should not support audio or reference images", () => { + const model = getModel("ltx-2")! + expect(model.supportsAudio).toBeUndefined() + expect(model.supportsReferenceImage).toBe(false) + expect(model.supportsInterpolation).toBeUndefined() + }) + + it("should have 1MP fixed video constraints", () => { + const model = getModel("ltx-2")! + expect(model.constraints.maxPixels).toBe(1_048_576) + expect(model.constraints.minDimension).toBe(256) + expect(model.constraints.maxDimension).toBe(1024) + expect(model.constraints.step).toBe(32) + expect(model.constraints.defaultDimensions).toEqual({ width: 1024, height: 576 }) + expect(model.constraints.dimensionsEnabled).toBe(false) + expect(model.constraints.supportedTiers).toEqual(["sd"]) + }) + + it("should have per-second pricing at $0.005/s", () => { + const model = getModel("ltx-2")! + expect(model.modelPricing.type).toBe("video") + expect(model.modelPricing.videoPricing?.perSecond).toBe(0.005) + expect(model.modelPricing.supportsReferenceImage).toBe(false) + expect(model.modelPricing.isAlpha).toBe(true) + }) + }) + describe("Video aspect ratios", () => { it("should only support 16:9 and 9:16 for video models", () => { const veoRatios = getModelAspectRatios("veo")! const seedanceRatios = getModelAspectRatios("seedance")! + const ltx2Ratios = getModelAspectRatios("ltx-2")! const grokVideoRatios = getModelAspectRatios("grok-video")! expect(veoRatios.length).toBe(2) @@ -903,6 +948,9 @@ describe("Video Model Properties", () => { expect(seedanceRatios.length).toBe(2) expect(seedanceRatios.map(r => r.value)).toEqual(["16:9", "9:16"]) + expect(ltx2Ratios.length).toBe(2) + expect(ltx2Ratios.map(r => r.value)).toEqual(["16:9", "9:16"]) + expect(grokVideoRatios.length).toBe(2) expect(grokVideoRatios.map(r => r.value)).toEqual(["16:9", "9:16"]) }) diff --git a/lib/config/models.ts b/lib/config/models.ts index 116562d..db2d6d3 100644 --- a/lib/config/models.ts +++ b/lib/config/models.ts @@ -864,6 +864,46 @@ export const MODEL_REGISTRY: Record = { isLegacy: true, }, + /** + * ltx-2 — Lambda-backed LTX video generation. + * + * Upstream behavior (image.pollinations.ai/src/models/ltx2VideoModel.ts): + * - Duration defaults to 5s, max 10s + * - Width/height are rounded to multiples of 32 + * - Max pixel budget is 1 MP + * - Text-only input path; no reference image or interpolation support in runtime + * - Seed is not forwarded to the backend + */ + "ltx-2": { + id: "ltx-2", + displayName: "LTX-2", + type: "video", + icon: "video", + description: "Fast text-to-video generation with an upscaler, optimized for short 720p-class clips", + constraints: { + maxPixels: 1_048_576, + minPixels: 65_536, + minDimension: 256, + maxDimension: 1024, + step: 32, + defaultDimensions: { width: 1024, height: 576 }, + dimensionsEnabled: false, + supportsSeed: false, + supportedTiers: ["sd"], + outputCertainty: "exact", + dimensionWarning: "Uses fixed 16:9 or 9:16 frames rounded to multiples of 32", + }, + aspectRatios: VIDEO_ASPECT_RATIOS, + supportsNegativePrompt: false, + supportsReferenceImage: false, + durationConstraints: { + min: 1, + max: 10, + defaultDuration: 5, + }, + modelPricing: VIDEO_MODEL_PRICING["ltx-2"], + }, + /** * grok-video — xAI video generation via api.airforce * diff --git a/lib/models/model-seo-slugs.test.ts b/lib/models/model-seo-slugs.test.ts index be883cf..010392f 100644 --- a/lib/models/model-seo-slugs.test.ts +++ b/lib/models/model-seo-slugs.test.ts @@ -15,7 +15,7 @@ import { describe("MODEL_SEO_SLUGS registry", () => { it("should contain only active (non-legacy) models", () => { - // Current active set: 8 image + 1 video = 9 + // Current active set: 8 image + 2 video = 10 expect(MODEL_SEO_SLUGS.length).toBeGreaterThan(0) }) @@ -24,9 +24,9 @@ describe("MODEL_SEO_SLUGS registry", () => { expect(imageEntries).toHaveLength(8) }) - it("should have 1 video model", () => { + it("should have 2 video models", () => { const videoEntries = MODEL_SEO_SLUGS.filter((e) => e.type === "video") - expect(videoEntries).toHaveLength(1) + expect(videoEntries).toHaveLength(2) }) it("should have unique slugs", () => { @@ -138,6 +138,7 @@ describe("getAllModelSlugs", () => { expect(slugs).toHaveLength(MODEL_SEO_SLUGS.length) expect(slugs).toContain("flux-2-dev") expect(slugs).toContain("flux-schnell") + expect(slugs).toContain("ltx-2") }) it("should contain only unique values", () => { diff --git a/lib/models/model-seo-slugs.ts b/lib/models/model-seo-slugs.ts index 6414a1a..c53db4a 100644 --- a/lib/models/model-seo-slugs.ts +++ b/lib/models/model-seo-slugs.ts @@ -133,6 +133,13 @@ const SEO_METADATA: readonly ModelSlugEntry[] = [ type: "video", categories: ["create", "features"], }, + { + modelId: "ltx-2", + slug: "ltx-2", + displayName: "LTX-2", + type: "video", + categories: ["create", "features"], + }, ]; // ============================================================================ diff --git a/lib/schemas/pollinations-pricing.schema.test.ts b/lib/schemas/pollinations-pricing.schema.test.ts index b1f446f..bd3af71 100644 --- a/lib/schemas/pollinations-pricing.schema.test.ts +++ b/lib/schemas/pollinations-pricing.schema.test.ts @@ -86,6 +86,14 @@ describe("pollinations-pricing.schema", () => { expect(cost).toBe(0.15 * 5); }); + it("should calculate cost for ltx-2 at $0.005/s", () => { + const cost5s = estimateVideoCost("ltx-2", 5); + expect(cost5s).toBe(0.005 * 5); + + const cost10s = estimateVideoCost("ltx-2", 10); + expect(cost10s).toBe(0.005 * 10); + }); + it("should calculate cost for grok-video at $0.0025/s", () => { // grok-video is 0.0025 per second const cost5s = estimateVideoCost("grok-video", 5); @@ -141,6 +149,7 @@ describe("pollinations-pricing.schema", () => { it("should return false for models that do not support reference image", () => { expect(modelSupportsReferenceImage("flux")).toBe(false); expect(modelSupportsReferenceImage("flux-2-dev")).toBe(false); + expect(modelSupportsReferenceImage("ltx-2")).toBe(false); }); it("should return false for unknown models", () => { @@ -151,6 +160,7 @@ describe("pollinations-pricing.schema", () => { describe("isModelAlpha", () => { it("should return true for alpha models", () => { expect(isModelAlpha("veo")).toBe(true); + expect(isModelAlpha("ltx-2")).toBe(true); expect(isModelAlpha("grok-video")).toBe(true); }); diff --git a/lib/schemas/pollinations-pricing.schema.ts b/lib/schemas/pollinations-pricing.schema.ts index 31b8455..5f6332e 100644 --- a/lib/schemas/pollinations-pricing.schema.ts +++ b/lib/schemas/pollinations-pricing.schema.ts @@ -364,6 +364,7 @@ export const IMAGE_MODEL_PRICING: Record = { perImage: 0.001, // completionImageTokens from API (~free tier) }, }, + } as const; // ============================================================================ @@ -434,6 +435,21 @@ export const VIDEO_MODEL_PRICING: Record = { }, }, + /** + * LTX-2 - Fast text-to-video generation with an upscaler + * ~40 videos per pollen at the default 5s duration + */ + "ltx-2": { + modelId: "ltx-2", + type: "video", + approximatePerPollen: 40, + supportsReferenceImage: false, + isAlpha: true, + videoPricing: { + perSecond: 0.005, + }, + }, + /** * Grok Video - xAI video generation via api.airforce * Per-second pricing, alpha status diff --git a/lib/schemas/pollinations.schema.test.ts b/lib/schemas/pollinations.schema.test.ts index f12ca39..111020a 100644 --- a/lib/schemas/pollinations.schema.test.ts +++ b/lib/schemas/pollinations.schema.test.ts @@ -56,6 +56,7 @@ describe("pollinations.schema", () => { expect(VideoModelSchema.parse("veo")).toBe("veo"); expect(VideoModelSchema.parse("seedance")).toBe("seedance"); expect(VideoModelSchema.parse("seedance-pro")).toBe("seedance-pro"); + expect(VideoModelSchema.parse("ltx-2")).toBe("ltx-2"); expect(VideoModelSchema.parse("grok-video")).toBe("grok-video"); }); diff --git a/lib/schemas/pollinations.schema.ts b/lib/schemas/pollinations.schema.ts index 4b9ee97..02eb73d 100644 --- a/lib/schemas/pollinations.schema.ts +++ b/lib/schemas/pollinations.schema.ts @@ -26,7 +26,7 @@ export const KnownImageModelSchema = z.enum([ export const ImageModelSchema = z.union([KnownImageModelSchema, z.string()]); // Video models -export const VideoModelSchema = z.enum(["veo", "seedance", "seedance-pro", "wan", "grok-video"]); +export const VideoModelSchema = z.enum(["veo", "seedance", "seedance-pro", "wan", "ltx-2", "grok-video"]); // Combined model schema export const GenerationModelSchema = z.union([ImageModelSchema, VideoModelSchema]); @@ -60,8 +60,8 @@ export const VideoGenerationParamsSchema = ImageGenerationParamsSchema.extend({ model: VideoModelSchema, duration: z.number().int().min(1).max(10).optional(), aspectRatio: VideoAspectRatioSchema.optional(), - audio: z.boolean().optional().default(false), // veo only - lastFrameImage: z.string().optional(), // Last frame image URL for interpolation (veo only) + audio: z.boolean().optional().default(false), // Supported by select video models + lastFrameImage: z.string().optional(), // Last frame image URL for interpolation-capable video models }); // Combined parameter schema for generated results diff --git a/package.json b/package.json index ddcb129..18fdbb6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "cf:dev": "wrangler dev --env development", "cf:types": "wrangler types --env development", "dev": "next dev", + "dev:convex": "CONVEX_AGENT_MODE=anonymous bunx convex dev --local", + "dev:convex:once": "CONVEX_AGENT_MODE=anonymous bunx convex dev --local --once", + "dev:convex:cloud": "bunx convex dev", "typecheck": "bunx tsc --noEmit", "lint": "eslint . --cache --cache-location .cache/eslint", "lint:fix": "eslint . --fix", diff --git a/proxy.ts b/proxy.ts index e235aa7..972cf79 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,4 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server" -import { NextResponse } from "next/server" /** * Route matcher for protected routes that require authentication. @@ -17,24 +16,13 @@ export const isProtectedRoute = createRouteMatcher([ /** * Clerk middleware for authentication enforcement at the edge. - * + * * This middleware runs before every request and: * - Checks if the route is protected * - Redirects unauthenticated users to sign-in for protected routes * - Allows all other routes to pass through */ export default clerkMiddleware(async (auth, req) => { - if (req.nextUrl.pathname === "/") { - const requestHeaders = new Headers(req.headers) - requestHeaders.set("x-bloom-public-shell", "maintenance") - - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) - } - if (isProtectedRoute(req)) await auth.protect() }) From 92fa3cb109b4a28e47c14906513e6c5273ad5bb7 Mon Sep 17 00:00:00 2001 From: Simplereally <120893410+Simplereally@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:36:22 +1000 Subject: [PATCH 2/3] Address callback and ltx-2 review findings --- .../pollinations/callback/actions.test.ts | 63 +++++++ app/auth/pollinations/callback/actions.ts | 51 ++++++ app/auth/pollinations/callback/page.test.tsx | 76 ++++++-- app/auth/pollinations/callback/page.tsx | 169 +++++++++++------- .../features/generation/controls-feature.tsx | 6 +- .../generation/controls-view.test.tsx | 16 ++ .../features/generation/controls-view.tsx | 2 +- .../studio/layout/studio-shell.test.tsx | 37 ++++ components/studio/layout/studio-shell.tsx | 26 ++- convex/lib/pollinations.test.ts | 4 +- convex/lib/pollinations.ts | 6 +- hooks/use-generation-settings.test.ts | 27 +++ hooks/use-generation-settings.ts | 4 +- 13 files changed, 393 insertions(+), 94 deletions(-) create mode 100644 app/auth/pollinations/callback/actions.test.ts create mode 100644 app/auth/pollinations/callback/actions.ts diff --git a/app/auth/pollinations/callback/actions.test.ts b/app/auth/pollinations/callback/actions.test.ts new file mode 100644 index 0000000..a1bb1dc --- /dev/null +++ b/app/auth/pollinations/callback/actions.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { savePollinationsApiKey } from "./actions"; +import { fetchMutation } from "convex/nextjs"; +import { getConvexClerkToken } from "@/app/_server/convex/client"; +import { api } from "@/convex/_generated/api"; + +vi.mock("convex/nextjs", () => ({ + fetchMutation: vi.fn(), +})); + +vi.mock("@/app/_server/convex/client", () => ({ + getConvexClerkToken: vi.fn(), +})); + +vi.mock("@/convex/_generated/api", () => ({ + api: { + users: { + setPollinationsApiKey: "users:setPollinationsApiKey", + }, + }, +})); + +describe("savePollinationsApiKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getConvexClerkToken).mockResolvedValue("convex-token"); + vi.mocked(fetchMutation).mockResolvedValue({ success: true }); + }); + + it("stores a valid API key with the Clerk Convex token", async () => { + const result = await savePollinationsApiKey("sk_test1234"); + + expect(result).toEqual({ status: "success" }); + expect(fetchMutation).toHaveBeenCalledWith( + api.users.setPollinationsApiKey, + { apiKey: "sk_test1234" }, + { token: "convex-token" }, + ); + }); + + it("rejects a missing API key before calling Convex", async () => { + const result = await savePollinationsApiKey(null); + + expect(result).toEqual({ status: "error_missing_key" }); + expect(fetchMutation).not.toHaveBeenCalled(); + }); + + it("rejects an invalid API key before calling Convex", async () => { + const result = await savePollinationsApiKey("invalid"); + + expect(result).toEqual({ status: "error_invalid_key" }); + expect(fetchMutation).not.toHaveBeenCalled(); + }); + + it("returns save failure when no auth token is available", async () => { + vi.mocked(getConvexClerkToken).mockResolvedValue(undefined); + + const result = await savePollinationsApiKey("sk_test1234"); + + expect(result).toEqual({ status: "error_save_failed" }); + expect(fetchMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/app/auth/pollinations/callback/actions.ts b/app/auth/pollinations/callback/actions.ts new file mode 100644 index 0000000..cd32c20 --- /dev/null +++ b/app/auth/pollinations/callback/actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; +import { getConvexClerkToken } from "@/app/_server/convex/client"; +import { isValidApiKeyFormat } from "@/lib/pollen-auth/storage"; + +export type SavePollinationsApiKeyResult = { + status: + | "idle" + | "success" + | "error_missing_key" + | "error_invalid_key" + | "error_save_failed"; +}; + +/** + * Stores the Pollinations API key from the OAuth callback via an authenticated + * server boundary. The browser still has to read the URL hash because fragments + * are not sent to the server. + */ +export async function savePollinationsApiKey( + apiKey: string | null, +): Promise { + if (!apiKey) { + return { status: "error_missing_key" }; + } + + if (!isValidApiKeyFormat(apiKey)) { + return { status: "error_invalid_key" }; + } + + try { + const token = await getConvexClerkToken(); + + if (!token) { + return { status: "error_save_failed" }; + } + + await fetchMutation( + api.users.setPollinationsApiKey, + { apiKey }, + { token }, + ); + + return { status: "success" }; + } catch (error) { + console.error("[PollinationsCallback] Error saving API key:", error); + return { status: "error_save_failed" }; + } +} diff --git a/app/auth/pollinations/callback/page.test.tsx b/app/auth/pollinations/callback/page.test.tsx index 829d6a7..c49d7ed 100644 --- a/app/auth/pollinations/callback/page.test.tsx +++ b/app/auth/pollinations/callback/page.test.tsx @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, fireEvent } from "@testing-library/react"; import PollinationsCallbackPage from "./page"; // Mock next/navigation @@ -33,20 +33,14 @@ vi.mock("@/lib/pollen-auth", () => ({ getCallbackUrl: vi.fn(() => "https://example.com/auth/pollinations/callback"), })); -// Mock Convex -const mockSetApiKey = vi.fn().mockResolvedValue({ success: true }); +// Mock server action +const mockSavePollinationsApiKey = vi.hoisted(() => + vi.fn().mockResolvedValue({ status: "success" }), +); -vi.mock("convex/react", () => ({ - useMutation: () => mockSetApiKey, - useConvexAuth: () => ({ isAuthenticated: true, isLoading: false }), -})); - -vi.mock("@/convex/_generated/api", () => ({ - api: { - users: { - setPollinationsApiKey: "setPollinationsApiKey", - }, - }, +vi.mock("./actions", () => ({ + savePollinationsApiKey: (apiKey: string | null) => + mockSavePollinationsApiKey(apiKey), })); // Mock sonner @@ -115,9 +109,9 @@ describe("PollinationsCallbackPage", () => { } /** - * Helper to advance past processing and wait for success state. + * Helper to advance past processing and wait for the user-confirmation state. */ - async function advanceToSuccessState() { + async function advanceToReadyState() { await advancePastProcessingDelay(); // Allow React to process the state update await act(async () => { @@ -125,6 +119,25 @@ describe("PollinationsCallbackPage", () => { }); } + /** + * Helper to submit the server-action form and wait for success state. + */ + async function submitConnection() { + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: /Finish Connection/i }), + ); + }); + } + + /** + * Helper to advance past processing and submit the connection. + */ + async function advanceToSuccessState() { + await advanceToReadyState(); + await submitConnection(); + } + describe("returnTo validation (isSafeReturnTo)", () => { it("should accept valid local paths", async () => { window.location.hash = "#api_key=sk_test123"; @@ -279,6 +292,7 @@ describe("PollinationsCallbackPage", () => { "", "/auth/pollinations/callback?returnTo=/dashboard", ); + expect(mockSavePollinationsApiKey).toHaveBeenCalledWith("sk_test123"); }); it("should work correctly when there is no query string", async () => { @@ -295,6 +309,36 @@ describe("PollinationsCallbackPage", () => { "/auth/pollinations/callback", ); }); + + it("should clear hash immediately during callback processing delay", () => { + window.location.hash = "#api_key=sk_test123"; + window.location.search = "?returnTo=/studio"; + + render(); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + "", + "/auth/pollinations/callback?returnTo=/studio", + ); + expect(mockSavePollinationsApiKey).not.toHaveBeenCalled(); + }); + + it("should clear hash before invalid-key callback bailout", async () => { + window.location.hash = "#api_key=invalid_key"; + + render(); + + await advancePastProcessingDelay(); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + "", + "/auth/pollinations/callback", + ); + expect(mockSavePollinationsApiKey).not.toHaveBeenCalled(); + expect(screen.getByText(/Invalid API Key/i)).toBeInTheDocument(); + }); }); describe("error states", () => { diff --git a/app/auth/pollinations/callback/page.tsx b/app/auth/pollinations/callback/page.tsx index bf9a15e..0370457 100644 --- a/app/auth/pollinations/callback/page.tsx +++ b/app/auth/pollinations/callback/page.tsx @@ -4,14 +4,16 @@ * Pollinations OAuth Callback Handler * * This page handles the redirect back from Pollinations after OAuth authorization. - * It extracts the API key from the URL hash fragment and stores it in Convex. + * It extracts the API key from the URL hash fragment and stores it in Convex + * through an authenticated server action. * * ## Flow * 1. User clicks "Connect to Pollinations" in the app * 2. User is redirected to Pollinations to authorize * 3. Pollinations redirects back here with the API key in the hash: #api_key=sk_... - * 4. This page extracts the key, validates it, and stores it in Convex (encrypted) - * 5. User is redirected back to the Studio + * 4. This page extracts and validates the key using browser-only URL access + * 5. User confirms the connection, then a server action stores it in Convex (encrypted) + * 6. User is redirected back to the Studio * * ## Security Notes * - The API key is passed in the URL hash fragment (#), NOT the query string @@ -19,11 +21,9 @@ * - The key is stored encrypted in Convex (AES-256-GCM) */ -import { useEffect, useState, useCallback } from "react"; +import { useActionState, useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Loader2, CheckCircle, XCircle, ArrowRight } from "lucide-react"; -import { useConvexAuth, useMutation } from "convex/react"; -import { api } from "@/convex/_generated/api"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { @@ -32,10 +32,15 @@ import { buildAuthorizationUrl, getCallbackUrl, } from "@/lib/pollen-auth"; +import { + savePollinationsApiKey, + type SavePollinationsApiKeyResult, +} from "./actions"; /** Possible states of the callback handler */ type CallbackState = | "processing" + | "ready" | "success" | "error_missing_key" | "error_invalid_key" @@ -62,6 +67,8 @@ const ERROR_MESSAGES: Record = { /** Default redirect path when returnTo is invalid or missing */ const DEFAULT_RETURN_PATH = "/studio"; +const INITIAL_ACTION_RESULT: SavePollinationsApiKeyResult = { status: "idle" }; +const PROCESSING_DELAY_MS = 100; /** * Validates that a returnTo path is safe for redirection. @@ -106,90 +113,102 @@ function getSafeReturnTo(searchParams: { return isSafeReturnTo(returnTo) ? returnTo : DEFAULT_RETURN_PATH; } +function clearUrlHash() { + if (typeof window === "undefined") return; + + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); +} + export default function PollinationsCallbackPage() { const router = useRouter(); const searchParams = useSearchParams(); - const [state, setState] = useState("processing"); + const [callbackState, setCallbackState] = + useState("processing"); + const [apiKey, setApiKey] = useState(null); const [redirectCountdown, setRedirectCountdown] = useState(3); - const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); - const setApiKey = useMutation(api.users.setPollinationsApiKey); + const hasShownSuccessToast = useRef(false); + const [actionResult, submitConnection, isPending] = useActionState( + async ( + _previousState: SavePollinationsApiKeyResult, + _formData: FormData, + ) => savePollinationsApiKey(apiKey), + INITIAL_ACTION_RESULT, + ); + const state: CallbackState = isPending + ? "processing" + : actionResult.status === "idle" + ? callbackState + : actionResult.status; /** * Extracts the API key from the URL hash fragment. * The hash format is: #api_key=sk_xxxxx */ - const extractKeyFromHash = useCallback((): string | null => { - if (typeof window === "undefined") return null; - - const hash = window.location.hash; - if (!hash || hash.length < 2) return null; - - // Remove the leading # and parse as URLSearchParams - const params = new URLSearchParams(hash.slice(1)); - return params.get(CALLBACK_KEY_PARAM); - }, []); + const extractKeyFromHash = useCallback( + (rawHash?: string | null): string | null => { + if (typeof window === "undefined") return null; + + const hash = rawHash ?? window.location.hash; + if (!hash || hash.length < 2) return null; + + // Remove the leading # and parse as URLSearchParams + const params = new URLSearchParams(hash.slice(1)); + return params.get(CALLBACK_KEY_PARAM); + }, + [], + ); /** - * Processes the OAuth callback by extracting and storing the API key. + * Extracts and validates the OAuth callback hash. This effect is limited to + * browser-only URL access and state updates; the Convex write happens only + * through the form action. */ - const processCallback = useCallback(async () => { - try { - // Extract key from hash - const apiKey = extractKeyFromHash(); + useEffect(() => { + let callbackHash = window.location.hash; + + if (callbackHash) { + // Clear the hash from the URL for security (prevent accidental sharing) + // before waiting for browser redirect timing quirks to settle. + clearUrlHash(); + } + + const timer = window.setTimeout(() => { + if (!callbackHash && window.location.hash) { + callbackHash = window.location.hash; + clearUrlHash(); + } + + const apiKey = extractKeyFromHash(callbackHash); if (!apiKey) { - setState("error_missing_key"); + setCallbackState("error_missing_key"); return; } - // Validate key format if (!isValidApiKeyFormat(apiKey)) { - setState("error_invalid_key"); + setCallbackState("error_invalid_key"); return; } - // Clear the hash from the URL for security (prevent accidental sharing) - // Do this before the async call to minimize exposure time - if (typeof window !== "undefined") { - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - } + setApiKey(apiKey); + setCallbackState("ready"); + }, PROCESSING_DELAY_MS); - // Store the key in Convex (encrypted server-side) - await setApiKey({ apiKey }); + return () => window.clearTimeout(timer); + }, [extractKeyFromHash]); - setState("success"); + useEffect(() => { + if (actionResult.status === "success" && !hasShownSuccessToast.current) { + hasShownSuccessToast.current = true; toast.success("Connected to Pollinations successfully!", { description: "You can now generate images with your own Pollen wallet.", }); - } catch (error) { - console.error("[PollinationsCallback] Error processing callback:", error); - setState("error_save_failed"); } - }, [extractKeyFromHash, setApiKey]); - - // Process the callback once Convex auth is ready. - // We must wait for isAuthLoading to be false before calling the mutation, - // because after the external OAuth redirect (full page reload), the Clerk - // auth token needs time to re-initialize and reach the Convex client. - // The 100ms delay handles browser quirks where window.location.hash - // isn't populated in the same tick after a redirect. - useEffect(() => { - if (isAuthLoading) return; - - const timer = setTimeout(() => { - if (!isAuthenticated) { - setState("error_save_failed"); - return; - } - - void processCallback(); - }, 100); - return () => clearTimeout(timer); - }, [isAuthLoading, isAuthenticated, processCallback]); + }, [actionResult.status]); // Redirect countdown for success state // Note: We must NOT call router.push inside setRedirectCountdown, as this would @@ -241,7 +260,9 @@ export default function PollinationsCallbackPage() {

- Connecting to Pollinations... + {isPending + ? "Saving your connection..." + : "Connecting to Pollinations..."}

Please wait while we complete the authorization. @@ -249,6 +270,26 @@ export default function PollinationsCallbackPage() { )} + {state === "ready" && ( + <> +

+ +
+

+ Finish connecting to Pollinations +

+

+ Your Pollinations key is ready to save. +

+
+ +
+ + )} + {state === "success" && ( <>
diff --git a/components/studio/features/generation/controls-feature.tsx b/components/studio/features/generation/controls-feature.tsx index 14a498f..2c46ad4 100644 --- a/components/studio/features/generation/controls-feature.tsx +++ b/components/studio/features/generation/controls-feature.tsx @@ -83,6 +83,10 @@ function ControlsFeatureView({ // Get current model definition for video-specific properties const currentModelDef = getModel(generationSettings.model) + const maxReferenceFrames = React.useMemo(() => { + if (!currentModelDef?.supportsReferenceImage) return 0 + return currentModelDef.supportsInterpolation ? 2 : (currentModelDef.referenceFrameCount ?? 1) + }, [currentModelDef]) // Convert array-based reference images to first/last frame format for interpolation models const interpolationImages = React.useMemo(() => ({ @@ -176,7 +180,7 @@ function ControlsFeatureView({ durationConstraints={currentModelDef?.durationConstraints} supportsAudio={currentModelDef?.supportsAudio ?? false} supportsInterpolation={currentModelDef?.supportsInterpolation ?? false} - maxReferenceFrames={currentModelDef?.referenceFrameCount ?? 2} + maxReferenceFrames={maxReferenceFrames} // History images for reference image browser historyImages={historyImages} diff --git a/components/studio/features/generation/controls-view.test.tsx b/components/studio/features/generation/controls-view.test.tsx index efc5073..9b25f16 100644 --- a/components/studio/features/generation/controls-view.test.tsx +++ b/components/studio/features/generation/controls-view.test.tsx @@ -359,4 +359,20 @@ describe("ControlsView", () => { expect(screen.getByTestId("video-reference-frames-picker")).toHaveTextContent("Frames: 1"); expect(screen.getByTestId("video-frames-section-collapsed-content")).toHaveTextContent("1 frame"); }); + + it("does not render video frames section when maxReferenceFrames is zero", () => { + render( + + ); + + expect(screen.queryByTestId("video-frames-section")).not.toBeInTheDocument(); + }); }); diff --git a/components/studio/features/generation/controls-view.tsx b/components/studio/features/generation/controls-view.tsx index 74bd14c..7fcd78b 100644 --- a/components/studio/features/generation/controls-view.tsx +++ b/components/studio/features/generation/controls-view.tsx @@ -357,7 +357,7 @@ export const ControlsView = React.memo(function ControlsView({ {/* Video Frames (video models only) */} - {isVideoModel && (supportsInterpolation + {isVideoModel && maxReferenceFrames > 0 && (supportsInterpolation ? videoInterpolationImages && onVideoInterpolationImagesChange : videoReferenceImages && onVideoReferenceImagesChange ) && ( diff --git a/components/studio/layout/studio-shell.test.tsx b/components/studio/layout/studio-shell.test.tsx index f53065d..8287707 100644 --- a/components/studio/layout/studio-shell.test.tsx +++ b/components/studio/layout/studio-shell.test.tsx @@ -450,6 +450,19 @@ describe("StudioShell", () => { vi.clearAllMocks() mockActiveGenerations = [] mockGalleryLoadMore.mockClear() + Object.assign(mockGenerationSettings, { + model: "flux", + aspectRatio: "1:1", + width: 1024, + height: 1024, + seed: -1, + referenceImage: undefined, + isVideoModel: false, + videoSettings: { duration: 5, audio: false }, + videoReferenceImages: [], + resolutionTier: "hd", + constraints: undefined, + }) }) it("renders all main components", () => { @@ -574,6 +587,30 @@ describe("StudioShell", () => { }) }) + it("omits video reference images when the selected video model does not support them", async () => { + Object.assign(mockGenerationSettings, { + model: "ltx-2", + aspectRatio: "16:9", + isVideoModel: true, + videoReferenceImages: [ + "https://example.com/first.jpg", + "https://example.com/second.jpg", + ], + }) + + render() + + fireEvent.click(screen.getByTestId("generate-button")) + + await waitFor(() => { + expect(mockGenerate).toHaveBeenCalledWith(expect.objectContaining({ + model: "ltx-2", + image: undefined, + lastFrameImage: undefined, + })) + }) + }) + it("shows Generate Image text on button by default", () => { render() diff --git a/components/studio/layout/studio-shell.tsx b/components/studio/layout/studio-shell.tsx index 2dd045a..ee0c65c 100644 --- a/components/studio/layout/studio-shell.tsx +++ b/components/studio/layout/studio-shell.tsx @@ -318,6 +318,18 @@ export function StudioShell({ if (!prompt) return; + const generationModel = getModel(generationSettings.model); + const supportsVideoReferenceImage = + generationSettings.isVideoModel && generationModel?.supportsReferenceImage === true; + const supportsVideoInterpolation = + generationSettings.isVideoModel && generationModel?.supportsInterpolation === true; + const videoReferenceImage = supportsVideoReferenceImage + ? generationSettings.videoReferenceImages[0] || undefined + : undefined; + const videoLastFrameImage = supportsVideoInterpolation + ? generationSettings.videoReferenceImages[1] || undefined + : undefined; + // Add to history promptManager.addToPromptHistory(prompt); @@ -337,16 +349,15 @@ export function StudioShell({ enhance: false, private: generationSettings.options.private, safe: generationSettings.options.safe, - // For video models, use the first frame as the reference image (image-to-video); - // for image models, use the standard reference image (image-to-image). + // Only include video frames for models that accept image-to-video input. image: generationSettings.isVideoModel - ? (generationSettings.videoReferenceImages[0] || undefined) + ? videoReferenceImage : generationSettings.referenceImage, // Video-specific parameters duration: generationSettings.videoSettings.duration, audio: generationSettings.videoSettings.audio, aspectRatio: generationSettings.aspectRatio, - lastFrameImage: generationSettings.videoReferenceImages[1] || undefined, + lastFrameImage: videoLastFrameImage, }, batchMode.batchSettings.count, ); @@ -369,10 +380,9 @@ export function StudioShell({ enhance: false, private: generationSettings.options.private, safe: generationSettings.options.safe, - // For video models, use the first frame as the reference image (image-to-video); - // for image models, use the standard reference image (image-to-image). + // Only include video frames for models that accept image-to-video input. image: generationSettings.isVideoModel - ? (generationSettings.videoReferenceImages[0] || undefined) + ? videoReferenceImage : generationSettings.referenceImage, }; @@ -391,7 +401,7 @@ export function StudioShell({ duration: generationSettings.videoSettings.duration, audio: generationSettings.videoSettings.audio, aspectRatio: videoAspectRatio, - lastFrameImage: generationSettings.videoReferenceImages[1] || undefined, + lastFrameImage: videoLastFrameImage, }; generate(params); return; diff --git a/convex/lib/pollinations.test.ts b/convex/lib/pollinations.test.ts index 8f73510..3a0cb90 100644 --- a/convex/lib/pollinations.test.ts +++ b/convex/lib/pollinations.test.ts @@ -35,7 +35,7 @@ describe("buildPollinationsUrl", () => { expect(parsed.searchParams.get("image_urls")).toBeNull() }) - it("does not encode last frame image for non-interpolation video models like ltx-2", () => { + it("does not encode reference images for text-only video models like ltx-2", () => { const url = buildPollinationsUrl({ prompt: "test prompt", model: "ltx-2", @@ -47,7 +47,7 @@ describe("buildPollinationsUrl", () => { const parsed = new URL(url) - expect(parsed.searchParams.get("image")).toBe("https://example.com/first.jpg") + expect(parsed.searchParams.get("image")).toBeNull() }) }) diff --git a/convex/lib/pollinations.ts b/convex/lib/pollinations.ts index 84927c8..4b0826a 100644 --- a/convex/lib/pollinations.ts +++ b/convex/lib/pollinations.ts @@ -16,6 +16,7 @@ export const POLLINATIONS_FETCH_TIMEOUT_MS = 10 * 60 * 1000 /** Video model IDs - these are the only models that accept video-specific query params */ const VIDEO_MODELS = ["veo", "seedance", "seedance-pro", "wan", "ltx-2", "grok-video"] as const +const VIDEO_MODELS_WITH_REFERENCE_IMAGE = ["veo", "seedance", "seedance-pro", "wan", "grok-video"] as const // ============================================================ // Types @@ -105,9 +106,12 @@ export function buildPollinationsUrl(params: PollinationsUrlParams): string { const isVideoModel = params.model && VIDEO_MODELS.includes(params.model as typeof VIDEO_MODELS[number]) if (isVideoModel) { // Reference image(s): only Veo currently supports interpolation with start+end frames. + const supportsReferenceImage = VIDEO_MODELS_WITH_REFERENCE_IMAGE.includes( + params.model as typeof VIDEO_MODELS_WITH_REFERENCE_IMAGE[number] + ) if (params.model === "veo" && params.image && params.lastFrameImage) { queryParams.append("image", `${params.image}|${params.lastFrameImage}`) - } else if (params.image) { + } else if (supportsReferenceImage && params.image) { queryParams.append("image", params.image) } diff --git a/hooks/use-generation-settings.test.ts b/hooks/use-generation-settings.test.ts index 0a5811a..636c2cc 100644 --- a/hooks/use-generation-settings.test.ts +++ b/hooks/use-generation-settings.test.ts @@ -207,6 +207,33 @@ describe("useGenerationSettings", () => { expect(result.current.videoReferenceImages).toEqual(["https://example.com/first.jpg"]) }) + it("clears persisted video reference frames for ltx-2", () => { + window.localStorage.setItem("ps:gen:model", JSON.stringify("ltx-2")) + window.localStorage.setItem( + "ps:gen:videoReferenceFrames", + JSON.stringify(["https://example.com/first.jpg"]) + ) + + const { result } = renderHook(() => useGenerationSettings()) + + expect(result.current.model).toBe("ltx-2") + expect(result.current.videoReferenceImages).toEqual([]) + }) + + it("prevents video reference frame updates for ltx-2", () => { + const { result } = renderHook(() => useGenerationSettings()) + + act(() => { + result.current.handleModelChange("ltx-2") + }) + + act(() => { + result.current.setVideoReferenceImages(["https://example.com/first.jpg"]) + }) + + expect(result.current.videoReferenceImages).toEqual([]) + }) + it("provides aspectRatios based on model", () => { const { result } = renderHook(() => useGenerationSettings()) diff --git a/hooks/use-generation-settings.ts b/hooks/use-generation-settings.ts index 25a0341..5166158 100644 --- a/hooks/use-generation-settings.ts +++ b/hooks/use-generation-settings.ts @@ -199,7 +199,9 @@ export function useGenerationSettings(): UseGenerationSettingsReturn { const isVideoModel = modelDef?.type === "video" const maxVideoReferenceImages = React.useMemo(() => { if (!modelDef) return undefined - return modelDef.supportsInterpolation ? 2 : modelDef.referenceFrameCount + if (modelDef.type !== "video") return undefined + if (!modelDef.supportsReferenceImage) return 0 + return modelDef.supportsInterpolation ? 2 : (modelDef.referenceFrameCount ?? 1) }, [modelDef]) const normalizedVideoReferenceImages = React.useMemo(() => { if (maxVideoReferenceImages === undefined) return videoReferenceImages From 0670b04591016e66829dc61cabadbcd91cb45c2f Mon Sep 17 00:00:00 2001 From: Simplereally <120893410+Simplereally@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:42:18 +1000 Subject: [PATCH 3/3] yep --- convex/batchGeneration.ts | 41 ++++++++++++++++++++++++++++++++++++++ convex/singleGeneration.ts | 30 ++++++++++++++++++---------- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/convex/batchGeneration.ts b/convex/batchGeneration.ts index 3c443af..5bbf541 100644 --- a/convex/batchGeneration.ts +++ b/convex/batchGeneration.ts @@ -22,6 +22,7 @@ import { internalQuery, mutation, query, + type MutationCtx, } from "./_generated/server" import { buildRecordBatchItemResultTransition, @@ -69,6 +70,41 @@ type BatchJobSummary = { lastErrorCode?: number } +/** + * Find an existing active batch for a user. This makes startBatchJob idempotent + * across double-clicks, hotkey repeats, stale tabs, and direct mutation calls. + */ +async function getExistingActiveBatchJob( + ctx: MutationCtx, + ownerId: string +): Promise | null> { + const [pending, processing, paused] = await Promise.all([ + ctx.db + .query("batchJobs") + .withIndex("by_owner_status", (q) => + q.eq("ownerId", ownerId).eq("status", "pending") + ) + .order("desc") + .take(1), + ctx.db + .query("batchJobs") + .withIndex("by_owner_status", (q) => + q.eq("ownerId", ownerId).eq("status", "processing") + ) + .order("desc") + .take(1), + ctx.db + .query("batchJobs") + .withIndex("by_owner_status", (q) => + q.eq("ownerId", ownerId).eq("status", "paused") + ) + .order("desc") + .take(1), + ]) + + return [...pending, ...processing, ...paused].sort((a, b) => b.createdAt - a.createdAt)[0] ?? null +} + /** * Convert a full batch job document to a lightweight summary. * Strips generationParams (can be 10-50KB for complex workflows), @@ -151,6 +187,11 @@ export const startBatchJob = mutation({ throw new Error(`Batch size must be between ${MIN_BATCH_SIZE} and ${MAX_BATCH_SIZE}`) } + const existingActiveBatch = await getExistingActiveBatchJob(ctx, identity.subject) + if (existingActiveBatch) { + return existingActiveBatch._id + } + const now = Date.now() // Create the aggregate batch job document. Per-item execution lives in diff --git a/convex/singleGeneration.ts b/convex/singleGeneration.ts index a18f1b3..0b273d3 100644 --- a/convex/singleGeneration.ts +++ b/convex/singleGeneration.ts @@ -297,6 +297,7 @@ export const cancelAllActiveGenerations = mutation({ * backoff while still cleaning up genuinely orphaned records. */ const STUCK_GENERATION_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes +const STUCK_GENERATION_CLEANUP_BATCH_SIZE = 100 /** * Clean up stuck generations — marks any pending/processing record older than @@ -310,22 +311,31 @@ export const cleanupStuckGenerations = internalMutation({ const cutoff = Date.now() - STUCK_GENERATION_THRESHOLD_MS const logger = "[StuckCleanup]" - // Query both stuck statuses using the by_status index. - // We filter by updatedAt < cutoff after retrieval (the index doesn't - // cover updatedAt, but the set of active records should be small). - const [pending, processing] = await Promise.all([ + // Query by dispatch status + updatedAt so the cron only reads stale + // worker-plane rows instead of scanning every active generation. + const [pendingDispatch, dispatched, processingDispatch] = await Promise.all([ ctx.db .query("pendingGenerations") - .withIndex("by_status", (q) => q.eq("status", "pending")) - .collect(), + .withIndex("by_dispatch_status", (q) => + q.eq("dispatchStatus", "pending").lt("updatedAt", cutoff) + ) + .take(STUCK_GENERATION_CLEANUP_BATCH_SIZE), ctx.db .query("pendingGenerations") - .withIndex("by_status", (q) => q.eq("status", "processing")) - .collect(), + .withIndex("by_dispatch_status", (q) => + q.eq("dispatchStatus", "dispatched").lt("updatedAt", cutoff) + ) + .take(STUCK_GENERATION_CLEANUP_BATCH_SIZE), + ctx.db + .query("pendingGenerations") + .withIndex("by_dispatch_status", (q) => + q.eq("dispatchStatus", "processing").lt("updatedAt", cutoff) + ) + .take(STUCK_GENERATION_CLEANUP_BATCH_SIZE), ]) - const stuck = [...pending, ...processing].filter( - (g) => g.updatedAt < cutoff + const stuck = [...pendingDispatch, ...dispatched, ...processingDispatch].filter( + (g) => g.status === "pending" || g.status === "processing" ) if (stuck.length === 0) {