node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,667 @@
package transcoder
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"seanime/internal/util"
"slices"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
lop "github.com/samber/lo/parallel"
)
type Flags int32
const (
AudioF Flags = 1 << 0
VideoF Flags = 1 << 1
Transmux Flags = 1 << 3
)
type StreamHandle interface {
getTranscodeArgs(segments string) []string
getOutPath(encoderId int) string
getFlags() Flags
}
type Stream struct {
kind string
handle StreamHandle
file *FileStream
segments []Segment
heads []Head
// the lock used for the heads
//lock sync.RWMutex
segmentsLock sync.RWMutex
headsLock sync.RWMutex
logger *zerolog.Logger
settings *Settings
killCh chan struct{}
ctx context.Context
cancel context.CancelFunc
}
type Segment struct {
// channel open if the segment is not ready. closed if ready.
// one can check if segment 1 is open by doing:
//
// ts.isSegmentReady(1).
//
// You can also wait for it to be ready (non-blocking if already ready) by doing:
// <-ts.segments[i]
channel chan struct{}
encoder int
}
type Head struct {
segment int32
end int32
command *exec.Cmd
stdin io.WriteCloser
}
var DeletedHead = Head{
segment: -1,
end: -1,
command: nil,
}
var streamLogger = util.NewLogger()
func NewStream(
kind string,
file *FileStream,
handle StreamHandle,
ret *Stream,
settings *Settings,
logger *zerolog.Logger,
) {
ret.kind = kind
ret.handle = handle
ret.file = file
ret.heads = make([]Head, 0)
ret.settings = settings
ret.logger = logger
ret.killCh = make(chan struct{})
ret.ctx, ret.cancel = context.WithCancel(context.Background())
length, isDone := file.Keyframes.Length()
ret.segments = make([]Segment, length, max(length, 2000))
for seg := range ret.segments {
ret.segments[seg].channel = make(chan struct{})
}
if !isDone {
file.Keyframes.AddListener(func(keyframes []float64) {
ret.segmentsLock.Lock()
defer ret.segmentsLock.Unlock()
oldLength := len(ret.segments)
if cap(ret.segments) > len(keyframes) {
ret.segments = ret.segments[:len(keyframes)]
} else {
ret.segments = append(ret.segments, make([]Segment, len(keyframes)-oldLength)...)
}
for seg := oldLength; seg < len(keyframes); seg++ {
ret.segments[seg].channel = make(chan struct{})
}
})
}
}
func (ts *Stream) GetIndex() (string, error) {
// playlist type is event since we can append to the list if Keyframe.IsDone is false.
// start time offset makes the stream start at 0s instead of ~3segments from the end (requires version 6 of hls)
index := `#EXTM3U
#EXT-X-VERSION:6
#EXT-X-PLAYLIST-TYPE:EVENT
#EXT-X-START:TIME-OFFSET=0
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
`
length, isDone := ts.file.Keyframes.Length()
for segment := int32(0); segment < length-1; segment++ {
index += fmt.Sprintf("#EXTINF:%.6f\n", ts.file.Keyframes.Get(segment+1)-ts.file.Keyframes.Get(segment))
index += fmt.Sprintf("segment-%d.ts\n", segment)
}
// do not forget to add the last segment between the last keyframe and the end of the file
// if the keyframes extraction is not done, do not bother to add it, it will be retrived on the next index retrival
if isDone {
index += fmt.Sprintf("#EXTINF:%.6f\n", float64(ts.file.Info.Duration)-ts.file.Keyframes.Get(length-1))
index += fmt.Sprintf("segment-%d.ts\n", length-1)
index += `#EXT-X-ENDLIST`
}
return index, nil
}
// GetSegment returns the path to the segment and waits for it to be ready.
func (ts *Stream) GetSegment(segment int32) (string, error) {
// DEVNOTE: Reset the kill channel
// This is needed because when the segment is needed again, this channel should be open
ts.killCh = make(chan struct{})
if debugStream {
streamLogger.Trace().Msgf("transcoder: Getting segment %d [GetSegment]", segment)
defer streamLogger.Trace().Msgf("transcoder: Retrieved segment %d [GetSegment]", segment)
}
ts.segmentsLock.RLock()
ts.headsLock.RLock()
ready := ts.isSegmentReady(segment)
// we want to calculate distance in the same lock else it can be funky
distance := 0.
isScheduled := false
if !ready {
distance = ts.getMinEncoderDistance(segment)
for _, head := range ts.heads {
if head.segment <= segment && segment < head.end {
isScheduled = true
break
}
}
}
readyChan := ts.segments[segment].channel
ts.segmentsLock.RUnlock()
ts.headsLock.RUnlock()
if !ready {
// Only start a new encode if there is too big a distance between the current encoder and the segment.
if distance > 60 || !isScheduled {
streamLogger.Trace().Msgf("transcoder: New encoder for segment %d", segment)
err := ts.run(segment)
if err != nil {
return "", err
}
} else {
streamLogger.Trace().Msgf("transcoder: Awaiting segment %d - %.2fs gap", segment, distance)
}
select {
// DEVNOTE: This can cause issues if the segment is called again but was "killed" beforehand
// It's used to interrupt the waiting process but might not be needed since there's a timeout
case <-ts.killCh:
return "", fmt.Errorf("transcoder: Stream killed while waiting for segment %d", segment)
case <-readyChan:
break
case <-time.After(25 * time.Second):
streamLogger.Error().Msgf("transcoder: Could not retrieve %s segment %d (timeout)", ts.kind, segment)
return "", errors.New("could not retrieve segment (timeout)")
}
}
//go ts.prepareNextSegments(segment)
ts.prepareNextSegments(segment)
return fmt.Sprintf(filepath.ToSlash(ts.handle.getOutPath(ts.segments[segment].encoder)), segment), nil
}
// prepareNextSegments will start the next segments if they are not already started.
func (ts *Stream) prepareNextSegments(segment int32) {
//if ts.IsKilled() {
// return
//}
// Audio is way cheaper to create than video, so we don't need to run them in advance
// Running it in advance might actually slow down the video encode since less compute
// power can be used, so we simply disable that.
if ts.handle.getFlags()&VideoF == 0 {
return
}
ts.segmentsLock.RLock()
defer ts.segmentsLock.RUnlock()
ts.headsLock.RLock()
defer ts.headsLock.RUnlock()
for i := segment + 1; i <= min(segment+10, int32(len(ts.segments)-1)); i++ {
// If the segment is already ready, we don't need to start a new encoder.
if ts.isSegmentReady(i) {
continue
}
// only start encode for segments not planned (getMinEncoderDistance returns Inf for them)
// or if they are 60s away (assume 5s per segments)
if ts.getMinEncoderDistance(i) < 60+(5*float64(i-segment)) {
continue
}
streamLogger.Trace().Msgf("transcoder: Creating new encoder head for future segment %d", i)
go func() {
_ = ts.run(i)
}()
return
}
}
func (ts *Stream) getMinEncoderDistance(segment int32) float64 {
t := ts.file.Keyframes.Get(segment)
distances := lop.Map(ts.heads, func(head Head, _ int) float64 {
// ignore killed heads or heads after the current time
if head.segment < 0 || ts.file.Keyframes.Get(head.segment) > t || segment >= head.end {
return math.Inf(1)
}
return t - ts.file.Keyframes.Get(head.segment)
})
if len(distances) == 0 {
return math.Inf(1)
}
return slices.Min(distances)
}
func (ts *Stream) Kill() {
streamLogger.Trace().Msgf("transcoder: Killing %s stream", ts.kind)
defer streamLogger.Trace().Msg("transcoder: Stream killed")
ts.lockHeads()
defer ts.unlockHeads()
for id := range ts.heads {
ts.KillHead(id)
}
}
func (ts *Stream) IsKilled() bool {
select {
case <-ts.killCh:
// if the channel returned, it means it was closed
return true
default:
return false
}
}
// KillHead
// Stream is assumed to be locked
func (ts *Stream) KillHead(encoderId int) {
//streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Killing %s encoder head", ts.kind)
defer streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Killed %s encoder head", ts.kind)
defer func() {
if r := recover(); r != nil {
}
}()
close(ts.killCh)
ts.cancel()
if ts.heads[encoderId] == DeletedHead || ts.heads[encoderId].command == nil {
return
}
ts.heads[encoderId].command.Process.Signal(os.Interrupt)
//_, _ = ts.heads[encoderId].stdin.Write([]byte("q"))
//_ = ts.heads[encoderId].stdin.Close()
ts.heads[encoderId] = DeletedHead
}
func (ts *Stream) SetIsKilled() {
}
//////////////////////////////
// Remember to lock before calling this.
func (ts *Stream) isSegmentReady(segment int32) bool {
select {
case <-ts.segments[segment].channel:
// if the channel returned, it means it was closed
return true
default:
return false
}
}
func (ts *Stream) isSegmentTranscoding(segment int32) bool {
for _, head := range ts.heads {
if head.segment == segment {
return true
}
}
return false
}
func toSegmentStr(segments []float64) string {
return strings.Join(lo.Map(segments, func(seg float64, _ int) string {
return fmt.Sprintf("%.6f", seg)
}), ",")
}
func (ts *Stream) run(start int32) error {
//if ts.IsKilled() {
// return nil
//}
ts.logger.Trace().Msgf("transcoder: Running %s encoder head from %d", ts.kind, start)
// Start the transcoder up to the 100th segment (or less)
length, isDone := ts.file.Keyframes.Length()
end := min(start+100, length)
// if keyframes analysis is not finished, always have a 1-segment padding
// for the extra segment needed for precise split (look comment before -to flag)
if !isDone {
end -= 2
}
// Stop at the first finished segment
ts.lockSegments()
for i := start; i < end; i++ {
if ts.isSegmentReady(i) || ts.isSegmentTranscoding(i) {
end = i
break
}
}
if start >= end {
// this can happen if the start segment was finished between the check
// to call run() and the actual call.
// since most checks are done in a RLock() instead of a Lock() this can
// happens when two goroutines try to make the same segment ready
ts.unlockSegments()
return nil
}
ts.unlockSegments()
ts.lockHeads()
encoderId := len(ts.heads)
ts.heads = append(ts.heads, Head{segment: start, end: end, command: nil})
ts.unlockHeads()
streamLogger.Trace().Any("eid", encoderId).Msgf(
"transcoder: Transcoding %d-%d/%d segments for %s",
start,
end,
length,
ts.kind,
)
// Include both the start and end delimiter because -ss and -to are not accurate
// Having an extra segment allows us to cut precisely the segments we want with the
// -f segment that does cut the beginning and the end at the keyframe like asked
startRef := float64(0)
startSeg := start
if start != 0 {
// we always take on segment before the current one, for different reasons for audio/video:
// - Audio: we need context before the starting point, without that ffmpeg doesn't know what to do and leave ~100ms of silence
// - Video: if a segment is really short (between 20 and 100ms), the padding given in the else block bellow is not enough and
// the previous segment is played another time. the -segment_times is way more precise, so it does not do the same with this one
startSeg = start - 1
if ts.handle.getFlags()&AudioF != 0 {
startRef = ts.file.Keyframes.Get(startSeg)
} else {
// the param for the -ss takes the keyframe before the specified time
// (if the specified time is a keyframe, it either takes that keyframe or the one before)
// to prevent this weird behavior, we specify a bit after the keyframe that interest us
// this can't be used with audio since we need to have context before the start-time
// without this context, the cut loses a bit of audio (audio gap of ~100ms)
if startSeg+1 == length {
startRef = (ts.file.Keyframes.Get(startSeg) + float64(ts.file.Info.Duration)) / 2
} else {
startRef = (ts.file.Keyframes.Get(startSeg) + ts.file.Keyframes.Get(startSeg+1)) / 2
}
}
}
endPadding := int32(1)
if end == length {
endPadding = 0
}
segments := ts.file.Keyframes.Slice(start+1, end+endPadding)
if len(segments) == 0 {
// we can't leave that empty else ffmpeg errors out.
segments = []float64{9999999}
}
outpath := ts.handle.getOutPath(encoderId)
err := os.MkdirAll(filepath.Dir(outpath), 0755)
if err != nil {
return err
}
args := []string{
"-nostats", "-hide_banner", "-loglevel", "warning",
}
args = append(args, ts.settings.HwAccel.DecodeFlags...)
if startRef != 0 {
if ts.handle.getFlags()&VideoF != 0 {
// This is the default behavior in transmux mode and needed to force pre/post segment to work
// This must be disabled when processing only audio because it creates gaps in audio
args = append(args, "-noaccurate_seek")
}
args = append(args,
"-ss", fmt.Sprintf("%.6f", startRef),
)
}
// do not include -to if we want the file to go to the end
if end+1 < length {
// sometimes, the duration is shorter than expected (only during transcode it seems)
// always include more and use the -f segment to split the file where we want
endRef := ts.file.Keyframes.Get(end + 1)
// it seems that the -to is confused when -ss seek before the given time (because it searches for a keyframe)
// add back the time that would be lost otherwise
// this only happens when -to is before -i but having -to after -i gave a bug (not sure, don't remember)
endRef += startRef - ts.file.Keyframes.Get(startSeg)
args = append(args,
"-to", fmt.Sprintf("%.6f", endRef),
)
}
args = append(args,
"-i", ts.file.Path,
// this makes behaviors consistent between soft and hardware decodes.
// this also means that after a -ss 50, the output video will start at 50s
"-start_at_zero",
// for hls streams, -copyts is mandatory
"-copyts",
// this makes output file start at 0s instead of a random delay + the -ss value
// this also cancel -start_at_zero weird delay.
// this is not always respected, but generally it gives better results.
// even when this is not respected, it does not result in a bugged experience but this is something
// to keep in mind when debugging
"-muxdelay", "0",
)
args = append(args, ts.handle.getTranscodeArgs(toSegmentStr(segments))...)
args = append(args,
"-f", "segment",
// needed for rounding issues when forcing keyframes
// recommended value is 1/(2*frame_rate), which for a 24fps is ~0.021
// we take a little bit more than that to be extra safe but too much can be harmful
// when segments are short (can make the video repeat itself)
"-segment_time_delta", "0.05",
"-segment_format", "mpegts",
"-segment_times", toSegmentStr(lop.Map(segments, func(seg float64, _ int) float64 {
// segment_times want durations, not timestamps so we must substract the -ss param
// since we give a greater value to -ss to prevent wrong seeks but -segment_times
// needs precise segments, we use the keyframe we want to seek to as a reference.
return seg - ts.file.Keyframes.Get(startSeg)
})),
"-segment_list_type", "flat",
"-segment_list", "pipe:1",
"-segment_start_number", fmt.Sprint(start),
outpath,
)
// Added logging for ffmpeg command and hardware transcoding state
streamLogger.Trace().Msgf("transcoder: ffmpeg command: %s %s", ts.settings.FfmpegPath, strings.Join(args, " "))
if len(ts.settings.HwAccel.DecodeFlags) > 0 {
streamLogger.Trace().Msgf("transcoder: Hardware transcoding enabled with flags: %v", ts.settings.HwAccel.DecodeFlags)
} else {
streamLogger.Trace().Msg("transcoder: Hardware transcoding not enabled")
}
cmd := util.NewCmdCtx(context.Background(), ts.settings.FfmpegPath, args...)
streamLogger.Trace().Msgf("transcoder: Executing ffmpeg for segments %d-%d of %s", start, end, ts.kind)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
var stderr strings.Builder
cmd.Stderr = &stderr
err = cmd.Start()
if err != nil {
return err
}
ts.lockHeads()
ts.heads[encoderId].command = cmd
ts.heads[encoderId].stdin = stdin
ts.unlockHeads()
go func(stdin io.WriteCloser) {
scanner := bufio.NewScanner(stdout)
format := filepath.Base(outpath)
shouldStop := false
for scanner.Scan() {
var segment int32
_, _ = fmt.Sscanf(scanner.Text(), format, &segment)
// If the segment number is less than the starting segment (start), it means it's not relevant for the current processing, so we skip it
if segment < start {
// This happens because we use -f segments for accurate cutting (since -ss is not)
// check comment at beginning of function for more info
continue
}
ts.lockHeads()
ts.heads[encoderId].segment = segment
ts.unlockHeads()
if debugFfmpegOutput {
streamLogger.Debug().Int("eid", encoderId).Msgf("t: \t ffmpeg finished segment %d/%d (%d-%d) of %s", segment, end, start, end, ts.kind)
}
ts.lockSegments()
// If the segment is already marked as done, we can stop the ffmpeg process
if ts.isSegmentReady(segment) {
// the current segment is already marked as done so another process has already gone up to here.
_, _ = stdin.Write([]byte("q"))
_ = stdin.Close()
//cmd.Process.Signal(os.Interrupt)
if debugFfmpeg {
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Terminated ffmpeg, segment %d is ready", segment)
}
shouldStop = true
} else {
// Mark the segment as ready
ts.segments[segment].encoder = encoderId
close(ts.segments[segment].channel)
if segment == end-1 {
// file finished, ffmpeg will finish soon on its own
shouldStop = true
} else if ts.isSegmentReady(segment + 1) {
// If the next segment is already marked as done, we can stop the ffmpeg process
_, _ = stdin.Write([]byte("q"))
_ = stdin.Close()
//cmd.Process.Signal(os.Interrupt)
if debugFfmpeg {
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Terminated ffmpeg, next segment %d is ready", segment)
}
shouldStop = true
}
}
ts.unlockSegments()
// we need this and not a return in the condition because we want to unlock
// the lock (and can't defer since this is a loop)
if shouldStop {
if debugFfmpeg {
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg completed segments %d-%d/%d of %s", start, end, length, ts.kind)
}
return
}
}
if err := scanner.Err(); err != nil {
streamLogger.Error().Int("eid", encoderId).Err(err).Msg("transcoder: Error scanning ffmpeg output")
return
}
}(stdin)
// Listen for kill signal
go func(stdin io.WriteCloser) {
select {
case <-ts.ctx.Done():
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: Aborting ffmpeg process for %s", ts.kind)
_, _ = stdin.Write([]byte("q"))
_ = stdin.Close()
return
}
}(stdin)
// Listen for process termination
go func() {
err := cmd.Wait()
var exitErr *exec.ExitError
// Check if hardware acceleration was attempted and if stderr indicates a failure to use it
if len(ts.settings.HwAccel.DecodeFlags) > 0 {
lowerOutput := strings.ToLower(stderr.String())
if strings.Contains(lowerOutput, "failed") &&
(strings.Contains(lowerOutput, "hwaccel") || strings.Contains(lowerOutput, "vaapi") || strings.Contains(lowerOutput, "cuvid") || strings.Contains(lowerOutput, "vdpau")) {
streamLogger.Warn().Int("eid", encoderId).Msg("transcoder: ffmpeg failed to use hardware acceleration settings; falling back to CPU")
}
}
if errors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg process was terminated")
} else if err != nil {
streamLogger.Error().Int("eid", encoderId).Err(fmt.Errorf("%s: %s", err, stderr.String())).Msgf("transcoder: ffmpeg process failed")
} else {
streamLogger.Trace().Int("eid", encoderId).Msgf("transcoder: ffmpeg process for %s exited", ts.kind)
}
ts.lockHeads()
defer ts.unlockHeads()
// we can't delete the head directly because it would invalidate the others encoderId
ts.heads[encoderId] = DeletedHead
}()
return nil
}
const debugLocks = false
const debugFfmpeg = true
const debugFfmpegOutput = false
const debugStream = false
func (ts *Stream) lockHeads() {
if debugLocks {
streamLogger.Debug().Msg("t: Locking heads")
}
ts.headsLock.Lock()
if debugLocks {
streamLogger.Debug().Msg("t: \t\tLocked heads")
}
}
func (ts *Stream) unlockHeads() {
if debugLocks {
streamLogger.Debug().Msg("t: Unlocking heads")
}
ts.headsLock.Unlock()
if debugLocks {
streamLogger.Debug().Msg("t: \t\tUnlocked heads")
}
}
func (ts *Stream) lockSegments() {
if debugLocks {
streamLogger.Debug().Msg("t: Locking segments")
}
ts.segmentsLock.Lock()
if debugLocks {
streamLogger.Debug().Msg("t: \t\tLocked segments")
}
}
func (ts *Stream) unlockSegments() {
if debugLocks {
streamLogger.Debug().Msg("t: Unlocking segments")
}
ts.segmentsLock.Unlock()
if debugLocks {
streamLogger.Debug().Msg("t: \t\tUnlocked segments")
}
}