Add native MoneyPrinter short video factory

This commit is contained in:
boos
2026-06-02 15:26:42 +08:00
parent 056e9fb651
commit b29c7ffa7a
6 changed files with 1226 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, userApi, type MoneyPrinterJob } from '../api.js';
import type { SessionAudience } from '../authSession.js';
const STYLE_PRESETS = [
'爆款短视频,节奏紧凑,适合口播',
'商品种草,真实体验,强转化',
'知识科普,信息密度高,画面干净',
'电影感广告,镜头运动丰富,高级质感',
];
function terminal(status: string) {
return ['succeeded', 'failed', 'cancelled'].includes(status);
}
function statusLabel(status: string) {
if (status === 'succeeded') return '完成';
if (status === 'failed') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'running') return '生成中';
return '排队中';
}
function progress(job: MoneyPrinterJob) {
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
}
function formatTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', { hour12: false });
}
function jobAssets(job: MoneyPrinterJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : [];
}
function planValue(job: MoneyPrinterJob, key: string) {
return job.result?.script?.plan?.[key] || '';
}
export default function MoneyPrinter() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
const [scriptModel, setScriptModel] = useState('gpt-5.4');
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
const [style, setStyle] = useState(STYLE_PRESETS[0]);
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
const [duration, setDuration] = useState(30);
const [ratio, setRatio] = useState('9:16');
const [resolution, setResolution] = useState('1080p');
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const loadJobs = useCallback(async () => {
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
}, [client]);
useEffect(() => {
loadJobs().catch((err) => setError(err?.message || '短视频任务加载失败'));
}, [loadJobs]);
useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => {
client.getMoneyPrinterJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || []))
.catch(() => {});
}, 5000);
return () => window.clearInterval(timer);
}, [client, jobs]);
const submit = async () => {
if (!topic.trim() || submitting) return;
setSubmitting(true);
setError('');
try {
const payload = await client.createMoneyPrinterJob({
topic: topic.trim(),
scriptModel: scriptModel.trim(),
videoModel: videoModel.trim(),
style,
audience: audienceText,
duration,
ratio,
resolution,
requirements,
language: '中文',
});
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
} catch (err: any) {
setError(err?.message || '短视频任务创建失败');
} finally {
setSubmitting(false);
}
};
const cancel = async (id: string) => {
const payload = await client.cancelMoneyPrinterJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
};
const clear = async () => {
await client.clearMoneyPrinterJobs();
await loadJobs();
};
const retry = (job: MoneyPrinterJob) => {
const request = job.result?.request || {};
setTopic(request.topic || job.result?.topic || topic);
setScriptModel(request.scriptModel || scriptModel);
setVideoModel(request.videoModel || request.model || videoModel);
setStyle(request.style || style);
setAudienceText(request.audience || audienceText);
setDuration(Number(request.duration || duration));
setRatio(request.ratio || ratio);
setResolution(request.resolution || resolution);
setRequirements(request.requirements || requirements);
};
return (
<div className="money-printer-page animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">短视频工厂</h1>
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}>
{submitting ? '提交中...' : '生成短视频'}
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div className="money-printer-layout">
<section className="money-printer-panel">
<div className="money-printer-hero">
<span>MoneyPrinterTurbo inside</span>
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong>
</div>
<label className="money-printer-field">
<span>短视频主题</span>
<textarea value={topic} onChange={(event) => setTopic(event.target.value)} rows={4} />
</label>
<div className="money-printer-form-grid">
<label className="money-printer-field">
<span>脚本模型</span>
<input value={scriptModel} onChange={(event) => setScriptModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>视频模型</span>
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>时长</span>
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} />
</label>
<label className="money-printer-field">
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label className="money-printer-field">
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
</label>
</div>
<label className="money-printer-field">
<span>风格</span>
<select value={style} onChange={(event) => setStyle(event.target.value)}>
{STYLE_PRESETS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="money-printer-field">
<span>目标受众</span>
<input value={audienceText} onChange={(event) => setAudienceText(event.target.value)} />
</label>
<label className="money-printer-field">
<span>额外要求</span>
<textarea value={requirements} onChange={(event) => setRequirements(event.target.value)} rows={4} />
</label>
</section>
<section className="money-printer-panel">
<div className="money-printer-section-head">
<div>
<h2>任务队列</h2>
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
</div>
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button>
</div>
<div className="money-printer-job-list">
{jobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 220 }}>
<div className="empty-state-title">暂无短视频任务</div>
<div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div>
</div>
) : jobs.map((job) => {
const percent = progress(job);
const assets = jobAssets(job);
return (
<article key={job.id} className="money-printer-job-card">
<div className="money-printer-job-head">
<div>
<strong>{job.title || '短视频工厂任务'}</strong>
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
</div>
<em>{statusLabel(job.status)}</em>
</div>
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
<div className="money-printer-job-meta">
<span>进度 {percent}%</span>
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
</div>
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
{job.error && <div className="money-printer-job-error">{job.error}</div>}
{assets.length > 0 && (
<div className="money-printer-assets">
{assets.map((asset: any) => (
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
成片 · {asset.publicId || asset.status}
</a>
))}
</div>
)}
<div className="money-printer-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
</div>
</article>
);
})}
</div>
</section>
</div>
</div>
);
}