gengx
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use app\model\AiModel;
|
||||
use app\model\UploadFile;
|
||||
use app\model\VideoProject;
|
||||
use app\model\VideoShot;
|
||||
|
||||
/**
|
||||
* MiniMax H3 原生 ComfyUI API 接入。
|
||||
*
|
||||
* 只使用 ComfyUI core 节点,提供无参考的 FL2VA 和带角色图的 REF2VA 两条工作流。
|
||||
*/
|
||||
class MiniMaxH3Service
|
||||
{
|
||||
public const WORKFLOW_VERSION = 'minimax-h3-joint-av-v6';
|
||||
private const FL2VA_MODEL = 'minimax_h3_fl2va_pruned_int8_convrot.safetensors';
|
||||
private const REF2VA_MODEL = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors';
|
||||
private const TEXT_ENCODER = 'qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors';
|
||||
private const VIDEO_VAE = 'minimax_h3_video_vae_fp16.safetensors';
|
||||
private const AUDIO_VAE = 'minimax_h3_audio_vae_fp32.safetensors';
|
||||
|
||||
public static function workflowManifest(): array
|
||||
{
|
||||
return [
|
||||
'version' => self::WORKFLOW_VERSION,
|
||||
'fps' => 24,
|
||||
'supported_shot_durations' => [5, 10],
|
||||
'frames_by_duration' => ['5' => 124, '10' => 243],
|
||||
'trained_frame_range' => [124, 362],
|
||||
'fl2va_model' => self::FL2VA_MODEL,
|
||||
'ref2va_model' => self::REF2VA_MODEL,
|
||||
'text_encoder' => self::TEXT_ENCODER,
|
||||
'video_vae' => self::VIDEO_VAE,
|
||||
'audio_vae' => self::AUDIO_VAE,
|
||||
'text_render_policy' => 'clean_surface_plus_exact_ass_postprocess',
|
||||
'synced_project_settings' => [
|
||||
'aspect_ratio',
|
||||
'quality',
|
||||
'voice_language',
|
||||
'show_subtitles',
|
||||
'character_origin',
|
||||
'screen_text_language',
|
||||
'shot_duration_mode',
|
||||
],
|
||||
'nodes' => [
|
||||
'UNETLoader',
|
||||
'MiniMaxH3SigmaShift',
|
||||
'CLIPLoader',
|
||||
'VAELoader',
|
||||
'MiniMaxH3ImageToVideo / MiniMaxH3ReferenceToVideo',
|
||||
'ConditioningZeroOut',
|
||||
'KSampler',
|
||||
'LTXVSeparateAVLatent',
|
||||
'VAEDecode',
|
||||
'VAEDecodeAudio',
|
||||
'CreateVideo',
|
||||
'SaveVideo',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,AiModel> 每个 ComfyUI 地址只保留一个工作节点。 */
|
||||
public static function workers(): array
|
||||
{
|
||||
$models = AiModel::where('provider', 'comfy')
|
||||
->where('enabled', 1)
|
||||
->order('is_default', 'desc')
|
||||
->order('sort_order')
|
||||
->order('id')
|
||||
->select();
|
||||
$workers = [];
|
||||
foreach ($models as $model) {
|
||||
$endpoint = strtolower(self::baseUrl((string) $model->api_base_url));
|
||||
if (!isset($workers[$endpoint])) {
|
||||
$workers[$endpoint] = $model;
|
||||
}
|
||||
}
|
||||
if (!$workers) {
|
||||
throw new \RuntimeException('管理端尚未启用 ComfyUI 模型');
|
||||
}
|
||||
return array_values($workers);
|
||||
}
|
||||
|
||||
public static function workerSummary(): array
|
||||
{
|
||||
try {
|
||||
$workers = self::workers();
|
||||
$count = count($workers);
|
||||
return [
|
||||
'configured_workers' => $count,
|
||||
'effective_concurrency' => min(3, $count),
|
||||
'mode' => $count > 1 ? 'multi_endpoint_parallel' : 'single_endpoint_serial',
|
||||
'message' => $count > 1
|
||||
? "已配置 {$count} 个独立 ComfyUI 地址,最多并发 3 个镜头"
|
||||
: '当前只有 1 个 ComfyUI 地址;同地址任务按队列串行执行',
|
||||
];
|
||||
} catch (\Throwable $error) {
|
||||
return [
|
||||
'configured_workers' => 0,
|
||||
'effective_concurrency' => 0,
|
||||
'mode' => 'unavailable',
|
||||
'message' => $error->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public static function model(?int $workerId = null): AiModel
|
||||
{
|
||||
if ($workerId !== null && $workerId > 0) {
|
||||
$model = AiModel::where('provider', 'comfy')->where('id', $workerId)->find();
|
||||
if ($model) {
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
return self::workers()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $characters
|
||||
* @return array{prompt_id:string,workflow_type:string,reference_count:int,continuity_applied:bool,audio_mode:string,workflow_version:string,worker_id:int,worker_name:string,applied_settings:array<string,mixed>}
|
||||
*/
|
||||
public static function submitShot(
|
||||
VideoShot $shot,
|
||||
VideoProject $project,
|
||||
array $characters,
|
||||
?array $preparedReferenceFiles = null,
|
||||
?int $continuityUploadId = null,
|
||||
?AiModel $worker = null
|
||||
): array
|
||||
{
|
||||
$model = $worker ?? self::model();
|
||||
$baseUrl = self::baseUrl($model->api_base_url);
|
||||
$apiKey = (string) ($model->api_key ?? '');
|
||||
$referenceFiles = $preparedReferenceFiles
|
||||
?? self::uploadReferenceFiles($project, $characters, $baseUrl, $apiKey);
|
||||
|
||||
$continuityFile = $continuityUploadId
|
||||
? self::uploadContinuityFrame($project, $continuityUploadId, $baseUrl, $apiKey)
|
||||
: null;
|
||||
$characterReferenceCount = count($referenceFiles);
|
||||
$language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN'));
|
||||
$shotMeta = is_array($shot->meta) ? $shot->meta : [];
|
||||
$timeline = is_array($shotMeta['timeline'] ?? null) ? $shotMeta['timeline'] : [];
|
||||
$audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only'));
|
||||
$workflowPrompt = self::audioDirective($language, $audioMode, $timeline)
|
||||
. self::screenTextDirective(
|
||||
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN),
|
||||
$timeline
|
||||
)
|
||||
. (string) $shot->prompt;
|
||||
$firstFrameFile = null;
|
||||
|
||||
if ($continuityFile !== null && $referenceFiles) {
|
||||
// REF2VA 最多支持 9 张图,给真实连续帧固定保留最后一个槽位。
|
||||
$referenceFiles = array_slice($referenceFiles, 0, 8);
|
||||
$continuityPictureNo = count($referenceFiles) + 1;
|
||||
$referenceFiles[] = $continuityFile;
|
||||
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
|
||||
$workflowPrompt .= "。<Picture {$continuityPictureNo}> 是上一镜头的真实结束帧;本镜头第一帧必须复现其人物位置、脸部、服装、动作相位、构图、背景、光向和色温,再从该动作自然继续,禁止重新起势或跳切。";
|
||||
$workflowType = 'ref2va-continuity';
|
||||
} elseif ($continuityFile !== null) {
|
||||
$firstFrameFile = $continuityFile;
|
||||
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
|
||||
$workflowPrompt .= '。输入首帧是上一镜头的真实结束帧;必须从这张画面无缝继续人物动作、视线和摄影机运动,禁止改变脸、服装、背景、光线或重新起势。';
|
||||
$workflowType = 'i2v-continuity';
|
||||
} else {
|
||||
$workflowType = $referenceFiles ? 'ref2va' : 'fl2va';
|
||||
}
|
||||
[$width, $height, $steps] = self::generationPreset(
|
||||
(string) $project->aspect_ratio,
|
||||
(string) $project->quality
|
||||
);
|
||||
$shotDuration = in_array((int) $shot->duration_seconds, [5, 10], true)
|
||||
? (int) $shot->duration_seconds
|
||||
: 5;
|
||||
$frameLength = self::frameLengthForDuration($shotDuration);
|
||||
$workflow = self::buildWorkflow([
|
||||
'workflow_type' => $workflowType,
|
||||
'prompt' => $workflowPrompt,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'length' => $frameLength,
|
||||
'steps' => $steps,
|
||||
'seed' => (int) ($shot->seed ?: random_int(1, PHP_INT_MAX)),
|
||||
'reference_files' => $referenceFiles,
|
||||
'first_frame_file' => $firstFrameFile,
|
||||
'ref_image_size' => ($shotMeta['identity_boost'] ?? false) ? 'max' : 'match',
|
||||
]);
|
||||
|
||||
return [
|
||||
'prompt_id' => self::queuePrompt($baseUrl, $workflow, $apiKey),
|
||||
'workflow_type' => $workflowType,
|
||||
'reference_count' => $characterReferenceCount,
|
||||
'continuity_applied' => $continuityFile !== null,
|
||||
'audio_mode' => $audioMode,
|
||||
'workflow_version' => self::WORKFLOW_VERSION,
|
||||
'worker_id' => (int) $model->id,
|
||||
'worker_name' => (string) $model->name,
|
||||
'applied_settings' => [
|
||||
'aspect_ratio' => (string) $project->aspect_ratio,
|
||||
'quality' => (string) $project->quality,
|
||||
'voice_language' => $language,
|
||||
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
|
||||
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin(
|
||||
(string) ($project->character_origin ?? '')
|
||||
),
|
||||
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage(
|
||||
(string) ($project->screen_text_language ?? '')
|
||||
),
|
||||
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode(
|
||||
(string) ($project->shot_duration_mode ?? '')
|
||||
),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'steps' => $steps,
|
||||
'shot_duration_seconds' => $shotDuration,
|
||||
'frame_length' => $frameLength,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function screenTextDirective(string $language, array $timeline): string
|
||||
{
|
||||
$language = ShortDramaPlannerService::normalizeScreenTextLanguage($language);
|
||||
$screenText = trim((string) ($timeline['screen_text'] ?? ''));
|
||||
if ($language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE || $screenText === '') {
|
||||
return 'SCENE TEXT POLICY: render no readable glyphs. ';
|
||||
}
|
||||
$label = $language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US
|
||||
? 'English'
|
||||
: 'Simplified Chinese';
|
||||
return "SCENE TEXT POLICY: reserve a clean, stable, unobstructed surface for {$label} scene text, but draw no glyphs inside H3. Exact post-render text is: “{$screenText}”. The compositor will burn it in; do not invent pseudo-letters or symbols. ";
|
||||
}
|
||||
|
||||
private static function audioDirective(string $language, string $audioMode, array $timeline): string
|
||||
{
|
||||
$noText = 'ABSOLUTELY NO visible text, subtitles, captions, speech bubbles, typography, letters, numbers, logos, watermarks or interface. ';
|
||||
if ($language === VideoDubService::LANG_NONE || $audioMode === VideoDubService::AUDIO_AMBIENT) {
|
||||
return 'MINIMAX H3 JOINT AUDIO-VIDEO SHOT. Generate continuous synchronized scene ambience and physical action sounds only. '
|
||||
. 'NO dialogue, narration, singing, yelling, mumbling, pseudo-language or any human voice. Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
|
||||
. $noText;
|
||||
}
|
||||
if ($audioMode === VideoDubService::AUDIO_SCENE) {
|
||||
$soundEffects = is_array($timeline['sound_effects'] ?? null)
|
||||
? array_values(array_filter(array_map('trim', $timeline['sound_effects'])))
|
||||
: [];
|
||||
$soundRule = $soundEffects
|
||||
? 'Generate these exact synchronized diegetic sounds: ' . implode('; ', $soundEffects) . '. '
|
||||
: 'Generate only synchronized diegetic ambience and physical action sounds visible in the shot. ';
|
||||
return 'MINIMAX H3 NATIVE JOINT AUDIO-VIDEO SCENE-SOUND SHOT. ' . $soundRule
|
||||
. 'These are environmental/action sounds, never spoken words. NO dialogue, narration, off-screen voice, singing, yelling, crying speech, mumbling, gibberish or pseudo-language. '
|
||||
. 'Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
|
||||
. $noText;
|
||||
}
|
||||
|
||||
$speaker = trim((string) ($timeline['dialogue_speaker'] ?? '主角')) ?: '主角';
|
||||
$dialogue = trim((string) ($timeline['dialogue'] ?? ''));
|
||||
$delivery = trim((string) ($timeline['dialogue_delivery'] ?? '自然')) ?: '自然';
|
||||
if ($audioMode === VideoDubService::AUDIO_NARRATION) {
|
||||
return 'MINIMAX H3 VISUAL SHOT FOR POST-DUBBED NARRATION. Do not generate the narration or any other human voice; the raw H3 soundtrack will be discarded. '
|
||||
. 'All visible people keep their mouths naturally closed and never lip-sync, yell or perform speaking motion. '
|
||||
. 'NO visible character speech, NO off-screen speech, NO singing, NO gibberish and NO pseudo-language. '
|
||||
. $noText;
|
||||
}
|
||||
|
||||
$gender = trim((string) ($timeline['speaker_gender'] ?? 'auto'));
|
||||
$genderRule = $gender === 'auto'
|
||||
? 'The speaking voice must match the visible speaker’s actual sex, apparent age and identity.'
|
||||
: "Use a {$gender} voice matching the visible speaker’s age and identity.";
|
||||
return "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible {$speaker}, and nobody else, speaks exact standard Mandarin Chinese: “{$dialogue}”. "
|
||||
. "Delivery: {$delivery}. {$genderRule} The speaker starts with a closed mouth, opens the mouth only for this exact line with frame-accurate natural lip synchronization, then closes the mouth. "
|
||||
. 'NO narrator, NO off-screen voice, NO second speaker, NO voice/sex mismatch, NO extra words, NO repeated words, NO gibberish and NO pseudo-language. Keep synchronized scene ambience and action sounds. '
|
||||
. $noText;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次整集提交只上传一次角色图,所有镜头复用同一个 ComfyUI input 文件。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $characters
|
||||
* @return string[]
|
||||
*/
|
||||
public static function prepareReferenceFiles(
|
||||
VideoProject $project,
|
||||
array $characters,
|
||||
?AiModel $worker = null
|
||||
): array
|
||||
{
|
||||
$model = $worker ?? self::model();
|
||||
return self::uploadReferenceFiles(
|
||||
$project,
|
||||
$characters,
|
||||
self::baseUrl($model->api_base_url),
|
||||
(string) ($model->api_key ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function uploadReferenceFiles(
|
||||
VideoProject $project,
|
||||
array $characters,
|
||||
string $baseUrl,
|
||||
string $apiKey
|
||||
): array {
|
||||
$referenceFiles = [];
|
||||
|
||||
foreach (array_slice($characters, 0, 9) as $index => $character) {
|
||||
$uploadId = (int) ($character['reference_upload_id'] ?? 0);
|
||||
if ($uploadId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$upload = UploadFile::where('id', $uploadId)
|
||||
->where('user_id', (int) $project->user_id)
|
||||
->where('file_type', 'image')
|
||||
->find();
|
||||
if (!$upload) {
|
||||
continue;
|
||||
}
|
||||
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$referenceFiles[] = self::uploadInputImage(
|
||||
$baseUrl,
|
||||
$path,
|
||||
$apiKey,
|
||||
'character_' . ((int) $index + 1)
|
||||
);
|
||||
}
|
||||
return $referenceFiles;
|
||||
}
|
||||
|
||||
private static function uploadContinuityFrame(
|
||||
VideoProject $project,
|
||||
int $uploadId,
|
||||
string $baseUrl,
|
||||
string $apiKey
|
||||
): ?string {
|
||||
$upload = UploadFile::where('id', $uploadId)
|
||||
->where('user_id', (int) $project->user_id)
|
||||
->where('file_type', 'image')
|
||||
->find();
|
||||
if (!$upload) {
|
||||
return null;
|
||||
}
|
||||
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
return self::uploadInputImage($baseUrl, $path, $apiKey, 'continuity');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,message:string,files:array,error:?string}
|
||||
*/
|
||||
public static function inspect(string $promptId, ?int $workerId = null): array
|
||||
{
|
||||
$model = self::model($workerId);
|
||||
$baseUrl = self::baseUrl($model->api_base_url);
|
||||
$apiKey = (string) ($model->api_key ?? '');
|
||||
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
|
||||
|
||||
if (isset($history[$promptId])) {
|
||||
$entry = $history[$promptId];
|
||||
$status = is_array($entry['status'] ?? null) ? $entry['status'] : [];
|
||||
foreach (($status['messages'] ?? []) as $message) {
|
||||
if (($message[0] ?? '') !== 'execution_error') {
|
||||
continue;
|
||||
}
|
||||
$detail = $message[1]['exception_message']
|
||||
?? json_encode($message[1] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
return [
|
||||
'state' => 'error',
|
||||
'message' => '视频生成失败',
|
||||
'files' => [],
|
||||
'error' => (string) $detail,
|
||||
];
|
||||
}
|
||||
|
||||
$files = self::collectVideoFiles($entry['outputs'] ?? []);
|
||||
if ($files) {
|
||||
return [
|
||||
'state' => 'done',
|
||||
'message' => '视频镜头生成完成',
|
||||
'files' => $files,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
|
||||
return [
|
||||
'state' => 'error',
|
||||
'message' => '任务完成但没有找到 MP4 输出',
|
||||
'files' => [],
|
||||
'error' => 'SaveVideo 未返回可下载文件',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$queue = self::getJson($baseUrl . '/queue', $apiKey);
|
||||
foreach (($queue['queue_running'] ?? []) as $item) {
|
||||
if ((string) ($item[1] ?? '') === $promptId) {
|
||||
return ['state' => 'running', 'message' => '正在渲染镜头', 'files' => [], 'error' => null];
|
||||
}
|
||||
}
|
||||
foreach (array_values($queue['queue_pending'] ?? []) as $index => $item) {
|
||||
if ((string) ($item[1] ?? '') === $promptId) {
|
||||
return [
|
||||
'state' => 'queued',
|
||||
'message' => $index > 0 ? "排队中,前面还有 {$index} 个任务" : '即将开始渲染',
|
||||
'files' => [],
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'missing',
|
||||
'message' => 'ComfyUI 队列中未找到任务,正在确认是否需要自动重试',
|
||||
'files' => [],
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,url:string,name:string,mime:string,size:int,path:string}
|
||||
*/
|
||||
public static function storeVideo(array $file, int $userId, ?int $workerId = null): array
|
||||
{
|
||||
$model = self::model($workerId);
|
||||
$baseUrl = self::baseUrl($model->api_base_url);
|
||||
$apiKey = (string) ($model->api_key ?? '');
|
||||
$filename = basename((string) ($file['filename'] ?? ''));
|
||||
if ($filename === '') {
|
||||
throw new \RuntimeException('ComfyUI 视频文件名为空');
|
||||
}
|
||||
$query = http_build_query([
|
||||
'filename' => $filename,
|
||||
'subfolder' => (string) ($file['subfolder'] ?? ''),
|
||||
'type' => (string) ($file['type'] ?? 'output'),
|
||||
]);
|
||||
$binary = self::getBinary($baseUrl . '/view?' . $query, $apiKey);
|
||||
if ($binary === null || $binary === '') {
|
||||
throw new \RuntimeException('下载 ComfyUI 视频失败');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, ['mp4', 'webm', 'mov'], true)) {
|
||||
$extension = 'mp4';
|
||||
}
|
||||
$subdir = date('Y/m/d');
|
||||
$storedBase = 'h3_' . uniqid('', true) . '.' . $extension;
|
||||
$relativePath = $subdir . '/' . $storedBase;
|
||||
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
|
||||
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
|
||||
throw new \RuntimeException('无法创建视频存储目录');
|
||||
}
|
||||
$fullPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
|
||||
if (file_put_contents($fullPath, $binary) === false) {
|
||||
throw new \RuntimeException('保存生成视频失败');
|
||||
}
|
||||
|
||||
$mime = @mime_content_type($fullPath) ?: ($extension === 'webm' ? 'video/webm' : 'video/mp4');
|
||||
$size = (int) filesize($fullPath);
|
||||
$upload = UploadFile::create([
|
||||
'user_id' => $userId,
|
||||
'original_name' => 'short_drama_shot.' . $extension,
|
||||
'stored_name' => $storedBase,
|
||||
'file_path' => $relativePath,
|
||||
'mime_type' => $mime,
|
||||
'file_size' => $size,
|
||||
'file_type' => 'video',
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int) $upload->id,
|
||||
'url' => '/api/uploads/' . rawurlencode($storedBase),
|
||||
'name' => (string) $upload->original_name,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'path' => $fullPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{workflow_type:string,prompt:string,width:int,height:int,length:int,steps:int,seed:int,reference_files:array,first_frame_file:?string,ref_image_size:string} $options
|
||||
*/
|
||||
public static function buildWorkflow(array $options): array
|
||||
{
|
||||
$isReference = str_starts_with($options['workflow_type'], 'ref2va')
|
||||
&& !empty($options['reference_files']);
|
||||
$workflow = [
|
||||
'1' => [
|
||||
'_meta' => ['title' => 'MiniMax H3 FL2VA / REF2VA 模型'],
|
||||
'class_type' => 'UNETLoader',
|
||||
'inputs' => [
|
||||
'unet_name' => $isReference ? self::REF2VA_MODEL : self::FL2VA_MODEL,
|
||||
'weight_dtype' => 'default',
|
||||
],
|
||||
],
|
||||
'2' => [
|
||||
'_meta' => ['title' => 'H3 视频/音频联合采样时间表'],
|
||||
'class_type' => 'MiniMaxH3SigmaShift',
|
||||
'inputs' => ['model' => ['1', 0], 'shift_video' => 12.0, 'shift_audio' => 3.0],
|
||||
],
|
||||
'3' => [
|
||||
'_meta' => ['title' => 'Qwen3-VL H3 文本编码器'],
|
||||
'class_type' => 'CLIPLoader',
|
||||
'inputs' => ['clip_name' => self::TEXT_ENCODER, 'type' => 'minimax', 'device' => 'default'],
|
||||
],
|
||||
'4' => [
|
||||
'_meta' => ['title' => 'H3 视频 VAE'],
|
||||
'class_type' => 'VAELoader',
|
||||
'inputs' => ['vae_name' => self::VIDEO_VAE],
|
||||
],
|
||||
'5' => [
|
||||
'_meta' => ['title' => 'H3 音频 VAE'],
|
||||
'class_type' => 'VAELoader',
|
||||
'inputs' => ['vae_name' => self::AUDIO_VAE],
|
||||
],
|
||||
];
|
||||
|
||||
if ($isReference) {
|
||||
$conditionInputs = [
|
||||
'clip' => ['3', 0],
|
||||
'vae' => ['4', 0],
|
||||
'audio_vae' => ['5', 0],
|
||||
'prompt' => (string) $options['prompt'],
|
||||
'width' => (int) $options['width'],
|
||||
'height' => (int) $options['height'],
|
||||
'length' => (int) $options['length'],
|
||||
'ref_image_size' => $options['ref_image_size'] === 'max' ? 'max' : 'match',
|
||||
];
|
||||
foreach (array_values($options['reference_files']) as $index => $filename) {
|
||||
$nodeId = (string) (20 + $index);
|
||||
$workflow[$nodeId] = [
|
||||
'_meta' => ['title' => '角色/连续性参考图 ' . ($index + 1)],
|
||||
'class_type' => 'LoadImage',
|
||||
'inputs' => ['image' => (string) $filename],
|
||||
];
|
||||
// V3 Autogrow inputs use dotted API keys: <group>.<generated input>.
|
||||
// The visible character numbering remains one-based in prompts (<Picture 1>),
|
||||
// while TemplatePrefix itself is zero-based (ref_image_0, ref_image_1, ...).
|
||||
$conditionInputs['ref_images.ref_image_' . $index] = [$nodeId, 0];
|
||||
}
|
||||
$workflow['6'] = [
|
||||
'_meta' => ['title' => 'H3 REF2VA 联合音画条件'],
|
||||
'class_type' => 'MiniMaxH3ReferenceToVideo',
|
||||
'inputs' => $conditionInputs,
|
||||
];
|
||||
} else {
|
||||
$imageToVideoInputs = [
|
||||
'clip' => ['3', 0],
|
||||
'vae' => ['4', 0],
|
||||
'prompt' => (string) $options['prompt'],
|
||||
'width' => (int) $options['width'],
|
||||
'height' => (int) $options['height'],
|
||||
'length' => (int) $options['length'],
|
||||
];
|
||||
if (!empty($options['first_frame_file'])) {
|
||||
$workflow['20'] = [
|
||||
'_meta' => ['title' => '上一镜头真实尾帧'],
|
||||
'class_type' => 'LoadImage',
|
||||
'inputs' => ['image' => (string) $options['first_frame_file']],
|
||||
];
|
||||
$imageToVideoInputs['first_frame'] = ['20', 0];
|
||||
}
|
||||
$workflow['6'] = [
|
||||
'_meta' => ['title' => 'H3 FL2VA / 首帧续拍联合音画条件'],
|
||||
'class_type' => 'MiniMaxH3ImageToVideo',
|
||||
'inputs' => $imageToVideoInputs,
|
||||
];
|
||||
}
|
||||
|
||||
$workflow += [
|
||||
'7' => [
|
||||
'_meta' => ['title' => '零负向条件'],
|
||||
'class_type' => 'ConditioningZeroOut',
|
||||
'inputs' => ['conditioning' => ['6', 0]],
|
||||
],
|
||||
'8' => [
|
||||
'_meta' => ['title' => 'H3 联合视频+音频采样器'],
|
||||
'class_type' => 'KSampler',
|
||||
'inputs' => [
|
||||
'model' => ['2', 0],
|
||||
'seed' => (int) $options['seed'],
|
||||
'steps' => (int) $options['steps'],
|
||||
'cfg' => 1.0,
|
||||
'sampler_name' => 'euler',
|
||||
'scheduler' => 'simple',
|
||||
'positive' => ['6', 0],
|
||||
'negative' => ['7', 0],
|
||||
'latent_image' => ['6', 1],
|
||||
'denoise' => 1.0,
|
||||
],
|
||||
],
|
||||
'9' => [
|
||||
'_meta' => ['title' => '拆分联合视频/音频潜变量'],
|
||||
'class_type' => 'LTXVSeparateAVLatent',
|
||||
'inputs' => ['av_latent' => ['8', 0]],
|
||||
],
|
||||
'10' => [
|
||||
'_meta' => ['title' => '解码视频画面'],
|
||||
'class_type' => 'VAEDecode',
|
||||
'inputs' => ['samples' => ['9', 0], 'vae' => ['4', 0]],
|
||||
],
|
||||
'11' => [
|
||||
'_meta' => ['title' => '解码 H3 原生同步音轨'],
|
||||
'class_type' => 'VAEDecodeAudio',
|
||||
'inputs' => ['samples' => ['9', 1], 'vae' => ['5', 0]],
|
||||
],
|
||||
'12' => [
|
||||
'_meta' => ['title' => '24fps 联合音画封装'],
|
||||
'class_type' => 'CreateVideo',
|
||||
'inputs' => ['images' => ['10', 0], 'fps' => 24.0, 'audio' => ['11', 0], 'bit_depth' => 8],
|
||||
],
|
||||
'13' => [
|
||||
'_meta' => ['title' => '保存 H3 联合音画 MP4'],
|
||||
'class_type' => 'SaveVideo',
|
||||
'inputs' => [
|
||||
'video' => ['12', 0],
|
||||
'filename_prefix' => 'short_drama/H3_AV_V2_',
|
||||
'format' => 'mp4',
|
||||
'codec' => 'auto',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $workflow;
|
||||
}
|
||||
|
||||
/** @return array{int,int,int} */
|
||||
private static function generationPreset(string $aspectRatio, string $quality): array
|
||||
{
|
||||
$portrait = $aspectRatio !== '16:9';
|
||||
if ($quality === 'high') {
|
||||
return $portrait ? [768, 1344, 16] : [1344, 768, 16];
|
||||
}
|
||||
if ($quality === 'standard') {
|
||||
return $portrait ? [576, 1024, 12] : [1024, 576, 12];
|
||||
}
|
||||
return $portrait ? [512, 896, 8] : [896, 512, 8];
|
||||
}
|
||||
|
||||
private static function frameLengthForDuration(int $durationSeconds): int
|
||||
{
|
||||
// H3 使用 17k+5 帧网格:124 帧约 5.17 秒,243 帧约 10.13 秒。
|
||||
return $durationSeconds >= 10 ? 243 : 124;
|
||||
}
|
||||
|
||||
private static function uploadInputImage(
|
||||
string $baseUrl,
|
||||
string $path,
|
||||
string $apiKey,
|
||||
string $purpose
|
||||
): string {
|
||||
$imageInfo = @getimagesize($path);
|
||||
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
|
||||
throw new \RuntimeException('角色参考图不是有效图片');
|
||||
}
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp'], true)) {
|
||||
$extension = $imageInfo['mime'] === 'image/jpeg' ? 'jpg' : 'png';
|
||||
}
|
||||
$subfolder = 'short_drama/' . date('Ymd');
|
||||
$uploadName = $purpose . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$ch = curl_init($baseUrl . '/upload/image');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'image' => new \CURLFile($path, (string) $imageInfo['mime'], $uploadName),
|
||||
'type' => 'input',
|
||||
'subfolder' => $subfolder,
|
||||
'overwrite' => 'true',
|
||||
],
|
||||
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 90,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
throw new \RuntimeException('上传角色参考图到 ComfyUI 失败: ' . ($error ?: 'HTTP ' . $httpCode));
|
||||
}
|
||||
$data = json_decode((string) $response, true);
|
||||
$name = trim((string) ($data['name'] ?? $uploadName));
|
||||
$storedSubfolder = trim((string) ($data['subfolder'] ?? $subfolder), '/\\');
|
||||
return $storedSubfolder === '' ? $name : $storedSubfolder . '/' . $name;
|
||||
}
|
||||
|
||||
private static function queuePrompt(string $baseUrl, array $workflow, string $apiKey): string
|
||||
{
|
||||
$prompt = new \stdClass();
|
||||
foreach ($workflow as $id => $node) {
|
||||
$prompt->{(string) $id} = $node;
|
||||
}
|
||||
$body = json_encode([
|
||||
'prompt' => $prompt,
|
||||
'client_id' => 'short-drama-' . bin2hex(random_bytes(6)),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
if ($body === false) {
|
||||
throw new \RuntimeException('H3 工作流编码失败');
|
||||
}
|
||||
$ch = curl_init($baseUrl . '/prompt');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], self::authHeaders($apiKey)),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$data = json_decode((string) $response, true);
|
||||
if ($response === false || $httpCode !== 200 || !empty($data['node_errors'])) {
|
||||
$detail = $data['error']['message'] ?? $data['error'] ?? ($error ?: 'HTTP ' . $httpCode);
|
||||
if (is_array($detail)) {
|
||||
$detail = json_encode($detail, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($data['node_errors'])) {
|
||||
$detail .= ';' . json_encode($data['node_errors'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
throw new \RuntimeException('ComfyUI 拒绝 H3 工作流: ' . $detail);
|
||||
}
|
||||
$promptId = trim((string) ($data['prompt_id'] ?? ''));
|
||||
if ($promptId === '') {
|
||||
throw new \RuntimeException('ComfyUI 未返回视频任务 ID');
|
||||
}
|
||||
return $promptId;
|
||||
}
|
||||
|
||||
private static function collectVideoFiles(array $outputs): array
|
||||
{
|
||||
$files = [];
|
||||
$walk = function (mixed $value) use (&$files, &$walk): void {
|
||||
if (!is_array($value)) {
|
||||
return;
|
||||
}
|
||||
if (isset($value['filename'])) {
|
||||
$extension = strtolower(pathinfo((string) $value['filename'], PATHINFO_EXTENSION));
|
||||
if (in_array($extension, ['mp4', 'webm', 'mov'], true)) {
|
||||
$files[] = $value;
|
||||
}
|
||||
}
|
||||
foreach ($value as $child) {
|
||||
if (is_array($child)) {
|
||||
$walk($child);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($outputs);
|
||||
|
||||
$unique = [];
|
||||
foreach ($files as $file) {
|
||||
$key = ($file['type'] ?? 'output') . '|' . ($file['subfolder'] ?? '') . '|' . $file['filename'];
|
||||
$unique[$key] = $file;
|
||||
}
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
private static function getJson(string $url, string $apiKey): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($response === false || $httpCode >= 400) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode((string) $response, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private static function getBinary(string $url, string $apiKey): ?string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return $response !== false && $httpCode === 200 ? $response : null;
|
||||
}
|
||||
|
||||
private static function baseUrl(?string $url): string
|
||||
{
|
||||
$url = rtrim((string) $url, '/');
|
||||
if ($url === '') {
|
||||
throw new \InvalidArgumentException('未配置 ComfyUI 地址');
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
private static function authHeaders(string $apiKey): array
|
||||
{
|
||||
return trim($apiKey) === '' ? [] : ['Authorization: Bearer ' . $apiKey];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user