Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

steps:
- name: Checkout main branch
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
Expand Down Expand Up @@ -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 }}
Expand Down
17 changes: 17 additions & 0 deletions components/studio/features/generation/controls-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ControlsView
{...defaultProps}
isVideoModel={true}
videoReferenceImages={["https://example.com/first.jpg", "https://example.com/second.jpg"]}
onVideoReferenceImagesChange={vi.fn()}
maxReferenceFrames={1}
videoSettings={undefined}
onVideoSettingsChange={undefined}
/>
);

expect(screen.getByTestId("video-reference-frames-picker")).toHaveTextContent("Frames: 1");
expect(screen.getByTestId("video-frames-section-collapsed-content")).toHaveTextContent("1 frame");
});
});
8 changes: 6 additions & 2 deletions components/studio/features/generation/controls-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -359,7 +363,7 @@ export const ControlsView = React.memo(function ControlsView({
) && (
<VideoFramesSection
isInterpolation={supportsInterpolation}
frames={videoReferenceImages}
frames={displayedVideoReferenceImages}
onFramesChange={onVideoReferenceImagesChange}
selectedImages={videoInterpolationImages}
onImagesChange={onVideoInterpolationImagesChange}
Expand Down
2 changes: 1 addition & 1 deletion convex/batchProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { v } from "convex/values"
import { internal } from "./_generated/api"
import { internalAction } from "./_generated/server"
import {
POLLINATIONS_FETCH_TIMEOUT_MS,
buildPollinationsUrl,
calculateBackoffDelay,
cropDirtberryImageBuffer,
Expand All @@ -40,7 +41,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
const ENABLE_DEV_GENERATION_MOCK = process.env.CONVEX_ENABLE_DEV_GENERATION_MOCK === "true"
const MOCK_PNG_BASE64 =
Expand Down
1 change: 1 addition & 0 deletions convex/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { decryptApiKey } from "./crypto"
// Pollinations API utilities
export {
POLLINATIONS_BASE_URL,
POLLINATIONS_FETCH_TIMEOUT_MS,
buildPollinationsUrl,
classifyHttpError,
classifyApiError,
Expand Down
19 changes: 19 additions & 0 deletions convex/lib/pollinations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,32 @@

import { describe, it, expect } from "vitest"
import {
buildPollinationsUrl,
classifyHttpError,
classifyApiError,
isFluxModelUnavailable,
matchNonRetryablePattern,
NON_RETRYABLE_ERROR_PATTERNS,
} from "./pollinations"

describe("buildPollinationsUrl", () => {
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
// ============================================================
Expand Down
22 changes: 15 additions & 7 deletions convex/lib/pollinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion convex/lib/videoPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

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

await chmod(ffmpegStatic, 0o755).catch(() => undefined)
ffmpegModule.default.setFfmpegPath(ffmpegStatic)
return ffmpegModule.default
})()
Expand Down
3 changes: 2 additions & 1 deletion convex/lib/videoThumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

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

await chmod(ffmpegStatic, 0o755).catch(() => undefined)
ffmpegModule.default.setFfmpegPath(ffmpegStatic)
return ffmpegModule.default
})()
Expand Down
2 changes: 1 addition & 1 deletion convex/singleGenerationProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

/**
Expand Down
31 changes: 31 additions & 0 deletions hooks/use-generation-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock("@/hooks/use-random-seed", () => ({
describe("useGenerationSettings", () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
})

it("initializes with default values", () => {
Expand Down Expand Up @@ -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())

Expand Down
26 changes: 24 additions & 2 deletions hooks/use-generation-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,29 @@ export function useGenerationSettings(): UseGenerationSettingsReturn {
duration: 5,
audio: false,
})
const [videoReferenceImages, setVideoReferenceImages] = useLocalStorage<string[]>("ps:gen:videoReferenceFrames", [])
const [videoReferenceImages, setStoredVideoReferenceImages] = useLocalStorage<string[]>("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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop empty legacy frame placeholders when limiting Grok frames

For users with persisted interpolation state, frame arrays can contain empty placeholders (the existing interpolation setter stores "" for missing slots). This normalization now does slice(0, maxVideoReferenceImages) for grok-video (max=1), which turns values like ["", "https://..."] into [''] instead of preserving a real frame or clearing it. Downstream, the non-interpolation picker treats that as a selected frame (and may render an empty image URL) while generation still sends no reference image via videoReferenceImages[0] || undefined, creating a broken/misleading state on load for returning users.

Useful? React with 👍 / 👎.

}, [maxVideoReferenceImages, videoReferenceImages])
const setVideoReferenceImages = React.useCallback<React.Dispatch<React.SetStateAction<string[]>>>((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) ?? [],
Expand All @@ -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
// ========================================
Expand Down Expand Up @@ -434,7 +456,7 @@ export function useGenerationSettings(): UseGenerationSettingsReturn {
isVideoModel,
videoSettings,
setVideoSettings,
videoReferenceImages,
videoReferenceImages: normalizedVideoReferenceImages,
setVideoReferenceImages,
}
}
4 changes: 2 additions & 2 deletions lib/config/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/config/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,8 +897,8 @@ export const MODEL_REGISTRY: Record<string, ModelDefinition> = {
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.
Expand Down
Loading