gengx
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('无更新内容');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 speaker’s actual sex, apparent age and identity.'
|
||||
: "Use a {$gender} voice matching the visible speaker’s age and identity.";
|
||||
return "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible {$speaker}, and nobody else, speaks exact standard Mandarin Chinese: “{$dialogue}”. "
|
||||
. "Delivery: {$delivery}. {$genderRule} The speaker starts with a closed mouth, opens the mouth only for this exact line with frame-accurate natural lip synchronization, then closes the mouth. "
|
||||
. 'NO narrator, NO off-screen voice, NO second speaker, NO voice/sex mismatch, NO extra words, NO repeated words, NO gibberish and NO pseudo-language. Keep synchronized scene ambience and action sounds. '
|
||||
. $noText;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次整集提交只上传一次角色图,所有镜头复用同一个 ComfyUI input 文件。
|
||||
*
|
||||
* @param array<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];
|
||||
}
|
||||
}
|
||||
@@ -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' => '角色管理',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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、单声道 PCM:48000 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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user