Files
2026-07-22 10:18:59 +08:00

3106 lines
118 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;
/**
* ComfyUI 文生图、图生图与局部重绘接入服务。
*
* 协议:
* - 提交工作流:POST {api_base_url}/prompt
* - 查询结果:GET {api_base_url}/history/{prompt_id}
* - 下载图片:GET {api_base_url}/view?filename=...&subfolder=...&type=output
*/
class ComfyUIService
{
public const JOB_TYPE = 'comfy_job';
private const DEFAULT_NEGATIVE = 'low quality, bad anatomy, blurry, watermark, signature, text, letters, words, Chinese characters, caption, title, subtitle, logo, typography, written language, poster text, UI overlay';
private const EXACT_TEXT_NEGATIVE = 'low quality, blurry, misspelled title, wrong character, missing character, extra character, duplicated character, duplicated title, translated title, random glyphs, gibberish, secondary text, small print, book mockup, book spine, 3D product render, watermark, signature, logo, UI overlay';
private const EDIT_DEFAULT_NEGATIVE = 'low quality, blurry, artifacts, seams, mask overlay, unwanted new text, newly added watermark, extra logo, altered existing typography, distorted letters, damaged layout';
private const OUTPAINT_NEGATIVE = 'drop shadow, cast shadow under photo, white border, white background, gray background, studio backdrop, floating photo, polaroid, card mockup, cutout, product shot on white, empty margins, frame, collage, pasted photo, hard rectangle edges, paper border, mirrored content, tiled image, repeated source content, duplicated subject, duplicated person, (new text:1.5), (letters:1.5), (words:1.5), (title extension:1.5), (duplicated typography:1.5), (pseudo-characters:1.5), (glyphs:1.4), gibberish text, illegible letters, unrelated scenery, environment mismatch, abrupt lighting change, exposure mismatch, color discontinuity, unrelated dominant focal point, isolated bright flare';
/**
* 默认扩写规则(不含具体画风字典;画风由用户描述或后台自定义 system_prompt 决定)
*/
private const PROMPT_ENGINEER_SYSTEM = <<<'PROMPT'
/no_think
You are a precise prompt compiler for text-to-image models.
Convert the user's request (often Chinese) into ONE concise English visual prompt.
Hard rules:
1) Output English ONLY, except text inside an EXACT_VISIBLE_TEXT marker, which must be copied verbatim in its original language.
2) By default the image contains zero readable text. If and only if EXACT_VISIBLE_TEXT is present, render that exact string once and no other wording; never translate, rewrite, omit, reorder, or substitute its characters.
3) Preserve every explicit constraint exactly: subject count and identity, action, environment, era, viewpoint, color, mood, medium, style, and aspect/composition intent. Never replace or contradict one.
4) Preserve cultural identity literally. A Chinese swordsman is a swordsman, never a samurai unless the user explicitly requests Japanese culture; wuxia and xianxia are not Japanese fantasy.
5) The first phrase must name the requested deliverable and genre. Then state the core subject, composition and camera, environment, lighting, palette, materials, and finish. Never omit the deliverable or genre to save words.
6) Do not invent extra people, objects, genres, brands, or story facts that the user did not request. When details are absent, choose a neutral coherent solution instead of changing the concept.
7) For a novel or book cover, create portrait cover artwork with one clear focal hierarchy and clean title-safe negative space, but render no title or fake typography. Preserve any genre or character details the user supplied.
8) If the user names a style, translate it into clear visual terms. If no style is named, use a polished coherent finish appropriate to the requested deliverable without imitating a named artist.
9) Every batch item is ONE standalone full-bleed finished image. Never request a contact sheet, storyboard, mood board, presentation, labeled sheet, split panels, borders, mockup, or UI.
10) Use concrete visible facts, not vague praise. Keep the result under 80 English words so the image model focuses on the request.
11) Output one paragraph of comma-separated visual phrases only. No markdown, quotes, explanation, alternatives, or meta commentary.
USER REQUEST:
PROMPT;
private const IMAGE_EDIT_PROMPT_ENGINEER_SYSTEM = <<<'PROMPT'
You are an expert prompt engineer for image editing and inpainting.
Convert the user's request (often Chinese) into ONE dense English editing prompt.
Hard rules:
1) Output English ONLY. Never output Chinese characters.
2) Describe the requested final visual result, not the editing operation or UI.
3) Preserve the source image composition, identity, geometry, lighting, and all areas the user did not ask to change.
4) For removal or inpainting, describe the natural background or texture that should replace the masked area.
5) Preserve all existing text, typography, logos, and design elements outside the requested removal or mask. Remove only the watermark, text, or object the user explicitly names. Do not add new text, captions, titles, signatures, logos, watermarks, or UI.
6) Output one paragraph of comma-separated visual phrases only. No markdown, no quotes, no explanation.
PROMPT;
/**
* @param callable|null $onProgress fn(string $message): void
* @return array{attachments: array, prompt: string, prompt_id: string}
*/
public static function generate(
AiModel $model,
string $prompt,
int $userId,
?callable $onProgress = null
): array {
if ($onProgress) {
$onProgress('正在提交生成任务…');
}
$promptId = self::submit($model, $prompt, 1);
if ($onProgress) {
$onProgress('图片生成中,请稍候…');
}
$attachments = self::waitAndCollect($model, $promptId, $userId, $onProgress);
return [
'attachments' => $attachments,
'prompt' => trim($prompt),
'prompt_id' => $promptId,
];
}
/**
* 仅提交工作流,立即返回 prompt_id(便于先落库,刷新后可恢复)。
*/
public static function submit(AiModel $model, string $prompt, int $imageCount = 1): string
{
$prompt = trim($prompt);
if ($prompt === '') {
throw new \InvalidArgumentException('请输入图片描述');
}
$baseUrl = self::baseUrl($model->api_base_url);
$workflow = self::buildWorkflow($prompt, $model, $imageCount);
return self::queuePrompt($baseUrl, $workflow, $model->api_key ?? '');
}
/**
* Upload a source image (and optional black/white mask) to ComfyUI, inject
* them into an img2img/inpaint API workflow, then queue the edit task.
*/
public static function submitEdit(
AiModel $model,
string $prompt,
string $sourcePath,
?string $maskPath = null,
string $mode = 'img2img',
?float $denoiseOverride = null,
string $operation = 'edit'
): string {
$prompt = trim($prompt);
if ($prompt === '') {
throw new \InvalidArgumentException('请输入图片处理要求');
}
if (!is_file($sourcePath)) {
throw new \InvalidArgumentException('找不到需要处理的原图');
}
$mode = $mode === 'inpaint' ? 'inpaint' : 'img2img';
if ($mode === 'inpaint' && ($maskPath === null || !is_file($maskPath))) {
throw new \InvalidArgumentException('局部重绘需要第二张黑白遮罩图片(白色为修改区域)');
}
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = $model->api_key ?? '';
$sourceName = self::uploadInputImage($baseUrl, $sourcePath, $apiKey, 'source');
$maskName = $mode === 'inpaint'
? self::uploadInputImage($baseUrl, (string) $maskPath, $apiKey, 'mask')
: null;
$workflow = self::buildEditWorkflow(
$prompt,
$model,
$sourceName,
$maskName,
$mode,
$denoiseOverride,
$operation,
self::deterministicSeedFromImage($sourcePath)
);
return self::queuePrompt($baseUrl, $workflow, $apiKey);
}
/**
* Remove an image background with the locally installed open-source RMBG
* node and preserve its alpha channel in the saved PNG.
*/
public static function submitBackgroundRemoval(AiModel $model, string $sourcePath): string
{
if (!is_file($sourcePath)) {
throw new \InvalidArgumentException('找不到需要抠图的原图');
}
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = $model->api_key ?? '';
$sourceName = self::uploadInputImage($baseUrl, $sourcePath, $apiKey, 'cutout');
$workflow = self::buildBackgroundRemovalWorkflow($sourceName);
return self::queuePrompt($baseUrl, $workflow, $apiKey);
}
/**
* 等待任务完成并下载图片。
*
* @param callable|null $onProgress fn(string $message): void
* @param int $httpBudgetSeconds 本 HTTP 请求最长等待;超时抛 ComfyJobDeferredException,消息保持 pending
* @return array<int, array{type:string,url:string,name:string,mime:string,size:int}>
*/
public static function waitAndCollect(
AiModel $model,
string $promptId,
int $userId,
?callable $onProgress = null,
int $httpBudgetSeconds = 25
): array {
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = $model->api_key ?? '';
$outputs = self::waitForOutputs($baseUrl, $promptId, $apiKey, $onProgress, $httpBudgetSeconds);
$attachments = self::downloadAndStore($baseUrl, $outputs, $userId, $apiKey);
if (empty($attachments)) {
throw new \RuntimeException('ComfyUI 未返回可用图片,请检查工作流与模型文件是否已正确加载');
}
return $attachments;
}
/**
* 非阻塞检查任务状态。
*
* @return array{state:string,message:string,images:?array,error:?string}
* state: queued|running|collecting|done|error|lost
*/
public static function inspect(AiModel $model, string $promptId): array
{
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = $model->api_key ?? '';
$promptId = (string) $promptId;
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
if (isset($history[$promptId])) {
$entry = $history[$promptId];
$status = $entry['status'] ?? [];
foreach (($status['messages'] ?? []) as $msg) {
if (($msg[0] ?? '') === 'execution_error') {
$err = $msg[1]['exception_message'] ?? json_encode($msg[1] ?? [], JSON_UNESCAPED_UNICODE);
return [
'state' => 'error',
'message' => 'ComfyUI 执行失败: ' . $err,
'images' => null,
'error' => (string) $err,
];
}
}
if (($status['status_str'] ?? '') === 'error') {
return [
'state' => 'error',
'message' => 'ComfyUI 执行失败',
'images' => null,
'error' => 'execution error',
];
}
$images = self::collectImages($entry['outputs'] ?? []);
if (!empty($images)) {
return [
'state' => 'done',
'message' => '生成完成',
'images' => $images,
'error' => null,
];
}
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
return [
'state' => 'error',
'message' => '任务已完成但未找到输出图片,请确认使用 API Format 工作流且包含 SaveImage',
'images' => null,
'error' => 'no output images',
];
}
return [
'state' => 'collecting',
'message' => '正在取回生成结果…',
'images' => null,
'error' => null,
];
}
$queue = self::getJson($baseUrl . '/queue', $apiKey);
$ahead = self::queueAheadCount(
$promptId,
$queue['queue_running'] ?? [],
$queue['queue_pending'] ?? []
);
if ($ahead === 0) {
return [
'state' => 'running',
'message' => '正在渲染…',
'images' => null,
'error' => null,
];
}
if ($ahead !== null && $ahead > 0) {
return [
'state' => 'queued',
'message' => "排队中(前面还有 {$ahead} 个任务)…",
'images' => null,
'error' => null,
];
}
return [
'state' => 'lost',
'message' => '等待 ComfyUI 响应…',
'images' => null,
'error' => null,
];
}
/**
* 将 inspect 得到的 Comfy 图片元数据下载并保存。
*/
public static function storeInspectImages(AiModel $model, array $images, int $userId): array
{
$baseUrl = self::baseUrl($model->api_base_url);
return self::downloadAndStore($baseUrl, $images, $userId, $model->api_key ?? '');
}
public static function buildJobAttachment(
string $promptId,
int $modelId,
string $progress = '图片生成中…',
?int $created = null,
int $imageCount = 1
): array {
return [
'type' => self::JOB_TYPE,
'status' => 'pending',
'prompt_id' => $promptId,
'model_id' => $modelId,
'progress' => $progress,
'created' => $created ?? time(),
'image_count' => max(1, min(4, $imageCount)),
];
}
public static function findPendingJob($attachments): ?array
{
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
if (!is_array($attachments)) {
return null;
}
foreach ($attachments as $att) {
if (!is_array($att)) {
continue;
}
if (($att['type'] ?? '') === self::JOB_TYPE && ($att['status'] ?? '') === 'pending') {
return $att;
}
}
return null;
}
public static function hasImageAttachments($attachments): bool
{
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
if (!is_array($attachments)) {
return false;
}
foreach ($attachments as $att) {
if (is_array($att) && ($att['type'] ?? '') === 'image' && !empty($att['url'])) {
return true;
}
}
return false;
}
/**
* Build a local black/white mask for common corner watermarks.
*/
public static function createAutomaticWatermarkMask(string $sourcePath, string $request): string
{
if (!is_file($sourcePath) || !function_exists('imagecreatefromstring')) {
throw new \RuntimeException('当前环境无法自动生成去水印遮罩,请上传第二张黑白遮罩图');
}
$bytes = @file_get_contents($sourcePath);
$source = is_string($bytes) ? @imagecreatefromstring($bytes) : false;
if ($source === false) {
throw new \RuntimeException('无法读取原图,请重新上传图片');
}
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($source)) {
imagepalettetotruecolor($source);
}
$width = imagesx($source);
$height = imagesy($source);
if ($width < 32 || $height < 32) {
imagedestroy($source);
throw new \RuntimeException('原图尺寸过小,无法自动定位水印');
}
$anchor = self::resolveWatermarkAnchor($source, $request, $width, $height);
$mask = imagecreatetruecolor($width, $height);
if ($mask === false) {
imagedestroy($source);
throw new \RuntimeException('自动去水印遮罩创建失败');
}
$black = imagecolorallocate($mask, 0, 0, 0);
$white = imagecolorallocate($mask, 255, 255, 255);
imagefill($mask, 0, 0, $black);
$boxWidth = max(24, (int) round($width * 0.22));
$boxHeight = max(18, (int) round($height * 0.055));
$inset = max(2, (int) round(min($width, $height) * 0.01));
[$left, $top, $right, $bottom] = self::watermarkMaskBounds(
$anchor,
$width,
$height,
$boxWidth,
$boxHeight,
$inset
);
$detectedPixels = self::paintDetectedWatermarkPixels(
$source,
$mask,
$white,
$left,
$top,
$right,
$bottom,
$width,
$height
);
$regionArea = max(1, ($right - $left + 1) * ($bottom - $top + 1));
if ($detectedPixels < max(12, (int) round($regionArea * 0.001))) {
imagefilledrectangle($mask, $left, $top, $right, $bottom, $white);
}
$maskPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'comfy_auto_watermark_' . bin2hex(random_bytes(8)) . '.png';
$saved = imagepng($mask, $maskPath);
imagedestroy($mask);
imagedestroy($source);
if (!$saved) {
throw new \RuntimeException('自动去水印遮罩保存失败');
}
return $maskPath;
}
public static function createAutomaticTextRemovalMask(string $sourcePath, string $target): string
{
if (!is_file($sourcePath) || !function_exists('imagecreatefromstring')) {
throw new \RuntimeException('当前环境无法自动生成文字移除遮罩,请上传第二张黑白遮罩图');
}
if (!in_array($target, ['author', 'title', 'all_text'], true)) {
throw new \InvalidArgumentException('不支持的文字移除目标');
}
$bytes = @file_get_contents($sourcePath);
$source = is_string($bytes) ? @imagecreatefromstring($bytes) : false;
if ($source === false) {
throw new \RuntimeException('无法读取原图,请重新上传图片');
}
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($source)) {
imagepalettetotruecolor($source);
}
$width = imagesx($source);
$height = imagesy($source);
$regions = [
// Book-cover author credits normally sit between the title block and artwork.
// Keep this band narrow so a follow-up "remove the author" cannot erase the title.
'author' => [0.34, 0.34, 0.66, 0.394],
'title' => [0.05, 0.05, 0.95, 0.34],
'all_text' => [0.03, 0.03, 0.97, 0.72],
];
[$leftRatio, $topRatio, $rightRatio, $bottomRatio] = $regions[$target];
$left = (int) round($width * $leftRatio);
$top = (int) round($height * $topRatio);
$right = (int) round($width * $rightRatio);
$bottom = (int) round($height * $bottomRatio);
$mask = imagecreatetruecolor($width, $height);
$black = imagecolorallocate($mask, 0, 0, 0);
$white = imagecolorallocate($mask, 255, 255, 255);
imagefill($mask, 0, 0, $black);
$detected = self::paintDetectedTextPixels(
$source,
$mask,
$white,
$left,
$top,
$right,
$bottom,
$width,
$height
);
imagedestroy($source);
if ($detected < 12) {
imagedestroy($mask);
throw new \RuntimeException('未能自动定位需要删除的文字,请明确文字位置或上传黑白遮罩');
}
$maskPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'comfy_auto_text_' . bin2hex(random_bytes(8)) . '.png';
$saved = imagepng($mask, $maskPath);
imagedestroy($mask);
if (!$saved) {
throw new \RuntimeException('自动文字遮罩保存失败');
}
return $maskPath;
}
/**
* Deterministic fill avoids diffusion models recreating text inside tiny masks.
*
* @return array<int, array{type:string,url:string,name:string,mime:string,size:int}>
*/
public static function removeMaskedContentWithContentAwareFill(
string $sourcePath,
string $maskPath,
int $userId,
string $displayName = 'image_edited.png',
string $fillMode = 'boundary'
): array {
if (!is_file($sourcePath) || !is_file($maskPath)) {
throw new \InvalidArgumentException('去水印所需的原图或遮罩不存在');
}
$sourceBytes = @file_get_contents($sourcePath);
$maskBytes = @file_get_contents($maskPath);
$source = is_string($sourceBytes) ? @imagecreatefromstring($sourceBytes) : false;
$mask = is_string($maskBytes) ? @imagecreatefromstring($maskBytes) : false;
if ($source === false || $mask === false) {
if ($source !== false) {
imagedestroy($source);
}
if ($mask !== false) {
imagedestroy($mask);
}
throw new \RuntimeException('无法读取去水印原图或遮罩');
}
$width = imagesx($source);
$height = imagesy($source);
if (imagesx($mask) !== $width || imagesy($mask) !== $height) {
imagedestroy($source);
imagedestroy($mask);
throw new \InvalidArgumentException('去水印遮罩必须与原图尺寸一致');
}
$result = imagecreatetruecolor($width, $height);
if ($result === false) {
imagedestroy($source);
imagedestroy($mask);
throw new \RuntimeException('去水印画布创建失败');
}
imagecopy($result, $source, 0, 0, 0, 0, $width, $height);
if ($fillMode === 'horizontal_text_band') {
self::fillTextBandFromHorizontalSurroundings($result, $mask, $width, $height);
} else {
self::fillMaskedPixelsFromBoundary($result, $mask, $width, $height);
}
$attachments = self::storeLocalGeneratedImage($result, $userId, $displayName);
imagedestroy($result);
imagedestroy($source);
imagedestroy($mask);
return $attachments;
}
public static function removeWatermarkWithContentAwareFill(
string $sourcePath,
string $maskPath,
int $userId
): array {
return self::removeMaskedContentWithContentAwareFill(
$sourcePath,
$maskPath,
$userId,
'watermark_removed.png'
);
}
/**
* Author credits are usually a horizontal line over a continuous cover background.
* Rebuilding the band from both clean sides removes glyph shadows without touching the title.
*/
private static function fillTextBandFromHorizontalSurroundings(
$result,
$mask,
int $width,
int $height
): void {
$minX = $width;
$minY = $height;
$maxX = -1;
$maxY = -1;
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
if (self::maskPixelValue($mask, $x, $y) < 128) {
continue;
}
$minX = min($minX, $x);
$minY = min($minY, $y);
$maxX = max($maxX, $x);
$maxY = max($maxY, $y);
}
}
if ($maxX < $minX || $maxY < $minY) {
throw new \RuntimeException('自动遮罩未检测到需要移除的文字');
}
$padding = max(12, min(42, (int) round(min($width, $height) * 0.022)));
$left = max(0, $minX - $padding);
$top = max(0, $minY - $padding);
$right = min($width - 1, $maxX + $padding);
$bottom = min($height - 1, $maxY + $padding);
$sampleGap = max(2, (int) round($padding * 0.16));
$sampleWidth = max(4, (int) round($padding * 0.42));
$verticalRadius = max(2, (int) round($padding * 0.2));
$feather = max(8, (int) round($padding * 0.82));
// Fall back near an image edge where clean context is unavailable on both sides.
if ($left < $sampleGap + $sampleWidth || $right + $sampleGap + $sampleWidth >= $width) {
self::fillMaskedPixelsFromBoundary($result, $mask, $width, $height);
return;
}
$source = imagecreatetruecolor($width, $height);
if ($source === false) {
throw new \RuntimeException('无法创建文字移除画布');
}
imagecopy($source, $result, 0, 0, 0, 0, $width, $height);
for ($y = $top; $y <= $bottom; $y++) {
$leftColor = self::averageImageStrip(
$source,
$left - $sampleGap - $sampleWidth,
$left - $sampleGap,
$y - $verticalRadius,
$y + $verticalRadius,
$width,
$height
);
$rightColor = self::averageImageStrip(
$source,
$right + $sampleGap,
$right + $sampleGap + $sampleWidth,
$y - $verticalRadius,
$y + $verticalRadius,
$width,
$height
);
for ($x = $left; $x <= $right; $x++) {
$progress = ($x - $left + 1) / max(2, ($right - $left) + 2);
$fillRed = $leftColor[0] * (1 - $progress) + $rightColor[0] * $progress;
$fillGreen = $leftColor[1] * (1 - $progress) + $rightColor[1] * $progress;
$fillBlue = $leftColor[2] * (1 - $progress) + $rightColor[2] * $progress;
$edgeDistance = min(
$x - $left + 1,
$right - $x + 1,
$y - $top + 1,
$bottom - $y + 1
);
$alpha = min(1.0, $edgeDistance / $feather);
$alpha = $alpha * $alpha * (3 - (2 * $alpha));
$original = imagecolorat($source, $x, $y);
$red = (int) round((($original >> 16) & 0xFF) * (1 - $alpha) + $fillRed * $alpha);
$green = (int) round((($original >> 8) & 0xFF) * (1 - $alpha) + $fillGreen * $alpha);
$blue = (int) round(($original & 0xFF) * (1 - $alpha) + $fillBlue * $alpha);
imagesetpixel($result, $x, $y, ($red << 16) | ($green << 8) | $blue);
}
}
imagedestroy($source);
}
private static function averageImageStrip(
$image,
int $left,
int $right,
int $top,
int $bottom,
int $width,
int $height
): array {
$red = 0;
$green = 0;
$blue = 0;
$count = 0;
for ($y = max(0, $top); $y <= min($height - 1, $bottom); $y++) {
for ($x = max(0, $left); $x <= min($width - 1, $right); $x++) {
$rgb = imagecolorat($image, $x, $y);
$red += ($rgb >> 16) & 0xFF;
$green += ($rgb >> 8) & 0xFF;
$blue += $rgb & 0xFF;
$count++;
}
}
return [
$red / max(1, $count),
$green / max(1, $count),
$blue / max(1, $count),
];
}
private static function fillMaskedPixelsFromBoundary($result, $mask, int $width, int $height): void
{
$minX = $width;
$minY = $height;
$maxX = -1;
$maxY = -1;
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
if (self::maskPixelValue($mask, $x, $y) < 128) {
continue;
}
$minX = min($minX, $x);
$minY = min($minY, $y);
$maxX = max($maxX, $x);
$maxY = max($maxY, $y);
}
}
if ($maxX < $minX || $maxY < $minY) {
throw new \RuntimeException('自动遮罩未检测到需要移除的水印');
}
$boxWidth = $maxX - $minX + 1;
$boxHeight = $maxY - $minY + 1;
$pixelCount = $boxWidth * $boxHeight;
$unknown = array_fill(0, $pixelCount, false);
$masked = array_fill(0, $pixelCount, false);
$queue = new \SplQueue();
for ($localY = 0; $localY < $boxHeight; $localY++) {
for ($localX = 0; $localX < $boxWidth; $localX++) {
$index = $localY * $boxWidth + $localX;
$isMasked = self::maskPixelValue($mask, $minX + $localX, $minY + $localY) >= 128;
$unknown[$index] = $isMasked;
$masked[$index] = $isMasked;
}
}
for ($localY = 0; $localY < $boxHeight; $localY++) {
for ($localX = 0; $localX < $boxWidth; $localX++) {
$index = $localY * $boxWidth + $localX;
if (!$unknown[$index]) {
continue;
}
if (self::hasKnownFillNeighbor($unknown, $boxWidth, $boxHeight, $localX, $localY)) {
$queue->enqueue($index);
}
}
}
while (!$queue->isEmpty()) {
$index = (int) $queue->dequeue();
if (!$unknown[$index]) {
continue;
}
$localX = $index % $boxWidth;
$localY = intdiv($index, $boxWidth);
$color = self::averageKnownNeighborColor(
$result,
$unknown,
$boxWidth,
$boxHeight,
$minX,
$minY,
$localX,
$localY
);
if ($color === null) {
continue;
}
imagesetpixel($result, $minX + $localX, $minY + $localY, $color);
$unknown[$index] = false;
for ($dy = -1; $dy <= 1; $dy++) {
for ($dx = -1; $dx <= 1; $dx++) {
$nextX = $localX + $dx;
$nextY = $localY + $dy;
if ($nextX < 0 || $nextY < 0 || $nextX >= $boxWidth || $nextY >= $boxHeight) {
continue;
}
$nextIndex = $nextY * $boxWidth + $nextX;
if ($unknown[$nextIndex]) {
$queue->enqueue($nextIndex);
}
}
}
}
$snapshot = imagecreatetruecolor($width, $height);
imagecopy($snapshot, $result, 0, 0, 0, 0, $width, $height);
for ($localY = 0; $localY < $boxHeight; $localY++) {
for ($localX = 0; $localX < $boxWidth; $localX++) {
$index = $localY * $boxWidth + $localX;
if (!$masked[$index]) {
continue;
}
$color = self::averageImageNeighborhood(
$snapshot,
$minX + $localX,
$minY + $localY,
$width,
$height
);
imagesetpixel($result, $minX + $localX, $minY + $localY, $color);
}
}
imagedestroy($snapshot);
}
private static function maskPixelValue($mask, int $x, int $y): int
{
$color = imagecolorat($mask, $x, $y);
if (imageistruecolor($mask)) {
return ($color >> 16) & 0xFF;
}
$channels = imagecolorsforindex($mask, $color);
return (int) ($channels['red'] ?? 0);
}
private static function hasKnownFillNeighbor(
array $unknown,
int $width,
int $height,
int $x,
int $y
): bool {
for ($dy = -1; $dy <= 1; $dy++) {
for ($dx = -1; $dx <= 1; $dx++) {
if ($dx === 0 && $dy === 0) {
continue;
}
$nextX = $x + $dx;
$nextY = $y + $dy;
if ($nextX < 0 || $nextY < 0 || $nextX >= $width || $nextY >= $height) {
return true;
}
if (!$unknown[$nextY * $width + $nextX]) {
return true;
}
}
}
return false;
}
private static function averageKnownNeighborColor(
$image,
array $unknown,
int $width,
int $height,
int $offsetX,
int $offsetY,
int $x,
int $y
): ?int {
$red = 0;
$green = 0;
$blue = 0;
$count = 0;
for ($dy = -1; $dy <= 1; $dy++) {
for ($dx = -1; $dx <= 1; $dx++) {
if ($dx === 0 && $dy === 0) {
continue;
}
$nextX = $x + $dx;
$nextY = $y + $dy;
if ($nextX < 0 || $nextY < 0 || $nextX >= $width || $nextY >= $height
|| $unknown[$nextY * $width + $nextX]) {
continue;
}
$rgb = imagecolorat($image, $offsetX + $nextX, $offsetY + $nextY);
$red += ($rgb >> 16) & 0xFF;
$green += ($rgb >> 8) & 0xFF;
$blue += $rgb & 0xFF;
$count++;
}
}
if ($count === 0) {
return null;
}
return ((int) round($red / $count) << 16)
| ((int) round($green / $count) << 8)
| (int) round($blue / $count);
}
private static function averageImageNeighborhood($image, int $x, int $y, int $width, int $height): int
{
$red = 0;
$green = 0;
$blue = 0;
$count = 0;
for ($dy = -1; $dy <= 1; $dy++) {
for ($dx = -1; $dx <= 1; $dx++) {
$sampleX = max(0, min($width - 1, $x + $dx));
$sampleY = max(0, min($height - 1, $y + $dy));
$rgb = imagecolorat($image, $sampleX, $sampleY);
$red += ($rgb >> 16) & 0xFF;
$green += ($rgb >> 8) & 0xFF;
$blue += $rgb & 0xFF;
$count++;
}
}
return ((int) round($red / $count) << 16)
| ((int) round($green / $count) << 8)
| (int) round($blue / $count);
}
private static function storeLocalGeneratedImage($image, int $userId, string $displayName): array
{
$uploadPath = rtrim(config('upload.path'), '/\\');
$subdir = date('Y/m/d');
$storedBase = 'processed_' . uniqid('', true) . '.png';
$storedName = $subdir . '/' . $storedBase;
$fullDir = $uploadPath . 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 (!imagepng($image, $fullPath)) {
throw new \RuntimeException('保存去水印图片失败');
}
$size = (int) filesize($fullPath);
UploadFile::create([
'user_id' => $userId,
'original_name' => $displayName,
'stored_name' => $storedBase,
'file_path' => $storedName,
'mime_type' => 'image/png',
'file_size' => $size,
'file_type' => 'image',
]);
return [[
'type' => 'image',
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => $displayName,
'mime' => 'image/png',
'size' => $size,
]];
}
private static function paintDetectedWatermarkPixels(
$source,
$mask,
int $white,
int $left,
int $top,
int $right,
int $bottom,
int $width,
int $height
): int {
$detected = 0;
$sampleStep = max(1, (int) floor(min($width, $height) / 900));
$radius = max(3, min(10, (int) round(min($width, $height) * 0.0045)));
for ($y = $top; $y <= $bottom; $y += $sampleStep) {
for ($x = $left; $x <= $right; $x += $sampleStep) {
$rgb = imagecolorat($source, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$luma = (0.299 * $r) + (0.587 * $g) + (0.114 * $b);
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($luma < 108 || $chroma > 62) {
continue;
}
imagefilledellipse($mask, $x, $y, $radius * 2 + 1, $radius * 2 + 1, $white);
$detected++;
}
}
return $detected;
}
private static function paintDetectedTextPixels(
$source,
$mask,
int $white,
int $left,
int $top,
int $right,
int $bottom,
int $width,
int $height
): int {
$detected = 0;
$sampleStep = max(1, (int) floor(min($width, $height) / 900));
$radius = max(4, min(12, (int) round(min($width, $height) * 0.005)));
for ($y = $top; $y <= $bottom; $y += $sampleStep) {
for ($x = $left; $x <= $right; $x += $sampleStep) {
$rgb = imagecolorat($source, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$luma = (0.299 * $r) + (0.587 * $g) + (0.114 * $b);
$chroma = max($r, $g, $b) - min($r, $g, $b);
$goldText = $r >= 125 && $g >= 95 && $r > $b * 1.22 && $g > $b * 1.08;
$neutralText = $luma >= 150 && $chroma <= 58;
if (!$goldText && !$neutralText) {
continue;
}
imagefilledellipse($mask, $x, $y, $radius * 2 + 1, $radius * 2 + 1, $white);
$detected++;
}
}
return $detected;
}
private static function resolveWatermarkAnchor($source, string $request, int $width, int $height): string
{
$request = mb_strtolower($request);
$locations = [
'top-left' => '/(?:左上|左上角|top[\s-]*left|upper[\s-]*left)/iu',
'top-right' => '/(?:右上|右上角|top[\s-]*right|upper[\s-]*right)/iu',
'bottom-left' => '/(?:左下|左下角|bottom[\s-]*left|lower[\s-]*left)/iu',
'bottom-right' => '/(?:右下|右下角|bottom[\s-]*right|lower[\s-]*right)/iu',
];
foreach ($locations as $anchor => $pattern) {
if (preg_match($pattern, $request)) {
return $anchor;
}
}
return self::detectLikelyWatermarkCorner($source, $width, $height);
}
private static function detectLikelyWatermarkCorner($source, int $width, int $height): string
{
$regionWidth = max(24, (int) round($width * 0.34));
$regionHeight = max(18, (int) round($height * 0.12));
$regions = [
'bottom-left' => [0, $height - $regionHeight],
'bottom-right' => [$width - $regionWidth, $height - $regionHeight],
];
$bestAnchor = 'bottom-right';
$bestScore = -INF;
$step = max(1, (int) floor(min($width, $height) / 320));
foreach ($regions as $anchor => [$startX, $startY]) {
$score = 0.0;
$samples = 0;
for ($y = $startY; $y < min($height - 1, $startY + $regionHeight); $y += $step) {
for ($x = $startX; $x < min($width - 1, $startX + $regionWidth); $x += $step) {
$rgb = imagecolorat($source, $x, $y);
$rightRgb = imagecolorat($source, $x + 1, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$rr = ($rightRgb >> 16) & 0xFF;
$rg = ($rightRgb >> 8) & 0xFF;
$rb = $rightRgb & 0xFF;
$luma = (0.299 * $r) + (0.587 * $g) + (0.114 * $b);
$rightLuma = (0.299 * $rr) + (0.587 * $rg) + (0.114 * $rb);
$neutral = max($r, $g, $b) - min($r, $g, $b) <= 48;
if ($neutral && $luma >= 105 && abs($luma - $rightLuma) >= 16) {
$score += 1.0;
}
$samples++;
}
}
$score = ($score / max(1, $samples))
+ ($anchor === 'bottom-right' ? 0.012 : 0.0);
if ($score > $bestScore) {
$bestScore = $score;
$bestAnchor = $anchor;
}
}
return $bestAnchor;
}
private static function watermarkMaskBounds(
string $anchor,
int $width,
int $height,
int $boxWidth,
int $boxHeight,
int $inset
): array {
$rightSide = str_ends_with($anchor, 'right');
$bottomSide = str_starts_with($anchor, 'bottom');
$left = $rightSide ? $width - $boxWidth - $inset : $inset;
$top = $bottomSide ? $height - $boxHeight - $inset : $inset;
return [
max(0, $left),
max(0, $top),
min($width - 1, $left + $boxWidth),
min($height - 1, $top + $boxHeight),
];
}
/**
* 刷新会话时恢复未完成的生图任务:已完成则落库图片,进行中则更新进度文案。
*
* @param array{id:int,content?:string,attachments?:mixed} $message
* @return array{content:string,attachments:array,finished:bool}
*/
public static function recoverPendingMessage(array $message, AiModel $model, int $userId): array
{
$attachments = $message['attachments'] ?? [];
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
if (!is_array($attachments)) {
$attachments = [];
}
$job = self::findPendingJob($attachments);
if (!$job) {
return [
'content' => (string) ($message['content'] ?? ''),
'attachments' => $attachments,
'finished' => true,
];
}
// 已被并发请求写成图片则直接返回
if (self::hasImageAttachments($attachments)) {
return [
'content' => (string) ($message['content'] ?? '已根据描述生成图片:'),
'attachments' => array_values(array_filter(
$attachments,
fn ($a) => is_array($a) && ($a['type'] ?? '') === 'image'
)),
'finished' => true,
];
}
$promptId = (string) ($job['prompt_id'] ?? '');
if ($promptId === '') {
return self::failJobResult('生图任务缺少 prompt_id');
}
$generationPrompt = mb_substr(trim((string) ($job['generation_prompt'] ?? '')), 0, 8000);
$created = (int) ($job['created'] ?? 0);
// 长时间任务也保留:关闭页面后仍可靠 prompt_id 找回(默认 7 天)
if ($created > 0 && (time() - $created) > 604800) {
return self::failJobResult('生图任务已过期(超过 7 天),请重新发送');
}
try {
$status = self::inspect($model, $promptId);
} catch (\Throwable $e) {
// 临时网络问题:保持 pending,下次刷新再试
$progress = '等待 ComfyUI 响应…';
$pendingAttachment = self::buildJobAttachment(
$promptId,
(int) $model->id,
$progress,
(int) ($job['created'] ?? time())
);
if ($generationPrompt !== '') {
$pendingAttachment['generation_prompt'] = $generationPrompt;
}
return [
'content' => $progress,
'attachments' => [$pendingAttachment],
'finished' => false,
];
}
if ($status['state'] === 'done' && !empty($status['images'])) {
$stored = self::storeInspectImages($model, $status['images'], $userId);
if (empty($stored)) {
return self::failJobResult('ComfyUI 已完成但下载图片失败');
}
if ($generationPrompt !== '') {
foreach ($stored as &$attachment) {
if (is_array($attachment) && ($attachment['type'] ?? '') === 'image') {
$attachment['generation_prompt'] = $generationPrompt;
}
}
unset($attachment);
}
return [
'content' => '已根据描述生成图片:',
'attachments' => $stored,
'finished' => true,
];
}
if ($status['state'] === 'error') {
return self::failJobResult($status['message'] ?: '图片生成失败');
}
$progress = $status['message'] ?: '图片生成中…';
$pendingAttachment = self::buildJobAttachment(
$promptId,
(int) $model->id,
$progress,
(int) ($job['created'] ?? time())
);
if ($generationPrompt !== '') {
$pendingAttachment['generation_prompt'] = $generationPrompt;
}
return [
'content' => $progress,
'attachments' => [$pendingAttachment],
'finished' => false,
];
}
private static function failJobResult(string $message): array
{
return [
'content' => '图片生成失败:' . $message,
'attachments' => [],
'finished' => true,
];
}
public static function testConnection(array $config): array
{
$apiBaseUrl = rtrim($config['api_base_url'] ?? '', '/');
if ($apiBaseUrl === '') {
throw new \InvalidArgumentException('请填写 ComfyUI 地址(如 http://127.0.0.1:8188');
}
$url = $apiBaseUrl . '/system_stats';
$start = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, self::curlDefaults($config['api_key'] ?? '', [
CURLOPT_HTTPGET => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 8,
]));
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$latencyMs = (int) round((microtime(true) - $start) * 1000);
if ($response === false) {
throw new \RuntimeException('连接失败: ' . ($curlError ?: '网络不可达,请确认 ComfyUI 已启动'));
}
if ($httpCode !== 200) {
throw new \RuntimeException('API 返回错误: HTTP ' . $httpCode . '(请确认地址指向 ComfyUI,不要带 /prompt 路径)');
}
$data = json_decode($response, true);
$devices = $data['devices'] ?? [];
$deviceName = is_array($devices) && !empty($devices[0]['name'])
? (string) $devices[0]['name']
: 'ComfyUI';
return [
'success' => true,
'latency_ms' => $latencyMs,
'reply' => 'ComfyUI 连接成功(' . $deviceName . '',
'model' => 'comfy',
'tokens' => null,
];
}
private static function buildWorkflow(string $prompt, AiModel $model, int $imageCount = 1): array
{
$extra = self::modelExtraConfig($model);
$workflow = self::loadWorkflowTemplate($extra);
$promptNode = self::resolvePromptNode($workflow, $extra['prompt_node'] ?? null);
$enriched = self::enrichUserPromptForImage($prompt);
self::injectPromptText($workflow, $promptNode, $enriched);
$exactVisibleText = self::extractExactVisibleText($prompt);
// Chinese must pass through TextGenerate even when refinement was disabled in
// model settings; otherwise command words can be rendered into the image.
$workflowCanRefine = self::workflowHasTextGenerate($workflow);
$refinePrompt = array_key_exists('refine_prompt', $extra)
? (bool) $extra['refine_prompt']
: $workflowCanRefine;
if ($exactVisibleText !== null) {
// Keep explicitly requested Unicode text byte-for-byte. A second
// language-model pass tends to translate or mutate book titles.
$refinePrompt = false;
} elseif ($workflowCanRefine && preg_match('/\p{Han}/u', $prompt)) {
$refinePrompt = true;
}
if ($refinePrompt) {
self::enablePromptRefineSwitches($workflow);
self::applySystemPrompt($workflow, $extra);
self::optimizeTextGenerateForImagePrompt($workflow, $prompt, $extra);
self::stripTextGenerateReasoningForPromptEncoders($workflow);
} else {
// 关闭扩写时用英文兜底提示,避免中文被模型「画成字」
self::shortCircuitLinkedPromptEncoders($workflow, self::buildDirectEnglishPrompt($prompt));
self::disablePromptRefineSwitches($workflow);
}
$seedNode = self::resolveSeedNode($workflow, $extra['seed_node'] ?? null);
if ($seedNode !== null && isset($workflow[$seedNode]['inputs']['seed'])) {
// Comfy 部分节点 seed 为 64-bit,这里用 31-bit 足够且兼容旧工作流
$workflow[$seedNode]['inputs']['seed'] = random_int(0, 2_147_483_647);
}
// Always merge the relevant baseline; exact-title jobs must not receive
// the global no-text negative prompt because it contradicts the request.
$negativeBaseline = $exactVisibleText !== null
? self::EXACT_TEXT_NEGATIVE
: self::DEFAULT_NEGATIVE;
$negativePromptNodes = array_fill_keys(
array_values(array_diff(
self::conditioningClipNodeIds($workflow, 'negative'),
self::conditioningClipNodeIds($workflow, 'positive')
)),
true
);
foreach ($workflow as $nodeId => &$node) {
if (($node['class_type'] ?? '') !== 'CLIPTextEncode') {
continue;
}
if (!isset($negativePromptNodes[(string) $nodeId])) {
continue;
}
if (!isset($node['inputs']['text']) || !is_string($node['inputs']['text'])) {
continue;
}
$negative = trim($node['inputs']['text']);
if ($negative === '') {
$node['inputs']['text'] = $negativeBaseline;
} elseif (stripos($negative, $exactVisibleText !== null ? 'misspelled title' : 'Chinese characters') === false) {
$node['inputs']['text'] = $negative . ', ' . $negativeBaseline;
}
}
unset($node);
self::optimizeTextToImageSampling($workflow, $extra);
self::applySizeOrAspect($workflow, $model, $extra, $prompt);
self::applyImageBatchSize($workflow, $imageCount);
if (preg_match('/图片要大|大图|高清|超清/u', $prompt)) {
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') !== 'ResolutionSelector') {
continue;
}
if (!isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
$mp = (float) ($node['inputs']['megapixels'] ?? 1);
$node['inputs']['megapixels'] = max($mp, 2);
}
unset($node);
}
self::assertWorkflowHasImageOutput($workflow);
return $workflow;
}
private static function applyImageBatchSize(array &$workflow, int $imageCount): void
{
$imageCount = max(1, min(4, $imageCount));
foreach ($workflow as &$node) {
if (!isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
if (array_key_exists('batch_size', $node['inputs'])
&& is_numeric($node['inputs']['batch_size'])) {
$node['inputs']['batch_size'] = $imageCount;
}
}
unset($node);
}
private static function buildBackgroundRemovalWorkflow(string $sourceName): array
{
return [
'1' => [
'class_type' => 'LoadImage',
'inputs' => [
'image' => $sourceName,
'upload' => 'image',
],
],
'2' => [
'class_type' => 'easy imageRemBg',
'inputs' => [
'images' => ['1', 0],
'rem_mode' => 'RMBG-1.4',
'image_output' => 'Save',
'save_prefix' => 'AIChat/cutout',
'torchscript_jit' => false,
'add_background' => 'none',
'refine_foreground' => true,
],
],
];
}
private static function buildEditWorkflow(
string $prompt,
AiModel $model,
string $sourceName,
?string $maskName,
string $mode,
?float $denoiseOverride = null,
string $operation = 'edit',
?int $sourceSeed = null
): array {
$extra = self::modelExtraConfig($model);
$workflow = self::loadEditWorkflowTemplate($extra, $mode, $operation);
$isOutpaint = $operation === 'outpaint' || stripos($prompt, 'OUTPAINT_FULL_BLEED') !== false;
$promptNode = self::resolvePromptNode(
$workflow,
$extra[$mode . '_prompt_node'] ?? ($extra['prompt_node'] ?? null)
);
self::injectPromptText(
$workflow,
$promptNode,
self::enrichUserPromptForEdit($prompt, $mode, $isOutpaint)
);
$workflowCanRefine = self::workflowHasTextGenerate($workflow);
$refinePrompt = array_key_exists('refine_prompt', $extra)
? (bool) $extra['refine_prompt']
: $workflowCanRefine;
if ($workflowCanRefine && preg_match('/\p{Han}/u', $prompt)) {
$refinePrompt = true;
}
// Outpaint prompts are already constrained; rewriting them often becomes a studio product shot.
if ($isOutpaint) {
$refinePrompt = false;
}
if ($refinePrompt) {
self::enablePromptRefineSwitches($workflow);
$editPromptConfig = $extra;
if (!empty($extra['edit_system_prompt'])) {
$editPromptConfig['system_prompt'] = $extra['edit_system_prompt'];
}
self::applySystemPrompt(
$workflow,
$editPromptConfig,
self::IMAGE_EDIT_PROMPT_ENGINEER_SYSTEM
);
self::randomizeTextGenerateSeed($workflow);
self::stripTextGenerateReasoningForPromptEncoders($workflow);
} else {
self::shortCircuitLinkedPromptEncoders(
$workflow,
self::buildDirectEnglishEditPrompt($prompt, $mode, $isOutpaint)
);
self::disablePromptRefineSwitches($workflow);
}
$seedNode = self::resolveSeedNode(
$workflow,
$extra[$mode . '_seed_node'] ?? ($extra['seed_node'] ?? null)
);
if ($seedNode !== null && isset($workflow[$seedNode]['inputs']['seed'])) {
if ($isOutpaint) {
// Derive a stable seed from the actual source image. Repeating
// the same edit stays reproducible, while unrelated images no
// longer inherit a seed that happened to suit one test image.
$configuredSeed = $extra['outpaint_seed'] ?? $sourceSeed ?? 1_093_690_499;
$workflow[$seedNode]['inputs']['seed'] = max(
0,
min(2_147_483_647, (int) $configuredSeed)
);
} else {
$workflow[$seedNode]['inputs']['seed'] = random_int(0, 2_147_483_647);
}
}
$imageNode = self::resolveImageInputNode(
$workflow,
$extra[$mode . '_image_node'] ?? null,
false
);
self::injectInputFilename($workflow, $imageNode, $sourceName);
if ($mode === 'inpaint') {
$maskNode = self::resolveImageInputNode(
$workflow,
$extra['inpaint_mask_node'] ?? null,
true
);
self::injectInputFilename($workflow, $maskNode, (string) $maskName);
}
$denoiseKey = $mode . '_denoise';
$denoise = $denoiseOverride ?? (array_key_exists($denoiseKey, $extra)
? (float) $extra[$denoiseKey]
: ($mode === 'inpaint' ? 0.72 : 0.45));
self::applyEditDenoise($workflow, max(0.01, min(1.0, $denoise)));
self::mergeDefaultNegativePrompt($workflow, $promptNode, $isOutpaint);
if ($isOutpaint) {
self::applyOutpaintMaskGrow($workflow);
}
self::assertWorkflowHasImageOutput($workflow);
return $workflow;
}
private static function deterministicSeedFromImage(string $sourcePath): int
{
$hash = @hash_file('sha256', $sourcePath);
if (!is_string($hash) || strlen($hash) < 8) {
return 1_093_690_499;
}
return max(1, ((int) hexdec(substr($hash, 0, 8))) & 0x7FFFFFFF);
}
private static function loadEditWorkflowTemplate(array $extra, string $mode, string $operation = 'edit'): array
{
if ($operation === 'outpaint' && !empty($extra['outpaint_workflow']) && is_array($extra['outpaint_workflow'])) {
return self::normalizeApiWorkflow($extra['outpaint_workflow']);
}
$key = $mode . '_workflow';
if (!empty($extra[$key]) && is_array($extra[$key])) {
return self::normalizeApiWorkflow($extra[$key]);
}
// Deriving from the configured text-to-image workflow preserves custom
// checkpoint, CLIP, VAE, sampler, and multi-GPU loader choices.
if ($operation === 'outpaint') {
return self::buildFooocusOutpaintWorkflow();
}
return self::deriveEditWorkflow(self::loadWorkflowTemplate($extra), $mode);
}
/**
* Fooocus SDXL outpaint graph based on Acly/comfyui-inpaint-nodes' official
* outpaint workflow. The masked canvas is prefilled using Navier-Stokes and
* low-frequency blur, never mirroring source pixels. The Fooocus inpaint
* patch then generates new content and the original centre is composited
* back unchanged.
*/
private static function buildFooocusOutpaintWorkflow(): array
{
return [
'910001' => [
'inputs' => ['image' => ''],
'class_type' => 'LoadImage',
'_meta' => ['title' => 'Outpaint Source Image'],
],
'910002' => [
'inputs' => ['image' => '', 'channel' => 'red'],
'class_type' => 'LoadImageMask',
'_meta' => ['title' => 'Outpaint Mask'],
],
'910003' => [
'inputs' => ['ckpt_name' => 'juggernautXL_version6Rundiffusion.safetensors'],
'class_type' => 'CheckpointLoaderSimple',
'_meta' => ['title' => 'Juggernaut XL v6'],
],
'910004' => [
'inputs' => [
'text' => '',
'clip' => ['910003', 1],
],
'class_type' => 'CLIPTextEncode',
'_meta' => ['title' => 'User Prompt'],
],
'910005' => [
'inputs' => [
'text' => 'text, letters, watermark, duplicate subject, repeated person, mirrored content, tiled image, frame, border, poster, mockup',
'clip' => ['910003', 1],
],
'class_type' => 'CLIPTextEncode',
'_meta' => ['title' => 'Negative Prompt'],
],
'910006' => [
'inputs' => ['mask' => ['910002', 0], 'grow' => 8, 'blur' => 7, 'blur_type' => 'gaussian'],
'class_type' => 'INPAINT_ExpandMask',
'_meta' => ['title' => 'Expand and Feather Outpaint Mask'],
],
'910007' => [
'inputs' => ['image' => ['910001', 0], 'mask' => ['910006', 0], 'fill' => 'navier-stokes', 'falloff' => 0],
'class_type' => 'INPAINT_MaskedFill',
'_meta' => ['title' => 'Navier-Stokes Border Prefill'],
],
'910008' => [
'inputs' => ['image' => ['910007', 0], 'mask' => ['910006', 0], 'blur' => 65, 'falloff' => 0],
'class_type' => 'INPAINT_MaskedBlur',
'_meta' => ['title' => 'Low Frequency Border Guide'],
],
'910009' => [
'inputs' => [
'positive' => ['910004', 0],
'negative' => ['910005', 0],
'vae' => ['910003', 2],
'pixels' => ['910008', 0],
'mask' => ['910006', 0],
],
'class_type' => 'INPAINT_VAEEncodeInpaintConditioning',
'_meta' => ['title' => 'Fooocus Inpaint Conditioning'],
],
'910010' => [
'inputs' => ['head' => 'fooocus_inpaint_head.pth', 'patch' => 'inpaint_v26.fooocus.patch'],
'class_type' => 'INPAINT_LoadFooocusInpaint',
'_meta' => ['title' => 'Load Fooocus Inpaint v2.6'],
],
'910011' => [
'inputs' => ['model' => ['910003', 0], 'patch' => ['910010', 0], 'latent' => ['910009', 2]],
'class_type' => 'INPAINT_ApplyFooocusInpaint',
'_meta' => ['title' => 'Apply Fooocus Inpaint Patch'],
],
'910012' => [
'inputs' => [
'model' => ['910011', 0],
'seed' => 0,
'steps' => 28,
'cfg' => 6.0,
'sampler_name' => 'dpmpp_2m_sde_gpu',
'scheduler' => 'karras',
'positive' => ['910009', 0],
'negative' => ['910009', 1],
'latent_image' => ['910009', 3],
'denoise' => 1.0,
],
'class_type' => 'KSampler',
'_meta' => ['title' => 'Fooocus Outpaint Sampler'],
],
'910013' => [
'inputs' => ['samples' => ['910012', 0], 'vae' => ['910003', 2]],
'class_type' => 'VAEDecode',
'_meta' => ['title' => 'Decode Outpaint'],
],
'910014' => [
'inputs' => [
'destination' => ['910001', 0],
'source' => ['910013', 0],
'x' => 0,
'y' => 0,
'resize_source' => false,
// The browser mask is already feathered only toward the source
// while its generated side remains pure white. Re-blurring it
// here would bleed toward the padded canvas and expose a gray
// shadow band on bright images.
'mask' => ['910002', 0],
],
'class_type' => 'ImageCompositeMasked',
'_meta' => ['title' => 'Single-Pass Inward Feather Composite'],
],
'910015' => [
'inputs' => ['filename_prefix' => 'Chat_Outpaint_Fooocus_', 'images' => ['910014', 0]],
'class_type' => 'SaveImage',
'_meta' => ['title' => 'Save Outpaint'],
],
];
}
private static function deriveEditWorkflow(array $workflow, string $mode): array
{
$samplerId = null;
foreach ($workflow as $id => $node) {
$inputs = $node['inputs'] ?? [];
if (is_array($inputs)
&& array_key_exists('latent_image', $inputs)
&& array_key_exists('denoise', $inputs)) {
$samplerId = (string) $id;
break;
}
}
if ($samplerId === null) {
throw new \RuntimeException('图片编辑工作流无法定位带 latent_image/denoise 的采样节点');
}
$vaeReference = null;
foreach ($workflow as $node) {
if (($node['class_type'] ?? '') !== 'VAEDecode') {
continue;
}
$candidate = $node['inputs']['vae'] ?? null;
if (is_array($candidate) && isset($candidate[0])) {
$vaeReference = [(string) $candidate[0], (int) ($candidate[1] ?? 0)];
break;
}
}
if ($vaeReference === null) {
foreach ($workflow as $id => $node) {
if (preg_match('/VAELoader/i', (string) ($node['class_type'] ?? ''))) {
$vaeReference = [(string) $id, 0];
break;
}
}
}
if ($vaeReference === null) {
throw new \RuntimeException('图片编辑工作流无法定位 VAE 节点');
}
$imageNode = self::unusedWorkflowNodeId($workflow, 900001);
$encodeNode = self::unusedWorkflowNodeId($workflow, (int) $imageNode + 1);
$workflow[$imageNode] = [
'inputs' => ['image' => ''],
'class_type' => 'LoadImage',
'_meta' => ['title' => 'Chat Source Image'],
];
if ($mode === 'inpaint') {
$maskNode = self::unusedWorkflowNodeId($workflow, (int) $encodeNode + 1);
$workflow[$maskNode] = [
'inputs' => ['image' => '', 'channel' => 'red'],
'class_type' => 'LoadImageMask',
'_meta' => ['title' => 'Chat Inpaint Mask'],
];
$workflow[$encodeNode] = [
'inputs' => [
'pixels' => [$imageNode, 0],
'vae' => $vaeReference,
'mask' => [$maskNode, 0],
'grow_mask_by' => 6,
],
'class_type' => 'VAEEncodeForInpaint',
'_meta' => ['title' => 'Chat Inpaint Encode'],
];
} else {
$workflow[$encodeNode] = [
'inputs' => [
'pixels' => [$imageNode, 0],
'vae' => $vaeReference,
],
'class_type' => 'VAEEncode',
'_meta' => ['title' => 'Chat Img2Img Encode'],
];
}
$workflow[$samplerId]['inputs']['latent_image'] = [$encodeNode, 0];
return $workflow;
}
private static function unusedWorkflowNodeId(array $workflow, int $start): string
{
while (isset($workflow[(string) $start]) || isset($workflow[$start])) {
$start++;
}
return (string) $start;
}
private static function resolveImageInputNode(
array $workflow,
mixed $configured,
bool $mask
): string {
$configured = trim((string) ($configured ?? ''));
if ($configured !== '' && isset($workflow[$configured]['inputs']['image'])) {
return $configured;
}
$expectedType = $mask ? 'LoadImageMask' : 'LoadImage';
foreach ($workflow as $id => $node) {
if (($node['class_type'] ?? '') === $expectedType
&& array_key_exists('image', $node['inputs'] ?? [])) {
return (string) $id;
}
}
throw new \RuntimeException($mask
? 'inpaint 工作流缺少 LoadImageMask 输入节点'
: 'img2img 工作流缺少 LoadImage 输入节点');
}
private static function injectInputFilename(array &$workflow, string $nodeId, string $filename): void
{
if ($filename === '' || !isset($workflow[$nodeId]['inputs']['image'])) {
throw new \RuntimeException('图片编辑工作流输入节点配置无效');
}
$workflow[$nodeId]['inputs']['image'] = $filename;
}
private static function applyEditDenoise(array &$workflow, float $denoise): void
{
$updated = false;
foreach ($workflow as &$node) {
if (!array_key_exists('denoise', $node['inputs'] ?? [])) {
continue;
}
$node['inputs']['denoise'] = $denoise;
$updated = true;
}
unset($node);
if (!$updated) {
throw new \RuntimeException('图片编辑工作流缺少 denoise 参数');
}
}
private static function applyOutpaintMaskGrow(array &$workflow): void
{
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') === 'GrowMask'
&& array_key_exists('expand', $node['inputs'] ?? [])) {
$node['inputs']['expand'] = max(12, (int) ($node['inputs']['expand'] ?? 0));
}
if (($node['class_type'] ?? '') !== 'VAEEncodeForInpaint') {
continue;
}
if (!array_key_exists('grow_mask_by', $node['inputs'] ?? [])) {
continue;
}
$node['inputs']['grow_mask_by'] = max(8, (int) ($node['inputs']['grow_mask_by'] ?? 0));
}
unset($node);
}
private static function mergeDefaultNegativePrompt(
array &$workflow,
string $promptNode,
bool $isOutpaint = false
): void {
foreach ($workflow as $nodeId => &$node) {
if (($node['class_type'] ?? '') !== 'CLIPTextEncode'
|| !isset($node['inputs']['text'])
|| !is_string($node['inputs']['text'])
|| (string) $nodeId === (string) $promptNode) {
continue;
}
$negative = trim($node['inputs']['text']);
$suffix = self::EDIT_DEFAULT_NEGATIVE
. ($isOutpaint ? ', ' . self::OUTPAINT_NEGATIVE : '');
$node['inputs']['text'] = $negative === ''
? $suffix
: $negative . ', ' . $suffix;
}
unset($node);
}
/**
* @return array{workflow?:array|null,prompt_node?:string|null,seed_node?:string|null,aspect_ratio?:string|null}
*/
private static function modelExtraConfig(AiModel $model): array
{
$extra = $model->extra_config ?? null;
if (is_string($extra)) {
$extra = json_decode($extra, true);
}
return is_array($extra) ? $extra : [];
}
private static function loadWorkflowTemplate(array $extra): array
{
if (!empty($extra['workflow']) && is_array($extra['workflow'])) {
$workflow = $extra['workflow'];
} else {
$path = root_path() . 'config' . DIRECTORY_SEPARATOR . 'comfyui_workflow.json';
if (!is_file($path)) {
throw new \RuntimeException('找不到 ComfyUI 工作流文件 config/comfyui_workflow.json,请在模型配置中粘贴工作流 JSON');
}
$workflow = json_decode((string) file_get_contents($path), true);
}
return self::normalizeApiWorkflow($workflow);
}
/**
* 统一成 ComfyUI /prompt 需要的 API Format,并拒绝 UI Format。
*/
public static function normalizeApiWorkflow(mixed $workflow): array
{
if (is_string($workflow)) {
$workflow = json_decode($workflow, true);
}
if (!is_array($workflow) || $workflow === []) {
throw new \RuntimeException('ComfyUI 工作流格式无效');
}
// 兼容 {prompt:{...}} / {workflow:{...}} 包装
if (isset($workflow['prompt']) && is_array($workflow['prompt']) && self::looksLikeApiWorkflow($workflow['prompt'])) {
$workflow = $workflow['prompt'];
} elseif (isset($workflow['workflow']) && is_array($workflow['workflow']) && self::looksLikeApiWorkflow($workflow['workflow'])) {
$workflow = $workflow['workflow'];
}
if (self::looksLikeUiWorkflow($workflow)) {
throw new \RuntimeException(
'检测到 ComfyUI「UI Format」工作流。请在 ComfyUI 开发者模式中使用 Save (API Format) 导出后再导入;'
. '当前这种带 nodes/links 的文件不能直接提交给 /prompt'
);
}
if (!self::looksLikeApiWorkflow($workflow)) {
throw new \RuntimeException('工作流不是有效的 API Format(需要每个节点包含 class_type 与 inputs');
}
$normalized = [];
foreach ($workflow as $id => $node) {
if (!is_array($node)) {
continue;
}
if (empty($node['class_type']) || !isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
$normalized[(string) $id] = $node;
}
if ($normalized === []) {
throw new \RuntimeException('工作流中没有可执行节点');
}
return $normalized;
}
private static function looksLikeUiWorkflow(array $workflow): bool
{
return isset($workflow['nodes']) && is_array($workflow['nodes'])
&& (isset($workflow['links']) || isset($workflow['version']) || isset($workflow['last_node_id']));
}
private static function looksLikeApiWorkflow(array $workflow): bool
{
$found = 0;
foreach ($workflow as $node) {
if (!is_array($node)) {
continue;
}
if (!empty($node['class_type']) && isset($node['inputs']) && is_array($node['inputs'])) {
$found++;
if ($found >= 1) {
return true;
}
}
}
return false;
}
private static function assertWorkflowHasImageOutput(array $workflow): void
{
foreach ($workflow as $node) {
$type = (string) ($node['class_type'] ?? '');
if (preg_match('/SaveImage|PreviewImage|SaveAnimated|VHS_VideoCombine/i', $type)) {
return;
}
}
throw new \RuntimeException('工作流缺少 SaveImage/PreviewImage 等输出节点,无法取回生成结果');
}
/**
* 若正向 CLIPTextEncode 的 text 是连线,则改为直接写入用户提示词(关闭 AI 扩写时使用)。
*/
private static function shortCircuitLinkedPromptEncoders(array &$workflow, string $prompt): void
{
$linked = [];
foreach ($workflow as $id => $node) {
if (($node['class_type'] ?? '') !== 'CLIPTextEncode') {
continue;
}
if (isset($node['inputs']['text']) && is_array($node['inputs']['text'])) {
$linked[] = (string) $id;
}
}
// 仅在「只有一个连线文本编码器」时自动短接,避免误伤复杂工作流
if (count($linked) !== 1) {
return;
}
$id = $linked[0];
$workflow[$id]['inputs']['text'] = $prompt;
}
private static function workflowHasTextGenerate(array $workflow): bool
{
foreach ($workflow as $node) {
if (($node['class_type'] ?? '') === 'TextGenerate') {
return true;
}
}
return false;
}
private static function enablePromptRefineSwitches(array &$workflow): void
{
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') !== 'PrimitiveBoolean') {
continue;
}
if (!array_key_exists('value', $node['inputs'] ?? [])) {
continue;
}
// Refine Prompt? 开关:开启 AI 扩写
$node['inputs']['value'] = true;
}
unset($node);
}
private static function disablePromptRefineSwitches(array &$workflow): void
{
if (!self::workflowHasTextGenerate($workflow)) {
return;
}
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') !== 'PrimitiveBoolean') {
continue;
}
if (!array_key_exists('value', $node['inputs'] ?? [])) {
continue;
}
if ($node['inputs']['value'] === true || $node['inputs']['value'] === 1) {
$node['inputs']['value'] = false;
}
}
unset($node);
}
/**
* 仅补充通用约束(禁止出字等),不写死任何画风。
*/
private static function enrichUserPromptForImage(string $prompt): string
{
$prompt = trim($prompt);
$notes = [
'Follow the user request faithfully, including any art style they named. Do not replace it with a different famous style.',
'Preserve cultural identity literally: a Chinese swordsman is not a samurai, and wuxia or xianxia is not Japanese fantasy.',
'CRITICAL: no text, no letters, no Chinese characters, no watermark, no caption, no title anywhere in the image.',
'Create one standalone full-bleed finished image, never a contact sheet, storyboard, concept sheet, presentation, poster layout, split panels, border, or UI.',
'Never visualize, quote, label, or print the user request inside the image.',
'Expand into detailed ENGLISH visual description only.',
];
return $prompt . "\n\n[Requirements]\n- " . implode("\n- ", $notes);
}
private static function enrichUserPromptForEdit(
string $prompt,
string $mode,
bool $isOutpaint = false
): string {
if ($isOutpaint) {
// CLIP treats forbidden nouns as visual concepts even when prefixed
// with "never". Keep positive conditioning scene-only; exclusions
// belong exclusively to the negative encoder.
return trim($prompt);
}
$operation = $mode === 'inpaint'
? 'Only the white mask area may be reconstructed; blend it seamlessly with surrounding pixels.'
: 'Use the supplied image as the source and preserve its identity and unrequested visual details.';
return trim($prompt) . "\n\n[Image editing requirements]\n- " . $operation
. "\n- Preserve composition, proportions, lighting continuity, and all areas not mentioned by the user."
. "\n- Preserve all existing text, typography, logos, and layout unless the user explicitly asks to remove them."
. "\n- Return a natural finished image with no newly added editing UI, mask overlay, watermark, caption, title, or text."
. "\n- Expand the requested final appearance into detailed ENGLISH visual description only.";
}
/**
* 无 TextGenerate 时的兜底英文提示(不写死画风)。
*/
private static function buildDirectEnglishPrompt(string $prompt): string
{
$prompt = trim($prompt);
$exactVisibleText = self::extractExactVisibleText($prompt);
if ($exactVisibleText !== null) {
$visualPrompt = preg_replace(
'/EXACT_VISIBLE_TEXT:\s*<<<.*?>>>\.?\s*(?:Render these exact UTF-8 characters once as the main title\.\s*Do not translate, rewrite, substitute, omit, reorder, or duplicate any character\.)?/isu',
'',
$prompt
) ?? $prompt;
$visualPrompt = preg_replace(
'/\s+(?:titled|with\s+(?:the\s+)?title)\s+[\'\"“][^\'\"”]+[\'\"”]/iu',
'',
$visualPrompt
) ?? $visualPrompt;
$visualPrompt = preg_replace(
'/(?:^|[,.]\s*)no\s+(?:readable\s+)?text\b(?:\s*,\s*no\s+[^,.\n]+)*[.]?/iu',
'',
$visualPrompt
) ?? $visualPrompt;
$visualPrompt = str_replace($exactVisibleText, '', $visualPrompt);
$visualPrompt = preg_replace(
'/\b(?:a\s+)?(?:high-quality\s+)?(?:novel|book)\s+cover\s+(?:design|mockup)\b/iu',
'edge-to-edge vertical front-facing 2D fantasy key art filling the entire canvas',
$visualPrompt
) ?? $visualPrompt;
$visualPrompt = preg_replace(
'/\b(?:novel|book)\s+cover(?:\s+artwork)?\b/iu',
'vertical full-bleed key art',
$visualPrompt
) ?? $visualPrompt;
$visualPrompt = preg_replace(
'/(?:制作|生成|设计|做)(?:一张)?(?:小说|网文|书籍|图书)?封面\s*[:]?/u',
'edge-to-edge vertical front-facing 2D fantasy key art filling the entire canvas, ',
$visualPrompt
) ?? $visualPrompt;
$visualPrompt = trim(preg_replace('/\s{2,}/u', ' ', $visualPrompt) ?? $visualPrompt, " \t\n\r\0\x0B,.");
if (stripos($visualPrompt, 'front-facing 2D fantasy key art') === false) {
$visualPrompt = 'edge-to-edge vertical front-facing 2D fantasy key art filling the entire canvas, '
. $visualPrompt;
}
return $visualPrompt
. ', main title must read exactly "' . $exactVisibleText . '" in clear Chinese typography'
. ', copy every character verbatim in the same order, one title instance only'
. ', edge-to-edge flat front cover composition with no perspective distortion';
}
return 'Create an image matching this user request, preserve the intended art style if any, '
. 'one standalone full-bleed finished composition, never a contact sheet, storyboard, concept sheet, presentation, poster layout, split panels, border, or UI, '
. 'never print or visualize the prompt, no text no letters no Chinese characters no watermark no caption. '
. 'User request: ' . preg_replace('/\s+/u', ' ', $prompt);
}
private static function extractExactVisibleText(string $prompt): ?string
{
if (!preg_match('/EXACT_VISIBLE_TEXT:\s*<<<(.*?)>>>/isu', $prompt, $matches)) {
return null;
}
$text = trim((string) $matches[1]);
if ($text === '' || mb_strlen($text) > 64) {
return null;
}
return str_replace(['<<<', '>>>'], '', $text);
}
private static function buildDirectEnglishEditPrompt(
string $prompt,
string $mode,
bool $isOutpaint = false
): string {
if ($isOutpaint) {
return preg_replace('/\s+/u', ' ', trim($prompt));
}
$scope = $mode === 'inpaint'
? 'Modify only the white masked region and blend it naturally into the source image.'
: 'Create a faithful edited variation of the supplied source image.';
return $scope . ' Preserve every unrequested subject and visual detail. '
. 'Preserve all existing text, typography, logos, and layout unless explicitly targeted for removal. '
. 'Do not add new text, captions, logos, signatures, UI, mask overlays, or watermarks. '
. 'Requested final result: ' . preg_replace('/\s+/u', ' ', trim($prompt));
}
/**
* 写入 TextGenerate 的 System Prompt:优先用模型 extra_config.system_prompt(后台可自定义画风规则)。
*/
private static function applySystemPrompt(
array &$workflow,
array $extra,
?string $defaultSystem = null
): void
{
$custom = trim((string) ($extra['system_prompt'] ?? ''));
$system = $custom !== ''
? $custom
: ($defaultSystem !== null ? $defaultSystem : self::PROMPT_ENGINEER_SYSTEM);
if (!preg_match('/^\s*\/no_think\b/i', $system)) {
$system = "/no_think\n" . $system;
}
foreach ($workflow as &$node) {
$type = (string) ($node['class_type'] ?? '');
if ($type !== 'PrimitiveStringMultiline' && $type !== 'PrimitiveString') {
continue;
}
$title = (string) ($node['_meta']['title'] ?? '');
$value = (string) ($node['inputs']['value'] ?? '');
$isSystem = preg_match('/system\s*prompt/i', $title)
|| preg_match('/you are an expert prompt engineer/i', $value);
if (!$isSystem) {
continue;
}
$node['inputs']['value'] = $system;
}
unset($node);
}
/**
* Prompt expansion should be short and stable. Image diversity comes from
* the sampler seed, so changing the language-model wording on every retry
* only reduces instruction accuracy and wastes generation time.
*/
private static function optimizeTextGenerateForImagePrompt(
array &$workflow,
string $prompt,
array $extra
): void {
$maxLength = max(96, min(256, (int) ($extra['prompt_max_length'] ?? 128)));
$temperature = max(0.05, min(0.7, (float) ($extra['prompt_temperature'] ?? 0.25)));
$unsignedHash = (int) sprintf('%u', crc32(trim($prompt)));
$seed = $unsignedHash % 2_147_483_647;
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') !== 'TextGenerate') {
continue;
}
if (!isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
$node['inputs']['thinking'] = false;
$node['inputs']['max_length'] = $maxLength;
$node['inputs']['sampling_mode'] = 'on';
$node['inputs']['sampling_mode.temperature'] = $temperature;
$node['inputs']['sampling_mode.top_k'] = 32;
$node['inputs']['sampling_mode.top_p'] = 0.88;
$node['inputs']['sampling_mode.min_p'] = 0.02;
$node['inputs']['sampling_mode.repetition_penalty'] = 1.08;
$node['inputs']['sampling_mode.seed'] = $seed;
}
unset($node);
}
/**
* Turbo checkpoints are distilled for a small number of steps. Some saved
* database workflows still contain the old 20-step Euler settings, which
* are considerably slower and can overcook the image.
*/
private static function optimizeTextToImageSampling(array &$workflow, array $extra): void
{
$checkpointNames = [];
foreach ($workflow as $node) {
foreach (['unet_name', 'ckpt_name', 'model_name'] as $key) {
$value = $node['inputs'][$key] ?? null;
if (is_string($value) && $value !== '') {
$checkpointNames[] = mb_strtolower($value);
}
}
}
$joinedNames = implode(' ', $checkpointNames);
$isTurbo = preg_match('/(?:turbo|schnell|lightning|hyper|lcm)/i', $joinedNames) === 1;
$configuredSteps = isset($extra['sampling_steps'])
? max(1, min(40, (int) $extra['sampling_steps']))
: null;
$steps = $configuredSteps ?? ($isTurbo ? 8 : null);
foreach ($workflow as &$node) {
$type = (string) ($node['class_type'] ?? '');
if (preg_match('/KSampler|SamplerCustom/i', $type) && isset($node['inputs']) && is_array($node['inputs'])) {
if ($steps !== null && array_key_exists('steps', $node['inputs'])) {
$node['inputs']['steps'] = $steps;
}
if ($isTurbo && str_contains($joinedNames, 'z_image') && array_key_exists('sampler_name', $node['inputs'])) {
$node['inputs']['sampler_name'] = (string) ($extra['sampler_name'] ?? 'res_multistep');
if (array_key_exists('scheduler', $node['inputs'])) {
$node['inputs']['scheduler'] = (string) ($extra['scheduler'] ?? 'simple');
}
}
}
if ($isTurbo
&& str_contains($joinedNames, 'z_image')
&& $type === 'ModelSamplingAuraFlow'
&& isset($node['inputs']['shift'])) {
$node['inputs']['shift'] = max(1, min(50, (float) ($extra['aura_shift'] ?? 5)));
}
}
unset($node);
}
private static function randomizeTextGenerateSeed(array &$workflow): void
{
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') !== 'TextGenerate') {
continue;
}
if (!isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
if (array_key_exists('sampling_mode.seed', $node['inputs'])) {
$node['inputs']['sampling_mode.seed'] = random_int(0, 2_147_483_647);
}
}
unset($node);
}
/**
* Qwen TextGenerate may return a <think>...</think> reasoning block even
* when its thinking option is disabled. Feeding that hidden reasoning to
* Qwen-Image makes it reproduce the user's words as headings or turn a
* scene into a labelled concept sheet. Strip the reasoning immediately
* before every linked prompt encoder while keeping PreviewAny available.
*/
private static function stripTextGenerateReasoningForPromptEncoders(array &$workflow): void
{
foreach (array_keys($workflow) as $nodeId) {
if (($workflow[$nodeId]['class_type'] ?? '') !== 'CLIPTextEncode') {
continue;
}
$textInput = $workflow[$nodeId]['inputs']['text'] ?? null;
if (!is_array($textInput) || !isset($textInput[0])) {
continue;
}
if (!self::workflowPathContainsClass($workflow, (string) $textInput[0], 'TextGenerate')) {
continue;
}
$cleanupId = '__prompt_cleanup_' . preg_replace('/[^a-zA-Z0-9_-]+/', '_', (string) $nodeId);
while (isset($workflow[$cleanupId])) {
$cleanupId .= '_1';
}
$workflow[$cleanupId] = [
'class_type' => 'RegexReplace',
'inputs' => [
'string' => $textInput,
'regex_pattern' => '<think>.*?(?:</think>|$)\\s*',
'replace' => '',
'case_insensitive' => true,
'multiline' => true,
'dotall' => true,
'count' => 0,
],
'_meta' => ['title' => 'Strip prompt reasoning'],
];
$workflow[$nodeId]['inputs']['text'] = [$cleanupId, 0];
}
}
private static function workflowPathContainsClass(
array $workflow,
string $nodeId,
string $classType,
array $visited = []
): bool {
if (isset($visited[$nodeId]) || !isset($workflow[$nodeId])) {
return false;
}
if (($workflow[$nodeId]['class_type'] ?? '') === $classType) {
return true;
}
$visited[$nodeId] = true;
foreach (($workflow[$nodeId]['inputs'] ?? []) as $input) {
if (!is_array($input) || !isset($input[0])) {
continue;
}
if (self::workflowPathContainsClass($workflow, (string) $input[0], $classType, $visited)) {
return true;
}
}
return false;
}
/**
* Return CLIP encoders reachable from a sampler conditioning input. A node
* reachable from both positive and negative (for example through
* ConditioningZeroOut) is intentionally filtered by the caller so a
* negative baseline can never leak into positive conditioning.
*
* @return string[]
*/
private static function conditioningClipNodeIds(array $workflow, string $slot): array
{
$ids = [];
foreach ($workflow as $node) {
$type = (string) ($node['class_type'] ?? '');
if (!preg_match('/KSampler|SamplerCustom/i', $type)) {
continue;
}
$input = $node['inputs'][$slot] ?? null;
if (!is_array($input) || !isset($input[0])) {
continue;
}
self::collectUpstreamNodesByClass(
$workflow,
(string) $input[0],
'CLIPTextEncode',
$ids
);
}
return array_values(array_unique($ids));
}
private static function collectUpstreamNodesByClass(
array $workflow,
string $nodeId,
string $classType,
array &$matches,
array &$visited = []
): void {
if (isset($visited[$nodeId]) || !isset($workflow[$nodeId])) {
return;
}
$visited[$nodeId] = true;
$node = $workflow[$nodeId];
if (($node['class_type'] ?? '') === $classType) {
$matches[] = $nodeId;
}
foreach (($node['inputs'] ?? []) as $input) {
if (!is_array($input) || !isset($input[0])) {
continue;
}
self::collectUpstreamNodesByClass(
$workflow,
(string) $input[0],
$classType,
$matches,
$visited
);
}
}
private static function resolvePromptNode(array $workflow, ?string $configured): string
{
$configured = $configured !== null ? trim($configured) : '';
if ($configured !== '' && isset($workflow[$configured])) {
return $configured;
}
foreach ($workflow as $id => $node) {
$title = (string) ($node['_meta']['title'] ?? '');
if ($title !== '' && preg_match('/user\s*prompt|用户提示|正向提示|正面提示/iu', $title)) {
return (string) $id;
}
}
foreach ($workflow as $id => $node) {
if (($node['class_type'] ?? '') !== 'CLIPTextEncode') {
continue;
}
if (isset($node['inputs']['text']) && is_string($node['inputs']['text'])) {
return (string) $id;
}
}
// 优先选择标题或内容像用户输入的 PrimitiveString
foreach ($workflow as $id => $node) {
$type = (string) ($node['class_type'] ?? '');
if ($type !== 'PrimitiveStringMultiline' && $type !== 'PrimitiveString') {
continue;
}
// 跳过明显的 system prompt(通常很长且含 expert/rules
$value = (string) ($node['inputs']['value'] ?? '');
if (preg_match('/you are an expert|system prompt|规则/i', $value)) {
continue;
}
return (string) $id;
}
foreach (['30:19', '67', '6', '3'] as $fallback) {
if (isset($workflow[$fallback])) {
return $fallback;
}
}
throw new \RuntimeException('无法定位提示词节点,请在模型配置中填写 prompt_node(如 30:19');
}
private static function injectPromptText(array &$workflow, string $nodeId, string $prompt): void
{
if (!isset($workflow[$nodeId]['inputs']) || !is_array($workflow[$nodeId]['inputs'])) {
throw new \RuntimeException("提示词节点 {$nodeId} 无效");
}
$inputs = &$workflow[$nodeId]['inputs'];
if (array_key_exists('value', $inputs) && (is_string($inputs['value']) || $inputs['value'] === null || $inputs['value'] === '')) {
$inputs['value'] = $prompt;
return;
}
if (array_key_exists('text', $inputs) && (is_string($inputs['text']) || $inputs['text'] === null || $inputs['text'] === '')) {
$inputs['text'] = $prompt;
return;
}
if (array_key_exists('prompt', $inputs) && (is_string($inputs['prompt']) || $inputs['prompt'] === null || $inputs['prompt'] === '')) {
$inputs['prompt'] = $prompt;
return;
}
// PrimitiveStringMultiline 等:强制写入 value
if (($workflow[$nodeId]['class_type'] ?? '') === 'PrimitiveStringMultiline') {
$inputs['value'] = $prompt;
return;
}
throw new \RuntimeException("提示词节点 {$nodeId} 没有可写入的 text/value 字段,请检查工作流");
}
private static function resolveSeedNode(array $workflow, ?string $configured): ?string
{
$configured = $configured !== null ? trim($configured) : '';
if ($configured !== '' && isset($workflow[$configured]['inputs']['seed'])) {
return $configured;
}
foreach ($workflow as $id => $node) {
$type = (string) ($node['class_type'] ?? '');
if (isset($node['inputs']['seed']) && preg_match('/KSampler|Sampler/i', $type)) {
return (string) $id;
}
}
foreach ($workflow as $id => $node) {
if (isset($node['inputs']['seed'])) {
return (string) $id;
}
}
return null;
}
/** ResolutionSelector 支持的宽高比(与 ComfyUI 节点 options 保持一致) */
private const ASPECT_RATIO_OPTIONS = [
'1:1 (Square)',
'2:3 (Portrait Photo)',
'3:2 (Photo)',
'3:4 (Portrait Standard)',
'4:3 (Standard)',
'9:16 (Portrait Widescreen)',
'16:9 (Widescreen)',
'21:9 (Ultrawide)',
];
private static function applySizeOrAspect(
array &$workflow,
AiModel $model,
array $extra,
string $prompt = ''
): void
{
$aspect = trim((string) ($extra['aspect_ratio'] ?? ''));
$size = trim((string) ($model->model_id ?? ''));
$intentAspect = $aspect === '' ? self::inferAspectRatioFromPrompt($prompt) : null;
// An explicit/use-case aspect in the prompt is more relevant than the
// model row's generic default (normally 1:1).
if ($intentAspect !== null) {
$aspect = $intentAspect;
} elseif ($aspect === '' && $size !== '') {
// model_id 仅在本身是合法宽高比时才当作 aspect_ratio(避免把 "admin" 等误写入)
$resolved = self::resolveAspectRatio($size);
if ($resolved !== null) {
$aspect = $resolved;
}
} elseif ($aspect !== '') {
$resolved = self::resolveAspectRatio($aspect);
$aspect = $resolved ?? '';
}
if ($aspect !== '') {
foreach ($workflow as &$node) {
if (($node['class_type'] ?? '') === 'ResolutionSelector' && isset($node['inputs']) && is_array($node['inputs'])) {
$node['inputs']['aspect_ratio'] = $aspect;
}
}
unset($node);
return;
}
// 非法宽高比:不覆盖工作流默认值(如 1:1 (Square)
if ($size !== '' && preg_match('/^(\d+)\s*[xX×]\s*(\d+)$/', $size, $m)) {
$width = max(64, min(2048, (int) $m[1]));
$height = max(64, min(2048, (int) $m[2]));
foreach ($workflow as &$node) {
$type = (string) ($node['class_type'] ?? '');
if (!isset($node['inputs']) || !is_array($node['inputs'])) {
continue;
}
// 仅改写数值型宽高,跳过连线数组
if (isset($node['inputs']['width']) && is_numeric($node['inputs']['width'])) {
$node['inputs']['width'] = $width;
}
if (isset($node['inputs']['height']) && is_numeric($node['inputs']['height'])) {
$node['inputs']['height'] = $height;
}
if (preg_match('/EmptyLatent|EmptySD3Latent/i', $type)) {
if (isset($node['inputs']['width']) && !is_array($node['inputs']['width'])) {
$node['inputs']['width'] = $width;
}
if (isset($node['inputs']['height']) && !is_array($node['inputs']['height'])) {
$node['inputs']['height'] = $height;
}
}
}
unset($node);
}
}
private static function inferAspectRatioFromPrompt(string $prompt): ?string
{
$prompt = mb_strtolower(trim($prompt));
if ($prompt === '') {
return null;
}
if (preg_match('/(?<!\d)(1\s*:\s*1|2\s*:\s*3|3\s*:\s*2|3\s*:\s*4|4\s*:\s*3|9\s*:\s*16|16\s*:\s*9|21\s*:\s*9)(?!\d)/u', $prompt, $matches)) {
return self::resolveAspectRatio(preg_replace('/\s+/', '', $matches[1]));
}
if (preg_match('/(?:小说|网文|书籍|图书|绘本).{0,8}(?:封面|书封)|(?:封面|书封).{0,8}(?:小说|网文|书籍|图书|绘本)|(?:novel|book)\s+cover/iu', $prompt)) {
return '2:3 (Portrait Photo)';
}
if (preg_match('/(?:手机壁纸|竖屏壁纸|phone wallpaper|vertical wallpaper)/iu', $prompt)) {
return '9:16 (Portrait Widescreen)';
}
if (preg_match('/(?:横版|横屏|宽屏|横幅|banner|widescreen)/iu', $prompt)) {
return '16:9 (Widescreen)';
}
if (preg_match('/(?:头像|应用图标|app icon|avatar|logo)/iu', $prompt)) {
return '1:1 (Square)';
}
return null;
}
/**
* 将用户输入解析为 ResolutionSelector 可接受的宽高比;无法识别则返回 null。
*/
private static function resolveAspectRatio(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
foreach (self::ASPECT_RATIO_OPTIONS as $opt) {
if (strcasecmp($opt, $value) === 0) {
return $opt;
}
}
// 允许只写 "1:1" / "16:9" 等简写
if (preg_match('/^(\d+)\s*:\s*(\d+)$/', $value, $m)) {
$short = $m[1] . ':' . $m[2];
foreach (self::ASPECT_RATIO_OPTIONS as $opt) {
if (str_starts_with($opt, $short . ' ')) {
return $opt;
}
}
}
return null;
}
/**
* 供管理后台校验工作流 JSON。
*/
public static function validateWorkflowConfig(?array $extra): ?string
{
if ($extra === null || $extra === []) {
return null;
}
try {
$workflow = self::loadWorkflowTemplate($extra);
self::resolvePromptNode($workflow, $extra['prompt_node'] ?? null);
self::assertWorkflowHasImageOutput($workflow);
foreach (['img2img', 'inpaint'] as $mode) {
if (empty($extra[$mode . '_workflow'])) {
continue;
}
$editWorkflow = self::loadEditWorkflowTemplate($extra, $mode);
self::resolvePromptNode(
$editWorkflow,
$extra[$mode . '_prompt_node'] ?? ($extra['prompt_node'] ?? null)
);
self::resolveImageInputNode(
$editWorkflow,
$extra[$mode . '_image_node'] ?? null,
false
);
if ($mode === 'inpaint') {
self::resolveImageInputNode(
$editWorkflow,
$extra['inpaint_mask_node'] ?? null,
true
);
}
self::assertWorkflowHasImageOutput($editWorkflow);
}
} catch (\Throwable $e) {
return $e->getMessage();
}
return null;
}
private static function uploadInputImage(
string $baseUrl,
string $path,
string $apiKey,
string $purpose
): string {
if (!is_file($path) || !is_readable($path)) {
throw new \RuntimeException('图片文件不存在或不可读');
}
$imageInfo = @getimagesize($path);
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
throw new \RuntimeException('ComfyUI 图片编辑仅支持有效的图片文件');
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], true)) {
$extension = $imageInfo['mime'] === 'image/jpeg' ? 'jpg' : 'png';
}
$subfolder = 'chat_edits/' . 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);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException(
'上传图片到 ComfyUI 失败: ' . ($curlError ?: 'HTTP ' . $httpCode)
);
}
$data = json_decode((string) $response, true);
$name = trim((string) ($data['name'] ?? $uploadName));
$storedSubfolder = trim((string) ($data['subfolder'] ?? $subfolder), '/\\');
if ($name === '') {
throw new \RuntimeException('ComfyUI 上传接口未返回图片文件名');
}
return $storedSubfolder === '' ? $name : $storedSubfolder . '/' . $name;
}
private static function queuePrompt(string $baseUrl, array $workflow, string $apiKey): string
{
// 用 stdClass 保证节点 ID 始终以 JSON 对象字符串键提交,避免 PHP 把 "29" 编成数组下标
$promptObj = new \stdClass();
foreach ($workflow as $id => $node) {
if (!is_array($node)) {
continue;
}
unset($node['_meta']);
$promptObj->{(string) $id} = $node;
}
$payload = [
'prompt' => $promptObj,
'client_id' => bin2hex(random_bytes(8)),
];
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
if ($body === false) {
throw new \RuntimeException('工作流 JSON 编码失败');
}
$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);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new \RuntimeException('提交 ComfyUI 任务失败: ' . ($curlError ?: '网络错误'));
}
$data = json_decode($response, true);
$nodeErrors = $data['node_errors'] ?? null;
if (is_array($nodeErrors)) {
$nodeErrors = array_filter($nodeErrors, fn ($v) => !empty($v));
} else {
$nodeErrors = null;
}
if ($httpCode !== 200 || !empty($nodeErrors)) {
$detail = $data['error']['message'] ?? $data['error'] ?? ('HTTP ' . $httpCode);
if (is_array($detail)) {
$detail = json_encode($detail, JSON_UNESCAPED_UNICODE);
}
if (!empty($nodeErrors)) {
$detail .= ';节点错误: ' . json_encode($nodeErrors, JSON_UNESCAPED_UNICODE);
}
throw new \RuntimeException('ComfyUI 拒绝任务: ' . $detail);
}
$promptId = $data['prompt_id'] ?? '';
if ($promptId === '') {
throw new \RuntimeException('ComfyUI 未返回 prompt_id');
}
return (string) $promptId;
}
private static function waitForOutputs(
string $baseUrl,
string $promptId,
string $apiKey,
?callable $onProgress,
int $httpBudgetSeconds = 25
): array {
@set_time_limit(0);
$startedAt = time();
// 开发环境 php think run 是单线程:不能长时间占住请求,否则整站(含刷新)都会卡死
$httpBudget = max(5, $httpBudgetSeconds);
$lastStatus = '';
$lastHeartbeatAt = 0;
while (true) {
$elapsed = time() - $startedAt;
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
if (isset($history[$promptId])) {
$entry = $history[$promptId];
$status = $entry['status'] ?? [];
if (($status['status_str'] ?? '') === 'error' || !empty($status['messages'])) {
foreach (($status['messages'] ?? []) as $msg) {
if (($msg[0] ?? '') === 'execution_error') {
$err = $msg[1]['exception_message'] ?? json_encode($msg[1] ?? [], JSON_UNESCAPED_UNICODE);
throw new \RuntimeException('ComfyUI 执行失败: ' . $err);
}
}
}
$outputs = $entry['outputs'] ?? [];
$images = self::collectImages($outputs);
if (!empty($images)) {
return $images;
}
// 有历史但尚无图片:可能仍在写盘,短暂等待
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
$msgTypes = [];
foreach (($status['messages'] ?? []) as $msg) {
if (is_array($msg) && isset($msg[0])) {
$msgTypes[] = (string) $msg[0];
}
}
$outKeys = array_keys(is_array($outputs) ? $outputs : []);
throw new \RuntimeException(
'任务已完成但未找到输出图片(输出节点: '
. ($outKeys ? implode(',', $outKeys) : '无')
. ';状态: ' . implode(',', $msgTypes)
. ')。请确认工作流为 API Format,且包含 SaveImage'
. 'ZImageTurbo 请勿把 UI Format(含 nodes/links)导入后台'
);
}
}
$queue = self::getJson($baseUrl . '/queue', $apiKey);
$running = $queue['queue_running'] ?? [];
$pending = $queue['queue_pending'] ?? [];
$ahead = self::queueAheadCount($promptId, $running, $pending);
if ($ahead === null) {
if (isset($history[$promptId])) {
$statusText = "正在取回生成结果…(已用时 {$elapsed} 秒)";
} else {
$statusText = "等待 ComfyUI 响应…(已等待 {$elapsed} 秒,可关闭页面稍后回来查看)";
}
} elseif ($ahead === 0) {
$statusText = "正在渲染…(已用时 {$elapsed} 秒,可关闭页面稍后回来查看)";
} else {
$statusText = "排队中(前面还有 {$ahead} 个任务,已等待 {$elapsed} 秒)…可关闭页面,完成后自动显示";
}
// 交还给轮询:消息保持 pending,不占用 PHP 工作进程
if ($elapsed >= $httpBudget) {
if ($onProgress) {
$onProgress("任务已在后台继续生成,关闭或刷新页面后也会自动显示结果…");
}
throw new ComfyJobDeferredException(
'任务已在后台继续生成,关闭或刷新页面后也会自动显示结果…'
);
}
$now = time();
if ($onProgress && ($statusText !== $lastStatus || ($now - $lastHeartbeatAt) >= 5)) {
$onProgress($statusText);
$lastStatus = $statusText;
$lastHeartbeatAt = $now;
}
usleep(800000);
}
}
private static function collectImages(array $outputs): array
{
$images = [];
$walk = function ($nodeOutput) use (&$images, &$walk) {
if (!is_array($nodeOutput)) {
return;
}
if (!empty($nodeOutput['images']) && is_array($nodeOutput['images'])) {
foreach ($nodeOutput['images'] as $img) {
if (is_array($img) && !empty($img['filename'])) {
$images[] = $img;
}
}
}
// 兼容嵌套 outputs(子图等)
foreach ($nodeOutput as $key => $value) {
if ($key === 'images' || !is_array($value)) {
continue;
}
// 仅深入疑似节点输出的关联数组
if (isset($value['images']) || self::isListOfAssoc($value)) {
$walk($value);
} elseif (!array_is_list($value)) {
$walk($value);
}
}
};
foreach ($outputs as $nodeOutput) {
$walk($nodeOutput);
}
return $images;
}
private static function isListOfAssoc(array $value): bool
{
if (!array_is_list($value) || $value === []) {
return false;
}
return isset($value[0]) && is_array($value[0]) && isset($value[0]['filename']);
}
/**
* 返回当前任务前面还有几个任务;正在执行中返回 0;不在队列中返回 null。
*/
private static function queueAheadCount(string $promptId, array $running, array $pending): ?int
{
$promptId = (string) $promptId;
foreach ($running as $item) {
if ((string) ($item[1] ?? '') === $promptId) {
return 0;
}
}
$runningCount = count($running);
foreach (array_values($pending) as $index => $item) {
if ((string) ($item[1] ?? '') === $promptId) {
// 前面 = 正在跑的全部 + 自己在 pending 里的下标
return $runningCount + $index;
}
}
return null;
}
/**
* @return array<int, array{type:string,url:string,name:string,mime:string,size:int}>
*/
private static function downloadAndStore(string $baseUrl, array $images, int $userId, string $apiKey): array
{
$attachments = [];
$uploadPath = rtrim(config('upload.path'), '/\\');
foreach ($images as $index => $img) {
$filename = (string) $img['filename'];
$subfolder = (string) ($img['subfolder'] ?? '');
$type = (string) ($img['type'] ?? 'output');
$query = http_build_query([
'filename' => $filename,
'subfolder' => $subfolder,
'type' => $type,
]);
$binary = self::getBinary($baseUrl . '/view?' . $query, $apiKey);
if ($binary === null || $binary === '') {
continue;
}
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION) ?: 'png');
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)) {
$ext = 'png';
}
$subdir = date('Y/m/d');
$storedBase = 'comfy_' . uniqid('', true) . '.' . $ext;
$storedName = $subdir . '/' . $storedBase;
$fullDir = $uploadPath . 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) ?: ('image/' . ($ext === 'jpg' ? 'jpeg' : $ext));
$size = (int) filesize($fullPath);
$displayName = 'generated_' . ($index + 1) . '.' . $ext;
UploadFile::create([
'user_id' => $userId,
'original_name' => $displayName,
'stored_name' => $storedBase,
'file_path' => $storedName,
'mime_type' => $mime,
'file_size' => $size,
'file_type' => 'image',
]);
$attachments[] = [
'type' => 'image',
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => $displayName,
'mime' => $mime,
'size' => $size,
];
}
return $attachments;
}
private static function getJson(string $url, string $apiKey): array
{
$ch = curl_init($url);
curl_setopt_array($ch, self::curlDefaults($apiKey, [
CURLOPT_HTTPGET => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 3,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, self::authHeaders($apiKey));
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode >= 400) {
return [];
}
$data = json_decode($response, true);
return is_array($data) ? $data : [];
}
private static function getBinary(string $url, string $apiKey): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, self::curlDefaults($apiKey, [
CURLOPT_HTTPGET => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 15,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, self::authHeaders($apiKey));
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
return null;
}
return $response;
}
private static function baseUrl(?string $url): string
{
$url = rtrim((string) $url, '/');
if ($url === '') {
throw new \InvalidArgumentException('未配置 ComfyUI API 地址');
}
return $url;
}
private static function authHeaders(string $apiKey): array
{
if (trim($apiKey) === '') {
return [];
}
return ['Authorization: Bearer ' . $apiKey];
}
private static function curlDefaults(string $apiKey, array $extra): array
{
return $extra + [
CURLOPT_SSL_VERIFYPEER => false,
];
}
}