const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291" export type JobStatus = | "created" | "downloading" | "downloaded" | "splitting" | "frames_extracted" | "transcribing" | "transcribed" | "failed" export interface FrameObject { name: string position?: string color?: string extract_prompt?: string } export interface FrameDescription { scene?: string objects?: FrameObject[] style?: string suggested_prompt?: string transparent_human_assessment?: TransparentHumanFrameScore } export interface GeneratedImage { id: string prompt: string model: string mode: string url: string selected: boolean created_at: number } export interface KeyElement { id: string name_zh: string name_en: string position?: string source: "auto" | "manual" | "region" region?: { x: number; y: number; w: number; h: number } | null cutouts?: string[] // v2 多张提取图 id 列表 cutout_id?: string | null // v1 兼容字段 cutout_background?: "white" | "black" subject_kind?: SubjectKind subject_assets?: SubjectAsset[] created_at?: number } export interface ImageRef { kind: "keyframe" | "cutout" | "asset" frame_idx: number element_id?: string | null cutout_id?: string | null label?: string } export interface ProductFusionRegion { x: number y: number w: number h: number } export interface ProductFusionShot { id: string first_image?: ImageRef | null last_image?: ImageRef | null product_images?: ImageRef[] product_image?: ImageRef | null character_id?: string character_name?: string subject_image?: ImageRef | null subject_images?: ImageRef[] person_image?: ImageRef | null product_region?: ProductFusionRegion | null scene_image?: ImageRef | null action_text?: string duration?: number image_model?: "gpt-image-2" video_model?: "seedance" guide_image?: ImageRef | null } export interface StoryboardScene { duration: number first_image?: ImageRef | null last_image?: ImageRef | null product_images?: ImageRef[] product_fusion_shots?: ProductFusionShot[] subject_image?: ImageRef | null scene_image?: ImageRef | null product_image?: ImageRef | null action_image?: ImageRef | null // v1 兼容 subject?: string product?: string scene?: string action?: string reference_ids?: string[] } export interface GeneratedVideo { id: string provider_id?: string frame_idx: number prompt: string model: string status: "queued" | "in_progress" | "completed" | "failed" url?: string poster_url?: string duration: number progress: number error?: string created_at: number } // 把 ImageRef 解析成可显示的 src URL export function resolveImageRefUrl(jobId: string, ref: ImageRef): string { if (ref.kind === "keyframe") { return effectiveFrameUrl(jobId, { index: ref.frame_idx, cleaned_applied: false }) } if (ref.kind === "asset" && ref.element_id) { return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg` } if (ref.element_id && ref.cutout_id) { if (ref.cutout_id === ref.element_id) { // legacy v1 return cutoutUrl(jobId, ref.frame_idx, ref.element_id) } return cutoutUrl(jobId, ref.frame_idx, ref.element_id, ref.cutout_id) } return "" } export async function uploadStoryboardAsset(jobId: string, file: File): Promise { const fd = new FormData() fd.append("file", file) const res = await fetch(`${API_BASE}/jobs/${jobId}/assets`, { method: "POST", body: fd }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`uploadStoryboardAsset ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function listProductLibrary(): Promise { const res = await fetch(`${API_BASE}/product-library/skg`) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`listProductLibrary ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function copyProductLibraryAsset(jobId: string, productId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-library`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ product_id: productId }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`copyProductLibraryAsset ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function listCharacterLibrary(): Promise { const res = await fetch(`${API_BASE}/character-library/skg`) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`listCharacterLibrary ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export function characterLibraryImageUrl(filename: string): string { return `${API_BASE}/character-library/skg/images/${filename}` } export async function copyCharacterLibraryAssets(jobId: string, characterId: string): Promise<{ character_id: string; character_name: string; images: ImageRef[] }> { const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/character-library`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ character_id: characterId }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`copyCharacterLibraryAssets ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function createProductFusionGuide( jobId: string, body: ProductFusionShot, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/product-fusion/guide`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`createProductFusionGuide ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function generateProductFusionDescriptions( jobId: string, shots: ProductFusionShot[], ): Promise<{ descriptions: string[]; mode: "llm" | "fallback" }> { const res = await fetch(`${API_BASE}/jobs/${jobId}/product-fusion/descriptions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shots }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`generateProductFusionDescriptions ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export interface KeyFrame { index: number timestamp: number url: string description?: FrameDescription | null transparent_human_score?: TransparentHumanFrameScore | null cleaned_url?: string | null cleaned_applied?: boolean quality_report?: QualityReport | null scene_assets?: SceneAsset[] elements?: KeyElement[] storyboard?: StoryboardScene | null generated_images?: GeneratedImage[] } export type FrameExtractTarget = "transparent_human" | "balanced" | "subject" | "transition" | "expression" | "motion" export type FrameExtractMode = "replace" | "append" export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra" export type AssetBackground = "white" | "black" export type AssetSize = "source" | "1024" | "1536" | "2048" export type SubjectKind = "object" | "living" export type SubjectView = string export type SceneMode = "remove_subject" | "similar" | "style" export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic" export type SceneAssetRole = "scene" | "first_frame" | "last_frame" export interface QualityReport { width: number height: number short_side: number sharpness: number risk: "ok" | "warn" | "bad" warnings: string[] } export interface TransparentHumanFrameScore { transparent_body_score: number skeleton_visible_score: number human_prominence_score: number clarity_score: number commercial_style_score: number product_usefulness_score: number total_score?: number qualified?: boolean reject_reason?: string notes?: string } export interface SceneAsset { id: string label: string url: string width: number height: number quality: "hd" size: AssetSize scene_mode?: SceneMode scene_style?: SceneStyle asset_role?: SceneAssetRole quality_report?: QualityReport | null created_at: number } export interface SubjectAsset { id: string view: SubjectView label: string url: string width: number height: number background: AssetBackground quality: "hd" size: AssetSize source_frame_indices?: number[] ai_completed?: boolean created_at: number } export interface ProductLibraryItem { id: string handle: string title: string product_type: string image_type: string image_index: number filename: string url: string width: number height: number source_path: string white_score: number near_white_score: number has_people: boolean tags: string[] } export interface CharacterLibraryImage { id: string view: string label: string filename: string url: string width: number height: number source_path: string } export interface CharacterLibraryItem { id: string name: string folder: string description: string primary_image: string images: CharacterLibraryImage[] } export interface TranscriptSegment { index: number start: number end: number en: string zh: string } export interface AudioScript { status: "idle" | "rewriting" | "completed" | "failed" source_text: string source_zh: string rewritten_text: string product_brief: string rewrite_model: string voice_provider: string voice_model: string voice_id: string voice_url: string error: string created_at: number } export interface StoryboardImage { ref_id: string kind: "keyframe" | "cutout" | "asset" frame_idx: number element_id?: string | null cutout_id?: string | null label?: string created_at?: number } export interface Job { id: string url: string status: JobStatus progress: number message?: string video_url?: string duration?: number width?: number height?: number source_audio_url?: string frames: KeyFrame[] transcript: TranscriptSegment[] audio_script?: AudioScript storyboard_images?: StoryboardImage[] generated_videos?: GeneratedVideo[] error?: string } export interface BackendHealth { ok: boolean llm_configured: boolean auth_configured?: boolean base_url: string models?: { asr?: string translate?: string rewrite?: string audio_rewrite?: string minimax_tts?: string minimax_voice?: string minimax_configured?: boolean video?: string video_aliases?: Record video_base_url?: string video_configured?: boolean } } export function apiAssetUrl(path?: string | null): string { if (!path) return "" if (/^https?:\/\//i.test(path)) return path return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}` } export async function getHealth(): Promise { const res = await fetch(`${API_BASE}/health`) if (!res.ok) throw new Error(`health ${res.status}`) return res.json() } export async function createJob(tkUrl: string): Promise { const res = await fetch(`${API_BASE}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: tkUrl }), }) if (!res.ok) throw new Error(`createJob ${res.status}`) return res.json() } export async function uploadJob(file: File): Promise { const fd = new FormData() fd.append("file", file) const res = await fetch(`${API_BASE}/jobs/upload`, { method: "POST", body: fd, }) if (!res.ok) { const text = await res.text().catch(() => "") throw new Error(`uploadJob ${res.status} ${text.slice(0, 200)}`) } return res.json() } export async function getJob(id: string): Promise { const res = await fetch(`${API_BASE}/jobs/${id}`) if (!res.ok) throw new Error(`getJob ${res.status}`) return res.json() } export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }> { const res = await fetch(`${API_BASE}/jobs/${id}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteJob ${res.status} ${txt.slice(0, 200)}`) } return res.json() } export interface JobSummary { id: string url: string status: JobStatus progress: number message: string duration: number width: number height: number video_url: string frame_count: number video_count: number thumbnail: string error: string mtime: number } export async function listJobs(limit?: number): Promise { const qs = limit && limit > 0 ? `?limit=${limit}` : "" const res = await fetch(`${API_BASE}/jobs${qs}`) if (!res.ok) throw new Error(`listJobs ${res.status}`) return res.json() } export async function triggerTranscribe(id: string): Promise { const res = await fetch(`${API_BASE}/jobs/${id}/transcribe`, { method: "POST" }) if (!res.ok) throw new Error(`transcribe ${res.status}`) return res.json() } export async function analyzeJob( id: string, frames = 12, target: FrameExtractTarget = "balanced", mode: FrameExtractMode = "replace", quality: FrameExtractQuality = "auto", ): Promise { const qs = new URLSearchParams({ frames: String(frames), target, mode, quality }) const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { method: "POST" }) if (!res.ok) { const t = await res.text().catch(() => "") throw new Error(`analyze ${res.status} ${t.slice(0, 200)}`) } return res.json() } export async function addManualFrame(id: string, t: number): Promise { const res = await fetch(`${API_BASE}/jobs/${id}/frames?t=${encodeURIComponent(t.toFixed(2))}`, { method: "POST" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`addFrame ${res.status} ${txt.slice(0, 200)}`) } return res.json() } export async function describeFrame(jobId: string, frameIdx: number): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/describe`, { method: "POST" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`describe ${res.status} ${txt.slice(0, 200)}`) } return res.json() } export async function translateText(text: string, target: "en" | "zh" = "en"): Promise { const res = await fetch(`${API_BASE}/translate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, target }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`translate ${res.status} ${txt.slice(0, 200)}`) } const j = await res.json() return (j.text || "").toString() } export async function generateImage( jobId: string, frameIdx: number, body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; mode?: "edit" | "text"; from_selected?: boolean }, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`generate ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function selectGenerated( jobId: string, frameIdx: number, genId: string, selected: boolean, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}/select`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected }), }) if (!res.ok) throw new Error(`select ${res.status}`) return res.json() } export function generatedImageUrl(jobId: string, frameIdx: number, genId: string): string { return `${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}.jpg` } export function frameUrl(jobId: string, frameIndex: number): string { return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}.jpg` } // 接 frame 对象时返回正确版本 URL(已应用清洗版时加 cache-bust) export function effectiveFrameUrl(jobId: string, frame: { index: number; cleaned_applied?: boolean | null }): string { const base = `${API_BASE}/jobs/${jobId}/frames/${frame.index}.jpg` return frame.cleaned_applied ? `${base}?v=applied` : base } export function videoUrl(jobId: string): string { return `${API_BASE}/jobs/${jobId}/video.mp4` } export function sourceAudioUrl(jobId: string): string { return `${API_BASE}/jobs/${jobId}/audio.wav` } export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string { const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg` return bust ? `${u}?t=${bust}` : u } export function cutoutUrl(jobId: string, frameIndex: number, elementId: string, cutoutId?: string): string { if (cutoutId) { return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutouts/${cutoutId}.jpg` } return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg` } export function jobAssetUrl(jobId: string, assetId: string): string { return `${API_BASE}/jobs/${jobId}/assets/${assetId}.jpg` } // 兼容 v1 (cutout_id) / v2 (cutouts 数组) — 返回"有没有提取图" export function hasCutout(e: KeyElement): boolean { return (Array.isArray(e.cutouts) && e.cutouts.length > 0) || !!e.cutout_id } // 返回代表性 cutout 的 URL(v2 取最新一张,v1 用旧路径) export function representativeCutoutUrl( jobId: string, frameIndex: number, e: KeyElement, ): string | null { if (Array.isArray(e.cutouts) && e.cutouts.length > 0) { return cutoutUrl(jobId, frameIndex, e.id, e.cutouts[e.cutouts.length - 1]) } if (e.cutout_id) return cutoutUrl(jobId, frameIndex, e.id) return null } export async function pushStoryboardImage( jobId: string, body: { kind: "keyframe" | "cutout" | "asset"; frame_idx: number; element_id?: string | null; cutout_id?: string | null; label?: string }, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`pushStoryboardImage ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function removeStoryboardImage(jobId: string, refId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images/${refId}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`removeStoryboardImage ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function updateStoryboard( jobId: string, frameIdx: number, body: StoryboardScene, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`updateStoryboard ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function generateStoryboardVideo( jobId: string, frameIdx: number, body: { prompt: string duration?: number first_image?: ImageRef | null last_image?: ImageRef | null product_images?: ImageRef[] subject_images?: ImageRef[] subject_image?: ImageRef | null scene_image?: ImageRef | null product_image?: ImageRef | null action_image?: ImageRef | null source_ref?: { kind: "image" | "source_video"; url: string } | null model?: string size?: string }, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/video`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") let detail = txt try { const parsed = JSON.parse(txt) detail = parsed?.detail || txt } catch {} throw new Error(detail || `generateStoryboardVideo ${res.status}`) } return res.json() } export async function deleteGeneratedVideo(jobId: string, videoId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-videos/${videoId}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteGeneratedVideo ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, { method: "DELETE", }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteCutout ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function cleanupFrame( jobId: string, frameIdx: number, regions?: Array<{ x: number; y: number; w: number; h: number }> | null, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ regions: regions ?? null }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`cleanup ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function applyCleanedFrame(jobId: string, frameIdx: number): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup/apply`, { method: "POST" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`applyCleaned ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function discardCleanedFrame(jobId: string, frameIdx: number): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`discardCleaned ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function addElement( jobId: string, frameIdx: number, body: { name_zh: string name_en?: string position?: string source?: "auto" | "manual" | "region" region?: { x: number; y: number; w: number; h: number } | null }, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: "manual", ...body }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`addElement ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function deleteElement(jobId: string, frameIdx: number, elementId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteElement ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function updateElement( jobId: string, frameIdx: number, elementId: string, body: { name_zh?: string; name_en?: string; position?: string }, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`updateElement ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function deleteFrame(jobId: string, frameIdx: number): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteFrame ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function deleteGeneratedImage(jobId: string, frameIdx: number, genId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}`, { method: "DELETE" }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`deleteGen ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST", }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function generateSceneAsset( jobId: string, frameIdx: number, body: { size?: AssetSize scene_mode?: SceneMode scene_style?: SceneStyle asset_role?: SceneAssetRole prompt?: string source_frame_indices?: number[] } = {}, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/scene-asset`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quality: "hd", size: body.size ?? "source", scene_mode: body.scene_mode ?? "remove_subject", scene_style: body.scene_style ?? "source", asset_role: body.asset_role ?? "scene", prompt: body.prompt ?? "", source_frame_indices: body.source_frame_indices ?? null, }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`sceneAsset ${res.status} ${txt.slice(0, 300)}`) } return res.json() } export async function generateSubjectAssets( jobId: string, frameIdx: number, elementId: string, body: { subject_kind?: SubjectKind background?: AssetBackground size?: AssetSize source_frame_indices?: number[] views?: string[] } = {}, ): Promise { const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quality: "hd", subject_kind: body.subject_kind ?? "object", background: body.background ?? "white", size: body.size ?? "source", source_frame_indices: body.source_frame_indices ?? null, views: body.views ?? null, }), }) if (!res.ok) { const txt = await res.text().catch(() => "") throw new Error(`subjectAssets ${res.status} ${txt.slice(0, 300)}`) } return res.json() }