mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 17:49:03 +00:00
♻️ refactor: split src into internal packages and cmd
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// .tsf layout, little-endian: "TSFR" | version uint16 | fps float64 |
|
||||
// count uint32 | count × (colorLen uint32, color JPEG).
|
||||
package tsf
|
||||
|
||||
import "sync"
|
||||
|
||||
const (
|
||||
tsfMagic = "TSFR"
|
||||
tsfVersion = 1
|
||||
maxTSFFPS = 240
|
||||
maxTSFFrameCount = 10_000_000
|
||||
)
|
||||
|
||||
type FramesContainer struct {
|
||||
ColorFrames [][]byte
|
||||
FPS float64
|
||||
Name string
|
||||
}
|
||||
|
||||
type frameFile struct {
|
||||
data []byte
|
||||
cleanup func() error
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *frameFile) Close() error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
f.once.Do(func() {
|
||||
if f.cleanup != nil {
|
||||
f.err = f.cleanup()
|
||||
}
|
||||
f.data = nil
|
||||
})
|
||||
return f.err
|
||||
}
|
||||
|
||||
var frameFileOwners sync.Map // map[*FramesContainer]*frameFile
|
||||
|
||||
func (data *FramesContainer) Close() error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
owner, ok := frameFileOwners.LoadAndDelete(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
data.ColorFrames = nil
|
||||
return owner.(*frameFile).Close()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package tsf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Write(output string, data *FramesContainer) error {
|
||||
if data == nil {
|
||||
return fmt.Errorf("cannot write nil frames container")
|
||||
}
|
||||
if math.IsNaN(data.FPS) || math.IsInf(data.FPS, 0) || data.FPS <= 0 || data.FPS > maxTSFFPS {
|
||||
return fmt.Errorf("cannot write .tsf: fps must be finite, positive, and at most %d", maxTSFFPS)
|
||||
}
|
||||
if len(data.ColorFrames) > maxTSFFrameCount || uint64(len(data.ColorFrames)) > math.MaxUint32 {
|
||||
return fmt.Errorf("cannot write .tsf: frame count exceeds limit")
|
||||
}
|
||||
for i, frame := range data.ColorFrames {
|
||||
if uint64(len(frame)) > math.MaxUint32 {
|
||||
return fmt.Errorf("cannot write .tsf: frame %d length exceeds uint32", i)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
w := bufio.NewWriterSize(f, 1<<20)
|
||||
if _, err := w.WriteString(tsfMagic); err != nil {
|
||||
return err
|
||||
}
|
||||
var hdr [14]byte
|
||||
binary.LittleEndian.PutUint16(hdr[0:], tsfVersion)
|
||||
binary.LittleEndian.PutUint64(hdr[2:], math.Float64bits(data.FPS))
|
||||
binary.LittleEndian.PutUint32(hdr[10:], uint32(len(data.ColorFrames)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lenBuf [4]byte
|
||||
for _, frame := range data.ColorFrames {
|
||||
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(frame)))
|
||||
if _, err := w.Write(lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func Load(filename string) (*FramesContainer, error) {
|
||||
file, err := readFrameFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned := false
|
||||
defer func() {
|
||||
if !owned {
|
||||
_ = file.Close()
|
||||
}
|
||||
}()
|
||||
raw := file.data
|
||||
invalid := func() error {
|
||||
return fmt.Errorf("invalid frames file %q: corrupt .tsf container", filename)
|
||||
}
|
||||
|
||||
if len(raw) < 18 || string(raw[:4]) != tsfMagic {
|
||||
return nil, invalid()
|
||||
}
|
||||
version := binary.LittleEndian.Uint16(raw[4:])
|
||||
if version != tsfVersion {
|
||||
return nil, fmt.Errorf("unsupported .tsf version %d in %q", version, filename)
|
||||
}
|
||||
fps := math.Float64frombits(binary.LittleEndian.Uint64(raw[6:]))
|
||||
count := binary.LittleEndian.Uint32(raw[14:])
|
||||
if math.IsNaN(fps) || math.IsInf(fps, 0) || fps <= 0 || fps > maxTSFFPS {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid frames file %q: fps must be finite, greater than 0, and at most %d",
|
||||
filename, maxTSFFPS,
|
||||
)
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("invalid frames file %q: expected non-empty frames", filename)
|
||||
}
|
||||
if count > maxTSFFrameCount || uint64(count) > uint64((len(raw)-18)/4) {
|
||||
return nil, invalid()
|
||||
}
|
||||
|
||||
colorFrames := make([][]byte, 0, int(count))
|
||||
off := 18
|
||||
for range count {
|
||||
if len(raw)-off < 4 {
|
||||
return nil, invalid()
|
||||
}
|
||||
n := uint64(binary.LittleEndian.Uint32(raw[off:]))
|
||||
off += 4
|
||||
if n > uint64(len(raw)-off) {
|
||||
return nil, invalid()
|
||||
}
|
||||
nativeLen := int(n)
|
||||
colorFrames = append(colorFrames, raw[off:off+nativeLen])
|
||||
off += nativeLen
|
||||
}
|
||||
|
||||
if off != len(raw) {
|
||||
return nil, invalid()
|
||||
}
|
||||
data := &FramesContainer{ColorFrames: colorFrames, FPS: fps}
|
||||
frameFileOwners.Store(data, file)
|
||||
owned = true
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !unix
|
||||
|
||||
package tsf
|
||||
|
||||
import "os"
|
||||
|
||||
func readFrameFile(filename string) (*frameFile, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
return &frameFile{data: data}, err
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build unix
|
||||
|
||||
package tsf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func readFrameFile(filename string) (*frameFile, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := info.Size()
|
||||
if size <= 0 || size != int64(int(size)) {
|
||||
data, err := os.ReadFile(filename)
|
||||
return &frameFile{data: data}, err
|
||||
}
|
||||
data, err := syscall.Mmap(int(f.Fd()), 0, int(size), syscall.PROT_READ, syscall.MAP_SHARED)
|
||||
if err != nil {
|
||||
data, err := os.ReadFile(filename)
|
||||
return &frameFile{data: data}, err
|
||||
}
|
||||
return &frameFile{
|
||||
data: data,
|
||||
cleanup: func() error {
|
||||
return syscall.Munmap(data)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package tsf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tsfHeader(fps float64, count uint32) []byte {
|
||||
raw := make([]byte, 18)
|
||||
copy(raw, tsfMagic)
|
||||
binary.LittleEndian.PutUint16(raw[4:], tsfVersion)
|
||||
binary.LittleEndian.PutUint64(raw[6:], math.Float64bits(fps))
|
||||
binary.LittleEndian.PutUint32(raw[14:], count)
|
||||
return raw
|
||||
}
|
||||
|
||||
func writeRawTSF(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "frames.tsf")
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestTSFRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.tsf")
|
||||
original := &FramesContainer{
|
||||
ColorFrames: [][]byte{{100, 101, 102}, {110, 120, 130}},
|
||||
FPS: 29.97,
|
||||
}
|
||||
if err := Write(path, original); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
fc, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer func() { _ = fc.Close() }()
|
||||
if fc.FPS != 29.97 {
|
||||
t.Errorf("fps = %v", fc.FPS)
|
||||
}
|
||||
if len(fc.ColorFrames) != 2 {
|
||||
t.Fatalf("frames = %d color", len(fc.ColorFrames))
|
||||
}
|
||||
if string(fc.ColorFrames[0]) != string([]byte{100, 101, 102}) {
|
||||
t.Errorf("color frame0 = %v", fc.ColorFrames[0])
|
||||
}
|
||||
if string(fc.ColorFrames[1]) != string([]byte{110, 120, 130}) {
|
||||
t.Errorf("color frame1 = %v", fc.ColorFrames[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.tsf")
|
||||
|
||||
if err := os.WriteFile(path, []byte("not a tsf file"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("expected error for garbage input")
|
||||
}
|
||||
|
||||
// Valid container but no frames.
|
||||
if err := Write(path, &FramesContainer{FPS: 30}); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("expected error for empty frames")
|
||||
}
|
||||
|
||||
// Valid container but fps <= 0.
|
||||
rawInvalidFPS := append(tsfHeader(0, 1), 1, 0, 0, 0, 1)
|
||||
if err := os.WriteFile(path, rawInvalidFPS, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("expected error for fps<=0")
|
||||
}
|
||||
|
||||
// Truncated payload.
|
||||
if err := Write(path, &FramesContainer{ColorFrames: [][]byte{{1, 2, 3, 4}}, FPS: 30}); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw[:len(raw)-2], 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("expected error for truncated file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFRejectsInvalidFPS(t *testing.T) {
|
||||
for _, fps := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -1, 0, 240.01} {
|
||||
raw := append(tsfHeader(fps, 1), 0, 0, 0, 0)
|
||||
if _, err := Load(writeRawTSF(t, raw)); err == nil {
|
||||
t.Errorf("Load accepted fps %v", fps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFRejectsImpossibleCountsAndLengths(t *testing.T) {
|
||||
if _, err := Load(writeRawTSF(t, tsfHeader(30, math.MaxUint32))); err == nil {
|
||||
t.Fatal("Load accepted impossible frame count")
|
||||
}
|
||||
|
||||
raw := append(tsfHeader(30, 1), 0xff, 0xff, 0xff, 0xff)
|
||||
if _, err := Load(writeRawTSF(t, raw)); err == nil {
|
||||
t.Fatal("Load accepted overflowing frame length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFCloseReleasesOwnedFrames(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "frames.tsf")
|
||||
if err := Write(path, &FramesContainer{FPS: 30, ColorFrames: [][]byte{{1, 2, 3}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := frames.ColorFrames[0]; len(got) != 3 || got[0] != 1 {
|
||||
t.Fatalf("unexpected zero-copy frame data: %v", got)
|
||||
}
|
||||
if err := frames.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if frames.ColorFrames != nil {
|
||||
t.Fatal("Close retained references to released frame data")
|
||||
}
|
||||
if err := frames.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFWriteRejectsInvalidHeaderValuesBeforeCreate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "frames.tsf")
|
||||
err := Write(path, &FramesContainer{FPS: math.NaN(), ColorFrames: [][]byte{{1}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "fps") {
|
||||
t.Fatalf("Write error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid write created output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPEGFrameSplitterAcrossChunks(t *testing.T) {
|
||||
splitter := &jpegFrameSplitter{}
|
||||
var frames [][]byte
|
||||
emit := func(frame []byte) error {
|
||||
frames = append(frames, bytes.Clone(frame))
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := [][]byte{
|
||||
{0x01, 0x02, 0xff},
|
||||
{0xd8, 0x10, 0xff},
|
||||
{0xd9, 0xff, 0xd8, 0x20},
|
||||
{0x30, 0xff},
|
||||
{0xd9, 0x03},
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if _, err := splitter.push(chunk, emit); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
}
|
||||
if err := splitter.finish(); err != nil {
|
||||
t.Fatalf("finish: %v", err)
|
||||
}
|
||||
|
||||
want := [][]byte{
|
||||
{0xff, 0xd8, 0x10, 0xff, 0xd9},
|
||||
{0xff, 0xd8, 0x20, 0x30, 0xff, 0xd9},
|
||||
}
|
||||
if len(frames) != len(want) {
|
||||
t.Fatalf("got %d frames, want %d", len(frames), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if !bytes.Equal(frames[i], want[i]) {
|
||||
t.Errorf("frame %d = %x, want %x", i, frames[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPEGFrameSplitterRejectsTruncatedFrame(t *testing.T) {
|
||||
splitter := &jpegFrameSplitter{}
|
||||
if _, err := splitter.push([]byte{0xff, 0xd8, 0x01}, func([]byte) error { return nil }); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if err := splitter.finish(); err == nil {
|
||||
t.Fatal("finish accepted a truncated JPEG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedLog(t *testing.T) {
|
||||
log := &boundedLog{limit: 4}
|
||||
if n, err := log.Write([]byte("abcdefgh")); err != nil || n != 8 {
|
||||
t.Fatalf("Write = %d, %v", n, err)
|
||||
}
|
||||
if got := log.String(); got != "abcd" {
|
||||
t.Fatalf("String = %q, want %q", got, "abcd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTSFCommitAndAbort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "frames.tsf")
|
||||
|
||||
stream, err := newStreamingTSF(output, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("newStreamingTSF: %v", err)
|
||||
}
|
||||
for _, frame := range [][]byte{{1, 2, 3}, {4, 5}} {
|
||||
if err := stream.addFrame(frame); err != nil {
|
||||
t.Fatalf("addFrame: %v", err)
|
||||
}
|
||||
}
|
||||
if err := stream.commit(output); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
stream.abort()
|
||||
|
||||
got, err := Load(output)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer func() { _ = got.Close() }()
|
||||
if got.FPS != 24 || len(got.ColorFrames) != 2 || !bytes.Equal(got.ColorFrames[1], []byte{4, 5}) {
|
||||
t.Fatalf("unexpected streamed TSF: %+v", got)
|
||||
}
|
||||
|
||||
original := []byte("existing destination")
|
||||
if err := os.WriteFile(output, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
failed, err := newStreamingTSF(output, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("newStreamingTSF: %v", err)
|
||||
}
|
||||
if err := failed.addFrame([]byte{9}); err != nil {
|
||||
t.Fatalf("addFrame: %v", err)
|
||||
}
|
||||
failed.abort()
|
||||
|
||||
contents, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !bytes.Equal(contents, original) {
|
||||
t.Fatalf("destination changed after abort: %q", contents)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, ".frames.tsf-*.tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("Glob: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary files remain after abort: %v", matches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package tsf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/YuzuZensai/TrollSSH/internal/logx"
|
||||
)
|
||||
|
||||
var (
|
||||
jpegSOI = []byte{0xff, 0xd8}
|
||||
jpegEOI = []byte{0xff, 0xd9}
|
||||
)
|
||||
|
||||
const (
|
||||
maxJPEGFrameBytes = 64 << 20
|
||||
maxFFmpegLogBytes = 64 << 10
|
||||
)
|
||||
|
||||
type jpegFrameSplitter struct {
|
||||
buffer []byte
|
||||
scan int
|
||||
inJPEG bool
|
||||
}
|
||||
|
||||
func (s *jpegFrameSplitter) push(chunk []byte, emit func([]byte) error) (int, error) {
|
||||
s.buffer = append(s.buffer, chunk...)
|
||||
emitted := 0
|
||||
for {
|
||||
if !s.inJPEG {
|
||||
start := bytes.Index(s.buffer[s.scan:], jpegSOI)
|
||||
if start == -1 {
|
||||
// Retain only a possible marker prefix spanning two reads.
|
||||
if len(s.buffer) > 0 && s.buffer[len(s.buffer)-1] == jpegSOI[0] {
|
||||
s.buffer = s.buffer[len(s.buffer)-1:]
|
||||
} else {
|
||||
s.buffer = s.buffer[:0]
|
||||
}
|
||||
s.scan = 0
|
||||
return emitted, nil
|
||||
}
|
||||
start += s.scan
|
||||
s.buffer = s.buffer[start:]
|
||||
s.scan = len(jpegSOI)
|
||||
s.inJPEG = true
|
||||
}
|
||||
|
||||
end := bytes.Index(s.buffer[s.scan:], jpegEOI)
|
||||
if end == -1 {
|
||||
if len(s.buffer) > maxJPEGFrameBytes {
|
||||
return emitted, fmt.Errorf("JPEG frame exceeds %d MiB limit", maxJPEGFrameBytes>>20)
|
||||
}
|
||||
s.scan = max(len(jpegSOI), len(s.buffer)-1)
|
||||
return emitted, nil
|
||||
}
|
||||
frameEnd := s.scan + end + len(jpegEOI)
|
||||
if frameEnd > maxJPEGFrameBytes {
|
||||
return emitted, fmt.Errorf("JPEG frame exceeds %d MiB limit", maxJPEGFrameBytes>>20)
|
||||
}
|
||||
if err := emit(s.buffer[:frameEnd]); err != nil {
|
||||
return emitted, err
|
||||
}
|
||||
emitted++
|
||||
s.buffer = s.buffer[frameEnd:]
|
||||
s.scan = 0
|
||||
s.inJPEG = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *jpegFrameSplitter) finish() error {
|
||||
if s.inJPEG {
|
||||
return fmt.Errorf("ffmpeg produced a truncated JPEG frame")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type boundedLog struct {
|
||||
buffer bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w *boundedLog) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
if remaining := w.limit - w.buffer.Len(); remaining > 0 {
|
||||
_, _ = w.buffer.Write(p[:min(len(p), remaining)])
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *boundedLog) String() string {
|
||||
return strings.TrimSpace(w.buffer.String())
|
||||
}
|
||||
|
||||
type streamingTSF struct {
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
path string
|
||||
count uint32
|
||||
}
|
||||
|
||||
func newStreamingTSF(output string, fps float64) (*streamingTSF, error) {
|
||||
if math.IsNaN(fps) || math.IsInf(fps, 0) || fps <= 0 || fps > maxTSFFPS {
|
||||
return nil, fmt.Errorf("cannot write .tsf: fps must be finite and between 0 and %d", maxTSFFPS)
|
||||
}
|
||||
dir := filepath.Dir(output)
|
||||
f, err := os.CreateTemp(dir, "."+filepath.Base(output)+"-*.tmp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &streamingTSF{file: f, writer: bufio.NewWriterSize(f, 1<<20), path: f.Name()}
|
||||
if err := f.Chmod(0o644); err != nil {
|
||||
s.abort()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.writer.WriteString(tsfMagic); err != nil {
|
||||
s.abort()
|
||||
return nil, err
|
||||
}
|
||||
var hdr [14]byte
|
||||
binary.LittleEndian.PutUint16(hdr[0:], tsfVersion)
|
||||
binary.LittleEndian.PutUint64(hdr[2:], math.Float64bits(fps))
|
||||
if _, err := s.writer.Write(hdr[:]); err != nil {
|
||||
s.abort()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *streamingTSF) addFrame(frame []byte) error {
|
||||
if s.count >= maxTSFFrameCount {
|
||||
return fmt.Errorf("too many video frames")
|
||||
}
|
||||
if uint64(len(frame)) > math.MaxUint32 {
|
||||
return fmt.Errorf("JPEG frame is too large")
|
||||
}
|
||||
var size [4]byte
|
||||
binary.LittleEndian.PutUint32(size[:], uint32(len(frame)))
|
||||
if _, err := s.writer.Write(size[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.writer.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
s.count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *streamingTSF) commit(output string) error {
|
||||
if s.count == 0 {
|
||||
return fmt.Errorf("no frames were decoded from the video")
|
||||
}
|
||||
if err := s.writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.file.Seek(14, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
var count [4]byte
|
||||
binary.LittleEndian.PutUint32(count[:], s.count)
|
||||
if _, err := s.file.Write(count[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.file = nil
|
||||
if err := os.Rename(s.path, output); err != nil {
|
||||
return err
|
||||
}
|
||||
s.path = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *streamingTSF) abort() {
|
||||
if s.file != nil {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
if s.path != "" {
|
||||
_ = os.Remove(s.path)
|
||||
s.path = ""
|
||||
}
|
||||
}
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Streams []struct {
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
NbFrames string `json:"nb_frames"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
func parseFrameRate(rate string) float64 {
|
||||
if rate == "" {
|
||||
return math.NaN()
|
||||
}
|
||||
parts := strings.SplitN(rate, "/", 2)
|
||||
num, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
den, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil || den == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return num / den
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func extractFrames(path, vf, label string, totalFrames int, emit func([]byte) error) (int, error) {
|
||||
cmd := exec.Command(
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats",
|
||||
"-i", path,
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "3",
|
||||
"-vf", vf,
|
||||
"-f", "image2pipe",
|
||||
"pipe:1",
|
||||
)
|
||||
stderr := &boundedLog{limit: maxFFmpegLogBytes}
|
||||
cmd.Stderr = stderr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
|
||||
splitter := &jpegFrameSplitter{}
|
||||
frameCount := 0
|
||||
lastReport := time.Now()
|
||||
reportProgress := func(force bool) {
|
||||
if !force && time.Since(lastReport) < 250*time.Millisecond {
|
||||
return
|
||||
}
|
||||
if totalFrames > 0 {
|
||||
pct := min(100, int(math.Round(float64(frameCount)/float64(totalFrames)*100)))
|
||||
fmt.Printf("\rGenerating %s frames: %d/%d (%d%%)", label, frameCount, totalFrames, pct)
|
||||
} else {
|
||||
fmt.Printf("\rGenerating %s frames: %d", label, frameCount)
|
||||
}
|
||||
lastReport = time.Now()
|
||||
}
|
||||
failStream := func(err error) (int, error) {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return frameCount, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
n, readErr := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
emitted, splitErr := splitter.push(buf[:n], emit)
|
||||
frameCount += emitted
|
||||
if splitErr != nil {
|
||||
return failStream(fmt.Errorf("ffmpeg stream error: %w", splitErr))
|
||||
}
|
||||
if emitted > 0 {
|
||||
reportProgress(false)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return failStream(fmt.Errorf("ffmpeg stream error: %w", readErr))
|
||||
}
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if msg := stderr.String(); msg != "" {
|
||||
return frameCount, fmt.Errorf("ffmpeg failed: %s", msg)
|
||||
}
|
||||
return frameCount, fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
if err := splitter.finish(); err != nil {
|
||||
return frameCount, err
|
||||
}
|
||||
if frameCount == 0 {
|
||||
return 0, fmt.Errorf("no frames were decoded from the video")
|
||||
}
|
||||
reportProgress(true)
|
||||
fmt.Println()
|
||||
return frameCount, nil
|
||||
}
|
||||
|
||||
func ProcessVideo(path, output string, maxDimension int) error {
|
||||
probeCmd := exec.Command(
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_streams", "-show_format",
|
||||
"-of", "json", path,
|
||||
)
|
||||
probeOut, err := probeCmd.Output()
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
return fmt.Errorf("ffprobe failed: %s", msg)
|
||||
}
|
||||
|
||||
var probe ffprobeOutput
|
||||
if err := json.Unmarshal(probeOut, &probe); err != nil {
|
||||
return fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
if len(probe.Streams) == 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
stream := probe.Streams[0]
|
||||
|
||||
fps := parseFrameRate(stream.RFrameRate)
|
||||
if math.IsNaN(fps) || fps <= 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
|
||||
totalFrames := 0
|
||||
if n, err := strconv.Atoi(stream.NbFrames); err == nil {
|
||||
totalFrames = n
|
||||
} else {
|
||||
durStr := probe.Format.Duration
|
||||
if durStr == "" {
|
||||
durStr = stream.Duration
|
||||
}
|
||||
if d, err := strconv.ParseFloat(durStr, 64); err == nil {
|
||||
totalFrames = int(math.Round(d * fps))
|
||||
}
|
||||
}
|
||||
|
||||
scaleFilter := fmt.Sprintf(
|
||||
"scale=w=%d:h=%d:force_original_aspect_ratio=decrease",
|
||||
maxDimension, maxDimension,
|
||||
)
|
||||
|
||||
outputFile, err := newStreamingTSF(output, fps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outputFile.abort()
|
||||
|
||||
frameCount, err := extractFrames(path, scaleFilter, "color", totalFrames, outputFile.addFrame)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := outputFile.commit(output); err != nil {
|
||||
return err
|
||||
}
|
||||
logx.Info(fmt.Sprintf("Saved %d frames to %s", frameCount, output))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user