2026-06-25 09:07:24 +08:00

471 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
type windowBounds struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Owner string `json:"owner"`
Title string `json:"title"`
ID int `json:"id"`
}
type streamState struct {
condition *sync.Cond
jpeg []byte
sequence int64
window windowBounds
startedAt time.Time
updatedAt time.Time
}
func newStreamState(window windowBounds) *streamState {
return &streamState{
condition: sync.NewCond(&sync.Mutex{}),
window: window,
startedAt: time.Now(),
}
}
func (state *streamState) update(jpeg []byte) {
state.condition.L.Lock()
state.jpeg = append(state.jpeg[:0], jpeg...)
state.sequence++
state.updatedAt = time.Now()
state.condition.Broadcast()
state.condition.L.Unlock()
}
func (state *streamState) snapshot() (int64, time.Time, windowBounds) {
state.condition.L.Lock()
defer state.condition.L.Unlock()
return state.sequence, state.updatedAt, state.window
}
func (state *streamState) waitNext(lastSequence int64) ([]byte, int64) {
state.condition.L.Lock()
defer state.condition.L.Unlock()
for state.sequence == lastSequence {
state.condition.Wait()
}
return append([]byte(nil), state.jpeg...), state.sequence
}
func main() {
if runtime.GOOS != "darwin" {
log.Fatal("this demo currently supports macOS only because it uses avfoundation + CoreGraphics")
}
var host string
var port int
var input string
var fps float64
var openBrowser bool
var waitWindow bool
flag.StringVar(&host, "host", "127.0.0.1", "HTTP server host")
flag.IntVar(&port, "port", 8765, "HTTP server port")
flag.StringVar(&input, "input", "3:none", "FFmpeg avfoundation input, e.g. 3:none")
flag.Float64Var(&fps, "fps", 15, "capture frame rate")
flag.BoolVar(&openBrowser, "open", true, "open browser automatically")
flag.BoolVar(&waitWindow, "wait", true, "wait until a visible WeChat window is found")
flag.Parse()
window, err := waitForWeChatWindow(waitWindow)
if err != nil {
log.Fatalf("find WeChat window failed: %v", err)
}
log.Printf("WeChat window: owner=%q title=%q id=%d rect=[%.0f,%.0f,%.0f,%.0f]", window.Owner, window.Title, window.ID, window.X, window.Y, window.Width, window.Height)
state := newStreamState(window)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runCapture(ctx, state, input, fps, window)
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", host, port),
Handler: handler(state),
ReadHeaderTimeout: 5 * time.Second,
}
url := fmt.Sprintf("http://%s:%d/", host, port)
log.Printf("Serving %s", url)
if openBrowser {
go func() {
time.Sleep(500 * time.Millisecond)
_ = exec.Command("open", url).Start()
}()
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func runCapture(ctx context.Context, state *streamState, input string, fps float64, window windowBounds) {
ffmpegCtx, cancelFFmpeg := context.WithCancel(ctx)
ffmpegErr := make(chan error, 1)
go func() {
ffmpegErr <- runFFmpeg(ffmpegCtx, state, input, fps, window)
}()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case <-ctx.Done():
cancelFFmpeg()
return
case err := <-ffmpegErr:
cancelFFmpeg()
log.Printf("FFmpeg stopped before first frame, fallback to screencapture: %v", err)
case <-timer.C:
sequence, _, _ := state.snapshot()
if sequence > 0 {
log.Printf("FFmpeg is producing frames")
if err := <-ffmpegErr; err != nil && ctx.Err() == nil {
log.Printf("FFmpeg stopped: %v", err)
}
return
}
cancelFFmpeg()
log.Printf("FFmpeg produced no frames in 3s, fallback to window-only screencapture")
}
if err := runScreencapture(ctx, state, fps, window); err != nil && ctx.Err() == nil {
log.Printf("screencapture stopped: %v", err)
}
}
func waitForWeChatWindow(wait bool) (windowBounds, error) {
for {
window, err := findWeChatWindow()
if err == nil {
return window, nil
}
if !wait {
return windowBounds{}, err
}
log.Printf("waiting for visible WeChat window: %v", err)
time.Sleep(2 * time.Second)
}
}
func findWeChatWindow() (windowBounds, error) {
script := `
import CoreGraphics
import Foundation
let options = CGWindowListOption(arrayLiteral: [.optionOnScreenOnly, .excludeDesktopElements])
guard let infos = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
exit(1)
}
for info in infos {
let owner = info[kCGWindowOwnerName as String] as? String ?? ""
let title = info[kCGWindowName as String] as? String ?? ""
let layer = info[kCGWindowLayer as String] as? Int ?? -1
if layer == 0 && (owner.contains("微信") || owner.lowercased().contains("wechat") || title == "微信") {
if let bounds = info[kCGWindowBounds as String] as? [String: Any],
let x = bounds["X"] as? Double,
let y = bounds["Y"] as? Double,
let w = bounds["Width"] as? Double,
let h = bounds["Height"] as? Double {
let id = info[kCGWindowNumber as String] as? Int ?? 0
let result: [String: Any] = ["x": x, "y": y, "width": w, "height": h, "owner": owner, "title": title, "id": id]
let data = try! JSONSerialization.data(withJSONObject: result)
print(String(data: data, encoding: .utf8)!)
exit(0)
}
}
}
exit(2)
`
command := exec.Command("swift", "-")
command.Stdin = strings.NewReader(script)
output, err := command.Output()
if err != nil {
return windowBounds{}, err
}
var window windowBounds
if err := json.Unmarshal(output, &window); err != nil {
return windowBounds{}, err
}
return window, nil
}
func runFFmpeg(ctx context.Context, state *streamState, input string, fps float64, window windowBounds) error {
args := []string{
"-hide_banner",
"-loglevel", "warning",
"-f", "avfoundation",
"-framerate", fmt.Sprintf("%.3f", fps),
"-pixel_format", "bgr0",
"-i", input,
"-vf", cropFilter(window),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-",
}
log.Printf("FFmpeg command: ffmpeg %s", strings.Join(args, " "))
command := exec.CommandContext(ctx, "ffmpeg", args...)
stdout, err := command.StdoutPipe()
if err != nil {
return err
}
stderr, err := command.StderrPipe()
if err != nil {
return err
}
if err := command.Start(); err != nil {
return err
}
go logLines("ffmpeg", stderr)
readErr := readJPEGFrames(stdout, state.update)
waitErr := command.Wait()
if ctx.Err() != nil {
return nil
}
if readErr != nil {
return readErr
}
return waitErr
}
func runScreencapture(ctx context.Context, state *streamState, fps float64, window windowBounds) error {
interval := time.Duration(float64(time.Second) / math.Max(fps, 1))
if interval < 80*time.Millisecond {
interval = 80 * time.Millisecond
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
tempDir, err := os.MkdirTemp("", "go-ffmpeg-wechat-stream-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
framePath := filepath.Join(tempDir, "frame.jpg")
rect := captureRect(window)
log.Printf("window-only screencapture fallback started window_id=%d rect=%s", window.ID, rect)
for ctx.Err() == nil {
command := exec.CommandContext(ctx, "screencapture", "-x", "-l", fmt.Sprintf("%d", window.ID), framePath)
if output, err := command.CombinedOutput(); err != nil {
if ctx.Err() != nil {
return nil
}
log.Printf("screencapture failed: %v output=%s", err, string(output))
} else if jpeg, err := os.ReadFile(framePath); err != nil {
log.Printf("read frame failed: %v", err)
} else if len(jpeg) > 0 {
state.update(jpeg)
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
return nil
}
func captureRect(window windowBounds) string {
x := int(math.Round(window.X))
y := int(math.Round(window.Y))
width := int(math.Round(window.Width))
height := int(math.Round(window.Height))
if x < 0 {
width += x
x = 0
}
if y < 0 {
height += y
y = 0
}
if width < 1 {
width = 1
}
if height < 1 {
height = 1
}
return fmt.Sprintf("%d,%d,%d,%d", x, y, width, height)
}
func cropFilter(window windowBounds) string {
width := int(math.Max(1, math.Round(window.Width)))
height := int(math.Max(1, math.Round(window.Height)))
x := int(math.Max(0, math.Round(window.X)))
y := int(math.Max(0, math.Round(window.Y)))
return fmt.Sprintf("crop=%d:%d:%d:%d", width, height, x, y)
}
func readJPEGFrames(reader io.Reader, onFrame func([]byte)) error {
buffer := make([]byte, 0, 1024*1024)
chunk := make([]byte, 64*1024)
for {
readCount, err := reader.Read(chunk)
if readCount > 0 {
buffer = append(buffer, chunk[:readCount]...)
for {
start := bytes.Index(buffer, []byte{0xff, 0xd8})
if start < 0 {
if len(buffer) > 1024*1024 {
buffer = buffer[len(buffer)-2:]
}
break
}
endOffset := bytes.Index(buffer[start+2:], []byte{0xff, 0xd9})
if endOffset < 0 {
if start > 0 {
buffer = buffer[start:]
}
break
}
end := start + 2 + endOffset + 2
onFrame(append([]byte(nil), buffer[start:end]...))
buffer = buffer[end:]
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
func handler(state *streamState) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(writer, `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Go FFmpeg WeChat Stream</title>
<style>
html, body { margin: 0; height: 100%; background: #050505; color: #d7ffe4; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr; }
.bar { display: flex; gap: 16px; align-items: center; padding: 10px 14px; background: #101510; border-bottom: 1px solid #263526; font-size: 13px; }
img { width: 100%; height: 100%; object-fit: contain; background: #000; }
</style>
</head>
<body>
<div class="wrap">
<div class="bar"><strong>Go FFmpeg WeChat Stream</strong><span id="status">connecting...</span></div>
<img src="/stream.mjpg" />
</div>
<script>
let prevSeq = null, prevTime = performance.now();
async function tick() {
try {
const res = await fetch('/latest.json?t=' + Date.now(), {cache: 'no-store'});
const data = await res.json();
const now = performance.now();
let fps = '--';
if (prevSeq !== null && data.sequence >= prevSeq) fps = ((data.sequence - prevSeq) / ((now - prevTime) / 1000)).toFixed(1);
prevSeq = data.sequence;
prevTime = now;
document.getElementById('status').textContent = 'seq=' + data.sequence + ' fps=' + fps + ' window=' + data.window.width + 'x' + data.window.height;
} catch (err) {
document.getElementById('status').textContent = 'waiting for stream';
}
}
tick(); setInterval(tick, 1000);
</script>
</body>
</html>`)
})
mux.HandleFunc("/latest.json", func(writer http.ResponseWriter, request *http.Request) {
sequence, updatedAt, window := state.snapshot()
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(writer).Encode(map[string]any{
"source": "go-ffmpeg-demo",
"sequence": sequence,
"updated_at": updatedAt.Format(time.RFC3339Nano),
"window": window,
})
})
mux.HandleFunc("/stream.mjpg", func(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "streaming unsupported", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
writer.Header().Set("Cache-Control", "no-store")
lastSequence := int64(-1)
for request.Context().Err() == nil {
jpeg, sequence := state.waitNext(lastSequence)
lastSequence = sequence
if len(jpeg) == 0 {
continue
}
if _, err := fmt.Fprintf(writer, "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n", len(jpeg)); err != nil {
return
}
if _, err := writer.Write(jpeg); err != nil {
return
}
if _, err := writer.Write([]byte("\r\n")); err != nil {
return
}
flusher.Flush()
}
})
return mux
}
func logLines(prefix string, reader io.Reader) {
buffer := make([]byte, 4096)
var line []byte
for {
count, err := reader.Read(buffer)
if count > 0 {
line = append(line, buffer[:count]...)
for {
index := bytes.IndexByte(line, '\n')
if index < 0 {
break
}
log.Printf("%s: %s", prefix, strings.TrimSpace(string(line[:index])))
line = line[index+1:]
}
}
if err != nil {
if len(line) > 0 {
log.Printf("%s: %s", prefix, strings.TrimSpace(string(line)))
}
return
}
}
}