初始提交:短剧库源码(含转码优化 ultrafast)

This commit is contained in:
yvan
2026-09-17 19:09:08 +08:00
commit 07dc0f1059
247 changed files with 43242 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
package app
import (
"context"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
const playbackNativeSegmentSeconds = 2
const playbackNativeBatchSegments = 12
const playbackNativeCacheBytes = 96 * 1024 * 1024
const playbackNativeSegmentBytes = 16 * 1024 * 1024
type playbackNativeJob struct {
start int
end int
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
type playbackNative struct {
ctx context.Context
cancel context.CancelFunc
app *UIApp
task Task
downloadID string
quality int
offset float64
background bool
once sync.Once
workers sync.WaitGroup
mu sync.Mutex
changed chan struct{}
firstBatch chan struct{}
batchOnce sync.Once
closed bool
prepared bool
directory string
media providerMedia
input string
proxy *hlsProxy
ffmpeg string
duration float64
segments map[int][]byte
bytes int
wanted int
job *playbackNativeJob
err error
}
func newPlaybackNative(app *UIApp, parent context.Context, task Task, downloadID string, quality int, offset float64, background bool) *playbackNative {
ctx := context.WithValue(parent, playbackQualityKey{}, quality)
ctx, cancel := context.WithCancel(ctx)
return &playbackNative{ctx: ctx, cancel: cancel, app: app, task: task, downloadID: downloadID, quality: quality, offset: offset, background: background,
changed: make(chan struct{}), firstBatch: make(chan struct{}), segments: make(map[int][]byte), wanted: int(offset / playbackNativeSegmentSeconds)}
}
func (cache *playbackNative) notifyLocked() {
close(cache.changed)
cache.changed = make(chan struct{})
}
func (cache *playbackNative) start() {
cache.once.Do(func() {
cache.mu.Lock()
if cache.closed {
cache.mu.Unlock()
return
}
cache.workers.Add(1)
cache.mu.Unlock()
go func() {
defer cache.workers.Done()
media, input, proxy, err := cache.app.preparePlaybackMedia(cache.ctx, cache.task, cache.downloadID)
var directory, ffmpeg string
if err == nil {
ffmpeg, err = cache.app.downloader.ensureFFmpeg(cache.ctx)
}
if err == nil {
directory, err = os.MkdirTemp("", "juku-playback-hls-")
}
if err == nil {
cache.app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.mode", Source: sourceFromDramaID(cache.task.DramaID),
DramaID: cache.task.DramaID, DramaTitle: cache.task.DramaTitle, Episode: cache.task.Index, StartSeconds: cache.offset, Message: "native HLS transcode H.264/AAC"})
}
cache.mu.Lock()
cache.media, cache.input, cache.proxy = media, input, proxy
cache.ffmpeg, cache.directory = ffmpeg, directory
cache.duration = media.Duration.Seconds()
cache.prepared = true
if err == nil && cache.duration > 0 && cache.offset >= cache.duration {
err = errors.New("播放位置超过本集时长")
}
if err != nil {
cache.err = err
cache.batchOnce.Do(func() { close(cache.firstBatch) })
} else if !cache.closed {
cache.startJobLocked(cache.wanted)
}
cache.notifyLocked()
cache.mu.Unlock()
}()
})
}
func (cache *playbackNative) Close() {
cache.mu.Lock()
if cache.closed {
cache.mu.Unlock()
return
}
cache.closed = true
cache.cancel()
if cache.job != nil {
cache.job.cancel()
}
cache.segments = nil
cache.bytes = 0
cache.notifyLocked()
cache.mu.Unlock()
go func() {
cache.workers.Wait()
cache.mu.Lock()
proxy, directory := cache.proxy, cache.directory
cache.mu.Unlock()
if proxy != nil {
proxy.Close()
}
if directory != "" {
_ = os.RemoveAll(directory)
}
}()
}
func (cache *playbackNative) segmentCountLocked() int {
if cache.duration <= 0 || math.IsNaN(cache.duration) || math.IsInf(cache.duration, 0) || cache.duration > 24*60*60 {
return 0
}
return int(math.Ceil(cache.duration / playbackNativeSegmentSeconds))
}
func (cache *playbackNative) startJobLocked(index int) {
if cache.closed || !cache.prepared || cache.err != nil {
return
}
previous := cache.job
if previous != nil {
select {
case <-previous.done:
default:
if index >= previous.start && index < previous.end {
return
}
}
previous.cancel()
}
ctx, cancel := context.WithCancel(cache.ctx)
job := &playbackNativeJob{start: index, end: index + playbackNativeBatchSegments, ctx: ctx, cancel: cancel, done: make(chan struct{})}
if count := cache.segmentCountLocked(); count > 0 && job.end > count {
job.end = count
}
cache.job = job
cache.workers.Add(1)
go func() {
defer cache.workers.Done()
defer cancel()
var resultErr error
defer func() {
failed := false
cache.mu.Lock()
close(job.done)
if cache.job == job && !cache.closed {
if resultErr != nil && cache.ctx.Err() == nil {
cache.err = resultErr
failed = true
}
cache.batchOnce.Do(func() { close(cache.firstBatch) })
cache.notifyLocked()
cache.prefillLocked(cache.wanted)
}
cache.mu.Unlock()
if failed {
cache.app.downloader.recordTaskFailure("playback.native_failed", cache.task, float64(job.start*playbackNativeSegmentSeconds), resultErr)
}
}()
if previous != nil {
select {
case <-previous.done:
case <-ctx.Done():
return
}
}
resultErr = cache.render(job)
}()
}
func playbackNativeArgs(media providerMedia, input, directory string, index, end int) []string {
offset := float64(index * playbackNativeSegmentSeconds)
args := playbackEncodingArgs(media, input, offset)
for i := 0; i+1 < len(args); i++ {
if args[i] == "-g" || args[i] == "-keyint_min" {
args[i+1] = strconv.Itoa(playbackNativeSegmentSeconds * 30)
}
}
return append(args, "-t", strconv.Itoa((end-index)*playbackNativeSegmentSeconds),
"-output_ts_offset", strconv.FormatFloat(offset, 'f', 3, 64),
"-f", "hls", "-hls_time", strconv.Itoa(playbackNativeSegmentSeconds), "-hls_list_size", "0", "-hls_segment_type", "mpegts",
"-hls_segment_options", "mpegts_flags=+initial_discontinuity",
"-hls_flags", "independent_segments+temp_file", "-start_number", strconv.Itoa(index),
"-hls_segment_filename", filepath.Join(directory, "%06d.ts"), filepath.Join(directory, "index.m3u8"))
}
func (cache *playbackNative) render(job *playbackNativeJob) error {
if err := job.ctx.Err(); err != nil {
return err
}
directory, err := os.MkdirTemp(cache.directory, "batch-")
if err != nil {
return err
}
defer os.RemoveAll(directory)
args := playbackNativeArgs(cache.media, cache.input, directory, job.start, job.end)
cache.mu.Lock()
background := cache.background
cache.mu.Unlock()
if background {
for i := 0; i+1 < len(args); i++ {
if args[i] == "-threads" {
args[i+1] = "1"
}
}
}
release, err := cache.app.mediaResources().acquire(job.ctx, "video", background)
if err != nil {
return err
}
defer release()
command := ffmpegMediaCommand(job.ctx, cache.ffmpeg, args...)
log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})}
command.Stderr = log
if err := command.Start(); err != nil {
return fmt.Errorf("无法启动在线播放转码:%w", err)
}
done := make(chan error, 1)
go func() { done <- command.Wait() }()
ticker := time.NewTicker(40 * time.Millisecond)
defer ticker.Stop()
next := job.start
collect := func() error {
duration, _ := log.snapshot()
cache.mu.Lock()
if duration > 0 && cache.duration <= 0 {
cache.duration = duration
}
cache.mu.Unlock()
for next < job.end {
path := filepath.Join(directory, fmt.Sprintf("%06d.ts", next))
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Size() <= 0 || info.Size() > playbackNativeSegmentBytes {
return errors.New("播放分片大小异常")
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
_ = os.Remove(path)
cache.mu.Lock()
if cache.job != job || cache.closed {
cache.mu.Unlock()
return context.Canceled
}
cache.bytes -= len(cache.segments[next])
cache.segments[next] = body
cache.bytes += len(body)
cache.evictLocked(next)
cache.notifyLocked()
cache.mu.Unlock()
next++
}
return nil
}
for {
select {
case err := <-done:
if err != nil {
return playbackStreamError(job.ctx, cache.proxy, log, cache.media, err, nil)
}
if err := collect(); err != nil {
return err
}
cache.mu.Lock()
count := cache.segmentCountLocked()
cache.mu.Unlock()
if count == 0 || next <= job.start {
return errors.New("未取得有效的播放时长或视频分片")
}
if next < job.end && next < count {
return errors.New("视频分片未完整生成,请重试播放")
}
return nil
case <-ticker.C:
if err := collect(); err != nil {
job.cancel()
<-done
return err
}
case <-job.ctx.Done():
<-done
return job.ctx.Err()
}
}
}
func (cache *playbackNative) evictLocked(keep int) {
for cache.bytes > playbackNativeCacheBytes {
victim, distance := -1, -1
for index := range cache.segments {
if index == keep || index == cache.wanted {
continue
}
delta := index - cache.wanted
if delta < 0 {
delta = -delta
}
if delta > distance {
victim, distance = index, delta
}
}
if victim < 0 {
return
}
cache.bytes -= len(cache.segments[victim])
delete(cache.segments, victim)
}
}
func (cache *playbackNative) segment(ctx context.Context, index int) ([]byte, error) {
for {
if err := ctx.Err(); err != nil {
return nil, err
}
cache.mu.Lock()
if cache.closed || cache.ctx.Err() != nil {
cache.mu.Unlock()
return nil, errors.New("播放已停止,请重新打开本集")
}
if index < 0 || cache.segmentCountLocked() > 0 && index >= cache.segmentCountLocked() {
cache.mu.Unlock()
return nil, errors.New("播放分片不存在")
}
cache.wanted = index
if body := cache.segments[index]; len(body) > 0 {
cache.prefillLocked(index)
cache.mu.Unlock()
return body, nil
}
if cache.err != nil {
err := cache.err
cache.mu.Unlock()
return nil, err
}
cache.startJobLocked(index)
changed := cache.changed
cache.mu.Unlock()
select {
case <-changed:
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func (cache *playbackNative) prefillLocked(index int) {
job := cache.job
if cache.background || job == nil || cache.err != nil || cache.closed || index < job.end-3 || job.end >= cache.segmentCountLocked() {
return
}
select {
case <-job.done:
if len(cache.segments[job.end]) == 0 {
cache.startJobLocked(job.end)
}
default:
}
}
func (cache *playbackNative) metadata() (providerMedia, float64, string) {
cache.mu.Lock()
defer cache.mu.Unlock()
source := "online"
if cache.proxy == nil {
source = "local"
}
return cache.media, cache.duration, source
}
func (cache *playbackNative) state() (string, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.err != nil {
return "failed", cache.err
}
if cache.closed || cache.ctx.Err() != nil {
return "stopped", nil
}
count := cache.segmentCountLocked()
if count > 0 && len(cache.segments[count-1]) > 0 {
return "ended", nil
}
if len(cache.segments) > 0 {
return "streaming", nil
}
return "buffering", nil
}