feat: refine money printer comfy template mode

This commit is contained in:
boos
2026-06-06 03:09:08 +08:00
parent 2b836b3e1e
commit e72e323313
4 changed files with 144 additions and 21 deletions
+20 -1
View File
@@ -126,6 +126,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> { async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?; let auth = resolve_auth(&headers, &state).await?;
let cloud_backend = state.config.comfyui.uses_cloud_backend();
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"mode": "boosapi-native", "mode": "boosapi-native",
@@ -142,6 +143,15 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
"enabled": state.config.comfyui.uses_native_backend(), "enabled": state.config.comfyui.uses_native_backend(),
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(), "backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"baseUrl": state.config.comfyui.target_base_url() "baseUrl": state.config.comfyui.target_base_url()
},
"builtinCloudTemplate": {
"enabled": cloud_backend,
"label": "内置 Cloud Seedance 文生视频",
"nodeType": "ByteDanceTextToVideoNode",
"model": "seedance-1-0-lite-t2v-250428",
"duration": { "min": 3, "max": 12, "default": 5 },
"resolutions": ["480p", "720p", "1080p"],
"ratios": ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"]
} }
}))) })))
} }
@@ -904,9 +914,18 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
} }
if inputs if inputs
.get("resolution") .get("resolution")
.is_some_and(|value| !matches!(value.as_str(), Some("480p" | "720p" | "1080p"))) .is_some_and(|value| {
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
})
{ {
inputs.insert("resolution".to_string(), json!("480p")); inputs.insert("resolution".to_string(), json!("480p"));
} else if let Some(normalized) = inputs
.get("resolution")
.and_then(Value::as_str)
.map(|value| value.trim().to_ascii_lowercase())
{
inputs.insert("resolution".to_string(), json!(normalized));
} }
if inputs if inputs
.get("aspect_ratio") .get("aspect_ratio")
+29
View File
@@ -1196,6 +1196,31 @@ export type MoneyPrinterJobsResponse = {
jobs: MoneyPrinterJob[]; jobs: MoneyPrinterJob[];
}; };
export type MoneyPrinterStatusResponse = {
success: boolean;
mode: string;
keyName?: string;
capabilities?: string[];
comfy?: {
enabled?: boolean;
backend?: string;
baseUrl?: string;
};
builtinCloudTemplate?: {
enabled?: boolean;
label?: string;
nodeType?: string;
model?: string;
duration?: {
min?: number;
max?: number;
default?: number;
};
resolutions?: string[];
ratios?: string[];
};
};
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -2093,6 +2118,8 @@ export const api = {
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
request<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) => createMoneyPrinterJob: (data: any) =>
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", { request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST", method: "POST",
@@ -2196,6 +2223,8 @@ export const userApi = {
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
userRequest<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) => createMoneyPrinterJob: (data: any) =>
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", { userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST", method: "POST",
+5
View File
@@ -9351,6 +9351,11 @@ textarea {
background: var(--color-bg-card); background: var(--color-bg-card);
} }
.money-printer-template-meta.is-warning {
border-color: color-mix(in srgb, var(--color-warning) 38%, var(--color-border-light));
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-bg-card));
}
.money-printer-template-meta strong { .money-printer-template-meta strong {
font-size: 13px; font-size: 13px;
} }
+90 -20
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api, userApi, type MoneyPrinterJob, type WorkflowSummary } from '../api.js'; import { api, userApi, type MoneyPrinterJob, type MoneyPrinterStatusResponse, type WorkflowSummary } from '../api.js';
import type { SessionAudience } from '../authSession.js'; import type { SessionAudience } from '../authSession.js';
const STYLE_PRESETS = [ const STYLE_PRESETS = [
@@ -225,6 +225,10 @@ function parseJsonInput(value: string, label: string) {
} }
const BUILTIN_CLOUD_TEMPLATE_VALUE = '__builtin_cloud_seedance__'; const BUILTIN_CLOUD_TEMPLATE_VALUE = '__builtin_cloud_seedance__';
const DEFAULT_BUILTIN_CLOUD_DURATION = 5;
const DEFAULT_BUILTIN_CLOUD_RESOLUTION = '480p';
const DEFAULT_CLOUD_RATIOS = ['9:16', '16:9', '1:1'];
const DEFAULT_CLOUD_RESOLUTIONS = ['480p', '720p', '1080p'];
export default function MoneyPrinter() { export default function MoneyPrinter() {
const location = useLocation(); const location = useLocation();
@@ -247,14 +251,40 @@ export default function MoneyPrinter() {
const [templateId, setTemplateId] = useState(''); const [templateId, setTemplateId] = useState('');
const [showAdvancedComfy, setShowAdvancedComfy] = useState(false); const [showAdvancedComfy, setShowAdvancedComfy] = useState(false);
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]); const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
const [status, setStatus] = useState<MoneyPrinterStatusResponse | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
const cloudResolutions = useMemo(
() => status?.builtinCloudTemplate?.resolutions?.length
? status.builtinCloudTemplate.resolutions
: DEFAULT_CLOUD_RESOLUTIONS,
[status?.builtinCloudTemplate?.resolutions],
);
const cloudRatios = useMemo(
() => status?.builtinCloudTemplate?.ratios?.length
? status.builtinCloudTemplate.ratios
: DEFAULT_CLOUD_RATIOS,
[status?.builtinCloudTemplate?.ratios],
);
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
const payload = await client.getMoneyPrinterJobs({ limit: 30 }); const payload = await client.getMoneyPrinterJobs({ limit: 30 });
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []); setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
}, [client]); }, [client]);
const loadStatus = useCallback(async () => {
const payload = await client.getMoneyPrinterStatus();
setStatus(payload);
}, [client]);
const refresh = useCallback(async () => {
await Promise.all([loadJobs(), loadStatus()]);
}, [loadJobs, loadStatus]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
const payload = await client.getWorkflows({ include_public: true }); const payload = await client.getWorkflows({ include_public: true });
const items = Array.isArray(payload.items) ? payload.items : []; const items = Array.isArray(payload.items) ? payload.items : [];
@@ -262,19 +292,44 @@ export default function MoneyPrinter() {
setWorkflows(videoItems); setWorkflows(videoItems);
setTemplateId((current) => { setTemplateId((current) => {
if (current && videoItems.some((item) => item.publicId === current)) return current; if (current && videoItems.some((item) => item.publicId === current)) return current;
if (isCloudComfy) return BUILTIN_CLOUD_TEMPLATE_VALUE;
return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|| videoItems[0]?.publicId || videoItems[0]?.publicId
|| ''; || '';
}); });
}, [client]); }, [client, isCloudComfy]);
useEffect(() => { useEffect(() => {
Promise.all([loadJobs(), loadWorkflows()]).catch((err) => setError(err?.message || '短视频任务加载失败')); Promise.all([loadJobs(), loadWorkflows(), loadStatus()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
}, [loadJobs, loadWorkflows]); }, [loadJobs, loadWorkflows, loadStatus]);
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null; const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
const useBuiltinCloudTemplate = videoBackend === 'comfy' && (!templateId || templateId === BUILTIN_CLOUD_TEMPLATE_VALUE); const useBuiltinCloudTemplate = videoBackend === 'comfy' && isCloudComfy && templateId === BUILTIN_CLOUD_TEMPLATE_VALUE;
const canSubmit = Boolean(topic.trim()) && !submitting; const comfyHasTemplate = useBuiltinCloudTemplate || Boolean(templateId && selectedWorkflow);
const canSubmit = Boolean(topic.trim()) && !submitting && (videoBackend !== 'comfy' || comfyHasTemplate);
const ratioOptions = useBuiltinCloudTemplate ? cloudRatios : DEFAULT_CLOUD_RATIOS;
const resolutionOptions = useBuiltinCloudTemplate ? cloudResolutions : DEFAULT_CLOUD_RESOLUTIONS;
const durationMin = useBuiltinCloudTemplate ? cloudDurationMin : 5;
const durationMax = useBuiltinCloudTemplate ? cloudDurationMax : 120;
useEffect(() => {
if (videoBackend !== 'comfy') return;
if (isCloudComfy) {
setTemplateId((current) => current || BUILTIN_CLOUD_TEMPLATE_VALUE);
return;
}
setTemplateId((current) => current === BUILTIN_CLOUD_TEMPLATE_VALUE ? '' : current);
}, [isCloudComfy, videoBackend]);
useEffect(() => {
if (!useBuiltinCloudTemplate) return;
setDuration((current) => {
const numeric = Number.isFinite(current) ? current : cloudDefaultDuration;
return Math.max(cloudDurationMin, Math.min(cloudDurationMax, numeric));
});
setResolution((current) => cloudResolutions.includes(current) ? current : DEFAULT_BUILTIN_CLOUD_RESOLUTION);
setRatio((current) => cloudRatios.includes(current) ? current : '9:16');
}, [cloudDefaultDuration, cloudDurationMax, cloudDurationMin, cloudRatios, cloudResolutions, useBuiltinCloudTemplate]);
useEffect(() => { useEffect(() => {
if (useBuiltinCloudTemplate) { if (useBuiltinCloudTemplate) {
@@ -304,6 +359,16 @@ export default function MoneyPrinter() {
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
try { try {
if (videoBackend === 'comfy' && !comfyHasTemplate) {
throw new Error(isCloudComfy ? '请选择 Cloud 内置模板或一个真实视频工作流模板' : '当前 Comfy 后端需要选择一个真实视频工作流模板');
}
const nextDuration = useBuiltinCloudTemplate
? Math.max(cloudDurationMin, Math.min(cloudDurationMax, Number(duration) || cloudDefaultDuration))
: duration;
const nextResolution = useBuiltinCloudTemplate && !cloudResolutions.includes(resolution)
? DEFAULT_BUILTIN_CLOUD_RESOLUTION
: resolution;
const nextRatio = useBuiltinCloudTemplate && !cloudRatios.includes(ratio) ? '9:16' : ratio;
const payload = await client.createMoneyPrinterJob({ const payload = await client.createMoneyPrinterJob({
topic: topic.trim(), topic: topic.trim(),
scriptModel: scriptModel.trim(), scriptModel: scriptModel.trim(),
@@ -311,9 +376,9 @@ export default function MoneyPrinter() {
videoModel: videoModel.trim(), videoModel: videoModel.trim(),
style, style,
audience: audienceText, audience: audienceText,
duration, duration: nextDuration,
ratio, ratio: nextRatio,
resolution, resolution: nextResolution,
requirements, requirements,
language: '中文', language: '中文',
...(videoBackend === 'comfy' ? { ...(videoBackend === 'comfy' ? {
@@ -366,7 +431,7 @@ export default function MoneyPrinter() {
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p> <p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button> <button type="button" className="btn btn-ghost" onClick={() => void refresh()}>刷新</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}> <button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? '提交中...' : '生成短视频'} {submitting ? '提交中...' : '生成短视频'}
</button> </button>
@@ -410,22 +475,18 @@ export default function MoneyPrinter() {
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>时长</span> <span>时长</span>
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} /> <input type="number" min={durationMin} max={durationMax} value={duration} onChange={(event) => setDuration(Number(event.target.value || durationMin))} />
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>画幅</span> <span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}> <select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option> {ratioOptions.map((item) => <option key={item} value={item}>{item}</option>)}
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select> </select>
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>分辨率</span> <span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}> <select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="1080p">1080p</option> {resolutionOptions.map((item) => <option key={item} value={item}>{item}</option>)}
<option value="720p">720p</option>
<option value="480p">480p</option>
</select> </select>
</label> </label>
</div> </div>
@@ -458,7 +519,8 @@ export default function MoneyPrinter() {
<label className="money-printer-field"> <label className="money-printer-field">
<span>工作流模板</span> <span>工作流模板</span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}> <select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value={BUILTIN_CLOUD_TEMPLATE_VALUE}>内置 Cloud Seedance 文生视频</option> {isCloudComfy && <option value={BUILTIN_CLOUD_TEMPLATE_VALUE}>内置 Cloud Seedance 文生视频</option>}
{!isCloudComfy && <option value="">选择真实 Comfy 视频模板</option>}
{workflows.map((workflow) => ( {workflows.map((workflow) => (
<option key={workflow.publicId} value={workflow.publicId}> <option key={workflow.publicId} value={workflow.publicId}>
{workflowLooksVideoCapable(workflow) ? '视频 · ' : '模板 · '}{workflowOptionLabel(workflow)} {workflowLooksVideoCapable(workflow) ? '视频 · ' : '模板 · '}{workflowOptionLabel(workflow)}
@@ -469,7 +531,10 @@ export default function MoneyPrinter() {
{useBuiltinCloudTemplate ? ( {useBuiltinCloudTemplate ? (
<div className="money-printer-template-meta"> <div className="money-printer-template-meta">
<strong>内置 Cloud Seedance 文生视频</strong> <strong>内置 Cloud Seedance 文生视频</strong>
<span>无需导入模板,使用 Comfy Cloud 官方 ByteDanceTextToVideoNode。若账号无余额,会直接显示余额不足。</span> <span>
当前后端为 Cloud,使用官方 {status?.builtinCloudTemplate?.nodeType || 'ByteDanceTextToVideoNode'}
{status?.builtinCloudTemplate?.model ? ` / ${status.builtinCloudTemplate.model}` : ''}。时长 {cloudDurationMin}-{cloudDurationMax} 秒,若账号无余额会直接显示余额不足。
</span>
</div> </div>
) : selectedWorkflow ? ( ) : selectedWorkflow ? (
<div className="money-printer-template-meta"> <div className="money-printer-template-meta">
@@ -480,7 +545,12 @@ export default function MoneyPrinter() {
{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '系统会按节点结构自动推断提示词和参数映射'} {mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '系统会按节点结构自动推断提示词和参数映射'}
</span> </span>
</div> </div>
) : null} ) : (
<div className="money-printer-template-meta is-warning">
<strong>需要视频工作流模板</strong>
<span>{isCloudComfy ? '可以使用内置 Cloud 模板,也可以选择模板库里的真实视频工作流。' : '当前不是 Cloud 后端,内置 Seedance 模板不可用,请选择 external/runninghub 可执行的视频模板。'}</span>
</div>
)}
<button <button
type="button" type="button"
className="btn btn-ghost money-printer-advanced-toggle" className="btn btn-ghost money-printer-advanced-toggle"