render-narration.sh 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. #!/usr/bin/env bash
  2. # render-narration.sh · 一条龙:HTML 解说动画 → 最终 MP4(带人声)
  3. #
  4. # 流水线:
  5. # 1. render-video.js 录无声 MP4(按 timeline.totalDuration)
  6. # 2. mix-voiceover.sh 混入 voiceover.mp3(可选 BGM)
  7. # 3. 输出 <basename>-narrated.mp4
  8. #
  9. # Usage:
  10. # bash render-narration.sh <html> --timeline=<path> [options]
  11. #
  12. # Required:
  13. # <html> 解说动画的 HTML(应内嵌 NarrationStage + recording 模式 rAF 自驱)
  14. # --timeline=<path> timeline.json 路径(自动读 totalDuration 和 voiceover.mp3 路径)
  15. #
  16. # Optional:
  17. # --bgm-mood=<name> BGM 预设(educational / tech / tutorial / ...)
  18. # --bgm=<path> 自定义 BGM 文件
  19. # --bgm-volume=<0-1> BGM 静态音量,默认 0.18
  20. # --no-ducking 关 sidechain ducking
  21. # --keep-silent 保留中间产物(无声 MP4),便于 debug
  22. # --seek 用 render-video-seek.js 逐帧 seek 渲染(真 60fps·确定性·无黑帧)
  23. # --seek-fps=<n> seek 渲染帧率,默认 60,需配合 --seek
  24. # --out=<path> 输出路径,默认 <html-basename>-narrated.mp4
  25. # --width=<px> 视频宽度(默认 1920)
  26. # --height=<px> 视频高度(默认 1080)
  27. #
  28. # Examples:
  29. # bash render-narration.sh demo.html --timeline=_narration/timeline.json
  30. # bash render-narration.sh demo.html --timeline=_narration/timeline.json --bgm-mood=educational
  31. #
  32. set -e
  33. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  34. SKILL_ROOT="$SCRIPT_DIR/.."
  35. HTML=""
  36. TIMELINE=""
  37. BGM_MOOD=""
  38. BGM=""
  39. BGM_VOLUME="0.18"
  40. NO_DUCKING=""
  41. KEEP_SILENT=""
  42. USE_SEEK=""
  43. SEEK_FPS="60"
  44. OUT=""
  45. WIDTH="1920"
  46. HEIGHT="1080"
  47. for arg in "$@"; do
  48. case "$arg" in
  49. --timeline=*) TIMELINE="${arg#*=}" ;;
  50. --bgm-mood=*) BGM_MOOD="${arg#*=}" ;;
  51. --bgm=*) BGM="${arg#*=}" ;;
  52. --bgm-volume=*) BGM_VOLUME="${arg#*=}" ;;
  53. --no-ducking) NO_DUCKING="--no-ducking" ;;
  54. --keep-silent) KEEP_SILENT="1" ;;
  55. --seek) USE_SEEK="1" ;;
  56. --seek-fps=*) SEEK_FPS="${arg#*=}" ;;
  57. --out=*) OUT="${arg#*=}" ;;
  58. --width=*) WIDTH="${arg#*=}" ;;
  59. --height=*) HEIGHT="${arg#*=}" ;;
  60. -*) echo "未知参数:$arg" >&2; exit 1 ;;
  61. *) HTML="$arg" ;;
  62. esac
  63. done
  64. if [ -z "$HTML" ] || [ ! -f "$HTML" ]; then
  65. echo "Usage: bash render-narration.sh <html> --timeline=<path> [options]" >&2
  66. exit 1
  67. fi
  68. if [ -z "$TIMELINE" ] || [ ! -f "$TIMELINE" ]; then
  69. echo "✗ 缺 --timeline=<path>(timeline.json 由 narrate-pipeline.mjs 生成)" >&2
  70. exit 1
  71. fi
  72. # ── 从 timeline.json 读 totalDuration 和 voiceover 路径 ──
  73. TIMELINE_DIR="$(cd "$(dirname "$TIMELINE")" && pwd)"
  74. TOTAL_DURATION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$TIMELINE','utf8')).totalDuration)")
  75. VOICEOVER_REL=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$TIMELINE','utf8')).voiceover || 'voiceover.mp3')")
  76. VOICEOVER="$TIMELINE_DIR/$VOICEOVER_REL"
  77. if [ ! -f "$VOICEOVER" ]; then
  78. echo "✗ voiceover.mp3 不存在: $VOICEOVER" >&2
  79. exit 1
  80. fi
  81. # 录制时长 = 总时长 + 1s 安全缓冲
  82. RECORD_DURATION=$(node -e "console.log(Math.ceil($TOTAL_DURATION + 1))")
  83. HTML_ABS="$(cd "$(dirname "$HTML")" && pwd)/$(basename "$HTML")"
  84. HTML_DIR="$(dirname "$HTML_ABS")"
  85. HTML_BASE="$(basename "$HTML" .html)"
  86. SILENT_MP4="$HTML_DIR/$HTML_BASE.mp4"
  87. if [ -z "$OUT" ]; then
  88. OUT="$HTML_DIR/$HTML_BASE-narrated.mp4"
  89. fi
  90. echo "═══ render-narration ═══════════════════"
  91. echo " HTML: $HTML_ABS"
  92. echo " Timeline: $TIMELINE"
  93. echo " Voiceover: $VOICEOVER"
  94. echo " Total dur: ${TOTAL_DURATION}s (录 ${RECORD_DURATION}s)"
  95. echo " 尺寸: ${WIDTH}×${HEIGHT}"
  96. [ -n "$BGM_MOOD" ] && echo " BGM mood: $BGM_MOOD"
  97. [ -n "$BGM" ] && echo " BGM: $BGM"
  98. echo " 最终输出: $OUT"
  99. echo "════════════════════════════════════════"
  100. # ── Step 1: 录无声 MP4 ──────────────────────
  101. echo ""
  102. if [ -n "$USE_SEEK" ]; then
  103. echo "▸ Step 1/2 · 逐帧 seek 渲染 HTML 动画 (无声 · ${SEEK_FPS}fps 确定性)"
  104. NODE_PATH=$(npm root -g) node "$SCRIPT_DIR/render-video-seek.js" "$HTML_ABS" \
  105. --duration="$RECORD_DURATION" \
  106. --fps="$SEEK_FPS" \
  107. --width="$WIDTH" \
  108. --height="$HEIGHT"
  109. else
  110. echo "▸ Step 1/2 · 录制 HTML 动画 (无声)"
  111. NODE_PATH=$(npm root -g) node "$SCRIPT_DIR/render-video.js" "$HTML_ABS" \
  112. --duration="$RECORD_DURATION" \
  113. --width="$WIDTH" \
  114. --height="$HEIGHT"
  115. fi
  116. if [ ! -f "$SILENT_MP4" ]; then
  117. echo "✗ 无声 MP4 没生成: $SILENT_MP4" >&2
  118. exit 1
  119. fi
  120. # ── Step 2: 混入人声 ──────────────────────
  121. echo ""
  122. echo "▸ Step 2/2 · 混入人声"
  123. MIX_ARGS=("$SILENT_MP4" "--voiceover=$VOICEOVER" "--out=$OUT")
  124. [ -n "$BGM_MOOD" ] && MIX_ARGS+=("--bgm-mood=$BGM_MOOD")
  125. [ -n "$BGM" ] && MIX_ARGS+=("--bgm=$BGM")
  126. [ -n "$BGM_MOOD$BGM" ] && MIX_ARGS+=("--bgm-volume=$BGM_VOLUME")
  127. [ -n "$NO_DUCKING" ] && MIX_ARGS+=("$NO_DUCKING")
  128. bash "$SCRIPT_DIR/mix-voiceover.sh" "${MIX_ARGS[@]}"
  129. # 清理中间产物
  130. if [ -z "$KEEP_SILENT" ]; then
  131. rm -f "$SILENT_MP4"
  132. fi
  133. echo ""
  134. echo "✓ 完成: $OUT"
  135. [ -n "$KEEP_SILENT" ] && echo " (中间产物保留: $SILENT_MP4)"