feat: improve simulation media tasks
This commit is contained in:
parent
d86651ff55
commit
ada765d90e
@ -7,10 +7,96 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSimulationClientReturnsImageDemoAssets(t *testing.T) {
|
||||||
|
response, err := (SimulationClient{}).Run(context.Background(), Request{
|
||||||
|
Kind: "images.generations",
|
||||||
|
Model: "gpt-image-1",
|
||||||
|
Body: map[string]any{
|
||||||
|
"prompt": "demo image",
|
||||||
|
"n": 2,
|
||||||
|
"simulationDurationMs": 5,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run simulation image client: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := response.Result["data"].([]any)
|
||||||
|
if len(data) != 2 || response.ResponseDurationMS <= 0 {
|
||||||
|
t.Fatalf("unexpected simulated image response: %+v duration=%d", response.Result, response.ResponseDurationMS)
|
||||||
|
}
|
||||||
|
item, _ := data[0].(map[string]any)
|
||||||
|
if item["url"] != "/static/simulation/image.svg" || item["assetSource"] != "simulation" {
|
||||||
|
t.Fatalf("unexpected simulated image item: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimulationClientReturnsVideoDemoAssets(t *testing.T) {
|
||||||
|
response, err := (SimulationClient{}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Model: "demo-video-model",
|
||||||
|
Body: map[string]any{
|
||||||
|
"prompt": "demo video",
|
||||||
|
"count": 2,
|
||||||
|
"duration": 6,
|
||||||
|
"simulationDurationMs": 5,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run simulation video client: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := response.Result["data"].([]any)
|
||||||
|
if len(data) != 2 || response.ResponseDurationMS <= 0 {
|
||||||
|
t.Fatalf("unexpected simulated video response: %+v duration=%d", response.Result, response.ResponseDurationMS)
|
||||||
|
}
|
||||||
|
item, _ := data[0].(map[string]any)
|
||||||
|
if item["video_url"] != "/static/simulation/video.mp4" || item["url"] != "/static/simulation/video.mp4" || item["poster"] != "/static/simulation/video-poster.svg" {
|
||||||
|
t.Fatalf("unexpected simulated video item: %+v", item)
|
||||||
|
}
|
||||||
|
if item["duration"] != 6 || item["assetSource"] != "simulation" {
|
||||||
|
t.Fatalf("unexpected simulated video metadata: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimulationDurationDefaultsByMediaType(t *testing.T) {
|
||||||
|
imageDuration := simulationDuration(Request{Kind: "images.generations"})
|
||||||
|
if imageDuration < 10*time.Second || imageDuration > 30*time.Second {
|
||||||
|
t.Fatalf("image simulation duration should default to 10-30s, got %s", imageDuration)
|
||||||
|
}
|
||||||
|
videoDuration := simulationDuration(Request{Kind: "videos.generations"})
|
||||||
|
if videoDuration < 2*time.Minute || videoDuration > 3*time.Minute {
|
||||||
|
t.Fatalf("video simulation duration should default to 2-3m, got %s", videoDuration)
|
||||||
|
}
|
||||||
|
textDuration := simulationDuration(Request{Kind: "chat.completions"})
|
||||||
|
if textDuration < 800*time.Millisecond || textDuration > 2400*time.Millisecond {
|
||||||
|
t.Fatalf("text simulation duration should keep short defaults, got %s", textDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimulationDurationCanBeControlledByParams(t *testing.T) {
|
||||||
|
fixedDuration := simulationDuration(Request{Body: map[string]any{"simulationDurationSeconds": 7}})
|
||||||
|
if fixedDuration != 7*time.Second {
|
||||||
|
t.Fatalf("simulationDurationSeconds should set fixed duration, got %s", fixedDuration)
|
||||||
|
}
|
||||||
|
rangeDuration := simulationDuration(Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
Body: map[string]any{
|
||||||
|
"simulationMinDurationSeconds": 1,
|
||||||
|
"simulationMaxDurationSeconds": 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if rangeDuration != time.Second {
|
||||||
|
t.Fatalf("simulation duration range params should override video defaults, got %s", rangeDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIClientChatContract(t *testing.T) {
|
func TestOpenAIClientChatContract(t *testing.T) {
|
||||||
var gotPath string
|
var gotPath string
|
||||||
var gotAuth string
|
var gotAuth string
|
||||||
|
|||||||
@ -3,24 +3,60 @@ package clients
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SimulationClient struct{}
|
type SimulationClient struct{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSimulationTextMinDuration = 800 * time.Millisecond
|
||||||
|
defaultSimulationTextMaxDuration = 2400 * time.Millisecond
|
||||||
|
defaultSimulationImageMinDuration = 10 * time.Second
|
||||||
|
defaultSimulationImageMaxDuration = 30 * time.Second
|
||||||
|
defaultSimulationVideoMinDuration = 2 * time.Minute
|
||||||
|
defaultSimulationVideoMaxDuration = 3 * time.Minute
|
||||||
|
maxSimulationDuration = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
func (c SimulationClient) Run(ctx context.Context, request Request) (Response, error) {
|
func (c SimulationClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||||
_ = ctx
|
|
||||||
profile := simulationProfile(request)
|
profile := simulationProfile(request)
|
||||||
|
responseStartedAt := time.Now()
|
||||||
|
duration := simulationDuration(request)
|
||||||
|
if duration > 0 {
|
||||||
|
timer := time.NewTimer(duration)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return Response{}, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responseFinishedAt := time.Now()
|
||||||
if profile == "retryable_failure" {
|
if profile == "retryable_failure" {
|
||||||
return Response{}, &ClientError{Code: "server_error", Message: "simulated retryable failure", Retryable: true}
|
return Response{}, &ClientError{
|
||||||
|
Code: "server_error",
|
||||||
|
Message: "simulated retryable failure",
|
||||||
|
RequestID: "simulated-request",
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||||
|
Retryable: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if profile == "fatal_failure" || profile == "non_retryable_failure" {
|
if profile == "fatal_failure" || profile == "non_retryable_failure" {
|
||||||
return Response{}, &ClientError{Code: "bad_request", Message: "simulated non-retryable failure", Retryable: false}
|
return Response{}, &ClientError{
|
||||||
|
Code: "bad_request",
|
||||||
|
Message: "simulated non-retryable failure",
|
||||||
|
RequestID: "simulated-request",
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||||
|
Retryable: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
responseStartedAt := time.Now()
|
|
||||||
result := simulatedResult(request)
|
result := simulatedResult(request)
|
||||||
responseFinishedAt := time.Now()
|
|
||||||
return Response{
|
return Response{
|
||||||
Result: result,
|
Result: result,
|
||||||
RequestID: requestIDFromResult(result),
|
RequestID: requestIDFromResult(result),
|
||||||
@ -80,24 +116,73 @@ func simulatedResult(request Request) map[string]any {
|
|||||||
"id": "img-edit-simulated",
|
"id": "img-edit-simulated",
|
||||||
"created": nowUnix(),
|
"created": nowUnix(),
|
||||||
"model": request.Model,
|
"model": request.Model,
|
||||||
"data": []any{map[string]any{
|
"data": simulatedImageData(request, "/static/simulation/image-edit.svg", "simulation image edit"),
|
||||||
"url": "/static/simulation/image-edit.png",
|
|
||||||
"revised_prompt": firstNonEmptyPrompt(request.Body, "simulation image edit"),
|
|
||||||
}},
|
|
||||||
}
|
}
|
||||||
default:
|
case "images.generations":
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": "img-simulated",
|
"id": "img-simulated",
|
||||||
"created": nowUnix(),
|
"created": nowUnix(),
|
||||||
"model": request.Model,
|
"model": request.Model,
|
||||||
"data": []any{map[string]any{
|
"data": simulatedImageData(request, "/static/simulation/image.svg", "simulation image"),
|
||||||
"url": "/static/simulation/image.png",
|
}
|
||||||
"revised_prompt": firstNonEmptyPrompt(request.Body, "simulation image"),
|
case "videos.generations":
|
||||||
}},
|
return map[string]any{
|
||||||
|
"id": "video-simulated",
|
||||||
|
"created": nowUnix(),
|
||||||
|
"model": request.Model,
|
||||||
|
"data": simulatedVideoData(request),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
modelType := strings.ToLower(request.ModelType)
|
||||||
|
kind := strings.ToLower(request.Kind)
|
||||||
|
if strings.Contains(modelType, "video") || strings.Contains(kind, "video") {
|
||||||
|
return map[string]any{
|
||||||
|
"id": "video-simulated",
|
||||||
|
"created": nowUnix(),
|
||||||
|
"model": request.Model,
|
||||||
|
"data": simulatedVideoData(request),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": "img-simulated",
|
||||||
|
"created": nowUnix(),
|
||||||
|
"model": request.Model,
|
||||||
|
"data": simulatedImageData(request, "/static/simulation/image.svg", "simulation image"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func simulatedImageData(request Request, url string, fallbackPrompt string) []any {
|
||||||
|
count := simulatedOutputCount(request.Body)
|
||||||
|
items := make([]any, 0, count)
|
||||||
|
for index := 0; index < count; index += 1 {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"url": url,
|
||||||
|
"assetSource": "simulation",
|
||||||
|
"index": index,
|
||||||
|
"revised_prompt": firstNonEmptyPrompt(request.Body, fallbackPrompt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulatedVideoData(request Request) []any {
|
||||||
|
count := simulatedOutputCount(request.Body)
|
||||||
|
items := make([]any, 0, count)
|
||||||
|
for index := 0; index < count; index += 1 {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"url": "/static/simulation/video.mp4",
|
||||||
|
"video_url": "/static/simulation/video.mp4",
|
||||||
|
"poster": "/static/simulation/video-poster.svg",
|
||||||
|
"duration": simulatedVideoDurationSeconds(request),
|
||||||
|
"assetSource": "simulation",
|
||||||
|
"index": index,
|
||||||
|
"revised_prompt": firstNonEmptyPrompt(request.Body, "simulation video"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func simulatedUsage(request Request) Usage {
|
func simulatedUsage(request Request) Usage {
|
||||||
if request.ModelType == "chat" || request.Kind == "responses" {
|
if request.ModelType == "chat" || request.Kind == "responses" {
|
||||||
return Usage{InputTokens: 12, OutputTokens: 8, TotalTokens: 20}
|
return Usage{InputTokens: 12, OutputTokens: 8, TotalTokens: 20}
|
||||||
@ -117,6 +202,117 @@ func simulatedProgress(request Request) []Progress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func simulationDuration(request Request) time.Duration {
|
||||||
|
if fixedMS := simulationDurationMS(request, "simulationDurationMs", "testDurationMs"); fixedMS >= 0 {
|
||||||
|
return clampSimulationDuration(time.Duration(fixedMS) * time.Millisecond)
|
||||||
|
}
|
||||||
|
if fixedSeconds := simulationDurationSeconds(request, "simulationDurationSeconds", "testDurationSeconds"); fixedSeconds >= 0 {
|
||||||
|
return clampSimulationDuration(time.Duration(fixedSeconds) * time.Second)
|
||||||
|
}
|
||||||
|
minDuration, maxDuration := defaultSimulationDurationRange(request)
|
||||||
|
if minMS := simulationDurationMS(request, "simulationMinDurationMs", "simulationDurationMinMs", "testMinDurationMs", "testDurationMinMs"); minMS >= 0 {
|
||||||
|
minDuration = time.Duration(minMS) * time.Millisecond
|
||||||
|
}
|
||||||
|
if maxMS := simulationDurationMS(request, "simulationMaxDurationMs", "simulationDurationMaxMs", "testMaxDurationMs", "testDurationMaxMs"); maxMS >= 0 {
|
||||||
|
maxDuration = time.Duration(maxMS) * time.Millisecond
|
||||||
|
}
|
||||||
|
if minSeconds := simulationDurationSeconds(request, "simulationMinDurationSeconds", "simulationDurationMinSeconds", "testMinDurationSeconds", "testDurationMinSeconds"); minSeconds >= 0 {
|
||||||
|
minDuration = time.Duration(minSeconds) * time.Second
|
||||||
|
}
|
||||||
|
if maxSeconds := simulationDurationSeconds(request, "simulationMaxDurationSeconds", "simulationDurationMaxSeconds", "testMaxDurationSeconds", "testDurationMaxSeconds"); maxSeconds >= 0 {
|
||||||
|
maxDuration = time.Duration(maxSeconds) * time.Second
|
||||||
|
}
|
||||||
|
minDuration = clampSimulationDuration(minDuration)
|
||||||
|
maxDuration = clampSimulationDuration(maxDuration)
|
||||||
|
if maxDuration < minDuration {
|
||||||
|
maxDuration = minDuration
|
||||||
|
}
|
||||||
|
spread := maxDuration - minDuration
|
||||||
|
if spread <= 0 {
|
||||||
|
return minDuration
|
||||||
|
}
|
||||||
|
return minDuration + time.Duration(rand.Int63n(int64(spread)+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSimulationDurationRange(request Request) (time.Duration, time.Duration) {
|
||||||
|
if simulationVideoRequest(request) {
|
||||||
|
return defaultSimulationVideoMinDuration, defaultSimulationVideoMaxDuration
|
||||||
|
}
|
||||||
|
if simulationImageRequest(request) {
|
||||||
|
return defaultSimulationImageMinDuration, defaultSimulationImageMaxDuration
|
||||||
|
}
|
||||||
|
return defaultSimulationTextMinDuration, defaultSimulationTextMaxDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulationVideoRequest(request Request) bool {
|
||||||
|
kind := strings.ToLower(request.Kind)
|
||||||
|
modelType := strings.ToLower(request.ModelType)
|
||||||
|
return strings.Contains(kind, "video") || strings.Contains(modelType, "video")
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulationImageRequest(request Request) bool {
|
||||||
|
kind := strings.ToLower(request.Kind)
|
||||||
|
modelType := strings.ToLower(request.ModelType)
|
||||||
|
return strings.Contains(kind, "image") || strings.Contains(modelType, "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulationDurationSeconds(request Request, keys ...string) int {
|
||||||
|
for _, source := range []map[string]any{request.Body, request.Candidate.PlatformConfig, request.Candidate.Credentials} {
|
||||||
|
for _, key := range keys {
|
||||||
|
value := intValue(source, key, -1)
|
||||||
|
if value >= 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulationDurationMS(request Request, keys ...string) int {
|
||||||
|
for _, source := range []map[string]any{request.Body, request.Candidate.PlatformConfig, request.Candidate.Credentials} {
|
||||||
|
for _, key := range keys {
|
||||||
|
value := intValue(source, key, -1)
|
||||||
|
if value >= 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampSimulationDuration(duration time.Duration) time.Duration {
|
||||||
|
if duration < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if duration > maxSimulationDuration {
|
||||||
|
return maxSimulationDuration
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulatedOutputCount(body map[string]any) int {
|
||||||
|
count := intValue(body, "n", 0)
|
||||||
|
if count <= 0 {
|
||||||
|
count = intValue(body, "count", 0)
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if count > 20 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulatedVideoDurationSeconds(request Request) int {
|
||||||
|
for _, key := range []string{"duration", "duration_seconds", "durationSeconds"} {
|
||||||
|
if value := intValue(request.Body, key, 0); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
func firstNonEmptyPrompt(body map[string]any, fallback string) string {
|
func firstNonEmptyPrompt(body map[string]any, fallback string) string {
|
||||||
for _, key := range []string{"prompt", "input"} {
|
for _, key := range []string{"prompt", "input"} {
|
||||||
if value := strings.TrimSpace(stringValue(body, key)); value != "" {
|
if value := strings.TrimSpace(stringValue(body, key)); value != "" {
|
||||||
|
|||||||
@ -248,10 +248,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": "gpt-4o-mini",
|
"model": "gpt-4o-mini",
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
"simulationDurationMs": 5,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
||||||
}, http.StatusAccepted, &taskResponse)
|
}, http.StatusAccepted, &taskResponse)
|
||||||
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
||||||
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
||||||
@ -271,10 +272,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
|
|
||||||
var compatChat map[string]any
|
var compatChat map[string]any
|
||||||
doJSON(t, server.URL, http.MethodPost, "/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": "gpt-4o-mini",
|
"model": "gpt-4o-mini",
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
|
"simulationDurationMs": 5,
|
||||||
}, http.StatusOK, &compatChat)
|
}, http.StatusOK, &compatChat)
|
||||||
if compatChat["object"] != "chat.completion" {
|
if compatChat["object"] != "chat.completion" {
|
||||||
t.Fatalf("unexpected compatible chat response: %+v", compatChat)
|
t.Fatalf("unexpected compatible chat response: %+v", compatChat)
|
||||||
@ -288,12 +290,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": "gpt-image-1",
|
"model": "gpt-image-1",
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"prompt": "a tiny gateway console",
|
"prompt": "a tiny gateway console",
|
||||||
"size": "1024x1024",
|
"size": "1024x1024",
|
||||||
"quality": "medium",
|
"quality": "medium",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
|
"simulationDurationMs": 5,
|
||||||
}, http.StatusAccepted, &imageResponse)
|
}, http.StatusAccepted, &imageResponse)
|
||||||
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
||||||
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
||||||
@ -307,12 +310,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": "gpt-image-1",
|
"model": "gpt-image-1",
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"prompt": "replace background with clean studio light",
|
"prompt": "replace background with clean studio light",
|
||||||
"image": "https://example.com/source.png",
|
"image": "https://example.com/source.png",
|
||||||
"mask": "https://example.com/mask.png",
|
"mask": "https://example.com/mask.png",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
|
"simulationDurationMs": 5,
|
||||||
}, http.StatusAccepted, &imageEditResponse)
|
}, http.StatusAccepted, &imageEditResponse)
|
||||||
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
||||||
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
|
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
|
||||||
|
|||||||
@ -32,6 +32,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", server.health)
|
mux.HandleFunc("GET /healthz", server.health)
|
||||||
mux.HandleFunc("GET /readyz", server.ready)
|
mux.HandleFunc("GET /readyz", server.ready)
|
||||||
|
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||||
|
|
||||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||||
|
|||||||
49
apps/api/internal/httpapi/simulation_assets.go
Normal file
49
apps/api/internal/httpapi/simulation_assets.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const simulationImageSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#111827"/><stop offset="0.52" stop-color="#2563eb"/><stop offset="1" stop-color="#14b8a6"/></linearGradient></defs><rect width="1024" height="1024" rx="96" fill="url(#g)"/><circle cx="784" cy="220" r="104" fill="#ffffff" opacity="0.16"/><rect x="168" y="260" width="688" height="504" rx="48" fill="#ffffff" opacity="0.15"/><path d="M232 672 392 504l120 120 96-104 184 152v48H232z" fill="#ffffff" opacity="0.72"/><circle cx="354" cy="398" r="58" fill="#ffffff" opacity="0.82"/><text x="512" y="832" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="64" font-weight="700" fill="#ffffff">EasyAI Demo</text></svg>`
|
||||||
|
|
||||||
|
const simulationImageEditSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#0f172a"/><stop offset="0.48" stop-color="#7c3aed"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs><rect width="1024" height="1024" rx="96" fill="url(#g)"/><rect x="168" y="208" width="456" height="608" rx="44" fill="#ffffff" opacity="0.16"/><rect x="400" y="304" width="456" height="512" rx="44" fill="#ffffff" opacity="0.22"/><path d="M248 678 382 532l92 100 78-84 232 198H248z" fill="#ffffff" opacity="0.76"/><circle cx="342" cy="384" r="54" fill="#ffffff" opacity="0.84"/><text x="512" y="884" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="58" font-weight="700" fill="#ffffff">EasyAI Edit Demo</text></svg>`
|
||||||
|
|
||||||
|
const simulationVideoPosterSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#111827"/><stop offset="0.5" stop-color="#0ea5e9"/><stop offset="1" stop-color="#22c55e"/></linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/><circle cx="1000" cy="170" r="120" fill="#fff" opacity="0.16"/><path d="M548 250v220l190-110z" fill="#fff" opacity="0.9"/><text x="640" y="602" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="64" font-weight="700" fill="#ffffff">EasyAI Video Demo</text></svg>`
|
||||||
|
|
||||||
|
const simulationVideoMP4Base64 = "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"
|
||||||
|
|
||||||
|
var simulationVideoMP4 = mustDecodeSimulationAsset(simulationVideoMP4Base64)
|
||||||
|
|
||||||
|
func serveSimulationAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asset := strings.ToLower(strings.TrimSpace(r.PathValue("asset")))
|
||||||
|
switch asset {
|
||||||
|
case "image.svg", "image.png":
|
||||||
|
serveSimulationContent(w, r, "image.svg", "image/svg+xml; charset=utf-8", []byte(simulationImageSVG))
|
||||||
|
case "image-edit.svg", "image-edit.png":
|
||||||
|
serveSimulationContent(w, r, "image-edit.svg", "image/svg+xml; charset=utf-8", []byte(simulationImageEditSVG))
|
||||||
|
case "video-poster.svg":
|
||||||
|
serveSimulationContent(w, r, "video-poster.svg", "image/svg+xml; charset=utf-8", []byte(simulationVideoPosterSVG))
|
||||||
|
case "video.mp4":
|
||||||
|
serveSimulationContent(w, r, "video.mp4", "video/mp4", simulationVideoMP4)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveSimulationContent(w http.ResponseWriter, r *http.Request, name string, contentType string, payload []byte) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDecodeSimulationAsset(value string) []byte {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user