This commit is contained in:
2026-08-05 15:56:08 +08:00
parent 01729b1e0b
commit 2d9e2376b6
106 changed files with 10007 additions and 253 deletions
+185
View File
@@ -5,6 +5,7 @@ namespace app\controller\api;
use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\Department;
use app\model\InvitationCode;
use app\model\MembershipLevel;
use app\model\Message;
use app\model\Role;
@@ -155,6 +156,7 @@ class Admin extends BaseApi
->leftJoin('roles r', 'u.role_id = r.id')
->leftJoin('departments d', 'u.department_id = d.id')
->field('u.id,u.username,u.email,u.nickname,u.role,u.role_id,u.department_id,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name,r.name as role_name,r.slug as role_slug,d.name as department_name')
->whereRaw('LEFT(u.username, 6) <> ?', ['guest_'])
->order('u.id', 'desc');
AdminScopeService::applyUserScope($query, $this->authUser(), 'u');
@@ -183,6 +185,9 @@ class Admin extends BaseApi
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
}
if (str_starts_with(strtolower($username), 'guest_')) {
return $this->error('guest_ 为系统访客账号保留前缀');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('邮箱格式不正确');
}
@@ -249,6 +254,9 @@ class Admin extends BaseApi
if (!$user) {
return $this->error('用户不存在', 404);
}
if (str_starts_with($user->username, 'guest_')) {
return $this->error('访客请在访客管理中操作', 422);
}
$roleSlug = Role::where('id', $user->role_id)->value('slug');
if ($roleSlug === 'super_admin') {
@@ -322,6 +330,9 @@ class Admin extends BaseApi
if (!$user) {
return $this->error('用户不存在', 404);
}
if (str_starts_with($user->username, 'guest_')) {
return $this->error('访客请在访客管理中操作', 422);
}
User::where('id', $targetId)->update($data);
@@ -332,6 +343,77 @@ class Admin extends BaseApi
return $this->success(null, '更新成功');
}
public function guests()
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['menu:guests']);
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
$status = trim((string) $this->request->get('status', ''));
$keyword = trim((string) $this->request->get('keyword', ''));
$query = User::alias('u')
->whereRaw('LEFT(u.username, 6) = ?', ['guest_'])
->field("u.id,u.username,u.nickname,u.status,u.created_at,u.last_login_at,
(SELECT COUNT(*) FROM conversations c WHERE c.user_id = u.id AND c.deleted_at IS NULL) AS conversation_count,
(SELECT COUNT(*) FROM messages msg INNER JOIN conversations c2 ON msg.conversation_id = c2.id WHERE c2.user_id = u.id) AS message_count")
->order('u.last_login_at', 'desc')
->order('u.id', 'desc');
AdminScopeService::applyUserScope($query, $auth, 'u');
if (in_array($status, ['active', 'disabled'], true)) {
$query->where('u.status', $status);
}
if ($keyword !== '') {
$query->where('u.username', 'like', '%' . $keyword . '%');
}
$total = (clone $query)->count();
$list = $query->page($page, $limit)->select();
return $this->success(['list' => $list, 'total' => $total]);
}
public function updateGuestStatus($id)
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['btn:guest:status']);
$guest = User::find((int) $id);
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
return $this->error('访客不存在', 404);
}
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
return $this->error('无权操作该访客', 403);
}
$status = trim((string) $this->request->put('status', ''));
if (!in_array($status, ['active', 'disabled'], true)) {
return $this->error('访客状态无效', 422);
}
$guest->save(['status' => $status]);
return $this->success(null, $status === 'active' ? '访客已启用' : '访客已禁用');
}
public function deleteGuest($id)
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['btn:guest:delete']);
$guest = User::find((int) $id);
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
return $this->error('访客不存在', 404);
}
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
return $this->error('无权操作该访客', 403);
}
User::destroy((int) $guest->id);
return $this->success(null, '访客记录已删除');
}
public function conversations()
{
AdminScopeService::requireAny($this->authUser(), [
@@ -557,6 +639,109 @@ class Admin extends BaseApi
return $this->success($options);
}
public function invitations()
{
AdminScopeService::requireAny($this->authUser(), [
'menu:invitations',
'btn:invitation:create',
'btn:invitation:revoke',
'can_manage_users',
]);
$status = trim((string) $this->request->get('status', ''));
$query = InvitationCode::alias('i')
->leftJoin('departments d', 'i.department_id = d.id')
->leftJoin('users creator', 'i.created_by = creator.id')
->leftJoin('users used', 'i.used_by = used.id')
->field('i.*,d.name as department_name,creator.username as creator_name,used.username as used_by_name')
->order('i.created_at', 'desc')
->order('i.id', 'desc');
if (in_array($status, ['active', 'used', 'revoked'], true)) {
$query->where('i.status', $status);
}
return $this->success($query->limit(500)->select());
}
public function createInvitation()
{
$user = $this->authUser();
AdminScopeService::requireAny($user, [
'btn:invitation:create',
'menu:invitations',
'can_manage_users',
]);
$input = $this->request->post();
$departmentId = $input['department_id'] ?? null;
if ($departmentId !== null && $departmentId !== '') {
$departmentId = (int) $departmentId;
if ($departmentId <= 0 || !Department::find($departmentId)) {
return $this->error('所选部门不存在', 422);
}
} else {
$departmentId = null;
}
$expiresAt = trim((string) ($input['expires_at'] ?? ''));
if ($expiresAt !== '') {
$expiresAt = str_replace('T', ' ', $expiresAt);
$expiresTimestamp = strtotime($expiresAt);
if (!$expiresTimestamp || $expiresTimestamp <= time()) {
return $this->error('过期时间必须晚于当前时间', 422);
}
$expiresAt = date('Y-m-d H:i:s', $expiresTimestamp);
} else {
$expiresAt = null;
}
do {
$code = 'INV-' . strtoupper(bin2hex(random_bytes(5)));
} while (InvitationCode::where('code', $code)->find());
$invitation = InvitationCode::create([
'code' => $code,
'department_id' => $departmentId,
'created_by' => (int) $user['id'],
'status' => 'active',
'expires_at' => $expiresAt,
]);
return $this->success([
'id' => (int) $invitation->id,
'code' => $invitation->code,
'department_id' => $departmentId,
'department_name' => $departmentId ? Department::where('id', $departmentId)->value('name') : null,
'status' => 'active',
'expires_at' => $expiresAt,
'created_at' => $invitation->created_at,
], '邀请码已生成');
}
public function revokeInvitation($id)
{
AdminScopeService::requireAny($this->authUser(), [
'btn:invitation:revoke',
'menu:invitations',
'can_manage_users',
]);
$invitation = InvitationCode::find((int) $id);
if (!$invitation) {
return $this->error('邀请码不存在', 404);
}
if ($invitation->status === 'used') {
return $this->error('已使用的邀请码不能作废', 422);
}
if ($invitation->status === 'revoked') {
return $this->success(null, '邀请码已作废');
}
$invitation->save(['status' => 'revoked']);
return $this->success(null, '邀请码已作废');
}
public function createDepartment()
{
AdminScopeService::requireAny($this->authUser(), ['btn:dept:create', 'can_manage_departments']);
+62 -9
View File
@@ -3,11 +3,14 @@
namespace app\controller\api;
use app\model\User;
use app\model\InvitationCode;
use app\model\MembershipLevel;
use app\model\Role;
use app\service\JwtService;
use app\service\SettingsService;
use app\service\UserContextService;
use think\exception\HttpResponseException;
use think\facade\Db;
class Auth extends BaseApi
{
@@ -51,6 +54,8 @@ class Auth extends BaseApi
return $this->error('游客访问暂不可用', 403);
}
$user->save(['last_login_at' => date('Y-m-d H:i:s')]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
@@ -70,6 +75,7 @@ class Auth extends BaseApi
$username = trim($input['username'] ?? '');
$email = trim($input['email'] ?? '');
$password = $input['password'] ?? '';
$invitationCode = strtoupper(preg_replace('/\s+/', '', trim((string) ($input['invitation_code'] ?? ''))));
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
@@ -80,21 +86,59 @@ class Auth extends BaseApi
if (strlen($password) < 6) {
return $this->error('密码至少 6 位');
}
if ($invitationCode === '') {
return $this->error('请输入邀请码', 422);
}
if (User::where('username', $username)->whereOr('email', $email)->find()) {
return $this->error('用户名或邮箱已存在');
}
$defaultRoleId = \app\model\Role::where('slug', 'user')->value('id');
$defaultRoleId = Role::where('slug', 'user')->value('id');
$membershipId = MembershipLevel::where('slug', 'free')->value('id') ?: 1;
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'membership_level_id' => 1,
'role_id' => $defaultRoleId ?: null,
]);
$user = Db::transaction(function () use (
$invitationCode,
$username,
$email,
$password,
$defaultRoleId,
$membershipId
) {
$invitation = InvitationCode::where('code', $invitationCode)->lock(true)->find();
if (!$invitation) {
$this->abortRegistration('邀请码不存在', 422);
}
if ($invitation->status !== 'active') {
$this->abortRegistration($invitation->status === 'used' ? '邀请码已被使用' : '邀请码已作废', 422);
}
if ($invitation->expires_at && strtotime((string) $invitation->expires_at) <= time()) {
$this->abortRegistration('邀请码已过期', 422);
}
if (User::where('username', $username)->whereOr('email', $email)->find()) {
$this->abortRegistration('用户名或邮箱已存在', 422);
}
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'role' => 'user',
'role_id' => $defaultRoleId ?: null,
'department_id' => $invitation->department_id ?: null,
'membership_level_id' => $membershipId,
'status' => 'active',
]);
$invitation->save([
'status' => 'used',
'used_by' => $user->id,
'used_at' => date('Y-m-d H:i:s'),
]);
return $user;
});
$token = JwtService::generateToken(['user_id' => $user->id]);
@@ -157,4 +201,13 @@ class Auth extends BaseApi
{
return UserContextService::formatPublicUser($userId);
}
private function abortRegistration(string $message, int $httpCode): never
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+19 -8
View File
@@ -12,6 +12,7 @@ use app\service\ComfyUIService;
use app\service\CosyVoiceService;
use app\service\DifyService;
use app\service\DocumentTextService;
use app\service\GuestAccessService;
use app\service\OpenAIService;
use app\service\PermissionService;
use app\service\SettingsService;
@@ -21,7 +22,8 @@ class Chat extends BaseApi
{
public function speech()
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$input = $this->request->post();
$text = trim((string) ($input['text'] ?? ''));
@@ -71,7 +73,8 @@ class Chat extends BaseApi
public function speechStream(): never
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$input = $this->request->post();
$text = trim((string) ($input['text'] ?? ''));
$requestId = trim((string) ($input['request_id'] ?? ''));
@@ -134,7 +137,8 @@ class Chat extends BaseApi
public function speechCancel()
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$requestId = trim((string) $this->request->post('request_id', ''));
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
return $this->error('语音请求标识无效', 422);
@@ -168,6 +172,7 @@ class Chat extends BaseApi
if (!is_array($attachments)) {
return $this->error('附件格式无效', 422);
}
GuestAccessService::assertTextChatOnly($user, $attachments, $agentId, $imageTool, $voiceMode);
if (!$conversationId) {
return $this->error('缺少 conversation_id');
@@ -178,10 +183,6 @@ class Chat extends BaseApi
if ($imageTool !== '' && !$this->hasImageAttachments($attachments)) {
return $this->error('图片处理工具需要一张原图', 422);
}
if (!empty($user['is_guest']) && $this->hasImageAttachments($attachments)) {
return $this->error('游客模式不支持发送图片,请登录后重试', 403);
}
$agent = AgentCatalog::find($agentId);
if ($agentId !== '' && !$agent) {
return $this->error('所选 Agent 不存在或已停用', 422);
@@ -197,7 +198,17 @@ class Chat extends BaseApi
}
$imageGenerationContent = $content;
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
if (GuestAccessService::isGuest($user)) {
$model = GuestAccessService::model();
if ((int) $conversation->model_id !== (int) $model->id) {
$conversation->save([
'model_id' => (int) $model->id,
'external_conversation_id' => null,
]);
}
} else {
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
}
if ($imageTool !== '' && $imageTool !== 'commit') {
$preferredImageModelId = ($model->provider ?? '') === 'comfy' ? (int) ($model->id ?? 0) : null;
$model = OpenAIService::getImageModel($preferredImageModelId ?: null);
@@ -6,6 +6,7 @@ use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\Message;
use app\service\ComfyUIService;
use app\service\GuestAccessService;
use app\service\PermissionService;
use think\exception\HttpResponseException;
@@ -14,6 +15,12 @@ class Conversation extends BaseApi
public function index()
{
$user = $this->authUser();
if (GuestAccessService::isGuest($user)) {
$guestModelId = GuestAccessService::modelId();
$reset = ['model_id' => $guestModelId, 'external_conversation_id' => null];
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->whereNull('model_id')->update($reset);
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->where('model_id', '<>', $guestModelId)->update($reset);
}
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
@@ -51,6 +58,12 @@ class Conversation extends BaseApi
$modelId = null;
}
if (GuestAccessService::isGuest($user)) {
$modelId = GuestAccessService::assertModelAllowed($user, $modelId);
} elseif ($modelId && !AiModel::where('id', $modelId)->where('enabled', 1)->find()) {
return $this->error('所选模型不存在或已停用', 422);
}
$conversation = ConversationModel::create([
'user_id' => $user['id'],
'title' => trim($input['title'] ?? '新对话'),
@@ -94,6 +107,13 @@ class Conversation extends BaseApi
$data['model_id'] = (int) $mid;
}
if (GuestAccessService::isGuest($user)) {
$requestedModelId = $data['model_id'] ?: null;
$data['model_id'] = GuestAccessService::assertModelAllowed($user, $requestedModelId);
} elseif ($data['model_id'] && !AiModel::where('id', $data['model_id'])->where('enabled', 1)->find()) {
return $this->error('所选模型不存在或已停用', 422);
}
$currentModelId = $conversation->model_id === null
? null
: (int) $conversation->model_id;
@@ -103,6 +123,14 @@ class Conversation extends BaseApi
}
}
if (GuestAccessService::isGuest($user) && !array_key_exists('model_id', $input)) {
$guestModelId = GuestAccessService::modelId();
if ((int) $conversation->model_id !== $guestModelId) {
$data['model_id'] = $guestModelId;
$data['external_conversation_id'] = null;
}
}
if (empty($data)) {
return $this->error('无更新内容');
}
+20
View File
@@ -5,6 +5,7 @@ namespace app\controller\api;
use app\model\AiModel;
use app\service\AgentCatalog;
use app\service\CosyVoiceService;
use app\service\GuestAccessService;
use app\service\SettingsService;
class Settings extends BaseApi
@@ -20,6 +21,7 @@ class Settings extends BaseApi
return $this->success([
'site_name' => SettingsService::get('site_name', 'AI Chat'),
'allow_register' => $allow === true || $allow === 'true',
'registration_requires_invite' => true,
'features' => SettingsService::getFeatures(),
'voice_persona' => CosyVoiceService::publicPersona(),
]);
@@ -27,6 +29,20 @@ class Settings extends BaseApi
public function models()
{
$user = $this->authUser();
if (GuestAccessService::isGuest($user)) {
$model = GuestAccessService::model();
return $this->success([[
'id' => (int) $model->id,
'name' => $model->name,
'provider' => $model->provider,
'model_id' => $model->model_id,
'is_default' => 1,
'support_context' => (int) $model->support_context,
'support_image' => 0,
]]);
}
$list = AiModel::where('enabled', 1)
->field('id,name,provider,model_id,is_default,support_context,support_image')
->order('sort_order,id')
@@ -37,6 +53,10 @@ class Settings extends BaseApi
public function agents()
{
if (GuestAccessService::isGuest($this->authUser())) {
return $this->success([]);
}
return $this->success(AgentCatalog::publicList());
}
}
File diff suppressed because it is too large Load Diff
+39 -5
View File
@@ -118,12 +118,46 @@ class Upload extends BaseApi
}
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Content-Length' => (string) filesize($path),
$size = (int) filesize($path);
$headers = [
'Content-Type' => $mime,
'Content-Length' => (string) $size,
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'public, max-age=604800',
]);
];
$range = trim((string) $this->request->header('range', ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
$start = $matches[1] === '' ? 0 : (int) $matches[1];
$end = $matches[2] === '' ? $size - 1 : (int) $matches[2];
if ($matches[1] === '' && $matches[2] !== '') {
$length = min($size, (int) $matches[2]);
$start = $size - $length;
$end = $size - 1;
}
if ($start < 0 || $start >= $size || $end < $start) {
return response('', 416, [
'Content-Range' => 'bytes */' . $size,
'Accept-Ranges' => 'bytes',
]);
}
$end = min($end, $size - 1);
$length = $end - $start + 1;
$handle = fopen($path, 'rb');
if ($handle === false || fseek($handle, $start) !== 0) {
if (is_resource($handle)) {
fclose($handle);
}
throw new HttpResponseException(response('文件读取失败', 500));
}
$content = (string) fread($handle, $length);
fclose($handle);
$headers['Content-Length'] = (string) strlen($content);
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
return response($content, 206, $headers);
}
return response(file_get_contents($path), 200, $headers);
}
/**
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class InvitationCode extends Model
{
protected $name = 'invitation_codes';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class VideoCharacter extends Model
{
protected $name = 'video_characters';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'is_locked' => 'boolean',
];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class VideoEpisode extends Model
{
protected $name = 'video_episodes';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class VideoProject extends Model
{
protected $name = 'video_projects';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class VideoShot extends Model
{
protected $name = 'video_shots';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'meta' => 'json',
];
}
@@ -0,0 +1,76 @@
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
class GuestAccessService
{
public const MODEL_NAME = 'qwen3.6';
public static function isGuest(array $user): bool
{
return !empty($user['is_guest']);
}
public static function model(): AiModel
{
$models = AiModel::where('enabled', 1)->order('sort_order')->order('id')->select();
foreach ($models as $model) {
$identity = strtolower(trim((string) ($model->model_id ?: $model->name)));
$name = strtolower(trim((string) $model->name));
if (str_starts_with($identity, self::MODEL_NAME) || str_starts_with($name, self::MODEL_NAME)) {
return $model;
}
}
self::abort('游客专用模型 qwen3.6 尚未启用,请联系管理员', 503);
}
public static function modelId(): int
{
return (int) self::model()->id;
}
public static function assertModelAllowed(array $user, ?int $modelId): int
{
if (!self::isGuest($user)) {
return $modelId ?: 0;
}
$guestModelId = self::modelId();
if ($modelId && $modelId !== $guestModelId) {
self::abort('游客仅可使用 qwen3.6 模型,登录后可使用全部模型', 403);
}
return $guestModelId;
}
public static function assertTextChatOnly(array $user, array $attachments, string $agentId, string $imageTool, bool $voiceMode): void
{
if (!self::isGuest($user)) {
return;
}
if ($attachments || $agentId !== '' || $imageTool !== '' || $voiceMode) {
self::abort('游客仅支持 qwen3.6 文本对话,登录后可使用全部模型和工具', 403);
}
}
public static function assertAccountRequired(array $user, string $feature): void
{
if (self::isGuest($user)) {
self::abort("游客不能使用{$feature},请先登录", 403);
}
}
private static function abort(string $message, int $httpCode): never
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+823
View File
@@ -0,0 +1,823 @@
<?php
namespace app\service;
use app\model\AiModel;
use app\model\UploadFile;
use app\model\VideoProject;
use app\model\VideoShot;
/**
* MiniMax H3 原生 ComfyUI API 接入。
*
* 只使用 ComfyUI core 节点,提供无参考的 FL2VA 和带角色图的 REF2VA 两条工作流。
*/
class MiniMaxH3Service
{
public const WORKFLOW_VERSION = 'minimax-h3-joint-av-v6';
private const FL2VA_MODEL = 'minimax_h3_fl2va_pruned_int8_convrot.safetensors';
private const REF2VA_MODEL = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors';
private const TEXT_ENCODER = 'qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors';
private const VIDEO_VAE = 'minimax_h3_video_vae_fp16.safetensors';
private const AUDIO_VAE = 'minimax_h3_audio_vae_fp32.safetensors';
public static function workflowManifest(): array
{
return [
'version' => self::WORKFLOW_VERSION,
'fps' => 24,
'supported_shot_durations' => [5, 10],
'frames_by_duration' => ['5' => 124, '10' => 243],
'trained_frame_range' => [124, 362],
'fl2va_model' => self::FL2VA_MODEL,
'ref2va_model' => self::REF2VA_MODEL,
'text_encoder' => self::TEXT_ENCODER,
'video_vae' => self::VIDEO_VAE,
'audio_vae' => self::AUDIO_VAE,
'text_render_policy' => 'clean_surface_plus_exact_ass_postprocess',
'synced_project_settings' => [
'aspect_ratio',
'quality',
'voice_language',
'show_subtitles',
'character_origin',
'screen_text_language',
'shot_duration_mode',
],
'nodes' => [
'UNETLoader',
'MiniMaxH3SigmaShift',
'CLIPLoader',
'VAELoader',
'MiniMaxH3ImageToVideo / MiniMaxH3ReferenceToVideo',
'ConditioningZeroOut',
'KSampler',
'LTXVSeparateAVLatent',
'VAEDecode',
'VAEDecodeAudio',
'CreateVideo',
'SaveVideo',
],
];
}
/** @return array<int,AiModel> 每个 ComfyUI 地址只保留一个工作节点。 */
public static function workers(): array
{
$models = AiModel::where('provider', 'comfy')
->where('enabled', 1)
->order('is_default', 'desc')
->order('sort_order')
->order('id')
->select();
$workers = [];
foreach ($models as $model) {
$endpoint = strtolower(self::baseUrl((string) $model->api_base_url));
if (!isset($workers[$endpoint])) {
$workers[$endpoint] = $model;
}
}
if (!$workers) {
throw new \RuntimeException('管理端尚未启用 ComfyUI 模型');
}
return array_values($workers);
}
public static function workerSummary(): array
{
try {
$workers = self::workers();
$count = count($workers);
return [
'configured_workers' => $count,
'effective_concurrency' => min(3, $count),
'mode' => $count > 1 ? 'multi_endpoint_parallel' : 'single_endpoint_serial',
'message' => $count > 1
? "已配置 {$count} 个独立 ComfyUI 地址,最多并发 3 个镜头"
: '当前只有 1 个 ComfyUI 地址;同地址任务按队列串行执行',
];
} catch (\Throwable $error) {
return [
'configured_workers' => 0,
'effective_concurrency' => 0,
'mode' => 'unavailable',
'message' => $error->getMessage(),
];
}
}
public static function model(?int $workerId = null): AiModel
{
if ($workerId !== null && $workerId > 0) {
$model = AiModel::where('provider', 'comfy')->where('id', $workerId)->find();
if ($model) {
return $model;
}
}
return self::workers()[0];
}
/**
* @param array<int,array<string,mixed>> $characters
* @return array{prompt_id:string,workflow_type:string,reference_count:int,continuity_applied:bool,audio_mode:string,workflow_version:string,worker_id:int,worker_name:string,applied_settings:array<string,mixed>}
*/
public static function submitShot(
VideoShot $shot,
VideoProject $project,
array $characters,
?array $preparedReferenceFiles = null,
?int $continuityUploadId = null,
?AiModel $worker = null
): array
{
$model = $worker ?? self::model();
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$referenceFiles = $preparedReferenceFiles
?? self::uploadReferenceFiles($project, $characters, $baseUrl, $apiKey);
$continuityFile = $continuityUploadId
? self::uploadContinuityFrame($project, $continuityUploadId, $baseUrl, $apiKey)
: null;
$characterReferenceCount = count($referenceFiles);
$language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN'));
$shotMeta = is_array($shot->meta) ? $shot->meta : [];
$timeline = is_array($shotMeta['timeline'] ?? null) ? $shotMeta['timeline'] : [];
$audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only'));
$workflowPrompt = self::audioDirective($language, $audioMode, $timeline)
. self::screenTextDirective(
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN),
$timeline
)
. (string) $shot->prompt;
$firstFrameFile = null;
if ($continuityFile !== null && $referenceFiles) {
// REF2VA 最多支持 9 张图,给真实连续帧固定保留最后一个槽位。
$referenceFiles = array_slice($referenceFiles, 0, 8);
$continuityPictureNo = count($referenceFiles) + 1;
$referenceFiles[] = $continuityFile;
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
$workflowPrompt .= "。<Picture {$continuityPictureNo}> 是上一镜头的真实结束帧;本镜头第一帧必须复现其人物位置、脸部、服装、动作相位、构图、背景、光向和色温,再从该动作自然继续,禁止重新起势或跳切。";
$workflowType = 'ref2va-continuity';
} elseif ($continuityFile !== null) {
$firstFrameFile = $continuityFile;
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
$workflowPrompt .= '。输入首帧是上一镜头的真实结束帧;必须从这张画面无缝继续人物动作、视线和摄影机运动,禁止改变脸、服装、背景、光线或重新起势。';
$workflowType = 'i2v-continuity';
} else {
$workflowType = $referenceFiles ? 'ref2va' : 'fl2va';
}
[$width, $height, $steps] = self::generationPreset(
(string) $project->aspect_ratio,
(string) $project->quality
);
$shotDuration = in_array((int) $shot->duration_seconds, [5, 10], true)
? (int) $shot->duration_seconds
: 5;
$frameLength = self::frameLengthForDuration($shotDuration);
$workflow = self::buildWorkflow([
'workflow_type' => $workflowType,
'prompt' => $workflowPrompt,
'width' => $width,
'height' => $height,
'length' => $frameLength,
'steps' => $steps,
'seed' => (int) ($shot->seed ?: random_int(1, PHP_INT_MAX)),
'reference_files' => $referenceFiles,
'first_frame_file' => $firstFrameFile,
'ref_image_size' => ($shotMeta['identity_boost'] ?? false) ? 'max' : 'match',
]);
return [
'prompt_id' => self::queuePrompt($baseUrl, $workflow, $apiKey),
'workflow_type' => $workflowType,
'reference_count' => $characterReferenceCount,
'continuity_applied' => $continuityFile !== null,
'audio_mode' => $audioMode,
'workflow_version' => self::WORKFLOW_VERSION,
'worker_id' => (int) $model->id,
'worker_name' => (string) $model->name,
'applied_settings' => [
'aspect_ratio' => (string) $project->aspect_ratio,
'quality' => (string) $project->quality,
'voice_language' => $language,
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin(
(string) ($project->character_origin ?? '')
),
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage(
(string) ($project->screen_text_language ?? '')
),
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode(
(string) ($project->shot_duration_mode ?? '')
),
'width' => $width,
'height' => $height,
'steps' => $steps,
'shot_duration_seconds' => $shotDuration,
'frame_length' => $frameLength,
],
];
}
private static function screenTextDirective(string $language, array $timeline): string
{
$language = ShortDramaPlannerService::normalizeScreenTextLanguage($language);
$screenText = trim((string) ($timeline['screen_text'] ?? ''));
if ($language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE || $screenText === '') {
return 'SCENE TEXT POLICY: render no readable glyphs. ';
}
$label = $language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US
? 'English'
: 'Simplified Chinese';
return "SCENE TEXT POLICY: reserve a clean, stable, unobstructed surface for {$label} scene text, but draw no glyphs inside H3. Exact post-render text is: “{$screenText}”. The compositor will burn it in; do not invent pseudo-letters or symbols. ";
}
private static function audioDirective(string $language, string $audioMode, array $timeline): string
{
$noText = 'ABSOLUTELY NO visible text, subtitles, captions, speech bubbles, typography, letters, numbers, logos, watermarks or interface. ';
if ($language === VideoDubService::LANG_NONE || $audioMode === VideoDubService::AUDIO_AMBIENT) {
return 'MINIMAX H3 JOINT AUDIO-VIDEO SHOT. Generate continuous synchronized scene ambience and physical action sounds only. '
. 'NO dialogue, narration, singing, yelling, mumbling, pseudo-language or any human voice. Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
. $noText;
}
if ($audioMode === VideoDubService::AUDIO_SCENE) {
$soundEffects = is_array($timeline['sound_effects'] ?? null)
? array_values(array_filter(array_map('trim', $timeline['sound_effects'])))
: [];
$soundRule = $soundEffects
? 'Generate these exact synchronized diegetic sounds: ' . implode('; ', $soundEffects) . '. '
: 'Generate only synchronized diegetic ambience and physical action sounds visible in the shot. ';
return 'MINIMAX H3 NATIVE JOINT AUDIO-VIDEO SCENE-SOUND SHOT. ' . $soundRule
. 'These are environmental/action sounds, never spoken words. NO dialogue, narration, off-screen voice, singing, yelling, crying speech, mumbling, gibberish or pseudo-language. '
. 'Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
. $noText;
}
$speaker = trim((string) ($timeline['dialogue_speaker'] ?? '主角')) ?: '主角';
$dialogue = trim((string) ($timeline['dialogue'] ?? ''));
$delivery = trim((string) ($timeline['dialogue_delivery'] ?? '自然')) ?: '自然';
if ($audioMode === VideoDubService::AUDIO_NARRATION) {
return 'MINIMAX H3 VISUAL SHOT FOR POST-DUBBED NARRATION. Do not generate the narration or any other human voice; the raw H3 soundtrack will be discarded. '
. 'All visible people keep their mouths naturally closed and never lip-sync, yell or perform speaking motion. '
. 'NO visible character speech, NO off-screen speech, NO singing, NO gibberish and NO pseudo-language. '
. $noText;
}
$gender = trim((string) ($timeline['speaker_gender'] ?? 'auto'));
$genderRule = $gender === 'auto'
? 'The speaking voice must match the visible speakers actual sex, apparent age and identity.'
: "Use a {$gender} voice matching the visible speakers age and identity.";
return "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible {$speaker}, and nobody else, speaks exact standard Mandarin Chinese: “{$dialogue}”. "
. "Delivery: {$delivery}. {$genderRule} The speaker starts with a closed mouth, opens the mouth only for this exact line with frame-accurate natural lip synchronization, then closes the mouth. "
. 'NO narrator, NO off-screen voice, NO second speaker, NO voice/sex mismatch, NO extra words, NO repeated words, NO gibberish and NO pseudo-language. Keep synchronized scene ambience and action sounds. '
. $noText;
}
/**
* 每次整集提交只上传一次角色图,所有镜头复用同一个 ComfyUI input 文件。
*
* @param array<int,array<string,mixed>> $characters
* @return string[]
*/
public static function prepareReferenceFiles(
VideoProject $project,
array $characters,
?AiModel $worker = null
): array
{
$model = $worker ?? self::model();
return self::uploadReferenceFiles(
$project,
$characters,
self::baseUrl($model->api_base_url),
(string) ($model->api_key ?? '')
);
}
/** @return string[] */
private static function uploadReferenceFiles(
VideoProject $project,
array $characters,
string $baseUrl,
string $apiKey
): array {
$referenceFiles = [];
foreach (array_slice($characters, 0, 9) as $index => $character) {
$uploadId = (int) ($character['reference_upload_id'] ?? 0);
if ($uploadId <= 0) {
continue;
}
$upload = UploadFile::where('id', $uploadId)
->where('user_id', (int) $project->user_id)
->where('file_type', 'image')
->find();
if (!$upload) {
continue;
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
continue;
}
$referenceFiles[] = self::uploadInputImage(
$baseUrl,
$path,
$apiKey,
'character_' . ((int) $index + 1)
);
}
return $referenceFiles;
}
private static function uploadContinuityFrame(
VideoProject $project,
int $uploadId,
string $baseUrl,
string $apiKey
): ?string {
$upload = UploadFile::where('id', $uploadId)
->where('user_id', (int) $project->user_id)
->where('file_type', 'image')
->find();
if (!$upload) {
return null;
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
return null;
}
return self::uploadInputImage($baseUrl, $path, $apiKey, 'continuity');
}
/**
* @return array{state:string,message:string,files:array,error:?string}
*/
public static function inspect(string $promptId, ?int $workerId = null): array
{
$model = self::model($workerId);
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
if (isset($history[$promptId])) {
$entry = $history[$promptId];
$status = is_array($entry['status'] ?? null) ? $entry['status'] : [];
foreach (($status['messages'] ?? []) as $message) {
if (($message[0] ?? '') !== 'execution_error') {
continue;
}
$detail = $message[1]['exception_message']
?? json_encode($message[1] ?? [], JSON_UNESCAPED_UNICODE);
return [
'state' => 'error',
'message' => '视频生成失败',
'files' => [],
'error' => (string) $detail,
];
}
$files = self::collectVideoFiles($entry['outputs'] ?? []);
if ($files) {
return [
'state' => 'done',
'message' => '视频镜头生成完成',
'files' => $files,
'error' => null,
];
}
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
return [
'state' => 'error',
'message' => '任务完成但没有找到 MP4 输出',
'files' => [],
'error' => 'SaveVideo 未返回可下载文件',
];
}
}
$queue = self::getJson($baseUrl . '/queue', $apiKey);
foreach (($queue['queue_running'] ?? []) as $item) {
if ((string) ($item[1] ?? '') === $promptId) {
return ['state' => 'running', 'message' => '正在渲染镜头', 'files' => [], 'error' => null];
}
}
foreach (array_values($queue['queue_pending'] ?? []) as $index => $item) {
if ((string) ($item[1] ?? '') === $promptId) {
return [
'state' => 'queued',
'message' => $index > 0 ? "排队中,前面还有 {$index} 个任务" : '即将开始渲染',
'files' => [],
'error' => null,
];
}
}
return [
'state' => 'missing',
'message' => 'ComfyUI 队列中未找到任务,正在确认是否需要自动重试',
'files' => [],
'error' => null,
];
}
/**
* @return array{id:int,url:string,name:string,mime:string,size:int,path:string}
*/
public static function storeVideo(array $file, int $userId, ?int $workerId = null): array
{
$model = self::model($workerId);
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$filename = basename((string) ($file['filename'] ?? ''));
if ($filename === '') {
throw new \RuntimeException('ComfyUI 视频文件名为空');
}
$query = http_build_query([
'filename' => $filename,
'subfolder' => (string) ($file['subfolder'] ?? ''),
'type' => (string) ($file['type'] ?? 'output'),
]);
$binary = self::getBinary($baseUrl . '/view?' . $query, $apiKey);
if ($binary === null || $binary === '') {
throw new \RuntimeException('下载 ComfyUI 视频失败');
}
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($extension, ['mp4', 'webm', 'mov'], true)) {
$extension = 'mp4';
}
$subdir = date('Y/m/d');
$storedBase = 'h3_' . uniqid('', true) . '.' . $extension;
$relativePath = $subdir . '/' . $storedBase;
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
throw new \RuntimeException('无法创建视频存储目录');
}
$fullPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
if (file_put_contents($fullPath, $binary) === false) {
throw new \RuntimeException('保存生成视频失败');
}
$mime = @mime_content_type($fullPath) ?: ($extension === 'webm' ? 'video/webm' : 'video/mp4');
$size = (int) filesize($fullPath);
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_shot.' . $extension,
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => $mime,
'file_size' => $size,
'file_type' => 'video',
]);
return [
'id' => (int) $upload->id,
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => (string) $upload->original_name,
'mime' => $mime,
'size' => $size,
'path' => $fullPath,
];
}
/**
* @param array{workflow_type:string,prompt:string,width:int,height:int,length:int,steps:int,seed:int,reference_files:array,first_frame_file:?string,ref_image_size:string} $options
*/
public static function buildWorkflow(array $options): array
{
$isReference = str_starts_with($options['workflow_type'], 'ref2va')
&& !empty($options['reference_files']);
$workflow = [
'1' => [
'_meta' => ['title' => 'MiniMax H3 FL2VA / REF2VA 模型'],
'class_type' => 'UNETLoader',
'inputs' => [
'unet_name' => $isReference ? self::REF2VA_MODEL : self::FL2VA_MODEL,
'weight_dtype' => 'default',
],
],
'2' => [
'_meta' => ['title' => 'H3 视频/音频联合采样时间表'],
'class_type' => 'MiniMaxH3SigmaShift',
'inputs' => ['model' => ['1', 0], 'shift_video' => 12.0, 'shift_audio' => 3.0],
],
'3' => [
'_meta' => ['title' => 'Qwen3-VL H3 文本编码器'],
'class_type' => 'CLIPLoader',
'inputs' => ['clip_name' => self::TEXT_ENCODER, 'type' => 'minimax', 'device' => 'default'],
],
'4' => [
'_meta' => ['title' => 'H3 视频 VAE'],
'class_type' => 'VAELoader',
'inputs' => ['vae_name' => self::VIDEO_VAE],
],
'5' => [
'_meta' => ['title' => 'H3 音频 VAE'],
'class_type' => 'VAELoader',
'inputs' => ['vae_name' => self::AUDIO_VAE],
],
];
if ($isReference) {
$conditionInputs = [
'clip' => ['3', 0],
'vae' => ['4', 0],
'audio_vae' => ['5', 0],
'prompt' => (string) $options['prompt'],
'width' => (int) $options['width'],
'height' => (int) $options['height'],
'length' => (int) $options['length'],
'ref_image_size' => $options['ref_image_size'] === 'max' ? 'max' : 'match',
];
foreach (array_values($options['reference_files']) as $index => $filename) {
$nodeId = (string) (20 + $index);
$workflow[$nodeId] = [
'_meta' => ['title' => '角色/连续性参考图 ' . ($index + 1)],
'class_type' => 'LoadImage',
'inputs' => ['image' => (string) $filename],
];
// V3 Autogrow inputs use dotted API keys: <group>.<generated input>.
// The visible character numbering remains one-based in prompts (<Picture 1>),
// while TemplatePrefix itself is zero-based (ref_image_0, ref_image_1, ...).
$conditionInputs['ref_images.ref_image_' . $index] = [$nodeId, 0];
}
$workflow['6'] = [
'_meta' => ['title' => 'H3 REF2VA 联合音画条件'],
'class_type' => 'MiniMaxH3ReferenceToVideo',
'inputs' => $conditionInputs,
];
} else {
$imageToVideoInputs = [
'clip' => ['3', 0],
'vae' => ['4', 0],
'prompt' => (string) $options['prompt'],
'width' => (int) $options['width'],
'height' => (int) $options['height'],
'length' => (int) $options['length'],
];
if (!empty($options['first_frame_file'])) {
$workflow['20'] = [
'_meta' => ['title' => '上一镜头真实尾帧'],
'class_type' => 'LoadImage',
'inputs' => ['image' => (string) $options['first_frame_file']],
];
$imageToVideoInputs['first_frame'] = ['20', 0];
}
$workflow['6'] = [
'_meta' => ['title' => 'H3 FL2VA / 首帧续拍联合音画条件'],
'class_type' => 'MiniMaxH3ImageToVideo',
'inputs' => $imageToVideoInputs,
];
}
$workflow += [
'7' => [
'_meta' => ['title' => '零负向条件'],
'class_type' => 'ConditioningZeroOut',
'inputs' => ['conditioning' => ['6', 0]],
],
'8' => [
'_meta' => ['title' => 'H3 联合视频+音频采样器'],
'class_type' => 'KSampler',
'inputs' => [
'model' => ['2', 0],
'seed' => (int) $options['seed'],
'steps' => (int) $options['steps'],
'cfg' => 1.0,
'sampler_name' => 'euler',
'scheduler' => 'simple',
'positive' => ['6', 0],
'negative' => ['7', 0],
'latent_image' => ['6', 1],
'denoise' => 1.0,
],
],
'9' => [
'_meta' => ['title' => '拆分联合视频/音频潜变量'],
'class_type' => 'LTXVSeparateAVLatent',
'inputs' => ['av_latent' => ['8', 0]],
],
'10' => [
'_meta' => ['title' => '解码视频画面'],
'class_type' => 'VAEDecode',
'inputs' => ['samples' => ['9', 0], 'vae' => ['4', 0]],
],
'11' => [
'_meta' => ['title' => '解码 H3 原生同步音轨'],
'class_type' => 'VAEDecodeAudio',
'inputs' => ['samples' => ['9', 1], 'vae' => ['5', 0]],
],
'12' => [
'_meta' => ['title' => '24fps 联合音画封装'],
'class_type' => 'CreateVideo',
'inputs' => ['images' => ['10', 0], 'fps' => 24.0, 'audio' => ['11', 0], 'bit_depth' => 8],
],
'13' => [
'_meta' => ['title' => '保存 H3 联合音画 MP4'],
'class_type' => 'SaveVideo',
'inputs' => [
'video' => ['12', 0],
'filename_prefix' => 'short_drama/H3_AV_V2_',
'format' => 'mp4',
'codec' => 'auto',
],
],
];
return $workflow;
}
/** @return array{int,int,int} */
private static function generationPreset(string $aspectRatio, string $quality): array
{
$portrait = $aspectRatio !== '16:9';
if ($quality === 'high') {
return $portrait ? [768, 1344, 16] : [1344, 768, 16];
}
if ($quality === 'standard') {
return $portrait ? [576, 1024, 12] : [1024, 576, 12];
}
return $portrait ? [512, 896, 8] : [896, 512, 8];
}
private static function frameLengthForDuration(int $durationSeconds): int
{
// H3 使用 17k+5 帧网格:124 帧约 5.17 秒,243 帧约 10.13 秒。
return $durationSeconds >= 10 ? 243 : 124;
}
private static function uploadInputImage(
string $baseUrl,
string $path,
string $apiKey,
string $purpose
): string {
$imageInfo = @getimagesize($path);
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
throw new \RuntimeException('角色参考图不是有效图片');
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp'], true)) {
$extension = $imageInfo['mime'] === 'image/jpeg' ? 'jpg' : 'png';
}
$subfolder = 'short_drama/' . date('Ymd');
$uploadName = $purpose . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
$ch = curl_init($baseUrl . '/upload/image');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'image' => new \CURLFile($path, (string) $imageInfo['mime'], $uploadName),
'type' => 'input',
'subfolder' => $subfolder,
'overwrite' => 'true',
],
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException('上传角色参考图到 ComfyUI 失败: ' . ($error ?: 'HTTP ' . $httpCode));
}
$data = json_decode((string) $response, true);
$name = trim((string) ($data['name'] ?? $uploadName));
$storedSubfolder = trim((string) ($data['subfolder'] ?? $subfolder), '/\\');
return $storedSubfolder === '' ? $name : $storedSubfolder . '/' . $name;
}
private static function queuePrompt(string $baseUrl, array $workflow, string $apiKey): string
{
$prompt = new \stdClass();
foreach ($workflow as $id => $node) {
$prompt->{(string) $id} = $node;
}
$body = json_encode([
'prompt' => $prompt,
'client_id' => 'short-drama-' . bin2hex(random_bytes(6)),
], JSON_UNESCAPED_UNICODE);
if ($body === false) {
throw new \RuntimeException('H3 工作流编码失败');
}
$ch = curl_init($baseUrl . '/prompt');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], self::authHeaders($apiKey)),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$data = json_decode((string) $response, true);
if ($response === false || $httpCode !== 200 || !empty($data['node_errors'])) {
$detail = $data['error']['message'] ?? $data['error'] ?? ($error ?: 'HTTP ' . $httpCode);
if (is_array($detail)) {
$detail = json_encode($detail, JSON_UNESCAPED_UNICODE);
}
if (!empty($data['node_errors'])) {
$detail .= '' . json_encode($data['node_errors'], JSON_UNESCAPED_UNICODE);
}
throw new \RuntimeException('ComfyUI 拒绝 H3 工作流: ' . $detail);
}
$promptId = trim((string) ($data['prompt_id'] ?? ''));
if ($promptId === '') {
throw new \RuntimeException('ComfyUI 未返回视频任务 ID');
}
return $promptId;
}
private static function collectVideoFiles(array $outputs): array
{
$files = [];
$walk = function (mixed $value) use (&$files, &$walk): void {
if (!is_array($value)) {
return;
}
if (isset($value['filename'])) {
$extension = strtolower(pathinfo((string) $value['filename'], PATHINFO_EXTENSION));
if (in_array($extension, ['mp4', 'webm', 'mov'], true)) {
$files[] = $value;
}
}
foreach ($value as $child) {
if (is_array($child)) {
$walk($child);
}
}
};
$walk($outputs);
$unique = [];
foreach ($files as $file) {
$key = ($file['type'] ?? 'output') . '|' . ($file['subfolder'] ?? '') . '|' . $file['filename'];
$unique[$key] = $file;
}
return array_values($unique);
}
private static function getJson(string $url, string $apiKey): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode >= 400) {
return [];
}
$data = json_decode((string) $response, true);
return is_array($data) ? $data : [];
}
private static function getBinary(string $url, string $apiKey): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $response !== false && $httpCode === 200 ? $response : null;
}
private static function baseUrl(?string $url): string
{
$url = rtrim((string) $url, '/');
if ($url === '') {
throw new \InvalidArgumentException('未配置 ComfyUI 地址');
}
return $url;
}
private static function authHeaders(string $apiKey): array
{
return trim($apiKey) === '' ? [] : ['Authorization: Bearer ' . $apiKey];
}
}
+22
View File
@@ -47,6 +47,17 @@ class PermissionCatalog
['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'],
],
],
[
'code' => 'menu:guests',
'name' => '访客管理',
'type' => 'menu',
'path' => '/guests',
'icon' => 'visitors',
'children' => [
['code' => 'btn:guest:status', 'name' => '启用/禁用访客', 'type' => 'btn'],
['code' => 'btn:guest:delete', 'name' => '删除访客', 'type' => 'btn'],
],
],
[
'code' => 'menu:departments',
'name' => '部门管理',
@@ -59,6 +70,17 @@ class PermissionCatalog
['code' => 'btn:dept:delete', 'name' => '删除部门', 'type' => 'btn'],
],
],
[
'code' => 'menu:invitations',
'name' => '邀请码管理',
'type' => 'menu',
'path' => '/invitations',
'icon' => 'ticket',
'children' => [
['code' => 'btn:invitation:create', 'name' => '生成邀请码', 'type' => 'btn'],
['code' => 'btn:invitation:revoke', 'name' => '作废邀请码', 'type' => 'btn'],
],
],
[
'code' => 'menu:roles',
'name' => '角色管理',
+1 -1
View File
@@ -51,7 +51,7 @@ class PermissionService
public static function canUpload(array $user, string $type): bool
{
if (!empty($user['is_guest']) && $type === 'image') {
if (!empty($user['is_guest'])) {
return false;
}
+16 -12
View File
@@ -6,6 +6,20 @@ use app\model\SystemSetting;
class SettingsService
{
private const DEFAULT_FEATURES = [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
'short_drama' => true,
];
public static function get(string $key, mixed $default = null): mixed
{
$row = SystemSetting::where('setting_key', $key)->find();
@@ -36,18 +50,8 @@ class SettingsService
public static function getFeatures(): array
{
return self::get('features', [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
]);
$stored = self::get('features', []);
return array_replace(self::DEFAULT_FEATURES, is_array($stored) ? $stored : []);
}
public static function isFeatureEnabled(string $feature): bool
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
<?php
namespace app\service;
use app\model\UploadFile;
/**
* 短剧确定性配音:不再依赖 H3 原生音频猜测语言。
*/
class VideoDubService
{
public const LANG_MANDARIN = 'zh-CN';
public const LANG_NONE = 'none';
public const LANG_NATIVE = 'native';
public const AUDIO_CHARACTER = 'character_dialogue';
public const AUDIO_NARRATION = 'narration';
public const AUDIO_SCENE = 'scene_sound';
public const AUDIO_AMBIENT = 'ambient_only';
public const AUDIO_POST_TTS = 'post_tts_narration';
public const POLICY_VERSION = 'h3-joint-av-v5';
public static function normalizeLanguage(?string $language): string
{
return in_array($language, [self::LANG_MANDARIN, self::LANG_NONE, self::LANG_NATIVE], true)
? (string) $language
: self::LANG_MANDARIN;
}
public static function label(string $language): string
{
return match (self::normalizeLanguage($language)) {
self::LANG_NONE => '无配音',
self::LANG_NATIVE => 'H3 原生音轨',
default => '普通话(H3 原生口型同步)',
};
}
public static function normalizeAudioMode(?string $mode): string
{
return in_array($mode, [
self::AUDIO_CHARACTER,
self::AUDIO_NARRATION,
self::AUDIO_SCENE,
self::AUDIO_AMBIENT,
self::AUDIO_POST_TTS,
], true) ? (string) $mode : self::AUDIO_AMBIENT;
}
/**
* @return array{id:int,processed:bool,language:string}
*/
public static function replaceVoice(
int $videoUploadId,
int $userId,
string $dialogue,
string $language,
int $durationSeconds = 5,
string $placement = 'start',
array $audioPolicy = []
): array {
$language = self::normalizeLanguage($language);
$audioMode = self::normalizeAudioMode((string) ($audioPolicy['audio_mode'] ?? self::AUDIO_AMBIENT));
if ($language === self::LANG_NATIVE) {
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_native',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_CHARACTER) {
// 只有画面角色对白保留 H3 联合采样原声,确保人物性别、声音和口型来自同一次生成。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_native',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_SCENE) {
// 场景音由 H3 与画面同次联合生成,才能让撞击、脚步、发动机等声音
// 精确跟随动作;规划器已明确禁止该类镜头产生任何人物声音。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_scene_sound',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_AMBIENT) {
// H3 已经在同一次联合采样里生成与画面同步的空间底噪、动作声和
// 环境声。旧版用极低音量粉红噪声覆盖它,最终成片约 -71dB,
// 听感接近静音;保留原声既有声音,也不会破坏动作同步。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_ambient',
'policy_version' => self::POLICY_VERSION,
];
}
$video = UploadFile::where('id', $videoUploadId)
->where('user_id', $userId)
->where('file_type', 'video')
->find();
if (!$video) {
throw new \RuntimeException('无法读取待配音视频');
}
$videoPath = self::uploadPath($video);
if (!is_file($videoPath)) {
throw new \RuntimeException('待配音视频文件不存在');
}
$durationSeconds = max(1, min(30, $durationSeconds));
$pcmPath = null;
$speechTempoFilter = '';
$voiceDelayMs = 200;
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_NARRATION) {
$spokenText = self::spokenText($dialogue);
if ($spokenText !== '') {
$pcmPath = tempnam(sys_get_temp_dir(), 'short_drama_tts_');
if ($pcmPath === false) {
throw new \RuntimeException('无法创建普通话配音任务');
}
$pcm = self::synthesizeMandarin($spokenText);
if (file_put_contents($pcmPath, $pcm) === false) {
@unlink($pcmPath);
throw new \RuntimeException('无法保存普通话配音数据');
}
// 24 kHz、16 bit、单声道 PCM48000 bytes/s。超过镜头时长时加速,避免截断台词。
$pcmDuration = strlen($pcm) / 48000;
$targetDuration = max(0.8, $durationSeconds - 0.4);
$tempo = max(1.0, $pcmDuration / $targetDuration);
$speechTempoFilter = self::tempoFilter($tempo);
$speechDuration = $pcmDuration / $tempo;
if ($placement === 'end') {
$voiceDelayMs = max(200, (int) round(
max(0.0, $durationSeconds - $speechDuration - 0.3) * 1000
));
}
}
}
$subdir = date('Y/m/d');
$storedBase = 'dub_' . str_replace('-', '_', $language) . '_' . uniqid('', true) . '.mp4';
$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)) {
if ($pcmPath) {
@unlink($pcmPath);
}
throw new \RuntimeException('无法创建配音视频目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
try {
$command = $audioMode === self::AUDIO_AMBIENT && $language !== self::LANG_NONE
? [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 'lavfi', '-i', 'anoisesrc=color=pink:amplitude=0.006:r=48000',
'-filter_complex', '[1:a]highpass=f=80,lowpass=f=4800,volume=0.35,apad,atrim=0:' . $durationSeconds . '[amb]',
'-map', '0:v:0', '-map', '[amb]',
'-t', (string) $durationSeconds,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart', $outputPath,
]
: ($language === self::LANG_NONE || $pcmPath === null
? [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono',
'-map', '0:v:0', '-map', '1:a:0',
'-t', (string) $durationSeconds,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart', $outputPath,
]
: [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 's16le', '-ar', '24000', '-ac', '1', '-i', (string) $pcmPath,
'-filter_complex', "[1:a]aresample=48000{$speechTempoFilter},volume=2.0,alimiter=limit=0.90:level=false,adelay={$voiceDelayMs},apad,atrim=0:{$durationSeconds}[dub]",
'-map', '0:v:0', '-map', '[dub]',
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart', '-shortest', $outputPath,
]);
[$exitCode, $error] = self::run($command);
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('普通话音轨合成失败: ' . mb_substr(trim($error), -500));
}
} finally {
if ($pcmPath) {
@unlink($pcmPath);
}
}
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_' . $language . '.mp4',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'video/mp4',
'file_size' => (int) filesize($outputPath),
'file_type' => 'video',
]);
return [
'id' => (int) $upload->id,
'processed' => true,
'language' => $language,
'source' => match (true) {
$language === self::LANG_NONE => 'silence',
$audioMode === self::AUDIO_AMBIENT => 'clean_ambient',
default => 'cosyvoice',
},
'policy_version' => self::POLICY_VERSION,
];
}
private static function tempoFilter(float $tempo): string
{
if ($tempo <= 1.0) {
return '';
}
$filters = [];
while ($tempo > 2.0) {
$filters[] = 'atempo=2.0';
$tempo /= 2.0;
}
$filters[] = 'atempo=' . number_format(max(1.0, $tempo), 4, '.', '');
return ',' . implode(',', $filters);
}
private static function synthesizeMandarin(string $text): string
{
$baseUrl = rtrim((string) config('short_drama.tts_base_url'), '/');
$promptWav = (string) config('short_drama.tts_prompt_wav');
$promptText = (string) config('short_drama.tts_prompt_text');
if ($baseUrl === '' || !is_file($promptWav)) {
throw new \RuntimeException('普通话配音服务尚未配置');
}
$ch = curl_init($baseUrl . '/inference_zero_shot');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'tts_text' => $text,
'prompt_text' => $promptText,
'prompt_wav' => new \CURLFile($promptWav, 'audio/wav', 'mandarin_reference.wav'),
],
CURLOPT_HTTPHEADER => ['Accept: application/octet-stream'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
]);
$pcm = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($pcm === false || $httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException('普通话配音服务调用失败: ' . ($error ?: 'HTTP ' . $httpCode));
}
if (strlen((string) $pcm) < 4800) {
throw new \RuntimeException('普通话配音服务返回空音频');
}
if (strlen($pcm) % 2 !== 0) {
$pcm = substr($pcm, 0, -1);
}
return $pcm;
}
private static function spokenText(string $dialogue): string
{
$dialogue = trim($dialogue);
$dialogue = preg_replace('/^[^:]{1,16}[:]/u', '', $dialogue) ?? $dialogue;
return mb_substr(trim($dialogue), 0, 80);
}
private static function uploadPath(UploadFile $upload): string
{
return rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
}
/** @return array{int,string} */
private static function run(array $command): array
{
$pipes = [];
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('服务器无法启动 FFmpeg');
}
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
return [proc_close($process), $error];
}
}
+351
View File
@@ -0,0 +1,351 @@
<?php
namespace app\service;
use app\model\UploadFile;
class VideoRenderService
{
/**
* 提取已生成镜头的最后一帧,作为下一镜头的真实视觉起点。
*/
public static function extractLastFrame(int $videoUploadId, int $userId): int
{
$video = UploadFile::where('id', $videoUploadId)
->where('user_id', $userId)
->where('file_type', 'video')
->find();
if (!$video) {
throw new \RuntimeException('无法读取上一镜头视频');
}
$videoPath = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $video->file_path);
if (!is_file($videoPath)) {
throw new \RuntimeException('上一镜头视频文件不存在');
}
$subdir = date('Y/m/d');
$storedBase = 'continuity_' . uniqid('', true) . '.jpg';
$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('无法创建镜头连续帧目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
// H3 输出最后几帧有时会包含编码尾部黑帧,向前取 0.12 秒更稳定。
[$exitCode, $error] = self::run([
'ffmpeg', '-y', '-sseof', '-0.12', '-i', $videoPath,
'-frames:v', '1', '-q:v', '2', $outputPath,
]);
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('提取镜头尾帧失败: ' . mb_substr(trim($error), -400));
}
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'continuity_last_frame.jpg',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'image/jpeg',
'file_size' => (int) filesize($outputPath),
'file_type' => 'image',
]);
return (int) $upload->id;
}
/**
* @param array<int,array<string,mixed>> $shots
*/
public static function concatenate(
array $shots,
int $userId,
bool $showSubtitles = false,
string $aspectRatio = '9:16',
string $screenTextLanguage = ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE,
string $quality = 'standard'
): int
{
$uploadIds = [];
$durations = [];
$renderShots = [];
foreach ($shots as $shot) {
$uploadId = (int) ($shot['output_upload_id'] ?? 0);
if ($uploadId <= 0) {
continue;
}
$uploadIds[] = $uploadId;
$durations[] = max(1, min(30, (int) ($shot['duration_seconds'] ?? 5)));
$renderShots[] = $shot;
}
if (!$uploadIds) {
throw new \RuntimeException('没有可合成的视频镜头');
}
$overlayDocument = self::overlayDocument(
$renderShots,
$durations,
$aspectRatio,
$showSubtitles,
$screenTextLanguage
);
if (count($uploadIds) === 1 && $overlayDocument === null) {
return $uploadIds[0];
}
$uploads = UploadFile::whereIn('id', $uploadIds)
->where('user_id', $userId)
->select()
->column(null, 'id');
$paths = [];
foreach ($uploadIds as $uploadId) {
$upload = $uploads[$uploadId] ?? null;
if (!$upload) {
throw new \RuntimeException('部分视频镜头文件已不存在');
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
throw new \RuntimeException('视频镜头文件无法读取');
}
$paths[] = $path;
}
$subdir = date('Y/m/d');
$storedBase = 'short_drama_' . uniqid('', true) . '.mp4';
$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('无法创建短剧成片目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
$subtitlePath = null;
if ($overlayDocument !== null) {
$subtitlePath = $fullDir . DIRECTORY_SEPARATOR . 'subtitle_' . uniqid('', true) . '.ass';
if (file_put_contents($subtitlePath, $overlayDocument) === false) {
throw new \RuntimeException('无法创建文字合成文件');
}
}
$command = ['ffmpeg', '-y'];
foreach ($paths as $path) {
$command[] = '-i';
$command[] = $path;
}
$filters = [];
$concatInputs = '';
[$targetWidth, $targetHeight] = self::renderSize($aspectRatio, $quality);
foreach ($durations as $index => $duration) {
// 不同批次或重试镜头可能使用不同质量档位。concat 要求每路画面和
// 音轨参数完全一致,因此先统一尺寸、SAR、帧率、像素格式和双声道。
$filters[] = "[{$index}:v:0]trim=duration={$duration},setpts=PTS-STARTPTS,"
. "scale={$targetWidth}:{$targetHeight}:force_original_aspect_ratio=decrease,"
. "pad={$targetWidth}:{$targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,"
. "setsar=1,fps=24,format=yuv420p[v{$index}]";
$filters[] = "[{$index}:a:0]aresample=48000,"
. "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
. "atrim=duration={$duration},asetpts=PTS-STARTPTS[a{$index}]";
$concatInputs .= "[v{$index}][a{$index}]";
}
if (count($paths) === 1) {
$videoOutput = '[v0]';
$audioOutput = '[a0]';
} else {
$filters[] = $concatInputs . 'concat=n=' . count($paths) . ':v=1:a=1[vconcat][aout]';
$videoOutput = '[vconcat]';
$audioOutput = '[aout]';
}
if ($subtitlePath !== null) {
$filters[] = $videoOutput . "ass=filename='" . self::escapeFilterPath($subtitlePath) . "'[vout]";
$videoOutput = '[vout]';
}
array_push(
$command,
'-filter_complex', implode(';', $filters),
'-map', $videoOutput, '-map', $audioOutput,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
'-movflags', '+faststart', $outputPath
);
[$exitCode, $error] = self::run($command);
if ($subtitlePath !== null) {
@unlink($subtitlePath);
}
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('视频合成失败: ' . mb_substr(trim($error), -600));
}
$size = (int) filesize($outputPath);
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_episode.mp4',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'video/mp4',
'file_size' => $size,
'file_type' => 'video',
]);
return (int) $upload->id;
}
/**
* 生成后期 ASS 文字层。字幕与场景文字都不交给视频模型直接绘制,避免乱码、漂移和闪烁。
*
* @param array<int,array<string,mixed>> $shots
* @param int[] $durations
*/
private static function overlayDocument(
array $shots,
array $durations,
string $aspectRatio,
bool $showSubtitles,
string $screenTextLanguage
): ?string
{
$screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage($screenTextLanguage);
$portrait = $aspectRatio !== '16:9';
$playResX = $portrait ? 1080 : 1920;
$playResY = $portrait ? 1920 : 1080;
$fontSize = $portrait ? 54 : 48;
$marginV = $portrait ? 150 : 72;
$lineLength = $portrait ? 17 : 28;
$cursor = 0.0;
$events = [];
foreach ($shots as $index => $shot) {
$duration = (float) ($durations[$index] ?? 5);
$meta = is_array($shot['meta'] ?? null) ? $shot['meta'] : [];
if (!$meta && is_string($shot['meta'] ?? null)) {
$decoded = json_decode((string) $shot['meta'], true);
$meta = is_array($decoded) ? $decoded : [];
}
$timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [];
$dialogue = $showSubtitles
? self::cleanSubtitleText((string) ($shot['dialogue'] ?? ''), $lineLength)
: '';
if ($dialogue !== '') {
$placement = (string) ($timeline['voice_timing'] ?? 'start');
$start = $cursor + 0.18;
if ($placement === 'end') {
$start = $cursor + max(0.18, $duration - 2.8);
}
$end = max($start + 0.5, $cursor + $duration - 0.16);
$events[] = 'Dialogue: 0,' . self::assTime($start) . ',' . self::assTime($end)
. ',Default,,0,0,0,,' . $dialogue;
}
$sceneText = $screenTextLanguage !== ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE
? self::cleanSceneText((string) ($timeline['screen_text'] ?? ''), $portrait ? 14 : 24)
: '';
if ($sceneText !== '') {
$start = $cursor + 0.35;
$end = max($start + 0.6, $cursor + $duration - 0.25);
$events[] = 'Dialogue: 1,' . self::assTime($start) . ',' . self::assTime($end)
. ',SceneText,,0,0,0,,' . $sceneText;
}
$cursor += $duration;
}
if (!$events) {
return null;
}
return "[Script Info]\n"
. "ScriptType: v4.00+\n"
. "PlayResX: {$playResX}\n"
. "PlayResY: {$playResY}\n"
. "WrapStyle: 0\n"
. "ScaledBorderAndShadow: yes\n\n"
. "[V4+ Styles]\n"
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
. "Style: Default,Noto Sans CJK SC,{$fontSize},&H00FFFFFF,&H00FFFFFF,&H50000000,&H78000000,-1,0,0,0,100,100,0,0,3,2,0,2,70,70,{$marginV},1\n"
. 'Style: SceneText,Noto Sans CJK SC,' . ($portrait ? 62 : 54) . ',&H00FFFFFF,&H00FFFFFF,&H78000000,&HA0000000,-1,0,0,0,100,100,0,0,3,3,0,8,90,90,' . ($portrait ? 260 : 105) . ",1\n\n"
. "[Events]\n"
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
. implode("\n", $events)
. "\n";
}
private static function cleanSubtitleText(string $text, int $lineLength): string
{
$text = trim($text);
$text = preg_replace('/^[^:\n]{1,20}[:]\s*/u', '', $text) ?? $text;
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
$text = trim($text);
if ($text === '') {
return '';
}
$text = mb_substr($text, 0, 80);
$text = str_replace(['\\', '{', '}'], ['', '', ''], $text);
$lines = [];
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
$lines[] = mb_substr($text, $offset, $lineLength);
}
return implode('\\N', array_slice($lines, 0, 3));
}
private static function cleanSceneText(string $text, int $lineLength): string
{
$text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text);
$text = preg_replace('/^[\s\"\'“”‘’]+|[\s\"\'“”‘’]+$/u', '', $text) ?? trim($text);
if ($text === '') {
return '';
}
$text = mb_substr($text, 0, 100);
$text = str_replace(['\\', '{', '}'], ['', '', ''], $text);
$lines = [];
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
$lines[] = mb_substr($text, $offset, $lineLength);
}
return implode('\\N', array_slice($lines, 0, 3));
}
private static function assTime(float $seconds): string
{
$centiseconds = max(0, (int) round($seconds * 100));
$hours = intdiv($centiseconds, 360000);
$minutes = intdiv($centiseconds % 360000, 6000);
$secs = intdiv($centiseconds % 6000, 100);
return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds % 100);
}
private static function escapeFilterPath(string $path): string
{
return str_replace(['\\', "'", ':'], ['\\\\', "\\'", '\\:'], $path);
}
/** @return array{int,int} */
private static function renderSize(string $aspectRatio, string $quality): array
{
$portrait = $aspectRatio !== '16:9';
return match ($quality) {
'high' => $portrait ? [768, 1344] : [1344, 768],
'fast' => $portrait ? [512, 896] : [896, 512],
default => $portrait ? [576, 1024] : [1024, 576],
};
}
/** @return array{int,string} */
private static function run(array $command): array
{
$pipes = [];
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('服务器未安装或无法启动 FFmpeg');
}
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, $error];
}
}