1 Commits
Author SHA1 Message Date
lmk ff0beb0f34 HyperFrames独立出来 2026-06-29 10:28:38 +08:00
2 changed files with 122 additions and 43 deletions
+5
View File
@@ -87,3 +87,8 @@ hyperframes:
# 是否启用 headless 模式 # 是否启用 headless 模式
headless: true headless: true
# HyperFrames 远程渲染服务配置(独立微服务)
hyperframes_service:
# 服务地址,留空则使用默认服务名 (http://127.0.0.1:3029)
address: "http://127.0.0.1:3029"
+117 -43
View File
@@ -1,12 +1,14 @@
package video package video
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"os/exec" "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) g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath)
// 10. 执行 HyperFrames 渲染 // 10. 远程渲染:打包项目 → 上传 OSS → 调用 hyperframes-service → 下载结果
outputPath := filepath.Join(projectDir, "output.mp4") outputPath := filepath.Join(projectDir, "hf_output.mp4")
if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil { if err := s.renderViaRemoteService(bgCtx, taskID, projectDir, outputPath); err != nil {
errMsg := fmt.Sprintf("视频渲染失败: %v", err) errMsg := fmt.Sprintf("远程渲染失败: %v", err)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil { if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err) g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
@@ -266,56 +268,128 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
} }
} }
// runHyperFramesRender 执行 HyperFrames 渲染 // ---------- 远程渲染(通过 hyperframes-service 微服务) ----------
func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// 使用全局 hyperframes 命令(已在 setup 中安装检查) // renderViaRemoteService 通过远程 hyperframes-service 执行渲染
hyperframesPath, lookErr := exec.LookPath("hyperframes") // 流程:打包项目 ZIP → 直接 POST 给 hyperframes-servicemultipart)→ 保存返回的 MP4
if lookErr != nil { func (s *captionService) renderViaRemoteService(ctx context.Context, taskID, projectDir, outputPath string) error {
return fmt.Errorf("hyperframes 未安装, 请运行: npm install -g hyperframes") // 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 // 3. 将 ZIP 作为 multipart 发送到渲染服务(同步渲染)
cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render") var reqBuf bytes.Buffer
cmd.Dir = projectDir mw := multipart.NewWriter(&reqBuf)
cmd.Env = append(os.Environ(),
"HYPERFRAMES_HEADLESS=true",
"HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium",
)
output, err := cmd.CombinedOutput() fw, err := mw.CreateFormFile("file", "project.zip")
if err != nil { 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
}
}
} }
// 回退:检查 projectDir 下的 output.mp4 zipFile, err := os.Open(zipPath)
fallback := filepath.Join(projectDir, "output.mp4") if err != nil {
if _, statErr := os.Stat(fallback); statErr == nil { return fmt.Errorf("打开ZIP文件失败: %v", err)
if err := os.Rename(fallback, outputPath); err != nil {
return fmt.Errorf("移动输出文件失败: %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 return nil
} }
return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback) // 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
}
// 跳过 ZIP 文件自身
if path == targetPath {
return nil
}
// 计算相对路径
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 模板 // buildHTML 生成 HyperFrames HTML 模板