From a785930f2142290de3a8615e335788baf686478f Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Sat, 15 Aug 2026 17:50:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dhyperframes=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 21 +++--- config.yml | 7 ++ controller/video/template_controller.go | 1 + service/video/caption_service.go | 99 ++++++++++++++++++++++--- service/video/concat_service.go | 14 +++- service/video/template_service.go | 8 +- 6 files changed, 124 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index e7f91b7..5fb6a59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config.yml b/config.yml index 457d915..cc19b01 100644 --- a/config.yml +++ b/config.yml @@ -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" diff --git a/controller/video/template_controller.go b/controller/video/template_controller.go index f59773c..40ce71c 100644 --- a/controller/video/template_controller.go +++ b/controller/video/template_controller.go @@ -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 } diff --git a/service/video/caption_service.go b/service/video/caption_service.go index 0719cab..f39da40 100644 --- a/service/video/caption_service.go +++ b/service/video/caption_service.go @@ -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...) diff --git a/service/video/concat_service.go b/service/video/concat_service.go index bcfe0ed..b34eb8b 100644 --- a/service/video/concat_service.go +++ b/service/video/concat_service.go @@ -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 diff --git a/service/video/template_service.go b/service/video/template_service.go index a83cdb1..1dc7d3f 100644 --- a/service/video/template_service.go +++ b/service/video/template_service.go @@ -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)