feat: 前端预览资源切换到 CDN 域名 airflow-play.airlabs.art
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

新增 rewriteTosUrl 在渲染层把 airdrama-media.tos-cn-beijing.volces.com
替换成 airflow-play.airlabs.art,覆盖 <video>/<audio> src 及 tosThumb
图片缩略;下载仍走原 TOS 直连域名以避开 CDN CORS 配置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-28 16:07:32 +08:00
parent 3f858257ea
commit f101878954
8 changed files with 37 additions and 21 deletions

View File

@ -4,7 +4,7 @@ import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { tosThumb } from '../lib/api';
import { tosThumb, rewriteTosUrl } from '../lib/api';
import styles from './GenerationCard.module.css';
const EditIcon = () => (
@ -314,7 +314,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
onMouseLeave={() => setRefPreview(null)}
>
{ref.type === 'video' && !ref.isAssetRef ? (
<video src={ref.previewUrl} className={styles.refMedia} muted />
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.refMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioThumb}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
@ -437,7 +437,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' && !refPreview.isAssetRef ? (
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
<video src={rewriteTosUrl(refPreview.url)} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
) : (
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
@ -475,7 +475,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
>
<video
ref={videoRef}
src={task.resultUrl}
src={rewriteTosUrl(task.resultUrl)}
className={styles.resultMedia}
loop
preload="metadata"

View File

@ -1,7 +1,7 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import { assetsApi, tosThumb } from '../lib/api';
import { assetsApi, tosThumb, rewriteTosUrl } from '../lib/api';
import type { UploadedFile, AssetSearchResult } from '../types';
import { parseAssetMentionsFromDOM } from '../lib/assetMentions';
import { showToast } from './Toast';
@ -716,7 +716,7 @@ export function PromptInput() {
>
<div className={styles.mentionThumb}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
<video src={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} />
) : ref.type === 'audio' ? (
<span style={{ fontSize: 16 }}>{'\u266B'}</span>
) : (
@ -774,7 +774,7 @@ export function PromptInput() {
>
{hoverRef.type === 'video' ? (
<video
src={hoverRef.previewUrl}
src={rewriteTosUrl(hoverRef.previewUrl)}
autoPlay
loop
muted

View File

@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import { tosThumb } from '../lib/api';
import { tosThumb, rewriteTosUrl } from '../lib/api';
import styles from './UniversalUpload.module.css';
const Spinner = () => (
@ -143,7 +143,7 @@ export function UniversalUpload() {
>
<div className={styles.thumbInner}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.thumbMedia} muted />
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.thumbMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioPlaceholder}>
<AudioIcon />

View File

@ -6,7 +6,7 @@ import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard';
import { tosThumb } from '../lib/api';
import { tosThumb, rewriteTosUrl } from '../lib/api';
import styles from './VideoDetailModal.module.css';
interface Props {
@ -302,7 +302,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
>
<video
ref={videoRef}
src={task.resultUrl}
src={rewriteTosUrl(task.resultUrl)}
className={styles.video}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
@ -487,7 +487,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div key={ref.id} className={styles.refItem}>
<div style={{ position: 'relative', width: 56, height: 56 }}>
{ref.type === 'video' && !ref.isAssetRef ? (
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
@ -572,11 +572,11 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div style={{ position: 'relative', background: '#111118', borderRadius: 12, padding: 24, border: '1px solid #2a2a38' }} onClick={(e) => e.stopPropagation()}>
<button style={{ position: 'absolute', top: 8, right: 12, background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer' }} onClick={() => setRefMediaPreview(null)}></button>
{refMediaPreview.type === 'video' ? (
<video src={refMediaPreview.url} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} />
<video src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: '#888' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<audio src={refMediaPreview.url} controls autoPlay style={{ width: 320 }} />
<audio src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ width: 320 }} />
</div>
)}
</div>

View File

@ -435,6 +435,20 @@ export const assetsApi = {
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};
const TOS_ORIGIN = 'https://airdrama-media.tos-cn-beijing.volces.com';
const PREVIEW_ORIGIN = 'https://airflow-play.airlabs.art';
/**
* TOS CDN <video>/<audio>/<img>
* fetch / a.href download使 TOS CDN CORS
* / blob: /
*/
export function rewriteTosUrl(url: string | undefined): string {
if (!url) return '';
if (!url.startsWith(TOS_ORIGIN)) return url;
return PREVIEW_ORIGIN + url.slice(TOS_ORIGIN.length);
}
/**
* Append TOS image resize parameter to reduce loading size.
* Only applies to TOS image URLs (volces.com with image extensions).
@ -444,8 +458,9 @@ export function tosThumb(url: string | undefined, height: number): string {
// 只对我们自己的 TOS 桶生效airdrama-media不处理火山内部桶ark-media-asset 等)
if (!url.includes('airdrama-media')) return url;
if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}x-tos-process=image/resize,h_${height}`;
const rewritten = rewriteTosUrl(url);
const sep = rewritten.includes('?') ? '&' : '?';
return `${rewritten}${sep}x-tos-process=image/resize,h_${height}`;
}
export default api;

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { adminApi } from '../lib/api';
import { adminApi, rewriteTosUrl } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
@ -21,7 +21,7 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
onClick={onClick}
>
{video.result_url ? (
<video ref={videoRef} src={video.result_url} className={styles.thumbVideo} muted loop preload="metadata" />
<video ref={videoRef} src={rewriteTosUrl(video.result_url)} className={styles.thumbVideo} muted loop preload="metadata" />
) : (
<div className={styles.thumbPlaceholder} />
)}

View File

@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import { rewriteTosUrl } from '../lib/api';
import { ConfirmModal } from '../components/ConfirmModal';
import type { GenerationTask } from '../types';
import styles from './AssetsPage.module.css';
@ -70,7 +71,7 @@ function VideoThumbnail({
{task.resultUrl ? (
<video
ref={videoRef}
src={task.resultUrl}
src={rewriteTosUrl(task.resultUrl)}
className={styles.thumbVideo}
muted
loop

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { teamApi } from '../lib/api';
import { teamApi, rewriteTosUrl } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
@ -21,7 +21,7 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
onClick={onClick}
>
{video.result_url ? (
<video ref={videoRef} src={video.result_url} className={styles.thumbVideo} muted loop preload="metadata" />
<video ref={videoRef} src={rewriteTosUrl(video.result_url)} className={styles.thumbVideo} muted loop preload="metadata" />
) : (
<div className={styles.thumbPlaceholder} />
)}