Files
llm-intelligence/scripts/generate_video_digest.go

618 lines
17 KiB
Go
Raw Normal View History

//go:build llm_script
package main
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type reportRow struct {
Model string `json:"model"`
Provider string `json:"provider"`
Scene string `json:"scene"`
Input string `json:"input,omitempty"`
Output string `json:"output,omitempty"`
Context string `json:"context,omitempty"`
}
type dailyReport struct {
ReportDate string `json:"report_date"`
Stats map[string]string `json:"stats"`
International []reportRow `json:"international"`
Domestic []reportRow `json:"domestic"`
SourceReport string `json:"source_report"`
GeneratedAt string `json:"generated_at,omitempty"`
SelectedSection string `json:"selected_section,omitempty"`
}
type DigestCard struct {
Slug string `json:"slug"`
Title string `json:"title"`
Headline string `json:"headline"`
BulletLines []string `json:"bullet_lines"`
Narration string `json:"narration"`
FramePath string `json:"frame_path,omitempty"`
ScriptPath string `json:"script_path,omitempty"`
}
type digestManifest struct {
ReportDate string `json:"report_date"`
SourceReport string `json:"source_report"`
GeneratedAt string `json:"generated_at"`
OutputDir string `json:"output_dir"`
VideoPath string `json:"video_path"`
AudioPath string `json:"audio_path"`
Cards []DigestCard `json:"cards"`
}
var framePalette = color.Palette{
color.RGBA{12, 18, 28, 255},
color.RGBA{32, 48, 74, 255},
color.RGBA{91, 192, 190, 255},
color.RGBA{245, 247, 250, 255},
color.RGBA{255, 196, 61, 255},
color.RGBA{255, 107, 107, 255},
}
var slideBackgrounds = []uint8{1, 2, 1, 4, 5}
func main() {
var reportPath string
var reportDate string
var outputDir string
flag.StringVar(&reportPath, "report", "", "path to daily markdown report")
flag.StringVar(&reportDate, "date", "", "report date in YYYY-MM-DD")
flag.StringVar(&outputDir, "output-dir", "", "output directory for video digest artifacts")
flag.Parse()
resolvedReport, err := resolveReportPath(reportPath, reportDate)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
content, err := os.ReadFile(resolvedReport)
if err != nil {
fmt.Fprintf(os.Stderr, "read report failed: %v\n", err)
os.Exit(1)
}
report, err := parseDailyReport(content)
if err != nil {
fmt.Fprintf(os.Stderr, "parse report failed: %v\n", err)
os.Exit(1)
}
report.SourceReport = resolvedReport
if reportDate == "" {
reportDate = report.ReportDate
}
if reportDate == "" {
reportDate = time.Now().Format("2006-01-02")
}
cards := buildDigestCards(report)
if len(cards) == 0 {
fmt.Fprintln(os.Stderr, "no digest cards generated")
os.Exit(1)
}
if outputDir == "" {
outputDir = filepath.Join(filepath.Dir(resolvedReport), "video", reportDate)
}
manifest, err := generateDigestArtifacts(report, cards, outputDir)
if err != nil {
fmt.Fprintf(os.Stderr, "generate digest artifacts failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("report=%s\n", manifest.SourceReport)
fmt.Printf("output=%s\n", manifest.OutputDir)
fmt.Printf("cards=%d\n", len(manifest.Cards))
fmt.Printf("video=%s\n", manifest.VideoPath)
fmt.Printf("audio=%s\n", manifest.AudioPath)
}
func resolveReportPath(explicitPath string, reportDate string) (string, error) {
if explicitPath != "" {
return explicitPath, nil
}
root := "/home/long/project/llm-intelligence/reports/daily"
if reportDate != "" {
return filepath.Join(root, fmt.Sprintf("daily_report_%s.md", reportDate)), nil
}
matches, err := filepath.Glob(filepath.Join(root, "daily_report_*.md"))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", errors.New("no daily report markdown files found")
}
sort.Strings(matches)
return matches[len(matches)-1], nil
}
func parseDailyReport(content []byte) (dailyReport, error) {
report := dailyReport{
Stats: make(map[string]string),
}
lines := strings.Split(string(content), "\n")
section := ""
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if strings.HasPrefix(line, "**报告日期**:") {
report.ReportDate = strings.TrimSpace(strings.TrimPrefix(line, "**报告日期**:"))
continue
}
switch line {
case "## 📊 数据质量摘要":
section = "stats"
continue
case "## 🌍 国际推荐模型 TOP 5":
section = "international"
continue
case "## 🇨🇳 国内模型 TOP 10":
section = "domestic"
continue
}
if strings.HasPrefix(line, "## ") {
section = ""
continue
}
if !strings.HasPrefix(line, "|") || strings.Contains(line, "------") {
continue
}
parts := splitMarkdownTableLine(line)
switch section {
case "stats":
if len(parts) >= 2 && parts[0] != "指标" {
report.Stats[parts[0]] = parts[1]
}
case "international":
if row, ok := parseModelRow(parts); ok {
report.International = append(report.International, row)
}
case "domestic":
if row, ok := parseModelRow(parts); ok {
report.Domestic = append(report.Domestic, row)
}
}
}
if report.ReportDate == "" {
return report, errors.New("report date not found")
}
return report, nil
}
func splitMarkdownTableLine(line string) []string {
trimmed := strings.Trim(line, "|")
parts := strings.Split(trimmed, "|")
out := make([]string, 0, len(parts))
for _, part := range parts {
out = append(out, strings.TrimSpace(part))
}
return out
}
func parseModelRow(parts []string) (reportRow, bool) {
if len(parts) < 7 || parts[0] == "排名" {
return reportRow{}, false
}
return reportRow{
Model: parts[1],
Provider: parts[2],
Scene: parts[3],
Input: parts[4],
Output: parts[5],
Context: parts[6],
}, true
}
func buildDigestCards(report dailyReport) []DigestCard {
total := report.Stats["模型总数"]
cny := report.Stats["CNY定价"]
usd := report.Stats["USD定价"]
codeRows := pickSceneRows(report, "代码")
reasoningRows := pickSceneRows(report, "推理")
visionRows := pickSceneRows(report, "视觉")
cards := []DigestCard{
newDigestCard(
"code",
"Code Digest",
fmt.Sprintf("%s total models tracked", total),
codeRows,
fmt.Sprintf("Code digest highlights %d candidate models. CNY priced entries %s.", len(codeRows), cny),
),
newDigestCard(
"reasoning",
"Reasoning Digest",
fmt.Sprintf("USD priced entries %s", usd),
reasoningRows,
fmt.Sprintf("Reasoning digest focuses on %d reasoning oriented models.", len(reasoningRows)),
),
newDigestCard(
"vision",
"Vision Digest",
fmt.Sprintf("CNY priced entries %s", cny),
visionRows,
fmt.Sprintf("Vision digest contains %d multimodal candidates from the latest report.", len(visionRows)),
),
newDigestCard(
"domestic",
"Domestic Digest",
fmt.Sprintf("Domestic pricing entries %s", cny),
firstRows(report.Domestic, 3),
fmt.Sprintf("Domestic digest summarizes top local platforms with %d highlighted entries.", min(3, len(report.Domestic))),
),
newDigestCard(
"global",
"Global Digest",
fmt.Sprintf("Global pricing entries %s", usd),
firstRows(report.International, 3),
fmt.Sprintf("Global digest summarizes top international recommendations with %d highlighted entries.", min(3, len(report.International))),
),
}
return cards
}
func newDigestCard(slug string, title string, headline string, rows []reportRow, narration string) DigestCard {
bullets := make([]string, 0, len(rows))
for _, row := range rows {
bullets = append(bullets, fmt.Sprintf("%s - %s - %s", row.Model, row.Provider, row.Scene))
}
if len(bullets) == 0 {
bullets = append(bullets, "No matching models in current report")
}
return DigestCard{
Slug: slug,
Title: title,
Headline: headline,
BulletLines: bullets,
Narration: narration,
}
}
func pickSceneRows(report dailyReport, scene string) []reportRow {
rows := make([]reportRow, 0, 3)
for _, source := range [][]reportRow{report.Domestic, report.International} {
for _, row := range source {
if strings.Contains(row.Scene, scene) {
rows = append(rows, row)
}
if len(rows) == 3 {
return rows
}
}
}
return rows
}
func firstRows(rows []reportRow, n int) []reportRow {
if len(rows) < n {
n = len(rows)
}
out := make([]reportRow, 0, n)
for i := 0; i < n; i++ {
out = append(out, rows[i])
}
return out
}
func generateDigestArtifacts(report dailyReport, cards []DigestCard, outputDir string) (digestManifest, error) {
scriptDir := filepath.Join(outputDir, "scripts")
frameDir := filepath.Join(outputDir, "frames")
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
return digestManifest{}, err
}
if err := os.MkdirAll(frameDir, 0o755); err != nil {
return digestManifest{}, err
}
frames := make([]*image.Paletted, 0, len(cards))
delays := make([]int, 0, len(cards))
for i, card := range cards {
frame := renderCardFrame(card, i)
framePath := filepath.Join(frameDir, fmt.Sprintf("%02d_%s.png", i+1, card.Slug))
if err := writePNG(framePath, frame); err != nil {
return digestManifest{}, err
}
scriptPath := filepath.Join(scriptDir, fmt.Sprintf("%02d_%s.md", i+1, card.Slug))
if err := os.WriteFile(scriptPath, []byte(renderCardScript(card)), 0o644); err != nil {
return digestManifest{}, err
}
card.FramePath = framePath
card.ScriptPath = scriptPath
cards[i] = card
frames = append(frames, frame)
delays = append(delays, 120)
}
videoPath := filepath.Join(outputDir, "video_digest.gif")
if err := writeAnimatedGIF(videoPath, frames, delays); err != nil {
return digestManifest{}, err
}
audioData, err := buildNarrationAudio(cards)
if err != nil {
return digestManifest{}, err
}
audioPath := filepath.Join(outputDir, "narration.wav")
if err := os.WriteFile(audioPath, audioData, 0o644); err != nil {
return digestManifest{}, err
}
manifest := digestManifest{
ReportDate: report.ReportDate,
SourceReport: report.SourceReport,
GeneratedAt: time.Now().Format(time.RFC3339),
OutputDir: outputDir,
VideoPath: videoPath,
AudioPath: audioPath,
Cards: cards,
}
manifestPath := filepath.Join(outputDir, "manifest.json")
payload, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return digestManifest{}, err
}
if err := os.WriteFile(manifestPath, payload, 0o644); err != nil {
return digestManifest{}, err
}
return manifest, nil
}
func renderCardScript(card DigestCard) string {
var b strings.Builder
b.WriteString("# " + card.Title + "\n\n")
b.WriteString("## Headline\n")
b.WriteString("- " + card.Headline + "\n\n")
b.WriteString("## Narration\n")
b.WriteString("- " + card.Narration + "\n\n")
b.WriteString("## Bullet Lines\n")
for _, line := range card.BulletLines {
b.WriteString("- " + line + "\n")
}
return b.String()
}
func writePNG(path string, img image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
func writeAnimatedGIF(path string, frames []*image.Paletted, delays []int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return gif.EncodeAll(f, &gif.GIF{Image: frames, Delay: delays, LoopCount: 0})
}
func renderCardFrame(card DigestCard, index int) *image.Paletted {
rect := image.Rect(0, 0, 640, 360)
img := image.NewPaletted(rect, framePalette)
bg := slideBackgrounds[index%len(slideBackgrounds)]
draw.Draw(img, rect, &image.Uniform{framePalette[bg]}, image.Point{}, draw.Src)
fillRect(img, 18, 18, 622, 342, 0)
fillRect(img, 28, 28, 612, 88, bg)
fillRect(img, 28, 104, 612, 332, 1)
drawRasterText(img, 40, 42, 3, sanitizeFrameText(card.Title), 3)
drawRasterText(img, 40, 116, 2, sanitizeFrameText(card.Headline), 4)
for i, line := range firstStrings(card.BulletLines, 3) {
drawRasterText(img, 40, 160+i*42, 2, sanitizeFrameText(line), 3)
}
drawRasterText(img, 40, 302, 1, sanitizeFrameText("LLM INTELLIGENCE VIDEO DIGEST"), 4)
return img
}
func firstStrings(lines []string, n int) []string {
if len(lines) < n {
n = len(lines)
}
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, lines[i])
}
return out
}
func sanitizeFrameText(input string) string {
upper := strings.ToUpper(input)
var b strings.Builder
for _, r := range upper {
if _, ok := glyphs[r]; ok {
b.WriteRune(r)
continue
}
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteRune(' ')
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func fillRect(img *image.Paletted, x1 int, y1 int, x2 int, y2 int, idx uint8) {
for y := y1; y < y2; y++ {
for x := x1; x < x2; x++ {
img.SetColorIndex(x, y, idx)
}
}
}
func drawRasterText(img *image.Paletted, x int, y int, scale int, text string, idx uint8) {
cursor := x
for _, r := range text {
pattern, ok := glyphs[r]
if !ok {
pattern = glyphs[' ']
}
for row, bits := range pattern {
for col := 0; col < 5; col++ {
if bits&(1<<(4-col)) == 0 {
continue
}
fillRect(img, cursor+col*scale, y+row*scale, cursor+(col+1)*scale, y+(row+1)*scale, idx)
}
}
cursor += 6 * scale
}
}
func buildNarrationAudio(cards []DigestCard) ([]byte, error) {
const sampleRate = 16000
var pcm []int16
for i, card := range cards {
freq := 330.0 + float64(i)*55.0
duration := 0.9 + float64(len(card.BulletLines))*0.18
pcm = append(pcm, synthTone(freq, duration, sampleRate)...)
pcm = append(pcm, make([]int16, sampleRate/5)...)
}
return encodeWAV(pcm, sampleRate), nil
}
func synthTone(freq float64, duration float64, sampleRate int) []int16 {
samples := int(duration * float64(sampleRate))
out := make([]int16, 0, samples)
for i := 0; i < samples; i++ {
t := float64(i) / float64(sampleRate)
envelope := 1.0
if i < sampleRate/50 {
envelope = float64(i) / float64(sampleRate/50)
}
if i > samples-sampleRate/25 {
remaining := samples - i
if remaining > 0 {
envelope = minFloat(envelope, float64(remaining)/float64(sampleRate/25))
}
}
value := math.Sin(2*math.Pi*freq*t) + 0.35*math.Sin(2*math.Pi*(freq/2)*t)
out = append(out, int16(value*envelope*12000))
}
return out
}
func encodeWAV(samples []int16, sampleRate int) []byte {
const channels = 1
const bitsPerSample = 16
dataSize := len(samples) * 2
byteRate := sampleRate * channels * bitsPerSample / 8
blockAlign := channels * bitsPerSample / 8
var buf bytes.Buffer
buf.WriteString("RIFF")
_ = binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
_ = binary.Write(&buf, binary.LittleEndian, uint32(16))
_ = binary.Write(&buf, binary.LittleEndian, uint16(1))
_ = binary.Write(&buf, binary.LittleEndian, uint16(channels))
_ = binary.Write(&buf, binary.LittleEndian, uint32(sampleRate))
_ = binary.Write(&buf, binary.LittleEndian, uint32(byteRate))
_ = binary.Write(&buf, binary.LittleEndian, uint16(blockAlign))
_ = binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample))
buf.WriteString("data")
_ = binary.Write(&buf, binary.LittleEndian, uint32(dataSize))
for _, sample := range samples {
_ = binary.Write(&buf, binary.LittleEndian, sample)
}
return buf.Bytes()
}
func min(a int, b int) int {
if a < b {
return a
}
return b
}
func minFloat(a float64, b float64) float64 {
if a < b {
return a
}
return b
}
var glyphs = map[rune][7]uint8{
' ': {0, 0, 0, 0, 0, 0, 0},
'-': {0, 0, 0, 31, 0, 0, 0},
'.': {0, 0, 0, 0, 0, 12, 12},
':': {0, 12, 12, 0, 12, 12, 0},
'/': {1, 2, 4, 8, 16, 0, 0},
'+': {0, 4, 4, 31, 4, 4, 0},
'(': {2, 4, 8, 8, 8, 4, 2},
')': {8, 4, 2, 2, 2, 4, 8},
'0': {14, 17, 19, 21, 25, 17, 14},
'1': {4, 12, 4, 4, 4, 4, 14},
'2': {14, 17, 1, 2, 4, 8, 31},
'3': {30, 1, 1, 14, 1, 1, 30},
'4': {2, 6, 10, 18, 31, 2, 2},
'5': {31, 16, 16, 30, 1, 1, 30},
'6': {14, 16, 16, 30, 17, 17, 14},
'7': {31, 1, 2, 4, 8, 8, 8},
'8': {14, 17, 17, 14, 17, 17, 14},
'9': {14, 17, 17, 15, 1, 1, 14},
'A': {14, 17, 17, 31, 17, 17, 17},
'B': {30, 17, 17, 30, 17, 17, 30},
'C': {14, 17, 16, 16, 16, 17, 14},
'D': {28, 18, 17, 17, 17, 18, 28},
'E': {31, 16, 16, 30, 16, 16, 31},
'F': {31, 16, 16, 30, 16, 16, 16},
'G': {14, 17, 16, 16, 19, 17, 15},
'H': {17, 17, 17, 31, 17, 17, 17},
'I': {14, 4, 4, 4, 4, 4, 14},
'J': {7, 2, 2, 2, 18, 18, 12},
'K': {17, 18, 20, 24, 20, 18, 17},
'L': {16, 16, 16, 16, 16, 16, 31},
'M': {17, 27, 21, 17, 17, 17, 17},
'N': {17, 25, 21, 19, 17, 17, 17},
'O': {14, 17, 17, 17, 17, 17, 14},
'P': {30, 17, 17, 30, 16, 16, 16},
'Q': {14, 17, 17, 17, 21, 18, 13},
'R': {30, 17, 17, 30, 20, 18, 17},
'S': {15, 16, 16, 14, 1, 1, 30},
'T': {31, 4, 4, 4, 4, 4, 4},
'U': {17, 17, 17, 17, 17, 17, 14},
'V': {17, 17, 17, 17, 17, 10, 4},
'W': {17, 17, 17, 17, 21, 27, 17},
'X': {17, 17, 10, 4, 10, 17, 17},
'Y': {17, 17, 10, 4, 4, 4, 4},
'Z': {31, 1, 2, 4, 8, 16, 31},
}