Files
chat/backend/app/service/MiniMaxH3Service.php
2026-08-05 15:56:08 +08:00

824 lines
35 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 speakers actual sex, apparent age and identity.'
: "Use a {$gender} voice matching the visible speakers 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];
}
}