mirror of
https://github.com/fxkt-tech/liv
synced 2025-09-26 20:11:20 +08:00
feat(ffcut): video track
This commit is contained in:
10
ffcut/shelf/errors.go
Normal file
10
ffcut/shelf/errors.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package shelf
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// 轨道类型不存在
|
||||
ErrTrackTypeNotFound = errors.New("track type not found")
|
||||
// 元素与轨道不匹配
|
||||
ErrTrackItemTypeNotMatch = errors.New("track and track_type not match")
|
||||
)
|
38
ffcut/shelf/operation.go
Normal file
38
ffcut/shelf/operation.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package shelf
|
||||
|
||||
// 元素操作
|
||||
type OpParams interface{}
|
||||
|
||||
// 操作
|
||||
type Operation struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Params OpParams `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// 旋转
|
||||
type OpParamsImageRotate struct {
|
||||
Angle int32 `json:"angle"`
|
||||
}
|
||||
|
||||
func WithOpParamsImageRotate(angle int32) *Operation {
|
||||
return &Operation{
|
||||
Type: "image_rotate",
|
||||
Params: &OpParamsImageRotate{
|
||||
Angle: angle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 调整音量
|
||||
type OpParamsAudioVolumes struct {
|
||||
All int32 `json:"all"`
|
||||
}
|
||||
|
||||
func WithOpParamsAudioVolumes(all int32) *Operation {
|
||||
return &Operation{
|
||||
Type: "audio_volumes",
|
||||
Params: &OpParamsAudioVolumes{
|
||||
All: all,
|
||||
},
|
||||
}
|
||||
}
|
9
ffcut/shelf/options.go
Normal file
9
ffcut/shelf/options.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package shelf
|
||||
|
||||
type ShelfOption func(*TrackData)
|
||||
|
||||
func WithStageSize(w, h int32) ShelfOption {
|
||||
return func(td *TrackData) {
|
||||
td.stageWidth, td.stageHeight = w, h
|
||||
}
|
||||
}
|
270
ffcut/shelf/track.go
Normal file
270
ffcut/shelf/track.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package shelf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fxkt-tech/liv/ffmpeg"
|
||||
"github.com/fxkt-tech/liv/ffmpeg/codec"
|
||||
"github.com/fxkt-tech/liv/ffmpeg/filter"
|
||||
"github.com/fxkt-tech/liv/ffmpeg/input"
|
||||
"github.com/fxkt-tech/liv/ffmpeg/output"
|
||||
"github.com/fxkt-tech/liv/internal/conv"
|
||||
"github.com/fxkt-tech/liv/internal/encoding/json"
|
||||
"github.com/fxkt-tech/liv/internal/sugar"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 轨道排序优先级,数字小的靠前
|
||||
var TrackTypeSortMap = map[TrackType]int{
|
||||
TrackTypeTitle: 1,
|
||||
TrackTypeFrame: 2,
|
||||
TrackTypeSubtitle: 3,
|
||||
TrackTypeImage: 4,
|
||||
TrackTypeAudio: 5,
|
||||
TrackTypeVideo: 6,
|
||||
}
|
||||
|
||||
// 轨道类型
|
||||
type TrackType string
|
||||
|
||||
const (
|
||||
// 文字轨道
|
||||
TrackTypeTitle TrackType = "title"
|
||||
// TODO
|
||||
TrackTypeFrame TrackType = "frame"
|
||||
// 字幕轨道
|
||||
TrackTypeSubtitle TrackType = "subtitle"
|
||||
// TODO
|
||||
TrackTypeImage TrackType = "image"
|
||||
// 音频轨道
|
||||
TrackTypeAudio TrackType = "audio"
|
||||
// 视频轨道
|
||||
TrackTypeVideo TrackType = "video"
|
||||
)
|
||||
|
||||
var (
|
||||
TrackTitleAllowdItems = []TrackItemType{TrackItemTypeTitle, TrackItemTypeAdvancedTitle, TrackItemTypeSequenceTitle, TrackItemTypePAGTitle}
|
||||
TrackSubtitleAllowdItems = []TrackItemType{TrackItemTypeSubtitle}
|
||||
TrackAudioAllowdItems = []TrackItemType{TrackItemTypeAudio}
|
||||
TrackVideoAllowdItems = []TrackItemType{TrackItemTypeVideo, TrackItemTypeImage, TrackItemTypeTransition}
|
||||
)
|
||||
|
||||
// 合成协议
|
||||
type TrackData struct {
|
||||
stageWidth int32
|
||||
stageHeight int32
|
||||
|
||||
tracks []*Track
|
||||
|
||||
err error
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
var New = NewTrackData
|
||||
|
||||
func NewTrackData(opts ...ShelfOption) *TrackData {
|
||||
d := &TrackData{ctx: context.Background()}
|
||||
sugar.Range(opts, func(opt ShelfOption) { opt(d) })
|
||||
return d
|
||||
}
|
||||
|
||||
// 获取指定轨道,返回nil则表示不存在
|
||||
func (d *TrackData) GetTrack(trackType TrackType, idx int) *Track {
|
||||
i := 0
|
||||
for _, track := range d.tracks {
|
||||
if track.Type == trackType {
|
||||
if i == idx {
|
||||
return track
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 添加轨道
|
||||
func (d *TrackData) AddTrack(track *Track) *TrackData {
|
||||
if d.err != nil {
|
||||
return d
|
||||
}
|
||||
d.tracks = append(d.tracks, track)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *TrackData) AppendTrack(tracks ...*Track) *TrackData {
|
||||
for _, track := range tracks {
|
||||
d.AddTrack(track)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// 轨道排序,使用稳定性排序
|
||||
func (d *TrackData) Sort() {
|
||||
n := len(d.tracks)
|
||||
for i := 0; i < n-1; i++ {
|
||||
for j := 0; j < n-i-1; j++ {
|
||||
if TrackTypeSortMap[d.tracks[j].Type] > TrackTypeSortMap[d.tracks[j+1].Type] {
|
||||
d.tracks[j], d.tracks[j+1] = d.tracks[j+1], d.tracks[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *TrackData) Exec() error {
|
||||
var (
|
||||
ff = ffmpeg.New(
|
||||
// ffmpeg.WithDry(true),
|
||||
ffmpeg.WithDebug(true),
|
||||
)
|
||||
|
||||
// 辅助性质的背景板,用于视频流
|
||||
bg = filter.Color("black", d.stageWidth, d.stageHeight, 5)
|
||||
|
||||
// 舞台
|
||||
stage = bg
|
||||
// 音响
|
||||
// sound *filter.SingleFilter
|
||||
)
|
||||
ff.AddFilter(bg)
|
||||
for i := len(d.tracks) - 1; i >= 0; i-- {
|
||||
switch d.tracks[i].Type {
|
||||
case TrackTypeVideo:
|
||||
for _, item := range d.tracks[i].Items {
|
||||
iVideo := input.WithTime(conv.MillToF32(item.StartTime), conv.MillToF32(item.Duration), item.AssetId)
|
||||
// iVideo := input.WithSimple(item.AssetId)
|
||||
// fTrim := filter.Trim(conv.MillToF32(item.Section.From), conv.MillToF32(item.Section.To)).Use(iVideo.V())
|
||||
fSetPTS := filter.SetPTS(fmt.Sprintf("PTS+%f/TB", conv.MillToF32(item.Section.From)))
|
||||
fOverlay := filter.OverlayWithEnable(
|
||||
item.Position.X, item.Position.Y,
|
||||
fmt.Sprintf("between(t,%f,%f)", conv.MillToF32(item.Section.From), conv.MillToF32(item.Section.To)),
|
||||
).Use(stage, fSetPTS)
|
||||
ff.AddInput(iVideo)
|
||||
ff.AddFilter(fSetPTS, fOverlay)
|
||||
|
||||
// 每完成一步的结果就是当前舞台的模样
|
||||
stage = fOverlay
|
||||
}
|
||||
case TrackTypeAudio:
|
||||
case TrackTypeTitle:
|
||||
case TrackTypeSubtitle:
|
||||
}
|
||||
}
|
||||
|
||||
ff.AddOutput(output.New(
|
||||
output.Map(stage),
|
||||
// output.Map(sound),
|
||||
output.VideoCodec(codec.X264),
|
||||
// output.AudioCodec(codec.AAC),
|
||||
output.AudioCodec(codec.Nope),
|
||||
output.File("out_test.mp4"),
|
||||
))
|
||||
err := ff.Run(d.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 导出合成协议
|
||||
func (d *TrackData) Export() (string, error) {
|
||||
if d.err != nil {
|
||||
return "", d.err
|
||||
}
|
||||
|
||||
// 处理字幕样式
|
||||
for _, track := range d.tracks {
|
||||
if track.Type == TrackTypeSubtitle {
|
||||
if len(track.Styles) > 0 {
|
||||
for _, tItem := range track.Items {
|
||||
tItem.TextStyleId = track.Styles[0].Id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Sort()
|
||||
return json.ToString(d.tracks), nil
|
||||
}
|
||||
|
||||
// --- 轨道 ---
|
||||
|
||||
type TrackBase struct {
|
||||
Id string `json:"id"` // 根据uuid生成,只要单个合成协议不重复就行
|
||||
Type TrackType `json:"type"`
|
||||
}
|
||||
|
||||
type Style struct {
|
||||
Id string `json:"id"`
|
||||
TextStyle *TextStyle `json:"text_style"`
|
||||
}
|
||||
|
||||
// 轨道
|
||||
// 轨道类型: title、frame、subtitle、image、audio、video
|
||||
type Track struct {
|
||||
TrackBase
|
||||
Items []*TrackItem `json:"items,omitempty"`
|
||||
Styles []*Style `json:"styles,omitempty"`
|
||||
|
||||
allowedTrackItems []TrackItemType `json:"-"`
|
||||
err error `json:"-"`
|
||||
}
|
||||
|
||||
// 创建轨道
|
||||
// 暂时仅支持title/subtitle/audio/video
|
||||
func NewTrack(trackType TrackType) *Track {
|
||||
var (
|
||||
base = TrackBase{
|
||||
Id: uuid.NewString(),
|
||||
Type: trackType,
|
||||
}
|
||||
allowedTrackItems []TrackItemType
|
||||
err error
|
||||
)
|
||||
|
||||
switch trackType {
|
||||
case TrackTypeTitle:
|
||||
allowedTrackItems = TrackTitleAllowdItems
|
||||
case TrackTypeSubtitle:
|
||||
allowedTrackItems = TrackSubtitleAllowdItems
|
||||
case TrackTypeAudio:
|
||||
allowedTrackItems = TrackAudioAllowdItems
|
||||
case TrackTypeVideo:
|
||||
allowedTrackItems = TrackVideoAllowdItems
|
||||
default:
|
||||
err = ErrTrackTypeNotFound
|
||||
}
|
||||
|
||||
return &Track{
|
||||
TrackBase: base,
|
||||
allowedTrackItems: allowedTrackItems,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// 向轨道中添加元素
|
||||
func (t *Track) Push(trackItem *TrackItem) *Track {
|
||||
if t.err != nil {
|
||||
return t
|
||||
}
|
||||
|
||||
if !sugar.In(t.allowedTrackItems, trackItem.Type) {
|
||||
t.err = ErrTrackItemTypeNotMatch
|
||||
return t
|
||||
}
|
||||
|
||||
t.Items = append(t.Items, trackItem)
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Track) Append(trackItems ...*TrackItem) *Track {
|
||||
for _, trackItem := range trackItems {
|
||||
t.Push(trackItem)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Track) SetStyles(styles ...*Style) *Track {
|
||||
t.Styles = append(t.Styles, styles...)
|
||||
return t
|
||||
}
|
208
ffcut/shelf/track_item.go
Normal file
208
ffcut/shelf/track_item.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package shelf
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// 轨道元素类型
|
||||
type TrackItemType string
|
||||
|
||||
const (
|
||||
// 普通文字
|
||||
TrackItemTypeTitle TrackItemType = "title"
|
||||
// 高级文字,一般用这个
|
||||
TrackItemTypeAdvancedTitle TrackItemType = "advanced_title"
|
||||
// 序列帧-文字(气泡文字)
|
||||
TrackItemTypeSequenceTitle TrackItemType = "sequence_title"
|
||||
// AE文件实现的文字(动效文字)
|
||||
TrackItemTypePAGTitle TrackItemType = "pag_title"
|
||||
|
||||
// TODO
|
||||
TrackItemTypeFrame TrackItemType = "frame"
|
||||
// 字幕文字
|
||||
TrackItemTypeSubtitle TrackItemType = "subtitle"
|
||||
// TODO
|
||||
TrackItemTypeImage TrackItemType = "image"
|
||||
// 音频
|
||||
TrackItemTypeAudio TrackItemType = "audio"
|
||||
// 视频
|
||||
TrackItemTypeVideo TrackItemType = "video"
|
||||
// TODO: 转场
|
||||
TrackItemTypeTransition TrackItemType = "transition"
|
||||
)
|
||||
|
||||
type TrackItemBase struct {
|
||||
Id string `json:"id"` // 根据uuid生成,单个合成协议内唯一
|
||||
Selection
|
||||
Type TrackItemType `json:"type"`
|
||||
AssetId string `json:"asset_id"` // cme素材id
|
||||
}
|
||||
|
||||
// 剪辑时间线
|
||||
type Selection struct {
|
||||
StartTime int32 `json:"start_time"`
|
||||
Duration int32 `json:"duration"`
|
||||
}
|
||||
|
||||
// 元素片段
|
||||
// 音频、视频、特效等元素必填
|
||||
type Section struct {
|
||||
From int32 `json:"from"`
|
||||
To int32 `json:"to"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
X int32 `json:"x"`
|
||||
Y int32 `json:"y"`
|
||||
}
|
||||
|
||||
type ItemSize struct {
|
||||
Width int32 `json:"width,omitempty"`
|
||||
Height int32 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
type ItemContents struct {
|
||||
Text string `json:"text"`
|
||||
TextStyle *TextStyle `json:"text_style,omitempty"`
|
||||
}
|
||||
|
||||
// 文字样式
|
||||
type TextStyle struct {
|
||||
// 通用
|
||||
FontSize int32 `json:"font_size"`
|
||||
FontColor string `json:"font_color"`
|
||||
FontColorList []string `json:"font_color_list,omitempty"`
|
||||
Font string `json:"font,omitempty"`
|
||||
FontAssetId string `json:"font_asset_id,omitempty"`
|
||||
FontAlpha int32 `json:"font_alpha,omitempty"`
|
||||
FontBold int32 `json:"font_bold,omitempty"`
|
||||
FontItalic int32 `json:"font_italic,omitempty"`
|
||||
FontUnderline int32 `json:"font_uline,omitempty"`
|
||||
FontAlign string `json:"font_align,omitempty"`
|
||||
BackgroundColor string `json:"background_color,omitempty"`
|
||||
BackgroundAlpha int32 `json:"background_alpha,omitempty"`
|
||||
BorderWidth int32 `json:"border_width,omitempty"`
|
||||
BorderColor string `json:"border_color,omitempty"`
|
||||
Align string `json:"align,omitempty"`
|
||||
|
||||
// 内容填充文字可用
|
||||
ShadowColor string `json:"shadow_color,omitempty"`
|
||||
ShadowAngle int32 `json:"shadow_angle,omitempty"`
|
||||
ShadowAlpha int32 `json:"shadow_alpha,omitempty"`
|
||||
|
||||
// 字幕可用
|
||||
Height int32 `json:"height"`
|
||||
Bold int32 `json:"bold,omitempty"`
|
||||
Italic int32 `json:"italic,omitempty"`
|
||||
BottomColor string `json:"bottom_color,omitempty"`
|
||||
BottomAlpha int32 `json:"bottom_alpha,omitempty"`
|
||||
MarginBottom int32 `json:"margin_bottom,omitempty"`
|
||||
|
||||
// 未知
|
||||
// LetterSpacing int32 `json:"letter_spacing"`
|
||||
// Leading int32 `json:"leading"`
|
||||
// TextBox *TextBox `json:"text_box"`
|
||||
}
|
||||
|
||||
type TextBox struct {
|
||||
Width float32 `json:"width"`
|
||||
Height float32 `json:"height"`
|
||||
FontSize int `json:"font_size"`
|
||||
}
|
||||
|
||||
func DefaultTextStyle() *TextStyle {
|
||||
return &TextStyle{
|
||||
FontSize: 60,
|
||||
FontColor: "#FFFFFF",
|
||||
Align: "center",
|
||||
Height: 220,
|
||||
Bold: 0,
|
||||
Italic: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultSubtitleStyle() *Style {
|
||||
return &Style{
|
||||
Id: uuid.NewString(),
|
||||
TextStyle: &TextStyle{
|
||||
FontSize: 30,
|
||||
FontColor: "#FFFFFF",
|
||||
Align: "center",
|
||||
Height: 55,
|
||||
Bold: 0,
|
||||
Italic: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SubtitleStyleWithTextStyle(ts *TextStyle) *Style {
|
||||
return &Style{
|
||||
Id: uuid.NewString(),
|
||||
TextStyle: ts,
|
||||
}
|
||||
}
|
||||
|
||||
// 轨道元素
|
||||
type TrackItem struct {
|
||||
TrackItemBase
|
||||
Section *Section `json:"section,omitempty"`
|
||||
ItemSize
|
||||
Position *Position `json:"position,omitempty"`
|
||||
TextStyleId string `json:"text_style_id,omitempty"`
|
||||
Contents *ItemContents `json:"contents,omitempty"`
|
||||
Operations []*Operation `json:"operations,omitempty"`
|
||||
}
|
||||
|
||||
// 创建轨道
|
||||
func NewTrackItem(trackItemType TrackItemType) *TrackItem {
|
||||
return &TrackItem{
|
||||
TrackItemBase: TrackItemBase{
|
||||
Id: uuid.NewString(),
|
||||
Type: trackItemType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetAssetId(assetId string) *TrackItem {
|
||||
i.AssetId = assetId
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetSelection(startTime, duration int32) *TrackItem {
|
||||
i.Selection = Selection{StartTime: startTime, Duration: duration}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetSection(from, to int32) *TrackItem {
|
||||
i.Section = &Section{From: from, To: to}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetItemSize(width, height int32) *TrackItem {
|
||||
i.ItemSize = ItemSize{Width: width, Height: height}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetPosition(x, y int32) *TrackItem {
|
||||
if i.Position != nil {
|
||||
i.Position.X = x
|
||||
i.Position.Y = y
|
||||
} else {
|
||||
i.Position = &Position{X: x, Y: y}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetSubtitle(text string) *TrackItem {
|
||||
// i.TextStyleId = styleId
|
||||
i.Contents = &ItemContents{Text: text}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetContents(text string, style *TextStyle) *TrackItem {
|
||||
i.Contents = &ItemContents{Text: text, TextStyle: style}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *TrackItem) SetOperations(ops ...*Operation) *TrackItem {
|
||||
i.Operations = append(i.Operations, ops...)
|
||||
return i
|
||||
}
|
115
ffcut/shelf/track_test.go
Normal file
115
ffcut/shelf/track_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package shelf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/fxkt-tech/liv/ffcut/shelf"
|
||||
)
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
trackData, err := shelf.New(
|
||||
shelf.WithStageSize(540, 960),
|
||||
).
|
||||
AddTrack(
|
||||
// 添加视频轨
|
||||
shelf.NewTrack(shelf.TrackTypeVideo).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeVideo).
|
||||
SetAssetId("660521dc64b43b0001ce490a").
|
||||
SetSelection(0, 5000).
|
||||
SetSection(0, 5000).
|
||||
SetItemSize(540, 960).
|
||||
SetPosition(270, 480),
|
||||
)).
|
||||
AddTrack(
|
||||
// 添加文字轨
|
||||
shelf.NewTrack(shelf.TrackTypeTitle).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeSequenceTitle).
|
||||
SetAssetId("608bc4d689ea7200013ff242@Public@CME").
|
||||
SetSelection(0, 5000).
|
||||
SetPosition(270, 180).
|
||||
SetContents("超好吃!", &shelf.TextStyle{
|
||||
Font: "huakangshaonvwenziW5-2",
|
||||
FontSize: 40,
|
||||
FontColor: "#173563",
|
||||
Align: "center",
|
||||
}),
|
||||
),
|
||||
).
|
||||
AddTrack(
|
||||
// 添加音频轨
|
||||
shelf.NewTrack(shelf.TrackTypeAudio).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeAudio).
|
||||
SetAssetId("65df048fb95d8900015302c9").
|
||||
SetSelection(0, 5000).
|
||||
SetSection(0, 5000),
|
||||
),
|
||||
).
|
||||
AddTrack(
|
||||
// 添加字幕轨
|
||||
shelf.NewTrack(shelf.TrackTypeSubtitle).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeSubtitle).
|
||||
SetSelection(0, 1000).
|
||||
SetSubtitle("此处字幕1"),
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeSubtitle).
|
||||
SetSelection(1000, 3000).
|
||||
SetSubtitle("此处字幕2"),
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeSubtitle).
|
||||
SetSelection(4000, 1000).
|
||||
SetSubtitle("此处字幕3"),
|
||||
).
|
||||
SetStyles(shelf.DefaultSubtitleStyle()),
|
||||
).
|
||||
Export() // 导出
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(trackData)
|
||||
// t.Log(json.Pretty([]byte(trackData)))
|
||||
}
|
||||
|
||||
func TestExec(t *testing.T) {
|
||||
err := shelf.New(
|
||||
// shelf.WithStageSize(540, 960),
|
||||
shelf.WithStageSize(1920, 1080),
|
||||
).
|
||||
AppendTrack(
|
||||
// 添加视频轨
|
||||
shelf.NewTrack(shelf.TrackTypeVideo).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeVideo).
|
||||
SetAssetId(`in.mp4`).
|
||||
SetSelection(0, 5000).
|
||||
SetSection(0, 5000).
|
||||
SetItemSize(540, 960).
|
||||
SetPosition(600, 600),
|
||||
),
|
||||
shelf.NewTrack(shelf.TrackTypeVideo).
|
||||
Append(
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeVideo).
|
||||
SetAssetId(`in.mp4`).
|
||||
SetSelection(8000, 2000).
|
||||
SetSection(1000, 3000).
|
||||
SetItemSize(540, 960).
|
||||
SetPosition(0, 0),
|
||||
shelf.NewTrackItem(shelf.TrackItemTypeVideo).
|
||||
SetAssetId(`in.mp4`).
|
||||
SetSelection(20000, 2000).
|
||||
SetSection(3000, 5000).
|
||||
SetItemSize(540, 960).
|
||||
SetPosition(100, 50),
|
||||
),
|
||||
).
|
||||
Exec() // 导出
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// fmt.Println(trackData)
|
||||
// t.Log(json.Pretty([]byte(trackData)))
|
||||
}
|
@@ -25,7 +25,7 @@ func Overlay[T Expr](dx, dy T) *SingleFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: 一个图像覆盖另一个图像(可激活某一时间段)
|
||||
// 一个图像覆盖另一个图像(可激活某一时间段)
|
||||
func OverlayWithEnable[T Expr](dx, dy T, enable string) *SingleFilter {
|
||||
return &SingleFilter{
|
||||
name: naming.Default.Gen(),
|
||||
@@ -33,6 +33,13 @@ func OverlayWithEnable[T Expr](dx, dy T, enable string) *SingleFilter {
|
||||
}
|
||||
}
|
||||
|
||||
func OverlayWithTime[T Expr](dx, dy T, t float32) *SingleFilter {
|
||||
return &SingleFilter{
|
||||
name: naming.Default.Gen(),
|
||||
content: fmt.Sprintf("overlay=%v:%v:t=%f", dx, dy, t),
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放
|
||||
func Scale[T Expr](w, h T) *SingleFilter {
|
||||
var ww, hh any = w, h
|
||||
@@ -99,7 +106,7 @@ func SetPTS(expr string) *SingleFilter {
|
||||
}
|
||||
|
||||
// 截取某一时间段
|
||||
func Trim(s, e float64) *SingleFilter {
|
||||
func Trim(s, e float32) *SingleFilter {
|
||||
var ps []string
|
||||
if s != 0 {
|
||||
ps = append(ps, fmt.Sprintf("start=%f", s))
|
||||
|
@@ -14,12 +14,12 @@ type Input struct {
|
||||
cv string
|
||||
r string
|
||||
safe string
|
||||
i string // i is input file.
|
||||
ss float64 // ss is start_time.
|
||||
t float64 // t is duration.
|
||||
ss float32 // ss is start_time.
|
||||
t float32 // t is duration.
|
||||
itsoffset float32 // offset
|
||||
metadata []string // kv pair.
|
||||
f string // format
|
||||
// ext []string // extra params.
|
||||
i string // i is input file.
|
||||
}
|
||||
|
||||
func New(opts ...Option) *Input {
|
||||
@@ -42,7 +42,7 @@ func WithMetadata(i string, kvs []string) *Input {
|
||||
return &Input{i: i, metadata: kvs}
|
||||
}
|
||||
|
||||
func WithTime(ss, t float64, i string) *Input {
|
||||
func WithTime(ss, t float32, i string) *Input {
|
||||
return &Input{
|
||||
ss: ss,
|
||||
t: t,
|
||||
@@ -82,6 +82,9 @@ func (i *Input) Params() (params []string) {
|
||||
if i.t != 0 {
|
||||
params = append(params, "-t", fmt.Sprintf("%.6f", i.t))
|
||||
}
|
||||
if i.itsoffset != 0 {
|
||||
params = append(params, "-itsoffset", fmt.Sprintf("%.6f", i.itsoffset))
|
||||
}
|
||||
if i.f != "" {
|
||||
params = append(params, "-f", i.f)
|
||||
}
|
||||
|
@@ -14,13 +14,13 @@ func VideoCodec(cv string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func StartTime(ss float64) Option {
|
||||
func StartTime(ss float32) Option {
|
||||
return func(o *Input) {
|
||||
o.ss = ss
|
||||
}
|
||||
}
|
||||
|
||||
func Duration(t float64) Option {
|
||||
func Duration(t float32) Option {
|
||||
return func(o *Input) {
|
||||
o.t = t
|
||||
}
|
||||
|
@@ -82,13 +82,13 @@ func Metadata(k, v string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func StartTime(ss float64) Option {
|
||||
func StartTime(ss float32) Option {
|
||||
return func(o *Output) {
|
||||
o.ss = ss
|
||||
}
|
||||
}
|
||||
|
||||
func Duration(t float64) Option {
|
||||
func Duration(t float32) Option {
|
||||
return func(o *Output) {
|
||||
o.t = t
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -20,8 +21,8 @@ type Output struct {
|
||||
threads int32 // thread counts, default 4.
|
||||
max_muxing_queue_size int32 // queue size when muxing, default 4086.
|
||||
movflags string // location of mp4 moov.
|
||||
ss float64 // ss is start_time.
|
||||
t float64 // t is duration.
|
||||
ss float32 // ss is start_time.
|
||||
t float32 // t is duration.
|
||||
f string // f is -f format.
|
||||
file string
|
||||
var_stream_map string
|
||||
@@ -151,10 +152,10 @@ func (o *Output) Params() (params []string) {
|
||||
params = append(params, "-movflags", o.movflags)
|
||||
}
|
||||
if o.ss != 0 {
|
||||
params = append(params, "-ss", strconv.FormatFloat(o.ss, 'f', -1, 64))
|
||||
params = append(params, "-ss", fmt.Sprintf("%f", o.ss))
|
||||
}
|
||||
if o.t != 0 {
|
||||
params = append(params, "-t", strconv.FormatFloat(o.t, 'f', -1, 64))
|
||||
params = append(params, "-t", fmt.Sprintf("%f", o.t))
|
||||
}
|
||||
if o.f != "" {
|
||||
params = append(params, "-f", o.f)
|
||||
|
@@ -12,7 +12,7 @@ type ProbeStream struct {
|
||||
CodecName string `json:"codec_name,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
BitRate int32 `json:"bit_rate,omitempty,string"`
|
||||
Duration float64 `json:"duration,omitempty,string"`
|
||||
Duration float32 `json:"duration,omitempty,string"`
|
||||
|
||||
// video
|
||||
Width int32 `json:"width,omitempty"`
|
||||
@@ -34,5 +34,5 @@ type ProbeStream struct {
|
||||
type ProbeFormat struct {
|
||||
FormatName string `json:"format_name,omitempty"`
|
||||
Size int64 `json:"size,omitempty,string"`
|
||||
Duration float64 `json:"duration,omitempty,string"`
|
||||
Duration float32 `json:"duration,omitempty,string"`
|
||||
}
|
||||
|
1
go.mod
1
go.mod
@@ -4,5 +4,6 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f
|
||||
)
|
||||
|
2
go.sum
2
go.sum
@@ -3,6 +3,8 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm
|
||||
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
5
internal/conv/conv.go
Normal file
5
internal/conv/conv.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package conv
|
||||
|
||||
func MillToF32(mill int32) float32 {
|
||||
return float32(mill) / 1000
|
||||
}
|
35
internal/encoding/json/json.go
Normal file
35
internal/encoding/json/json.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package json
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func ToString(i any) string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(i)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func ToBytes(i any) []byte {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
b, _ := json.Marshal(i)
|
||||
return b
|
||||
}
|
||||
|
||||
// json字节流转struct/map等,不会返回错误,所以确保传入的内容一定是能解析的
|
||||
func ToV[T any](b []byte) T {
|
||||
var i T
|
||||
json.Unmarshal(b, &i)
|
||||
return i
|
||||
}
|
||||
|
||||
func Pretty(b []byte) string {
|
||||
var str bytes.Buffer
|
||||
_ = json.Indent(&str, b, "", " ")
|
||||
return str.String()
|
||||
}
|
24
internal/encoding/json/json_test.go
Normal file
24
internal/encoding/json/json_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package json_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/fxkt-tech/liv/internal/encoding/json"
|
||||
)
|
||||
|
||||
func TestPretty(t *testing.T) {
|
||||
x := []byte(`{"x":1}`)
|
||||
fmt.Println(json.Pretty(x))
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Age int `json:"age,omitempty"`
|
||||
}
|
||||
|
||||
func TestToObjectT(t *testing.T) {
|
||||
b := []byte(`{"name":"xx","age":1}`)
|
||||
x := json.ToV[*Person](b)
|
||||
fmt.Println(x.Name, x.Age)
|
||||
}
|
34
snapshot.go
34
snapshot.go
@@ -90,7 +90,7 @@ func (ss *Snapshot) Simple(ctx context.Context, params *SnapshotParams) error {
|
||||
|
||||
func (ss *Snapshot) Sprite(ctx context.Context, params *SpriteParams) error {
|
||||
var (
|
||||
duration = float64(params.XLen*params.YLen) * params.Interval
|
||||
duration = float32(params.XLen*params.YLen) * params.Interval
|
||||
frames = params.XLen * params.YLen
|
||||
)
|
||||
|
||||
@@ -179,14 +179,14 @@ func (ss *Snapshot) SVGMark(ctx context.Context, params *SVGMarkParams) error {
|
||||
for _, annotation := range params.Annotations {
|
||||
switch annotation.Type {
|
||||
case "rect":
|
||||
fromx := int(annotation.FromPoint.X * float64(vstream.Width))
|
||||
fromy := int(annotation.FromPoint.Y * float64(vstream.Height))
|
||||
tox := int(annotation.ToPoint.X * float64(vstream.Width))
|
||||
toy := int(annotation.ToPoint.Y * float64(vstream.Height))
|
||||
minx := int(min(float64(fromx), float64(tox)))
|
||||
miny := int(min(float64(fromy), float64(toy)))
|
||||
w := int(math.Abs(float64(fromx - tox)))
|
||||
h := int(math.Abs(float64(fromy - toy)))
|
||||
fromx := int(annotation.FromPoint.X * float32(vstream.Width))
|
||||
fromy := int(annotation.FromPoint.Y * float32(vstream.Height))
|
||||
tox := int(annotation.ToPoint.X * float32(vstream.Width))
|
||||
toy := int(annotation.ToPoint.Y * float32(vstream.Height))
|
||||
minx := int(min(float32(fromx), float32(tox)))
|
||||
miny := int(min(float32(fromy), float32(toy)))
|
||||
w := int(math.Abs(float32(fromx - tox)))
|
||||
h := int(math.Abs(float32(fromy - toy)))
|
||||
styles := []string{"fill:transparent"}
|
||||
if annotation.Stroke != "" {
|
||||
styles = append(styles, fmt.Sprintf("stroke:%s", annotation.Stroke))
|
||||
@@ -199,8 +199,8 @@ func (ss *Snapshot) SVGMark(ctx context.Context, params *SVGMarkParams) error {
|
||||
var d string
|
||||
plen := len(annotation.Points)
|
||||
for i, point := range annotation.Points {
|
||||
x := int(point.X * float64(vstream.Width))
|
||||
y := int(point.Y * float64(vstream.Height))
|
||||
x := int(point.X * float32(vstream.Width))
|
||||
y := int(point.Y * float32(vstream.Height))
|
||||
if i == 0 {
|
||||
d = fmt.Sprintf("%sM%d %d ", d, x, y)
|
||||
} else if i == plen-1 {
|
||||
@@ -232,10 +232,10 @@ func (ss *Snapshot) SVGMark(ctx context.Context, params *SVGMarkParams) error {
|
||||
for i, point := range unitPoints {
|
||||
orix := point.X
|
||||
oriy := point.Y
|
||||
fromx := annotation.FromPoint.X * float64(vstream.Width)
|
||||
fromy := annotation.FromPoint.Y * float64(vstream.Height)
|
||||
tox := annotation.ToPoint.X * float64(vstream.Width)
|
||||
toy := annotation.ToPoint.Y * float64(vstream.Height)
|
||||
fromx := annotation.FromPoint.X * float32(vstream.Width)
|
||||
fromy := annotation.FromPoint.Y * float32(vstream.Height)
|
||||
tox := annotation.ToPoint.X * float32(vstream.Width)
|
||||
toy := annotation.ToPoint.Y * float32(vstream.Height)
|
||||
// 根据变换矩阵,变换后的点坐标(A, B)为
|
||||
// A = a(x2 - x1) - b(y2 - y1) + x1
|
||||
// B = a(y2 - y1) + b(x2 - x1) + y1
|
||||
@@ -259,8 +259,8 @@ func (ss *Snapshot) SVGMark(ctx context.Context, params *SVGMarkParams) error {
|
||||
canvas.Path(d, strings.Join(styles, ";"))
|
||||
|
||||
case "text":
|
||||
fromx := int(annotation.FromPoint.X * float64(vstream.Width))
|
||||
fromy := int(annotation.FromPoint.Y * float64(vstream.Height))
|
||||
fromx := int(annotation.FromPoint.X * float32(vstream.Width))
|
||||
fromy := int(annotation.FromPoint.Y * float32(vstream.Height))
|
||||
var styles []string
|
||||
if annotation.Stroke != "" {
|
||||
styles = append(styles, fmt.Sprintf("fill:%s", annotation.Stroke))
|
||||
|
@@ -3,7 +3,7 @@ package liv
|
||||
type SnapshotParams struct {
|
||||
Infile string
|
||||
Outfile string
|
||||
StartTime float64
|
||||
StartTime float32
|
||||
Interval int32
|
||||
Num int32
|
||||
FrameType int32 // 0-仅关键帧 1-指定时间点的帧
|
||||
@@ -18,13 +18,13 @@ type SpriteParams struct {
|
||||
YLen int32
|
||||
Width int32
|
||||
Height int32
|
||||
Interval float64
|
||||
Interval float32
|
||||
}
|
||||
|
||||
type SVGMarkParams struct {
|
||||
Infile string
|
||||
Outfile string
|
||||
StartTime float64 `json:"start_time,omitempty"`
|
||||
StartTime float32 `json:"start_time,omitempty"`
|
||||
Annotations []*SVGAnnotation `json:"annotation,omitempty"`
|
||||
}
|
||||
|
||||
@@ -40,6 +40,6 @@ type SVGAnnotation struct {
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
X float32 `json:"x,omitempty"`
|
||||
Y float32 `json:"y,omitempty"`
|
||||
}
|
||||
|
@@ -34,7 +34,7 @@ type ConcatParams struct {
|
||||
Infiles []string
|
||||
ConcatFile string // eg. mylist.txt
|
||||
Outfile string
|
||||
Duration float64
|
||||
Duration float32
|
||||
}
|
||||
|
||||
type ExtractAudioParams struct {
|
||||
@@ -89,10 +89,10 @@ type Audio struct {
|
||||
type Logo struct {
|
||||
File string `json:"file,omitempty"`
|
||||
Pos string `json:"pos,omitempty"`
|
||||
Dx float64 `json:"dx,omitempty"`
|
||||
Dy float64 `json:"dy,omitempty"`
|
||||
LW float64 `json:"lw,omitempty"`
|
||||
LH float64 `json:"lh,omitempty"`
|
||||
Dx float32 `json:"dx,omitempty"`
|
||||
Dy float32 `json:"dy,omitempty"`
|
||||
LW float32 `json:"lw,omitempty"`
|
||||
LH float32 `json:"lh,omitempty"`
|
||||
}
|
||||
|
||||
func (l *Logo) NeedScale() bool {
|
||||
@@ -101,20 +101,20 @@ func (l *Logo) NeedScale() bool {
|
||||
|
||||
// 矩形框
|
||||
type Rectangle struct {
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
W float64 `json:"w,omitempty"`
|
||||
H float64 `json:"h,omitempty"`
|
||||
X float32 `json:"x,omitempty"`
|
||||
Y float32 `json:"y,omitempty"`
|
||||
W float32 `json:"w,omitempty"`
|
||||
H float32 `json:"h,omitempty"`
|
||||
}
|
||||
|
||||
type Delogo struct {
|
||||
SS float64 `json:"ss,omitempty"`
|
||||
SS float32 `json:"ss,omitempty"`
|
||||
Rect *Rectangle `json:"rect,omitempty"`
|
||||
}
|
||||
|
||||
type Clip struct {
|
||||
Seek float64 `json:"seek,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
Seek float32 `json:"seek,omitempty"`
|
||||
Duration float32 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
type HLS struct {
|
||||
|
Reference in New Issue
Block a user