Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ module mediakit-cli

go 1.22

require github.com/spf13/cobra v1.10.2

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
)

require github.com/inconshreveable/mousetrap v1.1.0 // indirect
53 changes: 39 additions & 14 deletions internal/cloud/api_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,43 @@ type APIInfo struct {
}

var apiInfoRegistry = map[string]APIInfo{
"erase-video-subtitle-pro": {Method: "POST", Path: "/api/v1/tools/erase-video-subtitle-pro"},
"image-to-video": {Method: "POST", Path: "/api/v1/tools/image-to-video"},
"extract-audio": {Method: "POST", Path: "/api/v1/tools/extract-audio"},
"add-image-to-video": {Method: "POST", Path: "/api/v1/tools/add-image-to-video"},
"add-subtitle-to-video": {Method: "POST", Path: "/api/v1/tools/add-subtitle-to-video"},
"mux-audio-video": {Method: "POST", Path: "/api/v1/tools/mux-audio-video"},
"concat-video": {Method: "POST", Path: "/api/v1/tools/concat-video"},
"flip-video": {Method: "POST", Path: "/api/v1/tools/flip-video"},
"trim-video": {Method: "POST", Path: "/api/v1/tools/trim-video"},
"adjust-video-speed": {Method: "POST", Path: "/api/v1/tools/adjust-video-speed"},
"concat-audio": {Method: "POST", Path: "/api/v1/tools/concat-audio"},
"trim-audio": {Method: "POST", Path: "/api/v1/tools/trim-audio"},
"enhance-video": {Method: "POST", Path: "/api/v1/tools/enhance-video"},
"query-task": {Method: "GET", Path: "/api/v1/tasks/{task_id}"},
"erase-video-subtitle-pro": {Method: "POST", Path: "/api/v1/tools/erase-video-subtitle-pro"},
"image-to-video": {Method: "POST", Path: "/api/v1/tools/image-to-video"},
"extract-audio": {Method: "POST", Path: "/api/v1/tools/extract-audio"},
"add-image-to-video": {Method: "POST", Path: "/api/v1/tools/add-image-to-video"},
"add-subtitle-to-video": {Method: "POST", Path: "/api/v1/tools/add-subtitle-to-video"},
"mux-audio-video": {Method: "POST", Path: "/api/v1/tools/mux-audio-video"},
"concat-video": {Method: "POST", Path: "/api/v1/tools/concat-video"},
"flip-video": {Method: "POST", Path: "/api/v1/tools/flip-video"},
"trim-video": {Method: "POST", Path: "/api/v1/tools/trim-video"},
"adjust-video-speed": {Method: "POST", Path: "/api/v1/tools/adjust-video-speed"},
"concat-audio": {Method: "POST", Path: "/api/v1/tools/concat-audio"},
"trim-audio": {Method: "POST", Path: "/api/v1/tools/trim-audio"},
"enhance-video": {Method: "POST", Path: "/api/v1/tools/enhance-video"},
"erase-video-subtitle": {Method: "POST", Path: "/api/v1/tools/erase-video-subtitle"},
"video-ocr": {Method: "POST", Path: "/api/v1/tools/video-ocr"},
"asr-subtitles": {Method: "POST", Path: "/api/v1/tools/asr-subtitles"},
"separate-voice": {Method: "POST", Path: "/api/v1/tools/separate-voice"},
"enhance-video-generative": {Method: "POST", Path: "/api/v1/tools/enhance-video-generative"},
"generate-highlights-minigame": {Method: "POST", Path: "/api/v1/tools/generate-highlights-minigame"},
"generate-highlights-microdrama": {Method: "POST", Path: "/api/v1/tools/generate-highlights-microdrama"},
"segment-scenes": {Method: "POST", Path: "/api/v1/tools/segment-scenes"},
"analyze-video-storyline": {Method: "POST", Path: "/api/v1/tools/analyze-video-storyline"},
"analyze-video-highlights": {Method: "POST", Path: "/api/v1/tools/analyze-video-highlights"},
"matte-portrait-video": {Method: "POST", Path: "/api/v1/tools/matte-portrait-video"},
"matte-greenscreen-video": {Method: "POST", Path: "/api/v1/tools/matte-greenscreen-video"},
"probe-video-metadata": {Method: "POST", Path: "/api/v1/tools/probe-video-metadata"},
"fade-video-audio": {Method: "POST", Path: "/api/v1/tools/fade-video-audio"},
"apply-video-filter": {Method: "POST", Path: "/api/v1/tools/apply-video-filter"},
"adjust-video-volume": {Method: "POST", Path: "/api/v1/tools/adjust-video-volume"},
"fade-audio": {Method: "POST", Path: "/api/v1/tools/fade-audio"},
"mix-audio": {Method: "POST", Path: "/api/v1/tools/mix-audio"},
"adjust-audio-speed": {Method: "POST", Path: "/api/v1/tools/adjust-audio-speed"},
"probe-audio-metadata": {Method: "POST", Path: "/api/v1/tools/probe-audio-metadata"},
"image-ocr": {Method: "POST", Path: "/api/v1/tools-sync/image-ocr"},
"erase-image": {Method: "POST", Path: "/api/v1/tools-sync/erase-image"},
"remove-image-background": {Method: "POST", Path: "/api/v1/tools-sync/remove-image-background"},
"enhance-image": {Method: "POST", Path: "/api/v1/tools-sync/enhance-image"},
"evaluate-image-quality": {Method: "POST", Path: "/api/v1/tools-sync/evaluate-image-quality"},
"query-task": {Method: "GET", Path: "/api/v1/tasks/{task_id}"},
}
40 changes: 40 additions & 0 deletions internal/cloud/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ func Execute(cmd *cobra.Command, command string, params map[string]any, apiKey s
}

client := NewClient(apiKey, endpoint, surface, runtime)
requestParams, err = materializeCloudMediaInputs(client, normalizedCommand, requestParams)
if err != nil {
return writeJSON(cmd.OutOrStdout(), errorResponse(err, extractTaskID(requestParams), ""))
}
response, err := client.Call(normalizedCommand, requestParams)
if err != nil {
return writeJSON(cmd.OutOrStdout(), errorResponse(err, extractTaskID(requestParams), ""))
Expand Down Expand Up @@ -169,9 +173,45 @@ func formatCommandResponse(command string, response map[string]any) map[string]a
if command == queryTaskCommand {
return queryTaskResponse(response)
}
if isSyncCommand(command) {
return syncToolResponse(response)
}
return asyncTaskResponse(response)
}

func isSyncCommand(command string) bool {
api, ok := apiInfoRegistry[command]
if !ok {
return false
}
return strings.HasPrefix(api.Path, "/api/v1/tools-sync/")
}

func syncToolResponse(result map[string]any) map[string]any {
if len(result) == 0 {
return map[string]any{}
}

output := map[string]any{}
if taskID := strings.TrimSpace(fmt.Sprint(result["task_id"])); taskID != "" && taskID != "<nil>" {
output["task_id"] = taskID
}
if requestID := strings.TrimSpace(fmt.Sprint(result["request_id"])); requestID != "" && requestID != "<nil>" {
output["request_id"] = requestID
}
if status := strings.TrimSpace(fmt.Sprint(result["status"])); status != "" && status != "<nil>" {
output["status"] = status
}
taskResult, ok := result["result"].(map[string]any)
if !ok {
return output
}
for key, value := range taskResult {
output[key] = value
}
return output
}

func asyncTaskResponse(result map[string]any) map[string]any {
output := map[string]any{
"task_id": "",
Expand Down
197 changes: 197 additions & 0 deletions internal/cloud/media_inputs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package cloud

import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

cliconfig "mediakit-cli/internal/config"
)

const mediaUploadCommand = "request-media-upload-url"

var mediaInputNames = map[string]bool{
"video_url": true,
"video_urls": true,
"audio_url": true,
"audio_urls": true,
"image_url": true,
"image_urls": true,
"subtitle_url": true,
"subtitle_urls": true,
"sub_image_url": true,
}

func materializeCloudMediaInputs(client *Client, command string, params map[string]any) (map[string]any, error) {
if command == queryTaskCommand || command == mediaUploadCommand || len(params) == 0 {
return params, nil
}
home, err := cliconfig.ResolveHomeDir()
if err != nil {
return nil, err
}
materialized, err := materializeCloudValue(client, home, command, "", params, false)
if err != nil {
return nil, err
}
next, ok := materialized.(map[string]any)
if !ok {
return params, nil
}
return next, nil
}

func materializeCloudValue(client *Client, home string, command string, key string, value any, mediaContext bool) (any, error) {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for childKey, childValue := range typed {
childMediaContext := mediaContext || isMediaInputField(childKey)
materialized, err := materializeCloudValue(client, home, command, childKey, childValue, childMediaContext)
if err != nil {
return nil, err
}
next[childKey] = materialized
}
return next, nil
case []any:
next := make([]any, len(typed))
childMediaContext := mediaContext || isMediaInputField(key)
for i, childValue := range typed {
materialized, err := materializeCloudValue(client, home, command, key, childValue, childMediaContext)
if err != nil {
return nil, err
}
next[i] = materialized
}
return next, nil
case []string:
if !mediaContext && !isMediaInputField(key) {
return typed, nil
}
next := make([]string, len(typed))
for i, childValue := range typed {
materialized, err := materializeCloudMediaString(client, home, command, childValue)
if err != nil {
return nil, err
}
next[i] = materialized
}
return next, nil
case string:
if !mediaContext && !isMediaInputField(key) {
return typed, nil
}
return materializeCloudMediaString(client, home, command, typed)
default:
return value, nil
}
}

func materializeCloudMediaString(client *Client, home string, command string, value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || isRemoteOrMediaKitURL(value) {
return value, nil
}

identity, ok, err := resolveLocalMediaIdentity(value)
if err != nil {
return "", err
}
if !ok {
return value, nil
}

now := time.Now().UTC()
if fileID, err := lookupUploadCache(home, identity, now); err != nil {
return "", err
} else if fileID != "" {
return fileID, nil
}

fileID, err := client.uploadLocalMediaFile(command, identity.AbsPath)
if err != nil {
return "", err
}
return storeUploadCache(home, identity, fileID, now)
}

func resolveLocalMediaIdentity(value string) (fileIdentity, bool, error) {
path := expandUserPath(value)
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) && looksLikeLocalPath(value) {
return fileIdentity{}, false, fmt.Errorf("本地媒体文件不存在: %s", value)
}
return fileIdentity{}, false, nil
}
if info.IsDir() {
return fileIdentity{}, false, fmt.Errorf("本地媒体输入不能是目录: %s", value)
}
absPath, err := filepath.Abs(path)
if err != nil {
return fileIdentity{}, false, err
}
return fileIdentity{
AbsPath: absPath,
Size: info.Size(),
MTimeUnixNano: info.ModTime().UnixNano(),
}, true, nil
}

func isMediaInputField(name string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), "-", "_"))
if mediaInputNames[normalized] {
return true
}
if strings.HasSuffix(normalized, "_url") || strings.HasSuffix(normalized, "_urls") {
return strings.Contains(normalized, "video") ||
strings.Contains(normalized, "audio") ||
strings.Contains(normalized, "image") ||
strings.Contains(normalized, "subtitle")
}
return false
}

func isRemoteOrMediaKitURL(value string) bool {
lower := strings.ToLower(value)
return strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "mediakit://")
}

func looksLikeLocalPath(value string) bool {
if filepath.IsAbs(value) {
return true
}
if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
strings.HasPrefix(value, "~/") || strings.HasPrefix(value, ".\\") ||
strings.HasPrefix(value, "..\\") || strings.HasPrefix(value, "~\\") {
return true
}
if strings.Contains(value, "/") || strings.Contains(value, "\\") {
return true
}
switch strings.ToLower(filepath.Ext(value)) {
case ".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".mp3", ".m4a", ".wav", ".aac", ".flac", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".srt", ".ass", ".vtt":
return true
default:
return false
}
}

func expandUserPath(value string) string {
if value == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
}
if strings.HasPrefix(value, "~/") || strings.HasPrefix(value, "~\\") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, value[2:])
}
}
return value
}
Loading
Loading