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 { useGenerationStore } from '../store/generation';
import { showToast } from './Toast'; import { showToast } from './Toast';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { tosThumb } from '../lib/api'; import { tosThumb, rewriteTosUrl } from '../lib/api';
import styles from './GenerationCard.module.css'; import styles from './GenerationCard.module.css';
const EditIcon = () => ( const EditIcon = () => (
@ -314,7 +314,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
onMouseLeave={() => setRefPreview(null)} onMouseLeave={() => setRefPreview(null)}
> >
{ref.type === 'video' && !ref.isAssetRef ? ( {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' ? ( ) : ref.type === 'audio' ? (
<div className={styles.audioThumb}> <div className={styles.audioThumb}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> <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( {refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}> <div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' && !refPreview.isAssetRef ? ( {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'; }} /> <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 <video
ref={videoRef} ref={videoRef}
src={task.resultUrl} src={rewriteTosUrl(task.resultUrl)}
className={styles.resultMedia} className={styles.resultMedia}
loop loop
preload="metadata" preload="metadata"

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard'; import { renderPromptWithMentions } from './GenerationCard';
import { tosThumb } from '../lib/api'; import { tosThumb, rewriteTosUrl } from '../lib/api';
import styles from './VideoDetailModal.module.css'; import styles from './VideoDetailModal.module.css';
interface Props { interface Props {
@ -302,7 +302,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
> >
<video <video
ref={videoRef} ref={videoRef}
src={task.resultUrl} src={rewriteTosUrl(task.resultUrl)}
className={styles.video} className={styles.video}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
@ -487,7 +487,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div key={ref.id} className={styles.refItem}> <div key={ref.id} className={styles.refItem}>
<div style={{ position: 'relative', width: 56, height: 56 }}> <div style={{ position: 'relative', width: 56, height: 56 }}>
{ref.type === 'video' && !ref.isAssetRef ? ( {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' ? ( ) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, 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"> <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()}> <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> <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' ? ( {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={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: '#888' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> <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>
)} )}
</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`), 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. * Append TOS image resize parameter to reduce loading size.
* Only applies to TOS image URLs (volces.com with image extensions). * 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 等) // 只对我们自己的 TOS 桶生效airdrama-media不处理火山内部桶ark-media-asset 等)
if (!url.includes('airdrama-media')) return url; if (!url.includes('airdrama-media')) return url;
if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url; if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url;
const sep = url.includes('?') ? '&' : '?'; const rewritten = rewriteTosUrl(url);
return `${url}${sep}x-tos-process=image/resize,h_${height}`; const sep = rewritten.includes('?') ? '&' : '?';
return `${rewritten}${sep}x-tos-process=image/resize,h_${height}`;
} }
export default api; export default api;

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { teamApi } from '../lib/api'; import { teamApi, rewriteTosUrl } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal'; import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetMemberSummary, AssetVideo, GenerationTask } from '../types'; import type { AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css'; import styles from './AdminAssetsPage.module.css';
@ -21,7 +21,7 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
onClick={onClick} onClick={onClick}
> >
{video.result_url ? ( {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} /> <div className={styles.thumbPlaceholder} />
)} )}