Files
easyai-ai-gateway/apps/api/internal/clients/topaz.go
T
easyai 3056cf8fca
ci / verify (pull_request) Successful in 15m34s
feat(gateway): 补齐桌面端高级媒体直连接口
新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。

已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
2026-07-22 14:02:53 +08:00

618 lines
22 KiB
Go

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()
}