feat(acceptance): 增加生产同构媒体压力验收模式
引入动态流量门禁、隔离验收身份与协议级 Gemini/Volces 模拟器,覆盖双站点 API、Worker、PostgreSQL、River、账务、回调和媒体物化链路。 新增 P24/P28/P32 容量阶梯、Worker 强杀恢复、真实小流量 canary、CAS 放量和失败保持 validation 的生产编排;Worker 执行槽、连接池、媒体并发和双站点副本数改为环境配置。 验证:Go 全量测试、真实 PostgreSQL 迁移集成测试、迁移安全检查、OpenAPI 生成、ShellCheck、Kustomize、gofmt 和 git diff --check。
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
package acceptanceemulator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/acceptanceworkload"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxProtocolBodyBytes = 128 << 20
|
||||
|
||||
type Config struct {
|
||||
Now func() time.Time
|
||||
HTTPClient *http.Client
|
||||
Wait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
now func() time.Time
|
||||
httpClient *http.Client
|
||||
wait func(context.Context, time.Duration) error
|
||||
mu sync.RWMutex
|
||||
tasks map[string]videoTask
|
||||
fixtures map[string]fixture
|
||||
nextID atomic.Uint64
|
||||
report Report
|
||||
callbacks map[string]map[int64]int
|
||||
pngSmall string
|
||||
pngLarge string
|
||||
pngPeak string
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
GeminiRequests int64 `json:"geminiRequests"`
|
||||
GeminiInvalid int64 `json:"geminiInvalid"`
|
||||
GeminiInputBytes int64 `json:"geminiInputBytes"`
|
||||
GeminiOutputBytes int64 `json:"geminiOutputBytes"`
|
||||
VideoSubmissions int64 `json:"videoSubmissions"`
|
||||
VideoPolls int64 `json:"videoPolls"`
|
||||
VideoInvalid int64 `json:"videoInvalid"`
|
||||
VideoReferenceCounts map[string]int64 `json:"videoReferenceCounts"`
|
||||
VideoRoleCounts map[string]int64 `json:"videoRoleCounts"`
|
||||
UniqueImageHashes int `json:"uniqueImageHashes"`
|
||||
UniqueVideoTasks int `json:"uniqueVideoTasks"`
|
||||
ForcedConversions int64 `json:"forcedConversionRequests"`
|
||||
VerifiedConversions int64 `json:"verifiedConversions"`
|
||||
CallbackEvents int64 `json:"callbackEvents"`
|
||||
DuplicateCallbacks int64 `json:"duplicateCallbacks"`
|
||||
}
|
||||
|
||||
type videoTask struct {
|
||||
ID string
|
||||
Model string
|
||||
CreatedAt time.Time
|
||||
ReadyAt time.Time
|
||||
ImageRefs []imageReference
|
||||
}
|
||||
|
||||
type imageReference struct {
|
||||
Role string
|
||||
SHA256 string
|
||||
ContentType string
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
ContentType string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func New(config Config) *Server {
|
||||
now := config.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
client := config.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
waiter := config.Wait
|
||||
if waiter == nil {
|
||||
waiter = wait
|
||||
}
|
||||
return &Server{
|
||||
now: now, httpClient: client, wait: waiter, tasks: map[string]videoTask{}, fixtures: buildFixtures(),
|
||||
callbacks: map[string]map[int64]int{},
|
||||
report: Report{
|
||||
VideoReferenceCounts: map[string]int64{},
|
||||
VideoRoleCounts: map[string]int64{},
|
||||
},
|
||||
pngSmall: base64.StdEncoding.EncodeToString(paddedPNG(256 << 10)),
|
||||
pngLarge: base64.StdEncoding.EncodeToString(paddedPNG(4 << 20)),
|
||||
pngPeak: base64.StdEncoding.EncodeToString(paddedPNG(8 << 20)),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /report", s.getReport)
|
||||
mux.HandleFunc("POST /v1beta/models/", s.geminiGenerateContent)
|
||||
mux.HandleFunc("POST /v1/models/", s.geminiGenerateContent)
|
||||
mux.HandleFunc("POST /contents/generations/tasks", s.submitVideo)
|
||||
mux.HandleFunc("GET /contents/generations/tasks/{taskID}", s.getVideo)
|
||||
mux.HandleFunc("DELETE /contents/generations/tasks/{taskID}", s.deleteVideo)
|
||||
mux.HandleFunc("GET /media/{asset}", s.getMedia)
|
||||
mux.HandleFunc("GET /fixtures/{asset}", s.getFixture)
|
||||
mux.HandleFunc("POST /callbacks", s.collectCallback)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, ":generateContent") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.recordGeminiInvalid()
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
inputBytes, err := validateGeminiImageRequest(body)
|
||||
if err != nil {
|
||||
s.recordGeminiInvalid()
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
outputBytes, outputBase64, delay := s.geminiProfile(inputBytes)
|
||||
if err := s.wait(r.Context(), delay); err != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.report.GeminiRequests++
|
||||
s.report.GeminiInputBytes += int64(inputBytes)
|
||||
s.report.GeminiOutputBytes += int64(outputBytes)
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("X-Request-ID", "acceptance-gemini-"+strconv.FormatUint(s.nextID.Add(1), 10))
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"candidates": []any{map[string]any{
|
||||
"index": 0,
|
||||
"content": map[string]any{
|
||||
"role": "model",
|
||||
"parts": []any{map[string]any{"inlineData": map[string]any{
|
||||
"mimeType": "image/png",
|
||||
"data": outputBase64,
|
||||
}}},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) geminiProfile(inputBytes int) (int, string, time.Duration) {
|
||||
switch {
|
||||
case inputBytes > acceptanceworkload.GeminiLarge.InputBytes:
|
||||
profile := acceptanceworkload.GeminiPeak
|
||||
return profile.OutputBytes, s.pngPeak, scaledDelay(profile.DelayMin, profile.DelayMax, inputBytes)
|
||||
case inputBytes > acceptanceworkload.GeminiBaseline.InputBytes:
|
||||
profile := acceptanceworkload.GeminiLarge
|
||||
return profile.OutputBytes, s.pngLarge, scaledDelay(profile.DelayMin, profile.DelayMax, inputBytes)
|
||||
default:
|
||||
profile := acceptanceworkload.GeminiBaseline
|
||||
return profile.OutputBytes, s.pngSmall, profile.DelayMin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) submitVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.recordVideoInvalid()
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
refs, longRun, forceConversion, err := s.validateVideoRequest(r.Context(), body)
|
||||
if err != nil {
|
||||
s.recordVideoInvalid()
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
delay := videoDelay(body, longRun)
|
||||
id := "acceptance-video-" + strconv.FormatUint(s.nextID.Add(1), 10)
|
||||
task := videoTask{
|
||||
ID: id, Model: strings.TrimSpace(stringValue(body["model"])),
|
||||
CreatedAt: s.now(), ReadyAt: s.now().Add(delay), ImageRefs: refs,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.tasks[id] = task
|
||||
s.report.VideoSubmissions++
|
||||
s.report.VideoReferenceCounts[strconv.Itoa(len(refs))]++
|
||||
if forceConversion {
|
||||
s.report.ForcedConversions++
|
||||
s.report.VerifiedConversions++
|
||||
}
|
||||
for _, ref := range refs {
|
||||
s.report.VideoRoleCounts[ref.Role]++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id, "model": task.Model, "status": "queued",
|
||||
"created_at": task.CreatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("taskID"))
|
||||
s.mu.Lock()
|
||||
task, ok := s.tasks[id]
|
||||
if ok {
|
||||
s.report.VideoPolls++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
writeProtocolError(w, http.StatusNotFound, "video task not found")
|
||||
return
|
||||
}
|
||||
status := "running"
|
||||
content := map[string]any{}
|
||||
if !s.now().Before(task.ReadyAt) {
|
||||
status = "succeeded"
|
||||
content["video_url"] = requestOrigin(r) + "/media/" + url.PathEscape(id) + ".mp4"
|
||||
}
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id, "model": task.Model, "status": status, "content": content,
|
||||
"created_at": task.CreatedAt.Unix(),
|
||||
"usage": map[string]any{"completion_tokens": 1, "total_tokens": 1},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("taskID"))
|
||||
s.mu.Lock()
|
||||
_, ok := s.tasks[id]
|
||||
delete(s.tasks, id)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
writeProtocolError(w, http.StatusNotFound, "video task not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "status": "cancelled"})
|
||||
}
|
||||
|
||||
func (s *Server) getMedia(w http.ResponseWriter, r *http.Request) {
|
||||
asset := strings.TrimSuffix(strings.TrimSpace(r.PathValue("asset")), ".mp4")
|
||||
s.mu.RLock()
|
||||
_, ok := s.tasks[asset]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(minimalMP4)
|
||||
}
|
||||
|
||||
func (s *Server) getFixture(w http.ResponseWriter, r *http.Request) {
|
||||
fixture, ok := s.fixtures[strings.TrimSpace(r.PathValue("asset"))]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", fixture.ContentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("X-Content-SHA256", payloadSHA256(fixture.Payload))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(fixture.Payload)
|
||||
}
|
||||
|
||||
func (s *Server) getReport(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
report := s.report
|
||||
tasks := make([]videoTask, 0, len(s.tasks))
|
||||
for _, task := range s.tasks {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
hashes := map[string]struct{}{}
|
||||
for _, task := range tasks {
|
||||
for _, ref := range task.ImageRefs {
|
||||
hashes[ref.SHA256] = struct{}{}
|
||||
}
|
||||
}
|
||||
report.UniqueImageHashes = len(hashes)
|
||||
report.UniqueVideoTasks = len(tasks)
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) collectCallback(w http.ResponseWriter, r *http.Request) {
|
||||
var payload map[string]any
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
writeProtocolError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
taskID := strings.TrimSpace(stringValue(payload["taskId"]))
|
||||
seq := int64(0)
|
||||
switch value := payload["seq"].(type) {
|
||||
case json.Number:
|
||||
seq, _ = value.Int64()
|
||||
case float64:
|
||||
seq = int64(value)
|
||||
}
|
||||
if taskID == "" || seq <= 0 {
|
||||
writeProtocolError(w, http.StatusBadRequest, "callback taskId and seq are required")
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
seen := s.callbacks[taskID]
|
||||
if seen == nil {
|
||||
seen = map[int64]int{}
|
||||
s.callbacks[taskID] = seen
|
||||
}
|
||||
seen[seq]++
|
||||
s.report.CallbackEvents++
|
||||
if seen[seq] > 1 {
|
||||
s.report.DuplicateCallbacks++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) validateVideoRequest(ctx context.Context, body map[string]any) ([]imageReference, bool, bool, error) {
|
||||
content, _ := body["content"].([]any)
|
||||
refs := make([]imageReference, 0, 9)
|
||||
firstFrames := 0
|
||||
lastFrames := 0
|
||||
longRun := false
|
||||
forceConversion := false
|
||||
for _, raw := range content {
|
||||
item, _ := raw.(map[string]any)
|
||||
switch strings.TrimSpace(stringValue(item["type"])) {
|
||||
case "text":
|
||||
if strings.Contains(strings.ToLower(stringValue(item["text"])), "acceptance-long-recovery") {
|
||||
longRun = true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(stringValue(item["text"])), "acceptance-force-conversion") {
|
||||
forceConversion = true
|
||||
}
|
||||
case "image_url":
|
||||
role := strings.TrimSpace(stringValue(item["role"]))
|
||||
switch role {
|
||||
case "reference_image":
|
||||
case "first_frame":
|
||||
firstFrames++
|
||||
case "last_frame":
|
||||
lastFrames++
|
||||
default:
|
||||
return nil, false, false, fmt.Errorf("unsupported image role %q", role)
|
||||
}
|
||||
nested, _ := item["image_url"].(map[string]any)
|
||||
rawURL := strings.TrimSpace(stringValue(nested["url"]))
|
||||
ref, err := s.fetchImageReference(ctx, role, rawURL)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
if len(refs) != 3 && len(refs) != 6 && len(refs) != 9 {
|
||||
return nil, false, false, fmt.Errorf("expected 3, 6, or 9 image references, got %d", len(refs))
|
||||
}
|
||||
if firstFrames > 1 || lastFrames > 1 {
|
||||
return nil, false, false, errors.New("first_frame and last_frame must be unique")
|
||||
}
|
||||
if forceConversion {
|
||||
for _, ref := range refs {
|
||||
if ref.Width >= 3840 || ref.Height >= 2160 {
|
||||
return nil, false, false, errors.New("forced 4K image did not pass through automatic normalization")
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs, longRun, forceConversion, nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchImageReference(ctx context.Context, role string, rawURL string) (imageReference, error) {
|
||||
if rawURL == "" {
|
||||
return imageReference{}, errors.New("image reference URL is required")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return imageReference{}, err
|
||||
}
|
||||
response, err := s.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return imageReference{}, fmt.Errorf("fetch image reference: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return imageReference{}, fmt.Errorf("fetch image reference: status %d", response.StatusCode)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, 32<<20))
|
||||
if err != nil {
|
||||
return imageReference{}, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return imageReference{}, errors.New("image reference is empty")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return imageReference{}, fmt.Errorf("decode image reference: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return imageReference{
|
||||
Role: role, SHA256: hex.EncodeToString(sum[:]), ContentType: "image/" + format,
|
||||
Width: config.Width, Height: config.Height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateGeminiImageRequest(body map[string]any) (int, error) {
|
||||
generationConfig, _ := body["generationConfig"].(map[string]any)
|
||||
modalities, _ := generationConfig["responseModalities"].([]any)
|
||||
hasImageModality := false
|
||||
for _, modality := range modalities {
|
||||
if strings.EqualFold(strings.TrimSpace(stringValue(modality)), "IMAGE") {
|
||||
hasImageModality = true
|
||||
}
|
||||
}
|
||||
if !hasImageModality {
|
||||
return 0, errors.New("generationConfig.responseModalities must contain IMAGE")
|
||||
}
|
||||
total := 0
|
||||
contents, _ := body["contents"].([]any)
|
||||
for _, rawContent := range contents {
|
||||
content, _ := rawContent.(map[string]any)
|
||||
parts, _ := content["parts"].([]any)
|
||||
for _, rawPart := range parts {
|
||||
part, _ := rawPart.(map[string]any)
|
||||
inline, _ := part["inlineData"].(map[string]any)
|
||||
if inline == nil {
|
||||
inline, _ = part["inline_data"].(map[string]any)
|
||||
}
|
||||
encoded := strings.TrimSpace(stringValue(inline["data"]))
|
||||
if encoded == "" {
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Gemini inlineData is not valid Base64: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return 0, errors.New("Gemini inlineData is empty")
|
||||
}
|
||||
total += len(decoded)
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, errors.New("Gemini request has no inlineData image")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *Server) recordGeminiInvalid() {
|
||||
s.mu.Lock()
|
||||
s.report.GeminiInvalid++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) recordVideoInvalid() {
|
||||
s.mu.Lock()
|
||||
s.report.VideoInvalid++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, target any) error {
|
||||
reader := io.LimitReader(r.Body, maxProtocolBodyBytes+1)
|
||||
payload, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) > maxProtocolBodyBytes {
|
||||
return errors.New("protocol request body is too large")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.UseNumber()
|
||||
return decoder.Decode(target)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeProtocolError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": map[string]any{
|
||||
"code": "acceptance_protocol_invalid", "message": message,
|
||||
}})
|
||||
}
|
||||
|
||||
func wait(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func scaledDelay(minimum time.Duration, maximum time.Duration, seed int) time.Duration {
|
||||
if maximum <= minimum {
|
||||
return minimum
|
||||
}
|
||||
windowSeconds := int64((maximum - minimum) / time.Second)
|
||||
if windowSeconds <= 0 {
|
||||
return minimum
|
||||
}
|
||||
return minimum + time.Duration(int64(seed)%(windowSeconds+1))*time.Second
|
||||
}
|
||||
|
||||
func videoDelay(body map[string]any, longRun bool) time.Duration {
|
||||
seed := int64(0)
|
||||
switch value := body["seed"].(type) {
|
||||
case json.Number:
|
||||
seed, _ = value.Int64()
|
||||
case float64:
|
||||
seed = int64(value)
|
||||
case int:
|
||||
seed = int64(value)
|
||||
}
|
||||
if seed < 0 {
|
||||
seed = -seed
|
||||
}
|
||||
if longRun {
|
||||
return 2*time.Minute + time.Duration(seed%61)*time.Second
|
||||
}
|
||||
return 5*time.Second + time.Duration(seed%11)*time.Second
|
||||
}
|
||||
|
||||
func requestOrigin(r *http.Request) string {
|
||||
scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
host := strings.TrimSpace(r.Host)
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func paddedPNG(size int) []byte {
|
||||
base, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
if size <= len(base) {
|
||||
return base
|
||||
}
|
||||
iendOffset := len(base) - 12
|
||||
paddingLength := size - len(base) - 12
|
||||
if paddingLength < 0 {
|
||||
paddingLength = 0
|
||||
}
|
||||
chunk := make([]byte, 12+paddingLength)
|
||||
binary.BigEndian.PutUint32(chunk[:4], uint32(paddingLength))
|
||||
copy(chunk[4:8], []byte("teST"))
|
||||
binary.BigEndian.PutUint32(chunk[8+paddingLength:], crc32.ChecksumIEEE(chunk[4:8+paddingLength]))
|
||||
out := make([]byte, 0, len(base)+len(chunk))
|
||||
out = append(out, base[:iendOffset]...)
|
||||
out = append(out, chunk...)
|
||||
out = append(out, base[iendOffset:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func buildFixtures() map[string]fixture {
|
||||
fixtures := map[string]fixture{}
|
||||
for index := 0; index < 4; index++ {
|
||||
imageValue := patternedImage(768, 512, index+1)
|
||||
var encoded bytes.Buffer
|
||||
_ = png.Encode(&encoded, imageValue)
|
||||
fixtures[fmt.Sprintf("image-%02d.png", index)] = fixture{ContentType: "image/png", Payload: encoded.Bytes()}
|
||||
}
|
||||
for index := 0; index < 4; index++ {
|
||||
imageValue := patternedImage(1024, 768, index+11)
|
||||
var encoded bytes.Buffer
|
||||
_ = jpeg.Encode(&encoded, imageValue, &jpeg.Options{Quality: 88})
|
||||
fixtures[fmt.Sprintf("image-%02d.jpg", index+4)] = fixture{ContentType: "image/jpeg", Payload: encoded.Bytes()}
|
||||
}
|
||||
webpPayload, _ := base64.StdEncoding.DecodeString("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEALmk0mk0iIiIiIgBoSygABc6zbAAA")
|
||||
for index := 0; index < 4; index++ {
|
||||
payload := append([]byte(nil), webpPayload...)
|
||||
payload = append(payload, byte(index))
|
||||
fixtures[fmt.Sprintf("image-%02d.webp", index+8)] = fixture{ContentType: "image/webp", Payload: payload}
|
||||
}
|
||||
for index := 0; index < 4; index++ {
|
||||
imageValue := patternedImage(4096, 2160, index+21)
|
||||
var encoded bytes.Buffer
|
||||
_ = jpeg.Encode(&encoded, imageValue, &jpeg.Options{Quality: 90})
|
||||
fixtures[fmt.Sprintf("image-%02d-4k.jpg", index+12)] = fixture{ContentType: "image/jpeg", Payload: encoded.Bytes()}
|
||||
}
|
||||
return fixtures
|
||||
}
|
||||
|
||||
func patternedImage(width int, height int, seed int) image.Image {
|
||||
out := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
out.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8((x + seed*31) % 256),
|
||||
G: uint8((y + seed*47) % 256),
|
||||
B: uint8((x/8 + y/8 + seed*59) % 256),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func payloadSHA256(payload []byte) string {
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var minimalMP4 = []byte{
|
||||
0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm',
|
||||
0, 0, 0, 0, 'i', 's', 'o', 'm', 'm', 'p', '4', '2',
|
||||
0, 0, 0, 8, 'm', 'd', 'a', 't',
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package acceptanceemulator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeminiAndVolcesProtocolEmulation(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
server := New(Config{
|
||||
Now: func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return now
|
||||
},
|
||||
Wait: func(context.Context, time.Duration) error { return nil },
|
||||
})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
input := paddedPNG(256 << 10)
|
||||
geminiBody, _ := json.Marshal(map[string]any{
|
||||
"contents": []any{map[string]any{"parts": []any{
|
||||
map[string]any{"text": "edit image"},
|
||||
map[string]any{"inlineData": map[string]any{
|
||||
"mimeType": "image/png", "data": base64.StdEncoding.EncodeToString(input),
|
||||
}},
|
||||
}}},
|
||||
"generationConfig": map[string]any{"responseModalities": []any{"IMAGE"}},
|
||||
})
|
||||
response, err := http.Post(httpServer.URL+"/v1beta/models/gemini-test:generateContent", "application/json", bytes.NewReader(geminiBody))
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini request: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("Gemini status=%d body=%s", response.StatusCode, payload)
|
||||
}
|
||||
var geminiResult map[string]any
|
||||
if err := json.NewDecoder(response.Body).Decode(&geminiResult); err != nil {
|
||||
t.Fatalf("decode Gemini response: %v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
|
||||
fixtureResponse, err := http.Get(httpServer.URL + "/fixtures/image-12-4k.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("get 4K fixture: %v", err)
|
||||
}
|
||||
config, format, err := image.DecodeConfig(fixtureResponse.Body)
|
||||
_ = fixtureResponse.Body.Close()
|
||||
if err != nil || format != "jpeg" || config.Width != 4096 || config.Height != 2160 {
|
||||
t.Fatalf("4K fixture format=%s config=%+v err=%v", format, config, err)
|
||||
}
|
||||
|
||||
content := []any{map[string]any{"type": "text", "text": "video"}}
|
||||
for index, name := range []string{"image-00.png", "image-04.jpg", "image-08.webp"} {
|
||||
role := "reference_image"
|
||||
if index == 0 {
|
||||
role = "first_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{"url": httpServer.URL + "/fixtures/" + name},
|
||||
})
|
||||
}
|
||||
videoBody, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content, "seed": 1})
|
||||
response, err = http.Post(httpServer.URL+"/contents/generations/tasks", "application/json", bytes.NewReader(videoBody))
|
||||
if err != nil {
|
||||
t.Fatalf("submit video: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("submit video status=%d", response.StatusCode)
|
||||
}
|
||||
var submitted map[string]any
|
||||
_ = json.NewDecoder(response.Body).Decode(&submitted)
|
||||
_ = response.Body.Close()
|
||||
taskID := strings.TrimSpace(stringValue(submitted["id"]))
|
||||
if taskID == "" {
|
||||
t.Fatal("video emulator returned no task ID")
|
||||
}
|
||||
mu.Lock()
|
||||
now = now.Add(20 * time.Second)
|
||||
mu.Unlock()
|
||||
response, err = http.Get(httpServer.URL + "/contents/generations/tasks/" + taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("poll video: %v", err)
|
||||
}
|
||||
var polled map[string]any
|
||||
_ = json.NewDecoder(response.Body).Decode(&polled)
|
||||
_ = response.Body.Close()
|
||||
if polled["status"] != "succeeded" {
|
||||
t.Fatalf("video status=%v", polled["status"])
|
||||
}
|
||||
|
||||
response, err = http.Get(httpServer.URL + "/report")
|
||||
if err != nil {
|
||||
t.Fatalf("get report: %v", err)
|
||||
}
|
||||
var report Report
|
||||
_ = json.NewDecoder(response.Body).Decode(&report)
|
||||
_ = response.Body.Close()
|
||||
if report.GeminiRequests != 1 || report.VideoSubmissions != 1 || report.VideoInvalid != 0 ||
|
||||
report.VideoReferenceCounts["3"] != 1 || report.UniqueImageHashes != 3 {
|
||||
t.Fatalf("unexpected emulator report: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddedPNGIsValidAndExact(t *testing.T) {
|
||||
for _, size := range []int{256 << 10, 4 << 20, 8 << 20} {
|
||||
payload := paddedPNG(size)
|
||||
if len(payload) != size {
|
||||
t.Fatalf("PNG bytes=%d, want=%d", len(payload), size)
|
||||
}
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(payload)); err != nil {
|
||||
t.Fatalf("decode padded PNG: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaledDelayStaysOnWholeSecondsWithinProfile(t *testing.T) {
|
||||
delay := scaledDelay(8*time.Second, 15*time.Second, 2<<20)
|
||||
if delay < 8*time.Second || delay > 15*time.Second || delay%time.Second != 0 {
|
||||
t.Fatalf("scaled delay=%s", delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesProtocolAcceptsThreeSixAndNineReferenceImages(t *testing.T) {
|
||||
server := New(Config{Wait: func(context.Context, time.Duration) error { return nil }})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
for _, imageCount := range []int{3, 6, 9} {
|
||||
content := []any{map[string]any{"type": "text", "text": "multi-reference video"}}
|
||||
for index := 0; index < imageCount; index++ {
|
||||
role := "reference_image"
|
||||
if index == 0 {
|
||||
role = "first_frame"
|
||||
}
|
||||
if imageCount > 3 && index == imageCount-1 {
|
||||
role = "last_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{
|
||||
"url": fmt.Sprintf("%s/fixtures/image-%02d.png", httpServer.URL, index%4),
|
||||
},
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content, "seed": imageCount})
|
||||
response, err := http.Post(httpServer.URL+"/contents/generations/tasks", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("%d-image submit: %v", imageCount, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%d-image status=%d", imageCount, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := http.Get(httpServer.URL + "/report")
|
||||
if err != nil {
|
||||
t.Fatalf("get report: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var report Report
|
||||
if err := json.NewDecoder(response.Body).Decode(&report); err != nil {
|
||||
t.Fatalf("decode report: %v", err)
|
||||
}
|
||||
for _, imageCount := range []string{"3", "6", "9"} {
|
||||
if report.VideoReferenceCounts[imageCount] != 1 {
|
||||
t.Fatalf("reference counts=%v", report.VideoReferenceCounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user