Skip to content

Commit 335ea47

Browse files
authored
Fix/grok video image urls (#46)
* fix: update GitHub Actions to support Node.js 24 - Update actions/checkout from v4 to v6.0.2 - Update softprops/action-gh-release from v2 to v2.5.0 - Resolves Node.js 20 deprecation warnings These action versions support Node.js 24 which will become the default on GitHub Actions runners starting June 2nd, 2026. * fix: limit grok-video to single reference frame and set ffmpeg permissions - Change grok-video from interpolation (2 frames) to single reference frame - Update buildPollinationsUrl to use `image` param only for grok-video - Add maxVideoReferenceImages enforcement in useGenerationSettings hook - Normalize videoReferenceImages on load and update to respect frame limits - Add chmod 0o755 to ffmpeg-static binary in video preview/thumbnail modules - Add test coverage for grok-video single frame limit
1 parent 0ebfdfe commit 335ea47

10 files changed

Lines changed: 119 additions & 17 deletions

File tree

components/studio/features/generation/controls-view.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,21 @@ describe("ControlsView", () => {
342342
// Video settings section should NOT assume to be present if videoSettings is missing (but logic requires it as per current implementation)
343343
expect(screen.queryByTestId("video-settings-section")).not.toBeInTheDocument();
344344
});
345+
346+
it("limits non-interpolation video frames to maxReferenceFrames", () => {
347+
render(
348+
<ControlsView
349+
{...defaultProps}
350+
isVideoModel={true}
351+
videoReferenceImages={["https://example.com/first.jpg", "https://example.com/second.jpg"]}
352+
onVideoReferenceImagesChange={vi.fn()}
353+
maxReferenceFrames={1}
354+
videoSettings={undefined}
355+
onVideoSettingsChange={undefined}
356+
/>
357+
);
358+
359+
expect(screen.getByTestId("video-reference-frames-picker")).toHaveTextContent("Frames: 1");
360+
expect(screen.getByTestId("video-frames-section-collapsed-content")).toHaveTextContent("1 frame");
361+
});
345362
});

components/studio/features/generation/controls-view.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,15 @@ export const ControlsView = React.memo(function ControlsView({
271271
historyImages,
272272
}: ControlsViewProps) {
273273
const [modelExpanded, setModelExpanded] = React.useState(true);
274+
const displayedVideoReferenceImages = React.useMemo(
275+
() => videoReferenceImages?.slice(0, maxReferenceFrames) ?? [],
276+
[maxReferenceFrames, videoReferenceImages]
277+
);
274278

275279
// Calculate frame count for video reference display
276280
const videoFrameCount = supportsInterpolation
277281
? (videoInterpolationImages?.firstFrame ? 1 : 0) + (videoInterpolationImages?.lastFrame ? 1 : 0)
278-
: (videoReferenceImages?.length ?? 0)
282+
: displayedVideoReferenceImages.length
279283

280284
const handleModelChange = React.useCallback(
281285
(newModel: string) => {
@@ -359,7 +363,7 @@ export const ControlsView = React.memo(function ControlsView({
359363
) && (
360364
<VideoFramesSection
361365
isInterpolation={supportsInterpolation}
362-
frames={videoReferenceImages}
366+
frames={displayedVideoReferenceImages}
363367
onFramesChange={onVideoReferenceImagesChange}
364368
selectedImages={videoInterpolationImages}
365369
onImagesChange={onVideoInterpolationImagesChange}

convex/lib/pollinations.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,32 @@
1010

1111
import { describe, it, expect } from "vitest"
1212
import {
13+
buildPollinationsUrl,
1314
classifyHttpError,
1415
classifyApiError,
1516
isFluxModelUnavailable,
1617
matchNonRetryablePattern,
1718
NON_RETRYABLE_ERROR_PATTERNS,
1819
} from "./pollinations"
1920

21+
describe("buildPollinationsUrl", () => {
22+
it("encodes grok-video reference image through the image query param only", () => {
23+
const url = buildPollinationsUrl({
24+
prompt: "test prompt",
25+
model: "grok-video",
26+
image: "https://example.com/first.jpg",
27+
lastFrameImage: "https://example.com/second.jpg",
28+
duration: 5,
29+
aspectRatio: "16:9",
30+
})
31+
32+
const parsed = new URL(url)
33+
34+
expect(parsed.searchParams.get("image")).toBe("https://example.com/first.jpg")
35+
expect(parsed.searchParams.get("image_urls")).toBeNull()
36+
})
37+
})
38+
2039
// ============================================================
2140
// classifyHttpError — pure status-code classification
2241
// ============================================================

convex/lib/pollinations.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,20 @@ export function buildPollinationsUrl(params: PollinationsUrlParams): string {
104104
// Video-specific parameters - only include for video models
105105
const isVideoModel = params.model && VIDEO_MODELS.includes(params.model as typeof VIDEO_MODELS[number])
106106
if (isVideoModel) {
107-
// Reference image(s): for models that support interpolation (two reference images),
108-
// join both URLs with "|" in a single `image` param so the Pollinations gateway
109-
// splits them into the upstream `image_urls` array.
110-
if (params.image && params.lastFrameImage) {
111-
queryParams.append("image", `${params.image}|${params.lastFrameImage}`)
112-
} else if (params.image) {
113-
queryParams.append("image", params.image)
107+
// Reference image(s): handle different formats for different video models
108+
if (params.model === "grok-video") {
109+
if (params.image) {
110+
queryParams.append("image", params.image)
111+
}
112+
} else {
113+
// Other video models: for models that support interpolation (two reference images),
114+
// join both URLs with "|" in a single `image` param so the Pollinations gateway
115+
// splits them into the upstream `image_urls` array.
116+
if (params.image && params.lastFrameImage) {
117+
queryParams.append("image", `${params.image}|${params.lastFrameImage}`)
118+
} else if (params.image) {
119+
queryParams.append("image", params.image)
120+
}
114121
}
115122

116123
if (params.duration !== undefined && params.duration > 0) {

convex/lib/videoPreview.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import { tmpdir } from "os"
2424
import { join } from "path"
25-
import { writeFile, readFile, unlink, mkdir } from "fs/promises"
25+
import { writeFile, readFile, unlink, mkdir, chmod } from "fs/promises"
2626
import { randomUUID } from "crypto"
2727

2828
// ============================================================
@@ -94,6 +94,7 @@ function getFfmpeg(): Promise<typeof Ffmpeg> {
9494
throw new Error("ffmpeg-static binary not found")
9595
}
9696

97+
await chmod(ffmpegStatic, 0o755).catch(() => undefined)
9798
ffmpegModule.default.setFfmpegPath(ffmpegStatic)
9899
return ffmpegModule.default
99100
})()

convex/lib/videoThumbnail.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
import { tmpdir } from "os"
2727
import { join } from "path"
28-
import { writeFile, unlink, mkdir } from "fs/promises"
28+
import { writeFile, unlink, mkdir, chmod } from "fs/promises"
2929
import { randomUUID } from "crypto"
3030

3131
// ============================================================
@@ -73,6 +73,7 @@ function getFfmpeg(): Promise<typeof Ffmpeg> {
7373
throw new Error("ffmpeg-static binary not found")
7474
}
7575

76+
await chmod(ffmpegStatic, 0o755).catch(() => undefined)
7677
ffmpegModule.default.setFfmpegPath(ffmpegStatic)
7778
return ffmpegModule.default
7879
})()

hooks/use-generation-settings.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ vi.mock("@/hooks/use-random-seed", () => ({
1515
describe("useGenerationSettings", () => {
1616
beforeEach(() => {
1717
vi.clearAllMocks()
18+
window.localStorage.clear()
1819
})
1920

2021
it("initializes with default values", () => {
@@ -176,6 +177,36 @@ describe("useGenerationSettings", () => {
176177
expect(result.current.model).toBe("flux-realism")
177178
})
178179

180+
it("limits grok-video reference frames to a single image from persisted state", () => {
181+
window.localStorage.setItem("ps:gen:model", JSON.stringify("grok-video"))
182+
window.localStorage.setItem(
183+
"ps:gen:videoReferenceFrames",
184+
JSON.stringify(["https://example.com/first.jpg", "https://example.com/second.jpg"])
185+
)
186+
187+
const { result } = renderHook(() => useGenerationSettings())
188+
189+
expect(result.current.model).toBe("grok-video")
190+
expect(result.current.videoReferenceImages).toEqual(["https://example.com/first.jpg"])
191+
})
192+
193+
it("limits grok-video reference frame updates to a single image", () => {
194+
const { result } = renderHook(() => useGenerationSettings())
195+
196+
act(() => {
197+
result.current.handleModelChange("grok-video")
198+
})
199+
200+
act(() => {
201+
result.current.setVideoReferenceImages([
202+
"https://example.com/first.jpg",
203+
"https://example.com/second.jpg",
204+
])
205+
})
206+
207+
expect(result.current.videoReferenceImages).toEqual(["https://example.com/first.jpg"])
208+
})
209+
179210
it("provides aspectRatios based on model", () => {
180211
const { result } = renderHook(() => useGenerationSettings())
181212

hooks/use-generation-settings.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,29 @@ export function useGenerationSettings(): UseGenerationSettingsReturn {
190190
duration: 5,
191191
audio: false,
192192
})
193-
const [videoReferenceImages, setVideoReferenceImages] = useLocalStorage<string[]>("ps:gen:videoReferenceFrames", [])
193+
const [videoReferenceImages, setStoredVideoReferenceImages] = useLocalStorage<string[]>("ps:gen:videoReferenceFrames", [])
194194

195195
// ========================================
196196
// Model-specific Data (Memoized)
197197
// ========================================
198198
const modelDef = React.useMemo(() => getModel(model), [model])
199199
const isVideoModel = modelDef?.type === "video"
200+
const maxVideoReferenceImages = React.useMemo(() => {
201+
if (!modelDef) return undefined
202+
return modelDef.supportsInterpolation ? 2 : modelDef.referenceFrameCount
203+
}, [modelDef])
204+
const normalizedVideoReferenceImages = React.useMemo(() => {
205+
if (maxVideoReferenceImages === undefined) return videoReferenceImages
206+
return videoReferenceImages.slice(0, maxVideoReferenceImages)
207+
}, [maxVideoReferenceImages, videoReferenceImages])
208+
const setVideoReferenceImages = React.useCallback<React.Dispatch<React.SetStateAction<string[]>>>((value) => {
209+
setStoredVideoReferenceImages((prev) => {
210+
const nextValue = value instanceof Function ? value(prev) : value
211+
return maxVideoReferenceImages === undefined
212+
? nextValue
213+
: nextValue.slice(0, maxVideoReferenceImages)
214+
})
215+
}, [maxVideoReferenceImages, setStoredVideoReferenceImages])
200216

201217
const aspectRatios = React.useMemo(
202218
() => getModelAspectRatios(model) ?? [],
@@ -213,6 +229,12 @@ export function useGenerationSettings(): UseGenerationSettingsReturn {
213229
[constraints]
214230
)
215231

232+
React.useEffect(() => {
233+
if (maxVideoReferenceImages !== undefined && videoReferenceImages.length > maxVideoReferenceImages) {
234+
setStoredVideoReferenceImages(videoReferenceImages.slice(0, maxVideoReferenceImages))
235+
}
236+
}, [maxVideoReferenceImages, setStoredVideoReferenceImages, videoReferenceImages])
237+
216238
// ========================================
217239
// Resolution Tier Handler
218240
// ========================================
@@ -434,7 +456,7 @@ export function useGenerationSettings(): UseGenerationSettingsReturn {
434456
isVideoModel,
435457
videoSettings,
436458
setVideoSettings,
437-
videoReferenceImages,
459+
videoReferenceImages: normalizedVideoReferenceImages,
438460
setVideoReferenceImages,
439461
}
440462
}

lib/config/models.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -840,9 +840,9 @@ describe("Video Model Properties", () => {
840840
expect(model.supportsReferenceImage).toBe(true)
841841
})
842842

843-
it("should support 2 reference frames", () => {
843+
it("should support 1 reference frame", () => {
844844
const model = getModel("grok-video")!
845-
expect(model.referenceFrameCount).toBe(2)
845+
expect(model.referenceFrameCount).toBe(1)
846846
})
847847

848848
it("should not support audio", () => {

lib/config/models.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,8 +897,8 @@ export const MODEL_REGISTRY: Record<string, ModelDefinition> = {
897897
aspectRatios: VIDEO_ASPECT_RATIOS,
898898
supportsNegativePrompt: false,
899899
supportsReferenceImage: true,
900-
supportsInterpolation: true,
901-
referenceFrameCount: 2,
900+
supportsInterpolation: false,
901+
referenceFrameCount: 1,
902902
durationConstraints: {
903903
// Gateway validates min: 1 (enter.pollinations.ai/src/schemas/image.ts line 107).
904904
// Duration is approximate — not enforced by api.airforce backend.

0 commit comments

Comments
 (0)