修复hyperframes渲染失败的问题

This commit is contained in:
lmk
2026-08-15 17:50:57 +08:00
parent 9631a91658
commit a785930f21
6 changed files with 124 additions and 26 deletions
+9 -12
View File
@@ -60,18 +60,15 @@ RUN npm install -g hyperframes --ignore-scripts
# 安装 Chromium(提供系统级共享库依赖)和中文字体
RUN apk add --no-cache chromium font-noto-cjk fontconfig \
&& fc-cache -f
# 下载 HyperFrames 需要的 headless-shell 浏览器
# hyperframes browser ensure 硬编码 Google CDN,国内下载不可靠;
# 先用 @puppeteer/browsers CLI 从 npmmirror 镜像预置其缓存目录
# hyperframes 用同一套库读该缓存,命中后 browser ensure 不再下载)。
# 版本号必须与 hyperframes 固定版本 CHROME_VERSION 一致(hyperframes 升级需同步改)
# 镜像下载失败时 || true 回退,browser ensure 会改走 Google CDN
RUN node /usr/local/lib/node_modules/hyperframes/node_modules/@puppeteer/browsers/lib/main-cli.js install \
chrome-headless-shell@152.0.7928.2 \
--path /root/.cache/hyperframes/chrome \
--base-url https://npmmirror.com/mirrors/chrome-for-testing \
|| true
RUN npx hyperframes browser ensure
# HyperFrames 直接使用 Alpine 的系统 Chromiummusl 版),而不是下载 chrome-headless-shell。
# 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shellglibc 构建),
# 在 Alpinemusl libc)上无法加载:Error loading shared library
# ld-linux-x86-64.so.2: No such file or directory + symbol not found
# 上层 puppeteer 表现为 spawn ENOENT,渲染直接失败
# HYPERFRAMES_BROWSER_PATH 在 hyperframes 浏览器解析中优先级最高(env > 缓存 > 系统)
# 注意:系统 Chromium 捕获会回退到 screenshot 模式(比 beginFrame 略慢,但功能正常)。
# hyperframes 升级若改变浏览器解析逻辑,需同步检查此配置。
ENV HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium-browser
WORKDIR /app
+7
View File
@@ -86,4 +86,11 @@ hyperframes:
render_timeout: 30
# 是否启用 headless 模式
headless: true
# 浏览器可执行文件路径(非空且路径存在时设置 HYPERFRAMES_BROWSER_PATH,覆盖 HyperFrames 默认解析)。
# Docker/Alpine 必须用系统 chromiumAlpine 是 musl libc,而 hyperframes 默认下载的
# 官方 chrome-headless-shell 是 glibc 构建,musl 下无法加载(渲染报 spawn ENOENT / signal: killed)。
# 指向普通 Chromium 时 HyperFrames 会自动回退到 screenshot 捕获模式(比 headless-shell 略慢,功能正常)。
# Windows 本地无需改此值:该路径不存在时服务层会自动探测本机 Chrome/Chromium(含 %LOCALAPPDATA% 等标准路径),
# 避免 HyperFrames 回退到从 Google CDN 慢速下载;也可用系统环境变量 HYPERFRAMES_BROWSER_PATH 显式指定。
browser_path: "/usr/bin/chromium-browser"
+1
View File
@@ -31,6 +31,7 @@ func (c *template) CreateTemplate(ctx context.Context, req *dto.CreateTemplateTa
if taskErr != nil {
return nil, taskErr
}
g.Log().Infof(ctx, "[模板视频] 模板任务创建成功 taskId=%s(模板解析完成,转交异步渲染)", taskID)
return &dto.CreateTemplateTaskRes{TaskID: taskID}, nil
}
+87 -12
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -104,11 +105,13 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
// 2. 下载所有视频
var videoPaths []string
for i, videoURL := range videoURLs {
dlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr)
continue
}
g.Log().Infof(bgCtx, "[字幕 %s] 视频%d下载完成: %s, 耗时=%s", taskID, i, videoURL, time.Since(dlStart))
videoPaths = append(videoPaths, savePath)
}
if len(videoPaths) < 1 {
@@ -170,10 +173,12 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
// 7. 下载背景音乐(可选)
audioPath := ""
if audioURL != "" {
audioDlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir)
if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr)
} else {
g.Log().Infof(bgCtx, "[字幕 %s] 音频下载完成: %s, 耗时=%s", taskID, audioURL, time.Since(audioDlStart))
// 7.5 音频降噪:去除气口/口水音/底噪(时长不变)
denoisedPath := filepath.Join(projectDir, "denoised_audio.wav")
if err := s.denoiseAudio(bgCtx, savePath, denoisedPath); err != nil {
@@ -288,19 +293,43 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String()
env := os.Environ()
// 将 TMP/TEMP 设为与工作目录同盘,避免 Windows 跨驱动器符号链接失败
tmpDir := filepath.Join(projectDir, "hf_tmp")
if absDir, err := filepath.Abs(tmpDir); err == nil {
tmpDir = absDir
// 用短路径作为 hyperframes/Chrome 的 TMPDIR。
// 原因:Chrome 会在 TMPDIR 下创建 puppeteer 临时 profilepuppeteer_dev_chrome_profile-XXXXXX
// 及 Unix socket,而 sockaddr_un.sun_path 上限 108 字节。若 TMPDIR 过长(如
// /app/resource/temp/caption_cap_<36位taskID>/hf_tmp,加上 profile 后已 >108),socket 创建失败,
// 浏览器启动即崩溃 —— puppeteer 报 "Failed to launch the browser process: Code: null",渲染失败。
// Linux 直接用 /tmp(很短);Windows 保留"与工作目录同盘"约束(避免跨盘符号链接失败),
// 用项目盘符下的短路径。
tmpBase := os.TempDir() // Linux: /tmpWindows: 系统临时目录
if runtime.GOOS == "windows" {
if volume := filepath.VolumeName(projectDir); volume != "" {
tmpBase = filepath.Join(volume+string(os.PathSeparator), "hf_tmp")
}
}
os.MkdirAll(tmpDir, 0755)
env = append(env, "TMP="+tmpDir, "TEMP="+tmpDir, "TMPDIR="+tmpDir)
g.Log().Infof(ctx, "[HyperFrames] 设置 TMP=%s (避免跨盘符号链接问题)", tmpDir)
os.MkdirAll(tmpBase, 0755)
env = append(env, "TMP="+tmpBase, "TEMP="+tmpBase, "TMPDIR="+tmpBase)
g.Log().Infof(ctx, "[HyperFrames] 设置 TMP=%s (短路径,避免 Unix socket 超长; Windows 同盘避免跨盘符号链接)", tmpBase)
if headless {
env = append(env, "HYPERFRAMES_HEADLESS=true")
}
// 解析实际要用的浏览器路径:
// - 配置了 browser_path 且路径存在 → 直接用
// - 不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser)或未配置 → 自动探测本机系统
// Chrome/Chromium,避免 HyperFrames 回退到从 Google CDN 慢速下载(国内网络会长时间卡住)
resolvedBrowserPath := ""
if browserPath != "" {
env = append(env, "HYPERFRAMES_BROWSER_PATH="+browserPath)
if _, statErr := os.Stat(browserPath); statErr == nil {
resolvedBrowserPath = browserPath
} else {
g.Log().Warningf(ctx, "[HyperFrames] 配置的 hyperframes.browser_path=%s 不存在(%v),自动探测系统浏览器", browserPath, statErr)
}
}
if resolvedBrowserPath == "" {
resolvedBrowserPath = detectSystemChromePath()
}
if resolvedBrowserPath != "" {
env = append(env, "HYPERFRAMES_BROWSER_PATH="+resolvedBrowserPath)
g.Log().Infof(ctx, "[HyperFrames] 使用浏览器: %s", resolvedBrowserPath)
}
if ffmpegCfgPath != "" {
ffmpegDir := filepath.Dir(ffmpegCfgPath)
@@ -321,11 +350,19 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
}
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("hyperframes render 失败: %v\n%s", err, string(output))
// 实时转发 hyperframes 子进程输出到本服务 stdout:渲染可能长达数分钟,
// 若用 CombinedOutput 缓冲,控制台在渲染期间会长时间无输出,无法判断是否卡住。
// 输出同时保留到内存,供失败时诊断。
var renderOutput bytes.Buffer
cmd.Stdout = io.MultiWriter(os.Stdout, &renderOutput)
cmd.Stderr = io.MultiWriter(os.Stdout, &renderOutput)
renderStart := time.Now()
g.Log().Infof(ctx, "[HyperFrames] 开始渲染: 项目目录=%s, 渲染超时=%d分钟", projectDir, timeoutMin)
if err := cmd.Run(); err != nil {
return fmt.Errorf("hyperframes render 失败(耗时%s): %v\n%s", time.Since(renderStart), err, renderOutput.String())
}
g.Log().Infof(ctx, "[HyperFrames] render 完成: %s", strings.TrimSpace(string(output)))
g.Log().Infof(ctx, "[HyperFrames] render 完成(耗时%s): %s", time.Since(renderStart), strings.TrimSpace(renderOutput.String()))
// 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置)
rendersDir := filepath.Join(projectDir, "renders")
@@ -354,6 +391,38 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback)
}
// detectSystemChromePath 探测本机系统级 Chrome/Chromium 可执行文件路径。
// 用于配置的 browser_path 在当前平台不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser
// 或未配置时,自动找到可用的浏览器,避免 HyperFrames 回退到从 Google CDN 慢速下载。
func detectSystemChromePath() string {
// 优先级最高:显式设置的环境变量(用户可在 OS 层指定,Go 进程继承后这里能读到)
if p := os.Getenv("HYPERFRAMES_BROWSER_PATH"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
switch runtime.GOOS {
case "windows":
// Chrome 标准安装路径(用户级/系统级)
for _, p := range []string{
filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"),
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
case "linux":
for _, p := range []string{"/usr/bin/chromium-browser", "/usr/bin/chromium"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
return ""
}
// buildHTML 生成 HyperFrames HTML 模板
// videoPath: 已拼接好的单视频文件路径;totalDuration: 视频真实总时长(秒)
func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements []dto.CaptionElement, totalDuration float64, width, height int) string {
@@ -573,6 +642,12 @@ func (s *captionService) muteVideo(ctx context.Context, inputPath, outputPath st
"-c:v", "libx264", // 重新编码视频
"-crf", "23", // 画质(23=默认,越小越好)
"-preset", "veryfast", // 编码速度优先
// 固定关键帧间隔(30帧=1秒@30fps),避免产生稀疏关键帧:
// HyperFrames 对稀疏关键帧会告警 "sparse keyframes (max interval: Xs). This causes
// seek failures and frame freezing",浏览器在 probe 阶段 seek 时可能异常甚至崩溃
"-g", "30",
"-keyint_min", "30",
"-movflags", "+faststart", // MOOV 前置,便于浏览器快速播放
"-y", outputPath,
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
+13 -1
View File
@@ -764,6 +764,7 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
req.Header.Set("Accept", "*/*")
req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/")
dlStart := time.Now()
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
@@ -785,6 +786,13 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
}
expectedSize := resp.ContentLength
// 下载开始日志:大文件下载可能持续数分钟,先打出文件大小,避免控制台长时间无输出误判卡死
if expectedSize > 0 {
g.Log().Infof(ctx, "[下载] 开始下载: %s, 大小=%.1fMB", rawURL, float64(expectedSize)/1024/1024)
} else {
g.Log().Infof(ctx, "[下载] 开始下载: %s, 大小未知(分块传输)", rawURL)
}
out, err := os.Create(savePath)
if err != nil {
return "", err
@@ -794,7 +802,11 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
written, err := io.Copy(out, resp.Body)
if err != nil {
os.Remove(savePath)
return "", err
// 增强错误信息:给出已下载字节/总大小/耗时,unexpected EOF 时能看出下载到多少就断了
if expectedSize > 0 {
return "", fmt.Errorf("下载中断(已下载 %d/%d 字节, 耗时%s): %v", written, expectedSize, time.Since(dlStart), err)
}
return "", fmt.Errorf("下载中断(已下载 %d 字节, 耗时%s): %v", written, time.Since(dlStart), err)
}
// 验证下载完整性:检查文件大小是否匹配 Content-Length
+7 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
dto "media/model/dto/video"
@@ -42,11 +43,13 @@ func (s *templateService) CreateAsyncTask(ctx context.Context, videoURLs []strin
// 解析所有 HTML 模板为 CaptionElement
var allElements []dto.CaptionElement
for i, tmpl := range templates {
g.Log().Infof(ctx, "[模板解析] 处理模板%d: url=%s, start=%.2f, end=%.2f", i, tmpl.URL, tmpl.Start, tmpl.End)
tmplStart := time.Now()
g.Log().Infof(ctx, "[模板解析] 解析模板%d/%d: url=%s, start=%.2f, end=%.2f", i+1, len(templates), tmpl.URL, tmpl.Start, tmpl.End)
elements, err := s.parseSingleTemplate(ctx, tmpl, workDir)
if err != nil {
return "", fmt.Errorf("解析模板%d失败: %v", i, err)
}
g.Log().Infof(ctx, "[模板解析] 模板%d/%d 解析完成: 元素数=%d, 耗时=%s", i+1, len(templates), len(elements), time.Since(tmplStart))
allElements = append(allElements, elements...)
}
@@ -59,10 +62,13 @@ func (s *templateService) CreateAsyncTask(ctx context.Context, videoURLs []strin
// parseSingleTemplate 解析单个 HTML 模板,提取 CaptionElement 列表
func (s *templateService) parseSingleTemplate(ctx context.Context, tmpl dto.HtmlTemplate, downloadDir string) ([]dto.CaptionElement, error) {
// 1. 下载模板 HTML
dlStart := time.Now()
g.Log().Infof(ctx, "[模板解析] 开始下载模板HTML: %s", tmpl.URL)
htmlPath, err := downloadFile(ctx, tmpl.URL, downloadDir)
if err != nil {
return nil, fmt.Errorf("下载模板HTML失败: %v", err)
}
g.Log().Infof(ctx, "[模板解析] 模板HTML下载完成: %s, 耗时=%s", tmpl.URL, time.Since(dlStart))
// 2. 读取内容
htmlBytes, err := os.ReadFile(htmlPath)