Przeglądaj źródła

1.新增视频积分计费规则2.新增接口获取视频预扣费积分数

lh 1 miesiąc temu
rodzic
commit
e47c1d7b51

+ 45 - 18
app/Http/Controllers/Anime/AnimeController.php

@@ -681,18 +681,25 @@ class AnimeController extends BaseController
             Utils::throwError('20003:未找到分镜数据');
         }
         
-        // 余额预检:按实际需要生成的分镜数计算所需积分,不足直接报错
-        $needVideoCount = $segments->filter(function ($segment) {
-            return (int)$segment->current_type !== 2;
-        })->count();
-        if ($needVideoCount > 0) {
-            $needPoints = $this->pointsService->getVideoChargePoints([
+        // 余额预检:按实际需要生成的分镜逐条按时长估算积分,不足直接报错
+        $needPoints = 0;
+        foreach ($segments as $segment) {
+            if ((int)$segment->current_type === 2) {
+                continue;
+            }
+            $duration = (int)ceil((float)$segment->audio_duration);
+            if ($duration <= 0) {
+                $duration = 5; // 无音频时长时按默认5秒预估
+            }
+            $needPoints += $this->pointsService->getVideoChargePoints([
                 'model' => $model,
-                'video_resolution' => '720P',
-                'video_duration' => -1,
+                'video_resolution' => '720p',
+                'video_duration' => $duration,
                 'ratio' => $ratio,
                 'generate_audio' => (int)$generate_audio,
-            ]) * $needVideoCount;
+            ]);
+        }
+        if ($needPoints > 0) {
             $this->pointsService->checkUserPointsEnough($needPoints);
         }
         
@@ -2392,6 +2399,19 @@ class AnimeController extends BaseController
     }
 
     /**
+     * 预估视频生成所需积分(单一/批量)
+     * 传参与创建接口一致,批量场景需额外传 type=batch_segment / batch_act
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function previewVideoCharge(Request $request) {
+        $data = $request->all();
+        $result = $this->AnimeService->previewVideoCharge($data);
+        return $this->success($result);
+    }
+
+    /**
      * 批量生成片段视频
      */
     public function batchSetActVideos(Request $request) {
@@ -2554,18 +2574,25 @@ class AnimeController extends BaseController
             }
         }   
         
-        // 余额预检:按实际需要生成的片段数计算所需积分,不足直接报错
-        $needVideoCount = $acts->filter(function ($act) {
-            return $act->video_task_status !== '生成中' && (int)$act->current_type !== 2;
-        })->count();
-        if ($needVideoCount > 0) {
-            $needPoints = $this->pointsService->getVideoChargePoints([
+        // 余额预检:按实际需要生成的片段逐条按时长估算积分,不足直接报错
+        $needPoints = 0;
+        foreach ($acts as $act) {
+            if ($act->video_task_status === '生成中' || (int)$act->current_type === 2) {
+                continue;
+            }
+            $duration = (int)ceil((float)$act->act_duration);
+            if ($duration <= 0) {
+                $duration = 5; // 无时长时按默认5秒预估
+            }
+            $needPoints += $this->pointsService->getVideoChargePoints([
                 'model' => $model,
-                'video_resolution' => '720P',
-                'video_duration' => -1,
+                'video_resolution' => '720p',
+                'video_duration' => $duration,
                 'ratio' => $ratio,
                 'generate_audio' => (int)$generate_audio,
-            ]) * $needVideoCount;
+            ]);
+        }
+        if ($needPoints > 0) {
             $this->pointsService->checkUserPointsEnough($needPoints);
         }
         

+ 189 - 6
app/Services/Anime/AnimeService.php

@@ -4650,12 +4650,16 @@ class AnimeService
         // 音频传入的前提:必须有至少一张图片或一个视频
         $canUseAudio = ($hasImage || $hasVideo) && !empty($audioUrl);
 
-        // 余额预检:积分不足直接报错
+        // 余额预检:按预估时长计算所需积分,不足直接报错
+        $chargeDuration = (int)ceil((float)$audioDuration);
+        if ($chargeDuration <= 0) {
+            $chargeDuration = 5; // 无音频时长时按默认5秒预估
+        }
         $this->pointsService->checkUserPointsEnough(
             $this->pointsService->getVideoChargePoints([
                 'model' => $model,
-                'video_resolution' => $videoParams['video_resolution'] ?? '720P',
-                'video_duration' => $videoDuration,
+                'video_resolution' => strtolower($videoParams['video_resolution'] ?? '720P'),
+                'video_duration' => $chargeDuration,
                 'ratio' => $ratio,
                 'generate_audio' => $current_generate_audio,
             ])
@@ -4879,12 +4883,17 @@ class AnimeService
             'updated_at' => date('Y-m-d H:i:s')
         ]);
 
-        // 余额预检:积分不足直接报错
+        // 余额预检:按预估时长计算所需积分,不足直接报错
+        $chargeDuration = (int)$videoDuration;
+        if ($chargeDuration <= 0) {
+            $chargeDuration = 5; // 无时长时按默认5秒预估
+        }
+
         $this->pointsService->checkUserPointsEnough(
             $this->pointsService->getVideoChargePoints([
                 'model' => $model,
-                'video_resolution' => $video_resolution ?? '720P',
-                'video_duration' => $videoDuration,
+                'video_resolution' => strtolower($video_resolution ?? '720p'),
+                'video_duration' => $chargeDuration,
                 'ratio' => $ratio,
                 'generate_audio' => $current_generate_audio,
             ])
@@ -5062,6 +5071,180 @@ class AnimeService
     }
 
     /**
+     * 预估视频生成所需积分(支持单一/批量)
+     *
+     * 传参与对应创建接口一致,另支持 type 参数区分场景:
+     * segment-单分镜转视频、act-单片段转视频、batch_segment-分镜批量、batch_act-片段批量;
+     * type 缺省时按 segment_id / act_id 自动推断,批量场景需显式传入。
+     *
+     * @param array $data
+     * @return array
+     */
+    public function previewVideoCharge(array $data): array
+    {
+        $type = (string)getProp($data, 'type', '');
+        if (!$type) {
+            if (getProp($data, 'segment_id')) {
+                $type = 'segment';
+            } elseif (getProp($data, 'act_id')) {
+                $type = 'act';
+            } else {
+                Utils::throwError('1003:请传入type参数(segment/act/batch_segment/batch_act)');
+            }
+        }
+
+        $mode = (string)getProp($data, 'mode', 'video_generation');
+        $items = [];
+        $model = '';
+        $resolution = '720p';
+
+        $buildCharge = function ($itemId, $duration, $model, $resolution, $mode) {
+            return $this->pointsService->getVideoChargePoints([
+                'model' => $model,
+                'video_resolution' => $resolution,
+                'video_duration' => $duration,
+                'mode' => $mode,
+            ]);
+        };
+
+        if ($type === 'segment') {
+            // 单分镜转视频
+            $segmentId = getProp($data, 'segment_id');
+            if (!$segmentId) {
+                Utils::throwError('1002:分镜ID不能为空');
+            }
+            $segment = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->first();
+            if (!$segment) {
+                Utils::throwError('20003:分镜不存在');
+            }
+            $episode = DB::table('mp_anime_episodes')->where('id', $segment->episode_id)->first();
+            $model = getProp($data, 'model') ?: getProp($episode, 'video_model', 'doubao-seedance-1-5-pro-251215');
+            if (!DB::table('mp_video_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
+                $model = 'doubao-seedance-1-5-pro-251215';
+            }
+            $duration = (int)ceil((float)$segment->audio_duration);
+            if ($duration <= 0) {
+                $duration = 5;
+            }
+            $items[] = [
+                'id' => $segment->segment_id,
+                'duration' => $duration,
+                'points' => $buildCharge($segment->segment_id, $duration, $model, '720p', $mode),
+            ];
+        } elseif ($type === 'act') {
+            // 单片段转视频
+            $actId = getProp($data, 'act_id');
+            if (!$actId) {
+                Utils::throwError('1002:片段ID不能为空');
+            }
+            $act = DB::table('mp_episode_segments')->where('id', $actId)->first();
+            if (!$act) {
+                Utils::throwError('20003:片段不存在');
+            }
+            $episode = DB::table('mp_anime_episodes')->where('id', $act->episode_id)->first();
+            $model = getProp($data, 'model') ?: getProp($episode, 'video_model', 'zhizhen-20');
+            if (!DB::table('mp_video_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
+                $model = 'zhizhen-20';
+            }
+            $resolution = strtolower((string)getProp($data, 'video_resolution', ''));
+            if (!$resolution) {
+                $resolution = strtolower((string)getProp($episode, 'video_resolution', '720p'));
+            }
+            $duration = (int)getProp($data, 'video_duration', 0);
+            if ($duration <= 0) {
+                $duration = (int)ceil((float)$act->act_duration);
+            }
+            if ($duration <= 0) {
+                $duration = 5;
+            }
+            $items[] = [
+                'id' => $act->id,
+                'duration' => $duration,
+                'points' => $buildCharge($act->id, $duration, $model, $resolution, $mode),
+            ];
+        } elseif (in_array($type, ['batch_segment', 'batch_act'])) {
+            // 批量场景
+            $animeId = getProp($data, 'anime_id');
+            $episodeId = getProp($data, 'episode_id');
+            if (!$animeId || !$episodeId) {
+                Utils::throwError('1002:anime_id和episode_id不能为空');
+            }
+            $episode = DB::table('mp_anime_episodes')->where('id', $episodeId)->where('anime_id', $animeId)->first();
+            if (!$episode) {
+                Utils::throwError('20003:分集不存在');
+            }
+
+            if ($type === 'batch_segment') {
+                $model = getProp($data, 'model') ?: getProp($episode, 'video_model', 'doubao-seedance-1-5-pro-251215');
+                if (!DB::table('mp_video_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
+                    $model = 'doubao-seedance-1-5-pro-251215';
+                }
+                $segments = DB::table('mp_episode_segments')
+                    ->where('anime_id', $animeId)
+                    ->where('episode_id', $episodeId)
+                    ->whereNotIn('video_task_status', ['已完成', '生成中'])
+                    ->orderBy('segment_number')
+                    ->get();
+                foreach ($segments as $segment) {
+                    if ((int)$segment->current_type === 2) {
+                        continue;
+                    }
+                    $duration = (int)ceil((float)$segment->audio_duration);
+                    if ($duration <= 0) {
+                        $duration = 5;
+                    }
+                    $items[] = [
+                        'id' => $segment->segment_id,
+                        'duration' => $duration,
+                        'points' => $buildCharge($segment->segment_id, $duration, $model, '720p', $mode),
+                    ];
+                }
+            } else {
+                // 片段批量固定智帧20
+                $model = 'zhizhen-20';
+                $acts = DB::table('mp_episode_segments')
+                    ->where('anime_id', $animeId)
+                    ->where('episode_id', $episodeId)
+                    ->orderBy('act_number')
+                    ->get();
+                foreach ($acts as $act) {
+                    if ($act->video_task_status === '生成中' || (int)$act->current_type === 2) {
+                        continue;
+                    }
+                    $duration = (int)ceil((float)$act->act_duration);
+                    if ($duration <= 0) {
+                        $duration = 5;
+                    }
+                    $items[] = [
+                        'id' => $act->id,
+                        'duration' => $duration,
+                        'points' => $buildCharge($act->id, $duration, $model, '720p', $mode),
+                    ];
+                }
+            }
+        } else {
+            Utils::throwError('1003:type参数不正确');
+        }
+
+        $totalPoints = 0;
+        foreach ($items as $item) {
+            $totalPoints += $item['points'];
+        }
+
+        return [
+            'type' => $type,
+            'model' => $model,
+            'video_resolution' => $resolution,
+            'mode' => $mode,
+            'count' => count($items),
+            'total_points' => $totalPoints,
+            'items' => $items,
+            'points_balance' => $this->pointsService->getUserPointsBalance(),
+            'remark' => '积分为预估值,按预估时长计算;实际扣费以视频生成完成后的实际时长为准',
+        ];
+    }
+
+    /**
      * 根据提示词内容智能计算最优视频时长
      * 
      * @param string $segmentContent 分镜内容

+ 81 - 6
app/Services/PointsService.php

@@ -18,22 +18,97 @@ class PointsService
 {
     /**
      * 视频生成单次默认扣费积分数
-     * TODO: 后续接入积分映射表,根据 model / video_resolution / 视频时长 等计算具体积分
+     * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
      */
     const DEFAULT_VIDEO_CHARGE_POINTS = 0;
 
     /**
-     * 获取视频生成应扣积分数(公共方法)
+     * 获取视频模型应扣积分数(公共方法)
      *
-     * 后续积分映射表上线后,只需调整此方法的实现,业务调用方无需改动。
+     * 计费规则从 mp_video_models 表读取:
+     * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
+     * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
+     * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
      *
-     * @param array $chargeInfo 计费信息(user_id、model、video_resolution、video_duration 等)
+     * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
      * @return int
      */
     public function getVideoChargePoints(array $chargeInfo = []): int
     {
-        // TODO: 根据映射表(model + video_resolution + video_duration)查询具体积分数
-        return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        $model = (string)getProp($chargeInfo, 'model', '');
+        $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
+        $duration = (int)getProp($chargeInfo, 'video_duration', 0);
+        if ($duration <= 0) {
+            $duration = 1; // 时长未知时按1秒兜底
+        }
+        $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
+
+        // 从 mp_video_models 表读取计费规则
+        $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
+        if (!$modelRow || empty($modelRow->charge_type)) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        $priceJson = $modelRow->price_json;
+        $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
+        if (!is_array($priceRule) || empty($priceRule)) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        // 按次计费(如 Gemini 视频理解:每次固定积分)
+        if ($modelRow->charge_type === 'per_call') {
+            $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
+            return (int)max(1, round($price));
+        }
+
+        // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
+        $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
+        if (!is_array($prices) || empty($prices)) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
+        if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        return (int)max(1, round((float)$pricePerSecond * $duration));
+    }
+
+    /**
+     * 将分辨率归一化为计费档位
+     *
+     * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
+     * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
+     *
+     * @param string $resolution
+     * @return string
+     */
+    private function normalizeResolutionKey(string $resolution): string
+    {
+        $resolution = strtolower(trim($resolution));
+        switch ($resolution) {
+            case '4k':
+            case '2160p':
+            case '4096x2160':
+                return '4k';
+            case '1080p':
+                return '1080p';
+            case '720p':
+                return '720p';
+            case '480p':
+                return '480p';
+            case 'sr_720p':
+            case 'sr720p':
+            case '超分720p':
+                return '480p';
+            case 'sr_1080p':
+            case 'sr1080p':
+            case '超分1080p':
+                return 'sr_1080p';
+            default:
+                return $resolution;
+        }
     }
 
     /**

+ 110 - 0
database/migrations/2026_08_04_000001_add_charge_config_to_video_models_table.php

@@ -0,0 +1,110 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class AddChargeConfigToVideoModelsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('mp_video_models', function (Blueprint $table) {
+            $table->string('charge_type', 20)->default('per_second')->comment('计费方式:per_second-按秒计费, per_call-按次计费');
+            $table->json('price_json')->nullable()->comment('计费规则JSON:按场景+分辨率的价格表,如 {"video_generation":{"720p":10,"480p":6,"sr_1080p":10,...}}(480p即“超分720p”档);按次计费为 {"price":5}');
+        });
+
+        // seedance1.5 pro(豆包)
+        $this->updatePrice('doubao-seedance-1-5-pro-251215', 'per_second', [
+            'video_generation' => ['720p' => 4, '1080p' => 8],
+        ]);
+
+        // seedance2.0(豆包)
+        $this->updatePrice('doubao-seedance-2-0-260128', 'per_second', [
+            'video_generation' => ['720p' => 10, '480p' => 6, 'sr_1080p' => 10, '1080p' => 25, '4k' => 50],
+            'video_to_video' => ['480p' => 12, '720p' => 20, 'sr_1080p' => 20, '1080p' => 50, '4k' => 100],
+        ]);
+
+        // seedance2.0 fast(豆包)
+        $this->updatePrice('doubao-seedance-2-0-fast-260128', 'per_second', [
+            'video_generation' => ['720p' => 8],
+        ]);
+
+        // seedance2.0(智帧=zhizhen-20)
+        $this->updatePrice('zhizhen-20', 'per_second', [
+            'video_generation' => ['720p' => 10, '480p' => 6, 'sr_1080p' => 10, '1080p' => 25, '4k' => 50],
+            'video_to_video' => ['480p' => 12, '720p' => 20, 'sr_1080p' => 20, '1080p' => 50, '4k' => 100],
+        ]);
+
+        // seedance2.0 fast(智帧)
+        $this->updatePrice('zhizhen-20-fast', 'per_second', [
+            'video_generation' => ['720p' => 8],
+        ]);
+
+        // seedance2.0 mini(智帧)
+        $this->updatePrice('zhizhen-20-mini', 'per_second', [
+            'video_generation' => ['720p' => 6],
+        ]);
+
+        // Gemini 视频理解模型(暂未接入,先预设按次计费,is_enabled=0 不对外展示)
+        $geminiModels = [
+            ['model' => 'gemini-3.1-flash', 'name' => 'Gemini3.1flash', 'price' => 5],
+            ['model' => 'gemini-3.1-pro', 'name' => 'Gemini3.1pro', 'price' => 5],
+            ['model' => 'gemini-3.0-flash', 'name' => 'Gemini3.0flash', 'price' => 3],
+            ['model' => 'gemini-3.0-pro', 'name' => 'Gemini3.0pro', 'price' => 3],
+            ['model' => 'gemini-3.6-flash', 'name' => 'Gemini3.6flash', 'price' => 10],
+        ];
+        foreach ($geminiModels as $gemini) {
+            DB::table('mp_video_models')->updateOrInsert(
+                ['model' => $gemini['model']],
+                [
+                    'name' => $gemini['name'],
+                    'description' => 'Google Gemini 视频理解(暂未接入)',
+                    'is_enabled' => 0,
+                    'order' => 0,
+                    'is_multimodal' => 1,
+                    'charge_type' => 'per_call',
+                    'price_json' => json_encode(['price' => $gemini['price']], JSON_UNESCAPED_UNICODE),
+                ]
+            );
+        }
+    }
+
+    /**
+     * 更新模型的计费配置
+     *
+     * @param string $model
+     * @param string $chargeType
+     * @param array $priceJson
+     * @return void
+     */
+    private function updatePrice(string $model, string $chargeType, array $priceJson)
+    {
+        DB::table('mp_video_models')->where('model', $model)->update([
+            'charge_type' => $chargeType,
+            'price_json' => json_encode($priceJson, JSON_UNESCAPED_UNICODE),
+        ]);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        // 删除预设的 Gemini 模型
+        DB::table('mp_video_models')->whereIn('model', [
+            'gemini-3.1-flash', 'gemini-3.1-pro', 'gemini-3.0-flash', 'gemini-3.0-pro', 'gemini-3.6-flash',
+        ])->delete();
+
+        Schema::table('mp_video_models', function (Blueprint $table) {
+            $table->dropColumn(['charge_type', 'price_json']);
+        });
+    }
+}

+ 1 - 0
routes/api.php

@@ -214,6 +214,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::get('saveActVideoGenerateParams', [AnimeController::class, 'saveActVideoGenerateParams']);   // 保存生成视频参数
             Route::post('createActVideoTask', [AnimeController::class, 'createActVideoTask']);          // 片段转视频
             Route::post('batchSetActVideos', [AnimeController::class, 'batchSetActVideos']);            // 片段一键转视频
+            Route::post('previewVideoCharge', [AnimeController::class, 'previewVideoCharge']);          // 预估视频生成积分(单一/批量)
             
             // 完整视频合成任务
             Route::get('createCompleteVideoTask', [AnimeController::class, 'createCompleteVideoTask']);