diff --git a/src/api/admin/transcode.ts b/src/api/admin/transcode.ts new file mode 100644 index 0000000..f6d2e86 --- /dev/null +++ b/src/api/admin/transcode.ts @@ -0,0 +1,30 @@ +// Author: fruit.com.cn + +import request from '@/axios' +import type { EpisodeTranscodeItem, MediaThumbnailResult, TranscodeTriggerResult } from './types' + +/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */ +export const getEpisodeTranscodeApi = (episodeId: number | string) => { + return request.get({ + url: `/admin/episodes/${episodeId}/transcode` + }) +} + +/** 按媒体分页读取帧图;页面切换时复用该接口。 */ +export const getMediaThumbnailsApi = ( + mediaAssetId: number | string, + pageIndex: number, + pageSize: number +) => { + return request.get({ + url: `/admin/media/${mediaAssetId}/thumbnails`, + params: { pageIndex, pageSize } + }) +} + +/** 手动触发或重转当前分集绑定的媒体。 */ +export const triggerMediaTranscodeApi = (mediaAssetId: number | string) => { + return request.post({ + url: `/admin/media/${mediaAssetId}/transcode` + }) +} diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index d58e598..ad9d02d 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -248,6 +248,78 @@ export interface MediaTranscodeProfile { sprite: MediaTranscodeSprite } +export interface MediaRenditionItem { + id: number + quality: string + qualityName: string + width: number + height: number + resolution: string + videoBitrateKbps: number + audioBitrateKbps: number + videoCodec: string + audioCodec?: string + container: string + hlsUrl: string + sizeBytes: number + durationSec: number + segmentCount: number + status: string +} + +export interface MediaThumbnailFrameItem { + frameIndex: number + timeMs: number + endTimeMs?: number + imageUrl: string + spriteUrl: string + x: number + y: number + width: number + height: number +} + +export interface MediaThumbnailSpriteInfo { + spriteUrl?: string + spriteUrls: string[] + vttUrl?: string + columns: number + rows: number + totalFrames: number + thumbWidth: number + thumbHeight: number + intervalSeconds: number +} + +export interface MediaThumbnailResult { + mediaAssetId?: number | null + episodeId?: number | null + /** READY / FAILED / EMPTY / INVALID / NO_EPISODE */ + status: string + errorMsg?: string + pageIndex: number + pageSize: number + frames: PageResult + sprite?: MediaThumbnailSpriteInfo | null +} + +export interface EpisodeTranscodeItem { + episodeId: number + mediaAssetId?: number | null + taskId?: number | null + status: string + progress?: number + retryCount?: number + errorMsg?: string + renditions: MediaRenditionItem[] + thumbnails?: MediaThumbnailResult | null +} + +export interface TranscodeTriggerResult { + taskId: number + status: string +} + export interface BannerItem { id: number imageUrl: string diff --git a/src/constants/icons.ts b/src/constants/icons.ts index e1b530c..2a448d5 100644 --- a/src/constants/icons.ts +++ b/src/constants/icons.ts @@ -37,6 +37,8 @@ export const AppIcons = { delete: 'material-symbols:delete-rounded', upload: 'material-symbols:upload-rounded', download: 'material-symbols:download-rounded', + copy: 'material-symbols:content-copy-rounded', + openInNew: 'material-symbols:open-in-new-rounded', visibility: 'material-symbols:visibility-rounded', arrowUp: 'material-symbols:arrow-upward-rounded', arrowDown: 'material-symbols:arrow-downward-rounded', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5d3fcf7..3cefb8a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -529,6 +529,64 @@ export default { title: 'InputPassword', inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' }, + episodeTranscode: { + title: 'Transcode outputs', + description: 'Review the transcode task, renditions and frame previews for this episode.', + statusLabel: 'Status', + progress: 'Progress', + retryCount: 'Retries', + reprocess: 'Transcode again', + reprocessing: 'Submitting', + triggerSuccess: 'Transcode task submitted', + loadFailed: 'Failed to load transcode outputs', + retryLoad: 'Reload', + triggerFailed: 'Failed to retry transcode', + noMedia: 'No uploaded media is bound to this episode yet.', + noRendition: 'No rendition outputs', + renditionTitle: 'Rendition outputs', + quality: 'Quality', + resolution: 'Resolution', + videoBitrate: 'Video bitrate', + audioBitrate: 'Audio bitrate', + codec: 'Codec', + container: 'Container', + fileSize: 'File size', + duration: 'Duration', + segments: 'Segments', + statusColumn: 'Status', + actions: 'Actions', + copyUrl: 'Copy playback URL', + openUrl: 'Open in new window', + copySuccess: 'Playback URL copied', + copyFailed: 'Failed to copy playback URL', + openFailed: 'Unable to open playback URL', + frameTitle: 'Frame previews', + frameView: 'Frames', + spriteView: 'Sprites', + frameCount: '{total} frames', + frameTime: 'Timestamp', + previewFrame: 'Preview frame at {time}', + spriteSheet: 'Sprite {index}', + spriteSheetFrames: '{count} frames', + noFrames: 'No frame previews', + noSprites: 'No sprite sheets available', + zoomTitle: 'Frame preview', + status: { + queued: 'Queued', + running: 'Transcoding', + success: 'Success', + failed: 'Failed', + empty: 'No output', + noVideo: 'No video', + noMediaAsset: 'No media asset', + noTask: 'No transcode task', + noEpisode: 'No episode binding', + episodeNotFound: 'Episode not found', + invalid: 'Invalid output', + ready: 'Ready', + unknown: 'Unknown' + } + }, episodeBatch: { title: 'Batch upload episodes', entry: 'Batch upload', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 796b199..91de289 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -523,6 +523,64 @@ export default { title: '密码输入框', inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' }, + episodeTranscode: { + title: '转码产物', + description: '查看当前分集的转码任务、清晰度与帧图产物。', + statusLabel: '状态', + progress: '进度', + retryCount: '重试次数', + reprocess: '重新转码', + reprocessing: '正在触发', + triggerSuccess: '已提交转码任务', + loadFailed: '转码产物加载失败', + retryLoad: '重新加载', + triggerFailed: '重新转码失败', + noMedia: '当前分集尚未绑定已上传视频,暂无可查询的转码产物。', + noRendition: '暂无清晰度产物', + renditionTitle: '清晰度产物', + quality: '档位名称', + resolution: '分辨率', + videoBitrate: '视频码率', + audioBitrate: '音频码率', + codec: '编码', + container: '容器', + fileSize: '文件大小', + duration: '时长', + segments: '分片数', + statusColumn: '状态', + actions: '操作', + copyUrl: '复制播放地址', + openUrl: '新窗口打开', + copySuccess: '播放地址已复制', + copyFailed: '复制播放地址失败', + openFailed: '无法打开播放地址', + frameTitle: '帧图预览', + frameView: '逐帧', + spriteView: '雪碧图', + frameCount: '共 {total} 帧', + frameTime: '时间点', + previewFrame: '预览 {time} 帧图', + spriteSheet: '雪碧图 {index}', + spriteSheetFrames: '{count} 帧', + noFrames: '暂无帧图产物', + noSprites: '暂无可展示的雪碧图', + zoomTitle: '帧图预览', + status: { + queued: '排队中', + running: '转码中', + success: '成功', + failed: '失败', + empty: '暂无产物', + noVideo: '无视频', + noMediaAsset: '未绑定媒资', + noTask: '无转码任务', + noEpisode: '未绑定分集', + episodeNotFound: '分集不存在', + invalid: '产物异常', + ready: '就绪', + unknown: '未知状态' + } + }, episodeBatch: { title: '批量上传剧集', entry: '批量上传', diff --git a/src/views/contentManagement/upload/EpisodeFormDialog.vue b/src/views/contentManagement/upload/EpisodeFormDialog.vue index 836927a..7b5224d 100644 --- a/src/views/contentManagement/upload/EpisodeFormDialog.vue +++ b/src/views/contentManagement/upload/EpisodeFormDialog.vue @@ -6,6 +6,7 @@ import { nextTick, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode' import { formatCount } from '@/utils/format' +import EpisodeTranscodePanel from './EpisodeTranscodePanel.vue' import EpisodeVideoUploader from './EpisodeVideoUploader.vue' const props = defineProps({ @@ -263,6 +264,8 @@ defineExpose({ openCreate, openEdit }) + +