Files
2026-05-20 11:23:57 +08:00

682 lines
21 KiB
Go

package generated
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"mediakit-cli/internal/local/core"
)
type LocalWarning struct {
Field string `json:"field"`
Message string `json:"message"`
}
type LocalInputRef struct {
Original string `json:"original"`
LocalPath string `json:"local_path"`
}
func materializeLocalInput(ctx *core.ExecContext, value string) (LocalInputRef, error) {
localPath, err := core.MaterializeInput(ctx, value)
if err != nil {
return LocalInputRef{}, err
}
return LocalInputRef{Original: value, LocalPath: localPath}, nil
}
func outputPath(ctx *core.ExecContext, command string, ext string) (string, error) {
return outputPathFor(ctx, command, ext)
}
func concatFile(ctx *core.ExecContext, inputs []LocalInputRef) (string, error) {
paths := make([]string, 0, len(inputs))
for _, input := range inputs {
paths = append(paths, input.LocalPath)
}
return concatListFile(ctx, paths)
}
func unsupportedIfPresent(params map[string]any, key string) error {
value, ok := params[key]
if !ok || value == nil {
return nil
}
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" {
return nil
}
case []any:
if len(typed) == 0 {
return nil
}
case []string:
if len(typed) == 0 {
return nil
}
}
return fmt.Errorf("%s 当前不支持本地执行,请使用 cloud 模式", key)
}
func warnIfLocalNoop(params map[string]any, key string, warnings *[]LocalWarning) {
if _, ok := params[key]; !ok {
return
}
*warnings = append(*warnings, LocalWarning{
Field: key,
Message: key + " 仅用于 cloud 模式,本地执行会忽略",
})
}
func videoResponse(command string, output string, inputs []LocalInputRef, warnings []LocalWarning, extra map[string]any) map[string]any {
return localMediaResponse(command, "video", output, inputs, warnings, extra)
}
func audioResponse(command string, output string, inputs []LocalInputRef, warnings []LocalWarning, extra map[string]any) map[string]any {
return localMediaResponse(command, "audio", output, inputs, warnings, extra)
}
func localMediaResponse(command string, mediaType string, output string, inputs []LocalInputRef, warnings []LocalWarning, extra map[string]any) map[string]any {
result := map[string]any{}
switch mediaType {
case "audio":
result["audio_url"] = output
default:
result["video_url"] = output
}
return result
}
func buildConcatVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
if err := unsupportedIfPresent(ctx.Params, "transitions"); err != nil {
return nil, err
}
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
urls, err := requiredStringListParam(ctx.Params, "video_urls")
if err != nil {
return nil, err
}
inputs := make([]LocalInputRef, 0, len(urls))
for _, url := range urls {
input, err := materializeLocalInput(ctx, url)
if err != nil {
return nil, err
}
inputs = append(inputs, input)
}
fileList, err := concatFile(ctx, inputs)
if err != nil {
return nil, err
}
out, err := outputPath(ctx, "concat-video", ".mp4")
if err != nil {
return nil, err
}
args := []string{"-y", "-hide_banner", "-f", "concat", "-safe", "0", "-i", fileList, "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy", out}
return &FFmpegPlan{
Args: args,
Result: videoResponse("concat-video", out, inputs, warnings, map[string]any{
"input_count": len(inputs),
}),
}, nil
}
func buildConcatAudioPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
urls, err := requiredStringListParam(ctx.Params, "audio_urls")
if err != nil {
return nil, err
}
inputs := make([]LocalInputRef, 0, len(urls))
for _, url := range urls {
input, err := materializeLocalInput(ctx, url)
if err != nil {
return nil, err
}
inputs = append(inputs, input)
}
fileList, err := concatFile(ctx, inputs)
if err != nil {
return nil, err
}
out, err := outputPath(ctx, "concat-audio", ".m4a")
if err != nil {
return nil, err
}
args := []string{"-y", "-hide_banner", "-f", "concat", "-safe", "0", "-i", fileList, "-vn", "-c:a", "aac", "-b:a", "128k", out}
return &FFmpegPlan{
Args: args,
Result: audioResponse("concat-audio", out, inputs, warnings, map[string]any{
"input_count": len(inputs),
}),
}, nil
}
func buildTrimVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
start, hasStart, err := optionalFloatParam(ctx.Params, "start_time")
if err != nil {
return nil, err
}
end, hasEnd, err := optionalFloatParam(ctx.Params, "end_time")
if err != nil {
return nil, err
}
if err := validateTrimWindow(start, hasStart, end, hasEnd); err != nil {
return nil, err
}
out, err := outputPath(ctx, "trim-video", ".mp4")
if err != nil {
return nil, err
}
args := []string{"-y", "-hide_banner"}
if hasStart {
args = append(args, "-ss", formatFloat(start))
}
args = append(args, "-i", input.LocalPath)
if duration, ok := trimDuration(start, hasStart, end, hasEnd); ok {
args = append(args, "-t", formatFloat(duration))
}
args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy", out)
return &FFmpegPlan{Args: args, Result: videoResponse("trim-video", out, []LocalInputRef{input}, warnings, map[string]any{"start_time": start, "end_time": end})}, nil
}
func buildTrimAudioPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
audioURL, err := requiredStringParam(ctx.Params, "audio_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, audioURL)
if err != nil {
return nil, err
}
start, hasStart, err := optionalFloatParam(ctx.Params, "start_time")
if err != nil {
return nil, err
}
end, hasEnd, err := optionalFloatParam(ctx.Params, "end_time")
if err != nil {
return nil, err
}
if err := validateTrimWindow(start, hasStart, end, hasEnd); err != nil {
return nil, err
}
out, err := outputPath(ctx, "trim-audio", ".m4a")
if err != nil {
return nil, err
}
args := []string{"-y", "-hide_banner"}
if hasStart {
args = append(args, "-ss", formatFloat(start))
}
args = append(args, "-i", input.LocalPath)
if duration, ok := trimDuration(start, hasStart, end, hasEnd); ok {
args = append(args, "-t", formatFloat(duration))
}
args = append(args, "-vn", "-c:a", "aac", "-b:a", "128k", out)
return &FFmpegPlan{Args: args, Result: audioResponse("trim-audio", out, []LocalInputRef{input}, warnings, map[string]any{"start_time": start, "end_time": end})}, nil
}
func buildMuxAudioVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
audioURL, err := requiredStringParam(ctx.Params, "audio_url")
if err != nil {
return nil, err
}
video, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
audio, err := materializeLocalInput(ctx, audioURL)
if err != nil {
return nil, err
}
out, err := outputPath(ctx, "mux-audio-video", ".mp4")
if err != nil {
return nil, err
}
args := []string{"-y", "-hide_banner", "-i", video.LocalPath, "-i", audio.LocalPath, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-shortest", out}
return &FFmpegPlan{Args: args, Result: videoResponse("mux-audio-video", out, []LocalInputRef{video, audio}, warnings, map[string]any{})}, nil
}
func buildExtractAudioPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
ext, codec, err := extractAudioSpec(ctx.Params)
if err != nil {
return nil, err
}
out, err := outputPath(ctx, "extract-audio", ext)
if err != nil {
return nil, err
}
format := strings.TrimPrefix(ext, ".")
args := []string{"-y", "-hide_banner", "-i", input.LocalPath, "-map", "0:a:0", "-c:a", codec, out}
return &FFmpegPlan{Args: args, Result: audioResponse("extract-audio", out, []LocalInputRef{input}, warnings, map[string]any{"format": format})}, nil
}
func buildFlipVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
vertical, err := core.ParamBool(ctx.Params, "is_flip_vertical", false)
if err != nil {
return nil, err
}
horizontal, err := core.ParamBool(ctx.Params, "is_flip_horizontal", false)
if err != nil {
return nil, err
}
filters := []string{}
if horizontal {
filters = append(filters, "hflip")
}
if vertical {
filters = append(filters, "vflip")
}
if len(filters) == 0 {
filters = append(filters, "null")
}
out, err := outputPath(ctx, "flip-video", ".mp4")
if err != nil {
return nil, err
}
args := append(
[]string{"-y", "-hide_banner", "-i", input.LocalPath, "-filter_complex", "[0:v]" + strings.Join(filters, ",") + "[outv]", "-map", "[outv]", "-map", "0:a:0?", "-c:a", "aac", "-b:a", "128k"},
append(h264OutputArgs("1200k"), out)...,
)
return &FFmpegPlan{Args: args, Result: videoResponse("flip-video", out, []LocalInputRef{input}, warnings, map[string]any{"is_flip_vertical": vertical, "is_flip_horizontal": horizontal})}, nil
}
func buildAdjustVideoSpeedPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
speed, hasSpeed, err := optionalFloatParam(ctx.Params, "speed")
if err != nil {
return nil, err
}
if !hasSpeed {
speed = 1
}
if speed < 0.1 || speed > 4 {
return nil, fmt.Errorf("speed 取值范围为 0.1 到 4")
}
out, err := outputPath(ctx, "adjust-video-speed", ".mp4")
if err != nil {
return nil, err
}
videoExpr := "setpts=" + formatFloat(1/speed) + "*PTS"
hasAudio := hasAudioStream(input.LocalPath)
if hasAudio {
audioExpr := atempoChain(speed)
args := append(
[]string{"-y", "-hide_banner", "-i", input.LocalPath, "-filter_complex", "[0:v]" + videoExpr + "[outv];[0:a]" + audioExpr + "[outa]", "-map", "[outv]", "-map", "[outa]", "-c:a", "aac", "-b:a", "128k"},
append(h264OutputArgs("1200k"), out)...,
)
return &FFmpegPlan{Args: args, Result: videoResponse("adjust-video-speed", out, []LocalInputRef{input}, warnings, map[string]any{"speed": speed})}, nil
}
args := append(
[]string{"-y", "-hide_banner", "-i", input.LocalPath, "-filter_complex", "[0:v]" + videoExpr + "[outv]", "-map", "[outv]"},
append(h264OutputArgs("1200k"), "-an", out)...,
)
return &FFmpegPlan{Args: args, Result: videoResponse("adjust-video-speed", out, []LocalInputRef{input}, warnings, map[string]any{"speed": speed})}, nil
}
func hasAudioStream(filePath string) bool {
raw, err := core.RunFFprobe(
"-v", "error",
"-select_streams", "a",
"-show_entries", "stream=codec_type",
"-of", "csv=p=0",
filePath,
)
if err != nil {
return false
}
return strings.TrimSpace(string(raw)) == "audio"
}
func atempoChain(speed float64) string {
parts := []string{}
for speed > 2.0 {
parts = append(parts, "atempo=2.0")
speed /= 2.0
}
for speed < 0.5 {
parts = append(parts, "atempo=0.5")
speed /= 0.5
}
parts = append(parts, "atempo="+formatFloat(speed))
return strings.Join(parts, ",")
}
func buildAddSubtitleToVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
input, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
var subtitleInput LocalInputRef
var subtitleSource string
if rawURL, ok, err := optionalStringParam(ctx.Params, "subtitle_url"); err != nil {
return nil, err
} else if ok {
subtitleInput, err = materializeLocalInput(ctx, rawURL)
if err != nil {
return nil, err
}
subtitleSource = "subtitle_url"
} else {
subtitlePath, err := writeSubtitleObjectsAsSRT(ctx)
if err != nil {
return nil, err
}
subtitleInput = LocalInputRef{Original: "subtitles", LocalPath: subtitlePath}
subtitleSource = "subtitles"
}
style := buildSubtitleForceStyle(ctx.Params)
out, err := outputPath(ctx, "add-subtitle-to-video", ".mp4")
if err != nil {
return nil, err
}
args := append(
[]string{"-y", "-hide_banner", "-i", input.LocalPath, "-vf", "subtitles=" + escapeFilterPath(subtitleInput.LocalPath) + ":force_style='" + style + "'", "-map", "0:v:0", "-map", "0:a:0?", "-c:a", "aac", "-b:a", "128k"},
append(h264OutputArgs("1300k"), out)...,
)
return &FFmpegPlan{Args: args, Result: videoResponse("add-subtitle-to-video", out, []LocalInputRef{input, subtitleInput}, warnings, map[string]any{"subtitle_source": subtitleSource, "subtitle_pos_preset": valueOrDefault(ctx.Params, "subtitle_pos_preset", "bottom_center")})}, nil
}
func writeSubtitleObjectsAsSRT(ctx *core.ExecContext) (string, error) {
raw, ok := ctx.Params["subtitles"]
if !ok {
return "", fmt.Errorf("subtitle_url 和 subtitles 至少需要提供一个")
}
items, ok := raw.([]any)
if !ok || len(items) == 0 {
return "", fmt.Errorf("subtitles 至少需要 1 个元素")
}
lines := []string{}
for i, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
return "", fmt.Errorf("subtitles[%d] 必须是对象", i)
}
text, _ := item["subtitle_text"].(string)
start, startOK := numberField(item, "start_time")
end, endOK := numberField(item, "end_time")
if strings.TrimSpace(text) == "" || !startOK || !endOK || end <= start {
return "", fmt.Errorf("subtitles[%d] 需要合法 subtitle_text/start_time/end_time", i)
}
lines = append(lines, strconv.Itoa(i+1), formatSRTTime(start)+" --> "+formatSRTTime(end), text, "")
}
path := filepath.Join(ctx.TempDir, fmt.Sprintf("subtitles-%d.srt", time.Now().UnixNano()))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
return "", err
}
return path, nil
}
func buildAddImageToVideoPlan(ctx *core.ExecContext) (*FFmpegPlan, error) {
warnings := []LocalWarning{}
warnIfLocalNoop(ctx.Params, "callback_args", &warnings)
warnIfLocalNoop(ctx.Params, "client_token", &warnings)
videoURL, err := requiredStringParam(ctx.Params, "video_url")
if err != nil {
return nil, err
}
imageURL, err := requiredStringParam(ctx.Params, "sub_image_url")
if err != nil {
return nil, err
}
video, err := materializeLocalInput(ctx, videoURL)
if err != nil {
return nil, err
}
image, err := materializeLocalInput(ctx, imageURL)
if err != nil {
return nil, err
}
out, err := outputPath(ctx, "add-image-to-video", ".mp4")
if err != nil {
return nil, err
}
overlayExpr := overlayPosition(ctx.Params)
enableExpr := overlayEnable(ctx.Params)
// 只有用户明确指定了 width/height 时才缩放图片,否则保持原始尺寸(与云端行为一致)
var filterComplex string
if hasOverlayScale(ctx.Params) {
scaleExpr := overlayScale2Ref(ctx.Params)
filterComplex = "[1:v][0:v]scale2ref=" + scaleExpr + "[wm][base];[base][wm]overlay=" + overlayExpr + enableExpr + "[outv]"
} else {
filterComplex = "[0:v][1:v]overlay=" + overlayExpr + enableExpr + "[outv]"
}
args := append(
[]string{"-y", "-hide_banner", "-i", video.LocalPath, "-i", image.LocalPath, "-filter_complex", filterComplex, "-map", "[outv]", "-map", "0:a:0?", "-c:a", "aac", "-b:a", "128k"},
append(h264OutputArgs("1300k"), out)...,
)
return &FFmpegPlan{Args: args, Result: videoResponse("add-image-to-video", out, []LocalInputRef{video, image}, warnings, map[string]any{"overlay_start_time": ctx.Params["start_time"], "overlay_end_time": ctx.Params["end_time"]})}, nil
}
func valueOrDefault(params map[string]any, key string, defaultValue string) string {
value, ok := params[key].(string)
if !ok || strings.TrimSpace(value) == "" {
return defaultValue
}
return value
}
func numberField(params map[string]any, key string) (float64, bool) {
switch typed := params[key].(type) {
case float64:
return typed, true
case int:
return float64(typed), true
case json.Number:
value, err := typed.Float64()
return value, err == nil
default:
return 0, false
}
}
func formatSRTTime(seconds float64) string {
totalMilliseconds := int64(seconds * 1000)
milliseconds := totalMilliseconds % 1000
totalSeconds := totalMilliseconds / 1000
secs := totalSeconds % 60
totalMinutes := totalSeconds / 60
mins := totalMinutes % 60
hours := totalMinutes / 60
return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, mins, secs, milliseconds)
}
func buildSubtitleForceStyle(params map[string]any) string {
fontSize := valueOrDefault(params, "subtitle_font_size", "50")
fontColor := rgbaToASS(valueOrDefault(params, "subtitle_font_color", "#FFFFFFFF"))
fontName := fontNameFor(valueOrDefault(params, "subtitle_font_type", "sy_black"))
alignment := alignmentFor(valueOrDefault(params, "subtitle_pos_preset", "bottom_center"))
return strings.Join([]string{"FontName=" + fontName, "FontSize=" + fontSize, "PrimaryColour=" + fontColor, "Alignment=" + alignment}, ",")
}
func rgbaToASS(rgba string) string {
value := strings.TrimPrefix(strings.TrimSpace(rgba), "#")
if len(value) != 8 {
return "&H00FFFFFF"
}
rr := value[0:2]
gg := value[2:4]
bb := value[4:6]
aa := value[6:8]
return "&H" + aa + bb + gg + rr
}
func fontNameFor(fontType string) string {
switch fontType {
case "sy_black":
return "Source Han Sans"
case "pm_zhengdao":
return "PangMenZhengDao"
case "ali_puhui":
return "Alibaba PuHuiTi"
case "zhanku_kuaile":
return "ZhanKu KuaiLe"
default:
return "Source Han Sans"
}
}
func alignmentFor(preset string) string {
switch preset {
case "top_center":
return "8"
case "center":
return "5"
case "lower_third":
return "2"
default:
return "2"
}
}
func escapeFilterPath(path string) string {
path = strings.ReplaceAll(path, "\\", "\\\\")
path = strings.ReplaceAll(path, ":", "\\:")
path = strings.ReplaceAll(path, "'", "\\'")
return path
}
// hasOverlayScale 判断用户是否明确指定了图片缩放尺寸
func hasOverlayScale(params map[string]any) bool {
w, wOk := params["sub_image_width"].(string)
h, hOk := params["sub_image_height"].(string)
return (wOk && strings.TrimSpace(w) != "") || (hOk && strings.TrimSpace(h) != "")
}
func overlayScale2Ref(params map[string]any) string {
width := valueOrDefault(params, "sub_image_width", "10%")
height := valueOrDefault(params, "sub_image_height", "5%")
return "w=" + overlayDimension(width, "main_w") + ":h=" + overlayDimension(height, "main_h")
}
func overlayDimension(value string, base string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(value, "%") {
ratio, err := strconv.ParseFloat(strings.TrimSuffix(value, "%"), 64)
if err == nil {
return base + "*" + formatFloat(ratio/100)
}
}
if value == "" {
return "-1"
}
return value
}
func overlayPosition(params map[string]any) string {
x := overlayCoordinate(valueOrDefault(params, "sub_image_pos_x", "85%"), "W", "w")
y := overlayCoordinate(valueOrDefault(params, "sub_image_pos_y", "90%"), "H", "h")
return x + ":" + y
}
func overlayCoordinate(value string, base string, overlay string) string {
value = strings.TrimSpace(value)
if strings.HasSuffix(value, "%") {
ratio, err := strconv.ParseFloat(strings.TrimSuffix(value, "%"), 64)
if err == nil {
// 与云端一致:百分比表示图片左上角在视频宽/高的绝对位置,超出部分自然截断
return base + "*" + formatFloat(ratio/100)
}
}
if value == "" {
return "0"
}
return value
}
func overlayEnable(params map[string]any) string {
start, hasStart := numberField(params, "start_time")
end, hasEnd := numberField(params, "end_time")
if hasStart && hasEnd {
return ":enable='between(t," + formatFloat(start) + "," + formatFloat(end) + ")'"
}
if hasStart {
return ":enable='gte(t," + formatFloat(start) + ")'"
}
if hasEnd {
return ":enable='lte(t," + formatFloat(end) + ")'"
}
return ""
}