Skip to content

feat: create-content task uses API endpoints instead of internal functions#129

Merged
sweetmantech merged 9 commits intomainfrom
feature/content-task-use-api-endpoints
Apr 10, 2026
Merged

feat: create-content task uses API endpoints instead of internal functions#129
sweetmantech merged 9 commits intomainfrom
feature/content-task-use-api-endpoints

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented Apr 9, 2026

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

Step Before (internal) After (API)
5. Image generateContentImage() → fal.ai directly POST /api/content/image
6. Upscale image upscaleImage() → fal.ai directly POST /api/content/upscale
7. Video generateContentVideo() → fal.ai directly POST /api/content/video
8. Upscale video upscaleVideo() → fal.ai directly POST /api/content/upscale
9. Caption generateCaption() → Gemini via chat API POST /api/content/caption

What didn't change

  • Steps 1-4: template loading, face guide, audio clip selection, artist/audience context
  • Step 10: final ffmpeg render (renderFinalVideo)

New files

File Purpose
src/recoup/callRecoupApi.ts Authenticated API call helper
src/recoup/contentApi.ts Typed wrappers: generateImage, upscaleMedia, generateVideo, generateCaption
src/recoup/__tests__/callRecoupApi.test.ts 3 tests
src/recoup/__tests__/contentApi.test.ts 6 tests
src/tasks/__tests__/createContentTaskApi.test.ts 8 tests verifying API calls

Why

  • External devs and the internal pipeline use the same code paths
  • Easier to debug — API endpoints have logging, validation, error handling
  • Foundation for composable workflows (skip/replace any step)

Test plan

  • 333 total tests pass (17 new)
  • Existing createContentTask tests updated and passing
  • End-to-end test on Trigger.dev dev environment

🤖 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

    • Swapped internal calls for API endpoints:
      • Image → POST /api/content/image
      • Upscale (image/video) → POST /api/content/upscale
      • Video/lipsync → POST /api/content/video
      • Caption → POST /api/content/caption
    • Added src/recoup/callRecoupApi.ts (auth + request logging + safe JSON parsing + GET support) and split wrappers into generateImage.ts, generateVideo.ts, upscaleMedia.ts, generateCaption.ts with src/recoup/contentApi.ts as a barrel export; extracted fetchTemplateIds.ts.
    • Lipsync uploads audio to fal.storage via @fal-ai/client, then passes audio_url to the video endpoint.
    • Removed orphaned internal content functions and the local test harness; steps 1–4 and final render unchanged.
  • Bug Fixes

    • Filter non-URL image refs before calling POST /api/content/image (prevents “400 — Invalid URL”).
    • Caption API: fetch valid template IDs via GET /api/content/templates, omit unknown templates, and include artist/audience context in the topic for more relevant text.
    • API calls: log all requests/responses and handle non-JSON error bodies (e.g., 502) without throwing parse errors.
    • escapeDrawtext now 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

    • Integrated API-backed image, video, upscaling, and caption generation endpoints.
  • Refactor

    • Content pipeline now uses the new API flow instead of local media drivers; caption sanitization updated to remove single quotes and many emoji.
  • Tests

    • Added comprehensive tests for the API calls and the updated content task wiring.

…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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

A 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

Cohort / File(s) Summary
Recoup core & tests
src/recoup/callRecoupApi.ts, src/recoup/__tests__/callRecoupApi.test.ts
Adds callRecoupApi (env-configured base URL, requires API key, JSON body handling, method override, JSON/text fallback, logging) and tests validating headers, URL composition, success/error flows, and method override.
Recoup content wrappers & tests
src/recoup/contentApi.ts, src/recoup/generateImage.ts, src/recoup/generateVideo.ts, src/recoup/upscaleMedia.ts, src/recoup/generateCaption.ts, src/recoup/__tests__/contentApi.test.ts
Introduces API wrapper functions that build request bodies (filtering/omitting fields like non-HTTP images or disallowed templates), call callRecoupApi, and return specific response fields; tests assert payload shape, branching (media type, lipsync), template allowlisting, and result propagation.
Task pipeline refactor & tests
src/tasks/createContentTask.ts, src/tasks/__tests__/createContentTask.test.ts, src/tasks/__tests__/createContentTaskApi.test.ts
Refactors task to use Recoup wrappers (consolidated imageRefs/images, uploads audio for lipsync via fal.storage and passes audioUrl, calls upscaleMedia for image/video when requested); tests updated/mocked to use consolidated contentApi mocks.
Removed legacy content modules & tests
src/content/generateAudioVideo.ts (deleted), src/content/generateCaption.ts (deleted), src/content/generateContentImage.ts (deleted), src/content/generateContentVideo.ts (deleted), src/content/upscaleImage.ts (deleted), src/content/upscaleVideo.ts (deleted), src/content/testPipeline.ts (deleted), src/content/__tests__/generateContentImage.test.ts (deleted)
Deletes multiple fal.ai-based generation/upscaling modules, a local pipeline script, and an associated test; responsibilities moved to Recoup API wrappers.
Text escaping update & tests
src/content/escapeDrawtext.ts, src/content/__tests__/escapeDrawtext.test.ts
Changes escape strategy: removes single/curly quotes, strips emoji/symbol ranges, collapses whitespace, preserves colon/percent/backslash escapes; tests updated to expect removals and stricter equality for apostrophes.
Test mocks consolidation
src/tasks/__tests__/createContentTask.test.ts
Consolidates multiple per-function mocks into a single ../../recoup/contentApi mock and adjusts assertions to match new wrapper inputs (e.g., image overlay handling).

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped across the code tonight,

Keys in paw and endpoints bright,
Images, videos, captions sent with care,
Tasks hum softly — no local flair,
A little hop, a happy API affair!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: replacing internal functions with API endpoint calls in the create-content task.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/content-task-use-api-endpoints

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 46f2978 and 8072537.

📒 Files selected for processing (7)
  • src/recoup/__tests__/callRecoupApi.test.ts
  • src/recoup/__tests__/contentApi.test.ts
  • src/recoup/callRecoupApi.ts
  • src/recoup/contentApi.ts
  • src/tasks/__tests__/createContentTask.test.ts
  • src/tasks/__tests__/createContentTaskApi.test.ts
  • src/tasks/createContentTask.ts

Comment on lines +6 to +62
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" }),
);
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +19 to +30
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"}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'fetch\(|response\.json\(|AbortSignal\.timeout|signal:' src/recoup/callRecoupApi.ts -C 2

Repository: 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.

Suggested change
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.

Comment on lines +18 to +19
const data = await callRecoupApi("/api/content/image", body);
return data.imageUrl as string;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ 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 1

Repository: recoupable/tasks

Length of output: 618


🏁 Script executed:

cat -n src/recoup/contentApi.ts

Repository: 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).

Comment on lines 121 to 125
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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 2

Repository: 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>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

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" });
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

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

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>
Fix with Cubic


const imageRefs: string[] = [];
if (faceGuideUrl) imageRefs.push(faceGuideUrl);
if (referenceImagePath) imageRefs.push(referenceImagePath);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

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

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>
Fix with Cubic

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>
* @param params - Image generation parameters.
* @returns URL of the generated image.
*/
export async function generateImage(params: {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP - new lib file for generateImage and any other function in this file with a name different thant the file name.

sweetmantech and others added 2 commits April 9, 2026 18:51
- 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>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

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.

.replace(/\n/g, " ")
.replace(/\\/g, "\\\\\\\\")
.replace(/['\u2018\u2032]/g, "\u2019")
.replace(/['\u2018\u2019\u2032]/g, "")
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 10, 2026

Choose a reason for hiding this comment

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

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>
Suggested change
.replace(/['\u2018\u2019\u2032]/g, "")
.replace(/['\u2018\u2032]/g, "\u2019")
Fix with Cubic

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>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

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, "")
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 10, 2026

Choose a reason for hiding this comment

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

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>
Fix with Cubic

…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>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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, and src/content/buildOverlayTextFilter.ts:1-36 already strips emoji before calling escapeDrawtext(). Keeping both copies in sync will get brittle fast; this is a good candidate for a shared utility or composing with stripEmoji().

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8072537 and 7d9db64.

📒 Files selected for processing (20)
  • src/content/__tests__/escapeDrawtext.test.ts
  • src/content/__tests__/generateContentImage.test.ts
  • src/content/escapeDrawtext.ts
  • src/content/generateAudioVideo.ts
  • src/content/generateCaption.ts
  • src/content/generateContentImage.ts
  • src/content/generateContentVideo.ts
  • src/content/testPipeline.ts
  • src/content/upscaleImage.ts
  • src/content/upscaleVideo.ts
  • src/recoup/__tests__/callRecoupApi.test.ts
  • src/recoup/__tests__/contentApi.test.ts
  • src/recoup/callRecoupApi.ts
  • src/recoup/contentApi.ts
  • src/recoup/generateCaption.ts
  • src/recoup/generateImage.ts
  • src/recoup/generateVideo.ts
  • src/recoup/upscaleMedia.ts
  • src/tasks/__tests__/createContentTaskApi.test.ts
  • src/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

Comment on lines +58 to +62
it("removes emoji", () => {
const result = escapeDrawtext("fire 🔥🎶 music");
expect(result).not.toContain("🔥");
expect(result).not.toContain("🎶");
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +16 to +27
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the file and check its existence
find . -name "generateCaption.ts" -type f

Repository: 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
fi

Repository: 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 f

Repository: 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"
fi

Repository: 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.

Suggested change
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.

Comment on lines +9 to +23
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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"
fi

Repository: 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"
fi

Repository: 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 -50

Repository: 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 -60

Repository: recoupable/tasks

Length of output: 87


🏁 Script executed:

# Check if Zod is in package.json (simpler approach)
grep -i "zod" package.json

Repository: 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 -80

Repository: 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 -40

Repository: recoupable/tasks

Length of output: 176


🏁 Script executed:

# Find and examine callRecoupApi implementation
rg "callRecoupApi|function callRecoupApi" --type ts -B 2 -A 10 | head -60

Repository: 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 -20

Repository: recoupable/tasks

Length of output: 523


🏁 Script executed:

# Check if any files in recoup directory use Zod validation
rg "import.*zod" src/recoup/ --type ts

Repository: 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.

Comment on lines +9 to +22
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +10 to +12
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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/recoup

Repository: 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.ts

Repository: 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 -25

Repository: recoupable/tasks

Length of output: 612


🏁 Script executed:

cat package.json | grep -A 20 '"devDependencies"' | head -25

Repository: 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>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/recoup/generateCaption.ts (1)

8-12: ⚠️ Potential issue | 🟠 Major

Validate 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" || true

As 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7d9db64 and 9d98cfc.

📒 Files selected for processing (3)
  • src/recoup/__tests__/contentApi.test.ts
  • src/recoup/callRecoupApi.ts
  • src/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

@sweetmantech sweetmantech merged commit 13b15a3 into main Apr 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant