128 lines
8.5 KiB
TypeScript
128 lines
8.5 KiB
TypeScript
import type { ElectronAPI, BackendHealthStatus } from '../../shared/electron-api-schema'
|
|
|
|
const pendingFiles = new Map<string, File>()
|
|
const uploads = new Map<string, Promise<string>>()
|
|
export function mediaUrl(path: string): string {
|
|
return path.startsWith('blob:') || path.startsWith('data:') ? path : `/web/media?path=${encodeURIComponent(path)}`
|
|
}
|
|
export async function webRequest<T>(path: string, value?: unknown): Promise<T> {
|
|
const response = await fetch(path, value === undefined ? undefined : {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Deskweb-Request': '1' }, body: JSON.stringify(value),
|
|
})
|
|
const data = await response.json()
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`)
|
|
return data as T
|
|
}
|
|
function status(message: string) { window.dispatchEvent(new CustomEvent('web-status', { detail: message })) }
|
|
async function upload(file: File): Promise<string> {
|
|
status(`Uploading ${file.name}…`)
|
|
try {
|
|
const data = await new Promise<{ path: string }>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('POST', `/web/upload?name=${encodeURIComponent(file.name)}`)
|
|
xhr.setRequestHeader('X-Deskweb-Request', '1')
|
|
xhr.upload.onprogress = event => { if (event.lengthComputable) status(`Uploading ${file.name}: ${Math.round(event.loaded / event.total * 100)}%`) }
|
|
xhr.onload = () => { try { const result = JSON.parse(xhr.responseText); if (xhr.status < 200 || xhr.status >= 300) reject(new Error(result.error)); else resolve(result) } catch { reject(new Error('Invalid upload response')) } }
|
|
xhr.onerror = () => reject(new Error('Upload connection failed'))
|
|
xhr.send(file)
|
|
})
|
|
status('Upload complete'); return data.path
|
|
} catch (e) { status(`Upload failed: ${e instanceof Error ? e.message : String(e)}`); throw e }
|
|
}
|
|
async function resolveInput(input: string): Promise<string> {
|
|
const file = pendingFiles.get(input)
|
|
if (!file) return input
|
|
if (!uploads.has(input)) uploads.set(input, upload(file).catch(e => { uploads.delete(input); throw e }))
|
|
return uploads.get(input)!
|
|
}
|
|
function download(blob: Blob, name: string) {
|
|
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
|
link.href = url; link.download = name.split('/').pop() || 'download'; link.click()
|
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
}
|
|
const open = (url: string) => { const parsed = new URL(url); if (!['https:', 'http:'].includes(parsed.protocol)) return false; window.open(url, '_blank', 'noopener,noreferrer'); return true }
|
|
const fileOperation = <T>(action: string, input: Record<string, unknown>) => webRequest<T>('/web/files', { action, ...input })
|
|
type Dimensions = { success: true; width: number; height: number }
|
|
type Thumbnails = { success: true; bigThumbnailPath: string; smallThumbnailPath: string }
|
|
async function health(): Promise<BackendHealthStatus> {
|
|
try {
|
|
const response = await fetch('/ltx/health', { signal: AbortSignal.timeout(10000) })
|
|
if (!response.ok) return { status: 'dead' }
|
|
// Deck's own health endpoint is not proof that the LTX API is active.
|
|
const specs = await fetch('/ltx/api/generate/models-specs', { signal: AbortSignal.timeout(10000) })
|
|
return { status: specs.ok ? 'alive' : 'dead' }
|
|
} catch { return { status: 'dead' } }
|
|
}
|
|
const bridge: Partial<ElectronAPI> = {
|
|
platform: 'web',
|
|
getBackend: async () => ({ url: `${location.origin}/ltx`, token: '' }),
|
|
getBackendHealthStatus: health,
|
|
onBackendHealthStatus: callback => {
|
|
let active = true, timer: ReturnType<typeof setTimeout>
|
|
const poll = async () => { const result = await health(); if (active) { callback(result); timer = setTimeout(poll, 15000) } }
|
|
void poll(); return () => { active = false; clearTimeout(timer) }
|
|
},
|
|
getPathForFile: file => { const url = URL.createObjectURL(file); pendingFiles.set(url, file); return url },
|
|
showOpenFileDialog: options => new Promise((resolve, reject) => {
|
|
const input = document.createElement('input'); input.type = 'file'; input.multiple = Boolean(options.properties?.includes('multiSelections'))
|
|
input.accept = options.filters?.flatMap(filter => filter.extensions.map(ext => `.${ext}`)).join(',') || ''
|
|
input.oncancel = () => resolve(null)
|
|
input.onchange = () => { Promise.all(Array.from(input.files || []).map(upload)).then(resolve, reject) }
|
|
input.click()
|
|
}),
|
|
prepareGenerationInput: async ({ path }) => ({ success: true, path: await resolveInput(path) }),
|
|
addGenericAssetToProject: async ({ srcPath }) => fileOperation('copy', { path: await resolveInput(srcPath) }),
|
|
addVisualAssetToProject: async ({ srcPath }) => {
|
|
const copied = await fileOperation<{ path: string }>('copy', { path: await resolveInput(srcPath) })
|
|
const dimensions = await fileOperation<Dimensions>('metadata', copied)
|
|
const thumbs = await fileOperation<Thumbnails>('thumbnail', copied)
|
|
return { ...dimensions, ...thumbs, success: true, path: copied.path }
|
|
},
|
|
makeDimensionsForProjectAsset: async ({ path }) => fileOperation('metadata', { path: await resolveInput(path) }),
|
|
makeThumbnailsForProjectAsset: async ({ path }) => fileOperation('thumbnail', { path: await resolveInput(path) }),
|
|
extractVideoFrame: async ({ videoPath, seekTime }) => fileOperation('frame', { path: await resolveInput(videoPath), seekTime }),
|
|
checkFilesExist: async ({ filePaths }) => fileOperation('exists', { paths: filePaths }),
|
|
readLocalFile: async ({ filePath }) => {
|
|
const response = await fetch(mediaUrl(filePath)); if (!response.ok) throw Error('Cannot read media')
|
|
const buffer = new Uint8Array(await response.arrayBuffer()); let binary = ''
|
|
for (let offset = 0; offset < buffer.length; offset += 8192) binary += String.fromCharCode(...buffer.subarray(offset, offset + 8192))
|
|
return { data: btoa(binary), mimeType: response.headers.get('content-type') || 'application/octet-stream' }
|
|
},
|
|
showSaveDialog: async options => options.defaultPath || 'download',
|
|
saveFile: async ({ filePath, data, encoding }) => { const content = encoding === 'base64' ? Uint8Array.from(atob(data), c => c.charCodeAt(0)) : data; download(new Blob([content]), filePath); return { success: true, path: filePath } },
|
|
saveBinaryFile: async ({ filePath, data }) => { download(new Blob([data]), filePath); return { success: true, path: filePath } },
|
|
showItemInFolder: async ({ filePath }) => {
|
|
const link = document.createElement('a'); link.href = `${mediaUrl(filePath)}&download=1`; link.download = filePath.split('/').pop() || 'clip'; link.click()
|
|
},
|
|
openParentFolderOfFile: async () => { status('The file was downloaded to your browser download folder.') },
|
|
openExternalUrl: async ({ url }) => open(url),
|
|
openHuggingFaceRepo: async ({ repoId }) => open(`https://huggingface.co/${repoId}`),
|
|
openLtxApiKeyPage: async () => open('https://console.ltx.video'),
|
|
openLtxBillingPage: async () => open('https://console.ltx.video'),
|
|
openFalApiKeyPage: async () => open('https://fal.ai/dashboard'),
|
|
getAppInfo: async () => ({ version: '0.1.0', isPackaged: true, modelsPath: 'Managed by LTX backend', userDataPath: 'Browser project storage' }),
|
|
getProjectAssetsPath: async () => 'Shared LTX input directory',
|
|
getModelsPath: async () => 'Managed by LTX backend',
|
|
getDownloadsPath: async () => '',
|
|
getResourcePath: async () => null,
|
|
getNoticesText: async () => (await fetch('/NOTICES.md')).text(),
|
|
fetchLicenseText: async () => (await fetch('/LICENSE.txt')).text(),
|
|
notifyGenerationActive: async () => {}, // No process watchdog; Deck owns lifecycle.
|
|
writeLog: async () => {}, // Never persist prompts or media URLs in the web service.
|
|
getAnalyticsState: async () => ({ analyticsEnabled: false, installationId: '' }),
|
|
setAnalyticsEnabled: async () => {},
|
|
sendAnalyticsEvent: async () => {},
|
|
getUpdateState: async () => ({ status: 'not-available', currentVersion: '0.1.0' }),
|
|
onUpdateEvent: () => () => {},
|
|
getAutoCheckUpdates: async () => ({ enabled: false }),
|
|
exportNative: async () => ({ success: false, error: 'Timeline rendering is not available in this first web version. Download individual clips or export the timeline as XML.' }),
|
|
}
|
|
// Preserve the inherited typed UI boundary while porting. Unsupported desktop operations
|
|
// fail explicitly, never silently simulate installation, export, or process control.
|
|
window.electronAPI = new Proxy(bridge, {
|
|
get(target, property) {
|
|
if (property in target) return target[property as keyof ElectronAPI]
|
|
return async () => { const message = `Desktop function “${String(property)}” is not available in LTX DeskWEB.`; status(message); throw new Error(message) }
|
|
},
|
|
}) as ElectronAPI
|