This commit is contained in:
marcsello 2025-10-09 22:21:38 +02:00
commit fa47df6d89
22 changed files with 1724 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.idea/
db/

74
README.md Normal file
View file

@ -0,0 +1,74 @@
# stream-poc2
Very basic PoC to try out HLS with multiple audio and video tracks, with live transcoding using NVENC.
(Subtitles are not supported) (Only 16:9 video is supported)
## High-level overview
There is a "db" where all imported media data is stored.
The code is split into two: "import" and "serve".
"import" used to import various media files into that "db".
During import, streams are extracted, and sliced up to be served by HLS.
All audio streams are encoded to mp3 if needed, for better compatibility.
Also, a few video slices are pre-encoded and stored in the "cache" folder.
"serve" runs the http server to watch the imported media and tune the various dials.
Those dials are:
- "Video" here are all the video "profiles" in the HLS stream, all of them except "orig" is transcoded live with ffmpeg and NVENC (if not served from the cache)
- "Audio" is the audio stream.
- "NVENC Preset" The various AVC/HEVC presets supported by NVENC **this sets the value globally on the server**
**When tuning the dials, the video buffer is flushed, so the effect can be seen immanently. This is only for showcase as it is not desired in a real-world scenario.**
## Usage
Create a new folder for the "db":
This is where all metadata, original media chunks and cache is stored.
```
mkdir db
```
Import media files:
```
go run . import <path to media file> <media id>
```
"media id" is a url-safe string used to identify the media file on http and in the db folder.
Then start the server:
```
go run . server
```
Then open <http://localhost:8080> in your browser, select the media and enjoy!
## Code notes
chunks, slices and segments all mean the same trough the code.
## db structure:
- `db/` Root folder for the "db".
- `{id}/` Folder for each imported media, identified by "id".
- `meta.json` All metadata used by the server to handle the video.
- `ffprobe.json` Output of ffprobe stored for debugging purposes.
- `segments_video.csv` Output of the segment data for the video stream. Used during import, kept for debugging.
- `segments_audio_{idx}.csv` Output of the segment data for the audio stream identified by "idx". Used during import, kept for debugging.
- `video/` Folder for the video segments in the original format.
- `s{i}.ts` Video segment number "i".
- `audio/` Folder for the audio segments in mp3 format.
- `{idx}/` Folder for the audio segments for the audio stream identified by "idx".
- `s{i}.ts` Audio segment number "i".
- `cache/` Cache folder for pre-transcoded video segments.
- `video/` Video cache folder.
- `{profile}/` Folder for a specific transcoding profile.
- `s{i}.ts` Transcoded video segment number "i".

55
db.go Normal file
View file

@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"log"
"os"
"path"
"stream-poc2/model"
)
func pathForMedia(mediaID string) string {
return path.Join("db", mediaID)
}
var metadataCache = make(map[string]model.MediaMetadata) // metadata is currently immutable, so we can make it simple here
func loadMediaMetadata(id string) (model.MediaMetadata, error) {
var metadata model.MediaMetadata
var ok bool
metadata, ok = metadataCache[id]
if ok {
return metadata, nil
}
log.Printf("metadata for %s not found in cache... loading...", id)
mediaPath := pathForMedia(id)
if !isDir(mediaPath) {
log.Println("media path dir does not exists", mediaPath)
return model.MediaMetadata{}, os.ErrNotExist
}
f, err := os.OpenFile(path.Join(mediaPath, "meta.json"), os.O_RDONLY, 0)
if err != nil {
log.Println("Failed ot open file:", err)
return model.MediaMetadata{}, err
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Println("failed to close metadata file:", err)
}
}(f)
err = json.NewDecoder(f).Decode(&metadata)
if err != nil {
log.Println("Failed to load metadata:", metadata)
return model.MediaMetadata{}, err
}
metadataCache[id] = metadata
return metadata, nil
}

33
ffmpeg/const.go Normal file
View file

@ -0,0 +1,33 @@
package ffmpeg
import "slices"
const SlicerTargetDurationSec = 4
var ValidNVENCPresets = []string{
// ffmpeg -hide_banner -h encoder=h264_nvenc
// ffmpeg -hide_banner -h encoder=hevc_nvenc
"default",
"slow",
"medium",
"fast",
"hp",
"hq",
"bd",
"ll",
"llhq",
"llhp",
"lossless",
"losslesshp",
"p1",
"p2",
"p3",
"p4",
"p5",
"p6",
"p7",
}
func IsNVENCPresetValid(preset string) bool {
return slices.Contains(ValidNVENCPresets, preset)
}

120
ffmpeg/ffmpeg.go Normal file
View file

@ -0,0 +1,120 @@
package ffmpeg
import (
"fmt"
"io"
"log"
"os"
"os/exec"
"path"
"strconv"
"stream-poc2/model"
)
// EncodeChunk doesn't have the nicest function signature...
func EncodeChunk(profileSettings model.VideoProfileSettings, nativeHeight int, preset string, origSegmentPath string, writer io.Writer) error {
ffmpegArgs := []string{
"-nostdin", "-hide_banner", "-loglevel", "error",
}
if profileSettings.Resolution.Height != nativeHeight {
// apply resize filter
ffmpegArgs = append(ffmpegArgs,
"-hwaccel", "cuda", "-hwaccel_output_format", "cuda",
"-i", origSegmentPath,
"-vf", fmt.Sprintf("scale_cuda=%d:%d", profileSettings.Resolution.Width, profileSettings.Resolution.Height),
)
} else {
// just load the segment
ffmpegArgs = append(ffmpegArgs,
"-i", origSegmentPath,
)
}
ffmpegArgs = append(ffmpegArgs,
"-copyts",
"-c:v", profileSettings.FFMpegCodec(),
)
if preset != "" {
ffmpegArgs = append(ffmpegArgs,
"-preset", preset,
)
}
ffmpegArgs = append(ffmpegArgs,
"-b:v", strconv.Itoa(profileSettings.AvgBitrate), "-maxrate", strconv.Itoa(profileSettings.MaxBitrate), "-bufsize", "1M",
"-an", // <- no audio
"-f", "mpegts", "-mpegts_copyts", "1",
"-",
)
log.Println("exec ffmpeg args: ", ffmpegArgs)
cmd := exec.Command("/usr/bin/ffmpeg", ffmpegArgs...)
cmd.Stdout = writer
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
log.Println("Failed to run:", err)
return err
}
return nil
}
// MakeSegments has the ugliest function signature no the planet
func MakeSegments(filepath, outdir string, audioStreams []model.AudioStream) (string, []string, error) {
segmentsVideoCSV := path.Join(outdir, "segments_video.csv")
segmentsAudioCSV := make([]string, len(audioStreams))
// do slicing
ffmpegArgs := []string{
"-nostdin", "-hide_banner", "-loglevel", "error",
"-i", filepath,
// video stream
"-c:v", "copy", "-an",
"-f", "ssegment", "-segment_format", "mpegts", "-segment_list", segmentsVideoCSV,
"-segment_list_type", "csv", "-segment_time", strconv.Itoa(SlicerTargetDurationSec), path.Join(outdir, "video", "s%d.ts"),
}
for i, audioStream := range audioStreams {
csvName := path.Join(outdir, fmt.Sprintf("segments_audio_%d.csv", audioStream.Index))
segmentsAudioCSV[i] = csvName
ffmpegArgs = append(ffmpegArgs,
// audio streams (encode to mp3)
"-vn", "-map", fmt.Sprintf("0:a:%d", i),
)
if audioStream.Codec != "mp3" {
// mp3 always works
// TODO: This does not update the stream info in the metadata !!!
log.Println("Transcoding audio to mp3 for stream ", audioStream.Index)
ffmpegArgs = append(ffmpegArgs,
"-c:a", "libmp3lame", "-q:a", "0",
)
} else {
log.Println("Not transcoding audio as it is already mp3 for stream ", audioStream.Index)
ffmpegArgs = append(ffmpegArgs,
"-c:a", "copy",
)
}
ffmpegArgs = append(ffmpegArgs,
"-f", "ssegment", "-segment_format", "mpegts", "-segment_list", csvName,
"-segment_list_type", "csv", "-segment_time", strconv.Itoa(SlicerTargetDurationSec), path.Join(outdir, "audio", strconv.Itoa(audioStream.Index), "s%d.ts"),
)
}
cmd := exec.Command("/usr/bin/ffmpeg", ffmpegArgs...)
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
log.Println("failed to segment:", err)
return "", nil, err
}
return segmentsVideoCSV, segmentsAudioCSV, nil
}

86
ffmpeg/ffprobe.go Normal file
View file

@ -0,0 +1,86 @@
package ffmpeg
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
)
func FFProbe(fname string) (FFProbeOutput, error) {
log.Println("probe", fname)
cmd := exec.Command(
"/usr/bin/ffprobe",
"-loglevel", "error",
fname,
"-show_streams",
"-of", "json",
)
cmd.Stderr = os.Stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Println("Failed to pipe", err)
return FFProbeOutput{}, err
}
err = cmd.Start()
if err != nil {
log.Println("Failed start", err)
return FFProbeOutput{}, err
}
var ffout FFProbeOutput
err = json.NewDecoder(stdout).Decode(&ffout)
if err != nil {
log.Println("Failed to decode", err)
return FFProbeOutput{}, err
}
return ffout, nil
}
func FigureOutHLSCodecsParam(stream FFProbeStream) (string, error) {
// TODO: This is still very lame
var avcProfileBits = map[string]int{
"High": 0x64,
"Main": 0x4D,
"Baseline": 0x42,
}
var hevcProfileBits = map[string]int{
"Main": 1,
"Main 10": 2,
}
if stream.CodecType == "video" {
if stream.CodecName == "h264" {
//avc1.<profile_idc><constraint_set_flags><level_idc>
codecTagBytes, err := hex.DecodeString(strings.TrimPrefix(stream.CodecTag, "0x"))
if err != nil {
return "", err
}
return fmt.Sprintf(
"%s.%02X%02X%02X",
stream.CodecTagString,
avcProfileBits[stream.Profile],
codecTagBytes[1],
stream.Level,
), nil
}
if stream.CodecName == "hevc" {
// hvc1.<profile>.<compatibility>.<tier><level><constraints>
return fmt.Sprintf(
"%s.%d.L%d",
stream.CodecTagString,
hevcProfileBits[stream.Profile],
stream.Level,
), nil
}
}
return "", fmt.Errorf("unrecognized format")
}

76
ffmpeg/model.go Normal file
View file

@ -0,0 +1,76 @@
package ffmpeg
type FFProbeDispositions struct {
Default int `json:"default"`
Dub int `json:"dub"`
Original int `json:"original"`
Comment int `json:"comment"`
Lyrics int `json:"lyrics"`
Karaoke int `json:"karaoke"`
Forced int `json:"forced"`
HearingImpaired int `json:"hearing_impaired"`
VisualImpaired int `json:"visual_impaired"`
CleanEffects int `json:"clean_effects"`
AttachedPic int `json:"attached_pic"`
TimedThumbnails int `json:"timed_thumbnails"`
NonDiegetic int `json:"non_diegetic"`
Captions int `json:"captions"`
Descriptions int `json:"descriptions"`
Metadata int `json:"metadata"`
Dependent int `json:"dependent"`
StillImage int `json:"still_image"`
Multilayer int `json:"multilayer"`
}
type FFProbeStream struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecLongName string `json:"codec_long_name"`
CodecType string `json:"codec_type"`
CodecTagString string `json:"codec_tag_string"`
CodecTag string `json:"codec_tag"`
SampleFmt string `json:"sample_fmt,omitempty"`
SampleRate string `json:"sample_rate,omitempty"`
Channels int `json:"channels,omitempty"`
ChannelLayout string `json:"channel_layout,omitempty"`
BitsPerSample int `json:"bits_per_sample,omitempty"`
InitialPadding int `json:"initial_padding,omitempty"`
DmixMode string `json:"dmix_mode,omitempty"`
LtrtCmixlev string `json:"ltrt_cmixlev,omitempty"`
LtrtSurmixlev string `json:"ltrt_surmixlev,omitempty"`
LoroCmixlev string `json:"loro_cmixlev,omitempty"`
LoroSurmixlev string `json:"loro_surmixlev,omitempty"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
TimeBase string `json:"time_base"`
StartPts int `json:"start_pts"`
StartTime string `json:"start_time"`
BitRate string `json:"bit_rate,omitempty"`
Disposition FFProbeDispositions `json:"disposition"`
Tags map[string]string `json:"tags"`
DurationTs int `json:"duration_ts,omitempty"`
Duration string `json:"duration,omitempty"`
Profile string `json:"profile,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
CodedWidth int `json:"coded_width,omitempty"`
CodedHeight int `json:"coded_height,omitempty"`
HasBFrames int `json:"has_b_frames,omitempty"`
SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"`
DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"`
PixFmt string `json:"pix_fmt,omitempty"`
Level int `json:"level,omitempty"`
ColorRange string `json:"color_range,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ChromaLocation string `json:"chroma_location,omitempty"`
FieldOrder string `json:"field_order,omitempty"`
Refs int `json:"refs,omitempty"`
IsAvc string `json:"is_avc,omitempty"`
NalLengthSize string `json:"nal_length_size,omitempty"`
BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"`
ExtradataSize int `json:"extradata_size,omitempty"`
}
type FFProbeOutput struct {
Streams []FFProbeStream `json:"streams"`
}

62
ffmpeg/slices.go Normal file
View file

@ -0,0 +1,62 @@
package ffmpeg
import (
"encoding/csv"
"errors"
"io"
"log"
"os"
"strconv"
"stream-poc2/model"
)
func ReadSegmentsCSV(fname string) ([]model.Slice, error) {
log.Println("read csv:", fname)
f, err := os.OpenFile(fname, os.O_RDONLY, 0)
if err != nil {
log.Println("failed to open CSV:", err)
return nil, err
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Println("Failed to close CSV:", err)
}
}(f)
r := csv.NewReader(f)
var slices []model.Slice
for {
var cols []string
cols, err = r.Read()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
log.Println("failed reading csv row", err)
return nil, err
}
var startTime float64
var endTime float64
startTime, err = strconv.ParseFloat(cols[1], 64)
if err != nil {
log.Println("Failed to parse float", err)
return nil, err
}
endTime, err = strconv.ParseFloat(cols[2], 64)
if err != nil {
log.Println("Failed to parse float", err)
return nil, err
}
slices = append(slices, model.Slice{
Name: cols[0],
StartTime: startTime,
EndTime: endTime,
})
}
return slices, nil
}

38
go.mod Normal file
View file

@ -0,0 +1,38 @@
module stream-poc2
go 1.24
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

79
go.sum Normal file
View file

@ -0,0 +1,79 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

237
handlersMedia.go Normal file
View file

@ -0,0 +1,237 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path"
"strconv"
"stream-poc2/ffmpeg"
"stream-poc2/model"
"time"
"github.com/gin-gonic/gin"
)
func mediaListingHandler(ctx *gin.Context) {
entries, err := os.ReadDir("db")
if err != nil {
log.Println("Failed to list dir", err)
ctx.Status(500)
return
}
var medias []model.APIMedia
for _, e := range entries {
if !e.IsDir() {
continue
}
var m model.MediaMetadata
m, err = loadMediaMetadata(e.Name())
if err != nil {
log.Println("Failed to load metadata for", e)
continue
}
medias = append(medias, model.APIMedia{
Name: e.Name(),
FriendlyName: m.FriendlyName,
})
}
log.Println("loaded", medias)
ctx.JSON(200, medias)
}
func metadataHandler(ctx *gin.Context) {
_, m := getMetadata(ctx)
ctx.JSON(http.StatusOK, m)
}
func masterPlaylistHandler(ctx *gin.Context) {
mediaID, metadata := getMetadata(ctx)
masterPlaylist := fmt.Sprintf("#EXTM3U\n\n# Master playlist for %s\n\n", mediaID)
for profile, profileSettings := range model.VideoProfiles {
masterPlaylist += fmt.Sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,CODECS=\"%s\",RESOLUTION=%s,FRAME-RATE=%.6f,AUDIO=\"audios\",NAME=\"%s\",STABLE-VARIANT-ID\"%s\"\n",
profileSettings.MaxBitrate,
profileSettings.AvgBitrate,
profileSettings.HLSCodec(),
profileSettings.Resolution.String(),
metadata.VideoStream.FrameRate,
profile, profile,
)
masterPlaylist += fmt.Sprintf("video/%s/playlist.m3u8\n\n", profile)
}
profile := "orig"
masterPlaylist += fmt.Sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,CODECS=\"%s\",RESOLUTION=%s,FRAME-RATE=%.6f,AUDIO=\"audios\",NAME=\"%s\",STABLE-VARIANT-ID\"%s\"\n",
metadata.VideoStream.PeakBitrate,
metadata.VideoStream.AvgBitrate,
metadata.VideoStream.HLSCodecsString,
metadata.VideoStream.Resolution,
metadata.VideoStream.FrameRate,
profile, profile,
)
masterPlaylist += fmt.Sprintf("video/%s/playlist.m3u8\n\n", profile)
defaultMarked := false
for _, audioStream := range metadata.AudioStreams {
autoSelect := ""
if audioStream.Default && !defaultMarked {
autoSelect = ",AUTOSELECT=YES,DEFAULT=YES"
defaultMarked = true
}
audioStreamPath := fmt.Sprintf("audio/%d/playlist.m3u8", audioStream.Index)
masterPlaylist += fmt.Sprintf(
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audios\",NAME=\"%s\",LANGUAGE=\"%s\"%s,CHANNELS=\"%d\",SAMPLE-RATE=%d,URI=\"%s\"\n",
audioStream.Title, audioStream.Lang, autoSelect, audioStream.Channels, audioStream.SampleRate, audioStreamPath,
)
}
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(masterPlaylist))
}
func videoPlaylistHandler(ctx *gin.Context) {
_, metadata := getMetadata(ctx)
playlist := fmt.Sprintf(`#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:%f
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
`, metadata.TargetDuration)
for i, chunk := range metadata.VideoStream.VideoChunks {
lenStr := strconv.FormatFloat(chunk.Length, 'f', 6, 64)
playlist += fmt.Sprintf("#EXTINF:%s,\n", lenStr)
playlist += fmt.Sprintf("s%d.ts\n", i)
}
playlist += "#EXT-X-ENDLIST"
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(playlist))
}
func audioPlaylistHandler(ctx *gin.Context) {
_, metadata := getMetadata(ctx)
idxStr := ctx.Param("idx")
idx, err := strconv.Atoi(idxStr)
if err != nil {
log.Println("Invalid index")
ctx.Status(404)
return
}
var langChunks []model.Chunk
for _, audioStream := range metadata.AudioStreams {
if audioStream.Index == idx {
langChunks = audioStream.AudioChunks
break
}
}
if langChunks == nil {
log.Println("index not found")
ctx.Status(404)
return
}
playlist := fmt.Sprintf(`#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:%f
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
`, metadata.TargetDuration)
for i, chunk := range langChunks {
lenStr := strconv.FormatFloat(chunk.Length, 'f', 6, 64)
playlist += fmt.Sprintf("#EXTINF:%s,\n", lenStr)
playlist += fmt.Sprintf("s%d.ts\n", i)
}
playlist += "#EXT-X-ENDLIST"
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(playlist))
}
// serveChunk must be the last function call in the branch
func serveChunk(ctx *gin.Context, chunkPath string) {
stat, err := os.Stat(chunkPath)
if err != nil {
log.Printf("failed to stat chunk %s: %s", chunkPath, err)
if os.IsNotExist(err) {
ctx.Status(404)
return
}
ctx.Status(500)
return
}
f, err := os.OpenFile(chunkPath, os.O_RDONLY, 0)
if err != nil {
log.Printf("failed to open chunk %s: %s", chunkPath, err)
ctx.Status(500)
return
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Println("failed to close chunk:", err)
}
}(f)
ctx.DataFromReader(200, stat.Size(), "video/MP2T; charset=binary", f, nil)
}
func videoSegmentHandler(ctx *gin.Context) {
mediaID, metadata := getMetadata(ctx)
profile := ctx.Param("profile")
segment := ctx.Param("segment")
origSegmentPath := path.Join("db", mediaID, "video", segment)
// quick path for identity stream
if profile == "orig" {
log.Println("Serving identity")
serveChunk(ctx, origSegmentPath)
return
}
profileSettings, ok := model.VideoProfiles[profile]
if !ok {
log.Println("invalid profile")
ctx.Status(404)
return
}
preEncodedSegmentPath := path.Join("db", mediaID, "cache", "video", profile, segment)
if isFile(preEncodedSegmentPath) {
log.Println("Serving from cache")
serveChunk(ctx, preEncodedSegmentPath)
return
}
log.Println("Serving transcode")
// transcode video
ctx.Writer.Header().Set("Content-Type", "video/MP2T; charset=binary")
ctx.Writer.WriteHeader(200)
encodeStartTime := time.Now()
err := ffmpeg.EncodeChunk(profileSettings, metadata.VideoStream.Resolution.Height, getCurrentPreset(), origSegmentPath, ctx.Writer)
if err != nil {
log.Println("!!! WARNING: Something went wrong while encoding: ", err)
}
log.Println("transcode took ", time.Since(encodeStartTime))
}
func audioSegmentHandler(ctx *gin.Context) {
mediaID, _ := getMetadata(ctx)
idx := ctx.Param("idx")
segment := ctx.Param("segment")
origSegmentPath := path.Join("db", mediaID, "audio", idx, segment)
serveChunk(ctx, origSegmentPath)
}

57
handlersPreset.go Normal file
View file

@ -0,0 +1,57 @@
package main
import (
"log"
"net/http"
"stream-poc2/ffmpeg"
"sync/atomic"
"github.com/gin-gonic/gin"
)
// The reason why preset is configured separately is that there is no way of representing it properly in the
// HLS master/variant playlist which would confuse players, because there would be two identical looking playlist
// (hls.js solves this by omitting one for example)
// But that's not the only reason. I believe this must be a server-side setting, that the administrators of the server control
// So for example, when the server is under high load, they can tune it to "fast" to ease the load, and when there is less demand they can tune it to "slow" for better quality.
var currentPreset atomic.Pointer[string]
func init() {
profileFast := ""
currentPreset.Store(&profileFast)
}
func getCurrentPreset() string {
preset := currentPreset.Load()
if preset != nil {
return *preset
}
return ""
}
func setPresetHandler(ctx *gin.Context) {
var newPreset string
err := ctx.BindJSON(&newPreset)
if err != nil {
ctx.Status(400)
return
}
if newPreset == "" || ffmpeg.IsNVENCPresetValid(newPreset) {
log.Println("Update global ffmpeg NVENC preset to ", newPreset)
currentPreset.Store(&newPreset)
ctx.Status(http.StatusOK)
} else {
ctx.Status(400)
}
}
func getPresetHandler(ctx *gin.Context) {
preset := currentPreset.Load()
if preset != nil {
ctx.JSON(http.StatusOK, *preset)
} else {
ctx.JSON(http.StatusOK, "")
}
}

298
import.go Normal file
View file

@ -0,0 +1,298 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"strconv"
"stream-poc2/ffmpeg"
"stream-poc2/model"
"strings"
)
func isFile(path string) bool {
stat, err := os.Stat(path)
if err != nil {
return false
}
return !stat.IsDir()
}
func isDir(path string) bool {
stat, err := os.Stat(path)
if err != nil {
return false
}
return stat.IsDir()
}
func createRawStructure(root string, audioStreams []int) error {
dirs := []string{
"",
"video",
"audio",
"cache",
"cache/video",
}
for _, dir := range dirs {
log.Println("mkdir:", dir)
err := os.MkdirAll(path.Join(root, dir), 0o755)
if err != nil {
return err
}
}
for _, streamIdx := range audioStreams {
dir := path.Join(root, "audio", strconv.Itoa(streamIdx))
log.Println("mkdir:", dir)
err := os.MkdirAll(dir, 0o755)
if err != nil {
return err
}
}
return nil
}
func writeJSON(fpath string, data any) error {
log.Println("writing", fpath)
f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Println("Failed to close", fpath, err)
}
}(f)
err = json.NewEncoder(f).Encode(data)
if err != nil {
return err
}
return nil
}
func importMain(filepath, target string) {
targetFullPath := path.Join("db", target)
if isDir(targetFullPath) {
fmt.Println("target already exists")
return
}
if !isFile(filepath) {
fmt.Println("Source does not exists")
return
}
log.Println("Running FFProbe...")
ffProbed, err := ffmpeg.FFProbe(filepath)
if err != nil {
panic(err)
}
var numVideoStreams int
// we will fill those out from ffprobe's output
nativeVideoStream := model.VideoStream{}
var audioStreams []model.AudioStream
for _, stream := range ffProbed.Streams {
log.Println("Discovered stream:", stream.Index, stream.CodecType)
if stream.CodecType == "audio" {
lang, ok := stream.Tags["language"]
if !ok {
lang = "eng" // use as default
}
title, _ := stream.Tags["title"]
if !ok {
title = fmt.Sprintf("Unknown (%d)", stream.Index) // Title is used as name which must be different
}
sampleRate, err := strconv.Atoi(stream.SampleRate)
if err != nil {
log.Println("!!! Couldn't get audio sample rate !!!")
}
bitRate, err := strconv.Atoi(stream.BitRate)
if err != nil {
log.Println("!!! Couldn't get audio bit rate !!!")
}
audioStreams = append(audioStreams, model.AudioStream{
Index: stream.Index,
Lang: lang,
Title: title,
Default: stream.Disposition.Default == 1,
Codec: stream.CodecName,
SampleRate: sampleRate,
Bitrate: bitRate,
Channels: stream.Channels,
AudioChunks: nil,
})
}
if stream.CodecType == "video" {
// funnily mkv can encode any sort of files, including "cover images", those represented as video frames.
frameRateParts := strings.Split(stream.AvgFrameRate, "/")
a, err := strconv.Atoi(frameRateParts[0])
if err != nil {
panic(err)
}
b, err := strconv.Atoi(frameRateParts[1])
if err != nil {
panic(err)
}
if a > 0 && b > 0 {
nativeVideoStream.FrameRate = float64(a) / float64(b)
}
if nativeVideoStream.FrameRate == 0 || strings.HasPrefix(stream.Tags["mimetype"], "image") {
log.Println("This is a still image, ignoring...")
continue
}
numVideoStreams++
nativeVideoStream.Resolution = model.Resolution{
Width: stream.Width,
Height: stream.Height,
}
if stream.BitRate != "" {
nativeVideoStream.PeakBitrate, err = strconv.Atoi(stream.BitRate)
if err != nil {
panic(err)
}
nativeVideoStream.AvgBitrate, err = strconv.Atoi(stream.BitRate)
if err != nil {
panic(err)
}
} else {
log.Println("Warning: couldn't extract video bitrate")
}
nativeVideoStream.HLSCodecsString, _ = ffmpeg.FigureOutHLSCodecsParam(stream) // ignore if it's unrecognizable..
}
}
if numVideoStreams != 1 {
log.Printf("!!! The input media should have exactly one video stream, this has %d !!!\n", numVideoStreams)
}
metadata := model.MediaMetadata{
FriendlyName: path.Base(filepath),
TargetDuration: ffmpeg.SlicerTargetDurationSec,
VideoStream: nativeVideoStream,
AudioStreams: audioStreams,
}
audioStreamIndexes := make([]int, len(audioStreams))
for i, audioStream := range audioStreams {
audioStreamIndexes[i] = audioStream.Index
}
// create structure
err = createRawStructure(targetFullPath, audioStreamIndexes)
if err != nil {
panic(err)
}
// write ffprobeOutput
err = writeJSON(path.Join(targetFullPath, "ffprobe.json"), ffProbed)
if err != nil {
panic(err)
}
log.Println("Now segmenting...")
segmentsVideoCSV, audioSegmentsCSVs, err := ffmpeg.MakeSegments(filepath, targetFullPath, audioStreams)
if err != nil {
panic(err)
}
log.Println("Segment data: ", segmentsVideoCSV, audioSegmentsCSVs)
log.Println("Now processing segment data...")
// read slice stuff
segments, err := ffmpeg.ReadSegmentsCSV(segmentsVideoCSV)
if err != nil {
panic(err)
}
// load videoStream chunks
videoChunks := make([]model.Chunk, len(segments))
for i, segment := range segments {
videoChunks[i] = model.Chunk{
FName: segment.Name,
Length: segment.Len(),
}
}
metadata.VideoStream.VideoChunks = videoChunks
// load audioStream chunks
for i, slicesFile := range audioSegmentsCSVs {
segments, err = ffmpeg.ReadSegmentsCSV(slicesFile)
if err != nil {
panic(err)
}
audioChunks := make([]model.Chunk, len(segments))
for j, segment := range segments {
audioChunks[j] = model.Chunk{
FName: segment.Name,
Length: segment.Len(),
}
}
metadata.AudioStreams[i].AudioChunks = audioChunks
}
log.Println("Writing metadata...")
// write metadata
err = writeJSON(path.Join(targetFullPath, "meta.json"), metadata)
if err != nil {
panic(err)
}
// pre-encode
log.Println("Pre-encoding some slices...")
for i, chunk := range videoChunks {
// encode 7 slices to begin with...
if i > 6 {
break
}
origSegmentPath := path.Join(targetFullPath, "video", chunk.FName)
for profileName, profileSettings := range model.VideoProfiles {
preEncodedSegmentDir := path.Join(targetFullPath, "cache", "video", profileName)
err = os.MkdirAll(preEncodedSegmentDir, 0o755)
if err != nil {
panic(err)
}
preEncodedSegmentPath := path.Join(preEncodedSegmentDir, chunk.FName)
log.Println(origSegmentPath, " -> ", preEncodedSegmentPath)
var f *os.File
f, err = os.OpenFile(preEncodedSegmentPath, os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
log.Println("failed to open chunk", err)
panic(err)
}
err = ffmpeg.EncodeChunk(profileSettings, nativeVideoStream.Resolution.Height, "", origSegmentPath, f)
_ = f.Close()
if err != nil {
panic(err)
}
}
}
}

37
index.html Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>stream-poc2</h1>
<p>
Imported media:
</p>
<ul id="media_list"></ul>
<script>
async function fetchStuff() {
const res = await fetch(`/media`);
const data = await res.json();
const mediaList = document.getElementById("media_list")
data.forEach(entry => {
const a = document.createElement('a');
a.href = "/player.html#" + entry.name;
a.innerText = entry.friendly_name;
const li = document.createElement('li')
li.appendChild(a)
mediaList.appendChild(li);
})
}
fetchStuff()
</script>
</body>
</html>

19
main.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) == 2 && os.Args[1] == "server" {
serverMain()
return
}
if len(os.Args) == 4 && os.Args[1] == "import" {
importMain(os.Args[2], os.Args[3])
return
}
fmt.Println("usage: main [import/server] <import file path> <import video target name>")
}

36
middleware.go Normal file
View file

@ -0,0 +1,36 @@
package main
import (
"os"
"stream-poc2/model"
"github.com/gin-gonic/gin"
)
func mediaMustExists(ctx *gin.Context) {
id := ctx.Param("id")
metadata, err := loadMediaMetadata(id)
if err != nil {
if os.IsNotExist(err) {
ctx.AbortWithStatus(404)
return
}
ctx.AbortWithStatus(500)
return
}
ctx.Set("m", metadata)
ctx.Set("mid", id)
}
func getMetadata(ctx *gin.Context) (string, model.MediaMetadata) {
m, ok := ctx.Get("m")
if !ok {
panic("failed to load key for media metadata")
}
mid, ok := ctx.Get("mid")
if !ok {
panic("failed to load key for media id")
}
return mid.(string), m.(model.MediaMetadata)
}

6
model/api.go Normal file
View file

@ -0,0 +1,6 @@
package model
type APIMedia struct {
Name string `json:"name"`
FriendlyName string `json:"friendly_name"`
}

131
model/const.go Normal file
View file

@ -0,0 +1,131 @@
package model
const (
CodecHEVC = "hevc"
CodecAVC = "avc"
)
type VideoProfileSettings struct {
Resolution Resolution // Only for 16:9
Codec string
MaxBitrate int
AvgBitrate int
}
var FFMpegCodecs = map[string]string{
CodecAVC: "h264_nvenc",
CodecHEVC: "hevc_nvenc",
}
var HLSCodecs = map[string]string{
CodecAVC: "avc1.42E01E", //TODO: those are just hardcoded stuff
CodecHEVC: "hvc1.1.6.L93.B0",
}
func (p VideoProfileSettings) FFMpegCodec() string {
return FFMpegCodecs[p.Codec]
}
func (p VideoProfileSettings) HLSCodec() string {
return HLSCodecs[p.Codec]
}
// The bitrate are just random numbers I wrote in...
var VideoProfiles = map[string]VideoProfileSettings{
"144p-avc": VideoProfileSettings{
Resolution: Resolution{
Width: 176,
Height: 144,
},
MaxBitrate: 70_000,
AvgBitrate: 50_000,
Codec: CodecAVC,
},
"144p-hevc": VideoProfileSettings{
Resolution: Resolution{
Width: 176,
Height: 144,
},
MaxBitrate: 50_000,
AvgBitrate: 40_000,
Codec: CodecHEVC,
},
"480p-avc": VideoProfileSettings{
Resolution: Resolution{
Width: 848,
Height: 480,
},
MaxBitrate: 250_000,
AvgBitrate: 200_000,
Codec: CodecAVC,
},
"480p-hevc": VideoProfileSettings{
Resolution: Resolution{
Width: 848,
Height: 480,
},
MaxBitrate: 200_000,
AvgBitrate: 170_000,
Codec: CodecHEVC,
},
"720p-avc": VideoProfileSettings{
Resolution: Resolution{
Width: 1280,
Height: 720,
},
MaxBitrate: 700_000,
AvgBitrate: 500_000,
Codec: CodecAVC,
},
"720p-hevc": VideoProfileSettings{
Resolution: Resolution{
Width: 1280,
Height: 720,
},
MaxBitrate: 500_000,
AvgBitrate: 400_000,
Codec: CodecHEVC,
},
"1080p-avc": VideoProfileSettings{
Resolution: Resolution{
Width: 1920,
Height: 1080,
},
MaxBitrate: 1_200_000,
AvgBitrate: 700_000,
Codec: CodecAVC,
},
"1080p-hevc": VideoProfileSettings{
Resolution: Resolution{
Width: 1920,
Height: 1080,
},
MaxBitrate: 1_000_000,
AvgBitrate: 500_000,
Codec: CodecHEVC,
},
// high bitrate version
"1080p-avc-hbr": VideoProfileSettings{
Resolution: Resolution{
Width: 1920,
Height: 1080,
},
MaxBitrate: 4_200_000,
AvgBitrate: 3_700_000,
Codec: CodecAVC,
},
"1080p-hevc-hbr": VideoProfileSettings{
Resolution: Resolution{
Width: 1920,
Height: 1080,
},
MaxBitrate: 4_000_000,
AvgBitrate: 3_500_000,
Codec: CodecHEVC,
},
}
func IsValidVideoProfile(profile string) bool {
_, ok := VideoProfiles[profile]
return ok
}

45
model/metadata.go Normal file
View file

@ -0,0 +1,45 @@
package model
import "fmt"
type Chunk struct {
FName string `json:"fname"` // just the basename
Length float64 `json:"length"`
}
type AudioStream struct {
Index int `json:"index"`
Lang string `json:"lang"` // there can be multiple instances of the same language! (Frédi és Béni is one example)
Title string `json:"title"`
Default bool `json:"default"`
Codec string `json:"codec"`
SampleRate int `json:"sample_rate"`
Bitrate int `json:"bitrate"` // !!! <- This is before transcoding (if used!)
Channels int `json:"channels"`
AudioChunks []Chunk `json:"audio_chunks"`
}
type Resolution struct {
Width int `json:"width"`
Height int `json:"height"`
}
func (r Resolution) String() string {
return fmt.Sprintf("%dx%d", r.Width, r.Height)
}
type VideoStream struct {
Resolution Resolution `json:"resolution"`
HLSCodecsString string `json:"hls_codecs_string"` // i.e.: avc1.42E01E
PeakBitrate int `json:"peak_bitrate"` // for BANDWIDTH - bits per second
AvgBitrate int `json:"avg_bitrate"` // for AVERAGE-BANDWIDTH - bits per second
FrameRate float64 `json:"frame_rate"`
VideoChunks []Chunk `json:"video_chunks"`
}
type MediaMetadata struct {
FriendlyName string `json:"friendly_name"`
TargetDuration float64 `json:"target_duration"`
VideoStream VideoStream `json:"video_stream"`
AudioStreams []AudioStream `json:"audio_streams"`
}

11
model/slices.go Normal file
View file

@ -0,0 +1,11 @@
package model
type Slice struct {
Name string
StartTime float64
EndTime float64
}
func (s Slice) Len() float64 {
return s.EndTime - s.StartTime
}

192
player.html Normal file
View file

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body > div {
padding-bottom: 1em;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
</head>
<body>
<div id="videoHolder">
<video id="video" style="width: 100%; height: auto; max-height: 720px;" controls></video>
</div>
<div id="config">
<label for="videoSelect">Video: </label><select id="videoSelect"></select>
<label for="audioSelect">Audio: </label><select id="audioSelect"></select>
<label for="nvencPresetSelect">NVENC Preset: </label><select id="nvencPresetSelect">
<option value="">default (undefined)</option>
<option value="default">default</option>
<option value="slow">slow - hq 2 passes</option>
<option value="medium">medium - hq 1 pass</option>
<option value="fast">fast - hp 1 pass</option>
<option value="hp">hp</option>
<option value="hq">hq</option>
<option value="bd">bd</option>
<option value="ll">ll - low latency</option>
<option value="llhq">llhq - low latency hq</option>
<option value="llhp">llhp - low latency hp</option>
<option value="lossless">lossless - lossless</option>
<option value="losslesshp">losslesshp - lossless hp</option>
<option value="p1">p1 - fastest (lowest quality)</option>
<option value="p2">p2 - faster (lower quality)</option>
<option value="p3">p3 - fast (low quality)</option>
<option value="p4">p4 - medium (default)</option>
<option value="p5">p5 - slow (good quality)</option>
<option value="p6">p6 - slower (better quality)</option>
<option value="p7">p7 - slowest (best quality)</option>
</select>
</div>
<div id="metadata">
Metadata:
<pre id="metadataPre"></pre>
</div>
<script>
const video = document.getElementById('video');
const mediaPath = `/media/${window.location.hash.substring(1)}/`;
const hlsMasterPlaylistPath = `${mediaPath}master.m3u8`;
let onPresetChange = null;
if (Hls.isSupported()) {
const hls = new Hls({
// debug: true,
});
const levelSelector = document.getElementById("videoSelect")
levelSelector.onchange = (e) => {
hls.currentLevel = e.target.selectedIndex // this drops the current buffer
}
hls.on(Hls.Events.MANIFEST_PARSED, function () {
console.log(hls.levels)
hls.levels.forEach((lvl, idx) => {
const opt = document.createElement('option');
opt.value = idx;
opt.innerHTML = lvl.name;
levelSelector.appendChild(opt);
})
})
const audioSelector = document.getElementById("audioSelect")
audioSelector.onchange = (e) => {
hls.audioTrack = e.target.selectedIndex
}
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, function () {
console.log(hls.audioTracks)
hls.audioTracks.forEach((audio, idx) => {
const opt = document.createElement('option');
opt.value = idx;
opt.innerText = `[${audio.id}] ${audio.lang} ${audio.name}`;
audioSelector.appendChild(opt);
})
})
hls.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error("Network error");
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error("Media error");
break;
default:
console.error("Fatal error");
break;
}
}
})
hls.on(Hls.Events.LEVEL_SWITCHED, function (event, data) {
console.log("Switched to level " + data.level);
levelSelector.selectedIndex = data.level;
});
hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, function (event, data) {
console.log("Switched to audio " + data.id);
audioSelector.selectedIndex = data.id;
});
hls.loadSource(hlsMasterPlaylistPath);
hls.attachMedia(video);
console.log(hls);
onPresetChange = function () {
hls.bufferController.flushBackBuffer()
hls.bufferController.flushFrontBuffer()
}
}
// HLS.js is not supported on platforms that do not have Media Source
// Extensions (MSE) enabled.
//
// When the browser has built-in HLS support (check using `canPlayType`),
// we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
// element through the `src` property. This is using the built-in support
// of the plain video element, without using HLS.js.
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = hlsMasterPlaylistPath;
}
const nvencPresetSelect = document.getElementById("nvencPresetSelect")
fetch(`/config/preset`).then((resp) => {
if (resp.status !== 200) {
console.error("unexpected response", resp)
return
}
resp.json().then((data) => {
nvencPresetSelect.value = data
})
}).catch((e) => {
console.error(e)
})
nvencPresetSelect.onchange = (e) => {
nvencPresetSelect.disabled = true;
fetch(`/config/preset`, {
method: "POST",
body: JSON.stringify(nvencPresetSelect.value)
}).then((resp) => {
if (resp.status === 200) {
nvencPresetSelect.disabled = false;
if (onPresetChange !== null) {
onPresetChange()
}
} else {
console.error("failed to update preset!", resp)
}
})
}
fetch(mediaPath).then((resp) => {
if (resp.status !== 200) {
console.error("unexpected response", resp)
return
}
resp.json().then((data) => {
delete data.video_stream.video_chunks
data.audio_streams.forEach(a => {
delete a.audio_chunks
})
document.getElementById("metadataPre").innerText = JSON.stringify(data, null, 2)
})
}).catch((e) => {
console.error(e)
})
</script>
</body>
</html>

30
server.go Normal file
View file

@ -0,0 +1,30 @@
package main
import "github.com/gin-gonic/gin"
func serverMain() {
router := gin.Default()
router.StaticFile("/", "index.html")
router.StaticFile("/player.html", "player.html")
config := router.Group("config")
config.POST("/preset", setPresetHandler)
config.GET("/preset", getPresetHandler)
router.GET("/media", mediaListingHandler)
media := router.Group("/media/:id")
media.Use(mediaMustExists)
media.GET("/", metadataHandler)
media.GET("/master.m3u8", masterPlaylistHandler)
media.GET("/video/:profile/playlist.m3u8", videoPlaylistHandler)
media.GET("/video/:profile/:segment", videoSegmentHandler)
media.GET("/audio/:idx/playlist.m3u8", audioPlaylistHandler)
media.GET("/audio/:idx/:segment", audioSegmentHandler)
err := router.Run()
if err != nil {
panic(err)
}
}