diff --git a/apps/manager/.env.example b/apps/manager/.env.example index 0ccbb704c..f82d67693 100644 --- a/apps/manager/.env.example +++ b/apps/manager/.env.example @@ -26,5 +26,10 @@ WORKFLOW_API_KEY= # API Authentication — optional in dev, required in production MANAGER_API_KEY= +# Subtitle review editor +SUBTITLE_EDITOR_PUBLIC_URL=http://localhost:3003 +SUBTITLE_EDITOR_ALLOWED_ORIGINS=http://localhost:3003 +SUBTITLE_REVIEW_SESSION_SECRET= + # Public NEXT_PUBLIC_WATCH_URL= diff --git a/apps/manager/CLAUDE.md b/apps/manager/CLAUDE.md index 88087092e..4410b1e9f 100644 --- a/apps/manager/CLAUDE.md +++ b/apps/manager/CLAUDE.md @@ -68,19 +68,24 @@ Local dev requires a Strapi user with role name exactly `Manager`. Create via St ## Environment variables (Doppler project: forge-manager) -| Variable | Description | -| ---------------------------- | ------------------------------------------------------------------------- | -| MUX_TOKEN_ID | Mux API token ID | -| MUX_TOKEN_SECRET | Mux API token secret | -| OPENROUTER_API_KEY | OpenRouter API key | -| ELEVENLABS_API_KEY | ElevenLabs API key for audio isolation (optional — enables audio cleanup) | -| RAILWAY_S3_ENDPOINT | Railway Object Storage endpoint (optional — local fallback) | -| RAILWAY_S3_REGION | Railway S3 region (default: auto) | -| RAILWAY_S3_BUCKET | Railway S3 bucket name (optional — triggers S3 mode) | -| RAILWAY_S3_ACCESS_KEY_ID | Railway S3 access key (optional) | -| RAILWAY_S3_SECRET_ACCESS_KEY | Railway S3 secret key (optional) | -| STRAPI_URL | URL of apps/cms (Railway internal) | -| STRAPI_API_TOKEN | Strapi API token (seeded in bootstrap) | -| WORKFLOW_API_KEY | workflow API key (optional, for production durability) | -| MANAGER_API_KEY | API key for external clients (optional in dev) | -| NEXT_PUBLIC_WATCH_URL | Public video watch URL (optional) | +| Variable | Description | +| ------------------------------------- | ------------------------------------------------------------------------- | +| MUX_TOKEN_ID | Mux API token ID | +| MUX_TOKEN_SECRET | Mux API token secret | +| OPENROUTER_API_KEY | OpenRouter API key | +| ELEVENLABS_API_KEY | ElevenLabs API key for audio isolation (optional — enables audio cleanup) | +| RAILWAY_S3_ENDPOINT | Railway Object Storage endpoint (optional — local fallback) | +| RAILWAY_S3_REGION | Railway S3 region (default: auto) | +| RAILWAY_S3_BUCKET | Railway S3 bucket name (optional — triggers S3 mode) | +| RAILWAY_S3_ACCESS_KEY_ID | Railway S3 access key (optional) | +| RAILWAY_S3_SECRET_ACCESS_KEY | Railway S3 secret key (optional) | +| STRAPI_URL | URL of apps/cms (Railway internal) | +| STRAPI_API_TOKEN | Strapi API token (seeded in bootstrap) | +| WORKFLOW_API_KEY | workflow API key (optional, for production durability) | +| MANAGER_API_KEY | API key for external clients (optional in dev) | +| ELEVENLABS_REQUEST_TIMEOUT_MS | ElevenLabs request timeout override (optional) | +| ELEVENLABS_SOURCE_DOWNLOAD_TIMEOUT_MS | ElevenLabs source download timeout override (optional) | +| SUBTITLE_EDITOR_PUBLIC_URL | Public subtitle editor app URL | +| SUBTITLE_EDITOR_ALLOWED_ORIGINS | Exact allowed origins for editor browser APIs | +| SUBTITLE_REVIEW_SESSION_SECRET | Dedicated HMAC secret for subtitle review edit sessions | +| NEXT_PUBLIC_WATCH_URL | Public video watch URL (optional) | diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/response.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/response.ts new file mode 100644 index 000000000..440aa0832 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/response.ts @@ -0,0 +1,147 @@ +import { NextResponse } from "next/server" +import { + buildSubtitleReviewCorsHeaders, + getSubtitleReviewConfiguration, +} from "@/lib/subtitle-review-session" +import type { SubtitleReviewFailureReason } from "@/services/subtitleReview" + +const NO_STORE_HEADERS = { + "Cache-Control": "private, no-store", +} + +export function noStoreJson( + body: Record, + init?: ResponseInit, +): NextResponse { + return NextResponse.json(body, { + ...init, + headers: { + ...NO_STORE_HEADERS, + ...init?.headers, + }, + }) +} + +export function requireSubtitleReviewConfigurationResponse(init?: { + cors?: Record +}): NextResponse | null { + const configuration = getSubtitleReviewConfiguration() + if (configuration.ok) { + return null + } + + return noStoreJson( + { + error: "subtitle_review_not_configured", + missing: configuration.missing, + }, + { + status: 503, + headers: init?.cors, + }, + ) +} + +export function requireEditorCorsHeaders(request: Request): Response | null { + const headers = buildSubtitleReviewCorsHeaders(request.headers.get("origin")) + const configurationError = requireSubtitleReviewConfigurationResponse({ + cors: headers ?? { Vary: "Origin" }, + }) + if (configurationError) { + return configurationError + } + + if (headers) { + return null + } + + return NextResponse.json( + { error: "Forbidden origin" }, + { + status: 403, + headers: { + "Cache-Control": "private, no-store", + Vary: "Origin", + }, + }, + ) +} + +export function corsHeaders(request: Request): Record { + return ( + buildSubtitleReviewCorsHeaders(request.headers.get("origin")) ?? { + Vary: "Origin", + } + ) +} + +export function preflightResponse(request: Request): Response { + const headers = buildSubtitleReviewCorsHeaders(request.headers.get("origin")) + const configuration = getSubtitleReviewConfiguration() + if (!configuration.ok) { + return new Response(null, { + status: 503, + headers: { + ...NO_STORE_HEADERS, + ...(headers ?? { Vary: "Origin" }), + }, + }) + } + + if (!headers) { + return new Response(null, { + status: 403, + headers: { + "Cache-Control": "private, no-store", + Vary: "Origin", + }, + }) + } + + return new Response(null, { + status: 204, + headers: { + ...NO_STORE_HEADERS, + ...headers, + }, + }) +} + +export function failureResponse( + reason: SubtitleReviewFailureReason, + init?: { latestArtifactKey?: string; cors?: Record }, +): NextResponse { + const statusByReason: Record = { + job_not_found: 404, + artifact_not_found: 404, + invalid_artifact: 400, + invalid_launch: 401, + invalid_token: 401, + missing_playback: 422, + invalid_vtt: 422, + rate_limited: 429, + stale_base: 409, + persist_failed: 500, + } + + return noStoreJson( + { + error: reason, + ...(init?.latestArtifactKey + ? { latestArtifactKey: init.latestArtifactKey } + : {}), + }, + { + status: statusByReason[reason], + headers: init?.cors, + }, + ) +} + +export function readBearerToken(request: Request): string | null { + const header = request.headers.get("authorization") + if (!header?.startsWith("Bearer ")) { + return null + } + return header.slice("Bearer ".length) +} diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.test.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.test.ts new file mode 100644 index 000000000..88c99cc87 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { saveRevisionMock } = vi.hoisted(() => ({ + saveRevisionMock: vi.fn(), +})) + +vi.mock("@/services/subtitleReview", () => ({ + saveSubtitleReviewRevision: saveRevisionMock, +})) + +import { POST } from "@/app/api/jobs/[id]/subtitle-reviews/revisions/route" + +describe("POST /api/jobs/[id]/subtitle-reviews/revisions", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv( + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "https://subtitles.forge.test", + ) + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + saveRevisionMock.mockResolvedValue({ + ok: true, + status: "saved", + jobId: "job-1", + artifactKey: "subtitles-fr-reviewed-r0001", + reviewedArtifactKey: "subtitles-fr-reviewed-r0001", + revision: 1, + contentFingerprint: "content-fingerprint", + baseArtifactFingerprint: "base-fingerprint", + savedAt: "2026-04-12T12:00:00.000Z", + }) + }) + + it("saves a reviewed revision from an allowed editor origin", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/revisions", + { + method: "POST", + headers: { + Authorization: "Bearer edit-token", + "Content-Type": "application/json", + Origin: "https://subtitles.forge.test", + }, + body: JSON.stringify({ + baseArtifactFingerprint: "base-fingerprint", + clientSaveId: "save-1", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n", + }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ + status: "saved", + artifactKey: "subtitles-fr-reviewed-r0001", + reviewedArtifactKey: "subtitles-fr-reviewed-r0001", + revision: 1, + jobId: "job-1", + contentFingerprint: "content-fingerprint", + baseArtifactFingerprint: "base-fingerprint", + savedAt: "2026-04-12T12:00:00.000Z", + }) + expect(saveRevisionMock).toHaveBeenCalledWith({ + jobId: "job-1", + editToken: "edit-token", + baseArtifactFingerprint: "base-fingerprint", + clientSaveId: "save-1", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n", + }) + }) + + it("returns conflict when the save base is stale", async () => { + saveRevisionMock.mockResolvedValue({ + ok: false, + reason: "stale_base", + latestArtifactKey: "subtitles-fr-reviewed-r0002", + }) + + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/revisions", + { + method: "POST", + headers: { + Authorization: "Bearer edit-token", + "Content-Type": "application/json", + Origin: "https://subtitles.forge.test", + }, + body: JSON.stringify({ + clientSaveId: "save-1", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n", + }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: "stale_base", + latestArtifactKey: "subtitles-fr-reviewed-r0002", + }) + }) +}) diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.ts new file mode 100644 index 000000000..4d87cfaf2 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/revisions/route.ts @@ -0,0 +1,77 @@ +import { z } from "zod" +import { saveSubtitleReviewRevision } from "@/services/subtitleReview" +import { + corsHeaders, + failureResponse, + noStoreJson, + preflightResponse, + readBearerToken, + requireEditorCorsHeaders, +} from "../response" + +const requestBodySchema = z.object({ + baseArtifactFingerprint: z.string().min(1).optional(), + clientSaveId: z.string().min(1), + vtt: z.string().min(1), +}) + +export function OPTIONS(request: Request) { + return preflightResponse(request) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const corsError = requireEditorCorsHeaders(request) + if (corsError) return corsError + + const headers = corsHeaders(request) + const editToken = readBearerToken(request) + if (!editToken) { + return noStoreJson( + { error: "Edit token required" }, + { status: 401, headers }, + ) + } + + const parsedBody = requestBodySchema.safeParse( + await request.json().catch(() => null), + ) + if (!parsedBody.success) { + return noStoreJson( + { error: "Invalid revision request" }, + { status: 400, headers }, + ) + } + + const { id } = await params + const result = await saveSubtitleReviewRevision({ + jobId: id, + editToken, + baseArtifactFingerprint: parsedBody.data.baseArtifactFingerprint, + clientSaveId: parsedBody.data.clientSaveId, + vtt: parsedBody.data.vtt, + }) + + if (!result.ok) { + return failureResponse(result.reason, { + latestArtifactKey: result.latestArtifactKey, + cors: headers, + }) + } + + return noStoreJson( + { + status: result.status, + artifactKey: result.artifactKey, + reviewedArtifactKey: result.reviewedArtifactKey, + revision: result.revision, + jobId: result.jobId, + contentFingerprint: result.contentFingerprint, + baseArtifactFingerprint: result.baseArtifactFingerprint, + savedAt: result.savedAt, + }, + { status: result.status === "saved" ? 201 : 200, headers }, + ) +} diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.test.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.test.ts new file mode 100644 index 000000000..2282b011f --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { bootstrapMock } = vi.hoisted(() => ({ + bootstrapMock: vi.fn(), +})) + +vi.mock("@/services/subtitleReview", () => ({ + bootstrapSubtitleReviewSession: bootstrapMock, +})) + +import { POST } from "@/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route" + +describe("POST /api/jobs/[id]/subtitle-reviews/session/bootstrap", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv( + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "https://subtitles.forge.test", + ) + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + bootstrapMock.mockResolvedValue({ + ok: true, + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nBonjour\n", + media: { + muxPlaybackId: "playback-1", + muxAssetId: "asset-1", + }, + returnUrl: "/dashboard/jobs/job-1", + }) + }) + + it("returns the bootstrap payload for an allowed origin bearer token", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/bootstrap", + { + method: "POST", + headers: { + Authorization: "Bearer edit-token", + Origin: "https://subtitles.forge.test", + }, + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(200) + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://subtitles.forge.test", + ) + await expect(response.json()).resolves.toEqual({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nBonjour\n", + media: { + muxPlaybackId: "playback-1", + muxAssetId: "asset-1", + }, + returnUrl: "/dashboard/jobs/job-1", + }) + }) + + it("rejects missing edit tokens", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/bootstrap", + { + method: "POST", + headers: { Origin: "https://subtitles.forge.test" }, + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(401) + expect(bootstrapMock).not.toHaveBeenCalled() + }) + + it("returns a typed configuration error before bootstrapping", async () => { + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "") + vi.stubEnv("SUBTITLE_EDITOR_ALLOWED_ORIGINS", "") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "") + + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/bootstrap", + { + method: "POST", + headers: { + Authorization: "Bearer edit-token", + Origin: "https://subtitles.forge.test", + }, + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(503) + expect(response.headers.get("Cache-Control")).toBe("private, no-store") + await expect(response.json()).resolves.toEqual({ + error: "subtitle_review_not_configured", + missing: [ + "SUBTITLE_EDITOR_PUBLIC_URL", + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "SUBTITLE_REVIEW_SESSION_SECRET", + ], + }) + expect(bootstrapMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.ts new file mode 100644 index 000000000..61b374e8d --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/bootstrap/route.ts @@ -0,0 +1,51 @@ +import { bootstrapSubtitleReviewSession } from "@/services/subtitleReview" +import { + corsHeaders, + failureResponse, + noStoreJson, + preflightResponse, + readBearerToken, + requireEditorCorsHeaders, +} from "../../response" + +export function OPTIONS(request: Request) { + return preflightResponse(request) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const corsError = requireEditorCorsHeaders(request) + if (corsError) return corsError + + const headers = corsHeaders(request) + const editToken = readBearerToken(request) + if (!editToken) { + return noStoreJson( + { error: "Edit token required" }, + { status: 401, headers }, + ) + } + + const { id } = await params + const result = await bootstrapSubtitleReviewSession({ jobId: id, editToken }) + + if (!result.ok) { + return failureResponse(result.reason, { cors: headers }) + } + + return noStoreJson( + { + jobId: result.jobId, + sourceArtifactKey: result.sourceArtifactKey, + targetLanguage: result.targetLanguage, + baseArtifactKey: result.baseArtifactKey, + baseFingerprint: result.baseFingerprint, + vtt: result.vtt, + media: result.media, + returnUrl: result.returnUrl, + }, + { headers }, + ) +} diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.test.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.test.ts new file mode 100644 index 000000000..424b106fa --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { exchangeMock } = vi.hoisted(() => ({ + exchangeMock: vi.fn(), +})) + +vi.mock("@/services/subtitleReview", () => ({ + exchangeSubtitleReviewLaunchCode: exchangeMock, +})) + +import { + OPTIONS, + POST, +} from "@/app/api/jobs/[id]/subtitle-reviews/session/exchange/route" + +describe("POST /api/jobs/[id]/subtitle-reviews/session/exchange", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv( + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "https://subtitles.forge.test", + ) + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + exchangeMock.mockResolvedValue({ + ok: true, + editToken: "edit-token", + expiresAt: "2026-04-12T12:30:00.000Z", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + }) + }) + + it("exchanges a launch code from an allowed editor origin", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/exchange", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://subtitles.forge.test", + }, + body: JSON.stringify({ launchCode: "launch-code" }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(200) + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://subtitles.forge.test", + ) + expect(response.headers.get("Cache-Control")).toBe("private, no-store") + await expect(response.json()).resolves.toEqual({ + editToken: "edit-token", + expiresAt: "2026-04-12T12:30:00.000Z", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + }) + }) + + it("rejects unlisted origins before touching launch state", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/exchange", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://evil.test", + }, + body: JSON.stringify({ launchCode: "launch-code" }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(403) + expect(exchangeMock).not.toHaveBeenCalled() + }) + + it("answers preflight for allowed editor origins", async () => { + const response = await OPTIONS( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session/exchange", + { + method: "OPTIONS", + headers: { Origin: "https://subtitles.forge.test" }, + }, + ), + ) + + expect(response.status).toBe(204) + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ) + }) +}) diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.ts new file mode 100644 index 000000000..905a9c014 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/exchange/route.ts @@ -0,0 +1,58 @@ +import { z } from "zod" +import { exchangeSubtitleReviewLaunchCode } from "@/services/subtitleReview" +import { + corsHeaders, + failureResponse, + noStoreJson, + preflightResponse, + requireEditorCorsHeaders, +} from "../../response" + +const requestBodySchema = z.object({ + launchCode: z.string().min(1), +}) + +export function OPTIONS(request: Request) { + return preflightResponse(request) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const corsError = requireEditorCorsHeaders(request) + if (corsError) return corsError + + const headers = corsHeaders(request) + const parsedBody = requestBodySchema.safeParse( + await request.json().catch(() => null), + ) + if (!parsedBody.success) { + return noStoreJson( + { error: "Invalid exchange request" }, + { status: 400, headers }, + ) + } + + const { id } = await params + const result = await exchangeSubtitleReviewLaunchCode({ + jobId: id, + launchCode: parsedBody.data.launchCode, + }) + + if (!result.ok) { + return failureResponse(result.reason, { cors: headers }) + } + + return noStoreJson( + { + editToken: result.editToken, + expiresAt: result.expiresAt, + sourceArtifactKey: result.sourceArtifactKey, + targetLanguage: result.targetLanguage, + baseArtifactKey: result.baseArtifactKey, + baseFingerprint: result.baseFingerprint, + }, + { headers }, + ) +} diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.test.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.test.ts new file mode 100644 index 000000000..4b5a0b0c6 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.test.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const { authenticateManagerOverrideRequestMock, createSessionMock } = + vi.hoisted(() => ({ + authenticateManagerOverrideRequestMock: vi.fn(), + createSessionMock: vi.fn(), + })) + +vi.mock("@/lib/auth", () => ({ + authenticateManagerOverrideRequest: authenticateManagerOverrideRequestMock, +})) + +vi.mock("@/services/subtitleReview", () => ({ + createSubtitleReviewSession: createSessionMock, +})) + +import { POST } from "@/app/api/jobs/[id]/subtitle-reviews/session/route" + +describe("POST /api/jobs/[id]/subtitle-reviews/session", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv( + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "https://subtitles.forge.test", + ) + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + authenticateManagerOverrideRequestMock.mockResolvedValue({ + kind: "session", + approvedByUserId: "user-1", + }) + createSessionMock.mockResolvedValue({ + editorUrl: "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + expiresAt: "2026-04-12T12:05:00.000Z", + }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("creates a no-store subtitle review launch for the authorized manager actor", async () => { + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ artifactKey: "subtitles-fr" }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + await expect(response.json()).resolves.toEqual({ + editorUrl: "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + expiresAt: "2026-04-12T12:05:00.000Z", + }) + expect(response.status).toBe(200) + expect(response.headers.get("Cache-Control")).toBe("private, no-store") + expect(createSessionMock).toHaveBeenCalledWith({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + actorId: "user-1", + }) + }) + + it("does not create a launch session when manager authentication fails", async () => { + authenticateManagerOverrideRequestMock.mockResolvedValue( + NextResponse.json({ error: "nope" }, { status: 403 }), + ) + + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ artifactKey: "subtitles-fr" }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(403) + expect(createSessionMock).not.toHaveBeenCalled() + }) + + it("returns a typed configuration error before creating a launch session", async () => { + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "") + vi.stubEnv("SUBTITLE_EDITOR_ALLOWED_ORIGINS", "") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "") + + const response = await POST( + new Request( + "https://manager.test/api/jobs/job-1/subtitle-reviews/session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ artifactKey: "subtitles-fr" }), + }, + ), + { params: Promise.resolve({ id: "job-1" }) }, + ) + + expect(response.status).toBe(503) + expect(response.headers.get("Cache-Control")).toBe("private, no-store") + await expect(response.json()).resolves.toEqual({ + error: "subtitle_review_not_configured", + missing: [ + "SUBTITLE_EDITOR_PUBLIC_URL", + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "SUBTITLE_REVIEW_SESSION_SECRET", + ], + }) + expect(createSessionMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.ts b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.ts new file mode 100644 index 000000000..f0e290c11 --- /dev/null +++ b/apps/manager/src/app/api/jobs/[id]/subtitle-reviews/session/route.ts @@ -0,0 +1,65 @@ +import { z } from "zod" +import { authenticateManagerOverrideRequest } from "@/lib/auth" +import { createSubtitleReviewSession } from "@/services/subtitleReview" +import { + noStoreJson, + requireSubtitleReviewConfigurationResponse, +} from "../response" + +const requestBodySchema = z.object({ + artifactKey: z.string().min(1).startsWith("subtitles-"), +}) + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const actor = await authenticateManagerOverrideRequest(request) + if (actor instanceof Response) { + return actor + } + + const configError = requireSubtitleReviewConfigurationResponse() + if (configError) { + return configError + } + + const parsedBody = requestBodySchema.safeParse( + await request.json().catch(() => null), + ) + if (!parsedBody.success) { + return noStoreJson( + { error: "Invalid subtitle review request" }, + { status: 400 }, + ) + } + + const { id } = await params + + try { + const session = await createSubtitleReviewSession({ + jobId: id, + sourceArtifactKey: parsedBody.data.artifactKey, + actorId: actor.approvedByUserId, + }) + + return noStoreJson({ + editorUrl: session.editorUrl, + sourceArtifactKey: session.sourceArtifactKey, + targetLanguage: session.targetLanguage, + baseArtifactKey: session.baseArtifactKey, + expiresAt: session.expiresAt, + }) + } catch (error) { + const reason = + error instanceof Error && error.message ? error.message : "persist_failed" + const status = + reason === "job_not_found" || reason === "artifact_not_found" + ? 404 + : reason === "invalid_artifact" + ? 400 + : 500 + + return noStoreJson({ error: reason }, { status }) + } +} diff --git a/apps/manager/src/app/dashboard/jobs/[id]/page.tsx b/apps/manager/src/app/dashboard/jobs/[id]/page.tsx index d7ab08824..c45296f7b 100644 --- a/apps/manager/src/app/dashboard/jobs/[id]/page.tsx +++ b/apps/manager/src/app/dashboard/jobs/[id]/page.tsx @@ -4,6 +4,7 @@ import { graphql } from "@forge/graphql" import getClient from "@/cms/client" import { LiveJobDetailScreen } from "@/features/jobs/live-job-detail-screen" import { toJobRecord } from "@/lib/state" +import { isSubtitleReviewConfigured } from "@/lib/subtitle-review-session" import type { JobRecord } from "@/types/job" export const dynamic = "force-dynamic" @@ -118,6 +119,7 @@ export default async function JobDetailPage({ ) } diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index d475ed7d7..961ae6fcb 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -5171,6 +5171,62 @@ body.jobs-standalone .jobs-step-job-id { opacity: 0.68; } +.jobs-subtitle-review-list { + display: grid; + gap: 0.75rem; + margin-top: 0.65rem; +} + +.jobs-subtitle-review-card { + border: 1px solid rgba(95, 87, 76, 0.18); + border-radius: 8px; + padding: 0.85rem; + background: rgba(255, 255, 255, 0.52); +} + +.jobs-subtitle-review-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.jobs-subtitle-review-copy { + margin: 0.45rem 0 0; + color: rgba(50, 40, 32, 0.82); +} + +.jobs-subtitle-review-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem; + margin-top: 0.75rem; +} + +.jobs-subtitle-review-link, +.jobs-subtitle-review-button { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.jobs-subtitle-review-link { + color: rgb(79 72 62); + font-size: 0.92rem; + font-weight: 600; + text-decoration: none; +} + +.jobs-subtitle-review-link:hover, +.jobs-subtitle-review-link:focus-visible { + text-decoration: underline; +} + +.jobs-subtitle-review-button { + border-radius: 8px; +} + .jobs-step-retry-pill { display: inline-flex; align-items: center; diff --git a/apps/manager/src/config/env.ts b/apps/manager/src/config/env.ts index 9c57a681d..473b39d71 100644 --- a/apps/manager/src/config/env.ts +++ b/apps/manager/src/config/env.ts @@ -42,6 +42,11 @@ export const env = createEnv({ .int() .positive() .optional(), + + // Subtitle review editor + SUBTITLE_EDITOR_PUBLIC_URL: z.string().url().optional(), + SUBTITLE_EDITOR_ALLOWED_ORIGINS: z.string().min(1).optional(), + SUBTITLE_REVIEW_SESSION_SECRET: z.string().min(1).optional(), }, client: { NEXT_PUBLIC_WATCH_URL: z.string().url().optional(), @@ -69,6 +74,10 @@ export const env = createEnv({ ELEVENLABS_REQUEST_TIMEOUT_MS: process.env.ELEVENLABS_REQUEST_TIMEOUT_MS, ELEVENLABS_SOURCE_DOWNLOAD_TIMEOUT_MS: process.env.ELEVENLABS_SOURCE_DOWNLOAD_TIMEOUT_MS, + SUBTITLE_EDITOR_PUBLIC_URL: process.env.SUBTITLE_EDITOR_PUBLIC_URL, + SUBTITLE_EDITOR_ALLOWED_ORIGINS: + process.env.SUBTITLE_EDITOR_ALLOWED_ORIGINS, + SUBTITLE_REVIEW_SESSION_SECRET: process.env.SUBTITLE_REVIEW_SESSION_SECRET, NEXT_PUBLIC_WATCH_URL: process.env.NEXT_PUBLIC_WATCH_URL, }, }) diff --git a/apps/manager/src/features/jobs/live-job-detail-screen.tsx b/apps/manager/src/features/jobs/live-job-detail-screen.tsx index bf4762503..fe8ca076d 100644 --- a/apps/manager/src/features/jobs/live-job-detail-screen.tsx +++ b/apps/manager/src/features/jobs/live-job-detail-screen.tsx @@ -17,11 +17,13 @@ type ReviewContextLoadState = type LiveJobDetailScreenProps = { initialJob: JobRecord languageLabelsById: Record + subtitleReviewConfigured?: boolean } export function LiveJobDetailScreen({ initialJob, languageLabelsById, + subtitleReviewConfigured = true, }: LiveJobDetailScreenProps) { const [job, setJob] = useState(initialJob) const [reviewContext, setReviewContext] = useState({ @@ -92,6 +94,7 @@ export function LiveJobDetailScreen({ initialJob={initialJob} headingMeta={{initialJob.id}} onJobUpdate={setJob} + subtitleReviewConfigured={subtitleReviewConfigured} /> diff --git a/apps/manager/src/features/jobs/live-job-steps-table.tsx b/apps/manager/src/features/jobs/live-job-steps-table.tsx index c34158439..60e6e2af0 100644 --- a/apps/manager/src/features/jobs/live-job-steps-table.tsx +++ b/apps/manager/src/features/jobs/live-job-steps-table.tsx @@ -4,11 +4,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Captions, Download, + ExternalLink, FileAudio2, FileJson2, Languages, ListOrdered, Network, + PencilLine, RefreshCw, Search, type LucideIcon, @@ -46,6 +48,15 @@ import { shouldExpandSceneEmbeddingSyncByDefault, } from "./scene-embedding-sync-card" import { getPresentedMuxSyncComparisons } from "@/features/jobs/mux-sync-presenter" +import { + getPresentedSubtitleReviews, + type PresentedSubtitleReview, +} from "@/features/jobs/subtitle-review-presenter" +import { + closeSubtitleReviewPopup, + completeSubtitleReviewLaunch, + openSubtitleReviewPopup, +} from "@/features/jobs/subtitle-review-launch" import { CollapsibleStepRow } from "./collapsible-step-row" type RunPollOptions = { @@ -56,6 +67,7 @@ type LiveJobStepsTableProps = { initialJob: JobRecord headingMeta?: React.ReactNode onJobUpdate?: (job: JobRecord) => void + subtitleReviewConfigured?: boolean } function isTerminalJobStatus(status: JobRecord["status"]): boolean { @@ -114,6 +126,24 @@ function formatDuration(startedAt?: string, finishedAt?: string): string { return `${hours}h` } +function formatReviewDate(iso?: string): string { + if (!iso) { + return "No human review saved yet." + } + + const parsed = new Date(iso) + if (Number.isNaN(parsed.getTime())) { + return "Human review saved." + } + + return `Reviewed ${new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(parsed)}` +} + function getStepLabelIcon(stepName: WorkflowStepName): LucideIcon { switch (stepName) { case "download_video": @@ -385,6 +415,7 @@ export function LiveJobStepsTable({ initialJob, headingMeta, onJobUpdate, + subtitleReviewConfigured = true, }: LiveJobStepsTableProps) { const [job, setJob] = useState(initialJob) const [isRefreshing, setIsRefreshing] = useState(false) @@ -402,6 +433,10 @@ export function LiveJobStepsTable({ () => getTranscriptionRoutingReport(job.artifacts), [job.artifacts], ) + const initialSubtitleReviews = useMemo( + () => getPresentedSubtitleReviews(initialJob), + [initialJob], + ) const [expandedSteps, setExpandedSteps] = useState< Partial> >(() => ({ @@ -412,6 +447,7 @@ export function LiveJobStepsTable({ shouldExpandSceneEmbeddingSyncByDefault( getSceneEmbeddingSyncReport(initialJob.artifacts), ), + translation: initialSubtitleReviews.length > 0, })) const [overrideArtifactKey, setOverrideArtifactKey] = useState( null, @@ -420,6 +456,12 @@ export function LiveJobStepsTable({ const [rerunProvider, setRerunProvider] = useState(null) const [rerunError, setRerunError] = useState(null) + const [subtitleReviewArtifactKey, setSubtitleReviewArtifactKey] = useState< + string | null + >(null) + const [subtitleReviewError, setSubtitleReviewError] = useState( + null, + ) const requestSeqRef = useRef(0) const latestStatusRef = useRef(initialJob.status) @@ -652,6 +694,58 @@ export function LiveJobStepsTable({ [job.id, onJobUpdate], ) + const handleSubtitleReviewLaunch = useCallback( + async (review: PresentedSubtitleReview) => { + const reviewWindow = + typeof window !== "undefined" + ? openSubtitleReviewPopup((url, target) => window.open(url, target)) + : null + setSubtitleReviewArtifactKey(review.sourceArtifactKey) + setSubtitleReviewError(null) + + try { + const response = await fetch( + `/api/jobs/${encodeURIComponent(job.id)}/subtitle-reviews/session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + artifactKey: review.sourceArtifactKey, + }), + }, + ) + + const payload = (await response.json()) as { + editorUrl?: string + error?: string + } + if (!response.ok || !payload.editorUrl) { + closeSubtitleReviewPopup(reviewWindow) + setSubtitleReviewError( + payload.error ?? "Could not open subtitle review.", + ) + return + } + + if (typeof window !== "undefined") { + completeSubtitleReviewLaunch( + reviewWindow, + payload.editorUrl, + window.location, + ) + } + } catch { + closeSubtitleReviewPopup(reviewWindow) + setSubtitleReviewError("Could not open subtitle review.") + } finally { + setSubtitleReviewArtifactKey(null) + } + }, + [job.id], + ) + const liveStatus = useMemo(() => { if (isRefreshing) { return "Updating job..." @@ -672,6 +766,13 @@ export function LiveJobStepsTable({ () => getPresentedMuxSyncComparisons(job), [job], ) + const subtitleReviews = useMemo(() => getPresentedSubtitleReviews(job), [job]) + + useEffect(() => { + if (subtitleReviews.length > 0) { + setExpandedSteps((current) => ({ ...current, translation: true })) + } + }, [subtitleReviews.length]) return (
@@ -742,8 +843,11 @@ export function LiveJobStepsTable({ const hasEmbeddingDetails = step.name === "embeddings" && (embeddingSyncReport != null || hasSceneEmbeddingDetails) + const showSubtitleReviewDetails = + step.name === "translation" && subtitleReviews.length > 0 const hasTranslationDetails = - step.name === "translation" && translationFailures.length > 0 + step.name === "translation" && + (translationFailures.length > 0 || showSubtitleReviewDetails) const hasTranscriptionDetails = step.name === "transcription" && (transcriptionRoutingReport != null || rerunError != null) @@ -805,28 +909,110 @@ export function LiveJobStepsTable({ {translationFailureSummary} + ) : showSubtitleReviewDetails ? ( + + Subtitle review available. + ) : null detailContent = ( <> -

- {translationFailureSummary} -

-
    - {translationFailures.map((failure) => ( -
  • - {failure.lang} - {failure.error ? `: ${failure.error}` : null} -
  • - ))} -
+ {translationFailures.length > 0 ? ( + <> +

+ {translationFailureSummary} +

+
    + {translationFailures.map((failure) => ( +
  • + {failure.lang} + {failure.error ? `: ${failure.error}` : null} +
  • + ))} +
+ + ) : null} + {showSubtitleReviewDetails ? ( +
+

+ Subtitle review +

+ {!subtitleReviewConfigured ? ( +

+ Subtitle review editor is not configured for this + environment. +

+ ) : subtitleReviewError ? ( +

+ {subtitleReviewError} +

+ ) : null} + {subtitleReviews.map((review) => ( +
+
+ {review.targetLanguage} + + {review.latestRevision + ? `reviewed r${String( + review.latestRevision, + ).padStart(4, "0")}` + : "needs review"} + +
+

+ {formatReviewDate(review.latestReviewedAt)} +

+
+ {review.latestReviewArtifactKey ? ( + + + ) : null} + +
+
+ ))} +
+ ) : null} ) } else if (hasTranscriptionDetails) { diff --git a/apps/manager/src/features/jobs/subtitle-review-launch.test.ts b/apps/manager/src/features/jobs/subtitle-review-launch.test.ts new file mode 100644 index 000000000..6c4c580cb --- /dev/null +++ b/apps/manager/src/features/jobs/subtitle-review-launch.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest" +import { + closeSubtitleReviewPopup, + completeSubtitleReviewLaunch, + openSubtitleReviewPopup, +} from "@/features/jobs/subtitle-review-launch" + +function buildPopup() { + return { + opener: {}, + location: { + href: "about:blank", + }, + close: vi.fn(), + } +} + +describe("subtitle review launch helper", () => { + it("navigates a pre-opened popup when the popup is available", () => { + const popup = buildPopup() + const openWindow = vi.fn(() => popup) + const currentTab = { assign: vi.fn() } + + const target = openSubtitleReviewPopup(openWindow) + completeSubtitleReviewLaunch( + target, + "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + currentTab, + ) + + expect(openWindow).toHaveBeenCalledWith("about:blank", "_blank") + expect(popup.opener).toBeNull() + expect(popup.location.href).toBe( + "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + ) + expect(currentTab.assign).not.toHaveBeenCalled() + }) + + it("uses current-tab navigation when the popup is blocked", () => { + const openWindow = vi.fn(() => null) + const currentTab = { assign: vi.fn() } + + const target = openSubtitleReviewPopup(openWindow) + completeSubtitleReviewLaunch( + target, + "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + currentTab, + ) + + expect(openWindow).toHaveBeenCalledTimes(1) + expect(openWindow).toHaveBeenCalledWith("about:blank", "_blank") + expect(openWindow).not.toHaveBeenCalledWith( + "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + "_blank", + "noopener,noreferrer", + ) + expect(currentTab.assign).toHaveBeenCalledWith( + "https://subtitles.forge.test/edit?jobId=job-1&launch=abc", + ) + }) + + it("closes the pre-opened popup on launch failure", () => { + const popup = buildPopup() + + closeSubtitleReviewPopup(popup) + + expect(popup.close).toHaveBeenCalled() + }) +}) diff --git a/apps/manager/src/features/jobs/subtitle-review-launch.ts b/apps/manager/src/features/jobs/subtitle-review-launch.ts new file mode 100644 index 000000000..9f27739b6 --- /dev/null +++ b/apps/manager/src/features/jobs/subtitle-review-launch.ts @@ -0,0 +1,46 @@ +export type SubtitleReviewPopup = { + opener: unknown + location: { + href: string + } + close: () => void +} + +export type SubtitleReviewOpenWindow = ( + url: string, + target: string, +) => SubtitleReviewPopup | null + +export type SubtitleReviewCurrentTab = { + assign: (url: string) => void +} + +export function openSubtitleReviewPopup( + openWindow: SubtitleReviewOpenWindow, +): SubtitleReviewPopup | null { + const popup = openWindow("about:blank", "_blank") + if (popup) { + popup.opener = null + } + + return popup +} + +export function completeSubtitleReviewLaunch( + popup: SubtitleReviewPopup | null, + editorUrl: string, + currentTab: SubtitleReviewCurrentTab, +): void { + if (popup) { + popup.location.href = editorUrl + return + } + + currentTab.assign(editorUrl) +} + +export function closeSubtitleReviewPopup( + popup: SubtitleReviewPopup | null, +): void { + popup?.close() +} diff --git a/apps/manager/src/features/jobs/subtitle-review-presenter.test.ts b/apps/manager/src/features/jobs/subtitle-review-presenter.test.ts new file mode 100644 index 000000000..d67e61b4f --- /dev/null +++ b/apps/manager/src/features/jobs/subtitle-review-presenter.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest" +import { getPresentedSubtitleReviews } from "@/features/jobs/subtitle-review-presenter" +import type { JobRecord } from "@/types/job" + +function buildJob(overrides: Partial = {}): JobRecord { + return { + id: "job-1", + muxAssetId: "asset-1", + muxPlaybackId: "playback-1", + languages: ["es", "fr"], + options: {}, + status: "completed", + retries: 0, + createdAt: "2026-04-12T11:00:00.000Z", + updatedAt: "2026-04-12T11:00:00.000Z", + artifacts: {}, + steps: [], + errors: [], + ...overrides, + } +} + +describe("subtitle review presenter", () => { + it("presents generated subtitle artifacts with latest review state", () => { + const reviews = getPresentedSubtitleReviews( + buildJob({ + artifacts: { + "translation-fr": { kind: "downloadable" }, + "subtitles-fr": { kind: "downloadable" }, + "subtitles-es": { kind: "downloadable" }, + "subtitles-fr-reviewed-r0001": { kind: "downloadable" }, + "subtitles-fr-reviewed-r0002": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: { + revisions: [ + { + artifactKey: "subtitles-fr-reviewed-r0001", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 1, + baseFingerprint: "base-1", + contentFingerprint: "content-1", + clientSaveId: "save-1", + actorId: "user-1", + createdAt: "2026-04-12T12:00:00.000Z", + }, + { + artifactKey: "subtitles-fr-reviewed-r0002", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 2, + baseFingerprint: "base-2", + contentFingerprint: "content-2", + clientSaveId: "save-2", + actorId: "user-1", + createdAt: "2026-04-12T13:00:00.000Z", + }, + ], + launchSessions: [], + updatedAt: "2026-04-12T13:00:00.000Z", + }, + }, + }, + }), + ) + + expect(reviews).toEqual([ + { + sourceArtifactKey: "subtitles-es", + targetLanguage: "es", + latestRevision: undefined, + latestReviewArtifactKey: undefined, + latestReviewedAt: undefined, + }, + { + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + latestRevision: 2, + latestReviewArtifactKey: "subtitles-fr-reviewed-r0002", + latestReviewedAt: "2026-04-12T13:00:00.000Z", + }, + ]) + }) +}) diff --git a/apps/manager/src/features/jobs/subtitle-review-presenter.ts b/apps/manager/src/features/jobs/subtitle-review-presenter.ts new file mode 100644 index 000000000..196e10c29 --- /dev/null +++ b/apps/manager/src/features/jobs/subtitle-review-presenter.ts @@ -0,0 +1,61 @@ +import { + getLatestSubtitleReviewRevision, + isReviewedSubtitleArtifactKey, +} from "@/lib/subtitle-review" +import type { JobRecord } from "@/types/job" + +export type PresentedSubtitleReview = { + sourceArtifactKey: string + targetLanguage: string + latestRevision?: number + latestReviewArtifactKey?: string + latestReviewedAt?: string +} + +function getTargetLanguage(sourceArtifactKey: string): string | null { + if ( + !sourceArtifactKey.startsWith("subtitles-") || + isReviewedSubtitleArtifactKey(sourceArtifactKey) + ) { + return null + } + + const targetLanguage = sourceArtifactKey.slice("subtitles-".length) + return targetLanguage.length > 0 ? targetLanguage : null +} + +export function getPresentedSubtitleReviews( + job: JobRecord, +): PresentedSubtitleReview[] { + return Object.entries(job.artifacts) + .flatMap(([sourceArtifactKey, entry]) => { + if (entry.kind !== "downloadable") { + return [] + } + + const targetLanguage = getTargetLanguage(sourceArtifactKey) + if (!targetLanguage) { + return [] + } + + const latestRevision = getLatestSubtitleReviewRevision( + job.artifacts, + sourceArtifactKey, + ) + + return [ + { + sourceArtifactKey, + targetLanguage, + latestRevision: latestRevision?.revision, + latestReviewArtifactKey: latestRevision?.artifactKey, + latestReviewedAt: latestRevision?.createdAt, + }, + ] + }) + .sort((left, right) => + left.targetLanguage === right.targetLanguage + ? left.sourceArtifactKey.localeCompare(right.sourceArtifactKey) + : left.targetLanguage.localeCompare(right.targetLanguage), + ) +} diff --git a/apps/manager/src/lib/job-artifacts.test.ts b/apps/manager/src/lib/job-artifacts.test.ts index b553f0010..b375fbdf2 100644 --- a/apps/manager/src/lib/job-artifacts.test.ts +++ b/apps/manager/src/lib/job-artifacts.test.ts @@ -56,6 +56,16 @@ describe("job artifact helpers", () => { }) }) + it("resolves reviewed subtitle descriptors as downloadable VTT artifacts", () => { + expect(resolveJobArtifactDescriptor("subtitles-ja-reviewed-r0001")).toEqual( + { + artifactType: "subtitles-ja-reviewed-r0001", + ext: "vtt", + contentType: "text/vtt; charset=utf-8", + }, + ) + }) + it("maps exact transcription artifacts to the transcription step", () => { expect( getArtifactsForStep("transcription", "job-1", { @@ -159,4 +169,25 @@ describe("job artifact helpers", () => { }, ]) }) + + it("keeps reviewed subtitle revisions out of generated translation artifacts", () => { + expect( + getArtifactsForStep("translation", "job-1", { + "subtitles-es": { kind: "downloadable" }, + "subtitles-es-reviewed-r0001": { kind: "downloadable" }, + "translation-es": { kind: "downloadable" }, + }), + ).toEqual([ + { + key: "subtitles-es", + label: "Subtitles es", + url: "/api/jobs/job-1/artifacts/subtitles-es", + }, + { + key: "translation-es", + label: "Translation es", + url: "/api/jobs/job-1/artifacts/translation-es", + }, + ]) + }) }) diff --git a/apps/manager/src/lib/job-artifacts.ts b/apps/manager/src/lib/job-artifacts.ts index c0e97f3c0..7828fcc6a 100644 --- a/apps/manager/src/lib/job-artifacts.ts +++ b/apps/manager/src/lib/job-artifacts.ts @@ -105,6 +105,10 @@ export function formatJobArtifactLabel(logicalKey: string): string { ) } +function isReviewedSubtitleArtifactKey(logicalKey: string): boolean { + return /^subtitles-.+-reviewed-r\d{4}$/.test(logicalKey) +} + function buildTranslationArtifactDescriptor( logicalKey: string, ): JobArtifactDescriptor | null { @@ -163,7 +167,8 @@ export function getArtifactsForStep( value.kind === "downloadable" && (key.startsWith("subtitles-") || key.startsWith("translation-") || - key === "translations"), + key === "translations") && + !isReviewedSubtitleArtifactKey(key), ) .sort(([left], [right]) => left.localeCompare(right)) .map(([key]) => ({ diff --git a/apps/manager/src/lib/subtitle-review-session.test.ts b/apps/manager/src/lib/subtitle-review-session.test.ts new file mode 100644 index 000000000..23408a116 --- /dev/null +++ b/apps/manager/src/lib/subtitle-review-session.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +describe("subtitle review session helpers", () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.useRealTimers() + vi.resetModules() + }) + + it("signs and verifies short-lived edit tokens for the intended job and source", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { signSubtitleReviewToken, verifySubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const token = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + + expect(await verifySubtitleReviewToken(token)).toEqual( + expect.objectContaining({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + }), + ) + }) + + it("rejects expired or tampered edit tokens", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:20:00.000Z")) + + const { signSubtitleReviewToken, verifySubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const token = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + + await expect(verifySubtitleReviewToken(token)).resolves.toBeNull() + await expect( + verifySubtitleReviewToken(`${token.slice(0, -3)}bad`), + ).resolves.toBeNull() + }) + + it("builds editor launch urls that carry only a launch code, never the edit token", async () => { + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + + const { buildSubtitleEditorLaunchUrl } = + await import("@/lib/subtitle-review-session") + + const url = buildSubtitleEditorLaunchUrl({ + jobId: "job-1", + launchCode: "launch-code", + }) + + expect(url).toBe( + "https://subtitles.forge.test/edit?jobId=job-1&launch=launch-code", + ) + expect(url).not.toContain("token") + }) + + it("allows only exact configured subtitle editor origins", async () => { + vi.stubEnv( + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "https://subtitles.forge.test, http://localhost:3004", + ) + + const { isAllowedSubtitleEditorOrigin } = + await import("@/lib/subtitle-review-session") + + expect(isAllowedSubtitleEditorOrigin("https://subtitles.forge.test")).toBe( + true, + ) + expect(isAllowedSubtitleEditorOrigin("http://localhost:3004")).toBe(true) + expect( + isAllowedSubtitleEditorOrigin("https://evil-subtitles.forge.test"), + ).toBe(false) + expect(isAllowedSubtitleEditorOrigin(null)).toBe(false) + }) + + it("reports missing subtitle review configuration", async () => { + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "") + vi.stubEnv("SUBTITLE_EDITOR_ALLOWED_ORIGINS", "") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "") + + const { getSubtitleReviewConfiguration } = + await import("@/lib/subtitle-review-session") + + expect(getSubtitleReviewConfiguration()).toEqual({ + ok: false, + missing: [ + "SUBTITLE_EDITOR_PUBLIC_URL", + "SUBTITLE_EDITOR_ALLOWED_ORIGINS", + "SUBTITLE_REVIEW_SESSION_SECRET", + ], + }) + }) +}) diff --git a/apps/manager/src/lib/subtitle-review-session.ts b/apps/manager/src/lib/subtitle-review-session.ts new file mode 100644 index 000000000..99e29c91e --- /dev/null +++ b/apps/manager/src/lib/subtitle-review-session.ts @@ -0,0 +1,249 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto" +import { env } from "@/config/env" + +const TOKEN_VERSION = 1 + +export type SubtitleReviewConfigVariable = + | "SUBTITLE_EDITOR_PUBLIC_URL" + | "SUBTITLE_EDITOR_ALLOWED_ORIGINS" + | "SUBTITLE_REVIEW_SESSION_SECRET" + +export type SubtitleReviewConfiguration = + | { + ok: true + editorPublicUrl: string + allowedOrigins: string + sessionSecret: string + } + | { + ok: false + missing: SubtitleReviewConfigVariable[] + } + +export type SubtitleReviewTokenPayload = { + jobId: string + sourceArtifactKey: string + targetLanguage: string + baseArtifactKey: string + baseFingerprint: string + actorId: string + expiresAt: string +} + +type SignedSubtitleReviewTokenPayload = SubtitleReviewTokenPayload & { + version: typeof TOKEN_VERSION +} + +function configuredValue(value: string | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function getSubtitleReviewConfiguration(): SubtitleReviewConfiguration { + const editorPublicUrl = configuredValue( + process.env.SUBTITLE_EDITOR_PUBLIC_URL ?? env.SUBTITLE_EDITOR_PUBLIC_URL, + ) + const allowedOrigins = configuredValue( + process.env.SUBTITLE_EDITOR_ALLOWED_ORIGINS ?? + env.SUBTITLE_EDITOR_ALLOWED_ORIGINS, + ) + const sessionSecret = configuredValue( + process.env.SUBTITLE_REVIEW_SESSION_SECRET ?? + env.SUBTITLE_REVIEW_SESSION_SECRET, + ) + const missing: SubtitleReviewConfigVariable[] = [] + if (!editorPublicUrl) missing.push("SUBTITLE_EDITOR_PUBLIC_URL") + if (!allowedOrigins) missing.push("SUBTITLE_EDITOR_ALLOWED_ORIGINS") + if (!sessionSecret) missing.push("SUBTITLE_REVIEW_SESSION_SECRET") + + if ( + missing.length > 0 || + !editorPublicUrl || + !allowedOrigins || + !sessionSecret + ) { + return { ok: false, missing } + } + + return { + ok: true, + editorPublicUrl, + allowedOrigins, + sessionSecret, + } +} + +export function isSubtitleReviewConfigured(): boolean { + return getSubtitleReviewConfiguration().ok +} + +function getSubtitleReviewSessionSecret(): string { + const secret = configuredValue( + process.env.SUBTITLE_REVIEW_SESSION_SECRET ?? + env.SUBTITLE_REVIEW_SESSION_SECRET, + ) + if (!secret) { + throw new Error("SUBTITLE_REVIEW_SESSION_SECRET is required") + } + return secret +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, "utf8").toString("base64url") +} + +function base64UrlDecode(value: string): string | null { + try { + return Buffer.from(value, "base64url").toString("utf8") + } catch { + return null + } +} + +function signTokenBody(body: string): string { + return createHmac("sha256", getSubtitleReviewSessionSecret()) + .update(body) + .digest("base64url") +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url") + const rightBuffer = Buffer.from(right, "base64url") + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ) +} + +function isTokenPayload( + value: unknown, +): value is SignedSubtitleReviewTokenPayload { + if (typeof value !== "object" || value == null || Array.isArray(value)) { + return false + } + + const payload = value as Record + return ( + payload.version === TOKEN_VERSION && + typeof payload.jobId === "string" && + typeof payload.sourceArtifactKey === "string" && + typeof payload.targetLanguage === "string" && + typeof payload.baseArtifactKey === "string" && + typeof payload.baseFingerprint === "string" && + typeof payload.actorId === "string" && + typeof payload.expiresAt === "string" + ) +} + +export function createSubtitleReviewLaunchCode(): string { + return randomBytes(32).toString("base64url") +} + +export function hashSubtitleReviewLaunchCode(launchCode: string): string { + return createHmac("sha256", getSubtitleReviewSessionSecret()) + .update(`launch:${launchCode}`) + .digest("base64url") +} + +export async function signSubtitleReviewToken( + payload: SubtitleReviewTokenPayload, +): Promise { + const body = base64UrlEncode( + JSON.stringify({ + ...payload, + version: TOKEN_VERSION, + } satisfies SignedSubtitleReviewTokenPayload), + ) + return `${body}.${signTokenBody(body)}` +} + +export async function verifySubtitleReviewToken( + token: string, +): Promise { + const [body, signature, extra] = token.split(".") + if (!body || !signature || extra != null) { + return null + } + + if (!safeEqual(signature, signTokenBody(body))) { + return null + } + + const decoded = base64UrlDecode(body) + if (!decoded) { + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(decoded) + } catch { + return null + } + + if (!isTokenPayload(parsed)) { + return null + } + + if (Date.parse(parsed.expiresAt) <= Date.now()) { + return null + } + + return { + jobId: parsed.jobId, + sourceArtifactKey: parsed.sourceArtifactKey, + targetLanguage: parsed.targetLanguage, + baseArtifactKey: parsed.baseArtifactKey, + baseFingerprint: parsed.baseFingerprint, + actorId: parsed.actorId, + expiresAt: parsed.expiresAt, + } +} + +export function buildSubtitleEditorLaunchUrl(input: { + jobId: string + launchCode: string +}): string { + const editorPublicUrl = configuredValue( + process.env.SUBTITLE_EDITOR_PUBLIC_URL ?? env.SUBTITLE_EDITOR_PUBLIC_URL, + ) + if (!editorPublicUrl) { + throw new Error("SUBTITLE_EDITOR_PUBLIC_URL is required") + } + + const url = new URL("/edit", editorPublicUrl) + url.searchParams.set("jobId", input.jobId) + url.searchParams.set("launch", input.launchCode) + return url.toString() +} + +export function isAllowedSubtitleEditorOrigin(origin: string | null): boolean { + const allowedOrigins = configuredValue( + process.env.SUBTITLE_EDITOR_ALLOWED_ORIGINS ?? + env.SUBTITLE_EDITOR_ALLOWED_ORIGINS, + ) + if (!origin || !allowedOrigins) { + return false + } + + return allowedOrigins + .split(",") + .map((candidate) => candidate.trim()) + .filter(Boolean) + .includes(origin) +} + +export function buildSubtitleReviewCorsHeaders( + origin: string | null, +): Record | null { + if (!origin || !isAllowedSubtitleEditorOrigin(origin)) { + return null + } + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + Vary: "Origin", + } +} diff --git a/apps/manager/src/lib/subtitle-review.test.ts b/apps/manager/src/lib/subtitle-review.test.ts new file mode 100644 index 000000000..921949a90 --- /dev/null +++ b/apps/manager/src/lib/subtitle-review.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest" +import { + buildReviewedSubtitleArtifactKey, + findExistingSubtitleReviewRevision, + getLatestSubtitleReviewRevision, + getSubtitleReviewReport, + setSubtitleReviewReport, +} from "@/lib/subtitle-review" +import type { JobArtifactManifest, SubtitleReviewReport } from "@/types/job" + +describe("subtitle review helpers", () => { + it("builds stable revision artifact keys", () => { + expect(buildReviewedSubtitleArtifactKey("ja", 1)).toBe( + "subtitles-ja-reviewed-r0001", + ) + expect(buildReviewedSubtitleArtifactKey("pt-BR", 42)).toBe( + "subtitles-pt-BR-reviewed-r0042", + ) + }) + + it("normalizes persisted review metadata and selects the latest source revision", () => { + const artifacts = { + subtitleReviews: { + kind: "metadata", + data: { + revisions: [ + { + artifactKey: "subtitles-fr-reviewed-r0001", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 1, + baseFingerprint: "base-a", + contentFingerprint: "content-a", + clientSaveId: "save-a", + actorId: "user-1", + createdAt: "2026-04-12T12:00:00.000Z", + }, + { + artifactKey: "subtitles-fr-reviewed-r0002", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 2, + baseFingerprint: "base-b", + contentFingerprint: "content-b", + clientSaveId: "save-b", + actorId: "user-1", + createdAt: "2026-04-12T13:00:00.000Z", + }, + { + artifactKey: "subtitles-es-reviewed-r0001", + sourceArtifactKey: "subtitles-es", + targetLanguage: "es", + revision: 1, + baseFingerprint: "base-c", + contentFingerprint: "content-c", + clientSaveId: "save-c", + actorId: "user-2", + createdAt: "2026-04-12T14:00:00.000Z", + }, + ], + updatedAt: "2026-04-12T14:00:00.000Z", + }, + }, + } satisfies JobArtifactManifest + + expect(getLatestSubtitleReviewRevision(artifacts, "subtitles-fr")).toEqual( + expect.objectContaining({ + artifactKey: "subtitles-fr-reviewed-r0002", + revision: 2, + }), + ) + }) + + it("writes review metadata without dropping unrelated artifacts", () => { + const report: SubtitleReviewReport = { + revisions: [ + { + artifactKey: "subtitles-ja-reviewed-r0001", + sourceArtifactKey: "subtitles-ja", + targetLanguage: "ja", + revision: 1, + baseFingerprint: "base-fingerprint", + contentFingerprint: "content-fingerprint", + clientSaveId: "save-1", + actorId: "user-1", + createdAt: "2026-04-12T12:00:00.000Z", + }, + ], + launchSessions: [], + updatedAt: "2026-04-12T12:00:00.000Z", + } + + expect( + setSubtitleReviewReport( + { + transcript: { kind: "downloadable" }, + }, + report, + ), + ).toEqual({ + transcript: { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: report, + }, + }) + }) + + it("finds an existing idempotent save by client save id or content fingerprint", () => { + const report: SubtitleReviewReport = { + revisions: [ + { + artifactKey: "subtitles-ja-reviewed-r0001", + sourceArtifactKey: "subtitles-ja", + targetLanguage: "ja", + revision: 1, + baseFingerprint: "base-fingerprint", + contentFingerprint: "content-fingerprint", + clientSaveId: "save-1", + actorId: "user-1", + createdAt: "2026-04-12T12:00:00.000Z", + }, + ], + launchSessions: [], + updatedAt: "2026-04-12T12:00:00.000Z", + } + + expect( + findExistingSubtitleReviewRevision(report, { + sourceArtifactKey: "subtitles-ja", + clientSaveId: "save-1", + contentFingerprint: "different", + })?.artifactKey, + ).toBe("subtitles-ja-reviewed-r0001") + expect( + findExistingSubtitleReviewRevision(report, { + sourceArtifactKey: "subtitles-ja", + clientSaveId: "different", + contentFingerprint: "content-fingerprint", + })?.artifactKey, + ).toBe("subtitles-ja-reviewed-r0001") + }) + + it("returns an empty report for missing or malformed metadata", () => { + expect(getSubtitleReviewReport({})).toEqual({ + revisions: [], + launchSessions: [], + updatedAt: new Date(0).toISOString(), + }) + }) +}) diff --git a/apps/manager/src/lib/subtitle-review.ts b/apps/manager/src/lib/subtitle-review.ts new file mode 100644 index 000000000..8a9234aab --- /dev/null +++ b/apps/manager/src/lib/subtitle-review.ts @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto" +import type { + JobArtifactManifest, + SubtitleReviewLaunchSession, + SubtitleReviewReport, + SubtitleReviewRevision, +} from "@/types/job" + +const EMPTY_UPDATED_AT = new Date(0).toISOString() + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value) +} + +function normalizeRevision(raw: unknown): SubtitleReviewRevision | null { + if (!isObjectRecord(raw)) { + return null + } + + if ( + typeof raw.artifactKey !== "string" || + typeof raw.sourceArtifactKey !== "string" || + typeof raw.targetLanguage !== "string" || + typeof raw.revision !== "number" || + !Number.isInteger(raw.revision) || + typeof raw.baseFingerprint !== "string" || + typeof raw.contentFingerprint !== "string" || + typeof raw.clientSaveId !== "string" || + typeof raw.actorId !== "string" || + typeof raw.createdAt !== "string" + ) { + return null + } + + return { + artifactKey: raw.artifactKey, + sourceArtifactKey: raw.sourceArtifactKey, + targetLanguage: raw.targetLanguage, + revision: raw.revision, + baseFingerprint: raw.baseFingerprint, + contentFingerprint: raw.contentFingerprint, + clientSaveId: raw.clientSaveId, + actorId: raw.actorId, + createdAt: raw.createdAt, + } +} + +function normalizeLaunchSession( + raw: unknown, +): SubtitleReviewLaunchSession | null { + if (!isObjectRecord(raw)) { + return null + } + + if ( + typeof raw.nonceHash !== "string" || + typeof raw.sourceArtifactKey !== "string" || + typeof raw.targetLanguage !== "string" || + typeof raw.baseArtifactKey !== "string" || + typeof raw.baseFingerprint !== "string" || + typeof raw.actorId !== "string" || + typeof raw.createdAt !== "string" || + typeof raw.expiresAt !== "string" + ) { + return null + } + + return { + nonceHash: raw.nonceHash, + sourceArtifactKey: raw.sourceArtifactKey, + targetLanguage: raw.targetLanguage, + baseArtifactKey: raw.baseArtifactKey, + baseFingerprint: raw.baseFingerprint, + actorId: raw.actorId, + createdAt: raw.createdAt, + expiresAt: raw.expiresAt, + consumedAt: typeof raw.consumedAt === "string" ? raw.consumedAt : undefined, + } +} + +export function buildReviewedSubtitleArtifactKey( + targetLanguage: string, + revision: number, +): string { + return `subtitles-${targetLanguage}-reviewed-r${String(revision).padStart( + 4, + "0", + )}` +} + +export function isReviewedSubtitleArtifactKey(logicalKey: string): boolean { + return /^subtitles-.+-reviewed-r\d{4}$/.test(logicalKey) +} + +export function fingerprintSubtitleVtt(vtt: string): string { + return createHash("sha256").update(vtt, "utf8").digest("hex") +} + +export function getSubtitleReviewReport( + artifacts: JobArtifactManifest, +): SubtitleReviewReport { + const raw = artifacts.subtitleReviews + if (!raw || raw.kind !== "metadata" || !isObjectRecord(raw.data)) { + return { + revisions: [], + launchSessions: [], + updatedAt: EMPTY_UPDATED_AT, + } + } + + const revisions = Array.isArray(raw.data.revisions) + ? raw.data.revisions + .map(normalizeRevision) + .filter((entry): entry is SubtitleReviewRevision => entry != null) + : [] + const launchSessions = Array.isArray(raw.data.launchSessions) + ? raw.data.launchSessions + .map(normalizeLaunchSession) + .filter((entry): entry is SubtitleReviewLaunchSession => entry != null) + : [] + + return { + revisions, + launchSessions, + updatedAt: + typeof raw.data.updatedAt === "string" + ? raw.data.updatedAt + : EMPTY_UPDATED_AT, + } +} + +export function setSubtitleReviewReport( + artifacts: JobArtifactManifest, + report: SubtitleReviewReport, +): JobArtifactManifest { + return { + ...artifacts, + subtitleReviews: { + kind: "metadata", + data: report as unknown as Record, + }, + } +} + +export function getLatestSubtitleReviewRevision( + artifacts: JobArtifactManifest, + sourceArtifactKey: string, +): SubtitleReviewRevision | undefined { + return getSubtitleReviewReport(artifacts) + .revisions.filter( + (revision) => revision.sourceArtifactKey === sourceArtifactKey, + ) + .sort((left, right) => right.revision - left.revision)[0] +} + +export function findExistingSubtitleReviewRevision( + report: SubtitleReviewReport, + input: { + sourceArtifactKey: string + clientSaveId: string + contentFingerprint: string + }, +): SubtitleReviewRevision | undefined { + return report.revisions.find( + (revision) => + revision.sourceArtifactKey === input.sourceArtifactKey && + (revision.clientSaveId === input.clientSaveId || + revision.contentFingerprint === input.contentFingerprint), + ) +} diff --git a/apps/manager/src/services/subtitleReview.test.ts b/apps/manager/src/services/subtitleReview.test.ts new file mode 100644 index 000000000..6237e2637 --- /dev/null +++ b/apps/manager/src/services/subtitleReview.test.ts @@ -0,0 +1,489 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import type { JobRecord } from "@/types/job" + +const { getJobMock, readArtifactMock, updateJobMock, writeArtifactMock } = + vi.hoisted(() => ({ + getJobMock: vi.fn(), + readArtifactMock: vi.fn(), + updateJobMock: vi.fn(), + writeArtifactMock: vi.fn(), + })) + +vi.mock("@/lib/state", () => ({ + getJob: getJobMock, + updateJob: updateJobMock, +})) + +vi.mock("@/services/storage", () => ({ + readArtifact: readArtifactMock, + writeArtifact: writeArtifactMock, +})) + +function buildJob(overrides: Partial = {}): JobRecord { + return { + id: "job-1", + muxAssetId: "asset-1", + muxPlaybackId: "playback-1", + languages: ["fr"], + options: {}, + status: "completed", + retries: 0, + createdAt: "2026-04-12T11:00:00.000Z", + updatedAt: "2026-04-12T11:00:00.000Z", + artifacts: { + "subtitles-fr": { kind: "downloadable" }, + }, + steps: [], + errors: [], + ...overrides, + } +} + +const sampleVtt = `WEBVTT + +00:00:00.000 --> 00:00:01.000 +Bonjour +` + +const reviewedVtt = `WEBVTT + +00:00:00.000 --> 00:00:01.000 +Salut +` + +describe("subtitle review service", () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.useRealTimers() + vi.resetModules() + vi.clearAllMocks() + }) + + it("creates a launch session without persisting the raw launch code", async () => { + vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test") + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + getJobMock.mockResolvedValue(buildJob()) + readArtifactMock.mockResolvedValue(Buffer.from(sampleVtt)) + updateJobMock.mockResolvedValue(buildJob()) + + const { createSubtitleReviewSession } = + await import("@/services/subtitleReview") + + const result = await createSubtitleReviewSession({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + actorId: "user-1", + }) + + const url = new URL(result.editorUrl) + const launchCode = url.searchParams.get("launch") + expect(url.origin).toBe("https://subtitles.forge.test") + expect(url.searchParams.get("jobId")).toBe("job-1") + expect(launchCode).toBeTruthy() + expect(result.editorUrl).not.toContain("token") + expect(JSON.stringify(updateJobMock.mock.calls[0]?.[1])).not.toContain( + launchCode, + ) + expect(updateJobMock.mock.calls[0]?.[1]).toEqual({ + artifacts: expect.objectContaining({ + subtitleReviews: { + kind: "metadata", + data: expect.objectContaining({ + launchSessions: [ + expect.objectContaining({ + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + actorId: "user-1", + }), + ], + }), + }, + }), + }) + }) + + it("exchanges a stored launch code once and returns an edit token", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { hashSubtitleReviewLaunchCode, verifySubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const job = buildJob({ + artifacts: { + "subtitles-fr": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: { + revisions: [], + launchSessions: [ + { + nonceHash: hashSubtitleReviewLaunchCode("launch-code"), + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + actorId: "user-1", + createdAt: "2026-04-12T11:59:00.000Z", + expiresAt: "2026-04-12T12:05:00.000Z", + }, + ], + updatedAt: "2026-04-12T11:59:00.000Z", + }, + }, + }, + }) + getJobMock.mockResolvedValue(job) + updateJobMock.mockResolvedValue(job) + + const { exchangeSubtitleReviewLaunchCode } = + await import("@/services/subtitleReview") + + const result = await exchangeSubtitleReviewLaunchCode({ + jobId: "job-1", + launchCode: "launch-code", + }) + + expect(result.ok).toBe(true) + if (result.ok) { + await expect( + verifySubtitleReviewToken(result.editToken), + ).resolves.toEqual( + expect.objectContaining({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + }), + ) + } + expect(updateJobMock.mock.calls[0]?.[1]).toEqual({ + artifacts: expect.objectContaining({ + subtitleReviews: expect.objectContaining({ + kind: "metadata", + data: expect.objectContaining({ + launchSessions: [ + expect.objectContaining({ + consumedAt: "2026-04-12T12:00:00.000Z", + }), + ], + }), + }), + }), + }) + }) + + it("rate-limits repeated launch-code exchange attempts per job", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + getJobMock.mockResolvedValue( + buildJob({ + id: "job-rate-limited", + artifacts: { + "subtitles-fr": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: { + revisions: [], + launchSessions: [], + updatedAt: "2026-04-12T11:59:00.000Z", + }, + }, + }, + }), + ) + + const { exchangeSubtitleReviewLaunchCode } = + await import("@/services/subtitleReview") + + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect( + exchangeSubtitleReviewLaunchCode({ + jobId: "job-rate-limited", + launchCode: `bad-code-${attempt}`, + }), + ).resolves.toEqual({ ok: false, reason: "invalid_launch" }) + } + + await expect( + exchangeSubtitleReviewLaunchCode({ + jobId: "job-rate-limited", + launchCode: "one-too-many", + }), + ).resolves.toEqual({ ok: false, reason: "rate_limited" }) + }) + + it("bootstraps the editor with the token base VTT and playback id", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { signSubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const editToken = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + getJobMock.mockResolvedValue(buildJob()) + readArtifactMock.mockResolvedValue(Buffer.from(sampleVtt)) + + const { bootstrapSubtitleReviewSession } = + await import("@/services/subtitleReview") + + await expect( + bootstrapSubtitleReviewSession({ jobId: "job-1", editToken }), + ).resolves.toEqual({ + ok: true, + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: "base-fingerprint", + vtt: sampleVtt, + media: { + muxPlaybackId: "playback-1", + muxAssetId: "asset-1", + }, + returnUrl: "/dashboard/jobs/job-1", + }) + }) + + it("bootstraps with the latest reviewed revision when one exists", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { fingerprintSubtitleVtt } = await import("@/lib/subtitle-review") + const { signSubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const baseFingerprint = fingerprintSubtitleVtt(sampleVtt) + const reviewedFingerprint = fingerprintSubtitleVtt(reviewedVtt) + const editToken = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint, + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + getJobMock.mockResolvedValue( + buildJob({ + artifacts: { + "subtitles-fr": { kind: "downloadable" }, + "subtitles-fr-reviewed-r0001": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: { + revisions: [ + { + artifactKey: "subtitles-fr-reviewed-r0001", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 1, + baseFingerprint, + contentFingerprint: reviewedFingerprint, + clientSaveId: "save-1", + actorId: "user-2", + createdAt: "2026-04-12T12:05:00.000Z", + }, + ], + launchSessions: [], + updatedAt: "2026-04-12T12:05:00.000Z", + }, + }, + }, + }), + ) + readArtifactMock.mockImplementation( + (_assetId: string, artifactType: string) => + Promise.resolve( + Buffer.from( + artifactType === "subtitles-fr-reviewed-r0001" + ? reviewedVtt + : sampleVtt, + ), + ), + ) + + const { bootstrapSubtitleReviewSession } = + await import("@/services/subtitleReview") + + await expect( + bootstrapSubtitleReviewSession({ jobId: "job-1", editToken }), + ).resolves.toEqual({ + ok: true, + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr-reviewed-r0001", + baseFingerprint: reviewedFingerprint, + vtt: reviewedVtt, + media: { + muxPlaybackId: "playback-1", + muxAssetId: "asset-1", + }, + returnUrl: "/dashboard/jobs/job-1", + }) + expect(readArtifactMock).toHaveBeenCalledWith( + "asset-1", + "subtitles-fr-reviewed-r0001", + "vtt", + ) + }) + + it("saves a reviewed VTT as a new downloadable revision artifact", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { fingerprintSubtitleVtt } = await import("@/lib/subtitle-review") + const { signSubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const baseFingerprint = fingerprintSubtitleVtt(sampleVtt) + const editToken = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint, + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + getJobMock.mockResolvedValue(buildJob()) + writeArtifactMock.mockResolvedValue( + "asset-1/subtitles-fr-reviewed-r0001.vtt", + ) + updateJobMock.mockResolvedValue(buildJob()) + + const { saveSubtitleReviewRevision } = + await import("@/services/subtitleReview") + + const result = await saveSubtitleReviewRevision({ + jobId: "job-1", + editToken, + baseArtifactFingerprint: baseFingerprint, + vtt: sampleVtt.replace("Bonjour", "Salut"), + clientSaveId: "save-1", + }) + + expect(result).toEqual({ + ok: true, + status: "saved", + jobId: "job-1", + artifactKey: "subtitles-fr-reviewed-r0001", + reviewedArtifactKey: "subtitles-fr-reviewed-r0001", + revision: 1, + contentFingerprint: fingerprintSubtitleVtt( + sampleVtt.replace("Bonjour", "Salut"), + ), + baseArtifactFingerprint: baseFingerprint, + savedAt: "2026-04-12T12:00:00.000Z", + }) + expect(writeArtifactMock).toHaveBeenCalledWith({ + assetId: "asset-1", + artifactType: "subtitles-fr-reviewed-r0001", + ext: "vtt", + body: sampleVtt.replace("Bonjour", "Salut"), + contentType: "text/vtt; charset=utf-8", + }) + expect(updateJobMock.mock.calls[0]?.[1]).toEqual({ + artifacts: expect.objectContaining({ + "subtitles-fr-reviewed-r0001": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: expect.objectContaining({ + revisions: [ + expect.objectContaining({ + artifactKey: "subtitles-fr-reviewed-r0001", + sourceArtifactKey: "subtitles-fr", + revision: 1, + clientSaveId: "save-1", + }), + ], + }), + }, + }), + }) + }) + + it("does not rewrite storage for an idempotent duplicate save", async () => { + vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z")) + + const { fingerprintSubtitleVtt } = await import("@/lib/subtitle-review") + const { signSubtitleReviewToken } = + await import("@/lib/subtitle-review-session") + const contentFingerprint = fingerprintSubtitleVtt(sampleVtt) + const editToken = await signSubtitleReviewToken({ + jobId: "job-1", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + baseArtifactKey: "subtitles-fr", + baseFingerprint: contentFingerprint, + actorId: "user-1", + expiresAt: "2026-04-12T12:15:00.000Z", + }) + getJobMock.mockResolvedValue( + buildJob({ + artifacts: { + "subtitles-fr": { kind: "downloadable" }, + "subtitles-fr-reviewed-r0001": { kind: "downloadable" }, + subtitleReviews: { + kind: "metadata", + data: { + revisions: [ + { + artifactKey: "subtitles-fr-reviewed-r0001", + sourceArtifactKey: "subtitles-fr", + targetLanguage: "fr", + revision: 1, + baseFingerprint: contentFingerprint, + contentFingerprint, + clientSaveId: "save-1", + actorId: "user-1", + createdAt: "2026-04-12T11:59:00.000Z", + }, + ], + launchSessions: [], + updatedAt: "2026-04-12T11:59:00.000Z", + }, + }, + }, + }), + ) + + const { saveSubtitleReviewRevision } = + await import("@/services/subtitleReview") + + await expect( + saveSubtitleReviewRevision({ + jobId: "job-1", + editToken, + baseArtifactFingerprint: contentFingerprint, + vtt: sampleVtt, + clientSaveId: "save-1", + }), + ).resolves.toEqual({ + ok: true, + status: "duplicate", + jobId: "job-1", + artifactKey: "subtitles-fr-reviewed-r0001", + reviewedArtifactKey: "subtitles-fr-reviewed-r0001", + revision: 1, + contentFingerprint, + baseArtifactFingerprint: contentFingerprint, + savedAt: "2026-04-12T11:59:00.000Z", + }) + expect(writeArtifactMock).not.toHaveBeenCalled() + expect(updateJobMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/manager/src/services/subtitleReview.ts b/apps/manager/src/services/subtitleReview.ts new file mode 100644 index 000000000..c20dd4bb4 --- /dev/null +++ b/apps/manager/src/services/subtitleReview.ts @@ -0,0 +1,485 @@ +import { + buildReviewedSubtitleArtifactKey, + findExistingSubtitleReviewRevision, + fingerprintSubtitleVtt, + getLatestSubtitleReviewRevision, + getSubtitleReviewReport, + isReviewedSubtitleArtifactKey, + setSubtitleReviewReport, +} from "@/lib/subtitle-review" +import { + buildSubtitleEditorLaunchUrl, + createSubtitleReviewLaunchCode, + hashSubtitleReviewLaunchCode, + signSubtitleReviewToken, + verifySubtitleReviewToken, +} from "@/lib/subtitle-review-session" +import { getJob, updateJob } from "@/lib/state" +import { parseVTT } from "@/lib/vtt" +import { readArtifact, writeArtifact } from "@/services/storage" +import type { + JobArtifactManifest, + JobRecord, + SubtitleReviewLaunchSession, + SubtitleReviewReport, + SubtitleReviewRevision, +} from "@/types/job" + +const LAUNCH_TTL_MS = 5 * 60 * 1000 +const EDIT_TOKEN_TTL_MS = 30 * 60 * 1000 +const EXCHANGE_RATE_LIMIT_WINDOW_MS = 60 * 1000 +const EXCHANGE_RATE_LIMIT_MAX_ATTEMPTS = 10 +const exchangeAttemptsByJob = new Map< + string, + { + count: number + resetAt: number + } +>() + +export type SubtitleReviewFailureReason = + | "job_not_found" + | "artifact_not_found" + | "invalid_artifact" + | "invalid_launch" + | "invalid_token" + | "missing_playback" + | "invalid_vtt" + | "rate_limited" + | "stale_base" + | "persist_failed" + +export type SubtitleReviewFailure = { + ok: false + reason: SubtitleReviewFailureReason +} + +export type SubtitleReviewExchangeResult = + | { + ok: true + editToken: string + expiresAt: string + sourceArtifactKey: string + targetLanguage: string + baseArtifactKey: string + baseFingerprint: string + } + | SubtitleReviewFailure + +export type SubtitleReviewBootstrapResult = + | { + ok: true + jobId: string + sourceArtifactKey: string + targetLanguage: string + baseArtifactKey: string + baseFingerprint: string + vtt: string + media: { + muxPlaybackId: string + muxAssetId: string + } + returnUrl: string + } + | SubtitleReviewFailure + +export type SubtitleReviewSaveResult = + | { + ok: true + status: "saved" | "duplicate" + jobId: string + artifactKey: string + reviewedArtifactKey: string + revision: number + contentFingerprint: string + baseArtifactFingerprint: string + savedAt: string + } + | (SubtitleReviewFailure & { + latestArtifactKey?: string + }) + +type CreateSubtitleReviewSessionInput = { + jobId: string + sourceArtifactKey: string + actorId: string +} + +type CreateSubtitleReviewSessionResult = { + editorUrl: string + sourceArtifactKey: string + targetLanguage: string + baseArtifactKey: string + baseFingerprint: string + expiresAt: string +} + +function targetLanguageFromSubtitleArtifact(sourceArtifactKey: string): string { + if ( + !sourceArtifactKey.startsWith("subtitles-") || + isReviewedSubtitleArtifactKey(sourceArtifactKey) + ) { + throw new Error("invalid_artifact") + } + + return sourceArtifactKey.slice("subtitles-".length) +} + +function textFromArtifact(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("utf8") +} + +function isUsableReviewVtt(vtt: string): boolean { + return vtt.trimStart().startsWith("WEBVTT") && parseVTT(vtt).length > 0 +} + +async function readSubtitleArtifact( + job: JobRecord, + artifactKey: string, +): Promise { + if (job.artifacts[artifactKey]?.kind !== "downloadable") { + return null + } + + try { + return textFromArtifact( + await readArtifact(job.muxAssetId, artifactKey, "vtt"), + ) + } catch { + return null + } +} + +function pruneLaunchSessions( + sessions: SubtitleReviewLaunchSession[], + now: Date, +): SubtitleReviewLaunchSession[] { + return sessions.filter( + (session) => Date.parse(session.expiresAt) > now.getTime(), + ) +} + +function withUpdatedReport( + artifacts: JobArtifactManifest, + report: SubtitleReviewReport, +): JobArtifactManifest { + return setSubtitleReviewReport(artifacts, { + ...report, + launchSessions: report.launchSessions ?? [], + }) +} + +async function persistArtifacts( + jobId: string, + artifacts: JobArtifactManifest, +): Promise { + return (await updateJob(jobId, { artifacts })) != null +} + +function isExchangeRateLimited(jobId: string, nowMs: number): boolean { + const current = exchangeAttemptsByJob.get(jobId) + if (!current || current.resetAt <= nowMs) { + exchangeAttemptsByJob.set(jobId, { + count: 1, + resetAt: nowMs + EXCHANGE_RATE_LIMIT_WINDOW_MS, + }) + return false + } + + current.count += 1 + return current.count > EXCHANGE_RATE_LIMIT_MAX_ATTEMPTS +} + +export async function createSubtitleReviewSession( + input: CreateSubtitleReviewSessionInput, +): Promise { + const job = await getJob(input.jobId) + if (!job) { + throw new Error("job_not_found") + } + + const targetLanguage = targetLanguageFromSubtitleArtifact( + input.sourceArtifactKey, + ) + const report = getSubtitleReviewReport(job.artifacts) + const latestRevision = getLatestSubtitleReviewRevision( + job.artifacts, + input.sourceArtifactKey, + ) + const baseArtifactKey = latestRevision?.artifactKey ?? input.sourceArtifactKey + const baseVtt = await readSubtitleArtifact(job, baseArtifactKey) + if (!baseVtt) { + throw new Error("artifact_not_found") + } + + const now = new Date() + const expiresAt = new Date(now.getTime() + LAUNCH_TTL_MS).toISOString() + const launchCode = createSubtitleReviewLaunchCode() + const launchSession: SubtitleReviewLaunchSession = { + nonceHash: hashSubtitleReviewLaunchCode(launchCode), + sourceArtifactKey: input.sourceArtifactKey, + targetLanguage, + baseArtifactKey, + baseFingerprint: + latestRevision?.contentFingerprint ?? fingerprintSubtitleVtt(baseVtt), + actorId: input.actorId, + createdAt: now.toISOString(), + expiresAt, + } + + const nextReport: SubtitleReviewReport = { + ...report, + launchSessions: [ + ...pruneLaunchSessions(report.launchSessions ?? [], now), + launchSession, + ], + updatedAt: now.toISOString(), + } + + const persisted = await persistArtifacts( + input.jobId, + withUpdatedReport(job.artifacts, nextReport), + ) + if (!persisted) { + throw new Error("persist_failed") + } + + return { + editorUrl: buildSubtitleEditorLaunchUrl({ + jobId: input.jobId, + launchCode, + }), + sourceArtifactKey: input.sourceArtifactKey, + targetLanguage, + baseArtifactKey, + baseFingerprint: launchSession.baseFingerprint, + expiresAt, + } +} + +export async function exchangeSubtitleReviewLaunchCode(input: { + jobId: string + launchCode: string +}): Promise { + const job = await getJob(input.jobId) + if (!job) { + return { ok: false, reason: "job_not_found" } + } + + const report = getSubtitleReviewReport(job.artifacts) + const nonceHash = hashSubtitleReviewLaunchCode(input.launchCode) + const now = new Date() + if (isExchangeRateLimited(input.jobId, now.getTime())) { + return { ok: false, reason: "rate_limited" } + } + const session = (report.launchSessions ?? []).find( + (candidate) => candidate.nonceHash === nonceHash, + ) + if ( + !session || + session.consumedAt || + Date.parse(session.expiresAt) <= now.getTime() + ) { + return { ok: false, reason: "invalid_launch" } + } + + const consumedAt = now.toISOString() + const nextReport: SubtitleReviewReport = { + ...report, + launchSessions: (report.launchSessions ?? []).map((candidate) => + candidate.nonceHash === nonceHash + ? { ...candidate, consumedAt } + : candidate, + ), + updatedAt: consumedAt, + } + + const persisted = await persistArtifacts( + input.jobId, + withUpdatedReport(job.artifacts, nextReport), + ) + if (!persisted) { + return { ok: false, reason: "persist_failed" } + } + + const expiresAt = new Date(now.getTime() + EDIT_TOKEN_TTL_MS).toISOString() + return { + ok: true, + editToken: await signSubtitleReviewToken({ + jobId: input.jobId, + sourceArtifactKey: session.sourceArtifactKey, + targetLanguage: session.targetLanguage, + baseArtifactKey: session.baseArtifactKey, + baseFingerprint: session.baseFingerprint, + actorId: session.actorId, + expiresAt, + }), + expiresAt, + sourceArtifactKey: session.sourceArtifactKey, + targetLanguage: session.targetLanguage, + baseArtifactKey: session.baseArtifactKey, + baseFingerprint: session.baseFingerprint, + } +} + +export async function bootstrapSubtitleReviewSession(input: { + jobId: string + editToken: string +}): Promise { + const token = await verifySubtitleReviewToken(input.editToken) + if (!token || token.jobId !== input.jobId) { + return { ok: false, reason: "invalid_token" } + } + + const job = await getJob(input.jobId) + if (!job) { + return { ok: false, reason: "job_not_found" } + } + + if (!job.muxPlaybackId) { + return { ok: false, reason: "missing_playback" } + } + + const latestRevision = getLatestSubtitleReviewRevision( + job.artifacts, + token.sourceArtifactKey, + ) + const baseArtifactKey = latestRevision?.artifactKey ?? token.baseArtifactKey + const baseFingerprint = + latestRevision?.contentFingerprint ?? token.baseFingerprint + const vtt = await readSubtitleArtifact(job, baseArtifactKey) + if (!vtt) { + return { ok: false, reason: "artifact_not_found" } + } + + return { + ok: true, + jobId: input.jobId, + sourceArtifactKey: token.sourceArtifactKey, + targetLanguage: token.targetLanguage, + baseArtifactKey, + baseFingerprint, + vtt, + media: { + muxPlaybackId: job.muxPlaybackId, + muxAssetId: job.muxAssetId, + }, + returnUrl: `/dashboard/jobs/${encodeURIComponent(input.jobId)}`, + } +} + +export async function saveSubtitleReviewRevision(input: { + jobId: string + editToken: string + vtt: string + clientSaveId: string + baseArtifactFingerprint?: string +}): Promise { + const token = await verifySubtitleReviewToken(input.editToken) + if (!token || token.jobId !== input.jobId) { + return { ok: false, reason: "invalid_token" } + } + + if (!isUsableReviewVtt(input.vtt)) { + return { ok: false, reason: "invalid_vtt" } + } + + const job = await getJob(input.jobId) + if (!job) { + return { ok: false, reason: "job_not_found" } + } + + const report = getSubtitleReviewReport(job.artifacts) + const contentFingerprint = fingerprintSubtitleVtt(input.vtt) + const baseArtifactFingerprint = + input.baseArtifactFingerprint ?? token.baseFingerprint + const existingRevision = findExistingSubtitleReviewRevision(report, { + sourceArtifactKey: token.sourceArtifactKey, + clientSaveId: input.clientSaveId, + contentFingerprint, + }) + if (existingRevision) { + return { + ok: true, + status: "duplicate", + jobId: input.jobId, + artifactKey: existingRevision.artifactKey, + reviewedArtifactKey: existingRevision.artifactKey, + revision: existingRevision.revision, + contentFingerprint: existingRevision.contentFingerprint, + baseArtifactFingerprint: existingRevision.baseFingerprint, + savedAt: existingRevision.createdAt, + } + } + + const latestRevision = getLatestSubtitleReviewRevision( + job.artifacts, + token.sourceArtifactKey, + ) + if ( + latestRevision && + latestRevision.contentFingerprint !== baseArtifactFingerprint + ) { + return { + ok: false, + reason: "stale_base", + latestArtifactKey: latestRevision.artifactKey, + } + } + + const revision = + report.revisions + .filter((entry) => entry.sourceArtifactKey === token.sourceArtifactKey) + .reduce((highest, entry) => Math.max(highest, entry.revision), 0) + 1 + const artifactKey = buildReviewedSubtitleArtifactKey( + token.targetLanguage, + revision, + ) + + await writeArtifact({ + assetId: job.muxAssetId, + artifactType: artifactKey, + ext: "vtt", + body: input.vtt, + contentType: "text/vtt; charset=utf-8", + }) + + const now = new Date().toISOString() + const nextRevision: SubtitleReviewRevision = { + artifactKey, + sourceArtifactKey: token.sourceArtifactKey, + targetLanguage: token.targetLanguage, + revision, + baseFingerprint: baseArtifactFingerprint, + contentFingerprint, + clientSaveId: input.clientSaveId, + actorId: token.actorId, + createdAt: now, + } + const nextReport: SubtitleReviewReport = { + ...report, + revisions: [...report.revisions, nextRevision], + launchSessions: report.launchSessions ?? [], + updatedAt: now, + } + + const persisted = await persistArtifacts(input.jobId, { + ...withUpdatedReport(job.artifacts, nextReport), + [artifactKey]: { kind: "downloadable" }, + }) + if (!persisted) { + return { ok: false, reason: "persist_failed" } + } + + return { + ok: true, + status: "saved", + jobId: input.jobId, + artifactKey, + reviewedArtifactKey: artifactKey, + revision, + contentFingerprint, + baseArtifactFingerprint, + savedAt: now, + } +} diff --git a/apps/manager/src/types/job.ts b/apps/manager/src/types/job.ts index d4e574332..168ff5243 100644 --- a/apps/manager/src/types/job.ts +++ b/apps/manager/src/types/job.ts @@ -137,6 +137,36 @@ export type MuxSyncReport = { updatedAt: string } +export type SubtitleReviewRevision = { + artifactKey: string + sourceArtifactKey: string + targetLanguage: string + revision: number + baseFingerprint: string + contentFingerprint: string + clientSaveId: string + actorId: string + createdAt: string +} + +export type SubtitleReviewLaunchSession = { + nonceHash: string + sourceArtifactKey: string + targetLanguage: string + baseArtifactKey: string + baseFingerprint: string + actorId: string + createdAt: string + expiresAt: string + consumedAt?: string +} + +export type SubtitleReviewReport = { + revisions: SubtitleReviewRevision[] + launchSessions?: SubtitleReviewLaunchSession[] + updatedAt: string +} + export type RequestedTranscriptionProvider = "automatic" | "elevenlabs" | "mux" export type ResolvedTranscriptionProvider = "elevenlabs" | "mux" diff --git a/apps/subtitle-editor/.env.ci b/apps/subtitle-editor/.env.ci new file mode 100644 index 000000000..5c1abf531 --- /dev/null +++ b/apps/subtitle-editor/.env.ci @@ -0,0 +1 @@ +NEXT_PUBLIC_MANAGER_BASE_URL=http://localhost:3002 diff --git a/apps/subtitle-editor/.env.example b/apps/subtitle-editor/.env.example new file mode 100644 index 000000000..5c1abf531 --- /dev/null +++ b/apps/subtitle-editor/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_MANAGER_BASE_URL=http://localhost:3002 diff --git a/apps/subtitle-editor/AGENTS.md b/apps/subtitle-editor/AGENTS.md new file mode 100644 index 000000000..8bdca85dc --- /dev/null +++ b/apps/subtitle-editor/AGENTS.md @@ -0,0 +1,22 @@ +# Apps Subtitle Editor Agent Guide + +Scope: `apps/subtitle-editor/**` only. + +## Role + +This app is the internal Forge subtitle review editor. Keep it small, local to this app, and adapter-first. Do not reach into Manager code or repo-root files. + +## Rules + +- Read runtime config from `src/config/env.ts`; do not read `process.env` directly in app code. +- Keep launch tokens out of local storage and out of editable URLs after exchange. +- Treat Manager as the source of truth for auth, session exchange, bootstrap, and save. +- Keep the editor responsive only down to the documented minimum viewport; below that, show a return-only fallback. +- Use simple, tested helpers for launch parsing, viewport gating, and Manager API calls. + +## Local files + +- `src/config/env.ts` - validated public env +- `src/lib/manager-client.ts` - Manager API adapter +- `src/lib/launch-envelope.ts` - launch parsing helpers +- `src/components/subtitle-editor-app.tsx` - client editor shell diff --git a/apps/subtitle-editor/CLAUDE.md b/apps/subtitle-editor/CLAUDE.md new file mode 100644 index 000000000..1ac4a3628 --- /dev/null +++ b/apps/subtitle-editor/CLAUDE.md @@ -0,0 +1,27 @@ +# apps/subtitle-editor — Subtitle Review Editor + +## What this app does + +Hosts the Forge subtitle review editor. It exchanges a short-lived launch code for an in-memory edit token, loads subtitle VTT from Manager, lets an operator edit the text in a textarea, and saves reviewed subtitles back to Manager. + +## Stack + +- Next.js 16 App Router +- React 19 +- `@t3-oss/env-nextjs` for runtime env validation +- Vitest for helper tests + +## Conventions + +- Keep all runtime config in `src/config/env.ts`. +- Use `src/lib/manager-client.ts` for Manager calls. Keep fetch details centralized. +- Keep launch tokens in memory only. +- Preserve draft text through recoverable bootstrap/save errors. +- Keep `Return to Manager` always available once a launch has been resolved. +- Block editing below the documented minimum viewport with a clear fallback. + +## Environment variables + +| Variable | Description | +| ------------------------------ | ---------------------------------------- | +| `NEXT_PUBLIC_MANAGER_BASE_URL` | Public base URL of the Forge Manager app | diff --git a/apps/subtitle-editor/eslint.config.mjs b/apps/subtitle-editor/eslint.config.mjs new file mode 100644 index 000000000..23c8a9b3a --- /dev/null +++ b/apps/subtitle-editor/eslint.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig, globalIgnores } from "eslint/config" +import commonConfig from "../../eslint.config.mjs" +import nextVitals from "eslint-config-next/core-web-vitals" + +export default defineConfig([ + ...commonConfig, + ...nextVitals, + globalIgnores([".next/**", "out/**", "next-env.d.ts"]), +]) diff --git a/apps/subtitle-editor/next.config.ts b/apps/subtitle-editor/next.config.ts new file mode 100644 index 000000000..76acabdbb --- /dev/null +++ b/apps/subtitle-editor/next.config.ts @@ -0,0 +1,10 @@ +import path from "node:path" +import type { NextConfig } from "next" + +const nextConfig: NextConfig = { + output: "standalone", + typedRoutes: true, + outputFileTracingRoot: path.join(__dirname, "../.."), +} + +export default nextConfig diff --git a/apps/subtitle-editor/package.json b/apps/subtitle-editor/package.json new file mode 100644 index 000000000..56b2119e5 --- /dev/null +++ b/apps/subtitle-editor/package.json @@ -0,0 +1,28 @@ +{ + "name": "@forge/subtitle-editor", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "./node_modules/.bin/next dev --port 3003", + "build": "./node_modules/.bin/next build", + "start": "./node_modules/.bin/next start", + "lint": "./node_modules/.bin/eslint .", + "typecheck": "./node_modules/.bin/tsc --noEmit", + "test": "./node_modules/.bin/vitest run" + }, + "dependencies": { + "@t3-oss/env-nextjs": "^0.13.10", + "next": "^16.1.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "eslint": "^9.0.0", + "eslint-config-next": "^16.1.6", + "typescript": "^5", + "vitest": "2.1.9" + } +} diff --git a/apps/subtitle-editor/public/.gitkeep b/apps/subtitle-editor/public/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/subtitle-editor/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/subtitle-editor/railway.toml b/apps/subtitle-editor/railway.toml new file mode 100644 index 000000000..a72f44f1d --- /dev/null +++ b/apps/subtitle-editor/railway.toml @@ -0,0 +1,10 @@ +[build] +builder = "NIXPACKS" +buildCommand = "pnpm install --frozen-lockfile && pnpm --filter @forge/subtitle-editor... build && cp -r apps/subtitle-editor/.next/static apps/subtitle-editor/.next/standalone/apps/subtitle-editor/.next/static && cp -r apps/subtitle-editor/public apps/subtitle-editor/.next/standalone/apps/subtitle-editor/public" + +[deploy] +startCommand = "HOSTNAME=0.0.0.0 node apps/subtitle-editor/.next/standalone/apps/subtitle-editor/server.js" +healthcheckPath = "/api/health" +healthcheckTimeout = 60 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/apps/subtitle-editor/src/app/api/health/route.ts b/apps/subtitle-editor/src/app/api/health/route.ts new file mode 100644 index 000000000..d6c7d49e1 --- /dev/null +++ b/apps/subtitle-editor/src/app/api/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ status: "ok" }, { status: 200 }) +} diff --git a/apps/subtitle-editor/src/app/edit/page.tsx b/apps/subtitle-editor/src/app/edit/page.tsx new file mode 100644 index 000000000..03a82e5f5 --- /dev/null +++ b/apps/subtitle-editor/src/app/edit/page.tsx @@ -0,0 +1 @@ +export { default } from "../page" diff --git a/apps/subtitle-editor/src/app/globals.css b/apps/subtitle-editor/src/app/globals.css new file mode 100644 index 000000000..ff81ff7ed --- /dev/null +++ b/apps/subtitle-editor/src/app/globals.css @@ -0,0 +1,227 @@ +:root { + color-scheme: light; + --bg: #f7f8f7; + --panel: #ffffff; + --panel-soft: #eef2ef; + --line: #d9ded9; + --text: #171b17; + --muted: #596159; + --accent: #126b4d; + --accent-strong: #0a533a; + --danger: #a61c2d; + --shadow: 0 12px 32px rgba(23, 27, 23, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +body { + min-height: 100vh; +} + +a { + color: inherit; +} + +button, +input, +textarea { + font: inherit; +} + +button { + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + border-radius: 8px; + padding: 0.75rem 1rem; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.editor-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.editor-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--line); + background: rgba(247, 248, 247, 0.92); + backdrop-filter: blur(8px); +} + +.editor-title { + margin: 0; + font-size: 1.125rem; + line-height: 1.3; +} + +.editor-subtitle { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.95rem; +} + +.editor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; +} + +.editor-main { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.85fr); + gap: 1rem; + padding: 1rem 1.25rem 1.25rem; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + min-width: 0; +} + +.panel-body { + padding: 1rem; +} + +.preview-frame { + width: 100%; + aspect-ratio: 16 / 9; + border: 1px solid var(--line); + border-radius: 8px; + background: #0f1410; +} + +.editor-textarea { + width: 100%; + min-height: 28rem; + resize: vertical; + padding: 0.9rem 1rem; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--text); + line-height: 1.55; +} + +.status { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.status-error { + color: var(--danger); +} + +.stack { + display: grid; + gap: 0.9rem; +} + +.helper { + color: var(--muted); + font-size: 0.92rem; + line-height: 1.5; +} + +.footer-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; +} + +.label { + display: block; + margin: 0 0 0.4rem; + font-size: 0.9rem; + color: var(--muted); +} + +.notice { + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel-soft); + padding: 0.85rem 1rem; +} + +.notice-error { + border-color: rgba(166, 28, 45, 0.28); + background: rgba(166, 28, 45, 0.08); +} + +.blocked-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.blocked-card { + max-width: 34rem; + width: 100%; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 1.25rem; +} + +.blocked-card h1 { + margin: 0 0 0.5rem; + font-size: 1.35rem; +} + +.blocked-card p { + margin: 0.5rem 0 0; + color: var(--muted); + line-height: 1.5; +} + +@media (max-width: 1023px) { + .editor-main { + display: none; + } +} + +@media (max-width: 720px) { + .editor-header { + flex-direction: column; + } + + .editor-actions { + justify-content: flex-start; + } +} diff --git a/apps/subtitle-editor/src/app/layout.tsx b/apps/subtitle-editor/src/app/layout.tsx new file mode 100644 index 000000000..af7ce911e --- /dev/null +++ b/apps/subtitle-editor/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next" +import "./globals.css" + +export const metadata: Metadata = { + title: "Subtitle Review Editor", + description: "Forge-hosted subtitle review editor", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/apps/subtitle-editor/src/app/page.tsx b/apps/subtitle-editor/src/app/page.tsx new file mode 100644 index 000000000..7bddd62fd --- /dev/null +++ b/apps/subtitle-editor/src/app/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next" +import { SubtitleEditorApp } from "@/components/subtitle-editor-app" +import { decodeLaunchEnvelope } from "@/lib/editor-helpers" + +export const metadata: Metadata = { + title: "Subtitle Review Editor", +} + +type PageProps = { + searchParams?: + | Promise> + | Record +} + +function getSingleSearchParam( + value: string | string[] | undefined, +): string | null { + if (typeof value === "string") { + return value + } + + if (Array.isArray(value)) { + return value[0] ?? null + } + + return null +} + +export default async function Page({ searchParams }: PageProps) { + const resolvedSearchParams = await searchParams + const launch = getSingleSearchParam(resolvedSearchParams?.launch) + const jobId = getSingleSearchParam(resolvedSearchParams?.jobId) + const parsedLaunch = launch + ? (decodeLaunchEnvelope(launch) ?? + (jobId ? { jobId, launchCode: launch } : null)) + : null + + return ( + + ) +} diff --git a/apps/subtitle-editor/src/components/subtitle-editor-app.tsx b/apps/subtitle-editor/src/components/subtitle-editor-app.tsx new file mode 100644 index 000000000..854ee7c95 --- /dev/null +++ b/apps/subtitle-editor/src/components/subtitle-editor-app.tsx @@ -0,0 +1,441 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { env } from "@/config/env" +import { + MIN_SUPPORTED_VIEWPORT_WIDTH, + buildManagerJobUrl, + decodeLaunchEnvelope, + isSupportedViewportWidth, + type LaunchEnvelope, +} from "@/lib/editor-helpers" +import { + ManagerClientError, + bootstrapReviewSession, + exchangeLaunchCode, + saveReviewedVtt, + type BootstrapReviewSessionResult, +} from "@/lib/manager-client" + +type EditorPhase = + | "loading" + | "blocked" + | "ready" + | "saving" + | "saved" + | "error" + +type Props = { + initialLaunch: string | null + initialLaunchEnvelope: LaunchEnvelope | null +} + +function describeManagerClientError(error: unknown): string { + if (error instanceof ManagerClientError) { + if (error.kind === "validation") { + return error.message || "The reviewed VTT could not be saved." + } + + if (error.kind === "conflict") { + return "This review is out of date. Reload the latest version before saving again." + } + + if (error.kind === "unauthorized" || error.kind === "forbidden") { + return "The review session expired or is no longer allowed. Return to Manager and relaunch." + } + + if (error.kind === "not_found") { + return "The source job or subtitle artifact is no longer available." + } + + return error.message || "The Manager request failed." + } + + if (error instanceof Error) { + return error.message + } + + return "The Manager request failed." +} + +function getReturnUrl(jobId: string | null): string { + if (!jobId) { + return env.NEXT_PUBLIC_MANAGER_BASE_URL + } + + return buildManagerJobUrl(env.NEXT_PUBLIC_MANAGER_BASE_URL, jobId) +} + +export function SubtitleEditorApp({ + initialLaunch, + initialLaunchEnvelope, +}: Props) { + const [phase, setPhase] = useState("loading") + const [viewportWidth, setViewportWidth] = useState(null) + const [notice, setNotice] = useState(null) + const [noticeTone, setNoticeTone] = useState<"info" | "error">("info") + const [draft, setDraft] = useState("") + const [jobId, setJobId] = useState( + initialLaunchEnvelope?.jobId ?? null, + ) + const [editSessionToken, setEditSessionToken] = useState(null) + const [bootstrap, setBootstrap] = + useState(null) + const [baseFingerprint, setBaseFingerprint] = useState(null) + const [isDirty, setIsDirty] = useState(false) + const [isSaved, setIsSaved] = useState(false) + const [currentSaveId, setCurrentSaveId] = useState(null) + const [lastSavedRevision, setLastSavedRevision] = useState( + null, + ) + const [pendingLoadLabel, setPendingLoadLabel] = useState("Waiting for launch") + const textareaRef = useRef(null) + const sessionStartedRef = useRef(false) + + const resolvedLaunch = useMemo(() => { + return ( + initialLaunchEnvelope ?? + (initialLaunch ? decodeLaunchEnvelope(initialLaunch) : null) + ) + }, [initialLaunch, initialLaunchEnvelope]) + const blockedViewport = + viewportWidth !== null && !isSupportedViewportWidth(viewportWidth) + const missingLaunch = viewportWidth !== null && !resolvedLaunch + + useEffect(() => { + if (typeof window === "undefined") { + return + } + + const updateViewport = () => { + setViewportWidth(window.innerWidth) + } + + updateViewport() + window.addEventListener("resize", updateViewport) + return () => window.removeEventListener("resize", updateViewport) + }, []) + + useEffect(() => { + if (viewportWidth === null || sessionStartedRef.current) { + return + } + + if (blockedViewport) { + return + } + + const launchEnvelope = resolvedLaunch + if (!launchEnvelope) { + return + } + const { jobId: launchJobId, launchCode } = launchEnvelope + + let cancelled = false + async function startSession() { + try { + sessionStartedRef.current = true + setPhase("loading") + setPendingLoadLabel("Exchanging launch code") + const exchanged = await exchangeLaunchCode({ + jobId: launchJobId, + launchCode, + }) + + if (cancelled) { + return + } + + setEditSessionToken(exchanged.editSessionToken) + setJobId(launchJobId) + setPendingLoadLabel("Loading subtitles") + const review = await bootstrapReviewSession({ + jobId: launchJobId, + editSessionToken: exchanged.editSessionToken, + }) + + if (cancelled) { + return + } + + setBootstrap(review) + setBaseFingerprint(review.baseArtifactFingerprint) + setDraft(review.vtt) + setIsDirty(false) + setIsSaved(false) + setLastSavedRevision(null) + setNotice(null) + setPhase("ready") + setPendingLoadLabel("Ready") + window.history.replaceState({}, "", window.location.pathname) + if (textareaRef.current) { + textareaRef.current.focus() + } + } catch (error) { + if (cancelled) { + return + } + + setPhase("error") + setNotice(describeManagerClientError(error)) + setNoticeTone("error") + setPendingLoadLabel("Could not load review session") + } + } + + void startSession() + + return () => { + cancelled = true + } + }, [blockedViewport, viewportWidth, resolvedLaunch]) + + async function handleSave() { + if (!editSessionToken || !bootstrap || !jobId || !baseFingerprint) { + return + } + + const saveId = currentSaveId ?? crypto.randomUUID() + setCurrentSaveId(saveId) + setPhase("saving") + setNotice("Saving reviewed subtitles...") + setNoticeTone("info") + + try { + const result = await saveReviewedVtt({ + jobId, + editSessionToken, + vtt: draft, + baseArtifactFingerprint: baseFingerprint, + clientSaveId: saveId, + }) + + setBaseFingerprint(result.contentFingerprint) + setLastSavedRevision(result.revision) + setIsDirty(false) + setIsSaved(true) + setPhase("saved") + setNotice( + `Saved as reviewed revision r${String(result.revision).padStart(4, "0")}.`, + ) + setNoticeTone("info") + } catch (error) { + setPhase("ready") + setNotice(describeManagerClientError(error)) + setNoticeTone("error") + } + } + + async function handleReloadLatest() { + if (!jobId || !editSessionToken) { + return + } + + try { + setPhase("loading") + setNotice("Reloading the latest reviewed subtitles...") + setNoticeTone("info") + const review = await bootstrapReviewSession({ + jobId, + editSessionToken, + }) + setBootstrap(review) + setBaseFingerprint(review.baseArtifactFingerprint) + setDraft(review.vtt) + setIsDirty(false) + setPhase("ready") + setNotice("Loaded the latest reviewed version.") + setNoticeTone("info") + if (textareaRef.current) { + textareaRef.current.focus() + } + } catch (error) { + setPhase("error") + setNotice(describeManagerClientError(error)) + setNoticeTone("error") + } + } + + function handleDraftChange(value: string) { + setDraft(value) + setIsDirty(true) + setIsSaved(false) + setCurrentSaveId(null) + if (phase === "saved" || phase === "error") { + setPhase("ready") + } + if (noticeTone === "error") { + setNotice(null) + } + } + + function handleReturnToManager() { + const target = getReturnUrl(jobId) + const shouldWarn = isDirty && !isSaved + if (shouldWarn && typeof window !== "undefined") { + const confirmed = window.confirm( + "Return to Manager and discard the unsaved draft?", + ) + if (!confirmed) { + return + } + } + + window.location.assign(target) + } + + const returnUrl = getReturnUrl(jobId) + + if (viewportWidth === null) { + return ( +
+
+

Checking editor viewport

+

The subtitle editor is preparing its review layout.

+
+
+ ) + } + + if (blockedViewport) { + return ( +
+
+

Use a wider viewport to review subtitles

+

+ The editor is built for a minimum width of{" "} + {MIN_SUPPORTED_VIEWPORT_WIDTH}px. Return to Manager for now, or + reopen this review on a larger screen. +

+
+ +
+
+
+ ) + } + + if (missingLaunch) { + return ( +
+
+

Could not open the review session

+

The subtitle review session could not be loaded.

+
+ +
+
+
+ ) + } + + return ( +
+
+
+

Subtitle review editor

+

+ {pendingLoadLabel} + {bootstrap?.targetLanguage ? ` • ${bootstrap.targetLanguage}` : ""} + {jobId ? ` • job ${jobId}` : ""} +

+
+
+ + +
+
+ +
+
+
+
+

Media preview

+ {bootstrap?.mediaUrl ? ( +