// video-tool.jsx — Dedicated video generation page
// Tabs: 参考图生成视频 | 开始/结束帧 | 文生视频
// URL: /video?tab=ref | /video?tab=frames | /video?tab=text
const VIDEO_ACCENT = '#22d3ee';
const VIDEO_ACCENT_2 = '#a78bfa';
// ── OSS thumbnail helpers ────────────────────────────────────────────────────
// Delegates to api.js's getCdnDomain() — single fetch shared across all pages.
let _vtCdnDomain = null;
const _vtFetchCdn = () => {
if (_vtCdnDomain !== null) return Promise.resolve(_vtCdnDomain);
const p = window.YiteAPI?.getCdnDomain?.() || Promise.resolve('');
return p.then(d => { _vtCdnDomain = d; return d; });
};
// Returns a small thumbnail URL using OSS image processing if the URL is
// from our CDN. Falls back to the original URL for external/provider URLs.
const _vtThumbUrl = (url, tag, cdnDomain) => {
if (!url || !cdnDomain || !url.startsWith(cdnDomain) || url.includes('?')) return url;
if (tag === 'video') return url + '?x-oss-process=video/snapshot,t_1000,f_jpg,w_200,h_200,m_fast';
if (tag === 'image') return url + '?x-oss-process=image/resize,w_200,h_200,m_fill/format,webp';
return url;
};
const _isVideoUrl = url => /\.(mp4|mov|webm|avi|mkv)(\?|$)/i.test(url);
const ResultMedia = ({ url, style: s }) => _isVideoUrl(url)
?
:
;
const VIDEO_TABS = [
{ id: 'ref', labelKey: 'video.tab.ref', icon: 'image' },
{ id: 'frames', labelKey: 'video.tab.frames', icon: 'video' },
{ id: 'text', labelKey: 'video.tab.text', icon: 'pen' },
];
const VIDEO_TASK_FILTER_KEYS = [
{ label: '全部', key: 'video.filter.all' },
{ label: '参考生视频', key: 'video.filter.ref' },
{ label: '开始结束帧', key: 'video.filter.frames' },
{ label: '文生视频', key: 'video.filter.text' },
];
// ─── Model selector (video only) ─────────────────────────────────────────────
// ─── Model selector — full-screen modal ──────────────────────────────────────
const _isNewModel = m => {
if (!m.created_at) return false;
const diff = Date.now() - new Date(m.created_at).getTime();
return diff < 30 * 86400000; // 30 days
};
const ModelPickerModal = ({ models, value, onChange, accent, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [search, setSearch] = React.useState('');
const [platform, setPlatform] = React.useState('');
const platforms = [...new Set(models.map(m => m.platform).filter(Boolean))];
const filtered = models.filter(m =>
(!search || m.name.toLowerCase().includes(search.toLowerCase())) &&
(!platform || m.platform === platform)
);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{/* Header */}
Model
{/* Filters */}
{platforms.length > 1 && (
)}
setSearch(e.target.value)} placeholder="Search"
style={{ height: 34, paddingLeft: 28, paddingRight: 10, width: 150, borderRadius: 8, border: '1px solid var(--line-2)', background: 'rgba(255,255,255,0.03)', color: 'var(--fg-0)', fontSize: 14, outline: 'none' }} />
{/* List */}
{filtered.map(m => {
const isSelected = value === m.id;
const isNew = _isNewModel(m);
return (
);
})}
{filtered.length === 0 &&
{t('creation.noModels')}
}
,
document.body
);
};
const VModelSelect = ({ value, onChange, models }) => {
const { t } = useI18n();
const active = models.find(m => m.id === value) || models[0];
const [open, setOpen] = React.useState(false);
return (
{open &&
setOpen(false)} />}
);
};
// ─── Aspect ratio select with icons ───────────────────────────────────────────
const ASPECT_RATIOS = [
{ value: '1:1', w: 1, h: 1 },
{ value: '16:9', w: 16, h: 9 },
{ value: '9:16', w: 9, h: 16 },
{ value: '4:3', w: 4, h: 3 },
{ value: '3:4', w: 3, h: 4 },
];
const AspectRatioIcon = ({ w, h, size = 18, color = 'currentColor' }) => {
if (w === 0 && h === 0) {
// Auto icon — overlapping squares
return (
);
}
const maxDim = size - 4;
const scale = maxDim / Math.max(w, h);
const rw = Math.round(w * scale);
const rh = Math.round(h * scale);
const x = Math.round((size - rw) / 2);
const y = Math.round((size - rh) / 2);
return (
);
};
const AspectRatioSelect = ({ value, onChange }) => (
{ASPECT_RATIOS.map(r => {
const isActive = r.value === value;
return (
);
})}
);
// ─── Info tooltip (click/hover, works on mobile) ──────────────────────────────
const InfoTip = ({ text }) => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', close);
document.addEventListener('touchstart', close);
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('touchstart', close); };
}, [open]);
return (
{ e.stopPropagation(); setOpen(v => !v); }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
style={{ fontSize: 13, color: 'var(--fg-3)', cursor: 'pointer', lineHeight: 1, userSelect: 'none' }}>ⓘ
{open && (
{text}
)}
);
};
// ─── Param row ────────────────────────────────────────────────────────────────
const VParamRow = ({ label, children, info }) => (
{label}
{info && }
{children}
);
// ─── Toggle ───────────────────────────────────────────────────────────────────
const VToggleRow = ({ label, sublabel, info, checked, onChange }) => (
{label}
{sublabel && {checked ? 'On' : 'Off'}}
{info && }
onChange && onChange(!checked)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2,
background: checked ? VIDEO_ACCENT : 'rgba(255,255,255,0.1)',
cursor: 'pointer', transition: 'background .15s', display: 'flex', alignItems: 'center',
}}>
);
// ─── Asset picker API (reuse from asset-library) ─────────────────────────────
const _assetToken = () => window.YiteAPI?.getToken?.() || localStorage.getItem('token') || '';
const _assetAPI = {
list: (parentID = '', tag = '') => {
let url = '/api/v1/assets?parent_id=' + encodeURIComponent(parentID);
if (tag) url += '&tag=' + encodeURIComponent(tag);
return fetch(url, { headers: { Authorization: 'Bearer ' + _assetToken() } }).then(r => r.json());
},
upload: (file, parentID = '') => {
const fd = new FormData();
fd.append('file', file);
fd.append('parent_id', parentID);
return fetch('/api/v1/assets/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + _assetToken() }, body: fd }).then(r => r.json());
},
};
// ─── Video task API ───────────────────────────────────────────────────────────
const _videoTaskAPI = {
list: (type = '') => {
let url = '/api/v1/create-tasks?media_type=video';
if (type && type !== '全部') url += '&type=' + encodeURIComponent(type);
return fetch(url, { headers: { Authorization: 'Bearer ' + _assetToken() } }).then(r => r.json());
},
create: body =>
fetch('/api/v1/create-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _assetToken() },
body: JSON.stringify({ ...body, media_type: 'video' }),
}).then(r => r.json()),
remove: id =>
fetch('/api/v1/create-tasks/' + id, {
method: 'DELETE', headers: { Authorization: 'Bearer ' + _assetToken() },
}).then(r => r.json()),
publishTemplate: body =>
fetch('/api/v1/templates', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _assetToken() },
body: JSON.stringify(body),
}).then(r => r.json()),
unpublishTemplate: templateId =>
fetch('/api/v1/templates/' + templateId + '/unpublish', {
method: 'POST', headers: { Authorization: 'Bearer ' + _assetToken() },
}).then(r => r.json()),
uploadCover: file => {
const fd = new FormData(); fd.append('file', file);
return fetch('/api/v1/templates/cover', { method: 'POST', headers: { Authorization: 'Bearer ' + _assetToken() }, body: fd }).then(r => r.json());
},
};
// ─── Relative time helper — delegate to global from shared.jsx ────────────────
const _relTime = iso => (typeof relativeTime === 'function' ? relativeTime(iso) : (iso ? new Date(iso).toLocaleDateString() : ''));
// ─── Folder picker modal (for choosing save target) ──────────────────────────
const FolderPickerModal = ({ onSelect, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [folders, setFolders] = React.useState([]);
const [path, setPath] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const parentID = path.length > 0 ? path[path.length - 1].id : '';
const fetchFolders = async () => {
setLoading(true);
const res = await _assetAPI.list(parentID, '');
setFolders((res.items || []).filter(i => i.is_folder));
setLoading(false);
};
React.useEffect(() => { fetchFolders(); }, [parentID]);
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}>
{t('video.selectFolder')}
{path.map((p, i) => (
))}
{loading ? (
) : folders.length > 0 ? (
{folders.map(f => {
const hue = (f.name.charCodeAt(0) * 37) % 360;
return (
{ e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'var(--line-2)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}>
{ e.stopPropagation(); setPath([...path, { id: f.id, name: f.name }]); }} style={{ flex: 1, fontSize: 14, fontWeight: 500, color: 'var(--fg-0)', cursor: 'pointer' }}>{f.name}
);
})}
) : (
{t('video.noFolders')}
)}
{/* Save to current folder */}
,
document.body
);
};
// ─── Asset picker modal ──────────────────────────────────────────────────────
const _ASSET_TYPE_INFO = {
image: { label: '图片', color: '#22d3ee', bg: 'rgba(34,211,238,0.15)' },
video: { label: '视频', color: '#a78bfa', bg: 'rgba(167,139,250,0.15)' },
audio: { label: '音频', color: '#34d399', bg: 'rgba(52,211,153,0.15)' },
};
const _assetTypeInfo = t => _ASSET_TYPE_INFO[t] || { label: t || '?', color: 'var(--fg-3)', bg: 'rgba(255,255,255,0.08)' };
const AssetPickerModal = ({ tag = 'image', max = 6, existing = 0, onSelect, onClose }) => {
useModalLock(true, onClose);
const { t } = useI18n();
const [folders, setFolders] = React.useState([]);
const [allFiles, setAllFiles] = React.useState([]);
const [path, setPath] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [selected, setSelected] = React.useState(new Set());
const [typeFilter, setTypeFilter] = React.useState(tag);
const [viewMode, setViewMode] = React.useState('list');
const [cdnDomain, setCdnDomain] = React.useState(() => _vtCdnDomain || window.YiteAPI?.cdnDomain || '');
const pendingSelectRef = React.useRef(null);
const uploadRef = React.useRef(null);
const parentID = path.length > 0 ? path[path.length - 1].id : '';
const remaining = max - existing;
const files = typeFilter === 'all' ? allFiles : allFiles.filter(f => f.tag === typeFilter);
const fetchItems = async () => {
setLoading(true);
const res = await _assetAPI.list(parentID, '');
const items = res.items || [];
setFolders(items.filter(i => i.is_folder));
const newFiles = items.filter(i => !i.is_folder);
setAllFiles(newFiles);
if (pendingSelectRef.current) {
const id = pendingSelectRef.current;
pendingSelectRef.current = null;
if (newFiles.some(f => f.id === id)) {
setSelected(prev => {
const next = new Set(prev);
if (next.size < remaining) next.add(id);
return next;
});
}
}
setLoading(false);
};
React.useEffect(() => {
fetchItems();
if (!_vtCdnDomain) _vtFetchCdn().then(d => setCdnDomain(d));
}, [parentID]); // eslint-disable-line react-hooks/exhaustive-deps
const toggleSelect = id => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); }
else if (next.size < remaining) { next.add(id); }
return next;
});
};
const handleUpload = async e => {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
setLoading(true);
const res = await _assetAPI.upload(f, parentID);
if (!res.error) {
if (res.item?.id) pendingSelectRef.current = res.item.id;
fetchItems();
} else {
setLoading(false);
}
};
const handleConfirm = () => {
const picked = files.filter(f => selected.has(f.id)).map(f => ({ id: f.id, url: f.url, name: f.name }));
onSelect(picked);
onClose();
};
const uploadAccept = typeFilter === 'audio' ? 'audio/*' : typeFilter === 'video' ? 'video/*' : typeFilter === 'image' ? 'image/*' : '*/*';
const TYPE_FILTERS = [
{ key: 'all', label: '全部' }, { key: 'image', label: '图片' },
{ key: 'video', label: '视频' }, { key: 'audio', label: '音频' },
];
// All thumbnails use OSS-processed small URLs when CDN domain is known.
// Videos use a static JPEG snapshot instead of a