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, `