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,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