From 7414d271051dd2b2b44251bea450ffa5f844bf43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Sanz?= Date: Thu, 9 Jul 2026 21:13:18 +0200 Subject: [PATCH 1/2] feat: add media command (mediaStorage), deprecate upload --- README.md | 9 +- docs/runware.md | 2 +- docs/runware_media.md | 25 ++++ docs/runware_media_delete.md | 39 +++++ ...ware_upload.md => runware_media_upload.md} | 23 ++- internal/api/client.go | 41 ++++++ internal/api/constants.go | 7 + internal/api/media_storage_test.go | 135 ++++++++++++++++++ internal/api/types.go | 20 +++ internal/cmd/media/delete.go | 66 +++++++++ internal/cmd/media/media.go | 19 +++ internal/cmd/media/upload.go | 89 ++++++++++++ internal/cmd/root.go | 2 + internal/cmd/upload/upload.go | 98 ++----------- internal/cmdutil/mediainput.go | 88 ++++++++++++ .../mediainput_test.go} | 22 +-- 16 files changed, 568 insertions(+), 117 deletions(-) create mode 100644 docs/runware_media.md create mode 100644 docs/runware_media_delete.md rename docs/{runware_upload.md => runware_media_upload.md} (58%) create mode 100644 internal/api/media_storage_test.go create mode 100644 internal/cmd/media/delete.go create mode 100644 internal/cmd/media/media.go create mode 100644 internal/cmd/media/upload.go create mode 100644 internal/cmdutil/mediainput.go rename internal/{cmd/upload/upload_test.go => cmdutil/mediainput_test.go} (85%) diff --git a/README.md b/README.md index 6afb924..f81c0dc 100644 --- a/README.md +++ b/README.md @@ -193,13 +193,16 @@ runware config path # Print config file path Config is stored at `~/.runware/config.yaml`. -### Upload +### Media ```shell -runware upload # Upload an image asset; prints imageUUID (and taskUUID) for use in run params +runware media upload # Upload media; prints mediaUUID (and mediaURL) for use in run params +runware media delete # Permanently delete stored media by its UUID ``` -Accepts a local file path, public URL, or data URI. The returned imageUUID can be passed to parameters like `inputs.seedImage=` on the `run` command. The taskUUID can be used with `runware result`. +Accepts a local file path, public URL, or data URI for upload. The returned mediaUUID can be passed to parameters like `inputs.seedImage=` on the `run` command. The taskUUID can be used with `runware result`. + +The older `runware upload` command is deprecated; use `runware media upload` instead. ### Result diff --git a/docs/runware.md b/docs/runware.md index 7db942b..50e12ec 100644 --- a/docs/runware.md +++ b/docs/runware.md @@ -25,11 +25,11 @@ Use of Runware services is subject to our Terms of Service (https://runware.ai/t * [runware auth](runware_auth.md) - Manage authentication * [runware completion](runware_completion.md) - Generate shell completion scripts * [runware config](runware_config.md) - Manage CLI configuration +* [runware media](runware_media.md) - Store and delete media in your Runware account * [runware model](runware_model.md) - Manage and search models * [runware ping](runware_ping.md) - Check API connectivity * [runware preset](runware_preset.md) - Manage named presets * [runware result](runware_result.md) - Wait for and display the result of a task by taskUUID * [runware run](runware_run.md) - Run an inference request against any Runware model -* [runware upload](runware_upload.md) - Upload an asset and return its UUID * [runware version](runware_version.md) - Print version information diff --git a/docs/runware_media.md b/docs/runware_media.md new file mode 100644 index 0000000..66b783b --- /dev/null +++ b/docs/runware_media.md @@ -0,0 +1,25 @@ +## runware media + +Store and delete media in your Runware account + +### Options + +``` + -h, --help help for media +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware](runware.md) - CLI tool for the Runware API +* [runware media delete](runware_media_delete.md) - Permanently delete stored media by its UUID +* [runware media upload](runware_media_upload.md) - Upload media and return its UUID + diff --git a/docs/runware_media_delete.md b/docs/runware_media_delete.md new file mode 100644 index 0000000..1ec8e69 --- /dev/null +++ b/docs/runware_media_delete.md @@ -0,0 +1,39 @@ +## runware media delete + +Permanently delete stored media by its UUID + +### Synopsis + +Permanently remove media previously uploaded to your Runware account, +identified by its mediaUUID. This cannot be undone. + +``` +runware media delete [flags] +``` + +### Examples + +``` + # delete a stored asset by its UUID + runware media delete 5f1d2c3b-8a4e-4c2a-9f1a-2b3c4d5e6f70 +``` + +### Options + +``` + -h, --help help for delete +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware media](runware_media.md) - Store and delete media in your Runware account + diff --git a/docs/runware_upload.md b/docs/runware_media_upload.md similarity index 58% rename from docs/runware_upload.md rename to docs/runware_media_upload.md index 8028847..d64a57f 100644 --- a/docs/runware_upload.md +++ b/docs/runware_media_upload.md @@ -1,36 +1,35 @@ -## runware upload +## runware media upload -Upload an asset and return its UUID +Upload media and return its UUID ### Synopsis -Upload an image to the Runware platform for use as input in other tasks +Upload media to the Runware platform for use as input in other tasks (e.g. image-to-image, upscaling, background removal). The argument may be a local file path, a publicly accessible URL, or a data URI. Local files are read and uploaded; URLs and data URIs are forwarded as-is. The -command prints the uploaded imageUUID (and taskUUID), which can be passed to -image parameters such as inputs.seedImage on the run command. +command prints the stored mediaUUID and mediaURL, which can be passed to media +parameters such as inputs.seedImage on the run command. -Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF. Video and audio upload is -not yet supported by the API. +Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF. ``` -runware upload [flags] +runware media upload [flags] ``` ### Examples ``` # upload a local image and print its UUID - runware upload ./photo.jpg + runware media upload ./photo.jpg # upload a remote image by URL - runware upload https://example.com/photo.jpg + runware media upload https://example.com/photo.jpg # upload and use the UUID directly in a run command runware run runware:100@1 positivePrompt="Same scene at night" width=1024 height=1024 \ - inputs.seedImage=$(runware upload ./photo.jpg -F json | jq -r '.imageUUID') + inputs.seedImage=$(runware media upload ./photo.jpg -F json | jq -r '.mediaUUID') ``` ### Options @@ -50,5 +49,5 @@ runware upload [flags] ### SEE ALSO -* [runware](runware.md) - CLI tool for the Runware API +* [runware media](runware_media.md) - Store and delete media in your Runware account diff --git a/internal/api/client.go b/internal/api/client.go index 198c518..da6a106 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -86,6 +86,9 @@ func (c *Client) AccountDetails(ctx context.Context) (*AccountResult, error) { // UploadImage uploads an image to the Runware platform via the imageUpload task // and returns the stored asset's UUID. image may be a publicly accessible URL, a // data URI, or a base64-encoded image, per the API contract. +// +// Superseded by MediaStorage, which handles any media type and supports deletion; +// retained for the deprecated "upload" command. func (c *Client) UploadImage(ctx context.Context, image string) (*ImageUploadResult, error) { if image == "" { return nil, fmt.Errorf("image is required") @@ -116,6 +119,44 @@ func (c *Client) UploadImage(ctx context.Context, image string) (*ImageUploadRes return &result, nil } +// MediaStorage stores or removes media via the mediaStorage task. operation is +// "upload" or "delete"; media is the asset (publicly accessible URL, data URI, or +// base64 string) for upload, or the mediaUUID to remove for delete. Upload returns +// the stored asset's MediaUUID and MediaURL; delete returns just the MediaUUID. +func (c *Client) MediaStorage(ctx context.Context, operation, media string) (*MediaStorageResult, error) { + if operation == "" { + return nil, fmt.Errorf("operation is required") + } + if media == "" { + return nil, fmt.Errorf("media is required") + } + + tasks := []any{ + &MediaStorageRequest{ + TaskType: taskTypeMediaStorage, + TaskUUID: uuid.New(), + Operation: operation, + Media: media, + }, + } + + data, err := c.transport.Send(ctx, tasks) + if err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, fmt.Errorf("empty response from mediaStorage") + } + + var result MediaStorageResult + if err := json.Unmarshal(data[0], &result); err != nil { + return nil, fmt.Errorf("failed to parse mediaStorage response: %w", err) + } + + return &result, nil +} + // submit sends a single arbitrary task payload and returns all raw JSON responses. // It is the low-level wire call used by Run after system fields have been injected. func (c *Client) submit(ctx context.Context, payload map[string]any) ([]json.RawMessage, error) { diff --git a/internal/api/constants.go b/internal/api/constants.go index db83a95..87e3d6b 100644 --- a/internal/api/constants.go +++ b/internal/api/constants.go @@ -14,6 +14,13 @@ const ( taskTypeGetResponse TaskType = "getResponse" taskTypeAccountManagement TaskType = "accountManagement" taskTypeImageUpload TaskType = "imageUpload" + taskTypeMediaStorage TaskType = "mediaStorage" +) + +// Media storage operations, passed to Client.MediaStorage. +const ( + MediaOperationUpload = "upload" + MediaOperationDelete = "delete" ) // DeliveryMethod specifies how task results are delivered. diff --git a/internal/api/media_storage_test.go b/internal/api/media_storage_test.go new file mode 100644 index 0000000..6d73ed0 --- /dev/null +++ b/internal/api/media_storage_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "encoding/json" + "log/slog" + "testing" + + "github.com/google/uuid" +) + +// TestMediaStorage_UploadSuccess: a valid upload response is parsed and the +// request carries the mediaStorage task type, the upload operation, and media. +func TestMediaStorage_UploadSuccess(t *testing.T) { + mediaUUID := uuid.New() + mediaURL := "https://im.runware.ai/asset.png" + mock := &mockTransport{ + responses: []mockResponse{ + {data: []json.RawMessage{rawJSON(t, map[string]any{ + fieldTaskType: "mediaStorage", + "taskUUID": uuid.New().String(), + "operation": MediaOperationUpload, + "mediaUUID": mediaUUID.String(), + "mediaURL": mediaURL, + })}}, + }, + } + + c := NewClient(mock, slog.Default()) + result, err := c.MediaStorage(context.Background(), MediaOperationUpload, "data:image/png;base64,AAAA") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.MediaUUID != mediaUUID { + t.Errorf("MediaUUID = %v, want %v", result.MediaUUID, mediaUUID) + } + if result.MediaURL != mediaURL { + t.Errorf("MediaURL = %q, want %q", result.MediaURL, mediaURL) + } + + if len(mock.captured) != 1 || len(mock.captured[0]) != 1 { + t.Fatalf("expected one submitted task, got %#v", mock.captured) + } + req, ok := mock.captured[0][0].(*MediaStorageRequest) + if !ok { + t.Fatalf("submitted task is not *MediaStorageRequest: %T", mock.captured[0][0]) + } + if req.TaskType != taskTypeMediaStorage { + t.Errorf("TaskType = %q, want %q", req.TaskType, taskTypeMediaStorage) + } + if req.Operation != MediaOperationUpload { + t.Errorf("Operation = %q, want %q", req.Operation, MediaOperationUpload) + } + if req.Media != "data:image/png;base64,AAAA" { + t.Errorf("Media = %q, want the supplied value", req.Media) + } +} + +// TestMediaStorage_DeleteSuccess: a delete response (no mediaURL) is parsed and +// the request carries the delete operation with the target UUID as media. +func TestMediaStorage_DeleteSuccess(t *testing.T) { + mediaUUID := uuid.New() + mock := &mockTransport{ + responses: []mockResponse{ + {data: []json.RawMessage{rawJSON(t, map[string]any{ + fieldTaskType: "mediaStorage", + "taskUUID": uuid.New().String(), + "operation": MediaOperationDelete, + "mediaUUID": mediaUUID.String(), + })}}, + }, + } + + c := NewClient(mock, slog.Default()) + result, err := c.MediaStorage(context.Background(), MediaOperationDelete, mediaUUID.String()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.MediaUUID != mediaUUID { + t.Errorf("MediaUUID = %v, want %v", result.MediaUUID, mediaUUID) + } + if result.MediaURL != "" { + t.Errorf("MediaURL = %q, want empty for delete", result.MediaURL) + } + + req, ok := mock.captured[0][0].(*MediaStorageRequest) + if !ok { + t.Fatalf("submitted task is not *MediaStorageRequest: %T", mock.captured[0][0]) + } + if req.Operation != MediaOperationDelete { + t.Errorf("Operation = %q, want %q", req.Operation, MediaOperationDelete) + } + if req.Media != mediaUUID.String() { + t.Errorf("Media = %q, want the target UUID", req.Media) + } +} + +// TestMediaStorage_EmptyOperation: an empty operation is rejected before any +// transport call. +func TestMediaStorage_EmptyOperation(t *testing.T) { + mock := &mockTransport{} + c := NewClient(mock, slog.Default()) + if _, err := c.MediaStorage(context.Background(), "", "data:image/png;base64,AAAA"); err == nil { + t.Fatal("expected error for empty operation, got nil") + } + if mock.callCount != 0 { + t.Errorf("expected no transport calls, got %d", mock.callCount) + } +} + +// TestMediaStorage_EmptyMedia: an empty media value is rejected before any +// transport call. +func TestMediaStorage_EmptyMedia(t *testing.T) { + mock := &mockTransport{} + c := NewClient(mock, slog.Default()) + if _, err := c.MediaStorage(context.Background(), MediaOperationUpload, ""); err == nil { + t.Fatal("expected error for empty media, got nil") + } + if mock.callCount != 0 { + t.Errorf("expected no transport calls, got %d", mock.callCount) + } +} + +// TestMediaStorage_EmptyResponse: an empty data slice returns an error. +func TestMediaStorage_EmptyResponse(t *testing.T) { + mock := &mockTransport{ + responses: []mockResponse{ + {data: []json.RawMessage{}}, + }, + } + c := NewClient(mock, slog.Default()) + if _, err := c.MediaStorage(context.Background(), MediaOperationDelete, uuid.New().String()); err == nil { + t.Fatal("expected error for empty response, got nil") + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 83518fc..7fe59ea 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -80,6 +80,26 @@ type ImageUploadResult struct { ImageUUID uuid.UUID `json:"imageUUID"` } +// MediaStorageRequest is the request payload for the mediaStorage task. Operation +// is "upload" or "delete". Media is the asset (publicly accessible URL, data URI, +// or base64 string) for upload, or the mediaUUID to remove for delete. +type MediaStorageRequest struct { + TaskType TaskType `json:"taskType"` + TaskUUID uuid.UUID `json:"taskUUID"` + Operation string `json:"operation"` + Media string `json:"media"` +} + +// MediaStorageResult is the response from a mediaStorage task. MediaURL is set +// only for the upload operation; delete returns just the MediaUUID that was removed. +type MediaStorageResult struct { + TaskType TaskType `json:"taskType"` + TaskUUID uuid.UUID `json:"taskUUID"` + Operation string `json:"operation"` + MediaUUID uuid.UUID `json:"mediaUUID"` + MediaURL string `json:"mediaURL,omitempty"` +} + // PingRequest is the request payload for the ping task. type PingRequest struct { TaskType TaskType `json:"taskType"` diff --git a/internal/cmd/media/delete.go b/internal/cmd/media/delete.go new file mode 100644 index 0000000..cbceebf --- /dev/null +++ b/internal/cmd/media/delete.go @@ -0,0 +1,66 @@ +package media + +import ( + "log/slog" + + "github.com/charmbracelet/log" + "github.com/runware/runware-cli/internal/api" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +// deleteResult wraps the mediaStorage delete response for display. Delete returns +// only the mediaUUID that was removed (no URL). +type deleteResult struct { + MediaUUID string `json:"mediaUUID" yaml:"mediaUUID"` + TaskUUID string `json:"taskUUID" yaml:"taskUUID"` +} + +func (r deleteResult) Headers() []string { + return []string{"Field", "Value"} +} + +func (r deleteResult) Rows() [][]any { + return [][]any{ + {"Media UUID", r.MediaUUID}, + {"Task UUID", r.TaskUUID}, + } +} + +// newDeleteCmd returns the "media delete" command. +func newDeleteCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Permanently delete stored media by its UUID", + Long: `Permanently remove media previously uploaded to your Runware account, +identified by its mediaUUID. This cannot be undone.`, + Example: ` # delete a stored asset by its UUID + runware media delete 5f1d2c3b-8a4e-4c2a-9f1a-2b3c4d5e6f70`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + spin := cmdutil.NewSpinner("Deleting...") + spin.Start() + + t, err := cmdutil.NewTransport(cmd, slog.New(logger)) + if err != nil { + spin.Stop() + return err + } + defer t.Close() //nolint:errcheck + + client := api.NewClient(t, slog.New(logger)) + result, err := client.MediaStorage(cmd.Context(), api.MediaOperationDelete, args[0]) + if err != nil { + spin.Stop() + return err + } + + spin.Stop() + return output.Print(cmdutil.FormatFor(cmd), deleteResult{ + MediaUUID: result.MediaUUID.String(), + TaskUUID: result.TaskUUID.String(), + }) + }, + } +} diff --git a/internal/cmd/media/media.go b/internal/cmd/media/media.go new file mode 100644 index 0000000..ef53de3 --- /dev/null +++ b/internal/cmd/media/media.go @@ -0,0 +1,19 @@ +// Package media implements the "media" command group for managing media in a +// Runware account: uploading assets for reuse and deleting them by UUID. +package media + +import ( + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +// NewCmd returns the "media" command with upload and delete subcommands. +func NewCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "media", + Short: "Store and delete media in your Runware account", + } + cmd.AddCommand(newUploadCmd(logger)) + cmd.AddCommand(newDeleteCmd(logger)) + return cmd +} diff --git a/internal/cmd/media/upload.go b/internal/cmd/media/upload.go new file mode 100644 index 0000000..febc0e8 --- /dev/null +++ b/internal/cmd/media/upload.go @@ -0,0 +1,89 @@ +package media + +import ( + "log/slog" + + "github.com/charmbracelet/log" + "github.com/runware/runware-cli/internal/api" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +// uploadResult wraps the mediaStorage upload response for display. JSON and YAML +// output the struct directly (keys match the API for easy chaining); the table +// renderer flattens it into a two-column key/value layout. +type uploadResult struct { + MediaUUID string `json:"mediaUUID" yaml:"mediaUUID"` + MediaURL string `json:"mediaURL" yaml:"mediaURL"` + TaskUUID string `json:"taskUUID" yaml:"taskUUID"` +} + +func (r uploadResult) Headers() []string { + return []string{"Field", "Value"} +} + +func (r uploadResult) Rows() [][]any { + return [][]any{ + {"Media UUID", r.MediaUUID}, + {"Media URL", r.MediaURL}, + {"Task UUID", r.TaskUUID}, + } +} + +// newUploadCmd returns the "media upload" command. +func newUploadCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "upload ", + Short: "Upload media and return its UUID", + Long: `Upload media to the Runware platform for use as input in other tasks +(e.g. image-to-image, upscaling, background removal). + +The argument may be a local file path, a publicly accessible URL, or a data URI. +Local files are read and uploaded; URLs and data URIs are forwarded as-is. The +command prints the stored mediaUUID and mediaURL, which can be passed to media +parameters such as inputs.seedImage on the run command. + +Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF.`, + Example: ` # upload a local image and print its UUID + runware media upload ./photo.jpg + + # upload a remote image by URL + runware media upload https://example.com/photo.jpg + + # upload and use the UUID directly in a run command + runware run runware:100@1 positivePrompt="Same scene at night" width=1024 height=1024 \ + inputs.seedImage=$(runware media upload ./photo.jpg -F json | jq -r '.mediaUUID')`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + media, err := cmdutil.BuildImageInput(args[0]) + if err != nil { + return err + } + + spin := cmdutil.NewSpinner("Uploading...") + spin.Start() + + t, err := cmdutil.NewTransport(cmd, slog.New(logger)) + if err != nil { + spin.Stop() + return err + } + defer t.Close() //nolint:errcheck + + client := api.NewClient(t, slog.New(logger)) + result, err := client.MediaStorage(cmd.Context(), api.MediaOperationUpload, media) + if err != nil { + spin.Stop() + return err + } + + spin.Stop() + return output.Print(cmdutil.FormatFor(cmd), uploadResult{ + MediaUUID: result.MediaUUID.String(), + MediaURL: result.MediaURL, + TaskUUID: result.TaskUUID.String(), + }) + }, + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5fd4aa6..84889bc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/runware/runware-cli/internal/cmd/auth" cmdcompletion "github.com/runware/runware-cli/internal/cmd/completion" cmdconfig "github.com/runware/runware-cli/internal/cmd/config" + "github.com/runware/runware-cli/internal/cmd/media" "github.com/runware/runware-cli/internal/cmd/model" "github.com/runware/runware-cli/internal/cmd/ping" "github.com/runware/runware-cli/internal/cmd/preset" @@ -92,6 +93,7 @@ Use of Runware services is subject to our Terms of Service (https://runware.ai/t preset.NewCmd(logger), cmdrun.NewCmd(logger), cmdrun.NewResultCmd(logger), + media.NewCmd(logger), cmdupload.NewCmd(logger), cmdversion.NewCmd(), cmdcompletion.NewCmd(), diff --git a/internal/cmd/upload/upload.go b/internal/cmd/upload/upload.go index 43e82e7..1988ac3 100644 --- a/internal/cmd/upload/upload.go +++ b/internal/cmd/upload/upload.go @@ -1,15 +1,10 @@ -// Package upload implements the "upload" command, which uploads asset files to -// the Runware platform and returns their reusable UUID. +// Package upload implements the deprecated "upload" command, which uploads image +// files to the Runware platform and returns their reusable UUID. Superseded by +// the "media" command group, which handles any media type and supports deletion. package upload import ( - "encoding/base64" - "fmt" "log/slog" - "net/http" - "os" - "path/filepath" - "strings" "github.com/charmbracelet/log" "github.com/runware/runware-cli/internal/api" @@ -37,31 +32,12 @@ func (r uploadResult) Rows() [][]any { } } -// imageExtMIME maps the file extensions accepted by the imageUpload API to their -// MIME types. Used as a fallback when magic-byte detection is unreliable (e.g. BMP). -var imageExtMIME = map[string]string{ - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".gif": "image/gif", -} - -// allowedImageMIME is the set of MIME types accepted by the imageUpload API. -var allowedImageMIME = map[string]struct{}{ - "image/jpeg": {}, - "image/png": {}, - "image/webp": {}, - "image/bmp": {}, - "image/gif": {}, -} - -// NewCmd returns the "upload" command. +// NewCmd returns the deprecated "upload" command. func NewCmd(logger *log.Logger) *cobra.Command { return &cobra.Command{ - Use: "upload ", - Short: "Upload an asset and return its UUID", + Use: "upload ", + Short: "Upload an asset and return its UUID", + Deprecated: `use "runware media upload" instead.`, Long: `Upload an image to the Runware platform for use as input in other tasks (e.g. image-to-image, upscaling, background removal). @@ -83,7 +59,7 @@ not yet supported by the API.`, inputs.seedImage=$(runware upload ./photo.jpg -F json | jq -r '.imageUUID')`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - image, err := buildImageInput(args[0]) + image, err := cmdutil.BuildImageInput(args[0]) if err != nil { return err } @@ -113,61 +89,3 @@ not yet supported by the API.`, }, } } - -// buildImageInput converts a CLI argument into the value accepted by the -// imageUpload "image" field. Remote URLs and data URIs are returned unchanged; -// local file paths are read, validated by content type, and encoded as a data URI. -func buildImageInput(arg string) (string, error) { - if isRemoteOrDataURI(arg) { - return arg, nil - } - - info, err := os.Stat(arg) - if err != nil { - return "", fmt.Errorf("cannot stat file %q: %w", arg, err) - } - if info.IsDir() { - return "", fmt.Errorf("%q is a directory, not a file", arg) - } - - data, err := os.ReadFile(arg) //nolint:gosec // user-supplied path is expected for a CLI upload - if err != nil { - return "", fmt.Errorf("cannot read file %q: %w", arg, err) - } - - mime, err := detectImageMIME(data, arg) - if err != nil { - return "", err - } - - return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil -} - -// detectImageMIME returns the MIME type for image data, preferring magic-byte -// detection and falling back to the file extension when detection is unreliable. -func detectImageMIME(data []byte, path string) (string, error) { - mime := http.DetectContentType(data) - if _, ok := allowedImageMIME[mime]; ok { - return mime, nil - } - - if extMIME, ok := imageExtMIME[strings.ToLower(filepath.Ext(path))]; ok { - if _, allowed := allowedImageMIME[extMIME]; allowed { - return extMIME, nil - } - } - - return "", fmt.Errorf( - "unsupported file type (detected %q): supported types are JPEG, JPG, PNG, WEBP, BMP, GIF", - mime, - ) -} - -// isRemoteOrDataURI reports whether arg should be forwarded to the API verbatim -// rather than read from disk. -func isRemoteOrDataURI(arg string) bool { - lower := strings.ToLower(arg) - return strings.HasPrefix(lower, "http://") || - strings.HasPrefix(lower, "https://") || - strings.HasPrefix(lower, "data:") -} diff --git a/internal/cmdutil/mediainput.go b/internal/cmdutil/mediainput.go new file mode 100644 index 0000000..f9808e4 --- /dev/null +++ b/internal/cmdutil/mediainput.go @@ -0,0 +1,88 @@ +package cmdutil + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +// imageExtMIME maps the file extensions accepted by the media API to their MIME +// types. Used as a fallback when magic-byte detection is unreliable (e.g. BMP). +var imageExtMIME = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".gif": "image/gif", +} + +// allowedImageMIME is the set of MIME types accepted by the media API. +var allowedImageMIME = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, + "image/webp": {}, + "image/bmp": {}, + "image/gif": {}, +} + +// BuildImageInput converts a CLI argument into the value accepted by the media +// "image"/"media" fields. Remote URLs and data URIs are returned unchanged; local +// file paths are read, validated by content type, and encoded as a data URI. +func BuildImageInput(arg string) (string, error) { + if isRemoteOrDataURI(arg) { + return arg, nil + } + + info, err := os.Stat(arg) + if err != nil { + return "", fmt.Errorf("cannot stat file %q: %w", arg, err) + } + if info.IsDir() { + return "", fmt.Errorf("%q is a directory, not a file", arg) + } + + data, err := os.ReadFile(arg) //nolint:gosec // user-supplied path is expected for a CLI upload + if err != nil { + return "", fmt.Errorf("cannot read file %q: %w", arg, err) + } + + mime, err := detectImageMIME(data, arg) + if err != nil { + return "", err + } + + return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil +} + +// detectImageMIME returns the MIME type for image data, preferring magic-byte +// detection and falling back to the file extension when detection is unreliable. +func detectImageMIME(data []byte, path string) (string, error) { + mime := http.DetectContentType(data) + if _, ok := allowedImageMIME[mime]; ok { + return mime, nil + } + + if extMIME, ok := imageExtMIME[strings.ToLower(filepath.Ext(path))]; ok { + if _, allowed := allowedImageMIME[extMIME]; allowed { + return extMIME, nil + } + } + + return "", fmt.Errorf( + "unsupported file type (detected %q): supported types are JPEG, JPG, PNG, WEBP, BMP, GIF", + mime, + ) +} + +// isRemoteOrDataURI reports whether arg should be forwarded to the API verbatim +// rather than read from disk. +func isRemoteOrDataURI(arg string) bool { + lower := strings.ToLower(arg) + return strings.HasPrefix(lower, "http://") || + strings.HasPrefix(lower, "https://") || + strings.HasPrefix(lower, "data:") +} diff --git a/internal/cmd/upload/upload_test.go b/internal/cmdutil/mediainput_test.go similarity index 85% rename from internal/cmd/upload/upload_test.go rename to internal/cmdutil/mediainput_test.go index 7b2a925..fec3891 100644 --- a/internal/cmd/upload/upload_test.go +++ b/internal/cmdutil/mediainput_test.go @@ -1,4 +1,4 @@ -package upload +package cmdutil import ( "bytes" @@ -28,13 +28,13 @@ func TestBuildImageInput_Passthrough(t *testing.T) { "data:image/png;base64,AAAA", } for _, in := range cases { - got, err := buildImageInput(in) + got, err := BuildImageInput(in) if err != nil { - t.Errorf("buildImageInput(%q) error: %v", in, err) + t.Errorf("BuildImageInput(%q) error: %v", in, err) continue } if got != in { - t.Errorf("buildImageInput(%q) = %q, want unchanged", in, got) + t.Errorf("BuildImageInput(%q) = %q, want unchanged", in, got) } } } @@ -48,9 +48,9 @@ func TestBuildImageInput_LocalFile(t *testing.T) { t.Fatalf("write temp file: %v", err) } - got, err := buildImageInput(path) + got, err := BuildImageInput(path) if err != nil { - t.Fatalf("buildImageInput error: %v", err) + t.Fatalf("BuildImageInput error: %v", err) } wantPrefix := "data:image/png;base64," @@ -76,9 +76,9 @@ func TestBuildImageInput_ContentOverridesExtension(t *testing.T) { t.Fatalf("write temp file: %v", err) } - got, err := buildImageInput(path) + got, err := BuildImageInput(path) if err != nil { - t.Fatalf("buildImageInput error: %v", err) + t.Fatalf("BuildImageInput error: %v", err) } if !strings.HasPrefix(got, "data:image/png;base64,") { t.Fatalf("got %q, want image/png MIME from content", got) @@ -93,21 +93,21 @@ func TestBuildImageInput_UnsupportedContentType(t *testing.T) { t.Fatalf("write temp file: %v", err) } - if _, err := buildImageInput(path); err == nil { + if _, err := BuildImageInput(path); err == nil { t.Fatal("expected error for unsupported content type, got nil") } } // TestBuildImageInput_MissingFile: a non-existent path returns an error. func TestBuildImageInput_MissingFile(t *testing.T) { - if _, err := buildImageInput(filepath.Join(t.TempDir(), "nope.png")); err == nil { + if _, err := BuildImageInput(filepath.Join(t.TempDir(), "nope.png")); err == nil { t.Fatal("expected error for missing file, got nil") } } // TestBuildImageInput_Directory: a directory path is rejected. func TestBuildImageInput_Directory(t *testing.T) { - if _, err := buildImageInput(t.TempDir()); err == nil { + if _, err := BuildImageInput(t.TempDir()); err == nil { t.Fatal("expected error for directory, got nil") } } From c499ee2d48af2a50648537ada58a673838026026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Sanz?= Date: Fri, 10 Jul 2026 16:45:18 +0200 Subject: [PATCH 2/2] fix: address media PR review (any-media upload, uuid check, goconst) --- docs/runware_media_upload.md | 2 +- internal/api/media_storage_test.go | 4 +- internal/api/run_test.go | 10 ++-- internal/api/upload_test.go | 2 +- internal/cmd/media/delete.go | 6 ++ internal/cmd/media/upload.go | 4 +- internal/cmdutil/mediainput.go | 92 ++++++++++++++++++++++------- internal/cmdutil/mediainput_test.go | 49 +++++++++++++++ 8 files changed, 136 insertions(+), 33 deletions(-) diff --git a/docs/runware_media_upload.md b/docs/runware_media_upload.md index d64a57f..f06ccd3 100644 --- a/docs/runware_media_upload.md +++ b/docs/runware_media_upload.md @@ -12,7 +12,7 @@ Local files are read and uploaded; URLs and data URIs are forwarded as-is. The command prints the stored mediaUUID and mediaURL, which can be passed to media parameters such as inputs.seedImage on the run command. -Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF. +Accepts any media type: images, video, audio, 3D models, and more. ``` runware media upload [flags] diff --git a/internal/api/media_storage_test.go b/internal/api/media_storage_test.go index 6d73ed0..614ae19 100644 --- a/internal/api/media_storage_test.go +++ b/internal/api/media_storage_test.go @@ -18,7 +18,7 @@ func TestMediaStorage_UploadSuccess(t *testing.T) { responses: []mockResponse{ {data: []json.RawMessage{rawJSON(t, map[string]any{ fieldTaskType: "mediaStorage", - "taskUUID": uuid.New().String(), + fieldTaskUUID: uuid.New().String(), "operation": MediaOperationUpload, "mediaUUID": mediaUUID.String(), "mediaURL": mediaURL, @@ -64,7 +64,7 @@ func TestMediaStorage_DeleteSuccess(t *testing.T) { responses: []mockResponse{ {data: []json.RawMessage{rawJSON(t, map[string]any{ fieldTaskType: "mediaStorage", - "taskUUID": uuid.New().String(), + fieldTaskUUID: uuid.New().String(), "operation": MediaOperationDelete, "mediaUUID": mediaUUID.String(), })}}, diff --git a/internal/api/run_test.go b/internal/api/run_test.go index ea03774..b535167 100644 --- a/internal/api/run_test.go +++ b/internal/api/run_test.go @@ -90,7 +90,7 @@ func TestClientRun_SyncSuccess(t *testing.T) { func TestClientRun_AsyncSuccess(t *testing.T) { srv := inferenceSchemaServer(t, requestSchemaWithTaskType("videoInference", "async")) - submitAck := rawJSON(t, map[string]any{"taskUUID": "some-uuid"}) + submitAck := rawJSON(t, map[string]any{fieldTaskUUID: "some-uuid"}) pollResult := successItem(t, map[string]any{"videoURL": "https://example.com/vid.mp4"}) mock := &mockTransport{ @@ -270,7 +270,7 @@ func TestClientRun_OnProgressCalled(t *testing.T) { mock := &mockTransport{ responses: []mockResponse{ - {data: []json.RawMessage{rawJSON(t, map[string]any{"taskUUID": "x"})}}, // submit + {data: []json.RawMessage{rawJSON(t, map[string]any{fieldTaskUUID: "x"})}}, // submit {data: []json.RawMessage{processingItem(t, 30)}}, {data: []json.RawMessage{processingItem(t, 70)}}, {data: []json.RawMessage{successItem(t, nil)}}, @@ -438,7 +438,7 @@ func TestClientRun_UserProvidedTaskUUID(t *testing.T) { if err := json.Unmarshal(taskBytes, &payload); err != nil { t.Fatalf("unmarshal captured task: %v", err) } - got, ok := payload["taskUUID"] + got, ok := payload[fieldTaskUUID] if !ok { t.Fatal("taskUUID not present in submitted payload") } @@ -461,8 +461,8 @@ func TestClientRun_UserProvidedTaskUUID_Invalid(t *testing.T) { if err == nil { t.Fatal("expected error for invalid taskUUID, got nil") } - if !strings.Contains(err.Error(), "taskUUID") { - t.Errorf("expected error to mention %q, got: %v", "taskUUID", err) + if !strings.Contains(err.Error(), fieldTaskUUID) { + t.Errorf("expected error to mention %q, got: %v", fieldTaskUUID, err) } if mock.callCount != 0 { t.Errorf("expected 0 transport calls, got %d", mock.callCount) diff --git a/internal/api/upload_test.go b/internal/api/upload_test.go index 5dfe251..9ded1c3 100644 --- a/internal/api/upload_test.go +++ b/internal/api/upload_test.go @@ -21,7 +21,7 @@ func TestUploadImage_Success(t *testing.T) { responses: []mockResponse{ {data: []json.RawMessage{rawJSON(t, map[string]any{ fieldTaskType: "imageUpload", - "taskUUID": uuid.New().String(), + fieldTaskUUID: uuid.New().String(), "imageUUID": imageUUID.String(), })}}, }, diff --git a/internal/cmd/media/delete.go b/internal/cmd/media/delete.go index cbceebf..ad0900d 100644 --- a/internal/cmd/media/delete.go +++ b/internal/cmd/media/delete.go @@ -1,9 +1,11 @@ package media import ( + "fmt" "log/slog" "github.com/charmbracelet/log" + "github.com/google/uuid" "github.com/runware/runware-cli/internal/api" "github.com/runware/runware-cli/internal/cmdutil" "github.com/runware/runware-cli/internal/output" @@ -39,6 +41,10 @@ identified by its mediaUUID. This cannot be undone.`, runware media delete 5f1d2c3b-8a4e-4c2a-9f1a-2b3c4d5e6f70`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if _, err := uuid.Parse(args[0]); err != nil { + return fmt.Errorf("invalid mediaUUID %q: %w", args[0], err) + } + spin := cmdutil.NewSpinner("Deleting...") spin.Start() diff --git a/internal/cmd/media/upload.go b/internal/cmd/media/upload.go index febc0e8..7268018 100644 --- a/internal/cmd/media/upload.go +++ b/internal/cmd/media/upload.go @@ -44,7 +44,7 @@ Local files are read and uploaded; URLs and data URIs are forwarded as-is. The command prints the stored mediaUUID and mediaURL, which can be passed to media parameters such as inputs.seedImage on the run command. -Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF.`, +Accepts any media type: images, video, audio, 3D models, and more.`, Example: ` # upload a local image and print its UUID runware media upload ./photo.jpg @@ -56,7 +56,7 @@ Supported file types: JPEG, JPG, PNG, WEBP, BMP, GIF.`, inputs.seedImage=$(runware media upload ./photo.jpg -F json | jq -r '.mediaUUID')`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - media, err := cmdutil.BuildImageInput(args[0]) + media, err := cmdutil.BuildMediaInput(args[0]) if err != nil { return err } diff --git a/internal/cmdutil/mediainput.go b/internal/cmdutil/mediainput.go index f9808e4..3a84102 100644 --- a/internal/cmdutil/mediainput.go +++ b/internal/cmdutil/mediainput.go @@ -3,14 +3,15 @@ package cmdutil import ( "encoding/base64" "fmt" + "mime" "net/http" "os" "path/filepath" "strings" ) -// imageExtMIME maps the file extensions accepted by the media API to their MIME -// types. Used as a fallback when magic-byte detection is unreliable (e.g. BMP). +// imageExtMIME maps the file extensions accepted by the imageUpload task to their +// MIME types. Used as a fallback when magic-byte detection is unreliable (e.g. BMP). var imageExtMIME = map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", @@ -20,7 +21,7 @@ var imageExtMIME = map[string]string{ ".gif": "image/gif", } -// allowedImageMIME is the set of MIME types accepted by the media API. +// allowedImageMIME is the set of MIME types accepted by the imageUpload task. var allowedImageMIME = map[string]struct{}{ "image/jpeg": {}, "image/png": {}, @@ -29,55 +30,102 @@ var allowedImageMIME = map[string]struct{}{ "image/gif": {}, } -// BuildImageInput converts a CLI argument into the value accepted by the media -// "image"/"media" fields. Remote URLs and data URIs are returned unchanged; local -// file paths are read, validated by content type, and encoded as a data URI. +// BuildImageInput converts a CLI argument into an image value for the imageUpload +// task, which accepts images only. Remote URLs and data URIs are returned unchanged; +// local file paths are read, validated to be a supported image, and encoded as a +// data URI. Use BuildMediaInput for mediaStorage, which accepts any media type. func BuildImageInput(arg string) (string, error) { if isRemoteOrDataURI(arg) { return arg, nil } - info, err := os.Stat(arg) + data, err := readLocalFile(arg) if err != nil { - return "", fmt.Errorf("cannot stat file %q: %w", arg, err) - } - if info.IsDir() { - return "", fmt.Errorf("%q is a directory, not a file", arg) + return "", err } - data, err := os.ReadFile(arg) //nolint:gosec // user-supplied path is expected for a CLI upload + ct, err := detectImageMIME(data, arg) if err != nil { - return "", fmt.Errorf("cannot read file %q: %w", arg, err) + return "", err } - mime, err := detectImageMIME(data, arg) + return dataURI(ct, data), nil +} + +// BuildMediaInput converts a CLI argument into a media value for the mediaStorage +// task, which accepts any media type (image, video, audio, 3D, and more). Remote +// URLs and data URIs are returned unchanged; local file paths are read and encoded +// as a data URI with a best-effort content type, without restricting the type. +func BuildMediaInput(arg string) (string, error) { + if isRemoteOrDataURI(arg) { + return arg, nil + } + + data, err := readLocalFile(arg) if err != nil { return "", err } - return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil + return dataURI(detectMediaMIME(data, arg), data), nil +} + +// readLocalFile reads a local file path, rejecting directories and missing files. +func readLocalFile(arg string) ([]byte, error) { + info, err := os.Stat(arg) + if err != nil { + return nil, fmt.Errorf("cannot stat file %q: %w", arg, err) + } + if info.IsDir() { + return nil, fmt.Errorf("%q is a directory, not a file", arg) + } + + data, err := os.ReadFile(arg) //nolint:gosec // user-supplied path is expected for a CLI upload + if err != nil { + return nil, fmt.Errorf("cannot read file %q: %w", arg, err) + } + return data, nil +} + +// dataURI base64-encodes data as a data URI with the given content type. +func dataURI(contentType string, data []byte) string { + return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data)) } // detectImageMIME returns the MIME type for image data, preferring magic-byte -// detection and falling back to the file extension when detection is unreliable. +// detection and falling back to the file extension. It rejects non-image content. func detectImageMIME(data []byte, path string) (string, error) { - mime := http.DetectContentType(data) - if _, ok := allowedImageMIME[mime]; ok { - return mime, nil + ct := http.DetectContentType(data) + if _, ok := allowedImageMIME[ct]; ok { + return ct, nil } - if extMIME, ok := imageExtMIME[strings.ToLower(filepath.Ext(path))]; ok { - if _, allowed := allowedImageMIME[extMIME]; allowed { + // Fall back to the extension only when sniffing is indeterminate (e.g. some + // BMPs), never when it detected a clearly non-image type. + if ct == "application/octet-stream" { + if extMIME, ok := imageExtMIME[strings.ToLower(filepath.Ext(path))]; ok { return extMIME, nil } } return "", fmt.Errorf( "unsupported file type (detected %q): supported types are JPEG, JPG, PNG, WEBP, BMP, GIF", - mime, + ct, ) } +// detectMediaMIME returns a best-effort content type for any file: magic-byte +// detection first, then the file extension, defaulting to application/octet-stream. +// It never rejects a file, since mediaStorage accepts any media type. +func detectMediaMIME(data []byte, path string) string { + ct := http.DetectContentType(data) + if ct == "application/octet-stream" { + if byExt := mime.TypeByExtension(filepath.Ext(path)); byExt != "" { + return byExt + } + } + return ct +} + // isRemoteOrDataURI reports whether arg should be forwarded to the API verbatim // rather than read from disk. func isRemoteOrDataURI(arg string) bool { diff --git a/internal/cmdutil/mediainput_test.go b/internal/cmdutil/mediainput_test.go index fec3891..2e0feef 100644 --- a/internal/cmdutil/mediainput_test.go +++ b/internal/cmdutil/mediainput_test.go @@ -111,3 +111,52 @@ func TestBuildImageInput_Directory(t *testing.T) { t.Fatal("expected error for directory, got nil") } } + +// TestBuildMediaInput_NonImageAccepted: mediaStorage takes any media type, so a +// non-image file is encoded as a data URI instead of being rejected. +func TestBuildMediaInput_NonImageAccepted(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clip.bin") + if err := os.WriteFile(path, []byte("not an image at all"), 0600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + got, err := BuildMediaInput(path) + if err != nil { + t.Fatalf("BuildMediaInput error: %v", err) + } + if !strings.HasPrefix(got, "data:") || !strings.Contains(got, ";base64,") { + t.Fatalf("got %q, want a data URI", got) + } +} + +// TestBuildMediaInput_ImageStillWorks: an image file is also accepted (any type). +func TestBuildMediaInput_ImageStillWorks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pic.png") + if err := os.WriteFile(path, minimalPNG, 0600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + got, err := BuildMediaInput(path) + if err != nil { + t.Fatalf("BuildMediaInput error: %v", err) + } + if !strings.HasPrefix(got, "data:image/png;base64,") { + t.Fatalf("got %q, want image/png data URI", got) + } +} + +// TestBuildMediaInput_Passthrough: URLs and data URIs are forwarded unchanged. +func TestBuildMediaInput_Passthrough(t *testing.T) { + for _, in := range []string{"https://example.com/a.mp4", "data:video/mp4;base64,AAAA"} { + got, err := BuildMediaInput(in) + if err != nil { + t.Errorf("BuildMediaInput(%q) error: %v", in, err) + continue + } + if got != in { + t.Errorf("BuildMediaInput(%q) = %q, want unchanged", in, got) + } + } +}