diff --git a/app/api/content/create/route.ts b/app/api/content/create/route.ts new file mode 100644 index 00000000..9d1a5fd9 --- /dev/null +++ b/app/api/content/create/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createContentHandler } from "@/lib/content/createContentHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/content/create + * + * Triggers the background content-creation pipeline and returns a run ID. + * + * @param request - Incoming API request. + * @returns Trigger response for the created task run. + */ +export async function POST(request: NextRequest): Promise { + return createContentHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/estimate/route.ts b/app/api/content/estimate/route.ts new file mode 100644 index 00000000..e6ad622f --- /dev/null +++ b/app/api/content/estimate/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/estimate + * + * Returns estimated content-creation costs. + * + * @param request - Incoming API request. + * @returns Cost estimate response. + */ +export async function GET(request: NextRequest): Promise { + return getContentEstimateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/templates/route.ts b/app/api/content/templates/route.ts new file mode 100644 index 00000000..11572d48 --- /dev/null +++ b/app/api/content/templates/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/templates + * + * Lists available templates for the content-creation pipeline. + * + * @param request - Incoming API request. + * @returns Template list response. + */ +export async function GET(request: NextRequest): Promise { + return getContentTemplatesHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/validate/route.ts b/app/api/content/validate/route.ts new file mode 100644 index 00000000..9a205cdc --- /dev/null +++ b/app/api/content/validate/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/validate + * + * Validates whether an artist is ready for content creation. + * + * @param request - Incoming API request. + * @returns Artist readiness response. + */ +export async function GET(request: NextRequest): Promise { + return getContentValidateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/lib/const.ts b/lib/const.ts index f495d63c..a5cccfac 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; export const RECOUP_FROM_EMAIL = `Agent by Recoup `; export const SUPABASE_STORAGE_BUCKET = "user-files"; +export const CREATE_CONTENT_TASK_ID = "create-content"; /** * UUID of the Recoup admin organization. @@ -41,10 +42,4 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; -export const EVAL_ARTISTS = [ - "Gliiico", - "Mac Miller", - "Wiz Khalifa", - "Mod Sun", - "Julius Black", -]; +export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"]; diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts new file mode 100644 index 00000000..64589da6 --- /dev/null +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { createContentHandler } from "@/lib/content/createContentHandler"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateCreateContentBody", () => ({ + validateCreateContentBody: vi.fn(), +})); + +vi.mock("@/lib/trigger/triggerCreateContent", () => ({ + triggerCreateContent: vi.fn(), +})); + +vi.mock("@/lib/content/getArtistContentReadiness", () => ({ + getArtistContentReadiness: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +describe("createContentHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_account_id: "art_456", + ready: true, + missing: [], + warnings: [], + githubRepo: "https://github.com/test/repo", + }); + }); + + it("returns validation/auth error when validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateCreateContentBody).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + + expect(result).toBe(errorResponse); + }); + + it("returns 202 with runIds when trigger succeeds", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + captionLength: "short", + upscale: false, + batch: 1, + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + expect(result.status).toBe(202); + expect(body.runIds).toEqual(["run_abc123"]); + expect(body.status).toBe("triggered"); + expect(body.artist_account_id).toBe("art_456"); + }); + + it("returns 202 with empty runIds and failed count when trigger fails", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + captionLength: "short", + upscale: false, + batch: 1, + }); + vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + expect(result.status).toBe(202); + expect(body.runIds).toEqual([]); + expect(body.failed).toBe(1); + }); + + it("still triggers when readiness check finds missing files (best-effort)", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + captionLength: "short", + upscale: false, + batch: 1, + }); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_account_id: "art_456", + ready: false, + missing: [ + { + file: "context/images/face-guide.png", + severity: "required", + fix: "Generate a face guide image before creating content.", + }, + ], + warnings: [], + }); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + // Best-effort: validation doesn't block, task handles its own file discovery + expect(result.status).toBe(202); + expect(body.runIds).toBeDefined(); + }); +}); + diff --git a/lib/content/__tests__/getArtistContentReadiness.test.ts b/lib/content/__tests__/getArtistContentReadiness.test.ts new file mode 100644 index 00000000..e7cbf03e --- /dev/null +++ b/lib/content/__tests__/getArtistContentReadiness.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +vi.mock("@/lib/github/getRepoFileTree", () => ({ + getRepoFileTree: vi.fn(), +})); + +describe("getArtistContentReadiness", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + github_repo: "https://github.com/test-org/test-repo", + }, + ] as never); + }); + + it("returns ready=true when required files exist", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" }, + { path: "artists/gatsby-grace/config/content-creation/config.json", type: "blob", sha: "2" }, + { path: "artists/gatsby-grace/songs/song-a.mp3", type: "blob", sha: "3" }, + { path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "4" }, + { path: "artists/gatsby-grace/context/audience.md", type: "blob", sha: "5" }, + { path: "artists/gatsby-grace/context/era.json", type: "blob", sha: "6" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("returns ready=false with required issues when core files are missing", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "1" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(false); + expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true); + expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true); + }); + + it("returns ready=true when only face-guide and mp3 exist (config.json is optional)", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" }, + { path: "artists/gatsby-grace/songs/track.mp3", type: "blob", sha: "2" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistAccountId: "art_456", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(true); + expect(result.missing).toEqual([]); + // config.json appears as a warning, not a blocker + expect(result.warnings.some(item => item.file === "config/content-creation/config.json")).toBe(true); + }); + + it("throws when account has no github repo", async () => { + vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never); + + await expect( + getArtistContentReadiness({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }), + ).rejects.toThrow("No GitHub repository configured for this account"); + }); +}); + diff --git a/lib/content/__tests__/getContentEstimateHandler.test.ts b/lib/content/__tests__/getContentEstimateHandler.test.ts new file mode 100644 index 00000000..926a299f --- /dev/null +++ b/lib/content/__tests__/getContentEstimateHandler.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler"; +import { validateGetContentEstimateQuery } from "@/lib/content/validateGetContentEstimateQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateGetContentEstimateQuery", () => ({ + validateGetContentEstimateQuery: vi.fn(), +})); + +describe("getContentEstimateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation error when query validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "invalid query" }, + { status: 400 }, + ); + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/estimate", { method: "GET" }); + + const result = await getContentEstimateHandler(request); + expect(result).toBe(errorResponse); + }); + + it("returns estimate payload", async () => { + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue({ + lipsync: false, + batch: 2, + compare: false, + }); + const request = new NextRequest("http://localhost/api/content/estimate?batch=2", { + method: "GET", + }); + + const result = await getContentEstimateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.per_video_estimate_usd).toBe(0.82); + expect(body.total_estimate_usd).toBe(1.64); + expect(body.profiles).toBeUndefined(); + }); + + it("returns compare profiles when compare=true", async () => { + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue({ + lipsync: true, + batch: 1, + compare: true, + }); + const request = new NextRequest( + "http://localhost/api/content/estimate?compare=true&lipsync=true", + { + method: "GET", + }, + ); + + const result = await getContentEstimateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.per_video_estimate_usd).toBe(0.95); + expect(body.profiles.current).toBe(0.95); + }); +}); diff --git a/lib/content/__tests__/getContentTemplatesHandler.test.ts b/lib/content/__tests__/getContentTemplatesHandler.test.ts new file mode 100644 index 00000000..b7d45a16 --- /dev/null +++ b/lib/content/__tests__/getContentTemplatesHandler.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("getContentTemplatesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth error when auth validation fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = new NextRequest("http://localhost/api/content/templates", { method: "GET" }); + + const result = await getContentTemplatesHandler(request); + + expect(result.status).toBe(401); + }); + + it("returns templates when authenticated", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + const request = new NextRequest("http://localhost/api/content/templates", { method: "GET" }); + + const result = await getContentTemplatesHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(Array.isArray(body.templates)).toBe(true); + expect(body.templates.length).toBeGreaterThan(0); + }); +}); diff --git a/lib/content/__tests__/getContentValidateHandler.test.ts b/lib/content/__tests__/getContentValidateHandler.test.ts new file mode 100644 index 00000000..136d8333 --- /dev/null +++ b/lib/content/__tests__/getContentValidateHandler.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler"; +import { validateGetContentValidateQuery } from "@/lib/content/validateGetContentValidateQuery"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateGetContentValidateQuery", () => ({ + validateGetContentValidateQuery: vi.fn(), +})); + +vi.mock("@/lib/content/getArtistContentReadiness", () => ({ + getArtistContentReadiness: vi.fn(), +})); + +describe("getContentValidateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + ready: true, + missing: [], + warnings: [], + githubRepo: "https://github.com/test/repo", + }); + }); + + it("returns validation error when query validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "artist_account_id query parameter is required" }, + { status: 400 }, + ); + vi.mocked(validateGetContentValidateQuery).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/validate", { method: "GET" }); + + const result = await getContentValidateHandler(request); + expect(result).toBe(errorResponse); + }); + + it("returns readiness payload when validation succeeds", async () => { + vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ + accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", + artistSlug: "gatsby-grace", + }); + const request = new NextRequest( + "http://localhost/api/content/validate?artist_account_id=550e8400-e29b-41d4-a716-446655440000", + { + method: "GET", + }, + ); + + const result = await getContentValidateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.ready).toBe(true); + expect(body.artist_account_id).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(Array.isArray(body.missing)).toBe(true); + }); + + it("returns 500 when readiness check throws", async () => { + vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ + accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", + artistSlug: "gatsby-grace", + }); + vi.mocked(getArtistContentReadiness).mockRejectedValue( + new Error("Failed to retrieve repository file tree"), + ); + const request = new NextRequest( + "http://localhost/api/content/validate?artist_account_id=550e8400-e29b-41d4-a716-446655440000", + { + method: "GET", + }, + ); + + const result = await getContentValidateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Failed to validate content readiness"); + }); +}); diff --git a/lib/content/__tests__/persistCreateContentRunVideo.test.ts b/lib/content/__tests__/persistCreateContentRunVideo.test.ts new file mode 100644 index 00000000..c1c16828 --- /dev/null +++ b/lib/content/__tests__/persistCreateContentRunVideo.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; +import { selectFileByStorageKey } from "@/lib/supabase/files/selectFileByStorageKey"; +import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; +import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; +import { createSignedFileUrlByKey } from "@/lib/supabase/storage/createSignedFileUrlByKey"; + +vi.mock("@/lib/supabase/files/selectFileByStorageKey", () => ({ + selectFileByStorageKey: vi.fn(), +})); + +vi.mock("@/lib/supabase/storage/uploadFileByKey", () => ({ + uploadFileByKey: vi.fn(), +})); + +vi.mock("@/lib/supabase/files/createFileRecord", () => ({ + createFileRecord: vi.fn(), +})); + +vi.mock("@/lib/supabase/storage/createSignedFileUrlByKey", () => ({ + createSignedFileUrlByKey: vi.fn(), +})); + +describe("persistCreateContentRunVideo", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createSignedFileUrlByKey).mockResolvedValue("https://example.com/signed.mp4"); + }); + + it("returns run unchanged when task is not create-content", async () => { + const run = { id: "run_1", taskIdentifier: "other-task", status: "COMPLETED", output: {} }; + const result = await persistCreateContentRunVideo(run); + expect(result).toEqual(run); + }); + + it("hydrates from existing file without uploading", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue({ + id: "file_1", + owner_account_id: "acc_1", + artist_account_id: "acc_1", + storage_key: "content/acc_1/artist/run_1.mp4", + file_name: "artist-run_1.mp4", + mime_type: "video/mp4", + size_bytes: 100, + description: null, + tags: [], + }); + + const run = { + id: "run_1", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + const result = await persistCreateContentRunVideo(run); + + expect(uploadFileByKey).not.toHaveBeenCalled(); + expect(createFileRecord).not.toHaveBeenCalled(); + expect((result.output as { video?: { fileId: string; signedUrl: string } }).video?.fileId).toBe( + "file_1", + ); + expect( + (result.output as { video?: { fileId: string; signedUrl: string } }).video?.signedUrl, + ).toBe("https://example.com/signed.mp4"); + }); + + it("downloads and persists video when file does not exist", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(createFileRecord).mockResolvedValue({ + id: "file_2", + owner_account_id: "acc_1", + artist_account_id: "acc_1", + storage_key: "content/acc_1/artist/artist-run_2.mp4", + file_name: "artist-run_2.mp4", + mime_type: "video/mp4", + size_bytes: 100, + description: null, + tags: [], + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_2", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + const result = await persistCreateContentRunVideo(run); + + expect(uploadFileByKey).toHaveBeenCalledOnce(); + expect(createFileRecord).toHaveBeenCalledOnce(); + expect((result.output as { video?: { fileId: string; signedUrl: string } }).video?.fileId).toBe( + "file_2", + ); + expect( + (result.output as { video?: { fileId: string; signedUrl: string } }).video?.signedUrl, + ).toBe("https://example.com/signed.mp4"); + }); + + it("throws when upload fails", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(uploadFileByKey).mockRejectedValue(new Error("upload failed")); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_3", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + + await expect(persistCreateContentRunVideo(run)).rejects.toThrow("upload failed"); + expect(createFileRecord).not.toHaveBeenCalled(); + }); + + it("throws when file record creation fails", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(uploadFileByKey).mockResolvedValue(undefined); + vi.mocked(createFileRecord).mockRejectedValue(new Error("create file record failed")); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_4", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + + await expect(persistCreateContentRunVideo(run)).rejects.toThrow("Failed to create or find file record"); + }); +}); + diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts new file mode 100644 index 00000000..1bc66a43 --- /dev/null +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; + +const mockValidateAuthContext = vi.fn(); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(async (req: Request) => req.json()), +})); + +vi.mock("@/lib/content/resolveArtistSlug", () => ({ + resolveArtistSlug: vi.fn().mockResolvedValue("gatsby-grace"), +})); + +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/content/create", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify(body), + }); +} + +describe("validateCreateContentBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + }); + + it("returns validated payload for a valid request", async () => { + const request = createRequest({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + template: "artist-caption-bedroom", + lipsync: true, + }); + + const result = await validateCreateContentBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result).toEqual({ + accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: true, + captionLength: "short", + upscale: false, + batch: 1, + }); + } + }); + + it("applies defaults when optional fields are omitted", async () => { + const request = createRequest({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + const result = await validateCreateContentBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.template).toBe("artist-caption-bedroom"); + expect(result.lipsync).toBe(false); + } + }); + + it("returns 400 when artist_account_id is missing", async () => { + const request = createRequest({ + template: "artist-caption-bedroom", + }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when template is unsupported", async () => { + const request = createRequest({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + template: "not-a-real-template", + }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toContain("Unsupported template"); + } + }); + + it("returns auth error response when auth fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = createRequest({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000" }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); +}); + diff --git a/lib/content/booleanFromString.ts b/lib/content/booleanFromString.ts new file mode 100644 index 00000000..df2bc823 --- /dev/null +++ b/lib/content/booleanFromString.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +/** + * Parses a string query param as a boolean. Only "true" → true; everything else → false. + * z.coerce.boolean() would treat any non-empty string (including "false") as true. + */ +export const booleanFromString = z + .enum(["true", "false"]) + .default("false") + .transform(v => v === "true"); diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts new file mode 100644 index 00000000..6896c195 --- /dev/null +++ b/lib/content/contentTemplates.ts @@ -0,0 +1,31 @@ +export interface ContentTemplate { + name: string; + description: string; + defaultLipsync: boolean; +} + +export const CONTENT_TEMPLATES: ContentTemplate[] = [ + { + name: "artist-caption-bedroom", + description: "Moody purple bedroom setting", + defaultLipsync: false, + }, + { + name: "artist-caption-outside", + description: "Night street scene", + defaultLipsync: false, + }, + { + name: "artist-caption-stage", + description: "Small venue concert", + defaultLipsync: false, + }, +]; + +/** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ +export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name; + +export function isSupportedContentTemplate(template: string): boolean { + return CONTENT_TEMPLATES.some(item => item.name === template); +} + diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts new file mode 100644 index 00000000..b0e845f3 --- /dev/null +++ b/lib/content/createContentHandler.ts @@ -0,0 +1,85 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; + +/** + * Handler for POST /api/content/create. + * Always returns runIds array (KISS — one response shape for single and batch). + */ +export async function createContentHandler(request: NextRequest): Promise { + const validated = await validateCreateContentBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + // Best-effort readiness check. The task has its own submodule-aware file + // discovery that works even when the API-level check can't find files + // (e.g., artists in org submodule repos). + let githubRepo: string; + try { + const readiness = await getArtistContentReadiness({ + accountId: validated.accountId, + artistAccountId: validated.artistAccountId, + artistSlug: validated.artistSlug, + }); + githubRepo = readiness.githubRepo; + } catch { + // If readiness check fails, still try to resolve the repo + const snapshots = await selectAccountSnapshots(validated.accountId); + const repo = snapshots?.[0]?.github_repo; + if (!repo) { + return NextResponse.json( + { status: "error", error: "No GitHub repository found for this account" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + githubRepo = repo; + } + + const payload = { + accountId: validated.accountId, + artistSlug: validated.artistSlug, + template: validated.template, + lipsync: validated.lipsync, + captionLength: validated.captionLength, + upscale: validated.upscale, + githubRepo, + }; + + // Always use allSettled — works for single and batch. + const count = validated.batch; + const results = await Promise.allSettled( + Array.from({ length: count }, () => triggerCreateContent(payload)), + ); + const runIds = results + .filter(r => r.status === "fulfilled") + .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); + const failedCount = results.filter(r => r.status === "rejected").length; + + return NextResponse.json( + { + runIds, + status: "triggered", + artist_account_id: validated.artistAccountId, + template: validated.template, + lipsync: validated.lipsync, + ...(failedCount > 0 && { failed: failedCount }), + }, + { status: 202, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("Failed to trigger content creation:", error); + return NextResponse.json( + { + status: "error", + error: "Failed to trigger content creation", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts new file mode 100644 index 00000000..d199c087 --- /dev/null +++ b/lib/content/getArtistContentReadiness.ts @@ -0,0 +1,135 @@ +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getArtistFileTree } from "@/lib/content/getArtistFileTree"; +import { getArtistRootPrefix } from "@/lib/content/getArtistRootPrefix"; + +type MissingSeverity = "required" | "recommended"; + +export interface ContentReadinessIssue { + file: string; + severity: MissingSeverity; + fix: string; +} + +export interface ArtistContentReadiness { + artist_account_id: string; + ready: boolean; + missing: ContentReadinessIssue[]; + warnings: ContentReadinessIssue[]; + /** The GitHub repo URL for this account's sandbox. */ + githubRepo: string; +} + +/** + * Checks whether an artist has the expected files for content creation. + * Searches the main repo and org submodule repos. + */ +export async function getArtistContentReadiness({ + accountId, + artistAccountId, + artistSlug, +}: { + accountId: string; + artistAccountId: string; + artistSlug: string; +}): Promise { + const snapshots = await selectAccountSnapshots(accountId); + if (!snapshots) { + throw new Error("Failed to query account snapshots"); + } + const githubRepo = snapshots[0]?.github_repo ?? null; + if (!githubRepo) { + throw new Error("No GitHub repository configured for this account"); + } + + const tree = await getArtistFileTree(githubRepo, artistSlug); + if (!tree) { + // Empty repo or artist not found in any repo — return not-ready instead of crashing + return { + artist_account_id: artistAccountId, + ready: false, + missing: [ + { + file: "artists/", + severity: "required" as const, + fix: "No repository file tree found. The sandbox repo may be empty or the artist directory does not exist yet.", + }, + ], + warnings: [], + githubRepo, + }; + } + + const blobPaths = tree.filter(entry => entry.type === "blob").map(entry => entry.path); + const artistRootPrefix = getArtistRootPrefix(blobPaths, artistSlug); + + const hasFile = (relativePath: string): boolean => + blobPaths.some(path => path === `${artistRootPrefix}${relativePath}`); + const hasAnyMp3 = blobPaths.some( + path => + path.startsWith(artistRootPrefix) && + path.toLowerCase().endsWith(".mp3"), + ); + + const issues: ContentReadinessIssue[] = []; + + if (!hasFile("context/images/face-guide.png")) { + issues.push({ + file: "context/images/face-guide.png", + severity: "required", + fix: "Generate a face guide image before creating content.", + }); + } + + // config/content-creation/config.json is optional — the pipeline uses sensible + // defaults and only reads the artist config file to override specific fields. + if (!hasFile("config/content-creation/config.json")) { + issues.push({ + file: "config/content-creation/config.json", + severity: "recommended", + fix: "Add a pipeline config to override default model/resolution settings.", + }); + } + + if (!hasAnyMp3) { + issues.push({ + file: "songs/*.mp3", + severity: "required", + fix: "Add at least one .mp3 file for audio selection.", + }); + } + + if (!hasFile("context/artist.md")) { + issues.push({ + file: "context/artist.md", + severity: "recommended", + fix: "Add artist context to improve caption quality.", + }); + } + + if (!hasFile("context/audience.md")) { + issues.push({ + file: "context/audience.md", + severity: "recommended", + fix: "Add audience context to improve targeting.", + }); + } + + if (!hasFile("context/era.json")) { + issues.push({ + file: "context/era.json", + severity: "recommended", + fix: "Add era metadata to improve song selection relevance.", + }); + } + + const requiredMissing = issues.filter(item => item.severity === "required"); + const warnings = issues.filter(item => item.severity === "recommended"); + + return { + artist_account_id: artistAccountId, + ready: requiredMissing.length === 0, + missing: requiredMissing, + warnings, + githubRepo, + }; +} diff --git a/lib/content/getArtistFileTree.ts b/lib/content/getArtistFileTree.ts new file mode 100644 index 00000000..908855a0 --- /dev/null +++ b/lib/content/getArtistFileTree.ts @@ -0,0 +1,34 @@ +import { getRepoFileTree, type FileTreeEntry } from "@/lib/github/getRepoFileTree"; +import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; + +/** + * Gets the file tree that contains the artist, checking the main repo + * first, then falling back to org submodule repos. + */ +export async function getArtistFileTree( + githubRepo: string, + artistSlug: string, +): Promise { + const mainTree = await getRepoFileTree(githubRepo); + if (mainTree) { + const blobPaths = mainTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return mainTree; + } + + const orgUrls = await getOrgRepoUrls(githubRepo); + for (const orgUrl of orgUrls) { + const orgTree = await getRepoFileTree(orgUrl); + if (orgTree) { + const blobPaths = orgTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return orgTree; + } + } + + return mainTree; +} diff --git a/lib/content/getArtistRootPrefix.ts b/lib/content/getArtistRootPrefix.ts new file mode 100644 index 00000000..5a777abe --- /dev/null +++ b/lib/content/getArtistRootPrefix.ts @@ -0,0 +1,13 @@ +export function getArtistRootPrefix(paths: string[], artistSlug: string): string { + const preferredPrefix = `artists/${artistSlug}/`; + if (paths.some(path => path.startsWith(preferredPrefix))) { + return preferredPrefix; + } + + const directPrefix = `${artistSlug}/`; + if (paths.some(path => path.startsWith(directPrefix))) { + return directPrefix; + } + + return preferredPrefix; +} diff --git a/lib/content/getContentEstimateHandler.ts b/lib/content/getContentEstimateHandler.ts new file mode 100644 index 00000000..3d4d3a38 --- /dev/null +++ b/lib/content/getContentEstimateHandler.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetContentEstimateQuery } from "@/lib/content/validateGetContentEstimateQuery"; + +const BASE_IMAGE_TO_VIDEO_COST = 0.82; +const BASE_AUDIO_TO_VIDEO_COST = 0.95; + +/** + * Handler for GET /api/content/estimate. + * + * @param request + */ +export async function getContentEstimateHandler(request: NextRequest): Promise { + const validated = await validateGetContentEstimateQuery(request); + if (validated instanceof NextResponse) { + return validated; + } + + const perVideoEstimate = validated.lipsync ? BASE_AUDIO_TO_VIDEO_COST : BASE_IMAGE_TO_VIDEO_COST; + const totalEstimate = Number((perVideoEstimate * validated.batch).toFixed(2)); + + const response: Record = { + status: "success", + lipsync: validated.lipsync, + batch: validated.batch, + per_video_estimate_usd: perVideoEstimate, + total_estimate_usd: totalEstimate, + }; + + if (validated.compare) { + response.profiles = { + budget: 0.18, + mid: 0.62, + current: validated.lipsync ? BASE_AUDIO_TO_VIDEO_COST : BASE_IMAGE_TO_VIDEO_COST, + }; + } + + return NextResponse.json(response, { status: 200, headers: getCorsHeaders() }); +} diff --git a/lib/content/getContentTemplatesHandler.ts b/lib/content/getContentTemplatesHandler.ts new file mode 100644 index 00000000..d1a65d80 --- /dev/null +++ b/lib/content/getContentTemplatesHandler.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { CONTENT_TEMPLATES } from "@/lib/content/contentTemplates"; + +/** + * Handler for GET /api/content/templates. + * + * @param request + */ +export async function getContentTemplatesHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + return NextResponse.json( + { + status: "success", + templates: CONTENT_TEMPLATES, + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts new file mode 100644 index 00000000..268741c1 --- /dev/null +++ b/lib/content/getContentValidateHandler.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetContentValidateQuery } from "@/lib/content/validateGetContentValidateQuery"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +/** + * Handler for GET /api/content/validate. + * NOTE: Phase 1 returns structural readiness scaffolding. Deep filesystem checks + * are performed in the background task before spend-heavy steps. + */ +export async function getContentValidateHandler(request: NextRequest): Promise { + const validated = await validateGetContentValidateQuery(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const readiness = await getArtistContentReadiness({ + accountId: validated.accountId, + artistAccountId: validated.artistAccountId, + artistSlug: validated.artistSlug, + }); + + const { githubRepo: _, ...publicReadiness } = readiness; + + return NextResponse.json( + { + status: "success", + ...publicReadiness, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("Failed to validate content readiness:", error); + return NextResponse.json( + { + status: "error", + error: "Failed to validate content readiness", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} + diff --git a/lib/content/isCompletedRun.ts b/lib/content/isCompletedRun.ts new file mode 100644 index 00000000..855ea068 --- /dev/null +++ b/lib/content/isCompletedRun.ts @@ -0,0 +1,10 @@ +export type TriggerRunLike = { + id: string; + status?: string | null; + taskIdentifier?: string | null; + output?: unknown; +}; + +export function isCompletedRun(run: TriggerRunLike): boolean { + return run.status === "COMPLETED"; +} diff --git a/lib/content/persistCreateContentRunVideo.ts b/lib/content/persistCreateContentRunVideo.ts new file mode 100644 index 00000000..25a77eed --- /dev/null +++ b/lib/content/persistCreateContentRunVideo.ts @@ -0,0 +1,125 @@ +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; +import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; +import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; +import { selectFileByStorageKey } from "@/lib/supabase/files/selectFileByStorageKey"; +import { createSignedFileUrlByKey } from "@/lib/supabase/storage/createSignedFileUrlByKey"; +import { isCompletedRun, type TriggerRunLike } from "@/lib/content/isCompletedRun"; + +type CreateContentOutput = { + status?: string; + accountId?: string; + artistSlug?: string; + template?: string; + lipsync?: boolean; + videoSourceUrl?: string; + video?: { + fileId: string; + storageKey: string; + fileName: string; + mimeType: string | null; + sizeBytes: number | null; + signedUrl: string; + } | null; +}; + +/** + * Persists create-content task video output to Supabase storage + files table + * and returns the run with normalized output. + * + * This keeps Supabase writes in API only. + */ +export async function persistCreateContentRunVideo(run: T): Promise { + if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompletedRun(run)) { + return run; + } + + const output = (run.output ?? {}) as CreateContentOutput; + if (!output.accountId || !output.artistSlug || !output.videoSourceUrl) { + return run; + } + + if (output.video?.storageKey) { + return run; + } + + const fileName = `${output.artistSlug}-${run.id}.mp4`; + const storageKey = `content/${output.accountId}/${output.artistSlug}/${fileName}`; + + const existingFile = await selectFileByStorageKey({ + ownerAccountId: output.accountId, + storageKey, + }); + + if (existingFile) { + const signedUrl = await createSignedFileUrlByKey({ + key: existingFile.storage_key, + }); + + return { + ...run, + output: { + ...output, + video: { + fileId: existingFile.id, + storageKey: existingFile.storage_key, + fileName: existingFile.file_name, + mimeType: existingFile.mime_type, + sizeBytes: existingFile.size_bytes, + signedUrl, + }, + }, + }; + } + + const response = await fetch(output.videoSourceUrl); + if (!response.ok) { + throw new Error(`Failed to download rendered video: ${response.status} ${response.statusText}`); + } + + const videoBlob = await response.blob(); + const mimeType = response.headers.get("content-type") || "video/mp4"; + + await uploadFileByKey(storageKey, videoBlob, { + contentType: mimeType, + upsert: true, + }); + + let createdFile; + try { + createdFile = await createFileRecord({ + ownerAccountId: output.accountId, + // Phase 1: artist account mapping is not wired yet, so we scope to owner account. + artistAccountId: output.accountId, + storageKey, + fileName, + mimeType, + sizeBytes: videoBlob.size, + description: `Content pipeline output for ${output.artistSlug}`, + tags: ["content", "video", output.template ?? "unknown-template"], + }); + } catch { + // Race condition: another request may have created the record. Re-select. + const raceFile = await selectFileByStorageKey({ ownerAccountId: output.accountId, storageKey }); + if (!raceFile) throw new Error("Failed to create or find file record"); + createdFile = raceFile; + } + + const signedUrl = await createSignedFileUrlByKey({ + key: createdFile.storage_key, + }); + + return { + ...run, + output: { + ...output, + video: { + fileId: createdFile.id, + storageKey: createdFile.storage_key, + fileName: createdFile.file_name, + mimeType: createdFile.mime_type, + sizeBytes: createdFile.size_bytes, + signedUrl, + }, + }, + }; +} diff --git a/lib/content/resolveArtistSlug.ts b/lib/content/resolveArtistSlug.ts new file mode 100644 index 00000000..c74a8379 --- /dev/null +++ b/lib/content/resolveArtistSlug.ts @@ -0,0 +1,20 @@ +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; + +/** + * Resolves an artist_account_id to an artist slug (directory name). + * + * The slug is derived from the artist's name, lowercased with spaces + * replaced by hyphens — matching how sandboxes generate folder names. + * + * @param artistAccountId - The artist's account UUID + * @returns The artist slug, or null if not found + */ +export async function resolveArtistSlug( + artistAccountId: string, +): Promise { + const accounts = await selectAccounts(artistAccountId); + const name = accounts[0]?.name; + if (!name) return null; + + return name.toLowerCase().replace(/\s+/g, "-"); +} diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts new file mode 100644 index 00000000..6d7cdc56 --- /dev/null +++ b/lib/content/validateCreateContentBody.ts @@ -0,0 +1,96 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { + DEFAULT_CONTENT_TEMPLATE, + isSupportedContentTemplate, +} from "@/lib/content/contentTemplates"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; + +export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; + +export const createContentBodySchema = z.object({ + artist_account_id: z.string({ message: "artist_account_id is required" }).uuid("artist_account_id must be a valid UUID"), + template: z + .string() + .min(1, "template cannot be empty") + .optional() + .default(DEFAULT_CONTENT_TEMPLATE), + lipsync: z.boolean().optional().default(false), + caption_length: z.enum(CAPTION_LENGTHS).optional().default("short"), + upscale: z.boolean().optional().default(false), + batch: z.number().int().min(1).max(30).optional().default(1), +}); + +export type ValidatedCreateContentBody = { + accountId: string; + artistAccountId: string; + artistSlug: string; + template: string; + lipsync: boolean; + captionLength: "short" | "medium" | "long"; + upscale: boolean; + batch: number; +}; + +/** + * Validates auth and request body for POST /api/content/create. + */ +export async function validateCreateContentBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = createContentBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + field: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const template = result.data.template ?? DEFAULT_CONTENT_TEMPLATE; + if (!isSupportedContentTemplate(template)) { + return NextResponse.json( + { + status: "error", + error: `Unsupported template: ${template}`, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // Resolve artist_account_id → slug (directory name) + const artistSlug = await resolveArtistSlug(result.data.artist_account_id); + if (!artistSlug) { + return NextResponse.json( + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return { + accountId: authResult.accountId, + artistAccountId: result.data.artist_account_id, + artistSlug, + template, + lipsync: result.data.lipsync ?? false, + captionLength: result.data.caption_length ?? "short", + upscale: result.data.upscale ?? false, + batch: result.data.batch ?? 1, + }; +} diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts new file mode 100644 index 00000000..5828e7cc --- /dev/null +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -0,0 +1,41 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { booleanFromString } from "@/lib/content/booleanFromString"; + +export const getContentEstimateQuerySchema = z.object({ + lipsync: booleanFromString, + batch: z.coerce.number().int().min(1).max(100).default(1), + compare: booleanFromString, +}); + +export type ValidatedGetContentEstimateQuery = z.infer; + +/** + * Validates auth and query params for GET /api/content/estimate. + */ +export async function validateGetContentEstimateQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const lipsync = request.nextUrl.searchParams.get("lipsync") ?? undefined; + const batch = request.nextUrl.searchParams.get("batch") ?? undefined; + const compare = request.nextUrl.searchParams.get("compare") ?? undefined; + const result = getContentEstimateQuerySchema.safeParse({ lipsync, batch, compare }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/content/validateGetContentValidateQuery.ts b/lib/content/validateGetContentValidateQuery.ts new file mode 100644 index 00000000..c8084fa6 --- /dev/null +++ b/lib/content/validateGetContentValidateQuery.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; + +export type ValidatedGetContentValidateQuery = { + accountId: string; + artistAccountId: string; + artistSlug: string; +}; + +/** + * Validates auth and query params for GET /api/content/validate. + * Requires artist_account_id query parameter. + */ +export async function validateGetContentValidateQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const artistAccountId = request.nextUrl.searchParams.get("artist_account_id"); + if (!artistAccountId) { + return NextResponse.json( + { status: "error", error: "artist_account_id query parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const artistSlug = await resolveArtistSlug(artistAccountId); + if (!artistSlug) { + return NextResponse.json( + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return { + accountId: authResult.accountId, + artistAccountId: artistAccountId, + artistSlug, + }; +} diff --git a/lib/github/__tests__/getRepoGitModules.test.ts b/lib/github/__tests__/getRepoGitModules.test.ts index fd78fe0d..6cc87f95 100644 --- a/lib/github/__tests__/getRepoGitModules.test.ts +++ b/lib/github/__tests__/getRepoGitModules.test.ts @@ -25,8 +25,10 @@ describe("getRepoGitModules", () => { expect(result).toEqual([{ path: "orgs/my-org", url: "https://github.com/recoupable/org-abc" }]); expect(fetch).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/owner/repo/main/.gitmodules", - expect.objectContaining({ headers: { Authorization: "Bearer test-token" } }), + "https://api.github.com/repos/owner/repo/contents/.gitmodules?ref=main", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer test-token" }), + }), ); }); @@ -51,8 +53,10 @@ describe("getRepoGitModules", () => { expect(result).toEqual([{ path: "sub", url: "https://github.com/owner/sub" }]); expect(fetch).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/owner/repo/develop/.gitmodules", - {}, + "https://api.github.com/repos/owner/repo/contents/.gitmodules?ref=develop", + expect.objectContaining({ + headers: expect.objectContaining({ Accept: "application/vnd.github.v3.raw" }), + }), ); }); }); diff --git a/lib/github/getOrgRepoUrls.ts b/lib/github/getOrgRepoUrls.ts new file mode 100644 index 00000000..1aca55c1 --- /dev/null +++ b/lib/github/getOrgRepoUrls.ts @@ -0,0 +1,30 @@ +import { getRepoGitModules } from "./getRepoGitModules"; +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; + +/** + * Gets the GitHub URLs of all org submodule repos for a sandbox repository. + * + * Reads .gitmodules from the main repo and extracts the submodule URLs. + * Used by content readiness checks and artist file fetching. + * + * @param githubRepoUrl - Full GitHub repo URL (e.g. https://github.com/org/repo) + * @param branch - Branch to read .gitmodules from (defaults to "main") + * @returns Array of org repo URLs, or empty array if none found + */ +export async function getOrgRepoUrls( + githubRepoUrl: string, + branch = "main", +): Promise { + const repoInfo = parseGitHubRepoUrl(githubRepoUrl); + if (!repoInfo) return []; + + const submodules = await getRepoGitModules({ + owner: repoInfo.owner, + repo: repoInfo.repo, + branch, + }); + + if (!submodules) return []; + + return submodules.map(s => s.url); +} diff --git a/lib/github/getRepoFileTree.ts b/lib/github/getRepoFileTree.ts index 6ce2c94b..bceeaae5 100644 --- a/lib/github/getRepoFileTree.ts +++ b/lib/github/getRepoFileTree.ts @@ -40,7 +40,7 @@ export async function getRepoFileTree(githubRepoUrl: string): Promise { + const { data, error } = await supabase + .from("files") + .select("*") + .eq("owner_account_id", ownerAccountId) + .eq("storage_key", storageKey) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to select file by storage key: ${error.message}`); + } + + return data; +} diff --git a/lib/supabase/storage/createSignedFileUrlByKey.ts b/lib/supabase/storage/createSignedFileUrlByKey.ts new file mode 100644 index 00000000..30625b7a --- /dev/null +++ b/lib/supabase/storage/createSignedFileUrlByKey.ts @@ -0,0 +1,27 @@ +import supabase from "@/lib/supabase/serverClient"; +import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; + +/** + * Creates a signed URL for a file in Supabase storage. + * + * @param root0 + * @param root0.key + * @param root0.expiresInSeconds + */ +export async function createSignedFileUrlByKey({ + key, + expiresInSeconds = 60 * 60 * 24 * 7, +}: { + key: string; + expiresInSeconds?: number; +}): Promise { + const { data, error } = await supabase.storage + .from(SUPABASE_STORAGE_BUCKET) + .createSignedUrl(key, expiresInSeconds); + + if (error || !data?.signedUrl) { + throw new Error(`Failed to create signed URL: ${error?.message ?? "unknown error"}`); + } + + return data.signedUrl; +} diff --git a/lib/trigger/__tests__/triggerCreateContent.test.ts b/lib/trigger/__tests__/triggerCreateContent.test.ts new file mode 100644 index 00000000..aafc108d --- /dev/null +++ b/lib/trigger/__tests__/triggerCreateContent.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { tasks } from "@trigger.dev/sdk"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; + +vi.mock("@trigger.dev/sdk", () => ({ + tasks: { + trigger: vi.fn(), + }, +})); + +describe("triggerCreateContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("triggers create-content task with the expected payload", async () => { + const mockHandle = { id: "run_abc123" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const payload = { + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: true, + }; + const result = await triggerCreateContent(payload); + + expect(tasks.trigger).toHaveBeenCalledWith(CREATE_CONTENT_TASK_ID, payload); + expect(result).toEqual(mockHandle); + }); +}); diff --git a/lib/trigger/triggerCreateContent.ts b/lib/trigger/triggerCreateContent.ts new file mode 100644 index 00000000..86dab6c8 --- /dev/null +++ b/lib/trigger/triggerCreateContent.ts @@ -0,0 +1,24 @@ +import { tasks } from "@trigger.dev/sdk"; +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; + +export interface TriggerCreateContentPayload { + accountId: string; + artistSlug: string; + template: string; + lipsync: boolean; + /** Controls caption length: "short", "medium", or "long". */ + captionLength: "short" | "medium" | "long"; + /** Whether to upscale image and video for higher quality. */ + upscale: boolean; + /** GitHub repo URL so the task can fetch artist files. */ + githubRepo: string; +} + +/** + * Triggers the create-content task in Trigger.dev. + */ +export async function triggerCreateContent(payload: TriggerCreateContentPayload) { + const handle = await tasks.trigger(CREATE_CONTENT_TASK_ID, payload); + return handle; +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97781bbc..7e51e443 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -818,105 +818,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1128,28 +1112,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.0.10': resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.0.10': resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.0.10': resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.0.10': resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} @@ -1538,79 +1518,66 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2451,49 +2418,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}