This commit is contained in:
2026-08-05 15:56:08 +08:00
parent 01729b1e0b
commit 2d9e2376b6
106 changed files with 10007 additions and 253 deletions
+823
View File
@@ -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 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];
}
}