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.