Files
chat/backend/app/controller/api/ShortDrama.php
T
2026-08-05 15:56:08 +08:00

1114 lines
52 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\controller\api;
use app\model\UploadFile;
use app\model\VideoCharacter;
use app\model\VideoEpisode;
use app\model\VideoProject;
use app\model\VideoShot;
use app\service\GuestAccessService;
use app\service\MiniMaxH3Service;
use app\service\SettingsService;
use app\service\ShortDramaPlannerService;
use app\service\VideoDubService;
use app\service\VideoRenderService;
use think\facade\Db;
class ShortDrama extends BaseApi
{
private const SHOT_CONCURRENCY = 3;
public function bootstrap()
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
return $this->success([
'templates' => $this->templates(),
'projects' => $this->projectList((int) $user['id']),
'defaults' => [
'aspect_ratio' => '9:16',
'duration' => 'auto',
'quality' => 'fast',
'style' => '电影写实',
'mode' => 'auto',
'voice_language' => VideoDubService::LANG_MANDARIN,
'show_subtitles' => true,
'character_origin' => ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN,
'screen_text_language' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN,
'shot_duration_mode' => ShortDramaPlannerService::SHOT_DURATION_AUTO,
],
'voice_languages' => [
['value' => VideoDubService::LANG_MANDARIN, 'label' => '普通话 · H3 原生口型同步(推荐)'],
['value' => VideoDubService::LANG_NONE, 'label' => '无配音'],
['value' => VideoDubService::LANG_NATIVE, 'label' => 'H3 原生音轨(可能不清晰)'],
],
'subtitle_options' => [
['value' => true, 'label' => '显示字幕'],
['value' => false, 'label' => '关闭字幕'],
],
'character_origins' => [
['value' => ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN, 'label' => '东方面孔(东亚)'],
['value' => ShortDramaPlannerService::CHARACTER_ORIGIN_GLOBAL, 'label' => '全球面孔(国家不限)'],
],
'screen_text_languages' => [
['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN, 'label' => '简体中文'],
['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US, 'label' => 'English(英文)'],
['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE, 'label' => '不显示画面文字'],
],
'shot_duration_modes' => [
['value' => ShortDramaPlannerService::SHOT_DURATION_AUTO, 'label' => 'AI 智能 · 5/10 秒(推荐)'],
['value' => ShortDramaPlannerService::SHOT_DURATION_FIVE, 'label' => '每镜 5 秒 · 节奏更快'],
['value' => ShortDramaPlannerService::SHOT_DURATION_TEN, 'label' => '每镜 10 秒 · 表演更完整'],
],
'engine' => [
'target_model' => ShortDramaPlannerService::targetModel(),
'workflow_version' => MiniMaxH3Service::WORKFLOW_VERSION,
'workflow' => MiniMaxH3Service::workflowManifest(),
'shot_concurrency' => MiniMaxH3Service::workerSummary()['effective_concurrency'],
'worker_pool' => MiniMaxH3Service::workerSummary(),
'director_prompt_enabled' => true,
'director_prompt_version' => substr(
hash('sha256', ShortDramaPlannerService::directorSystemPrompt()),
0,
12
),
],
]);
}
public function index()
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
return $this->success($this->projectList((int) $user['id']));
}
public function createProject()
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$input = $this->request->post();
$idea = trim((string) ($input['idea'] ?? $input['script'] ?? ''));
if (mb_strlen($idea) < 5) {
return $this->error('请至少用一句话描述想拍的短剧', 422);
}
if (mb_strlen($idea) > 50000) {
return $this->error('剧本内容不能超过 50000 字', 422);
}
$requestedDuration = (string) ($input['duration'] ?? 'auto');
$durationMode = $requestedDuration === 'auto' ? 'auto' : 'fixed';
$duration = $durationMode === 'auto'
? ShortDramaPlannerService::recommendDuration($idea)
: (int) $requestedDuration;
if ($durationMode === 'fixed' && !in_array($duration, [5, 10, 30, 60, 90], true)) {
$duration = 30;
}
$aspectRatio = ($input['aspect_ratio'] ?? '9:16') === '16:9' ? '16:9' : '9:16';
$requestedQuality = (string) ($input['quality'] ?? 'fast');
$quality = in_array($requestedQuality, ['fast', 'standard', 'high'], true)
? $requestedQuality
: 'fast';
$style = trim((string) ($input['style'] ?? '电影写实')) ?: '电影写实';
$voiceLanguage = VideoDubService::normalizeLanguage((string) ($input['voice_language'] ?? 'zh-CN'));
$showSubtitles = filter_var(
$input['show_subtitles'] ?? true,
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
);
$showSubtitles = $showSubtitles ?? true;
$characterOrigin = ShortDramaPlannerService::normalizeCharacterOrigin(
(string) ($input['character_origin'] ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN)
);
$screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage(
(string) ($input['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN)
);
$shotDurationMode = ShortDramaPlannerService::normalizeShotDurationMode(
(string) ($input['shot_duration_mode'] ?? ShortDramaPlannerService::SHOT_DURATION_AUTO)
);
$plan = ShortDramaPlannerService::planWithAi(
$idea,
$duration,
$aspectRatio,
$style,
$voiceLanguage,
$characterOrigin,
$screenTextLanguage,
$shotDurationMode
);
$result = Db::transaction(function () use ($user, $idea, $duration, $durationMode, $aspectRatio, $quality, $style, $voiceLanguage, $showSubtitles, $characterOrigin, $screenTextLanguage, $shotDurationMode, $plan) {
$project = VideoProject::create([
'user_id' => (int) $user['id'],
'title' => $plan['title'],
'idea' => $idea,
'style' => $style,
'aspect_ratio' => $aspectRatio,
'episode_duration' => $duration,
'duration_mode' => $durationMode,
'quality' => $quality,
'voice_language' => $voiceLanguage,
'show_subtitles' => $showSubtitles ? 1 : 0,
'character_origin' => $characterOrigin,
'screen_text_language' => $screenTextLanguage,
'shot_duration_mode' => $shotDurationMode,
'status' => 'storyboard',
]);
$episode = VideoEpisode::create([
'project_id' => (int) $project->id,
'episode_no' => 1,
'title' => '第 1 集',
'script' => $plan['script'],
'status' => 'storyboard',
'progress' => 10,
'progress_message' => '分镜已准备好,请确认角色后生成',
]);
foreach ($plan['shots'] as $shot) {
$shot['episode_id'] = (int) $episode->id;
VideoShot::create($shot);
}
return [$project, $episode];
});
[$project, $episode] = $result;
if (!empty($input['auto_generate'])) {
try {
$this->submitEpisode($project, $episode, []);
} catch (\Throwable $error) {
$episode->save([
'status' => 'needs_attention',
'progress_message' => $this->progressErrorMessage('', $error),
]);
}
}
return $this->success(
$this->projectDetail((int) $project->id, (int) $user['id']),
'短剧项目已创建',
201
);
}
public function show($id)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$detail = $this->projectDetail((int) $id, (int) $user['id']);
return $detail ? $this->success($detail) : $this->error('短剧项目不存在', 404);
}
public function addCharacter($id)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
if (VideoCharacter::where('project_id', $project->id)->count() >= 9) {
return $this->error('一个项目最多添加 9 个固定角色', 422);
}
$input = $this->request->post();
$name = trim((string) ($input['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
return $this->error('请填写 1-80 字的角色名称', 422);
}
$uploadId = (int) ($input['reference_upload_id'] ?? 0);
if ($uploadId > 0) {
$upload = UploadFile::where('id', $uploadId)
->where('user_id', (int) $user['id'])
->where('file_type', 'image')
->find();
if (!$upload) {
return $this->error('角色参考图不存在或不属于当前用户', 422);
}
}
$character = VideoCharacter::create([
'project_id' => (int) $project->id,
'user_id' => (int) $user['id'],
'name' => $name,
'description' => mb_substr(trim((string) ($input['description'] ?? '')), 0, 1000),
'reference_upload_id' => $uploadId > 0 ? $uploadId : null,
'voice_key' => trim((string) ($input['voice_key'] ?? '')) ?: null,
'is_locked' => 1,
'asset_version' => 1,
]);
$this->refreshDraftPrompts($project);
return $this->success($this->characterData($character), '角色已锁定', 201);
}
public function deleteCharacter($id, $characterId)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
$character = VideoCharacter::where('id', (int) $characterId)
->where('project_id', (int) $project->id)
->find();
if (!$character) {
return $this->error('角色不存在', 404);
}
$character->delete();
$this->refreshDraftPrompts($project);
return $this->success(null, '角色已移除');
}
public function generate($id)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
$episodeId = (int) ($this->request->post('episode_id') ?? 0);
$episodeQuery = VideoEpisode::where('project_id', (int) $project->id);
$episode = $episodeId > 0
? $episodeQuery->where('id', $episodeId)->find()
: $episodeQuery->order('episode_no', 'desc')->find();
if (!$episode) {
return $this->error('分集不存在', 404);
}
$characters = VideoCharacter::where('project_id', (int) $project->id)
->order('id')
->select()
->toArray();
try {
$summary = $this->submitEpisode($project, $episode, $characters);
} catch (\Throwable $error) {
return $this->error($error->getMessage(), 502);
}
return $this->success($summary, '视频生成任务已提交');
}
public function status($id)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
$lockName = 'short_drama_project_' . (int) $project->id;
$lockRows = Db::query('SELECT GET_LOCK(?, 0) AS acquired', [$lockName]);
$acquired = (int) ($lockRows[0]['acquired'] ?? 0) === 1;
if ($acquired) {
try {
$episodes = VideoEpisode::where('project_id', (int) $project->id)
->order('episode_no')
->select();
foreach ($episodes as $episode) {
$this->refreshEpisodeStatus($project, $episode, (int) $user['id']);
}
} finally {
Db::query('SELECT RELEASE_LOCK(?)', [$lockName]);
}
}
return $this->success($this->projectDetail((int) $project->id, (int) $user['id']));
}
public function retryShot($id, $shotId)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
$shot = VideoShot::alias('s')
->join('video_episodes e', 's.episode_id = e.id')
->where('s.id', (int) $shotId)
->where('e.project_id', (int) $project->id)
->field('s.*')
->find();
if (!$shot) {
return $this->error('镜头不存在', 404);
}
$input = $this->request->post();
$reason = (string) ($input['reason'] ?? 'replace');
$meta = is_array($shot->meta) ? $shot->meta : [];
$planned = ['shots' => []];
$plannedShot = null;
// AI 已完成的语义分类必须在重试时原样保留,不能重新退化成固定节拍,
// 否则“人物对白/场景声/屏幕文字”的判定会被覆盖。旧项目才走本地补全。
if (($meta['analysis_source'] ?? '') !== 'ai') {
$planned = ShortDramaPlannerService::plan(
(string) $project->idea,
(int) $project->episode_duration,
(string) $project->aspect_ratio,
(string) $project->style,
(string) ($project->voice_language ?? 'zh-CN'),
(string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN),
(string) ($project->shot_duration_mode ?? ShortDramaPlannerService::SHOT_DURATION_FIVE)
);
$plannedShot = $planned['shots'][max(0, (int) $shot->shot_no - 1)] ?? null;
}
if (is_array($plannedShot)) {
$meta = array_replace($meta, $plannedShot['meta'] ?? []);
}
if ($reason === 'identity') {
$meta['identity_boost'] = true;
}
unset(
$meta['raw_output_upload_id'],
$meta['dubbed_language'],
$meta['voice_source'],
$meta['audio_policy_version'],
$meta['continuity_frame_upload_id'],
$meta['continuity_from_shot_id']
);
$characters = VideoCharacter::where('project_id', (int) $project->id)->order('id')->select()->toArray();
$prompt = trim((string) ($plannedShot['prompt'] ?? $meta['base_prompt'] ?? $shot->prompt));
if ($characters) {
$prompt = ShortDramaPlannerService::recompileShotPrompt(
(string) $project->idea,
$prompt,
$characters,
(string) $project->style,
(string) $project->aspect_ratio,
(string) ($project->voice_language ?? 'zh-CN'),
(string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN)
);
}
if ($reason === 'action') {
$prompt = preg_replace('/。+$/u', '', $prompt) ?? $prompt;
$prompt .= '。动作保持简单、单一、连贯,不要突然切换场景或生成多余人物。';
}
$shot->save([
'status' => 'draft',
'prompt_id' => null,
'output_upload_id' => null,
'error_message' => null,
'seed' => random_int(1, PHP_INT_MAX),
'meta' => $meta,
'prompt' => $prompt,
'dialogue' => (string) ($plannedShot['dialogue'] ?? $shot->dialogue),
]);
$episode = VideoEpisode::where('id', (int) $shot->episode_id)
->where('project_id', (int) $project->id)
->find();
if (!$episode) {
return $this->error('分集不存在', 404);
}
// 中间镜头改变后,后续镜头的动作和尾帧依据全部失效,必须从这里重新接力。
$followingShots = VideoShot::where('episode_id', (int) $episode->id)
->where('shot_no', '>', (int) $shot->shot_no)
->select();
foreach ($followingShots as $followingShot) {
$followingPlanned = $planned['shots'][max(0, (int) $followingShot->shot_no - 1)] ?? null;
$followingMeta = is_array($followingShot->meta) ? $followingShot->meta : [];
if (is_array($followingPlanned)) {
$followingMeta = array_replace($followingMeta, $followingPlanned['meta'] ?? []);
}
unset(
$followingMeta['raw_output_upload_id'],
$followingMeta['dubbed_language'],
$followingMeta['voice_source'],
$followingMeta['audio_policy_version'],
$followingMeta['continuity_frame_upload_id'],
$followingMeta['continuity_from_shot_id']
);
$followingPrompt = trim((string) ($followingPlanned['prompt'] ?? $followingShot->prompt));
if ($characters) {
$followingPrompt = ShortDramaPlannerService::recompileShotPrompt(
(string) $project->idea,
$followingPrompt,
$characters,
(string) $project->style,
(string) $project->aspect_ratio,
(string) ($project->voice_language ?? 'zh-CN'),
(string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN)
);
}
$followingShot->save([
'status' => 'draft',
'prompt_id' => null,
'output_upload_id' => null,
'error_message' => null,
'seed' => random_int(1, PHP_INT_MAX),
'meta' => $followingMeta,
'prompt' => $followingPrompt,
'dialogue' => (string) ($followingPlanned['dialogue'] ?? $followingShot->dialogue),
]);
}
$episode->save([
'status' => 'generating',
'progress' => 15,
'progress_message' => '正在从修改的镜头重新建立连续性',
'final_upload_id' => null,
]);
$project->save(['status' => 'generating']);
try {
$this->submitEpisode($project, $episode, $characters);
} catch (\Throwable $error) {
$shot->save(['status' => 'error', 'error_message' => $error->getMessage()]);
return $this->error($error->getMessage(), 502);
}
return $this->success($this->shotData($shot), '镜头已重新生成');
}
public function deleteProject($id)
{
$user = $this->requireAccess();
if (!is_array($user)) {
return $user;
}
$project = $this->findProject((int) $id, (int) $user['id']);
if (!$project) {
return $this->error('短剧项目不存在', 404);
}
$episodeIds = VideoEpisode::where('project_id', (int) $project->id)->column('id');
$uploadIds = VideoEpisode::where('project_id', (int) $project->id)
->whereNotNull('final_upload_id')
->column('final_upload_id');
if ($episodeIds) {
$shots = VideoShot::whereIn('episode_id', $episodeIds)->select();
foreach ($shots as $shot) {
if ((int) $shot->output_upload_id > 0) {
$uploadIds[] = (int) $shot->output_upload_id;
}
$meta = is_array($shot->meta) ? $shot->meta : [];
if ((int) ($meta['continuity_frame_upload_id'] ?? 0) > 0) {
$uploadIds[] = (int) $meta['continuity_frame_upload_id'];
}
if ((int) ($meta['raw_output_upload_id'] ?? 0) > 0) {
$uploadIds[] = (int) $meta['raw_output_upload_id'];
}
}
}
$project->delete();
$this->deleteGeneratedUploads(array_values(array_unique(array_map('intval', $uploadIds))), (int) $user['id']);
return $this->success(null, '短剧和生成的视频已删除');
}
private function submitEpisode(VideoProject $project, VideoEpisode $episode, array $characters): array
{
$active = VideoShot::where('episode_id', (int) $episode->id)
->whereIn('status', ['queued', 'running', 'waiting'])
->count();
if ($active > 0) {
return [
'submitted' => 0,
'failed' => 0,
'message' => "当前批次还有 {$active} 个镜头正在生成",
];
}
// ComfyUI 的单个地址只有一个执行队列。同地址提交三条只是排队,不是真并发;
// 有固定角色时才把镜头分发到不同地址,无角色时仍逐镜头承接真实尾帧。
$workers = array_slice(MiniMaxH3Service::workers(), 0, self::SHOT_CONCURRENCY);
$batchLimit = $characters ? count($workers) : 1;
$shots = VideoShot::where('episode_id', (int) $episode->id)
->whereIn('status', ['draft', 'error'])
->order('shot_no')
->limit($batchLimit)
->select();
if ($shots->count() === 0) {
throw new \RuntimeException('没有需要生成的镜头');
}
$firstShot = $shots[0];
$previousShot = VideoShot::where('episode_id', (int) $episode->id)
->where('shot_no', '=', (int) $firstShot->shot_no - 1)
->where('status', 'completed')
->find();
$continuityUploadId = null;
if ($previousShot) {
$previousMeta = is_array($previousShot->meta) ? $previousShot->meta : [];
$continuityUploadId = (int) ($previousMeta['continuity_frame_upload_id'] ?? 0) ?: null;
}
$submittedCount = 0;
$errors = [];
$referenceCache = [];
foreach ($shots as $batchIndex => $shot) {
$worker = $workers[$batchIndex % count($workers)];
$workerId = (int) $worker->id;
if (!array_key_exists($workerId, $referenceCache)) {
$referenceCache[$workerId] = MiniMaxH3Service::prepareReferenceFiles(
$project,
$characters,
$worker
);
}
// 一批的第一个镜头承接上一批真实尾帧;其余两个依靠固定角色图和导演提示词并发生成。
$shotContinuityUploadId = $batchIndex === 0 ? $continuityUploadId : null;
try {
$submitted = MiniMaxH3Service::submitShot(
$shot,
$project,
$characters,
$referenceCache[$workerId],
$shotContinuityUploadId,
$worker
);
$meta = is_array($shot->meta) ? $shot->meta : [];
$meta['reference_count'] = $submitted['reference_count'];
$meta['continuity_applied'] = $submitted['continuity_applied'];
$meta['audio_mode'] = $submitted['audio_mode'];
$meta['workflow_version'] = $submitted['workflow_version'];
$meta['workflow_settings'] = $submitted['applied_settings'] ?? [];
$meta['comfy_worker_id'] = $submitted['worker_id'];
$meta['comfy_worker_name'] = $submitted['worker_name'];
$meta['concurrency'] = $batchLimit;
$meta['batch_position'] = $batchIndex + 1;
$meta['submitted_at'] = date(DATE_ATOM);
unset($meta['missing_poll_count']);
if ($previousShot && $submitted['continuity_applied']) {
$meta['continuity_from_shot_id'] = (int) $previousShot->id;
}
$shot->save([
'status' => 'queued',
'prompt_id' => $submitted['prompt_id'],
'workflow_type' => $submitted['workflow_type'],
'error_message' => null,
'meta' => $meta,
]);
$submittedCount++;
} catch (\Throwable $error) {
$shot->save(['status' => 'error', 'error_message' => $error->getMessage()]);
$errors[] = "镜头 {$shot->shot_no}: {$error->getMessage()}";
}
}
if ($submittedCount === 0) {
throw new \RuntimeException($errors[0] ?? '本批镜头提交失败');
}
$firstShotNo = (int) $firstShot->shot_no;
$lastShotNo = (int) $shots[$shots->count() - 1]->shot_no;
$episode->save([
'status' => 'generating',
'progress' => 15,
'progress_message' => $batchLimit > 1
? "正在由 {$batchLimit} 个独立 ComfyUI 节点并发生成镜头 {$firstShotNo}-{$lastShotNo}"
: "正在生成连续镜头 {$firstShotNo},完成后自动接力下一镜头",
]);
$project->save(['status' => 'generating']);
return [
'submitted' => $submittedCount,
'failed' => count($errors),
'message' => $batchLimit > 1
? "镜头 {$firstShotNo}-{$lastShotNo} 已分发到 {$batchLimit} 个独立 ComfyUI 节点"
: "镜头 {$firstShotNo} 已提交,将通过真实尾帧逐镜头接力",
];
}
private function refreshEpisodeStatus(VideoProject $project, VideoEpisode $episode, int $userId): void
{
$shots = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select();
$voiceOutputsChanged = false;
foreach ($shots as $shot) {
if ((string) $shot->status === 'completed') {
try {
$voiceOutputsChanged = $this->ensureShotVoice($shot, $project, $userId)
|| $voiceOutputsChanged;
} catch (\Throwable $error) {
$shot->save(['status' => 'error', 'error_message' => $error->getMessage()]);
}
continue;
}
if (!in_array((string) $shot->status, ['queued', 'running', 'waiting'], true)
|| trim((string) $shot->prompt_id) === '') {
continue;
}
$meta = is_array($shot->meta) ? $shot->meta : [];
$workerId = (int) ($meta['comfy_worker_id'] ?? 0) ?: null;
$inspection = MiniMaxH3Service::inspect((string) $shot->prompt_id, $workerId);
if ($inspection['state'] === 'done') {
try {
$stored = MiniMaxH3Service::storeVideo($inspection['files'][0], $userId, $workerId);
$voice = VideoDubService::replaceVoice(
(int) $stored['id'],
$userId,
(string) $shot->dialogue,
(string) ($project->voice_language ?? 'zh-CN'),
(int) $shot->duration_seconds,
(string) ($meta['timeline']['voice_timing'] ?? 'start'),
is_array($meta['timeline'] ?? null) ? $meta['timeline'] : []
);
if ($voice['processed']) {
$meta['raw_output_upload_id'] = (int) $stored['id'];
}
$meta['dubbed_language'] = $voice['language'];
$meta['voice_source'] = $voice['source'];
$meta['audio_policy_version'] = $voice['policy_version'];
$meta['continuity_frame_upload_id'] = VideoRenderService::extractLastFrame(
(int) $voice['id'],
$userId
);
$shot->save([
'status' => 'completed',
'output_upload_id' => $voice['id'],
'error_message' => null,
'meta' => $meta,
]);
} catch (\Throwable $error) {
$shot->save(['status' => 'error', 'error_message' => $error->getMessage()]);
}
} elseif ($inspection['state'] === 'error') {
$shot->save(['status' => 'error', 'error_message' => $inspection['error']]);
} elseif ($inspection['state'] === 'missing') {
$missingPollCount = (int) ($meta['missing_poll_count'] ?? 0) + 1;
$meta['missing_poll_count'] = $missingPollCount;
if ($missingPollCount >= 3) {
$autoRetryCount = (int) ($meta['auto_retry_count'] ?? 0);
if ($autoRetryCount < 2) {
$meta['auto_retry_count'] = $autoRetryCount + 1;
unset($meta['missing_poll_count']);
$shot->save([
'status' => 'draft',
'prompt_id' => null,
'error_message' => null,
'meta' => $meta,
]);
} else {
$shot->save([
'status' => 'error',
'error_message' => 'ComfyUI 任务连续丢失 3 次,请检查远端队列或显存',
'meta' => $meta,
]);
}
} else {
$shot->save(['status' => 'waiting', 'meta' => $meta]);
}
} else {
$shot->save(['status' => $inspection['state'] === 'queued' ? 'queued' : 'running']);
}
}
$shotRows = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select()->toArray();
$total = count($shotRows);
$completed = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'completed'));
$errors = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'error'));
$active = count(array_filter($shotRows, fn ($shot) => in_array($shot['status'], ['queued', 'running', 'waiting'], true)));
$drafts = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'draft'));
$progress = $total > 0 ? 15 + (int) floor(($completed / $total) * 75) : 0;
if ($total > 0 && $completed === $total) {
if (!(int) $episode->final_upload_id || $voiceOutputsChanged) {
try {
$previousFinalUploadId = (int) $episode->final_upload_id;
$finalUploadId = VideoRenderService::concatenate(
$shotRows,
$userId,
(bool) ($project->show_subtitles ?? false),
(string) $project->aspect_ratio,
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE),
(string) ($project->quality ?? 'standard')
);
$episode->save([
'final_upload_id' => $finalUploadId,
'status' => 'completed',
'progress' => 100,
'progress_message' => '成片已完成',
]);
$project->save(['status' => 'completed']);
if ($previousFinalUploadId > 0 && $previousFinalUploadId !== $finalUploadId) {
$this->deleteGeneratedUploads([$previousFinalUploadId], $userId);
}
} catch (\Throwable $error) {
$episode->save([
'status' => 'needs_attention',
'progress' => 92,
'progress_message' => $this->progressErrorMessage('镜头已完成,合成失败:', $error),
]);
$project->save(['status' => 'needs_attention']);
}
}
return;
}
if ($active === 0 && $errors > 0) {
$episode->save([
'status' => 'needs_attention',
'progress' => max(15, $progress),
'progress_message' => "有 {$errors} 个镜头需要重新生成",
]);
$project->save(['status' => 'needs_attention']);
} elseif ($active === 0 && $drafts > 0) {
try {
$characters = VideoCharacter::where('project_id', (int) $project->id)
->order('id')
->select()
->toArray();
$this->submitEpisode($project, $episode, $characters);
$nextShots = VideoShot::where('episode_id', (int) $episode->id)
->whereIn('status', ['queued', 'running', 'waiting'])
->order('shot_no')
->select();
$activeShotNumbers = array_map(
fn ($shot) => (int) $shot->shot_no,
$nextShots->all()
);
$episode->save([
'status' => 'generating',
'progress' => min(90, max(15, $progress)),
'progress_message' => $activeShotNumbers
? (count($activeShotNumbers) > 1
? '上一批完成,正在并发生成镜头 '
: '上一镜头完成,正在连续生成镜头 ')
. implode('、', $activeShotNumbers) . "{$completed}/{$total}"
: "正在准备下一批镜头({$completed}/{$total}",
]);
} catch (\Throwable $error) {
$episode->save([
'status' => 'needs_attention',
'progress' => max(15, $progress),
'progress_message' => $this->progressErrorMessage('连续镜头提交失败:', $error),
]);
$project->save(['status' => 'needs_attention']);
}
} else {
$hasCharacters = VideoCharacter::where('project_id', (int) $project->id)->count() > 0;
$episode->save([
'status' => 'generating',
'progress' => min(90, max(15, $progress)),
'progress_message' => $hasCharacters && $active > 1
? "正在并发生成 {$active} 个镜头(已完成 {$completed}/{$total}"
: "正在生成当前镜头(已完成 {$completed}/{$total}",
]);
}
}
/**
* 为迁移前已经生成完成的镜头补做所选语言的音轨。
*/
private function ensureShotVoice(VideoShot $shot, VideoProject $project, int $userId): bool
{
$language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN'));
$meta = is_array($shot->meta) ? $shot->meta : [];
if (($meta['dubbed_language'] ?? '') === $language
&& ($meta['audio_policy_version'] ?? '') === VideoDubService::POLICY_VERSION) {
return false;
}
$previousOutputUploadId = (int) $shot->output_upload_id;
$rawUploadId = (int) ($meta['raw_output_upload_id'] ?? 0);
if ($rawUploadId <= 0) {
$rawUploadId = (int) $shot->output_upload_id;
}
if ($rawUploadId <= 0) {
throw new \RuntimeException('镜头原始视频不存在,无法补做配音');
}
$voice = VideoDubService::replaceVoice(
$rawUploadId,
$userId,
(string) $shot->dialogue,
$language,
(int) $shot->duration_seconds,
(string) ($meta['timeline']['voice_timing'] ?? 'start'),
is_array($meta['timeline'] ?? null) ? $meta['timeline'] : []
);
if ($voice['processed']) {
$meta['raw_output_upload_id'] = $rawUploadId;
}
$meta['dubbed_language'] = $voice['language'];
$meta['voice_source'] = $voice['source'];
$meta['audio_policy_version'] = $voice['policy_version'];
$shot->save([
'output_upload_id' => (int) $voice['id'],
'meta' => $meta,
'error_message' => null,
]);
return (bool) $voice['processed'] || $previousOutputUploadId !== (int) $voice['id'];
}
private function progressErrorMessage(string $prefix, \Throwable $error): string
{
$message = trim($prefix . preg_replace('/\s+/u', ' ', $error->getMessage()));
return mb_strlen($message) > 240 ? mb_substr($message, 0, 239) . '…' : $message;
}
private function deleteGeneratedUploads(array $uploadIds, int $userId): void
{
if (!$uploadIds) {
return;
}
$uploads = UploadFile::whereIn('id', $uploadIds)
->where('user_id', $userId)
->select();
foreach ($uploads as $upload) {
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (is_file($path)) {
@unlink($path);
}
$upload->delete();
}
}
private function refreshDraftPrompts(VideoProject $project): void
{
$characters = VideoCharacter::where('project_id', (int) $project->id)->order('id')->select()->toArray();
$episodes = VideoEpisode::where('project_id', (int) $project->id)->select();
foreach ($episodes as $episode) {
$shots = VideoShot::where('episode_id', (int) $episode->id)
->whereIn('status', ['draft', 'error'])
->select();
foreach ($shots as $shot) {
$meta = is_array($shot->meta) ? $shot->meta : [];
$basePrompt = trim((string) ($meta['base_prompt'] ?? $shot->prompt));
$shot->save([
'prompt' => ShortDramaPlannerService::recompileShotPrompt(
(string) $project->idea,
$basePrompt,
$characters,
(string) $project->style,
(string) $project->aspect_ratio,
(string) ($project->voice_language ?? 'zh-CN'),
(string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN)
),
'workflow_type' => $characters ? 'ref2va' : 'fl2va',
]);
}
}
}
private function projectList(int $userId): array
{
$projects = VideoProject::where('user_id', $userId)->order('updated_at', 'desc')->select();
$result = [];
foreach ($projects as $project) {
$episode = VideoEpisode::where('project_id', (int) $project->id)->order('episode_no', 'desc')->find();
$result[] = [
'id' => (int) $project->id,
'title' => (string) $project->title,
'idea' => (string) $project->idea,
'aspect_ratio' => (string) $project->aspect_ratio,
'duration' => (int) $project->episode_duration,
'duration_mode' => (string) ($project->duration_mode ?? 'fixed'),
'quality' => (string) $project->quality,
'voice_language' => (string) ($project->voice_language ?? 'native'),
'voice_language_label' => VideoDubService::label((string) ($project->voice_language ?? 'native')),
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
'subtitle_label' => !empty($project->show_subtitles) ? '显示字幕' : '关闭字幕',
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin((string) ($project->character_origin ?? '')),
'character_origin_label' => ShortDramaPlannerService::characterOriginLabel((string) ($project->character_origin ?? '')),
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage((string) ($project->screen_text_language ?? '')),
'screen_text_language_label' => ShortDramaPlannerService::screenTextLanguageLabel((string) ($project->screen_text_language ?? '')),
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode((string) ($project->shot_duration_mode ?? '')),
'shot_duration_mode_label' => ShortDramaPlannerService::shotDurationModeLabel((string) ($project->shot_duration_mode ?? '')),
'status' => (string) $project->status,
'updated_at' => $project->updated_at,
'character_count' => VideoCharacter::where('project_id', (int) $project->id)->count(),
'episode' => $episode ? [
'id' => (int) $episode->id,
'episode_no' => (int) $episode->episode_no,
'status' => (string) $episode->status,
'progress' => (int) $episode->progress,
'progress_message' => (string) $episode->progress_message,
'final_url' => $this->uploadUrl((int) $episode->final_upload_id),
] : null,
];
}
return $result;
}
private function projectDetail(int $projectId, int $userId): ?array
{
$project = $this->findProject($projectId, $userId);
if (!$project) {
return null;
}
$characters = VideoCharacter::where('project_id', $projectId)->order('id')->select();
$episodes = VideoEpisode::where('project_id', $projectId)->order('episode_no')->select();
$episodeData = [];
foreach ($episodes as $episode) {
$shots = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select();
$episodeData[] = [
'id' => (int) $episode->id,
'episode_no' => (int) $episode->episode_no,
'title' => (string) $episode->title,
'script' => (string) $episode->script,
'status' => (string) $episode->status,
'progress' => (int) $episode->progress,
'progress_message' => (string) $episode->progress_message,
'final_upload_id' => (int) $episode->final_upload_id,
'final_url' => $this->uploadUrl((int) $episode->final_upload_id),
'shots' => array_map(fn ($shot) => $this->shotData($shot), $shots->all()),
];
}
return [
'id' => (int) $project->id,
'title' => (string) $project->title,
'idea' => (string) $project->idea,
'style' => (string) $project->style,
'aspect_ratio' => (string) $project->aspect_ratio,
'duration' => (int) $project->episode_duration,
'duration_mode' => (string) ($project->duration_mode ?? 'fixed'),
'quality' => (string) $project->quality,
'voice_language' => (string) ($project->voice_language ?? 'native'),
'voice_language_label' => VideoDubService::label((string) ($project->voice_language ?? 'native')),
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
'subtitle_label' => !empty($project->show_subtitles) ? '显示字幕' : '关闭字幕',
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin((string) ($project->character_origin ?? '')),
'character_origin_label' => ShortDramaPlannerService::characterOriginLabel((string) ($project->character_origin ?? '')),
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage((string) ($project->screen_text_language ?? '')),
'screen_text_language_label' => ShortDramaPlannerService::screenTextLanguageLabel((string) ($project->screen_text_language ?? '')),
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode((string) ($project->shot_duration_mode ?? '')),
'shot_duration_mode_label' => ShortDramaPlannerService::shotDurationModeLabel((string) ($project->shot_duration_mode ?? '')),
'status' => (string) $project->status,
'characters' => array_map(fn ($character) => $this->characterData($character), $characters->all()),
'episodes' => $episodeData,
'created_at' => $project->created_at,
'updated_at' => $project->updated_at,
];
}
private function characterData(VideoCharacter|array $character): array
{
$data = is_array($character) ? $character : $character->toArray();
return [
'id' => (int) $data['id'],
'name' => (string) $data['name'],
'description' => (string) ($data['description'] ?? ''),
'reference_upload_id' => (int) ($data['reference_upload_id'] ?? 0),
'reference_url' => $this->uploadUrl((int) ($data['reference_upload_id'] ?? 0)),
'is_locked' => !empty($data['is_locked']),
'asset_version' => (int) ($data['asset_version'] ?? 1),
];
}
private function shotData(VideoShot|array $shot): array
{
$data = is_array($shot) ? $shot : $shot->toArray();
$meta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
$timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [];
$audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only'));
return [
'id' => (int) $data['id'],
'shot_no' => (int) $data['shot_no'],
'title' => (string) $data['title'],
'prompt' => (string) $data['prompt'],
'dialogue' => (string) ($data['dialogue'] ?? ''),
'dialogue_speaker' => (string) ($timeline['dialogue_speaker'] ?? ''),
'dialogue_delivery' => (string) ($timeline['dialogue_delivery'] ?? ''),
'audio_mode' => $audioMode,
'audio_mode_label' => match ($audioMode) {
VideoDubService::AUDIO_CHARACTER => '角色原声 · H3 口型同步',
VideoDubService::AUDIO_NARRATION => '画外旁白 · 后期精确普通话',
VideoDubService::AUDIO_SCENE => '原生场景声 · 无人物说话',
default => '干净环境音 · 无人物声音',
},
'scene' => (string) ($timeline['scene'] ?? ''),
'scene_id' => (string) ($timeline['scene_id'] ?? ''),
'source_excerpt' => (string) ($timeline['source_excerpt'] ?? ''),
'action_beats' => is_array($timeline['action_beats'] ?? null)
? array_values($timeline['action_beats'])
: [],
'character_state_before' => (string) ($timeline['character_state_before'] ?? ''),
'character_state_after' => (string) ($timeline['character_state_after'] ?? ''),
'continuity_from_previous' => (string) ($timeline['continuity_from_previous'] ?? ''),
'sound_effects' => is_array($timeline['sound_effects'] ?? null)
? array_values($timeline['sound_effects'])
: [],
'screen_event' => (string) ($timeline['screen_event'] ?? ''),
'screen_text' => (string) ($timeline['screen_text'] ?? ''),
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage(
(string) ($timeline['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE)
),
'analysis_source' => (string) ($meta['analysis_source'] ?? 'local'),
'voice_source' => (string) ($meta['voice_source'] ?? $timeline['voice_source'] ?? ''),
'workflow_version' => (string) ($meta['workflow_version'] ?? ''),
'workflow_settings' => is_array($meta['workflow_settings'] ?? null)
? $meta['workflow_settings']
: [],
'duration_seconds' => (int) $data['duration_seconds'],
'workflow_type' => (string) $data['workflow_type'],
'status' => (string) $data['status'],
'progress_text' => $this->shotProgressText((string) $data['status']),
'output_upload_id' => (int) ($data['output_upload_id'] ?? 0),
'output_url' => $this->uploadUrl((int) ($data['output_upload_id'] ?? 0)),
'error_message' => (string) ($data['error_message'] ?? ''),
];
}
private function uploadUrl(int $uploadId): ?string
{
if ($uploadId <= 0) {
return null;
}
$storedName = UploadFile::where('id', $uploadId)->value('stored_name');
return $storedName ? '/api/uploads/' . rawurlencode((string) $storedName) : null;
}
private function shotProgressText(string $status): string
{
return match ($status) {
'queued' => '排队中',
'running', 'waiting' => '正在生成',
'completed' => '已完成',
'error' => '需要处理',
default => '等待生成',
};
}
private function findProject(int $projectId, int $userId): ?VideoProject
{
return VideoProject::where('id', $projectId)->where('user_id', $userId)->find();
}
private function requireAccess(): mixed
{
if (!SettingsService::isFeatureEnabled('short_drama')) {
return $this->error('短剧工坊暂未开放', 404);
}
$user = $this->authUser();
if (GuestAccessService::isGuest($user)) {
return $this->error('请登录后使用短剧工坊', 403);
}
if (empty($user['id'])) {
return $this->error('登录状态已失效', 401);
}
return $user;
}
private function templates(): array
{
return [
['id' => 'urban_reverse', 'name' => '都市反转', 'icon' => '⚡', 'prompt' => '一段都市情感短剧,主角在最绝望时发现事情完全不是表面看到的那样,结尾强反转。'],
['id' => 'ancient_romance', 'name' => '古风情缘', 'icon' => '🌙', 'prompt' => '一段古风短剧,身份对立的两个人在危机中被迫联手,克制而有张力。'],
['id' => 'suspense', 'name' => '悬疑追凶', 'icon' => '🔍', 'prompt' => '一段节奏紧凑的悬疑短剧,主角发现一个不可能存在的线索,并在结尾揭开更大阴谋。'],
['id' => 'family', 'name' => '家庭情感', 'icon' => '🏠', 'prompt' => '一段真实克制的家庭情感短剧,通过一个生活细节揭示家人之间长期隐藏的爱与误解。'],
['id' => 'comedy', 'name' => '轻松搞笑', 'icon' => '😄', 'prompt' => '一段节奏明快的生活喜剧,主角为了掩盖一个小失误不断制造更大的误会。'],
];
}
}