feat: create-content task uses API endpoints instead of internal functions#129
Conversation
…tent Replace direct fal.ai/LLM calls with Recoup API endpoint calls: - generateContentImage() → POST /api/content/image - upscaleImage()/upscaleVideo() → POST /api/content/upscale - generateContentVideo()/generateAudioVideo() → POST /api/content/video - generateCaption() → POST /api/content/caption New shared utilities: - recoup/callRecoupApi.ts — authenticated API call helper - recoup/contentApi.ts — typed wrappers for each content endpoint For lipsync: uploads songBuffer to fal.ai storage to get a URL, then passes audio_url to the video API. For captions: synthesizes topic from song title + clip lyrics + mood, then passes to caption API with template and length. Steps 1-4 and 10 unchanged (template loading, face guide, audio clip selection, artist/audience context, final ffmpeg render). All 333 tests pass (17 new tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughA Recoup API client (callRecoupApi) and content wrappers were added; the createContentTask pipeline was refactored to use these wrappers. Multiple legacy fal.ai-based content modules and related tests were removed or replaced with tests for the new API-based flow. EscapeDrawtext sanitization was tightened. Changes
Sequence Diagram(s)sequenceDiagram
participant Task as CreateContentTask
participant Fal as fal.storage
participant Wrap as Recoup Content Wrappers
participant Core as callRecoupApi
participant API as Recoup External API
Task->>Fal: upload MP3 (lipsync) -> returns audioUrl
Task->>Wrap: generateImage({ prompt, referenceImageUrl, images })
Wrap->>Core: POST /api/content/image with body
Core->>API: HTTP request (x-api-key, JSON)
API-->>Core: JSON { imageUrl }
Core-->>Wrap: parsed response
Wrap-->>Task: imageUrl
Task->>Wrap: generateVideo({ imageUrl, prompt, audioUrl? })
Wrap->>Core: POST /api/content/video with body
Core->>API: HTTP request
API-->>Core: JSON { videoUrl }
Core-->>Wrap: parsed response
Wrap-->>Task: videoUrl
Task->>Wrap: generateCaption({ topic, template?, length? })
Wrap->>Core: POST /api/content/caption with body
Core->>API: HTTP request
API-->>Core: JSON { content }
Core-->>Wrap: parsed content
Wrap-->>Task: captionText
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
src/recoup/contentApi.ts (1)
1-72: Split these endpoint wrappers into one export per file.This module now exposes four public functions, which breaks the repo's SRP rule and is already duplicating boundary code. A tiny shared internal helper plus one exported wrapper per file will keep each API contract isolated.
As per coding guidelines, "Implement SRP (Single Responsibility Principle): have one exported function per file".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/recoup/contentApi.ts` around lines 1 - 72, The file currently exports four public functions (generateImage, upscaleMedia, generateVideo, generateCaption) which violates SRP; split them so each exported wrapper lives in its own file and reuse the existing callRecoupApi helper. Create four files (one per function) that import callRecoupApi from "./callRecoupApi", move the corresponding function body (including any request body shaping like image_url, reference_image_url, audio_url, etc.) into its file and export that single function, remove the multiple exports from src/recoup/contentApi.ts (or delete it) and update any call sites to import the specific function file instead. Ensure each new file only exports its single wrapper function and keeps the same function name/signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/recoup/__tests__/callRecoupApi.test.ts`:
- Around line 6-62: Add a one-line test case to cover the RECOUP_API_KEY guard
by deleting process.env.RECOUP_API_KEY (e.g., delete process.env.RECOUP_API_KEY
or set it to undefined), importing callRecoupApi from "../callRecoupApi",
calling callRecoupApi("/api/content/image", { prompt: "x" }) and asserting it
throws the expected error (e.g., rejects.toThrow with the guard message); place
it alongside the existing tests so the env-var check in callRecoupApi is
exercised and cannot regress.
In `@src/recoup/callRecoupApi.ts`:
- Around line 19-30: The HTTP call in callRecoupApi should be hardened: add an
AbortController-based timeout around fetch (use maxDuration or a sensible
default) so stalled requests are aborted, then check response.ok before
attempting to parse JSON; when handling the body, try parsing JSON in a
try/catch and if JSON parsing fails fall back to await response.text() so
non-JSON error bodies don't throw SyntaxError and mask the HTTP error. On error
throw a descriptive Error that includes response.status, response.statusText and
the parsed body (JSON or text) to aid diagnostics, and ensure the abort signal
is cleaned up after fetch.
In `@src/recoup/contentApi.ts`:
- Around line 18-19: Add Zod response schemas and validate the Recoup API
results before casting: for each exported function (generateImage, upscaleMedia,
generateVideo, generateCaption) use callRecoupApi to fetch raw data, parse it
with a corresponding Zod schema (e.g., ImageResponseSchema,
UpscaleResponseSchema, VideoResponseSchema, CaptionResponseSchema) and throw or
return a clear error when parsing fails instead of casting with "as string".
Also refactor by moving each function into its own module (one file per exported
function) so each file contains the single responsibility of calling
callRecoupApi, validating via the Zod schema, and returning the validated value
(reference the existing function names and callRecoupApi to find where to apply
the changes).
In `@src/tasks/createContentTask.ts`:
- Around line 140-152: The caption payload currently omits the previously
fetched artistContext and audienceContext causing dropped inputs and unnecessary
blocking; update the captionTopic construction in createContentTask (where
captionTopic is built and generateCaption is called) to include artistContext
and audienceContext (conditionally, like audioClip fields) so they are passed
into generateCaption (and/or include them as explicit properties in the object
passed to generateCaption) to restore their use and avoid wasted fetches on the
hot path.
- Around line 121-125: The code currently enforces presence of FAL_KEY
unconditionally even though fal is only used when payload.lipsync is true;
change the validation/initialization so FAL_KEY is checked and fal (or storage
upload) is only required when payload.lipsync is true — e.g., move the FAL_KEY
presence check and any fal initialization into the lipsync branch that contains
payload.lipsync / fal.storage.upload, or lazy-initialize fal just before calling
fal.storage.upload, ensuring non-lipsync runs skip the FAL_KEY requirement.
---
Nitpick comments:
In `@src/recoup/contentApi.ts`:
- Around line 1-72: The file currently exports four public functions
(generateImage, upscaleMedia, generateVideo, generateCaption) which violates
SRP; split them so each exported wrapper lives in its own file and reuse the
existing callRecoupApi helper. Create four files (one per function) that import
callRecoupApi from "./callRecoupApi", move the corresponding function body
(including any request body shaping like image_url, reference_image_url,
audio_url, etc.) into its file and export that single function, remove the
multiple exports from src/recoup/contentApi.ts (or delete it) and update any
call sites to import the specific function file instead. Ensure each new file
only exports its single wrapper function and keeps the same function
name/signature.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: eb802154-9b1f-4113-a066-d2650baa88c6
📒 Files selected for processing (7)
src/recoup/__tests__/callRecoupApi.test.tssrc/recoup/__tests__/contentApi.test.tssrc/recoup/callRecoupApi.tssrc/recoup/contentApi.tssrc/tasks/__tests__/createContentTask.test.tssrc/tasks/__tests__/createContentTaskApi.test.tssrc/tasks/createContentTask.ts
| describe("callRecoupApi", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| process.env.RECOUP_API_KEY = "test-key"; | ||
| process.env.RECOUP_API_BASE_URL = "https://api.test.com"; | ||
| }); | ||
|
|
||
| it("makes a POST request with api key and json body", async () => { | ||
| mockFetch.mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ imageUrl: "https://example.com/img.png" }), | ||
| }); | ||
|
|
||
| const { callRecoupApi } = await import("../callRecoupApi"); | ||
| const result = await callRecoupApi("/api/content/image", { prompt: "sunset" }); | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "https://api.test.com/api/content/image", | ||
| expect.objectContaining({ | ||
| method: "POST", | ||
| headers: expect.objectContaining({ | ||
| "x-api-key": "test-key", | ||
| "Content-Type": "application/json", | ||
| }), | ||
| body: JSON.stringify({ prompt: "sunset" }), | ||
| }), | ||
| ); | ||
| expect(result).toEqual({ imageUrl: "https://example.com/img.png" }); | ||
| }); | ||
|
|
||
| it("throws on non-ok response", async () => { | ||
| mockFetch.mockResolvedValue({ | ||
| ok: false, | ||
| status: 400, | ||
| json: async () => ({ error: "Bad request" }), | ||
| }); | ||
|
|
||
| const { callRecoupApi } = await import("../callRecoupApi"); | ||
| await expect(callRecoupApi("/api/content/image", { prompt: "" })) | ||
| .rejects.toThrow("API call failed"); | ||
| }); | ||
|
|
||
| it("supports PATCH method", async () => { | ||
| mockFetch.mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ runId: "run_123" }), | ||
| }); | ||
|
|
||
| const { callRecoupApi } = await import("../callRecoupApi"); | ||
| await callRecoupApi("/api/content", { video_url: "https://example.com/v.mp4" }, "PATCH"); | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "https://api.test.com/api/content", | ||
| expect.objectContaining({ method: "PATCH" }), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Add the missing RECOUP_API_KEY guard case.
The new helper's first failure mode is the env-var check, but this suite only exercises HTTP behavior. A one-line test that deletes process.env.RECOUP_API_KEY and asserts the thrown error will keep that contract from regressing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/__tests__/callRecoupApi.test.ts` around lines 6 - 62, Add a
one-line test case to cover the RECOUP_API_KEY guard by deleting
process.env.RECOUP_API_KEY (e.g., delete process.env.RECOUP_API_KEY or set it to
undefined), importing callRecoupApi from "../callRecoupApi", calling
callRecoupApi("/api/content/image", { prompt: "x" }) and asserting it throws the
expected error (e.g., rejects.toThrow with the guard message); place it
alongside the existing tests so the env-var check in callRecoupApi is exercised
and cannot regress.
src/recoup/callRecoupApi.ts
Outdated
| const response = await fetch(`${baseUrl}${path}`, { | ||
| method, | ||
| headers: { | ||
| "x-api-key": apiKey, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
|
|
||
| const data = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(`API call failed: ${response.status} — ${data.error || "Unknown error"}`); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n 'fetch\(|response\.json\(|AbortSignal\.timeout|signal:' src/recoup/callRecoupApi.ts -C 2Repository: recoupable/tasks
Length of output: 398
Harden this HTTP boundary against hangs and non-JSON failures.
The fetch call has no timeout, so one stalled upstream request can hang the task until maxDuration expires. Additionally, await response.json() on line 28 runs before the status check on line 29, which means non-JSON error responses will throw SyntaxError and mask the actual HTTP failure.
Suggested fix
+ const timeoutMs = Number(process.env.RECOUP_API_TIMEOUT_MS ?? 30_000);
const response = await fetch(`${baseUrl}${path}`, {
method,
+ signal: AbortSignal.timeout(timeoutMs),
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
- const data = await response.json();
+ const raw = await response.text();
+ const data = raw
+ ? (() => {
+ try {
+ return JSON.parse(raw) as Record<string, unknown>;
+ } catch {
+ return { error: raw } satisfies Record<string, unknown>;
+ }
+ })()
+ : {};
if (!response.ok) {
throw new Error(`API call failed: ${response.status} — ${data.error || "Unknown error"}`);
}
return data;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await fetch(`${baseUrl}${path}`, { | |
| method, | |
| headers: { | |
| "x-api-key": apiKey, | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(`API call failed: ${response.status} — ${data.error || "Unknown error"}`); | |
| const timeoutMs = Number(process.env.RECOUP_API_TIMEOUT_MS ?? 30_000); | |
| const response = await fetch(`${baseUrl}${path}`, { | |
| method, | |
| signal: AbortSignal.timeout(timeoutMs), | |
| headers: { | |
| "x-api-key": apiKey, | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(body), | |
| }); | |
| const raw = await response.text(); | |
| const data = raw | |
| ? (() => { | |
| try { | |
| return JSON.parse(raw) as Record<string, unknown>; | |
| } catch { | |
| return { error: raw } satisfies Record<string, unknown>; | |
| } | |
| })() | |
| : {}; | |
| if (!response.ok) { | |
| throw new Error(`API call failed: ${response.status} — ${data.error || "Unknown error"}`); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/callRecoupApi.ts` around lines 19 - 30, The HTTP call in
callRecoupApi should be hardened: add an AbortController-based timeout around
fetch (use maxDuration or a sensible default) so stalled requests are aborted,
then check response.ok before attempting to parse JSON; when handling the body,
try parsing JSON in a try/catch and if JSON parsing fails fall back to await
response.text() so non-JSON error bodies don't throw SyntaxError and mask the
HTTP error. On error throw a descriptive Error that includes response.status,
response.statusText and the parsed body (JSON or text) to aid diagnostics, and
ensure the abort signal is cleaned up after fetch.
src/recoup/contentApi.ts
Outdated
| const data = await callRecoupApi("/api/content/image", body); | ||
| return data.imageUrl as string; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n 'callRecoupApi\(|as string' src/recoup/contentApi.ts -C 1
rg -n 'from "zod"|\.parse\(|\.safeParse\(' src/recoup/contentApi.ts -C 1Repository: recoupable/tasks
Length of output: 618
🏁 Script executed:
cat -n src/recoup/contentApi.tsRepository: recoupable/tasks
Length of output: 2704
Validate Recoup responses with Zod and split module to follow SRP.
This file exports four functions that all cast untrusted external API responses directly without validation. If endpoint shapes change or error envelopes slip through, these calls return undefined and fail far from the boundary. Add Zod schemas to parse each response (lines 18-19, 30-31, 51-52, 70-71). Additionally, per coding guidelines, move each exported function to its own file to follow Single Responsibility Principle (currently exports generateImage, upscaleMedia, generateVideo, generateCaption — one per file).
Possible fix pattern
+import { z } from "zod";
import { callRecoupApi } from "./callRecoupApi";
+
+const generateImageResponseSchema = z.object({
+ imageUrl: z.string().url(),
+});
export async function generateImage(params: {
prompt: string;
referenceImageUrl?: string;
images?: string[];
}): Promise<string> {
const body: Record<string, unknown> = { prompt: params.prompt };
if (params.referenceImageUrl) body.reference_image_url = params.referenceImageUrl;
if (params.images) body.images = params.images;
- const data = await callRecoupApi("/api/content/image", body);
- return data.imageUrl as string;
+ const data = generateImageResponseSchema.parse(
+ await callRecoupApi("/api/content/image", body),
+ );
+ return data.imageUrl;
}Coding guidelines require: "Use Zod for schema validation" (applies to src/**/*.{ts,tsx}); "Implement SRP: have one exported function per file" (applies to src/**/*.{ts,tsx,js,jsx}).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/contentApi.ts` around lines 18 - 19, Add Zod response schemas and
validate the Recoup API results before casting: for each exported function
(generateImage, upscaleMedia, generateVideo, generateCaption) use callRecoupApi
to fetch raw data, parse it with a corresponding Zod schema (e.g.,
ImageResponseSchema, UpscaleResponseSchema, VideoResponseSchema,
CaptionResponseSchema) and throw or return a clear error when parsing fails
instead of casting with "as string". Also refactor by moving each function into
its own module (one file per exported function) so each file contains the single
responsibility of calling callRecoupApi, validating via the Zod schema, and
returning the validated value (reference the existing function names and
callRecoupApi to find where to apply the changes).
| if (payload.lipsync) { | ||
| // Lipsync path: audio baked into video | ||
| logStep("Generating audio-to-video (lipsync)"); | ||
| videoUrl = await generateAudioVideo({ | ||
| imageUrl, | ||
| songBuffer: audioClip.songBuffer, | ||
| audioStartSeconds: audioClip.startSeconds, | ||
| audioDurationSeconds: audioClip.durationSeconds, | ||
| motionPrompt, | ||
| }); | ||
| } else { | ||
| // Normal path: image-to-video, audio added in post | ||
| logStep("Generating video"); | ||
| videoUrl = await generateContentVideo({ | ||
| imageUrl, | ||
| motionPrompt, | ||
| }); | ||
| logStep("Uploading audio for lipsync"); | ||
| const audioFile = new File([audioClip.songBuffer], "song.mp3", { type: "audio/mpeg" }); | ||
| audioUrl = await fal.storage.upload(audioFile); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n 'FAL_KEY|fal\.config|fal\.storage\.upload|payload\.lipsync' src/tasks/createContentTask.ts -C 2
rg -n 'FAL_KEY' src/tasks/__tests__/createContentTask.test.ts src/tasks/__tests__/createContentTaskApi.test.ts -C 2Repository: recoupable/tasks
Length of output: 2643
Only require FAL_KEY for lipsync runs.
After this refactor, fal is only used at line 124 inside the if (payload.lipsync) block for fal.storage.upload(), but the task still throws unconditionally at lines 63-65 when FAL_KEY is missing. This breaks non-lipsync API-only runs unnecessarily.
Suggested fix
- const falKey = process.env.FAL_KEY;
- if (!falKey) throw new Error("FAL_KEY environment variable is required");
- fal.config({ credentials: falKey });
-
// --- Step 1: Load template ---
logStep("Loading template");
const template = await loadTemplate(payload.template);
@@
let audioUrl: string | undefined;
if (payload.lipsync) {
+ const falKey = process.env.FAL_KEY;
+ if (!falKey) throw new Error("FAL_KEY environment variable is required for lipsync");
+ fal.config({ credentials: falKey });
+
logStep("Uploading audio for lipsync");
const audioFile = new File([audioClip.songBuffer], "song.mp3", { type: "audio/mpeg" });
audioUrl = await fal.storage.upload(audioFile);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/tasks/createContentTask.ts` around lines 121 - 125, The code currently
enforces presence of FAL_KEY unconditionally even though fal is only used when
payload.lipsync is true; change the validation/initialization so FAL_KEY is
checked and fal (or storage upload) is only required when payload.lipsync is
true — e.g., move the FAL_KEY presence check and any fal initialization into the
lipsync branch that contains payload.lipsync / fal.storage.upload, or
lazy-initialize fal just before calling fal.storage.upload, ensuring non-lipsync
runs skip the FAL_KEY requirement.
YAGNI cleanup: - Delete generateContentImage.ts, generateContentVideo.ts, generateAudioVideo.ts, upscaleImage.ts, upscaleVideo.ts, generateCaption.ts, testPipeline.ts (no longer imported) Bug fix: - pickRandomReferenceImage returns local paths (e.g. "references/images/ref-01.png") which are not valid URLs for the API. Filter out non-http strings from images array. Reproduces Trigger.dev error: "API call failed: 400 — Invalid URL" All 328 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
4 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/tasks/createContentTask.ts">
<violation number="1" location="src/tasks/createContentTask.ts:100">
P1: `referenceImagePath` is a local file path, but this line sends it to the API as if it were a URL. Upload the file and pass a public URL instead.</violation>
<violation number="2" location="src/tasks/createContentTask.ts:123">
P1: Lipsync now ignores the selected clip window (`startSeconds`/`durationSeconds`) and uploads the full song, which can desync generated lipsync from the chosen clip.</violation>
<violation number="3" location="src/tasks/createContentTask.ts:142">
P2: Include `artistContext` and `audienceContext` in the caption topic payload; they are fetched earlier but currently dropped, which regresses caption personalization and adds unnecessary blocking fetches.</violation>
</file>
<file name="src/recoup/callRecoupApi.ts">
<violation number="1" location="src/recoup/callRecoupApi.ts:28">
P1: Check response status before assuming JSON; parsing with `response.json()` unconditionally can throw on non-JSON error bodies and hide the actual HTTP failure details.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| motionPrompt, | ||
| }); | ||
| logStep("Uploading audio for lipsync"); | ||
| const audioFile = new File([audioClip.songBuffer], "song.mp3", { type: "audio/mpeg" }); |
There was a problem hiding this comment.
P1: Lipsync now ignores the selected clip window (startSeconds/durationSeconds) and uploads the full song, which can desync generated lipsync from the chosen clip.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tasks/createContentTask.ts, line 123:
<comment>Lipsync now ignores the selected clip window (`startSeconds`/`durationSeconds`) and uploads the full song, which can desync generated lipsync from the chosen clip.</comment>
<file context>
@@ -89,65 +88,67 @@ export const createContentTask = schemaTask({
- motionPrompt,
- });
+ logStep("Uploading audio for lipsync");
+ const audioFile = new File([audioClip.songBuffer], "song.mp3", { type: "audio/mpeg" });
+ audioUrl = await fal.storage.upload(audioFile);
}
</file context>
|
|
||
| const imageRefs: string[] = []; | ||
| if (faceGuideUrl) imageRefs.push(faceGuideUrl); | ||
| if (referenceImagePath) imageRefs.push(referenceImagePath); |
There was a problem hiding this comment.
P1: referenceImagePath is a local file path, but this line sends it to the API as if it were a URL. Upload the file and pass a public URL instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tasks/createContentTask.ts, line 100:
<comment>`referenceImagePath` is a local file path, but this line sends it to the API as if it were a URL. Upload the file and pass a public URL instead.</comment>
<file context>
@@ -89,65 +88,67 @@ export const createContentTask = schemaTask({
+
+ const imageRefs: string[] = [];
+ if (faceGuideUrl) imageRefs.push(faceGuideUrl);
+ if (referenceImagePath) imageRefs.push(referenceImagePath);
+ if (!template.usesImageOverlay && additionalImageUrls.length) {
+ imageRefs.push(...additionalImageUrls);
</file context>
1. callRecoupApi now logs every request/response via logStep 2. generateCaption skips template param if not in API's known list (fixes 400 error for artist-release-editorial template) Reproduces Trigger.dev error: "Invalid option: expected one of artist-caption-bedroom|..." All 330 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
src/recoup/contentApi.ts
Outdated
| * @param params - Image generation parameters. | ||
| * @returns URL of the generated image. | ||
| */ | ||
| export async function generateImage(params: { |
There was a problem hiding this comment.
SRP - new lib file for generateImage and any other function in this file with a name different thant the file name.
- generateImage.ts - upscaleMedia.ts - generateVideo.ts - generateCaption.ts - contentApi.ts is now a re-export barrel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ly quote The curly right single quotation mark (U+2019) looks identical to a closing single quote to ffmpeg's filter_complex parser, causing "No option name" errors when captions contain apostrophes. Fix: remove all quote-like characters entirely. Captions read naturally without them (e.g. "youre" vs "you're"). Reproduces Trigger.dev error: filter_complex parsing failed on caption "Desire ignites: you're my addiction" — the apostrophe in "you're" was interpreted as the end of the drawtext text= string. All 331 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/content/escapeDrawtext.ts">
<violation number="1" location="src/content/escapeDrawtext.ts:16">
P2: Stripping apostrophes degrades caption readability in rendered videos. The previous approach — replacing with U+2019 (right single quotation mark) — already solved the ffmpeg delimiter problem while preserving natural-looking text. Consider keeping the replacement strategy rather than deleting. Words like "won't" → "wont" and "it's" → "its" change meaning or look like typos in user-visible captions.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
src/content/escapeDrawtext.ts
Outdated
| .replace(/\n/g, " ") | ||
| .replace(/\\/g, "\\\\\\\\") | ||
| .replace(/['\u2018\u2032]/g, "\u2019") | ||
| .replace(/['\u2018\u2019\u2032]/g, "") |
There was a problem hiding this comment.
P2: Stripping apostrophes degrades caption readability in rendered videos. The previous approach — replacing with U+2019 (right single quotation mark) — already solved the ffmpeg delimiter problem while preserving natural-looking text. Consider keeping the replacement strategy rather than deleting. Words like "won't" → "wont" and "it's" → "its" change meaning or look like typos in user-visible captions.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/content/escapeDrawtext.ts, line 16:
<comment>Stripping apostrophes degrades caption readability in rendered videos. The previous approach — replacing with U+2019 (right single quotation mark) — already solved the ffmpeg delimiter problem while preserving natural-looking text. Consider keeping the replacement strategy rather than deleting. Words like "won't" → "wont" and "it's" → "its" change meaning or look like typos in user-visible captions.</comment>
<file context>
@@ -14,7 +13,7 @@ export function escapeDrawtext(text: string): string {
.replace(/\n/g, " ")
.replace(/\\/g, "\\\\\\\\")
- .replace(/['\u2018\u2032]/g, "\u2019")
+ .replace(/['\u2018\u2019\u2032]/g, "")
.replace(/:/g, "\\\\:")
.replace(/%/g, "%%");
</file context>
| .replace(/['\u2018\u2019\u2032]/g, "") | |
| .replace(/['\u2018\u2032]/g, "\u2019") |
The caption API produces richer text than the old internal generator: double quotes, emoji, hashtags. These break ffmpeg's filter_complex parser when used inside single-quoted drawtext text= strings. Now removes: single quotes, double quotes, curly quotes, emoji. Also collapses multiple spaces and trims. Reproduces exact production error: caption contained "Yo quiero un chin, tu eres mía." 🎶🔥 — the double quotes broke ffmpeg's filter_complex parser. All 334 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/content/escapeDrawtext.ts">
<violation number="1" location="src/content/escapeDrawtext.ts:17">
P2: Emoji stripping misses Zero Width Joiners (U+200D) and several common emoji ranges (U+2300–23FF, U+2B00–2BFF). Compound emoji like 👨👩👧 will have their visible codepoints removed but leave invisible ZWJ characters in the output, which can still break ffmpeg's parser.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| .replace(/\\/g, "\\\\\\\\") | ||
| .replace(/['\u2018\u2032]/g, "\u2019") | ||
| .replace(/['\u2018\u2019\u2032""\u201C\u201D]/g, "") | ||
| .replace(/[\u{1F000}-\u{1FFFF}]/gu, "") |
There was a problem hiding this comment.
P2: Emoji stripping misses Zero Width Joiners (U+200D) and several common emoji ranges (U+2300–23FF, U+2B00–2BFF). Compound emoji like 👨👩👧 will have their visible codepoints removed but leave invisible ZWJ characters in the output, which can still break ffmpeg's parser.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/content/escapeDrawtext.ts, line 17:
<comment>Emoji stripping misses Zero Width Joiners (U+200D) and several common emoji ranges (U+2300–23FF, U+2B00–2BFF). Compound emoji like 👨👩👧 will have their visible codepoints removed but leave invisible ZWJ characters in the output, which can still break ffmpeg's parser.</comment>
<file context>
@@ -13,7 +13,12 @@ export function escapeDrawtext(text: string): string {
.replace(/\\/g, "\\\\\\\\")
- .replace(/['\u2018\u2019\u2032]/g, "")
+ .replace(/['\u2018\u2019\u2032""\u201C\u201D]/g, "")
+ .replace(/[\u{1F000}-\u{1FFFF}]/gu, "")
+ .replace(/[\u{2600}-\u{27BF}]/gu, "")
+ .replace(/[\u{FE00}-\u{FE0F}]/gu, "")
</file context>
…ext in caption 1. callRecoupApi: try/catch response.json() — handles 502 and non-JSON error responses without throwing a parse error 2. Caption topic now includes artistContext and audienceContext so the caption API generates contextually relevant text All 335 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
generateCaption now calls GET /api/content/templates to get the real list of valid template IDs, instead of hardcoding them. If the template isn't in the API's list, it's omitted from the caption request. Also adds GET support to callRecoupApi. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
src/content/escapeDrawtext.ts (1)
17-19: Reuse the existing emoji-stripping helper instead of duplicating the ranges.These three replacements duplicate
src/content/stripEmoji.ts, andsrc/content/buildOverlayTextFilter.ts:1-36already strips emoji before callingescapeDrawtext(). Keeping both copies in sync will get brittle fast; this is a good candidate for a shared utility or composing withstripEmoji().As per coding guidelines,
src/**/*.{ts,tsx,js,jsx}: "Extract shared logic into reusable utilities to follow DRY (Don't Repeat Yourself)".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/content/escapeDrawtext.ts` around lines 17 - 19, The three duplicate emoji-range .replace calls in escapeDrawtext() should be removed and the existing stripEmoji utility reused: import stripEmoji (from the module implemented in stripEmoji.ts) and call or compose it with escapeDrawtext (or call stripEmoji inside escapeDrawtext) so emoji are removed by the shared function; ensure buildOverlayTextFilter still strips emoji before/when calling escapeDrawtext by using the same stripEmoji to avoid duplicated ranges and keep behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/content/__tests__/escapeDrawtext.test.ts`:
- Around line 58-62: Update the test for escapeDrawtext to assert the exact
normalized output (including collapsed whitespace and trimming) rather than only
checking emoji absence: call escapeDrawtext("fire 🔥🎶 music") and expect the
returned string to equal the normalized "fire music" (no extra spaces),
referencing the escapeDrawtext function and the new collapse/trim behavior added
around lines 22-23 so regressions in whitespace collapsing/trimming are caught.
In `@src/recoup/generateCaption.ts`:
- Around line 16-27: The generateCaption function unsafely casts the API
response (data.content as string); replace this with Zod validation: add a Zod
schema (e.g., const CaptionResponseSchema = z.object({ content: z.string() })),
import z from "zod", then parse/validate the result from callRecoupApi (e.g.,
CaptionResponseSchema.parse(data)) and return the validated content string;
reference generateCaption and callRecoupApi when updating the response handling
to remove the unsafe cast.
In `@src/recoup/generateImage.ts`:
- Around line 9-23: The generateImage function lacks runtime validation: replace
the loose startsWith("http") check and the unsafe cast of data.imageUrl with Zod
schemas for input and API response; create a Zod schema for the input shape
(prompt: nonempty string, optional referenceImageUrl:
z.string().url().optional(), optional images:
z.array(z.string().url()).optional()) and validate params at the start of
generateImage, filter/normalize images using the validated input, then create a
strict response schema (e.g., { imageUrl: z.string().url() }) and validate the
result returned by callRecoupApi before returning the image URL; throw or return
a controlled error when validation fails so callers of generateImage get a safe,
typed value.
In `@src/recoup/generateVideo.ts`:
- Around line 9-22: The generateVideo function is doing an unchecked cast of
data.videoUrl; add Zod validation for both the request body and the response:
create a request schema (image_url, prompt, optional audio_url) and a response
schema (videoUrl: string), validate the body before calling callRecoupApi (or at
least ensure the outgoing shape matches the request schema) and parse/validate
the API response using the response schema then return the validated videoUrl
instead of using "data.videoUrl as string"; reference the generateVideo
function, the body object, callRecoupApi call, and data.videoUrl when
implementing the schema checks and throwing a clear error if validation fails.
In `@src/recoup/upscaleMedia.ts`:
- Around line 10-12: The upscaleMedia function currently casts the API response
with "return data.url as string"; replace that unsafe cast by validating the
response with a Zod schema (e.g., define a schema like UpscaleResponse =
z.object({ url: z.string() })) and use UpscaleResponse.parse or .safeParse on
the result of callRecoupApi inside upscaleMedia; if validation fails, throw or
return an appropriate error instead of returning an unvalidated value so callers
always get a guaranteed string from upscaleMedia.
---
Nitpick comments:
In `@src/content/escapeDrawtext.ts`:
- Around line 17-19: The three duplicate emoji-range .replace calls in
escapeDrawtext() should be removed and the existing stripEmoji utility reused:
import stripEmoji (from the module implemented in stripEmoji.ts) and call or
compose it with escapeDrawtext (or call stripEmoji inside escapeDrawtext) so
emoji are removed by the shared function; ensure buildOverlayTextFilter still
strips emoji before/when calling escapeDrawtext by using the same stripEmoji to
avoid duplicated ranges and keep behavior consistent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9f486818-56b2-4095-a6bc-c0af7d7100d4
📒 Files selected for processing (20)
src/content/__tests__/escapeDrawtext.test.tssrc/content/__tests__/generateContentImage.test.tssrc/content/escapeDrawtext.tssrc/content/generateAudioVideo.tssrc/content/generateCaption.tssrc/content/generateContentImage.tssrc/content/generateContentVideo.tssrc/content/testPipeline.tssrc/content/upscaleImage.tssrc/content/upscaleVideo.tssrc/recoup/__tests__/callRecoupApi.test.tssrc/recoup/__tests__/contentApi.test.tssrc/recoup/callRecoupApi.tssrc/recoup/contentApi.tssrc/recoup/generateCaption.tssrc/recoup/generateImage.tssrc/recoup/generateVideo.tssrc/recoup/upscaleMedia.tssrc/tasks/__tests__/createContentTaskApi.test.tssrc/tasks/createContentTask.ts
💤 Files with no reviewable changes (8)
- src/content/upscaleImage.ts
- src/content/upscaleVideo.ts
- src/content/tests/generateContentImage.test.ts
- src/content/testPipeline.ts
- src/content/generateContentImage.ts
- src/content/generateContentVideo.ts
- src/content/generateAudioVideo.ts
- src/content/generateCaption.ts
✅ Files skipped from review due to trivial changes (1)
- src/tasks/tests/createContentTaskApi.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- src/recoup/tests/contentApi.test.ts
- src/recoup/callRecoupApi.ts
- src/tasks/createContentTask.ts
- src/recoup/tests/callRecoupApi.test.ts
- src/recoup/contentApi.ts
| it("removes emoji", () => { | ||
| const result = escapeDrawtext("fire 🔥🎶 music"); | ||
| expect(result).not.toContain("🔥"); | ||
| expect(result).not.toContain("🎶"); | ||
| }); |
There was a problem hiding this comment.
Assert the normalized output here, not just emoji absence.
This test would still pass if the function returned fire music or left leading/trailing spaces, so the new collapse/trim behavior on src/content/escapeDrawtext.ts Lines 22-23 can regress unnoticed.
✅ Tighten the expectation
it("removes emoji", () => {
const result = escapeDrawtext("fire 🔥🎶 music");
- expect(result).not.toContain("🔥");
- expect(result).not.toContain("🎶");
+ expect(result).toBe("fire music");
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| it("removes emoji", () => { | |
| const result = escapeDrawtext("fire 🔥🎶 music"); | |
| expect(result).not.toContain("🔥"); | |
| expect(result).not.toContain("🎶"); | |
| }); | |
| it("removes emoji", () => { | |
| const result = escapeDrawtext("fire 🔥🎶 music"); | |
| expect(result).toBe("fire music"); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/content/__tests__/escapeDrawtext.test.ts` around lines 58 - 62, Update
the test for escapeDrawtext to assert the exact normalized output (including
collapsed whitespace and trimming) rather than only checking emoji absence: call
escapeDrawtext("fire 🔥🎶 music") and expect the returned string to equal the
normalized "fire music" (no extra spaces), referencing the escapeDrawtext
function and the new collapse/trim behavior added around lines 22-23 so
regressions in whitespace collapsing/trimming are caught.
| export async function generateCaption(params: { | ||
| topic: string; | ||
| template?: string; | ||
| length?: string; | ||
| }): Promise<string> { | ||
| const body: Record<string, unknown> = { topic: params.topic }; | ||
| if (params.template && API_TEMPLATE_IDS.includes(params.template)) body.template = params.template; | ||
| if (params.length) body.length = params.length; | ||
|
|
||
| const data = await callRecoupApi("/api/content/caption", body); | ||
| return data.content as string; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the file and check its existence
find . -name "generateCaption.ts" -type fRepository: recoupable/tasks
Length of output: 91
🏁 Script executed:
# Check if the file path exists and read the relevant section
if [ -f "src/recoup/generateCaption.ts" ]; then
cat -n "src/recoup/generateCaption.ts" | head -40
else
echo "File not found at expected path, searching in repository..."
git ls-files | grep -i generatecaption
fiRepository: recoupable/tasks
Length of output: 1031
🏁 Script executed:
# Check project dependencies for Zod
cat package.json | jq '.dependencies | has("zod")' 2>/dev/null || echo "package.json not found or jq failed"Repository: recoupable/tasks
Length of output: 64
🏁 Script executed:
# Find and read callRecoupApi
cat -n "src/recoup/callRecoupApi.ts" 2>/dev/null || find . -name "callRecoupApi.ts" -type fRepository: recoupable/tasks
Length of output: 1763
🏁 Script executed:
# Check if callRecoupApi exists and read it
if [ -f "src/recoup/callRecoupApi.ts" ]; then
cat -n "src/recoup/callRecoupApi.ts"
fiRepository: recoupable/tasks
Length of output: 1763
Use Zod to validate the API response instead of unsafe casting on line 26.
callRecoupApi returns Record<string, unknown>, so accessing data.content as string bypasses runtime validation. Add Zod schema for the response to catch data shape mismatches from the API.
Proposed fix
import { callRecoupApi } from "./callRecoupApi";
+import { z } from "zod";
const API_TEMPLATE_IDS = [
"artist-caption-bedroom",
"artist-caption-outside",
"artist-caption-stage",
"album-record-store",
];
+
+const CaptionResponseSchema = z.object({
+ content: z.string().min(1),
+});
export async function generateCaption(params: {
topic: string;
template?: string;
length?: string;
}): Promise<string> {
const body: Record<string, unknown> = { topic: params.topic };
if (params.template && API_TEMPLATE_IDS.includes(params.template)) body.template = params.template;
if (params.length) body.length = params.length;
const data = await callRecoupApi("/api/content/caption", body);
- return data.content as string;
+ const parsedResponse = CaptionResponseSchema.safeParse(data);
+ if (!parsedResponse.success) {
+ throw new Error("Invalid /api/content/caption response: missing string content");
+ }
+ return parsedResponse.data.content;
}Per coding guidelines: src/**/*.{ts,tsx}: Use Zod for schema validation.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function generateCaption(params: { | |
| topic: string; | |
| template?: string; | |
| length?: string; | |
| }): Promise<string> { | |
| const body: Record<string, unknown> = { topic: params.topic }; | |
| if (params.template && API_TEMPLATE_IDS.includes(params.template)) body.template = params.template; | |
| if (params.length) body.length = params.length; | |
| const data = await callRecoupApi("/api/content/caption", body); | |
| return data.content as string; | |
| } | |
| export async function generateCaption(params: { | |
| topic: string; | |
| template?: string; | |
| length?: string; | |
| }): Promise<string> { | |
| const body: Record<string, unknown> = { topic: params.topic }; | |
| if (params.template && API_TEMPLATE_IDS.includes(params.template)) body.template = params.template; | |
| if (params.length) body.length = params.length; | |
| const data = await callRecoupApi("/api/content/caption", body); | |
| const parsedResponse = CaptionResponseSchema.safeParse(data); | |
| if (!parsedResponse.success) { | |
| throw new Error("Invalid /api/content/caption response: missing string content"); | |
| } | |
| return parsedResponse.data.content; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/generateCaption.ts` around lines 16 - 27, The generateCaption
function unsafely casts the API response (data.content as string); replace this
with Zod validation: add a Zod schema (e.g., const CaptionResponseSchema =
z.object({ content: z.string() })), import z from "zod", then parse/validate the
result from callRecoupApi (e.g., CaptionResponseSchema.parse(data)) and return
the validated content string; reference generateCaption and callRecoupApi when
updating the response handling to remove the unsafe cast.
| export async function generateImage(params: { | ||
| prompt: string; | ||
| referenceImageUrl?: string; | ||
| images?: string[]; | ||
| }): Promise<string> { | ||
| const body: Record<string, unknown> = { prompt: params.prompt }; | ||
| if (params.referenceImageUrl) body.reference_image_url = params.referenceImageUrl; | ||
| if (params.images) { | ||
| const validUrls = params.images.filter(url => url.startsWith("http")); | ||
| if (validUrls.length > 0) body.images = validUrls; | ||
| } | ||
|
|
||
| const data = await callRecoupApi("/api/content/image", body); | ||
| return data.imageUrl as string; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if Zod is in dependencies
if [ -f "package.json" ]; then
jq '.dependencies // {} | has("zod"), .devDependencies // {} | has("zod")' package.json
else
echo "package.json not found"
fiRepository: recoupable/tasks
Length of output: 137
🏁 Script executed:
# Verify file exists and check its current content
if [ -f "src/recoup/generateImage.ts" ]; then
cat -n src/recoup/generateImage.ts
else
echo "File not found at src/recoup/generateImage.ts"
fiRepository: recoupable/tasks
Length of output: 966
🏁 Script executed:
# Search for other Zod usage in the codebase to understand the pattern
rg "import.*from ['\"]zod['\"]" --type ts --type tsx -A 2 | head -50Repository: recoupable/tasks
Length of output: 87
🏁 Script executed:
# Check for URL validation patterns in similar files
rg "startsWith.*http|new URL|isValidUrl|url.*valid" --type ts --type tsx -B 2 -A 2 | head -60Repository: recoupable/tasks
Length of output: 87
🏁 Script executed:
# Check if Zod is in package.json (simpler approach)
grep -i "zod" package.jsonRepository: recoupable/tasks
Length of output: 80
🏁 Script executed:
# Find Zod schema usage patterns in the codebase
rg "z\\.object|z\\.string|z\\.array|ZodSchema" --type ts -B 2 -A 5 | head -80Repository: recoupable/tasks
Length of output: 5073
🏁 Script executed:
# Check the callRecoupApi function to understand what it returns
fd "callRecoupApi" --extension ts -o --exec cat {} \;Repository: recoupable/tasks
Length of output: 176
🏁 Script executed:
# Look for any validation patterns in src/recoup directory
fd . src/recoup --extension ts -o --exec rg "parse|validate|schema" {} + | head -40Repository: recoupable/tasks
Length of output: 176
🏁 Script executed:
# Find and examine callRecoupApi implementation
rg "callRecoupApi|function callRecoupApi" --type ts -B 2 -A 10 | head -60Repository: recoupable/tasks
Length of output: 3151
🏁 Script executed:
# Look for similar response validation patterns in recoup directory
find src/recoup -name "*.ts" -type f -exec basename {} \; | head -20Repository: recoupable/tasks
Length of output: 523
🏁 Script executed:
# Check if any files in recoup directory use Zod validation
rg "import.*zod" src/recoup/ --type tsRepository: recoupable/tasks
Length of output: 606
Add runtime schema validation for both input and API response.
startsWith("http") is too loose, and data.imageUrl as string bypasses runtime safety. Invalid URLs and response shapes can leak through and cause failures. Per coding guidelines, src/**/*.{ts,tsx} must use Zod for schema validation—other files in this directory already follow this pattern.
Suggested hardening (Zod + strict URL check)
import { callRecoupApi } from "./callRecoupApi";
+import { z } from "zod";
+
+const GenerateImageParamsSchema = z.object({
+ prompt: z.string().min(1),
+ referenceImageUrl: z.string().url().optional(),
+ images: z.array(z.string()).optional(),
+});
+
+const GenerateImageResponseSchema = z.object({
+ imageUrl: z.string().url(),
+});
+
+const isHttpUrl = (value: string): boolean => {
+ try {
+ const parsed = new URL(value);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+};
export async function generateImage(params: {
prompt: string;
referenceImageUrl?: string;
images?: string[];
}): Promise<string> {
- const body: Record<string, unknown> = { prompt: params.prompt };
- if (params.referenceImageUrl) body.reference_image_url = params.referenceImageUrl;
- if (params.images) {
- const validUrls = params.images.filter(url => url.startsWith("http"));
+ const parsedParams = GenerateImageParamsSchema.parse(params);
+ const body: Record<string, unknown> = { prompt: parsedParams.prompt };
+ if (parsedParams.referenceImageUrl) body.reference_image_url = parsedParams.referenceImageUrl;
+ if (parsedParams.images) {
+ const validUrls = parsedParams.images.filter(isHttpUrl);
if (validUrls.length > 0) body.images = validUrls;
}
const data = await callRecoupApi("/api/content/image", body);
- return data.imageUrl as string;
+ return GenerateImageResponseSchema.parse(data).imageUrl;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/generateImage.ts` around lines 9 - 23, The generateImage function
lacks runtime validation: replace the loose startsWith("http") check and the
unsafe cast of data.imageUrl with Zod schemas for input and API response; create
a Zod schema for the input shape (prompt: nonempty string, optional
referenceImageUrl: z.string().url().optional(), optional images:
z.array(z.string().url()).optional()) and validate params at the start of
generateImage, filter/normalize images using the validated input, then create a
strict response schema (e.g., { imageUrl: z.string().url() }) and validate the
result returned by callRecoupApi before returning the image URL; throw or return
a controlled error when validation fails so callers of generateImage get a safe,
typed value.
| export async function generateVideo(params: { | ||
| imageUrl: string; | ||
| prompt: string; | ||
| audioUrl?: string; | ||
| }): Promise<string> { | ||
| const body: Record<string, unknown> = { | ||
| image_url: params.imageUrl, | ||
| prompt: params.prompt, | ||
| }; | ||
| if (params.audioUrl) body.audio_url = params.audioUrl; | ||
|
|
||
| const data = await callRecoupApi("/api/content/video", body); | ||
| return data.videoUrl as string; | ||
| } |
There was a problem hiding this comment.
Validate request/response with Zod; avoid unchecked cast on Line 21.
data.videoUrl as string trusts an external API shape without runtime checks. If the endpoint returns malformed JSON, this can propagate invalid values and fail later.
Suggested fix
import { callRecoupApi } from "./callRecoupApi";
+import { z } from "zod";
+
+const GenerateVideoParamsSchema = z.object({
+ imageUrl: z.url(),
+ prompt: z.string().min(1),
+ audioUrl: z.url().optional(),
+});
+
+const GenerateVideoResponseSchema = z.object({
+ videoUrl: z.url(),
+});
/**
* Generate a video via POST /api/content/video.
*
* `@param` params - Video generation parameters.
* `@returns` URL of the generated video.
*/
export async function generateVideo(params: {
imageUrl: string;
prompt: string;
audioUrl?: string;
}): Promise<string> {
+ const parsedParams = GenerateVideoParamsSchema.parse(params);
+
const body: Record<string, unknown> = {
- image_url: params.imageUrl,
- prompt: params.prompt,
+ image_url: parsedParams.imageUrl,
+ prompt: parsedParams.prompt,
};
- if (params.audioUrl) body.audio_url = params.audioUrl;
+ if (parsedParams.audioUrl) body.audio_url = parsedParams.audioUrl;
const data = await callRecoupApi("/api/content/video", body);
- return data.videoUrl as string;
+ return GenerateVideoResponseSchema.parse(data).videoUrl;
}As per coding guidelines, src/**/*.{ts,tsx}: Use Zod for schema validation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/generateVideo.ts` around lines 9 - 22, The generateVideo function
is doing an unchecked cast of data.videoUrl; add Zod validation for both the
request body and the response: create a request schema (image_url, prompt,
optional audio_url) and a response schema (videoUrl: string), validate the body
before calling callRecoupApi (or at least ensure the outgoing shape matches the
request schema) and parse/validate the API response using the response schema
then return the validated videoUrl instead of using "data.videoUrl as string";
reference the generateVideo function, the body object, callRecoupApi call, and
data.videoUrl when implementing the schema checks and throwing a clear error if
validation fails.
| export async function upscaleMedia(url: string, type: "image" | "video"): Promise<string> { | ||
| const data = await callRecoupApi("/api/content/upscale", { url, type }); | ||
| return data.url as string; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "1) Inspect callRecoupApi return typing:"
rg -n --type=ts -C3 'export\s+async\s+function\s+callRecoupApi|Promise<|return' src/recoup/callRecoupApi.ts
echo
echo "2) Find wrappers relying on response casts:"
rg -n --type=ts -C2 'as string|as number|as boolean|return\s+data\.' src/recoupRepository: recoupable/tasks
Length of output: 2676
🏁 Script executed:
fd -t f -E node_modules 'package.json|package-lock.json|yarn.lock|pnpm-lock.yaml'Repository: recoupable/tasks
Length of output: 87
🏁 Script executed:
head -20 src/recoup/upscaleMedia.tsRepository: recoupable/tasks
Length of output: 519
🏁 Script executed:
rg -l 'from.*zod|import.*zod' src/ || echo "No Zod imports found"Repository: recoupable/tasks
Length of output: 829
🏁 Script executed:
cat package.json | grep -A 20 '"dependencies"' | head -25Repository: recoupable/tasks
Length of output: 612
🏁 Script executed:
cat package.json | grep -A 20 '"devDependencies"' | head -25Repository: recoupable/tasks
Length of output: 227
Validate the API response shape instead of casting at line 12.
return data.url as string trusts an unvalidated payload. If the endpoint returns malformed JSON, this can propagate bad data and fail later. Parse with Zod before returning.
Proposed fix
+import { z } from "zod";
import { callRecoupApi } from "./callRecoupApi";
+
+const UpscaleResponseSchema = z.object({
+ url: z.string().url(),
+});
@@
export async function upscaleMedia(url: string, type: "image" | "video"): Promise<string> {
- const data = await callRecoupApi("/api/content/upscale", { url, type });
- return data.url as string;
+ const raw = await callRecoupApi("/api/content/upscale", { url, type });
+ const data = UpscaleResponseSchema.parse(raw);
+ return data.url;
}Per coding guidelines, "Use Zod for schema validation" for TypeScript files.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/recoup/upscaleMedia.ts` around lines 10 - 12, The upscaleMedia function
currently casts the API response with "return data.url as string"; replace that
unsafe cast by validating the response with a Zod schema (e.g., define a schema
like UpscaleResponse = z.object({ url: z.string() })) and use
UpscaleResponse.parse or .safeParse on the result of callRecoupApi inside
upscaleMedia; if validation fails, throw or return an appropriate error instead
of returning an unvalidated value so callers always get a guaranteed string from
upscaleMedia.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/recoup/generateCaption.ts (1)
8-12:⚠️ Potential issue | 🟠 MajorValidate API responses with Zod before accessing
templates/content.Line 10 and Line 35 currently trust untyped API data via casts. If the response shape changes, this can throw at runtime or return invalid values.
Proposed fix
import { callRecoupApi } from "./callRecoupApi"; +import { z } from "zod"; + +const TemplatesResponseSchema = z.object({ + templates: z.array(z.object({ id: z.string().min(1) })), +}); + +const CaptionResponseSchema = z.object({ + content: z.string().min(1), +}); @@ async function fetchTemplateIds(): Promise<string[]> { const data = await callRecoupApi("/api/content/templates", {}, "GET"); - const templates = data.templates as Array<{ id: string }>; - return templates.map(t => t.id); + const parsed = TemplatesResponseSchema.safeParse(data); + if (!parsed.success) { + throw new Error("Invalid /api/content/templates response"); + } + return parsed.data.templates.map(t => t.id); } @@ if (params.length) body.length = params.length; const data = await callRecoupApi("/api/content/caption", body); - return data.content as string; + const parsed = CaptionResponseSchema.safeParse(data); + if (!parsed.success) { + throw new Error("Invalid /api/content/caption response"); + } + return parsed.data.content; }#!/bin/bash set -euo pipefail FILE="$(fd -a '^generateCaption\.ts$' src | head -n1)" echo "Reviewing: $FILE" nl -ba "$FILE" | sed -n '1,140p' echo echo "Unsafe casts (should be removed):" rg -n 'as string|as Array<\{ id: string \}>' "$FILE" || true echo echo "Zod validation usage (should exist):" rg -n 'from "zod"|safeParse|\.parse\(' "$FILE" || trueAs per coding guidelines:
src/**/*.{ts,tsx}: Use Zod for schema validation.Also applies to: 34-35
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/recoup/generateCaption.ts` around lines 8 - 12, The code is trusting untyped API responses (casts like "as Array<{ id: string }>" in fetchTemplateIds and similar casts around content) which can crash if the shape changes; fix by defining Zod schemas (e.g., TemplateListSchema, ContentResponseSchema) and use safeParse/parse on the result of callRecoupApi inside fetchTemplateIds and the other content-handling function(s), remove the unsafe casts, return validated values (or throw/log a descriptive error on validation failure) and keep function return types as Promise<string[]> (or the correct typed shape) so downstream code receives only validated data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/recoup/generateCaption.ts`:
- Around line 8-12: The code is trusting untyped API responses (casts like "as
Array<{ id: string }>" in fetchTemplateIds and similar casts around content)
which can crash if the shape changes; fix by defining Zod schemas (e.g.,
TemplateListSchema, ContentResponseSchema) and use safeParse/parse on the result
of callRecoupApi inside fetchTemplateIds and the other content-handling
function(s), remove the unsafe casts, return validated values (or throw/log a
descriptive error on validation failure) and keep function return types as
Promise<string[]> (or the correct typed shape) so downstream code receives only
validated data.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 81af2b23-43cb-4719-a012-d0dd390cebef
📒 Files selected for processing (3)
src/recoup/__tests__/contentApi.test.tssrc/recoup/callRecoupApi.tssrc/recoup/generateCaption.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- src/recoup/callRecoupApi.ts
- src/recoup/tests/contentApi.test.ts
Summary
Swap 5 internal fal.ai/LLM function calls in the create-content task with Recoup API endpoint calls. The task now uses the same endpoints that external developers use.
What changed
generateContentImage()→ fal.ai directlyPOST /api/content/imageupscaleImage()→ fal.ai directlyPOST /api/content/upscalegenerateContentVideo()→ fal.ai directlyPOST /api/content/videoupscaleVideo()→ fal.ai directlyPOST /api/content/upscalegenerateCaption()→ Gemini via chat APIPOST /api/content/captionWhat didn't change
New files
src/recoup/callRecoupApi.tssrc/recoup/contentApi.tssrc/recoup/__tests__/callRecoupApi.test.tssrc/recoup/__tests__/contentApi.test.tssrc/tasks/__tests__/createContentTaskApi.test.tsWhy
Test plan
🤖 Generated with Claude Code
Summary by cubic
Routes the create-content task through Recoup API endpoints instead of internal fal/LLM calls. Adds request logging, safer API error handling, stricter caption escaping, and validates caption templates against the API to reduce failures and improve quality.
Refactors
POST /api/content/imagePOST /api/content/upscalePOST /api/content/videoPOST /api/content/captionsrc/recoup/callRecoupApi.ts(auth + request logging + safe JSON parsing + GET support) and split wrappers intogenerateImage.ts,generateVideo.ts,upscaleMedia.ts,generateCaption.tswithsrc/recoup/contentApi.tsas a barrel export; extractedfetchTemplateIds.ts.fal.storagevia@fal-ai/client, then passesaudio_urlto the video endpoint.Bug Fixes
POST /api/content/image(prevents “400 — Invalid URL”).GET /api/content/templates, omit unknown templates, and include artist/audience context in the topic for more relevant text.escapeDrawtextnow removes single/double/curly quotes and emoji, escapes colons/percent/backslashes, collapses spaces, and trims to avoid ffmpeg filter_complex parsing errors.Written for commit d58b8fd. Summary will update on new commits.
Summary by CodeRabbit
New Features
Refactor
Tests