修复hyperframes渲染失败的问题
This commit is contained in:
+9
-12
@@ -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 的系统 Chromium(musl 版),而不是下载 chrome-headless-shell。
|
||||
# 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shell(glibc 构建),
|
||||
# 在 Alpine(musl 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
|
||||
|
||||
|
||||
@@ -86,4 +86,11 @@ hyperframes:
|
||||
render_timeout: 30
|
||||
# 是否启用 headless 模式
|
||||
headless: true
|
||||
# 浏览器可执行文件路径(非空且路径存在时设置 HYPERFRAMES_BROWSER_PATH,覆盖 HyperFrames 默认解析)。
|
||||
# Docker/Alpine 必须用系统 chromium:Alpine 是 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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 临时 profile(puppeteer_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: /tmp;Windows: 系统临时目录
|
||||
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...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user