feat(gateway): 补齐桌面端高级媒体直连接口
ci / verify (pull_request) Successful in 15m34s
ci / verify (pull_request) Successful in 15m34s
新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。 已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestVectorizerClientMultipartAndPrivateResumeState(t *testing.T) {
|
||||
var receivedPath string
|
||||
var receivedFields map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
if username, password, ok := r.BasicAuth(); !ok || username != "id" || password != "secret" {
|
||||
t.Fatalf("unexpected vectorizer auth")
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
receivedFields = map[string]string{}
|
||||
for key, values := range r.MultipartForm.Value {
|
||||
receivedFields[key] = values[0]
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("X-Image-Token", "private-image-token")
|
||||
w.Header().Set("X-Receipt", "private-receipt")
|
||||
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
privateState := map[string]any{}
|
||||
response, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
|
||||
}}).Run(context.Background(), Request{
|
||||
Kind: "images.vectorize", Model: "easy-image-vectorizer-1",
|
||||
Body: map[string]any{"source": map[string]any{"url": "https://example.com/input.png"}, "format": "svg", "maxColors": 16, "cleanupLevel": "strong"},
|
||||
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"accessKey": "id", "secretKey": "secret"}),
|
||||
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error { privateState = payload; return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run vectorizer: %v", err)
|
||||
}
|
||||
if receivedPath != "/vectorize" || receivedFields["image.url"] == "" || receivedFields["output.file_format"] != "svg" || receivedFields["processing.max_colors"] != "16" {
|
||||
t.Fatalf("unexpected multipart request: path=%s fields=%+v", receivedPath, receivedFields)
|
||||
}
|
||||
if privateState["imageToken"] != "private-image-token" || privateState["receipt"] != "private-receipt" {
|
||||
t.Fatalf("private resume state missing: %+v", privateState)
|
||||
}
|
||||
serialized := strings.ToLower(toJSONForTest(response.Result))
|
||||
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") {
|
||||
t.Fatalf("private vectorizer state leaked into response: %s", serialized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorizerClientRejectsPrivateSourceBeforeProviderCall(t *testing.T) {
|
||||
providerCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { providerCalls++ }))
|
||||
defer server.Close()
|
||||
_, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
|
||||
}}).Run(context.Background(), Request{
|
||||
Body: map[string]any{"source": map[string]any{"url": "https://assets.example.test/input.png"}},
|
||||
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "blocked network") {
|
||||
t.Fatalf("expected blocked source error, got %v", err)
|
||||
}
|
||||
if providerCalls != 0 {
|
||||
t.Fatalf("provider received %d calls for blocked source", providerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorizerClientReusesImageTokenForExtraFormat(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/download" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.FormValue("image.token") != "token" || r.FormValue("receipt") != "receipt" || r.FormValue("output.file_format") != "pdf" {
|
||||
t.Fatalf("unexpected reuse form: %+v", r.MultipartForm.Value)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
_, _ = w.Write([]byte("%PDF-1.7\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
response, err := (VectorizerClient{}).Run(context.Background(), Request{
|
||||
Body: map[string]any{"_vectorizer_image_token": "token", "_vectorizer_receipt": "receipt", "format": "pdf"},
|
||||
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := response.Result["data"].([]any)[0].(map[string]any)
|
||||
if item["type"] != "file" || item["mime_type"] != "application/pdf" {
|
||||
t.Fatalf("unexpected pdf result: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopazClientUploadsPartsPollsAndValidatesOutput(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
called := map[string]int{}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
called[r.Method+" "+r.URL.Path]++
|
||||
mu.Unlock()
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/source.mp4":
|
||||
_, _ = w.Write([]byte("small-video-source"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/video/":
|
||||
if r.Header.Get("X-API-Key") != "topaz-key" {
|
||||
t.Fatalf("missing Topaz API key")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"requestId":"job-1"}`)
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/accept":
|
||||
_, _ = io.WriteString(w, `{"urls":["`+server.URL+`/upload/1","`+server.URL+`/upload/2"]}`)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
|
||||
w.Header().Set("ETag", `"etag-`+strings.TrimPrefix(r.URL.Path, "/upload/")+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/complete-upload/":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/video/job-1/status":
|
||||
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/output.mp4"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
phases := []string{}
|
||||
client := TopazClient{Probe: func(_ context.Context, target string) (TopazVideoMetadata, error) {
|
||||
if strings.HasPrefix(target, "http") {
|
||||
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24, HasAudio: true}, nil
|
||||
}
|
||||
return TopazVideoMetadata{Width: 320, Height: 180, Duration: 3, FrameRate: 24, FrameCount: 72, HasAudio: true}, nil
|
||||
}}
|
||||
response, err := client.Run(context.Background(), Request{
|
||||
Model: "easy-proteus-standard-4",
|
||||
Body: map[string]any{"video_url": server.URL + "/source.mp4", "output_width": 640, "output_height": 360, "preserve_audio": true},
|
||||
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"allowPrivateSourceDownloads": true, "pollIntervalMs": 1, "pollTimeoutMs": 100}),
|
||||
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error {
|
||||
phases = append(phases, payload["phase"].(string))
|
||||
return nil
|
||||
},
|
||||
OnRemoteTaskPolled: func(_ string, payload map[string]any) error {
|
||||
phases = append(phases, payload["phase"].(string))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run Topaz: %v", err)
|
||||
}
|
||||
if response.RequestID != "job-1" || !containsStringForTest(phases, "created") || !containsStringForTest(phases, "uploaded") {
|
||||
t.Fatalf("unexpected Topaz response/phases: response=%+v phases=%+v", response, phases)
|
||||
}
|
||||
if called["PUT /upload/1"] != 1 || called["PUT /upload/2"] != 1 || called["PATCH /video/job-1/complete-upload/"] != 1 {
|
||||
t.Fatalf("upload lifecycle incomplete: %+v", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopazClientResumesUploadedTaskWithoutDownloadingSource(t *testing.T) {
|
||||
var sourceCalls int
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/source.mp4" {
|
||||
sourceCalls++
|
||||
}
|
||||
if r.URL.Path == "/video/job-resume/status" {
|
||||
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/result.mp4"}}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
|
||||
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24}, nil
|
||||
}}).Run(context.Background(), Request{
|
||||
RemoteTaskID: "job-resume", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
|
||||
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
|
||||
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 100}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourceCalls != 0 {
|
||||
t.Fatalf("resume downloaded source %d times", sourceCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopazClientReportsProviderFailureAndPollingTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusBody string
|
||||
timeoutMS int
|
||||
want string
|
||||
}{
|
||||
{name: "provider failure", statusBody: `{"status":"failed","message":"upstream rejected"}`, timeoutMS: 100, want: "upstream rejected"},
|
||||
{name: "poll timeout", statusBody: `{"status":"processing"}`, timeoutMS: 3, want: "polling timed out"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/video/job/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, test.statusBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := (TopazClient{}).Run(context.Background(), Request{
|
||||
RemoteTaskID: "job", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
|
||||
Body: map[string]any{"video_url": "https://assets.example.test/source.mp4"},
|
||||
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
|
||||
"pollIntervalMs": 1, "pollTimeoutMs": test.timeoutMS,
|
||||
}),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("expected %q error, got %v", test.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopazClientRejectsOversizedInputBeforeProbe(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/source.mp4" {
|
||||
_, _ = io.WriteString(w, "too-large")
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
probeCalls := 0
|
||||
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
|
||||
probeCalls++
|
||||
return TopazVideoMetadata{}, nil
|
||||
}}).Run(context.Background(), Request{
|
||||
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
|
||||
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
|
||||
"allowPrivateSourceDownloads": true, "maxInputBytes": 3,
|
||||
}),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "size limit") {
|
||||
t.Fatalf("expected input size limit error, got %v", err)
|
||||
}
|
||||
if probeCalls != 0 {
|
||||
t.Fatalf("oversized input was probed %d times", probeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func storeCandidate(baseURL, specType string, credentials map[string]any) store.RuntimeModelCandidate {
|
||||
return storeCandidateWithConfig(baseURL, specType, credentials, nil)
|
||||
}
|
||||
|
||||
func storeCandidateWithConfig(baseURL, specType string, credentials, config map[string]any) store.RuntimeModelCandidate {
|
||||
return store.RuntimeModelCandidate{BaseURL: baseURL, SpecType: specType, Provider: specType, Credentials: credentials, PlatformConfig: config}
|
||||
}
|
||||
|
||||
func toJSONForTest(value any) string {
|
||||
raw, _ := json.Marshal(value)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func containsStringForTest(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
topazDefaultPollInterval = 15 * time.Second
|
||||
topazDefaultPollTimeout = 60 * time.Minute
|
||||
topazDefaultMaxInput = int64(2 << 30)
|
||||
)
|
||||
|
||||
type TopazClient struct {
|
||||
HTTPClient *http.Client
|
||||
Probe func(context.Context, string) (TopazVideoMetadata, error)
|
||||
}
|
||||
|
||||
type TopazVideoMetadata struct {
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
FrameRate float64
|
||||
FrameCount int
|
||||
HasAudio bool
|
||||
}
|
||||
|
||||
type topazSource struct {
|
||||
Path string
|
||||
Container string
|
||||
Size int64
|
||||
MD5 string
|
||||
Resolution map[string]any
|
||||
Duration float64
|
||||
FrameRate float64
|
||||
FrameCount int
|
||||
}
|
||||
|
||||
func (c TopazClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||
startedAt := time.Now()
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return Response{}, &ClientError{Code: "missing_credentials", Message: "Topaz API key is required", Retryable: false}
|
||||
}
|
||||
requestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
payload := cloneMapAny(request.RemoteTaskPayload)
|
||||
var source *topazSource
|
||||
var target map[string]int
|
||||
if requestID == "" || strings.TrimSpace(firstNonEmptyString(payload["phase"])) != "uploaded" {
|
||||
videoURL := strings.TrimSpace(firstNonEmptyString(request.Body["video_url"], request.Body["videoUrl"]))
|
||||
if videoURL == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
prepared, err := c.prepareSource(ctx, request, videoURL)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
source = &prepared
|
||||
defer os.Remove(prepared.Path)
|
||||
target = topazTargetResolution(request.Body, prepared.Resolution)
|
||||
if requestID == "" {
|
||||
created, err := c.createRequest(ctx, request, apiKey, prepared, target)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, "", startedAt, time.Now())
|
||||
}
|
||||
requestID = strings.TrimSpace(firstNonEmptyString(created["requestId"], created["request_id"], created["id"]))
|
||||
if requestID == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz create response is missing requestId", Retryable: false}
|
||||
}
|
||||
payload = map[string]any{"phase": "created", "targetResolution": target}
|
||||
if request.OnRemoteTaskSubmitted != nil {
|
||||
if err := request.OnRemoteTaskSubmitted(requestID, payload); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := c.uploadSource(ctx, request, apiKey, requestID, prepared); err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
|
||||
}
|
||||
payload = map[string]any{"phase": "uploaded", "targetResolution": target}
|
||||
if request.OnRemoteTaskPolled != nil {
|
||||
if err := request.OnRemoteTaskPolled(requestID, payload); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
target = topazTargetFromPayload(payload, request.Body)
|
||||
}
|
||||
completed, err := c.poll(ctx, request, apiKey, requestID, target)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
|
||||
}
|
||||
outputURL := topazDownloadURL(completed)
|
||||
if outputURL == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz completed without a download URL", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
metadata, err := c.probe(ctx, outputURL)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "cannot validate Topaz output metadata: " + err.Error(), RequestID: requestID, Retryable: true}
|
||||
}
|
||||
if target["width"] > 0 && target["height"] > 0 && (metadata.Width+4 < target["width"] || metadata.Height+4 < target["height"]) {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: fmt.Sprintf("Topaz output resolution %dx%d does not reach target %dx%d", metadata.Width, metadata.Height, target["width"], target["height"]), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
finishedAt := time.Now()
|
||||
resultItem := map[string]any{
|
||||
"type": "video",
|
||||
"url": outputURL,
|
||||
"video_url": outputURL,
|
||||
"width": metadata.Width,
|
||||
"height": metadata.Height,
|
||||
"target_resolution": fmt.Sprintf("%dx%d", target["width"], target["height"]),
|
||||
"duration": metadata.Duration,
|
||||
"target_frame_rate": metadata.FrameRate,
|
||||
"slow_motion_rate": numericValue(request.Body["slow_motion_rate"], 1),
|
||||
}
|
||||
if source != nil {
|
||||
resultItem["source_resolution"] = fmt.Sprintf("%vx%v", source.Resolution["width"], source.Resolution["height"])
|
||||
resultItem["source_frame_rate"] = source.FrameRate
|
||||
resultItem["duration"] = source.Duration
|
||||
}
|
||||
return Response{
|
||||
Result: map[string]any{
|
||||
"status": "success",
|
||||
"model": request.Model,
|
||||
"task_id": requestID,
|
||||
"upstream_task_id": requestID,
|
||||
"data": []any{resultItem},
|
||||
},
|
||||
RequestID: requestID,
|
||||
Progress: append(providerProgress(request), Progress{Phase: "polling", Progress: 0.9, Message: "Topaz upscale completed", Payload: map[string]any{"upstreamTaskId": requestID}}),
|
||||
ResponseStartedAt: startedAt,
|
||||
ResponseFinishedAt: finishedAt,
|
||||
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c TopazClient) prepareSource(ctx context.Context, request Request, rawURL string) (topazSource, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url must be an http(s) URL", Param: "video_url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return topazSource{}, err
|
||||
}
|
||||
downloadClient := topazSourceHTTPClient(httpClient(request.HTTPClient, c.HTTPClient), boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false))
|
||||
resp, err := downloadClient.Do(req)
|
||||
if err != nil {
|
||||
return topazSource{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url download failed: " + resp.Status, Param: "video_url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
limit := int64(numericValue(firstPresent(request.Candidate.PlatformConfig["maxInputBytes"], request.Candidate.PlatformConfig["max_input_bytes"]), float64(topazDefaultMaxInput)))
|
||||
if limit <= 0 {
|
||||
limit = topazDefaultMaxInput
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "easyai-topaz-*"+topazContainerExtension(parsed.Path))
|
||||
if err != nil {
|
||||
return topazSource{}, err
|
||||
}
|
||||
path := tmp.Name()
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
hash := md5.New()
|
||||
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(resp.Body, limit+1))
|
||||
closeErr := tmp.Close()
|
||||
if err != nil || closeErr != nil || written <= 0 || written > limit {
|
||||
cleanup()
|
||||
if written > limit {
|
||||
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video source exceeds configured size limit", Param: "video_url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
return topazSource{}, firstError(err, closeErr)
|
||||
}
|
||||
metadata, err := c.probe(ctx, path)
|
||||
if err != nil || metadata.Width <= 0 || metadata.Height <= 0 {
|
||||
cleanup()
|
||||
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "cannot probe source video metadata", Param: "video_url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
frameRate := metadata.FrameRate
|
||||
if frameRate <= 0 {
|
||||
frameRate = numericValue(request.Candidate.PlatformConfig["frameRate"], 30)
|
||||
}
|
||||
duration := math.Max(1, metadata.Duration)
|
||||
frameCount := metadata.FrameCount
|
||||
if frameCount <= 0 {
|
||||
frameCount = int(math.Round(duration * frameRate))
|
||||
}
|
||||
return topazSource{
|
||||
Path: path, Container: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), Size: written,
|
||||
MD5: hex.EncodeToString(hash.Sum(nil)), Resolution: map[string]any{"width": metadata.Width, "height": metadata.Height},
|
||||
Duration: duration, FrameRate: frameRate, FrameCount: frameCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c TopazClient) createRequest(ctx context.Context, request Request, apiKey string, source topazSource, target map[string]int) (map[string]any, error) {
|
||||
model := topazModelName(firstNonEmptyString(request.Candidate.ProviderModelName, request.Model))
|
||||
scale := math.Max(float64(target["width"])/numericValue(source.Resolution["width"], 1), float64(target["height"])/numericValue(source.Resolution["height"], 1))
|
||||
filter := map[string]any{"model": model}
|
||||
if scale > 1 {
|
||||
key := "scale"
|
||||
if model == "slf-2" || model == "slhq-1" || model == "slm-1" || model == "slp-2.5" {
|
||||
key = "upscaling_factor"
|
||||
}
|
||||
filter[key] = math.Round(scale*1000) / 1000
|
||||
}
|
||||
preserveAudio := boolishDefault(request.Body["preserve_audio"], true)
|
||||
filters := []any{filter}
|
||||
targetFrameRate := numericValue(firstPresent(request.Body["target_frame_rate"], request.Body["output_frame_rate"]), source.FrameRate)
|
||||
slowMotionRate := numericValue(request.Body["slow_motion_rate"], 1)
|
||||
if targetFrameRate <= 0 {
|
||||
targetFrameRate = source.FrameRate
|
||||
}
|
||||
if slowMotionRate <= 0 {
|
||||
slowMotionRate = 1
|
||||
}
|
||||
if targetFrameRate != source.FrameRate || slowMotionRate > 1 {
|
||||
interpolationModel := strings.TrimSpace(firstNonEmptyString(request.Body["frame_interpolation_model"]))
|
||||
if interpolationModel == "" {
|
||||
return nil, &ClientError{Code: "invalid_parameter", Message: "frame_interpolation_model is required when output frame rate or slow motion changes", Param: "frame_interpolation_model", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
interpolation := map[string]any{"model": topazModelName(interpolationModel)}
|
||||
if targetFrameRate > 0 {
|
||||
interpolation["fps"] = targetFrameRate
|
||||
}
|
||||
if slowMotionRate > 1 {
|
||||
interpolation["slowmo"] = slowMotionRate
|
||||
}
|
||||
filters = append(filters, interpolation)
|
||||
}
|
||||
body := map[string]any{
|
||||
"source": map[string]any{"container": source.Container, "size": source.Size, "duration": source.Duration, "frameCount": source.FrameCount, "frameRate": source.FrameRate, "resolution": source.Resolution},
|
||||
"filters": filters,
|
||||
"output": map[string]any{"resolution": target, "frameRate": targetFrameRate, "audioCodec": "AAC", "audioTransfer": map[bool]string{true: "Copy", false: "None"}[preserveAudio], "dynamicCompressionLevel": "High", "videoEncoder": "H265", "videoProfile": "Main", "container": "mp4"},
|
||||
}
|
||||
return c.topazJSON(ctx, request, apiKey, http.MethodPost, "/video/", body)
|
||||
}
|
||||
|
||||
func (c TopazClient) uploadSource(ctx context.Context, request Request, apiKey, requestID string, source topazSource) error {
|
||||
accepted, err := c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/accept", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
urls := stringList(accepted["urls"])
|
||||
if len(urls) == 0 {
|
||||
return &ClientError{Code: "invalid_response", Message: "Topaz accept response has no upload URLs", RequestID: requestID}
|
||||
}
|
||||
file, err := os.Open(source.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
chunkSize := (source.Size + int64(len(urls)) - 1) / int64(len(urls))
|
||||
results := make([]any, 0, len(urls))
|
||||
for index, uploadURL := range urls {
|
||||
remaining := source.Size - int64(index)*chunkSize
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
length := minInt64(chunkSize, remaining)
|
||||
section := io.NewSectionReader(file, int64(index)*chunkSize, length)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = length
|
||||
req.Header.Set("Content-Type", topazContainerContentType(source.Container))
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &ClientError{Code: "provider_failed", Message: "Topaz part upload failed: " + resp.Status, StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500}
|
||||
}
|
||||
etag := strings.Trim(resp.Header.Get("ETag"), `"`)
|
||||
if etag == "" {
|
||||
return &ClientError{Code: "invalid_response", Message: "Topaz part upload is missing ETag", Retryable: false}
|
||||
}
|
||||
results = append(results, map[string]any{"partNum": index + 1, "eTag": etag})
|
||||
}
|
||||
_, err = c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/complete-upload/", map[string]any{"md5Hash": source.MD5, "uploadResults": results})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c TopazClient) poll(ctx context.Context, request Request, apiKey, requestID string, target map[string]int) (map[string]any, error) {
|
||||
interval := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollInterval, "pollIntervalMs", "poll_interval_ms")
|
||||
timeout := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollTimeout, "pollTimeoutMs", "poll_timeout_ms", "timeoutMs")
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
status, err := c.topazJSON(ctx, request, apiKey, http.MethodGet, "/video/"+url.PathEscape(requestID)+"/status", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(firstNonEmptyString(status["status"], status["state"])))
|
||||
if request.OnRemoteTaskPolled != nil {
|
||||
if err := request.OnRemoteTaskPolled(requestID, map[string]any{"phase": "uploaded", "status": state, "targetResolution": target}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
switch state {
|
||||
case "complete", "completed", "success", "succeeded":
|
||||
return status, nil
|
||||
case "failed", "failure", "cancelled", "canceled", "error":
|
||||
return nil, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(status["message"], "Topaz task failed"), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||
case <-deadline.C:
|
||||
return nil, &ClientError{Code: "timeout", Message: "Topaz task polling timed out", RequestID: requestID, Retryable: true}
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c TopazClient) topazJSON(ctx context.Context, request Request, apiKey, method, path string, body map[string]any) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, providerURL(request.Candidate.BaseURL, path), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
result, decodeErr := decodeHTTPResponse(resp)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c TopazClient) probe(ctx context.Context, target string) (TopazVideoMetadata, error) {
|
||||
if c.Probe != nil {
|
||||
return c.Probe(ctx, target)
|
||||
}
|
||||
return probeTopazVideo(ctx, target)
|
||||
}
|
||||
|
||||
func probeTopazVideo(ctx context.Context, target string) (TopazVideoMetadata, error) {
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
return TopazVideoMetadata{}, err
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,avg_frame_rate,nb_frames", "-of", "json", target).Output()
|
||||
if err != nil {
|
||||
return TopazVideoMetadata{}, err
|
||||
}
|
||||
var decoded struct {
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AverageRate string `json:"avg_frame_rate"`
|
||||
FrameCount string `json:"nb_frames"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &decoded); err != nil {
|
||||
return TopazVideoMetadata{}, err
|
||||
}
|
||||
metadata := TopazVideoMetadata{}
|
||||
metadata.Duration, _ = strconv.ParseFloat(decoded.Format.Duration, 64)
|
||||
for _, stream := range decoded.Streams {
|
||||
if stream.CodecType == "audio" {
|
||||
metadata.HasAudio = true
|
||||
}
|
||||
if stream.CodecType != "video" || stream.Width <= 0 || stream.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
metadata.Width, metadata.Height = stream.Width, stream.Height
|
||||
metadata.FrameRate = parseTopazRate(stream.AverageRate)
|
||||
metadata.FrameCount, _ = strconv.Atoi(stream.FrameCount)
|
||||
}
|
||||
if metadata.Width <= 0 || metadata.Height <= 0 {
|
||||
return TopazVideoMetadata{}, errors.New("video stream metadata is missing")
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func topazTargetResolution(body map[string]any, source map[string]any) map[string]int {
|
||||
width := int(numericValue(body["output_width"], 0))
|
||||
height := int(numericValue(body["output_height"], 0))
|
||||
if width > 0 && height > 0 {
|
||||
return map[string]int{"width": width, "height": height}
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["target_resolution"], body["output_resolution"], "1080p")))
|
||||
if parsedWidth, parsedHeight, ok := parseTopazSize(value); ok {
|
||||
return map[string]int{"width": parsedWidth, "height": parsedHeight}
|
||||
}
|
||||
base := map[string][2]int{"480p": {854, 480}, "720p": {1280, 720}, "1080p": {1920, 1080}, "1440p": {2560, 1440}, "2k": {2560, 1440}, "2160p": {3840, 2160}, "4k": {3840, 2160}}
|
||||
value = strings.TrimSuffix(value, "_upscale")
|
||||
target, ok := base[value]
|
||||
if !ok {
|
||||
target = base["1080p"]
|
||||
}
|
||||
sourceWidth := numericValue(source["width"], 1)
|
||||
sourceHeight := numericValue(source["height"], 1)
|
||||
if sourceWidth == sourceHeight {
|
||||
side := minInt(target[0], target[1])
|
||||
return map[string]int{"width": side, "height": side}
|
||||
}
|
||||
if sourceWidth > sourceHeight {
|
||||
return map[string]int{"width": int(math.Round(sourceWidth / sourceHeight * float64(target[1]))), "height": target[1]}
|
||||
}
|
||||
return map[string]int{"width": target[1], "height": int(math.Round(sourceHeight / sourceWidth * float64(target[1])))}
|
||||
}
|
||||
|
||||
func topazTargetFromPayload(payload map[string]any, body map[string]any) map[string]int {
|
||||
if raw, ok := payload["targetResolution"].(map[string]any); ok {
|
||||
return map[string]int{"width": int(numericValue(raw["width"], 0)), "height": int(numericValue(raw["height"], 0))}
|
||||
}
|
||||
return topazTargetResolution(body, map[string]any{"width": 16, "height": 9})
|
||||
}
|
||||
|
||||
func topazDownloadURL(result map[string]any) string {
|
||||
if download, ok := result["download"].(map[string]any); ok {
|
||||
return firstNonEmptyString(download["url"], download["downloadUrl"])
|
||||
}
|
||||
return firstNonEmptyString(result["download_url"], result["downloadUrl"], result["url"])
|
||||
}
|
||||
|
||||
func topazModelName(model string) string {
|
||||
switch strings.TrimSpace(model) {
|
||||
case "easy-proteus-standard-4":
|
||||
return "prob-4"
|
||||
case "easy-starlight-fast-2":
|
||||
return "slf-2"
|
||||
case "easy-starlight-hq-1":
|
||||
return "slhq-1"
|
||||
case "easy-starlight-mini-1":
|
||||
return "slm-1"
|
||||
default:
|
||||
return strings.TrimSpace(model)
|
||||
}
|
||||
}
|
||||
|
||||
func topazContainerExtension(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(strings.Split(path, "?")[0]))
|
||||
switch ext {
|
||||
case ".mov", ".mkv", ".webm", ".mp4":
|
||||
return ext
|
||||
default:
|
||||
return ".mp4"
|
||||
}
|
||||
}
|
||||
|
||||
func topazContainerContentType(container string) string {
|
||||
switch strings.ToLower(container) {
|
||||
case "mov":
|
||||
return "video/quicktime"
|
||||
case "mkv":
|
||||
return "video/x-matroska"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
default:
|
||||
return "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
func parseTopazRate(value string) float64 {
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) == 2 {
|
||||
numerator, _ := strconv.ParseFloat(parts[0], 64)
|
||||
denominator, _ := strconv.ParseFloat(parts[1], 64)
|
||||
if denominator > 0 {
|
||||
return numerator / denominator
|
||||
}
|
||||
}
|
||||
parsed, _ := strconv.ParseFloat(value, 64)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseTopazSize(value string) (int, int, bool) {
|
||||
parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
width, errWidth := strconv.Atoi(parts[0])
|
||||
height, errHeight := strconv.Atoi(parts[1])
|
||||
return width, height, errWidth == nil && errHeight == nil && width > 0 && height > 0
|
||||
}
|
||||
|
||||
func stringList(value any) []string {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
if values, ok := value.([]string); ok {
|
||||
return values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolishDefault(value any, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstError(values ...error) error {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minInt64(left, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func topazSourceHTTPClient(base *http.Client, allowPrivate bool) *http.Client {
|
||||
client := *base
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if client.Timeout <= 0 {
|
||||
client.Timeout = 10 * time.Minute
|
||||
}
|
||||
if allowPrivate {
|
||||
return &client
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errors.New("video source DNS resolution failed")
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if topazBlockedAddress(address.IP) {
|
||||
return nil, errors.New("video source resolved to a blocked network")
|
||||
}
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
var attempts []error
|
||||
for _, resolved := range addresses {
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
attempts = append(attempts, dialErr)
|
||||
}
|
||||
return nil, errors.Join(attempts...)
|
||||
}
|
||||
client.Transport = transport
|
||||
return &client
|
||||
}
|
||||
|
||||
func topazBlockedAddress(ip net.IP) bool {
|
||||
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const vectorizerMaxResponseBytes = 128 << 20
|
||||
|
||||
// VectorizerClient implements the Vectorizer.AI binary vectorization API.
|
||||
// Gateway async semantics are supplied by the outer River-backed task runner.
|
||||
type VectorizerClient struct {
|
||||
HTTPClient *http.Client
|
||||
LookupIP func(context.Context, string) ([]net.IPAddr, error)
|
||||
}
|
||||
|
||||
func (c VectorizerClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||
startedAt := time.Now()
|
||||
format := strings.ToLower(strings.TrimSpace(firstNonEmptyString(request.Body["format"], request.Body["output_format"])))
|
||||
if format == "" {
|
||||
format = "svg"
|
||||
}
|
||||
if !vectorizerFormatAllowed(format) {
|
||||
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer format must be svg, eps, pdf, dxf, or png", Param: "format", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
imageToken := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_image_token"]))
|
||||
receipt := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_receipt"]))
|
||||
endpoint := "vectorize"
|
||||
fields := map[string]string{"output.file_format": format}
|
||||
if imageToken != "" {
|
||||
endpoint = "download"
|
||||
fields["image.token"] = imageToken
|
||||
if receipt != "" {
|
||||
fields["receipt"] = receipt
|
||||
}
|
||||
} else {
|
||||
imageURL := vectorizerImageURL(request.Body)
|
||||
if imageURL == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer source image URL is required", Param: "source.url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
if err := c.validateSourceURL(ctx, imageURL, boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false)); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
fields["image.url"] = imageURL
|
||||
fields["mode"] = firstNonEmptyString(request.Candidate.PlatformConfig["mode"], "production")
|
||||
fields["policy.retention_days"] = firstNonEmptyString(request.Candidate.PlatformConfig["retentionDays"], request.Candidate.PlatformConfig["retention_days"], "7")
|
||||
fields["processing.shapes.min_area_px"] = vectorizerCleanupMinArea(request.Body)
|
||||
if maxColors := vectorizerMaxColors(request.Body); maxColors > 0 {
|
||||
fields["processing.max_colors"] = fmt.Sprint(maxColors)
|
||||
}
|
||||
}
|
||||
appendVectorizerOutputFields(fields, format)
|
||||
|
||||
payload, contentType, err := vectorizerMultipartBody(fields)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
url := providerURL(request.Candidate.BaseURL, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, payload)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
applyVectorizerAuth(req, request.Candidate.Credentials)
|
||||
client := httpClient(request.HTTPClient, c.HTTPClient)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
result, decodeErr := decodeHTTPResponse(resp)
|
||||
if decodeErr != nil {
|
||||
return Response{}, annotateResponseError(decodeErr, requestID, startedAt, time.Now())
|
||||
}
|
||||
return Response{}, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(result["message"], result["error"], resp.Status), RequestID: requestID, StatusCode: resp.StatusCode, Retryable: resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, vectorizerMaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: err.Error(), RequestID: requestID, Retryable: true}
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > vectorizerMaxResponseBytes {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "vectorizer response is empty or too large", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
returnedToken := strings.TrimSpace(resp.Header.Get("X-Image-Token"))
|
||||
returnedReceipt := strings.TrimSpace(resp.Header.Get("X-Receipt"))
|
||||
if request.OnRemoteTaskSubmitted != nil && returnedToken != "" {
|
||||
digest := sha256.Sum256([]byte(returnedToken))
|
||||
if err := request.OnRemoteTaskSubmitted("vectorizer-"+hex.EncodeToString(digest[:8]), map[string]any{
|
||||
"imageToken": returnedToken,
|
||||
"receipt": returnedReceipt,
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
finishedAt := time.Now()
|
||||
mimeType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||||
if mimeType == "" || mimeType == "application/octet-stream" {
|
||||
mimeType = vectorizerContentType(format)
|
||||
}
|
||||
return Response{
|
||||
Result: map[string]any{
|
||||
"status": "success",
|
||||
"model": request.Model,
|
||||
"data": []any{map[string]any{
|
||||
"type": vectorizerOutputKind(format),
|
||||
"b64_json": base64.StdEncoding.EncodeToString(raw),
|
||||
"mime_type": mimeType,
|
||||
"format": format,
|
||||
}},
|
||||
"vectorizer": map[string]any{
|
||||
"format": format,
|
||||
"creditsCharged": numericHeader(resp.Header.Get("X-Credits-Charged")),
|
||||
"creditsCalculated": numericHeader(resp.Header.Get("X-Credits-Calculated")),
|
||||
},
|
||||
},
|
||||
RequestID: requestID,
|
||||
Progress: append(providerProgress(request), Progress{Phase: "uploading", Progress: 0.9, Message: "vectorizer result received"}),
|
||||
ResponseStartedAt: startedAt,
|
||||
ResponseFinishedAt: finishedAt,
|
||||
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c VectorizerClient) validateSourceURL(ctx context.Context, rawURL string, allowPrivate bool) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.TrimSpace(parsed.Hostname()) == "" || parsed.User != nil {
|
||||
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source URL must be a public http(s) URL without userinfo", Param: "source.url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
if allowPrivate {
|
||||
return nil
|
||||
}
|
||||
lookup := c.LookupIP
|
||||
if lookup == nil {
|
||||
lookup = net.DefaultResolver.LookupIPAddr
|
||||
}
|
||||
addresses, err := lookup(ctx, parsed.Hostname())
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source DNS resolution failed", Param: "source.url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if topazBlockedAddress(address.IP) {
|
||||
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source resolved to a blocked network", Param: "source.url", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vectorizerImageURL(body map[string]any) string {
|
||||
if source, ok := body["source"].(map[string]any); ok {
|
||||
return firstNonEmptyString(source["url"], source["image_url"], source["imageUrl"])
|
||||
}
|
||||
return firstNonEmptyString(body["image_url"], body["imageUrl"])
|
||||
}
|
||||
|
||||
func vectorizerMultipartBody(fields map[string]string) (*bytes.Buffer, string, error) {
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
for key, value := range fields {
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, strings.ReplaceAll(key, `"`, `\"`)))
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := io.WriteString(part, value); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &payload, writer.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
func applyVectorizerAuth(req *http.Request, credentials map[string]any) {
|
||||
username := credential(credentials, "username", "accessKey", "access_key", "apiId", "api_id", "id")
|
||||
password := credential(credentials, "password", "secretKey", "secret_key", "apiSecret", "api_secret", "secret")
|
||||
if username != "" || password != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
return
|
||||
}
|
||||
if apiKey := credential(credentials, "apiKey", "api_key", "token"); apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
func vectorizerFormatAllowed(format string) bool {
|
||||
switch format {
|
||||
case "svg", "eps", "pdf", "dxf", "png":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func vectorizerCleanupMinArea(body map[string]any) string {
|
||||
cleanup := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["cleanupLevel"], body["cleanup_level"])))
|
||||
switch cleanup {
|
||||
case "low":
|
||||
return "0"
|
||||
case "strong":
|
||||
return "1"
|
||||
default:
|
||||
return "0.125"
|
||||
}
|
||||
}
|
||||
|
||||
func vectorizerMaxColors(body map[string]any) int {
|
||||
for _, key := range []string{"maxColors", "max_colors"} {
|
||||
if value := int(numericValue(body[key], 0)); value == 0 || value == 2 || value == 4 || value == 8 || value == 16 || value == 32 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func appendVectorizerOutputFields(fields map[string]string, format string) {
|
||||
if format == "svg" {
|
||||
fields["output.svg.version"] = "svg_1_1"
|
||||
fields["output.svg.fixed_size"] = "false"
|
||||
fields["output.svg.adobe_compatibility_mode"] = "true"
|
||||
}
|
||||
if format == "dxf" {
|
||||
fields["output.dxf.compatibility_level"] = "lines_and_arcs"
|
||||
}
|
||||
}
|
||||
|
||||
func vectorizerContentType(format string) string {
|
||||
switch format {
|
||||
case "svg":
|
||||
return "image/svg+xml"
|
||||
case "eps":
|
||||
return "application/postscript"
|
||||
case "pdf":
|
||||
return "application/pdf"
|
||||
case "dxf":
|
||||
return "application/dxf"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func vectorizerOutputKind(format string) string {
|
||||
if format == "svg" || format == "png" {
|
||||
return "image"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
func numericHeader(value string) any {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return numericValue(value, 0)
|
||||
}
|
||||
Reference in New Issue
Block a user