125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import type { Scene } from '@/types/scene'
|
|
|
|
const DEFAULT_SERVER = 'http://localhost:6000'
|
|
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
|
|
const RENDER_TOKEN = import.meta.env.VITE_RENDER_TOKEN as string | undefined
|
|
|
|
export interface ServerRenderOptions {
|
|
onProgress?: (progress: number) => void
|
|
signal?: AbortSignal
|
|
fps?: number
|
|
crf?: number
|
|
preset?: string
|
|
}
|
|
|
|
export interface ServerRenderResult {
|
|
blob: Blob
|
|
extension: string
|
|
}
|
|
|
|
export function getServerUrl(): string {
|
|
return SERVER_URL
|
|
}
|
|
|
|
export async function renderOnServer(
|
|
scene: Scene,
|
|
opts: ServerRenderOptions = {},
|
|
): Promise<ServerRenderResult> {
|
|
const baseUrl = SERVER_URL
|
|
const startRes = await fetch(`${baseUrl}/render`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
scene,
|
|
options: {
|
|
fps: opts.fps,
|
|
crf: opts.crf,
|
|
preset: opts.preset,
|
|
},
|
|
}),
|
|
signal: opts.signal,
|
|
})
|
|
if (!startRes.ok) {
|
|
const body = await startRes.text().catch(() => '')
|
|
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
|
|
}
|
|
const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
|
|
if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
|
|
|
|
const downloadUrl = await new Promise<string>((resolve, reject) => {
|
|
const jobToken = encodeURIComponent(accessToken)
|
|
const es = new EventSource(`${baseUrl}/render/${jobId}/events?token=${jobToken}`)
|
|
const onAbort = () => {
|
|
try {
|
|
es.close()
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
|
|
reject(new DOMException('aborted', 'AbortError'))
|
|
}
|
|
if (opts.signal) {
|
|
if (opts.signal.aborted) return onAbort()
|
|
opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
}
|
|
es.onmessage = (evt) => {
|
|
let data: {
|
|
progress?: number
|
|
done?: boolean
|
|
error?: string | null
|
|
url?: string | null
|
|
}
|
|
try {
|
|
data = JSON.parse(evt.data)
|
|
} catch {
|
|
return
|
|
}
|
|
if (typeof data.progress === 'number') opts.onProgress?.(data.progress)
|
|
if (data.done) {
|
|
es.close()
|
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
if (data.error) reject(new Error(data.error))
|
|
else if (data.url) resolve(`${baseUrl}${data.url}`)
|
|
else reject(new Error('Server reported done without download URL'))
|
|
}
|
|
}
|
|
es.onerror = () => {
|
|
es.close()
|
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
reject(new Error('Connection to render server lost'))
|
|
}
|
|
})
|
|
|
|
const fileRes = await fetch(downloadUrl, { signal: opts.signal })
|
|
if (!fileRes.ok) throw new Error(`Download fehlgeschlagen (${fileRes.status})`)
|
|
const blob = await fileRes.blob()
|
|
return { blob, extension: 'mp4' }
|
|
}
|
|
|
|
export async function downloadFromServer(
|
|
scene: Scene,
|
|
opts: ServerRenderOptions = {},
|
|
): Promise<void> {
|
|
const { blob, extension } = await renderOnServer(scene, opts)
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `animation.${extension}`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
}
|
|
|
|
export async function isServerReachable(): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`${SERVER_URL}/health`, { method: 'GET' })
|
|
return res.ok
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|