diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc4fe4f..c6e8fad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout main branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: main fetch-depth: 0 @@ -102,7 +102,7 @@ jobs: git push origin ${{ steps.version.outputs.new_version }} - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.5.0 with: tag_name: ${{ steps.version.outputs.new_version }} name: Release ${{ steps.version.outputs.new_version }} diff --git a/components/studio/features/generation/controls-view.test.tsx b/components/studio/features/generation/controls-view.test.tsx index 0fcb86c..efc5073 100644 --- a/components/studio/features/generation/controls-view.test.tsx +++ b/components/studio/features/generation/controls-view.test.tsx @@ -342,4 +342,21 @@ describe("ControlsView", () => { // Video settings section should NOT assume to be present if videoSettings is missing (but logic requires it as per current implementation) expect(screen.queryByTestId("video-settings-section")).not.toBeInTheDocument(); }); + + it("limits non-interpolation video frames to maxReferenceFrames", () => { + render( + + ); + + expect(screen.getByTestId("video-reference-frames-picker")).toHaveTextContent("Frames: 1"); + expect(screen.getByTestId("video-frames-section-collapsed-content")).toHaveTextContent("1 frame"); + }); }); diff --git a/components/studio/features/generation/controls-view.tsx b/components/studio/features/generation/controls-view.tsx index d07fa94..74bd14c 100644 --- a/components/studio/features/generation/controls-view.tsx +++ b/components/studio/features/generation/controls-view.tsx @@ -271,11 +271,15 @@ export const ControlsView = React.memo(function ControlsView({ historyImages, }: ControlsViewProps) { const [modelExpanded, setModelExpanded] = React.useState(true); + const displayedVideoReferenceImages = React.useMemo( + () => videoReferenceImages?.slice(0, maxReferenceFrames) ?? [], + [maxReferenceFrames, videoReferenceImages] + ); // Calculate frame count for video reference display const videoFrameCount = supportsInterpolation ? (videoInterpolationImages?.firstFrame ? 1 : 0) + (videoInterpolationImages?.lastFrame ? 1 : 0) - : (videoReferenceImages?.length ?? 0) + : displayedVideoReferenceImages.length const handleModelChange = React.useCallback( (newModel: string) => { @@ -359,7 +363,7 @@ export const ControlsView = React.memo(function ControlsView({ ) && ( { + it("encodes grok-video reference image through the image query param only", () => { + const url = buildPollinationsUrl({ + prompt: "test prompt", + model: "grok-video", + 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") + expect(parsed.searchParams.get("image_urls")).toBeNull() + }) +}) + // ============================================================ // classifyHttpError — pure status-code classification // ============================================================ diff --git a/convex/lib/pollinations.ts b/convex/lib/pollinations.ts index 4ce32b7..c0675ba 100644 --- a/convex/lib/pollinations.ts +++ b/convex/lib/pollinations.ts @@ -12,6 +12,7 @@ /** Pollinations API base URL */ 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 @@ -103,13 +104,20 @@ 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): 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): 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) + } } if (params.duration !== undefined && params.duration > 0) { diff --git a/convex/lib/videoPreview.ts b/convex/lib/videoPreview.ts index 01899c6..e93cf1c 100644 --- a/convex/lib/videoPreview.ts +++ b/convex/lib/videoPreview.ts @@ -22,7 +22,7 @@ import { tmpdir } from "os" import { join } from "path" -import { writeFile, readFile, unlink, mkdir } from "fs/promises" +import { writeFile, readFile, unlink, mkdir, chmod } from "fs/promises" import { randomUUID } from "crypto" // ============================================================ @@ -94,6 +94,7 @@ function getFfmpeg(): Promise { throw new Error("ffmpeg-static binary not found") } + await chmod(ffmpegStatic, 0o755).catch(() => undefined) ffmpegModule.default.setFfmpegPath(ffmpegStatic) return ffmpegModule.default })() diff --git a/convex/lib/videoThumbnail.ts b/convex/lib/videoThumbnail.ts index 862e100..2e986f2 100644 --- a/convex/lib/videoThumbnail.ts +++ b/convex/lib/videoThumbnail.ts @@ -25,7 +25,7 @@ import { tmpdir } from "os" import { join } from "path" -import { writeFile, unlink, mkdir } from "fs/promises" +import { writeFile, unlink, mkdir, chmod } from "fs/promises" import { randomUUID } from "crypto" // ============================================================ @@ -73,6 +73,7 @@ function getFfmpeg(): Promise { throw new Error("ffmpeg-static binary not found") } + await chmod(ffmpegStatic, 0o755).catch(() => undefined) ffmpegModule.default.setFfmpegPath(ffmpegStatic) return ffmpegModule.default })() diff --git a/convex/singleGenerationProcessor.ts b/convex/singleGenerationProcessor.ts index 38018a4..e876163 100644 --- a/convex/singleGenerationProcessor.ts +++ b/convex/singleGenerationProcessor.ts @@ -18,6 +18,7 @@ import type { Id } from "./_generated/dataModel" import { internal } from "./_generated/api" import { action, internalAction, type ActionCtx } from "./_generated/server" import { + POLLINATIONS_FETCH_TIMEOUT_MS, buildPollinationsUrl, calculateBackoffDelay, cropDirtberryImageBuffer, @@ -41,7 +42,6 @@ const POLLINATIONS_RETRY_CONFIG: RetryConfig = { baseDelayMs: 2000, maxDelayMs: 30000, } -const POLLINATIONS_FETCH_TIMEOUT_MS = 45_000 const MAX_POLLINATIONS_ATTEMPTS = POLLINATIONS_RETRY_CONFIG.maxRetries + 1 /** diff --git a/hooks/use-generation-settings.test.ts b/hooks/use-generation-settings.test.ts index 82659ab..0a5811a 100644 --- a/hooks/use-generation-settings.test.ts +++ b/hooks/use-generation-settings.test.ts @@ -15,6 +15,7 @@ vi.mock("@/hooks/use-random-seed", () => ({ describe("useGenerationSettings", () => { beforeEach(() => { vi.clearAllMocks() + window.localStorage.clear() }) it("initializes with default values", () => { @@ -176,6 +177,36 @@ describe("useGenerationSettings", () => { expect(result.current.model).toBe("flux-realism") }) + it("limits grok-video reference frames to a single image from persisted state", () => { + window.localStorage.setItem("ps:gen:model", JSON.stringify("grok-video")) + window.localStorage.setItem( + "ps:gen:videoReferenceFrames", + JSON.stringify(["https://example.com/first.jpg", "https://example.com/second.jpg"]) + ) + + const { result } = renderHook(() => useGenerationSettings()) + + expect(result.current.model).toBe("grok-video") + expect(result.current.videoReferenceImages).toEqual(["https://example.com/first.jpg"]) + }) + + it("limits grok-video reference frame updates to a single image", () => { + const { result } = renderHook(() => useGenerationSettings()) + + act(() => { + result.current.handleModelChange("grok-video") + }) + + act(() => { + result.current.setVideoReferenceImages([ + "https://example.com/first.jpg", + "https://example.com/second.jpg", + ]) + }) + + expect(result.current.videoReferenceImages).toEqual(["https://example.com/first.jpg"]) + }) + 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 5da817a..25a0341 100644 --- a/hooks/use-generation-settings.ts +++ b/hooks/use-generation-settings.ts @@ -190,13 +190,29 @@ export function useGenerationSettings(): UseGenerationSettingsReturn { duration: 5, audio: false, }) - const [videoReferenceImages, setVideoReferenceImages] = useLocalStorage("ps:gen:videoReferenceFrames", []) + const [videoReferenceImages, setStoredVideoReferenceImages] = useLocalStorage("ps:gen:videoReferenceFrames", []) // ======================================== // Model-specific Data (Memoized) // ======================================== const modelDef = React.useMemo(() => getModel(model), [model]) const isVideoModel = modelDef?.type === "video" + const maxVideoReferenceImages = React.useMemo(() => { + if (!modelDef) return undefined + return modelDef.supportsInterpolation ? 2 : modelDef.referenceFrameCount + }, [modelDef]) + const normalizedVideoReferenceImages = React.useMemo(() => { + if (maxVideoReferenceImages === undefined) return videoReferenceImages + return videoReferenceImages.slice(0, maxVideoReferenceImages) + }, [maxVideoReferenceImages, videoReferenceImages]) + const setVideoReferenceImages = React.useCallback>>((value) => { + setStoredVideoReferenceImages((prev) => { + const nextValue = value instanceof Function ? value(prev) : value + return maxVideoReferenceImages === undefined + ? nextValue + : nextValue.slice(0, maxVideoReferenceImages) + }) + }, [maxVideoReferenceImages, setStoredVideoReferenceImages]) const aspectRatios = React.useMemo( () => getModelAspectRatios(model) ?? [], @@ -213,6 +229,12 @@ export function useGenerationSettings(): UseGenerationSettingsReturn { [constraints] ) + React.useEffect(() => { + if (maxVideoReferenceImages !== undefined && videoReferenceImages.length > maxVideoReferenceImages) { + setStoredVideoReferenceImages(videoReferenceImages.slice(0, maxVideoReferenceImages)) + } + }, [maxVideoReferenceImages, setStoredVideoReferenceImages, videoReferenceImages]) + // ======================================== // Resolution Tier Handler // ======================================== @@ -434,7 +456,7 @@ export function useGenerationSettings(): UseGenerationSettingsReturn { isVideoModel, videoSettings, setVideoSettings, - videoReferenceImages, + videoReferenceImages: normalizedVideoReferenceImages, setVideoReferenceImages, } } diff --git a/lib/config/models.test.ts b/lib/config/models.test.ts index 4320371..38d5aff 100644 --- a/lib/config/models.test.ts +++ b/lib/config/models.test.ts @@ -840,9 +840,9 @@ describe("Video Model Properties", () => { expect(model.supportsReferenceImage).toBe(true) }) - it("should support 2 reference frames", () => { + it("should support 1 reference frame", () => { const model = getModel("grok-video")! - expect(model.referenceFrameCount).toBe(2) + expect(model.referenceFrameCount).toBe(1) }) it("should not support audio", () => { diff --git a/lib/config/models.ts b/lib/config/models.ts index edfdc4c..116562d 100644 --- a/lib/config/models.ts +++ b/lib/config/models.ts @@ -897,8 +897,8 @@ export const MODEL_REGISTRY: Record = { aspectRatios: VIDEO_ASPECT_RATIOS, supportsNegativePrompt: false, supportsReferenceImage: true, - supportsInterpolation: true, - referenceFrameCount: 2, + supportsInterpolation: false, + referenceFrameCount: 1, durationConstraints: { // Gateway validates min: 1 (enter.pollinations.ai/src/schemas/image.ts line 107). // Duration is approximate — not enforced by api.airforce backend.