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 每个 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> $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} */ 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 .= "。 是上一镜头的真实结束帧;本镜头第一帧必须复现其人物位置、脸部、服装、动作相位、构图、背景、光向和色温,再从该动作自然继续,禁止重新起势或跳切。"; $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> $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: .. // The visible character numbering remains one-based in prompts (), // 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]; } }