commit fa47df6d890fb1b4eba33a6a6bfb252d0f8aa5d5 Author: marcsello Date: Thu Oct 9 22:21:38 2025 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbeb2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +db/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..54985cf --- /dev/null +++ b/README.md @@ -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 +``` + +"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 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". \ No newline at end of file diff --git a/db.go b/db.go new file mode 100644 index 0000000..23e41a3 --- /dev/null +++ b/db.go @@ -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 +} diff --git a/ffmpeg/const.go b/ffmpeg/const.go new file mode 100644 index 0000000..d88ada1 --- /dev/null +++ b/ffmpeg/const.go @@ -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) +} diff --git a/ffmpeg/ffmpeg.go b/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..8720b86 --- /dev/null +++ b/ffmpeg/ffmpeg.go @@ -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 +} diff --git a/ffmpeg/ffprobe.go b/ffmpeg/ffprobe.go new file mode 100644 index 0000000..b6316a7 --- /dev/null +++ b/ffmpeg/ffprobe.go @@ -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. + 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... + return fmt.Sprintf( + "%s.%d.L%d", + stream.CodecTagString, + hevcProfileBits[stream.Profile], + stream.Level, + ), nil + } + + } + + return "", fmt.Errorf("unrecognized format") +} diff --git a/ffmpeg/model.go b/ffmpeg/model.go new file mode 100644 index 0000000..b035660 --- /dev/null +++ b/ffmpeg/model.go @@ -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"` +} diff --git a/ffmpeg/slices.go b/ffmpeg/slices.go new file mode 100644 index 0000000..af54b78 --- /dev/null +++ b/ffmpeg/slices.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cf1ac2a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bf36a7 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handlersMedia.go b/handlersMedia.go new file mode 100644 index 0000000..db0b7bc --- /dev/null +++ b/handlersMedia.go @@ -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) +} diff --git a/handlersPreset.go b/handlersPreset.go new file mode 100644 index 0000000..354cd43 --- /dev/null +++ b/handlersPreset.go @@ -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, "") + } +} diff --git a/import.go b/import.go new file mode 100644 index 0000000..910648c --- /dev/null +++ b/import.go @@ -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) + } + } + } + +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..8eaa915 --- /dev/null +++ b/index.html @@ -0,0 +1,37 @@ + + + + + Title + + + +

stream-poc2

+

+ Imported media: +

+
    + + + + + \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..818c21d --- /dev/null +++ b/main.go @@ -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] ") +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..e199ba0 --- /dev/null +++ b/middleware.go @@ -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) +} diff --git a/model/api.go b/model/api.go new file mode 100644 index 0000000..5c4d2e6 --- /dev/null +++ b/model/api.go @@ -0,0 +1,6 @@ +package model + +type APIMedia struct { + Name string `json:"name"` + FriendlyName string `json:"friendly_name"` +} diff --git a/model/const.go b/model/const.go new file mode 100644 index 0000000..c9caf2a --- /dev/null +++ b/model/const.go @@ -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 +} diff --git a/model/metadata.go b/model/metadata.go new file mode 100644 index 0000000..cd21a70 --- /dev/null +++ b/model/metadata.go @@ -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"` +} diff --git a/model/slices.go b/model/slices.go new file mode 100644 index 0000000..bf56cd4 --- /dev/null +++ b/model/slices.go @@ -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 +} diff --git a/player.html b/player.html new file mode 100644 index 0000000..fb7d251 --- /dev/null +++ b/player.html @@ -0,0 +1,192 @@ + + + + + + + + + +
    + +
    + +
    + + + + + +
    + +
    + Metadata: +
    
    +
    + + + + diff --git a/server.go b/server.go new file mode 100644 index 0000000..71d58dc --- /dev/null +++ b/server.go @@ -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) + } +}