unknown 6 dienas atpakaļ
vecāks
revīzija
b5c3d5028d
1 mainītis faili ar 644 papildinājumiem un 215 dzēšanām
  1. 644 215
      application/api/controller/WorkOrder.php

+ 644 - 215
application/api/controller/WorkOrder.php

@@ -12,6 +12,7 @@ use think\Log;
 use think\Queue;
 use think\queue\job\Redis;
 use think\Request;
+use app\api\controller\Common;
 
 class WorkOrder extends Api{
     protected $noNeedLogin = ['*'];
@@ -34,21 +35,6 @@ class WorkOrder extends Api{
      * task_id:查询获取任务图片
      */
     public function GetImageStatus(){
-//        //测试模拟返回前端图片数据
-//        $taskInfo = [
-//            'status' => 'completed',
-//            'image' => '/uploads/Product/img2img-20260317152818-69b902924548d.png',
-//            'image_url' => '/uploads/Product/img2img-20260317152818-69b902924548d.png',
-//            'completed_at' => date('Y-m-d H:i:s')
-//        ];
-//        $taskInfo['image'] = Common::ossFullUrl((string)$taskInfo['image']);
-//        $taskInfo['image_url'] = Common::ossFullUrl((string)$taskInfo['image_url']);
-//        $res = [
-//            'code' => 0,
-//            'msg' => '查询成功',
-//            'data' => $taskInfo
-//        ];
-//        return json($res);die;
 
         $params = $this->request->param();
         $taskId = $params['task_id'];
@@ -108,9 +94,7 @@ class WorkOrder extends Api{
         try {
             // 1. 获取并校验入参
             $params = $this->request->param();
-//            echo "<pre>";
-//            print_r($params);
-//            echo "<pre>";die;
+
             $statusVal = $this->validateAndGetStatusVal($params);
 
             // 2. 映射并校验处理方法
@@ -222,9 +206,7 @@ class WorkOrder extends Api{
      */
     private function handleAiTextToText(array $params): \think\response\Json
     {
-//        echo "<pre>";
-//        print_r($params);
-//        echo "<pre>";die;
+
         //构造生成提示词
         $promptTemplate = "\n请根据上述内容生成一段完整的话术,要求:\n"
             . "1. 内容必须是连贯的一段话,不要使用列表、分隔线或其他结构化格式\n"
@@ -279,73 +261,57 @@ class WorkOrder extends Api{
 
     /**
      * 即梦AI--创建视频任务接口
-     * 首帧图 + 尾帧图 = 新效果视频
+     * 支持:单张首帧图 / 首尾双帧图
+     * 图片入参:form-data 文件(first_image/last_image)、base64、或 http(s) URL
      */
     public function Create_ImgToVideo()
     {
-        $apiUrl = '';
-        $apiKey = '';
+        $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
+        $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
 
         $params = $this->request->param();
-        $modelParams = [
-            'resolution' => $params['resolution'] ?? '720p',        // 分辨率:480p, 720p, 1080p
-            'duration' => $params['duration'] ?? 5,                  // 视频时长(秒)
-            'camerafixed' => $params['camerafixed'] ?? false,        // 相机固定
-            'watermark' => $params['watermark'] ?? true,             // 水印
-            'aspect_ratio' => $params['aspect_ratio'] ?? '16:9'      // 视频比例:16:9, 4:3, 1:1等
-        ];
-        // 构建提示词
-        $prompt = $params['prompt'] ?? '';
-        $prompt .= ' --resolution ' . $modelParams['resolution'];
-        $prompt .= ' --duration ' . $modelParams['duration'];
-        $prompt .= ' --camerafixed ' . ($modelParams['camerafixed'] ? 'true' : 'false');
-        $prompt .= ' --watermark ' . ($modelParams['watermark'] ? 'true' : 'false');
-        $prompt .= ' --aspect_ratio ' . $modelParams['aspect_ratio'];
-
-        // 构建请求数据
+        $prompt = trim((string)($params['prompt'] ?? ''));
+        if ($prompt === '') {
+            return json(['code' => 0, 'msg' => 'prompt 不能为空']);
+        }
+
+        $firstError = '';
+        $firstFrame = $this->resolveFrameImagePayload($params, 'first', $firstError);
+        if (($firstFrame['api_url'] ?? '') === '') {
+            $hint = Common::isOssEnabled()
+                ? '请用 form-data 上传 first_image(类型选文件),并查看 runtime/log'
+                : '请在 application/config.php 配置 oss(accessKeyId、endpoint、bucket、host)';
+            $detail = $firstError !== '' ? '(' . $firstError . ')' : '';
+            return json(['code' => 0, 'msg' => '首帧图片无效或上传 OSS 失败。' . $hint . $detail]);
+        }
+
+        $lastFrame = $this->resolveFrameImagePayload($params, 'last');
+        $firstImageUrl = $firstFrame['api_url'];
+        $lastImageUrl = $lastFrame['api_url'];
+        $content = $this->buildImgToVideoContent($prompt, $firstImageUrl, $lastImageUrl);
+
         $data = [
-            'model' => 'doubao-seedance-1-0-pro-250528',//模型
-            'content' => [
-                [
-                    'type' => 'text',
-                    'text' => $prompt
-                ],
-                [
-                    'type' => 'image_url',
-                    'image_url' => [
-                        'url' => $params['first_image_url']// 首帧图片URL(参数)
-                    ],
-                    'role' => 'first_image'
-                ],
-                [
-                    'type' => 'image_url',
-                    'image_url' => [
-                        'url' => $params['last_image_url'] // 尾帧图片URL(参数)
-                    ],
-                    'role' => 'last_image'
-                ]
-            ]
+            'model' => 'doubao-seedance-1-5-pro-251215',
+            'content' => $content,
+            'generate_audio' => filter_var($params['generate_audio'] ?? true, FILTER_VALIDATE_BOOLEAN),
+            'ratio' => $params['ratio'] ?? $params['aspect_ratio'] ?? 'adaptive',
+            'duration' => (int)($params['duration'] ?? 5),
+            'watermark' => filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN),
         ];
-        // 转换为 JSON 字符串
-        $jsonData = json_encode($data);
 
-        // 初始化 cURL
         $ch = curl_init();
         curl_setopt($ch, CURLOPT_URL, $apiUrl);
         curl_setopt($ch, CURLOPT_POST, true);
-        curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
         curl_setopt($ch, CURLOPT_HTTPHEADER, [
             'Content-Type: application/json',
             'Authorization: Bearer ' . $apiKey
         ]);
         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
-        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 开发环境临时关闭SSL验证
-        curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 超时时间
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 60);
 
-        // 执行请求
         $response = curl_exec($ch);
-
-        // 检查 cURL 错误
         if (curl_errno($ch)) {
             $error = curl_error($ch);
             curl_close($ch);
@@ -353,27 +319,55 @@ class WorkOrder extends Api{
         }
         curl_close($ch);
 
-        // 解析响应
         $responseData = json_decode($response, true);
-
-        // 检查 API 错误
         if (isset($responseData['error'])) {
-            return json(['code' => 0, 'msg' => 'API 错误: ' . $responseData['error']['message']]);
+            $msg = $responseData['error']['message'] ?? 'API 请求失败';
+            return json(['code' => 0, 'msg' => 'API 错误: ' . $msg]);
         }
 
-        // 获取任务 ID
         $taskId = $responseData['id'] ?? '';
-        if (empty($taskId)) {
+        if ($taskId === '') {
             return json(['code' => 0, 'msg' => '获取任务 ID 失败']);
         }
 
-        // 返回结果
+        $firstImageUrl = $this->finalizeFrameImageToOss($firstFrame, $taskId);
+        $lastImageUrl = $this->finalizeFrameImageToOss($lastFrame, $taskId);
+
+        $videoData = [
+            'video_id' => $taskId,
+            'prompt' => $prompt,
+            'first_image_url' => $firstImageUrl,
+            'last_image_url' => $lastImageUrl,
+            'model' => $data['model'],
+            'seconds' => (string)$data['duration'],
+            'size' => (string)$data['ratio'],
+            'sys_rq' => date('Y-m-d H:i:s'),
+        ];
+
+        try {
+            Db::name('video')->insert($videoData);
+        } catch (Exception $e) {
+            return json([
+                'code' => 0,
+                'msg' => '任务已创建,但数据库保存失败',
+                'data' => [
+                    'task_id' => $taskId,
+                    'error_message' => $e->getMessage(),
+                ],
+            ]);
+        }
+
         return json([
             'code' => 1,
+            'msg' => '任务创建成功',
             'data' => [
                 'task_id' => $taskId,
+                'video_id' => $taskId,
                 'status' => $responseData['status'] ?? '',
-                'created_at' => $responseData['created_at'] ?? ''
+                'created_at' => $responseData['created_at'] ?? '',
+                'mode' => $lastImageUrl !== '' ? 'first_last_frame' : 'single_frame',
+                'first_image_url' => $firstImageUrl,
+                'last_image_url' => $lastImageUrl,
             ]
         ]);
     }
@@ -384,12 +378,12 @@ class WorkOrder extends Api{
      */
     public function Get_ImgToVideo()
     {
-        $apiUrl = '';
-        $apiKey = '';
+        $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
+        $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
 
         $params = $this->request->param();
-        $taskId = $params['task_id'] ?? '';
-        if (empty($taskId)) {
+        $taskId = $params['task_id'] ?? $params['video_id'] ?? '';
+        if ($taskId === '') {
             return json(['code' => 0, 'msg' => '任务 ID 不能为空']);
         }
 
@@ -420,9 +414,10 @@ class WorkOrder extends Api{
         // print_r($queryData);die;
 
         // 轮询任务状态,直到完成
-        $maxPolls = 30; // 最大轮询次数
+        $maxPolls = 30;
         $pollCount = 0;
-        $taskStatus = $queryData['status'] ?? '';
+        $pollData = is_array($queryData) ? $queryData : [];
+        $taskStatus = $pollData['status'] ?? '';
 
         while (!in_array($taskStatus, ['completed', 'succeeded']) && $pollCount < $maxPolls) {
             sleep(5); // 每5秒轮询一次
@@ -461,51 +456,96 @@ class WorkOrder extends Api{
             return json(['code' => 0, 'msg' => '任务执行超时']);
         }
 
-        // 获取视频 URL
-        $videoUrl = $pollData['content']['video_url'] ?? '';
-        if (empty($videoUrl)) {
+        // 获取视频 URL(兼容不同响应结构)
+        $videoUrl = $pollData['content']['video_url']
+            ?? $pollData['output']['video_url']
+            ?? $pollData['video_url']
+            ?? '';
+        if ($videoUrl === '') {
             return json(['code' => 0, 'msg' => '获取视频 URL 失败', 'data' => ['pollData' => $pollData]]);
         }
 
-        // 确保保存目录存在
-        $saveDir = ROOT_PATH . 'public/uploads/ceshi/';
+        $fileName = $this->sanitizeTaskIdSegment($taskId) . '.mp4';
+        $saveDir = $this->buildTaskMediaLocalDir($taskId);
         if (!is_dir($saveDir)) {
             mkdir($saveDir, 0755, true);
         }
 
-        // 生成保存文件名
-        $fileName =  $taskId . '.mp4';
-        $savePath = $saveDir . '/' . $fileName;
-
-        // 下载视频
-        // 设置超时时间为300秒(5分钟)
-        $context = stream_context_create(['http' => ['timeout' => 300]]);
-        $videoContent = file_get_contents($videoUrl, false, $context);
+        $savePath = $saveDir . $fileName;
+        $videoContent = $this->downloadRemoteFile($videoUrl);
         if ($videoContent === false) {
             return json(['code' => 0, 'msg' => '下载视频失败', 'data' => ['videoUrl' => $videoUrl]]);
         }
 
-        // 保存视频到文件
-        $saveResult = file_put_contents($savePath, $videoContent);
-        if ($saveResult === false) {
-            return json(['code' => 0, 'msg' => '保存视频失败', 'data' => ['savePath' => $savePath, 'dirWritable' => is_writable($saveDir)]]);
+        if (file_put_contents($savePath, $videoContent) === false) {
+            return json(['code' => 0, 'msg' => '保存视频失败', 'data' => ['savePath' => $savePath]]);
         }
 
-        // 生成视频的访问 URL
-        $videoAccessUrl = '/uploads/ceshi/' . $fileName;
+        $objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName);
+        $upload = $this->uploadToOSS($savePath, $objectKey);
+        if ($upload['success']) {
+            $webUrl = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
+        } else {
+            $webUrl = $objectKey;
+            Log::write('[Get_ImgToVideo] OSS上传失败,使用本地相对路径: ' . $objectKey, 'error');
+        }
+
+        try {
+            Db::name('video')->where('video_id', $taskId)->update(['web_url' => $webUrl]);
+        } catch (Exception $e) {
+            return json([
+                'code' => 0,
+                'msg' => '视频已生成,但数据库更新失败',
+                'data' => [
+                    'task_id' => $taskId,
+                    'web_url' => $webUrl,
+                    'error_message' => $e->getMessage(),
+                ],
+            ]);
+        }
 
-        // 返回结果
         return json([
             'code' => 1,
+            'msg' => '视频获取成功',
             'data' => [
                 'task_id' => $taskId,
+                'video_id' => $taskId,
                 'status' => $taskStatus,
-                'video_url' => $videoAccessUrl,
-                'save_path' => $savePath
-            ]
+                'web_url' => $webUrl,
+                'oss_object_key' => $upload['object_key'] ?? $objectKey,
+                'oss_uploaded' => $upload['success'] ?? false,
+                'local_path' => $objectKey,
+            ],
         ]);
     }
 
+    /**
+     * 下载远程文件(视频等)
+     * @return string|false
+     */
+    private function downloadRemoteFile(string $url)
+    {
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $url);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 300);
+        $body = curl_exec($ch);
+        if (curl_errno($ch)) {
+            Log::write('[downloadRemoteFile] ' . curl_error($ch) . ' | url=' . $url, 'error');
+            curl_close($ch);
+            return false;
+        }
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        curl_close($ch);
+        if ($httpCode < 200 || $httpCode >= 300 || $body === false) {
+            return false;
+        }
+        return $body;
+    }
+
     //获取视频列表
     public function Getvideolist(){
         if (!$this->request->isGet()) {
@@ -539,13 +579,13 @@ class WorkOrder extends Api{
      */
     //文生视频
     public function video(){
-        $apiUrl = '';
-        $apiKey = '';
+        $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
+        $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
 
         $params = $this->request->param();
         $postData = [
             'prompt' => $params['prompt'],
-            'model' => $params['model'],
+            'model' => 'doubao-seedance-1-5-pro-251215',
             'seconds' => $params['seconds'],
             'size' => $params['size'],
         ];
@@ -636,9 +676,7 @@ class WorkOrder extends Api{
             'size' => $postData['size'],
             'sys_rq' => date("Y-m-d H:i:s")
         ];
-        echo "<pre>";
-        print_r($videoData);
-        echo "<pre>";die;
+     
         // 尝试插入数据
         try {
             $res = Db::name('video')->insert($videoData);
@@ -838,9 +876,6 @@ class WorkOrder extends Api{
     }
 
 
-
-
-
     /**
      * 查询模版
      */
@@ -905,56 +940,6 @@ class WorkOrder extends Api{
         ]);
     }
 
-    /**
-     * 新增模版
-     * (暂时无用)
-     */
-//    public function Add_Product_Template(){
-//        try {
-//            $params = $this->request->param();
-//
-//            $chinese_description  = $params['chinese_description'] ?? '';
-//            $template_image_url = $params['template_image_url'] ?? '';
-//            $template_name = $params['template_name'] ?? '';
-//            $size = $params['size'] ?? '';
-//            $style = $params['style'] ?? '';
-//            $type = $params['type'] ?? '';
-//            $user_id = $params['user_id'] ?? '';
-//            $seconds = $params['seconds'] ?? '';
-//            $video_id = $params['video_id'] ?? '';
-//
-//            if (empty($template_name)) {
-//                return json(['code' => 1, 'msg' => '模板名称不能为空']);
-//            }
-//
-//            $data = [
-//                'toexamine' => "未审核",
-//                'chinese_description' => $chinese_description,
-//                'template_image_url' => $template_image_url,
-//                'template_name' => $template_name,
-//                'type' => $type,
-//                'style' => $style,
-//                'seconds' => $seconds,
-//                'size' => $size,
-//                'video_id' => $video_id,
-//                'user_id' => $user_id,
-//                'sys_rq' => date('Y-m-d'),
-//                'create_time' => date('Y-m-d H:i:s')
-//            ];
-//            $result = Db::name('product_template')->insert($data);
-//            if ($result) {
-//                return json(['code' => 0, 'msg' => '模板保存成功']);
-//            } else {
-//                return json(['code' => 1, 'msg' => '模板保存失败: 数据库操作未影响任何行']);
-//            }
-//        } catch (\Exception $e) {
-//            Log::record('模板保存异常: ' . $e->getMessage(), 'error');
-//            Log::record('异常堆栈: ' . $e->getTraceAsString(), 'error');
-//            return json(['code' => 1, 'msg' => '模板保存失败: ' . $e->getMessage()]);
-//        }
-//    }
-
-
 
     /**
      * 获取 AI 模型配置
@@ -1068,17 +1053,6 @@ class WorkOrder extends Api{
             $where['sys_id'] = ['like', '%' . $params['sys_id'] . '%'];
         }
 
-//        $isSuperAdmin = false;
-//        if (!empty($params['sys_id']) && $params['sys_id'] == '超级管理员') {
-//            $isSuperAdmin = true;
-//        }
-//
-//        if (!$isSuperAdmin && !empty($params['sys_id'])) {
-// 普通
-//        }else{
-//
-//        }
-
         $prompt = Db::name('product_image_generate')->field('prompt')->where($where)->group('prompt')->order('id desc')->select();
         $data = Db::name('product_image_generate')->where($where)->order('id desc')->page($page, $pageSize)->select();
 
@@ -1305,49 +1279,12 @@ class WorkOrder extends Api{
 
         $count = $redis->lLen($key_txttoimg) + $redis->lLen($key_txttotxt) + $redis->lLen($key_imgtotxt) + $redis->lLen($key_imgtoimg);
 
-//        if ($count === 0) {
-//            return json([
-//                'code' => 1,
-//                'msg'  => '暂无队列需要停止'
-//            ]);
-//        }
         return json([
             'code' => 0,
             'msg'  => '成功停止队列任务'
         ]);
     }
 
-    /**
-     * 开启队列任务
-     * 暂时用不到、服务器已开启自动开启队列模式
-     */
-//    public function kaiStats()
-//    {
-//        // 判断是否已有监听进程在运行
-//        $check = shell_exec("ps aux | grep 'queue:listen' | grep -v grep");
-//        if ($check) {
-//            return json([
-//                'code' => 1,
-//                'msg'  => '监听进程已存在,请勿重复启动'
-//            ]);
-//        }
-//        // 启动监听
-//        $command = 'nohup php think queue:listen --queue --timeout=300 --sleep=3 --memory=256 > /var/log/job_queue.log 2>&1 &';
-//        exec($command, $output, $status);
-//        if ($status === 0) {
-//            return json([
-//                'code' => 0,
-//                'msg'  => '队列监听已启动'
-//            ]);
-//        } else {
-//            return json([
-//                'code' => 1,
-//                'msg'  => '队列启动失败',
-//                'output' => $output
-//            ]);
-//        }
-//    }
-
     /**
      *获取服务器URL地址和端口 IP地址:端口
      * 用于获取图片路径拼接时
@@ -1367,4 +1304,496 @@ class WorkOrder extends Api{
         ];
         return json($res);
     }
-}
+
+
+
+    /**
+     * 构建图生视频 content:单帧无 role,双帧带 first_frame / last_frame
+     */
+    private function buildImgToVideoContent(string $prompt, string $firstImageUrl, string $lastImageUrl): array
+    {
+        $content = [
+            ['type' => 'text', 'text' => $prompt],
+        ];
+
+        if ($lastImageUrl !== '') {
+            $content[] = [
+                'type' => 'image_url',
+                'image_url' => ['url' => $firstImageUrl],
+                'role' => 'first_frame',
+            ];
+            $content[] = [
+                'type' => 'image_url',
+                'image_url' => ['url' => $lastImageUrl],
+                'role' => 'last_frame',
+            ];
+            return $content;
+        }
+
+        $content[] = [
+            'type' => 'image_url',
+            'image_url' => ['url' => $firstImageUrl],
+        ];
+        return $content;
+    }
+
+    /** 图生视频创建前,帧图暂存 OSS 子目录(AI 返回 id 后会迁移到正式目录) */
+    private const FRAME_OSS_STAGING_ID = '_staging';
+
+    /**
+     * 任务 id 用于路径的安全片段
+     */
+    private function sanitizeTaskIdSegment(string $taskId): string
+    {
+        $segment = preg_replace('/[\\\\\/:*?"<>|]/u', '_', trim($taskId));
+        return $segment !== '' ? $segment : '_unknown';
+    }
+
+    /**
+     * 图生视频任务媒体本地目录:public/uploads/videos/{日期}/{taskId}/
+     */
+    private function buildTaskMediaLocalDir(string $taskId): string
+    {
+        return str_replace('\\', '/', ROOT_PATH . 'public/uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/');
+    }
+
+    /**
+     * 图生视频任务媒体 OSS 对象键:uploads/videos/{日期}/{taskId}/{文件名}(帧图与生成视频同目录)
+     */
+    private function buildTaskMediaObjectKey(string $taskId, string $fileName): string
+    {
+        return 'uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/' . ltrim($fileName, '/');
+    }
+
+    /**
+     * 解析帧图片(含本地路径与暂存 OSS 信息,供创建任务后按 AI id 归档)
+     * @return array{type:string,api_url:string,local_path:string,staging_object_key:string}
+     */
+    private function resolveFrameImagePayload(array $params, string $role, string &$error = ''): array
+    {
+        $empty = ['type' => 'empty', 'api_url' => '', 'local_path' => '', 'staging_object_key' => ''];
+        $prefix = $role === 'last' ? 'last' : 'first';
+        $error = '';
+        $taskId = trim((string)($params['task_id'] ?? $params['video_id'] ?? ''));
+        $ossTaskId = $taskId !== '' ? $taskId : self::FRAME_OSS_STAGING_ID;
+
+        $uploadedFile = $this->request->file("{$prefix}_image");
+        if (!empty($uploadedFile)) {
+            $fileList = is_array($uploadedFile) ? $uploadedFile : [$uploadedFile];
+            foreach ($fileList as $file) {
+                if (!$file) {
+                    continue;
+                }
+                $fileError = '';
+                $result = $this->uploadFrameFileToOss($file, $prefix, $fileError, $ossTaskId);
+                if (($result['url'] ?? '') !== '') {
+                    return [
+                        'type' => 'local',
+                        'api_url' => $result['url'],
+                        'local_path' => $result['local_path'] ?? '',
+                        'staging_object_key' => $result['object_key'] ?? '',
+                    ];
+                }
+                if ($fileError !== '') {
+                    $error = $fileError;
+                }
+            }
+        }
+
+        $base64Keys = ["{$prefix}_image", "{$prefix}_image_base64"];
+        foreach ($base64Keys as $key) {
+            if (empty($params[$key]) || !is_string($params[$key])) {
+                continue;
+            }
+            $raw = trim($params[$key]);
+            if ($raw === '' || strlen($raw) < 50) {
+                continue;
+            }
+            $result = $this->uploadBase64ImageToOss($raw, $prefix, $ossTaskId);
+            if (($result['url'] ?? '') !== '') {
+                return [
+                    'type' => 'local',
+                    'api_url' => $result['url'],
+                    'local_path' => $result['local_path'] ?? '',
+                    'staging_object_key' => $result['object_key'] ?? '',
+                ];
+            }
+        }
+
+        $urlKey = "{$prefix}_image_url";
+        if (!empty($params[$urlKey])) {
+            $url = trim((string)$params[$urlKey]);
+            if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
+                return ['type' => 'external', 'api_url' => $url, 'local_path' => '', 'staging_object_key' => ''];
+            }
+        }
+
+        return $empty;
+    }
+
+    /**
+     * AI 返回任务 id 后,将本地帧图上传到 uploads/videos/{日期}/{taskId}/(与生成视频同目录)
+     */
+    private function finalizeFrameImageToOss(array $payload, string $taskId): string
+    {
+        if (($payload['type'] ?? '') === 'external') {
+            return (string)($payload['api_url'] ?? '');
+        }
+        if (($payload['type'] ?? '') !== 'local') {
+            return '';
+        }
+
+        $localPath = (string)($payload['local_path'] ?? '');
+        if ($localPath === '' || !is_file($localPath)) {
+            return (string)($payload['api_url'] ?? '');
+        }
+
+        $objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localPath));
+        $stagingKey = (string)($payload['staging_object_key'] ?? '');
+        if ($stagingKey !== '' && $stagingKey === $objectKey) {
+            return (string)($payload['api_url'] ?? '') ?: Common::ossFullUrl($objectKey);
+        }
+        $upload = $this->uploadToOSS($localPath, $objectKey);
+        if (!$upload['success']) {
+            Log::write('[finalizeFrameImageToOss] OSS上传失败: ' . $objectKey, 'error');
+            return (string)($payload['api_url'] ?? '');
+        }
+
+        if ($stagingKey !== '' && $stagingKey !== $objectKey) {
+            Common::deleteOssObject($stagingKey);
+        }
+
+        return $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
+    }
+
+    /**
+     * 解析帧图片:form-data 文件 > base64 > http(s) URL
+     */
+    private function resolveFrameImageUrl(array $params, string $role, string &$error = ''): string
+    {
+        $payload = $this->resolveFrameImagePayload($params, $role, $error);
+        return (string)($payload['api_url'] ?? '');
+    }
+
+    /**
+     * form-data 上传的图片落盘并同步 OSS
+     * @return array{url:string,local_path:string,object_key:string}
+     */
+    private function uploadFrameFileToOss($file, string $roleLabel, string &$error = '', string $taskId = ''): array
+    {
+        $empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
+        if ($taskId === '') {
+            $taskId = self::FRAME_OSS_STAGING_ID;
+        }
+        $error = '';
+        $ext = $this->resolveUploadedImageExt($file);
+        if ($ext === '') {
+            $error = '不支持的图片格式';
+            Log::write('[uploadFrameFileToOss] ' . $error, 'error');
+            return $empty;
+        }
+
+        $uploadInfo = $file->getInfo();
+        $tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : '';
+        if ($tmpPath === '' || !is_file($tmpPath)) {
+            $tmpPath = (string)($file->getRealPath() ?: '');
+        }
+        if ($tmpPath === '' || !is_file($tmpPath)) {
+            $uploadErr = $uploadInfo['error'] ?? UPLOAD_ERR_NO_FILE;
+            $error = '未接收到上传文件(错误码' . $uploadErr . ')';
+            Log::write('[uploadFrameFileToOss] ' . $error, 'error');
+            return $empty;
+        }
+
+        $saveDir = $this->buildTaskMediaLocalDir($taskId);
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        $saveFileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
+        $localFullPath = $saveDir . $saveFileName;
+        $saved = false;
+
+        if (method_exists($file, 'isValid') && $file->isValid()) {
+            $info = $file->move($saveDir, $saveFileName);
+            if ($info) {
+                $localFullPath = $saveDir . $info->getFilename();
+                $saved = true;
+            }
+        }
+        if (!$saved && !@copy($tmpPath, $localFullPath)) {
+            $moveErr = method_exists($file, 'getError') ? (string)$file->getError() : 'copy失败';
+            $error = '图片保存失败: ' . $moveErr;
+            Log::write('[uploadFrameFileToOss] ' . $error, 'error');
+            return $empty;
+        }
+
+        $objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localFullPath));
+        $upload = $this->uploadToOSS($localFullPath, $objectKey);
+        if (!$upload['success']) {
+            $error = 'OSS上传失败';
+            Log::write('[uploadFrameFileToOss] ' . $error . ' | ' . $objectKey, 'error');
+            return $empty;
+        }
+
+        $url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
+        return [
+            'url' => $url,
+            'local_path' => $localFullPath,
+            'object_key' => $upload['object_key'] ?? $objectKey,
+        ];
+    }
+
+    /**
+     * 解析上传图片扩展名
+     */
+    private function resolveUploadedImageExt($file): string
+    {
+        $name = $file->getInfo('name') ?? '';
+        $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
+        if ($ext === 'jpeg') {
+            $ext = 'jpg';
+        }
+        $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
+        if ($ext && in_array($ext, $allowed, true)) {
+            return $ext;
+        }
+        $mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : '';
+        $map = [
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/gif' => 'gif',
+            'image/webp' => 'webp',
+            'image/bmp' => 'bmp',
+        ];
+        return $map[$mime] ?? '';
+    }
+
+    /**
+     * base64 图片落盘并上传 OSS
+     * @return array{url:string,local_path:string,object_key:string}
+     */
+    private function uploadBase64ImageToOss(string $base64Input, string $roleLabel, string $taskId = ''): array
+    {
+        $empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
+        if ($taskId === '') {
+            $taskId = self::FRAME_OSS_STAGING_ID;
+        }
+        $parsed = $this->parseBase64Image($base64Input);
+        if ($parsed === null) {
+            return $empty;
+        }
+
+        [$ext, $imageData] = $parsed;
+        $saveDir = $this->buildTaskMediaLocalDir($taskId);
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        $fileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
+        $localFullPath = $saveDir . $fileName;
+        if (file_put_contents($localFullPath, $imageData) === false) {
+            Log::write('[uploadBase64ImageToOss] 本地保存失败: ' . $localFullPath, 'error');
+            return $empty;
+        }
+
+        $objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName);
+        $upload = $this->uploadToOSS($localFullPath, $objectKey);
+        if (!$upload['success']) {
+            Log::write('[uploadBase64ImageToOss] OSS上传失败: ' . $objectKey, 'error');
+            return $empty;
+        }
+
+        $url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
+        return [
+            'url' => $url,
+            'local_path' => $localFullPath,
+            'object_key' => $upload['object_key'] ?? $objectKey,
+        ];
+    }
+
+    /**
+     * 解析 base64 图片(支持 data URI 与纯 base64)
+     * @return array{0:string,1:string}|null [扩展名, 二进制内容]
+     */
+    private function parseBase64Image(string $base64Input): ?array
+    {
+        $base64Input = trim($base64Input);
+        if ($base64Input === '') {
+            return null;
+        }
+
+        if (preg_match('/^data:image\/(png|jpe?g|webp);base64,(.+)$/is', $base64Input, $m)) {
+            $ext = strtolower($m[1]) === 'jpeg' ? 'jpg' : strtolower($m[1]);
+            $raw = preg_replace('/\s+/', '', $m[2]);
+            $imageData = base64_decode($raw, true);
+        } else {
+            $raw = preg_replace('/\s+/', '', $base64Input);
+            $imageData = base64_decode($raw, true);
+            $ext = $imageData !== false ? $this->detectImageExtension($imageData) : '';
+        }
+
+        if ($imageData === false || strlen($imageData) < 100 || $ext === '') {
+            return null;
+        }
+
+        return [$ext, $imageData];
+    }
+
+    private function detectImageExtension(string $imageData): string
+    {
+        if (strncmp($imageData, "\x89PNG\r\n\x1a\n", 8) === 0) {
+            return 'png';
+        }
+        if (strncmp($imageData, "\xFF\xD8\xFF", 3) === 0) {
+            return 'jpg';
+        }
+        if (strlen($imageData) >= 12 && substr($imageData, 0, 4) === 'RIFF' && substr($imageData, 8, 4) === 'WEBP') {
+            return 'webp';
+        }
+        return 'jpg';
+    }
+
+    /**
+     * 将本地文件上传到阿里云 OSS
+     *
+     * @param string $file 本地完整路径,或 public 下相对路径(如 uploads/videos/20260604/xxx.mp4)
+     * @param string $objectKey OSS 对象键;传 uploads/ 开头或带扩展名则视为完整键,否则作为子目录标识(如 task_id)
+     * @return array{success:bool,object_key:string,url:string}
+     */
+    private function uploadToOSS(string $file, string $objectKey = ''): array
+    {
+        $localFullPath = $this->resolvePublicLocalPath($file);
+        if ($localFullPath === '') {
+            Log::write('[uploadToOSS] 本地文件不存在: ' . $file, 'error');
+            return ['success' => false, 'object_key' => '', 'url' => ''];
+        }
+
+        $fileName = basename($localFullPath);
+        if ($objectKey !== '' && (strpos($objectKey, 'uploads/') === 0 || preg_match('/\.[a-z0-9]{1,8}$/i', $objectKey))) {
+            $ossObjectKey = Common::normalizeOssObjectKey($objectKey);
+        } else {
+            $segments = ['uploads', 'videos', date('Ymd')];
+            if ($objectKey !== '') {
+                $segments[] = preg_replace('/[\\\\\/:*?"<>|]/u', '_', $objectKey);
+            }
+            $segments[] = $fileName;
+            $ossObjectKey = implode('/', $segments);
+        }
+
+        $success = $this->uploadLocalFileToAliyunOss($localFullPath, $ossObjectKey);
+        return [
+            'success' => $success,
+            'object_key' => $ossObjectKey,
+            'url' => $success ? Common::ossFullUrl($ossObjectKey) : '',
+        ];
+    }
+
+    /**
+     * 上传本地文件到阿里云 OSS(仅 WorkOrder 内实现,不修改 Common)
+     */
+    private function uploadLocalFileToAliyunOss(string $localFullPath, string $objectKey): bool
+    {
+        if (!Common::isOssEnabled() || !is_file($localFullPath)) {
+            return false;
+        }
+        $objectKey = Common::normalizeOssObjectKey($objectKey);
+        if ($objectKey === '') {
+            return false;
+        }
+
+        if (class_exists(\OSS\OssClient::class, true)) {
+            try {
+                $config = Common::getOssConfig();
+                $client = new \OSS\OssClient(
+                    $config['accessKeyId'],
+                    $config['accessKeySecret'],
+                    $config['endpoint']
+                );
+                $client->uploadFile($config['bucket'], $objectKey, $localFullPath);
+                return true;
+            } catch (\Throwable $e) {
+                Log::write('[uploadLocalFileToAliyunOss SDK] ' . $e->getMessage() . ' | ' . $objectKey, 'error');
+            }
+        }
+
+        return $this->putLocalFileToAliyunOssByCurl($localFullPath, $objectKey);
+    }
+
+    /**
+     * 无 OSS SDK 时通过 REST PUT 上传
+     */
+    private function putLocalFileToAliyunOssByCurl(string $localFullPath, string $objectKey): bool
+    {
+        $config = Common::getOssConfig();
+        $content = file_get_contents($localFullPath);
+        if ($content === false) {
+            return false;
+        }
+
+        $mime = function_exists('mime_content_type') ? (mime_content_type($localFullPath) ?: '') : '';
+        if ($mime === '') {
+            $ext = strtolower(pathinfo($localFullPath, PATHINFO_EXTENSION));
+            $mimeMap = [
+                'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
+                'gif' => 'image/gif', 'webp' => 'image/webp', 'mp4' => 'video/mp4',
+            ];
+            $mime = $mimeMap[$ext] ?? 'application/octet-stream';
+        }
+
+        $date = gmdate('D, d M Y H:i:s \G\M\T');
+        $bucket = $config['bucket'];
+        $endpoint = ltrim((string)$config['endpoint'], 'https://');
+        $endpoint = ltrim($endpoint, 'http://');
+        $canonicalizedResource = '/' . $bucket . '/' . $objectKey;
+        $stringToSign = "PUT\n\n{$mime}\n{$date}\n{$canonicalizedResource}";
+        $signature = base64_encode(hash_hmac('sha1', $stringToSign, $config['accessKeySecret'], true));
+        $urlPath = implode('/', array_map('rawurlencode', explode('/', $objectKey)));
+        $url = 'https://' . $bucket . '.' . $endpoint . '/' . $urlPath;
+
+        $ch = curl_init($url);
+        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
+        curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_HTTPHEADER, [
+            'Date: ' . $date,
+            'Content-Type: ' . $mime,
+            'Authorization: OSS ' . $config['accessKeyId'] . ':' . $signature,
+        ]);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 300);
+        $response = curl_exec($ch);
+        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $curlError = curl_error($ch);
+        curl_close($ch);
+
+        if ($httpCode >= 200 && $httpCode < 300) {
+            return true;
+        }
+
+        Log::write(
+            '[putLocalFileToAliyunOssByCurl] http=' . $httpCode
+            . ' err=' . $curlError
+            . ' resp=' . substr((string)$response, 0, 500)
+            . ' | objectKey=' . $objectKey,
+            'error'
+        );
+        return false;
+    }
+
+    /**
+     * 解析为 public 目录下的本地绝对路径
+     */
+    private function resolvePublicLocalPath(string $file): string
+    {
+        $file = str_replace('\\', '/', trim($file));
+        if ($file === '') {
+            return '';
+        }
+        if (is_file($file)) {
+            return $file;
+        }
+        $publicPath = str_replace('\\', '/', ROOT_PATH . 'public/' . ltrim($file, '/'));
+        return is_file($publicPath) ? $publicPath : '';
+    }
+}