Files
ReelShortWeb/src/utils/uploader.ts
T

119 lines
4.0 KiB
TypeScript

// Author: fruit.com.cn
import {
completeUploadApi,
getUploadProgressApi,
initUploadApi,
uploadChunkApi
} from '@/api/admin/media'
import type { MediaAsset } from '@/api/admin/types'
export interface UploadFileOptions {
/** DRAMA_COVER / EPISODE / AVATAR */
bizType?: string
/** 并发分片数,默认 3 */
concurrency?: number
/** 视频时长(秒),由调用方探测后传入 */
durationSec?: number
onProgress?: (percent: number) => void
/** init 建好会话就回调一次:调用方可以把它记下来,失败/刷新后走 resumeUpload 续传 */
onSession?: (uploadId: string) => void
}
/**
* 分片上传:init 建会话 → 并发传分片 → complete 合并。
* 服务端返回合并后的媒资(含可访问 URL)。
*/
export async function uploadFile(file: File, options: UploadFileOptions = {}): Promise<MediaAsset> {
const { bizType = 'EPISODE', concurrency = 3, onProgress } = options
const initRes = await initUploadApi({
fileName: file.name,
fileSize: file.size,
bizType
})
const info = initRes.data
const chunkSize = Number(info.chunkSize)
options.onSession?.(info.uploadId)
const total = Math.max(Number(info.totalChunks), 1)
const pending: number[] = Array.from({ length: total }, (_, index) => index)
let finished = 0
const worker = async () => {
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(info.uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker))
const doneRes = await completeUploadApi(info.uploadId, options.durationSec)
onProgress?.(100)
return doneRes.data
}
/**
* 断点续传:先问服务端传到第几片,从断点接着传,再合并。
*
* 服务端只记账"已收到多少片",不回传具体是哪几片,所以这里按"已收片数之后的分片全部重传"处理 ——
* 重传同一片是幂等覆盖,不会产生脏数据;片数落后于实际时最多多传几片。
*/
export async function resumeUpload(
file: File,
uploadId: string,
options: UploadFileOptions = {}
): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options
const progress = (await getUploadProgressApi(uploadId)).data
const chunkSize = Number(progress.chunkSize)
const total = Math.max(Number(progress.totalChunks), 1)
const received = Math.min(progress.receivedChunks || 0, total)
const pending: number[] = []
for (let index = received; index < total; index += 1) {
pending.push(index)
}
let finished = received
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
const worker = async () => {
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, Math.max(pending.length, 1)) }, worker)
)
const doneRes = await completeUploadApi(uploadId, options.durationSec)
onProgress?.(100)
return doneRes.data
}
/** 读取本地视频时长,取不到时返回 0。 */
export function probeVideoDuration(file: File): Promise<number> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
const cleanup = (value: number) => {
URL.revokeObjectURL(url)
resolve(value)
}
video.onloadedmetadata = () => {
cleanup(Number.isFinite(video.duration) ? Math.round(video.duration) : 0)
}
video.onerror = () => cleanup(0)
video.src = url
})
}