669 lines
19 KiB
Go
669 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
_ "image/jpeg"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type visionFrameState struct {
|
|
condition *sync.Cond
|
|
jpeg []byte
|
|
frameWidth int
|
|
frameHeight int
|
|
sequence int64
|
|
frameIndex int64
|
|
updatedAt time.Time
|
|
target previewTarget
|
|
annotation annotationMeta
|
|
event map[string]any
|
|
}
|
|
|
|
type annotationMeta struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
ScaleFactor float64 `json:"scaleFactor"`
|
|
}
|
|
|
|
type captureSource struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Label string `json:"label"`
|
|
AppName string `json:"appName"`
|
|
Title string `json:"title"`
|
|
PID int `json:"pid"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
ScaleFactor float64 `json:"scaleFactor"`
|
|
}
|
|
|
|
type previewTarget struct {
|
|
Kind string
|
|
Label string
|
|
WindowID int
|
|
Rect WindowBounds
|
|
}
|
|
|
|
func newVisionFrameState(target previewTarget) *visionFrameState {
|
|
return &visionFrameState{
|
|
condition: sync.NewCond(&sync.Mutex{}),
|
|
target: target,
|
|
event: map[string]any{"type": "waiting"},
|
|
}
|
|
}
|
|
|
|
func (state *visionFrameState) update(jpeg []byte) {
|
|
frameWidth, frameHeight := jpegSize(jpeg)
|
|
state.condition.L.Lock()
|
|
state.jpeg = append(state.jpeg[:0], jpeg...)
|
|
if frameWidth > 0 && frameHeight > 0 {
|
|
state.frameWidth = frameWidth
|
|
state.frameHeight = frameHeight
|
|
}
|
|
state.sequence++
|
|
state.frameIndex++
|
|
state.updatedAt = time.Now().UTC()
|
|
state.event = map[string]any{
|
|
"type": "preview_frame",
|
|
"timestamp": state.updatedAt.Format(time.RFC3339Nano),
|
|
"frame_index": state.frameIndex,
|
|
"target_kind": state.target.Kind,
|
|
"target_label": state.target.Label,
|
|
}
|
|
state.condition.Broadcast()
|
|
state.condition.L.Unlock()
|
|
}
|
|
|
|
func (state *visionFrameState) updateAnnotation(annotation annotationMeta) {
|
|
state.condition.L.Lock()
|
|
state.annotation = annotation
|
|
state.condition.L.Unlock()
|
|
}
|
|
|
|
func (state *visionFrameState) updateStatus(event map[string]any, target previewTarget) {
|
|
state.condition.L.Lock()
|
|
state.updatedAt = time.Now().UTC()
|
|
state.event = event
|
|
state.target = target
|
|
state.condition.Broadcast()
|
|
state.condition.L.Unlock()
|
|
}
|
|
|
|
func (state *visionFrameState) snapshot() ([]byte, map[string]any, int64, time.Time, int, int, annotationMeta) {
|
|
state.condition.L.Lock()
|
|
defer state.condition.L.Unlock()
|
|
jpeg := append([]byte(nil), state.jpeg...)
|
|
event := make(map[string]any, len(state.event))
|
|
for key, value := range state.event {
|
|
event[key] = value
|
|
}
|
|
return jpeg, event, state.sequence, state.updatedAt, state.frameWidth, state.frameHeight, state.annotation
|
|
}
|
|
|
|
func (state *visionFrameState) waitForNext(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 runVisionStream(args []string) error {
|
|
flags := flag.NewFlagSet("vision-stream", flag.ContinueOnError)
|
|
host := flags.String("host", "127.0.0.1", "HTTP server host")
|
|
port := flags.Int("port", 8765, "HTTP server port")
|
|
input := flags.String("input", "3:none", "FFmpeg avfoundation input for experiments")
|
|
fps := flags.Float64("fps", 30, "capture FPS")
|
|
cropWechat := flags.Bool("crop-wechat", true, "capture only the WeChat window")
|
|
regionsPath := flags.String("regions", defaultRegionsPath(), "annotation regions JSON path")
|
|
if err := flags.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
state := newVisionFrameState(previewTarget{Kind: "waiting", Label: "等待标注来源"})
|
|
server := &http.Server{
|
|
Addr: fmt.Sprintf("%s:%d", *host, *port),
|
|
Handler: visionHTTPHandler(state),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer shutdownCancel()
|
|
_ = server.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
errCh := make(chan error, 2)
|
|
go func() {
|
|
errCh <- server.ListenAndServe()
|
|
}()
|
|
go func() {
|
|
errCh <- runVisionCapture(ctx, state, *input, *fps, *cropWechat, *regionsPath)
|
|
}()
|
|
|
|
logInfo(fmt.Sprintf("Go annotation source stream started http://%s:%d/stream.mjpg regions=%s fps=%.1f", *host, *port, *regionsPath, *fps))
|
|
|
|
for err := range errCh {
|
|
if err == nil || err == http.ErrServerClosed || ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
cancel()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func visionHTTPHandler(state *visionFrameState) 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, `<html><body style="margin:0;background:#050505;color:#d7ffe4;font-family:monospace"><img src="/stream.mjpg" style="width:100vw;height:100vh;object-fit:contain" /></body></html>`)
|
|
})
|
|
mux.HandleFunc("/latest.json", func(writer http.ResponseWriter, request *http.Request) {
|
|
_, event, sequence, updatedAt, frameWidth, frameHeight, annotation := state.snapshot()
|
|
payload := map[string]any{
|
|
"source": "go-window-capture",
|
|
"sequence": sequence,
|
|
"updated_at": updatedAt.Format(time.RFC3339Nano),
|
|
"event": event,
|
|
"frame": map[string]any{
|
|
"width": frameWidth,
|
|
"height": frameHeight,
|
|
},
|
|
"annotation": annotation,
|
|
"target": map[string]any{
|
|
"kind": state.target.Kind,
|
|
"label": state.target.Label,
|
|
"window_id": state.target.WindowID,
|
|
"rect": state.target.Rect,
|
|
},
|
|
}
|
|
if updatedAt.IsZero() {
|
|
payload["updated_at"] = time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(writer).Encode(payload)
|
|
})
|
|
mux.HandleFunc("/frame.jpg", func(writer http.ResponseWriter, request *http.Request) {
|
|
jpeg, _, sequence, _, _, _, _ := state.snapshot()
|
|
if len(jpeg) == 0 {
|
|
http.Error(writer, "frame not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
writer.Header().Set("Content-Type", "image/jpeg")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("X-Frame-Sequence", fmt.Sprintf("%d", sequence))
|
|
_, _ = writer.Write(jpeg)
|
|
})
|
|
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.waitForNext(lastSequence)
|
|
lastSequence = sequence
|
|
if len(jpeg) == 0 {
|
|
continue
|
|
}
|
|
_, err := fmt.Fprintf(writer, "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n", len(jpeg))
|
|
if 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 runVisionCapture(ctx context.Context, state *visionFrameState, input string, fps float64, cropWechat bool, regionsPath string) error {
|
|
for ctx.Err() == nil {
|
|
target, annotation, err := previewTargetFromAnnotation(regionsPath)
|
|
if err != nil {
|
|
message := "等待标注来源窗口出现:" + err.Error()
|
|
logWarning(message)
|
|
state.updateStatus(map[string]any{
|
|
"type": "source_waiting",
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
|
"message": message,
|
|
}, previewTarget{Kind: "waiting", Label: "等待标注来源"})
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(2 * time.Second):
|
|
continue
|
|
}
|
|
}
|
|
|
|
state.updateAnnotation(annotation)
|
|
return runResolvedVisionCapture(ctx, state, input, fps, cropWechat, target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runResolvedVisionCapture(ctx context.Context, state *visionFrameState, input string, fps float64, cropWechat bool, target previewTarget) error {
|
|
if target.Kind == "window" {
|
|
logInfo(fmt.Sprintf("using macOS window-only capture window_id=%d", target.WindowID))
|
|
return runScreencaptureMJPEG(ctx, state, fps, &target.Rect, target.WindowID)
|
|
}
|
|
|
|
if target.Kind == "display" {
|
|
logInfo(fmt.Sprintf("using macOS display rectangle capture rect=[%.0f,%.0f,%.0f,%.0f]", target.Rect.X, target.Rect.Y, target.Rect.Width, target.Rect.Height))
|
|
return runScreencaptureMJPEG(ctx, state, fps, &target.Rect, 0)
|
|
}
|
|
|
|
window, err := maybeFindWeChatWindow(cropWechat)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logInfo("using macOS window-only capture for fallback WeChat preview")
|
|
return runScreencaptureMJPEG(ctx, state, fps, window, window.ID)
|
|
|
|
// Keep the FFmpeg path below as an experiment. It captures the screen and crops
|
|
// by rectangle, so covered windows can leak into the preview. Tauri preview uses
|
|
// screencapture -l instead, which captures the WeChat window itself.
|
|
ffmpegErr := make(chan error, 1)
|
|
ffmpegCtx, cancelFFmpeg := context.WithCancel(ctx)
|
|
go func() {
|
|
ffmpegErr <- runFFmpegMJPEG(ffmpegCtx, state, input, fps, window)
|
|
}()
|
|
|
|
timer := time.NewTimer(3 * time.Second)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
cancelFFmpeg()
|
|
return nil
|
|
case err := <-ffmpegErr:
|
|
cancelFFmpeg()
|
|
if err != nil {
|
|
logWarning("FFmpeg exited before first frame, fallback to screencapture: " + err.Error())
|
|
}
|
|
return runScreencaptureMJPEG(ctx, state, fps, window, window.ID)
|
|
case <-timer.C:
|
|
_, _, sequence, _, _, _, _ := state.snapshot()
|
|
if sequence > 0 {
|
|
logInfo("FFmpeg produced frames, keep using FFmpeg capture")
|
|
return <-ffmpegErr
|
|
}
|
|
cancelFFmpeg()
|
|
logWarning("FFmpeg produced no frames in 3s, fallback to screencapture")
|
|
return runScreencaptureMJPEG(ctx, state, fps, window, window.ID)
|
|
}
|
|
}
|
|
|
|
func maybeFindWeChatWindow(cropWechat bool) (*WindowBounds, error) {
|
|
if !cropWechat {
|
|
return nil, nil
|
|
}
|
|
bounds, err := findWeChatWindow()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find WeChat window failed, fallback to full screen capture: %w", err)
|
|
}
|
|
logInfo(fmt.Sprintf(
|
|
"WeChat window found owner=%s title=%s id=%d rect=[%.0f,%.0f,%.0f,%.0f]",
|
|
bounds.Owner,
|
|
bounds.Title,
|
|
bounds.ID,
|
|
bounds.X,
|
|
bounds.Y,
|
|
bounds.Width,
|
|
bounds.Height,
|
|
))
|
|
return &bounds, nil
|
|
}
|
|
|
|
func defaultRegionsPath() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "data/regions/wechat.json"
|
|
}
|
|
return filepath.Join(home, "Library", "Application Support", "com.tauri.dev", "data", "regions", "wechat.json")
|
|
}
|
|
|
|
func previewTargetFromAnnotation(path string) (previewTarget, annotationMeta, error) {
|
|
annotation, err := loadAnnotation(path)
|
|
if err != nil {
|
|
return previewTarget{}, annotationMeta{}, fmt.Errorf("load annotation source failed: %w", err)
|
|
}
|
|
meta := annotationMeta{
|
|
Width: annotation.ScreenshotWidth,
|
|
Height: annotation.ScreenshotHeight,
|
|
ScaleFactor: annotation.ScaleFactor,
|
|
}
|
|
|
|
source := captureSourceFromAnnotation(annotation.Source)
|
|
if source.Kind == "" {
|
|
target, err := fallbackWeChatPreviewTarget()
|
|
return target, meta, err
|
|
}
|
|
|
|
if source.Kind == "window" {
|
|
window, err := findWindowForSource(source)
|
|
if err != nil {
|
|
return previewTarget{}, meta, err
|
|
}
|
|
return previewTarget{
|
|
Kind: "window",
|
|
Label: source.Label,
|
|
WindowID: window.ID,
|
|
Rect: window,
|
|
}, meta, nil
|
|
}
|
|
|
|
if source.Kind == "display" {
|
|
return previewTarget{
|
|
Kind: "display",
|
|
Label: source.Label,
|
|
Rect: WindowBounds{
|
|
X: source.X,
|
|
Y: source.Y,
|
|
Width: source.Width,
|
|
Height: source.Height,
|
|
Owner: "display",
|
|
Title: source.Label,
|
|
},
|
|
}, meta, nil
|
|
}
|
|
|
|
return previewTarget{}, meta, fmt.Errorf("unsupported annotation source kind: %s", source.Kind)
|
|
}
|
|
|
|
func fallbackWeChatPreviewTarget() (previewTarget, error) {
|
|
window, err := findWeChatWindow()
|
|
if err != nil {
|
|
return previewTarget{}, err
|
|
}
|
|
return previewTarget{
|
|
Kind: "window",
|
|
Label: "微信",
|
|
WindowID: window.ID,
|
|
Rect: window,
|
|
}, nil
|
|
}
|
|
|
|
func captureSourceFromAnnotation(raw jsonRawOptional) captureSource {
|
|
data, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return captureSource{}
|
|
}
|
|
var source captureSource
|
|
if err := json.Unmarshal(data, &source); err != nil {
|
|
return captureSource{}
|
|
}
|
|
return source
|
|
}
|
|
|
|
func findWindowForSource(source captureSource) (WindowBounds, error) {
|
|
appName := strings.ReplaceAll(source.AppName, "\\", "\\\\")
|
|
appName = strings.ReplaceAll(appName, `"`, `\"`)
|
|
title := strings.ReplaceAll(source.Title, "\\", "\\\\")
|
|
title = strings.ReplaceAll(title, `"`, `\"`)
|
|
|
|
script := fmt.Sprintf(`
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
let targetOwner = "%s"
|
|
let targetTitle = "%s"
|
|
let options = CGWindowListOption(arrayLiteral: [.optionOnScreenOnly, .excludeDesktopElements])
|
|
guard let infos = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { exit(1) }
|
|
|
|
func printWindow(_ info: [String: Any]) {
|
|
let owner = info[kCGWindowOwnerName as String] as? String ?? ""
|
|
let title = info[kCGWindowName as String] as? String ?? ""
|
|
let bounds = info[kCGWindowBounds as String] as? [String: Any] ?? [:]
|
|
let x = bounds["X"] as? Double ?? 0
|
|
let y = bounds["Y"] as? Double ?? 0
|
|
let w = bounds["Width"] as? Double ?? 0
|
|
let h = bounds["Height"] as? Double ?? 0
|
|
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)!)
|
|
}
|
|
|
|
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 == targetOwner && title == targetTitle { printWindow(info); exit(0) }
|
|
}
|
|
|
|
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 && targetOwner != "" && owner.contains(targetOwner) { printWindow(info); exit(0) }
|
|
}
|
|
|
|
exit(2)
|
|
`, appName, title)
|
|
|
|
output, err := runSwift(script)
|
|
if err != nil {
|
|
return WindowBounds{}, fmt.Errorf("find annotation window source failed app=%q title=%q: %w", source.AppName, source.Title, err)
|
|
}
|
|
var window WindowBounds
|
|
if err := json.Unmarshal([]byte(output), &window); err != nil {
|
|
return WindowBounds{}, err
|
|
}
|
|
return window, nil
|
|
}
|
|
|
|
func runFFmpegMJPEG(ctx context.Context, state *visionFrameState, input string, fps float64, window *WindowBounds) error {
|
|
args := []string{
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-f", "avfoundation",
|
|
"-framerate", fmt.Sprintf("%.3f", fps),
|
|
"-pixel_format", "bgr0",
|
|
"-i", input,
|
|
}
|
|
if window != nil {
|
|
args = append(args, "-vf", ffmpegCropFilter(*window))
|
|
}
|
|
args = append(args,
|
|
"-f", "image2pipe",
|
|
"-vcodec", "mjpeg",
|
|
"-",
|
|
)
|
|
logInfo("FFmpeg command: ffmpeg " + 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 fmt.Errorf("启动 FFmpeg 失败:%w", err)
|
|
}
|
|
|
|
go drainFFmpegStderr(stderr)
|
|
|
|
readErr := readJPEGFrames(stdout, state.update)
|
|
waitErr := command.Wait()
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if waitErr != nil {
|
|
return fmt.Errorf("FFmpeg 退出:%w", waitErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runScreencaptureMJPEG(ctx context.Context, state *visionFrameState, fps float64, window *WindowBounds, windowID int) error {
|
|
if window == nil {
|
|
return errors.New("screencapture requires a capture target")
|
|
}
|
|
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("", "wechat-go-vision-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
imagePath := filepath.Join(tempDir, "frame.jpg")
|
|
rect := captureRect(*window)
|
|
if windowID > 0 {
|
|
logInfo(fmt.Sprintf("window-only screencapture started window_id=%d", windowID))
|
|
} else {
|
|
logInfo("rectangle screencapture started rect=" + rect)
|
|
}
|
|
for ctx.Err() == nil {
|
|
args := []string{"-x"}
|
|
if windowID > 0 {
|
|
args = append(args, "-l", fmt.Sprintf("%d", windowID))
|
|
} else {
|
|
args = append(args, "-R", rect)
|
|
}
|
|
args = append(args, imagePath)
|
|
command := exec.CommandContext(ctx, "screencapture", args...)
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
logWarning(fmt.Sprintf("screencapture failed: %v output=%s", err, string(output)))
|
|
} else if jpeg, err := os.ReadFile(imagePath); err != nil {
|
|
logWarning("read screencapture frame failed: " + err.Error())
|
|
} else if len(jpeg) > 0 {
|
|
state.update(jpeg)
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ffmpegCropFilter(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 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 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
|
|
frame := append([]byte(nil), buffer[start:end]...)
|
|
buffer = buffer[end:]
|
|
onFrame(frame)
|
|
}
|
|
}
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func jpegSize(jpeg []byte) (int, int) {
|
|
config, _, err := image.DecodeConfig(bytes.NewReader(jpeg))
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
return config.Width, config.Height
|
|
}
|
|
|
|
func drainFFmpegStderr(reader io.Reader) {
|
|
scanner := bufio.NewScanner(reader)
|
|
for scanner.Scan() {
|
|
logWarning("ffmpeg: " + scanner.Text())
|
|
}
|
|
}
|