diff --git a/config.yml b/config.yml index 457d915..a60664a 100644 --- a/config.yml +++ b/config.yml @@ -87,3 +87,8 @@ hyperframes: # 是否启用 headless 模式 headless: true +# HyperFrames 远程渲染服务配置(独立微服务) +hyperframes_service: + # 服务地址,留空则使用默认服务名 (http://127.0.0.1:3029) + address: "http://127.0.0.1:3029" + diff --git a/service/video/caption_service.go b/service/video/caption_service.go index ef5fdd2..7a345d2 100644 --- a/service/video/caption_service.go +++ b/service/video/caption_service.go @@ -1,12 +1,14 @@ package video import ( + "archive/zip" "bytes" "context" "encoding/json" "fmt" "html" "io" + "mime/multipart" "net/http" "os" "os/exec" @@ -205,10 +207,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs } g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath) - // 10. 执行 HyperFrames 渲染 - outputPath := filepath.Join(projectDir, "output.mp4") - if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil { - errMsg := fmt.Sprintf("视频渲染失败: %v", err) + // 10. 远程渲染:打包项目 → 上传 OSS → 调用 hyperframes-service → 下载结果 + outputPath := filepath.Join(projectDir, "hf_output.mp4") + if err := s.renderViaRemoteService(bgCtx, taskID, projectDir, outputPath); err != nil { + errMsg := fmt.Sprintf("远程渲染失败: %v", err) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg) if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil { g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err) @@ -266,56 +268,128 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs } } -// runHyperFramesRender 执行 HyperFrames 渲染 -func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error { - ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() +// ---------- 远程渲染(通过 hyperframes-service 微服务) ---------- - // 使用全局 hyperframes 命令(已在 setup 中安装检查) - hyperframesPath, lookErr := exec.LookPath("hyperframes") - if lookErr != nil { - return fmt.Errorf("hyperframes 未安装, 请运行: npm install -g hyperframes") +// renderViaRemoteService 通过远程 hyperframes-service 执行渲染 +// 流程:打包项目 ZIP → 直接 POST 给 hyperframes-service(multipart)→ 保存返回的 MP4 +func (s *captionService) renderViaRemoteService(ctx context.Context, taskID, projectDir, outputPath string) error { + // 1. 打包项目目录为 ZIP + zipPath := filepath.Join(projectDir, "project.zip") + if err := s.zipProjectDir(projectDir, zipPath); err != nil { + return fmt.Errorf("打包项目文件失败: %v", err) + } + defer os.Remove(zipPath) + + // 2. 获取 hyperframes-service 地址 + serviceAddr := g.Cfg().MustGet(ctx, "hyperframes_service.address", "").String() + if serviceAddr == "" { + serviceAddr = "http://127.0.0.1:3029" } - // 直接在工作目录运行 hyperframes render,输出默认 output.mp4 - cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render") - cmd.Dir = projectDir - cmd.Env = append(os.Environ(), - "HYPERFRAMES_HEADLESS=true", - "HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium", - ) + // 3. 将 ZIP 作为 multipart 发送到渲染服务(同步渲染) + var reqBuf bytes.Buffer + mw := multipart.NewWriter(&reqBuf) - output, err := cmd.CombinedOutput() + fw, err := mw.CreateFormFile("file", "project.zip") if err != nil { - return fmt.Errorf("hyperframes render 失败: %v\n%s", err, string(output)) + return fmt.Errorf("创建表单文件字段失败: %v", err) } - g.Log().Infof(ctx, "[HyperFrames] render 完成: %s", strings.TrimSpace(string(output))) - // 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置) - rendersDir := filepath.Join(projectDir, "renders") - if entries, readErr := os.ReadDir(rendersDir); readErr == nil { - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".mp4") { - src := filepath.Join(rendersDir, entry.Name()) - if err := os.Rename(src, outputPath); err != nil { - return fmt.Errorf("移动输出文件失败: %v", err) - } - g.Log().Infof(ctx, "[HyperFrames] 找到输出文件: %s", entry.Name()) - return nil - } + zipFile, err := os.Open(zipPath) + if err != nil { + return fmt.Errorf("打开ZIP文件失败: %v", err) + } + if _, err = io.Copy(fw, zipFile); err != nil { + zipFile.Close() + mw.Close() + return fmt.Errorf("写入ZIP内容失败: %v", err) + } + zipFile.Close() + mw.Close() + + g.Log().Infof(ctx, "[字幕 %s] 发送渲染请求, ZIP大小=%d", taskID, reqBuf.Len()) + + client := &http.Client{Timeout: 60 * time.Minute} + resp, err := client.Post(serviceAddr+"/render", mw.FormDataContentType(), &reqBuf) + if err != nil { + return fmt.Errorf("调用渲染服务失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("渲染服务返回错误(%d): %s", resp.StatusCode, string(body)) + } + + // 4. 保存响应体(MP4)到本地 + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("创建输出文件失败: %v", err) + } + defer outFile.Close() + + written, err := io.Copy(outFile, resp.Body) + if err != nil { + return fmt.Errorf("保存渲染结果失败: %v", err) + } + + g.Log().Infof(ctx, "[字幕 %s] ✅ 远程渲染完成, 输出=%s (%d bytes)", taskID, outputPath, written) + return nil +} + +// zipProjectDir 将项目目录打包为 ZIP 文件 +func (s *captionService) zipProjectDir(sourceDir, targetPath string) error { + zipFile, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("创建ZIP文件失败: %v", err) + } + defer zipFile.Close() + + writer := zip.NewWriter(zipFile) + defer writer.Close() + + return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err } - } - // 回退:检查 projectDir 下的 output.mp4 - fallback := filepath.Join(projectDir, "output.mp4") - if _, statErr := os.Stat(fallback); statErr == nil { - if err := os.Rename(fallback, outputPath); err != nil { - return fmt.Errorf("移动输出文件失败: %v", err) + // 跳过 ZIP 文件自身 + if path == targetPath { + return nil } - return nil - } - return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback) + // 计算相对路径 + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + + if info.IsDir() { + // 跳过空目录(HyperFrames 渲染只需要文件) + return nil + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return fmt.Errorf("创建ZIP头失败: %v", err) + } + header.Name = relPath + header.Method = zip.Deflate + + fw, err := writer.CreateHeader(header) + if err != nil { + return fmt.Errorf("创建ZIP条目失败: %v", err) + } + + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("打开文件失败: %v", err) + } + defer file.Close() + + _, err = io.Copy(fw, file) + return err + }) } // buildHTML 生成 HyperFrames HTML 模板