返回 JoyAI-Echo
downloadMedia.ts
根目录 / echo_longvideo / Director_Agent / webui / src / lib / downloadMedia.ts
1 /**
2 * 媒体下载:成片优先走 nanobot workplace 代理,避免跨域与 <a download> 失效。
3 */
4
5 export interface DownloadMediaOptions {
6 /** 媒体资源 URL(同域直链下载时使用) */
7 url: string
8 /** 自定义下载文件名 */
9 fileName?: string
10 /** workplace session key;与 token 同时提供时走 /download/final 代理 */
11 sessionKey?: string
12 /** WebUI Bearer token */
13 token?: string
14 onError?: (error: Error) => void
15 }
16
17 let isDownloading = false
18
19 const isCrossOrigin = (url: string): boolean => {
20 try {
21 return window.location.origin !== new URL(url).origin
22 } catch {
23 return true
24 }
25 }
26
27 const getDefaultFileName = (url: string): string => {
28 if (!url) return `media-${Date.now()}`
29
30 const pureUrl = url.split('?')[0]
31 let fileName = pureUrl.split('/').pop() || `media-${Date.now()}`
32
33 const extMap: Record<string, string> = {
34 mp4: 'mp4',
35 webm: 'webm',
36 avi: 'avi',
37 mov: 'mov',
38 jpg: 'jpg',
39 jpeg: 'jpeg',
40 png: 'png',
41 webp: 'webp',
42 gif: 'gif',
43 }
44
45 const hasExt = Object.keys(extMap).some((ext) => fileName.endsWith(`.${ext}`))
46 if (!hasExt) {
47 for (const [key, ext] of Object.entries(extMap)) {
48 if (url.includes(`.${key}`)) {
49 fileName = `${fileName}.${ext}`
50 break
51 }
52 }
53 }
54
55 return fileName
56 }
57
58 /** Parse filename from Content-Disposition (attachment; filename="..."). */
59 function parseContentDisposition(header: string | null): string | undefined {
60 if (!header) return undefined
61 const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(header)
62 if (utf8Match?.[1]) {
63 try {
64 return decodeURIComponent(utf8Match[1].trim())
65 } catch {
66 return utf8Match[1].trim()
67 }
68 }
69 const quoted = /filename="([^"]+)"/i.exec(header)
70 if (quoted?.[1]) return quoted[1]
71 const plain = /filename=([^;]+)/i.exec(header)
72 return plain?.[1]?.trim()
73 }
74
75 function triggerBlobDownload(blob: Blob, fileName: string): void {
76 const downloadUrl = URL.createObjectURL(blob)
77 const link = document.createElement('a')
78 link.href = downloadUrl
79 link.download = fileName
80 link.style.display = 'none'
81 document.body.appendChild(link)
82 link.click()
83 document.body.removeChild(link)
84 URL.revokeObjectURL(downloadUrl)
85 }
86
87 const downloadSameOrigin = (url: string, fileName: string): void => {
88 const link = document.createElement('a')
89 link.href = url
90 link.download = fileName
91 link.style.display = 'none'
92 document.body.appendChild(link)
93 link.click()
94 document.body.removeChild(link)
95 }
96
97 async function readDownloadError(res: Response): Promise<string> {
98 const text = (await res.text()).trim()
99 if (!text) return `HTTP ${res.status}`
100 try {
101 const body = JSON.parse(text) as { message?: string }
102 if (typeof body.message === 'string' && body.message) return body.message
103 } catch {
104 // plain text error body
105 }
106 return text
107 }
108
109 /** Gateway proxy for final merged video (SSRF-safe server-side fetch). */
110 async function downloadWorkplaceFinal(
111 sessionKey: string,
112 token: string,
113 fallbackFileName: string,
114 ): Promise<void> {
115 const res = await fetch(
116 `/api/workplace/${encodeURIComponent(sessionKey)}/download/final`,
117 {
118 headers: { Authorization: `Bearer ${token}` },
119 credentials: 'same-origin',
120 },
121 )
122 if (!res.ok) {
123 throw new Error(await readDownloadError(res))
124 }
125 const blob = await res.blob()
126 const fileName =
127 parseContentDisposition(res.headers.get('Content-Disposition')) ||
128 fallbackFileName
129 triggerBlobDownload(blob, fileName)
130 }
131
132 export const downloadMedia = async (
133 options: DownloadMediaOptions,
134 ): Promise<void> => {
135 if (isDownloading) {
136 console.warn('A download is already in progress.')
137 return
138 }
139
140 const {
141 url,
142 fileName: customFileName,
143 sessionKey,
144 token,
145 onError,
146 } = options
147
148 const finalFileName = customFileName || getDefaultFileName(url)
149 isDownloading = true
150
151 try {
152 if (sessionKey && token) {
153 await downloadWorkplaceFinal(sessionKey, token, finalFileName)
154 return
155 }
156
157 if (!url) {
158 throw new Error('Download failed: the resource URL is empty.')
159 }
160
161 if (isCrossOrigin(url)) {
162 console.warn(
163 'Cross-origin media downloads require a session key and token, or a same-origin URL.',
164 )
165 throw new Error('Cross-origin media cannot be downloaded directly.')
166 }
167
168 downloadSameOrigin(url, finalFileName)
169 } catch (error) {
170 const err = error instanceof Error ? error : new Error('Download failed: unknown error.')
171 onError?.(err)
172 console.error('Media download failed:', err)
173 alert(`Download failed: ${err.message}`)
174 } finally {
175 isDownloading = false
176 }
177 }
178
179 export const batchDownloadMedia = async (
180 list: DownloadMediaOptions[],
181 delay = 300,
182 ): Promise<void> => {
183 for (let i = 0; i < list.length; i++) {
184 await downloadMedia(list[i])
185 if (i < list.length - 1) {
186 await new Promise((resolve) => setTimeout(resolve, delay))
187 }
188 }
189 }
190
190 lines TYPESCRIPT