471 lines
13 KiB
Go
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
|
|
}
|
|
}
|
|
}
|