From 7e8244668e8092ec6c50b0148f201d97e929bea4 Mon Sep 17 00:00:00 2001 From: wtgoku <2687861+wtgoku@users.noreply.github.com> Date: Mon, 18 May 2026 17:32:53 +0800 Subject: [PATCH] Support start-end frame image2video routing The CLI now submits gateway-compatible first/final-frame payloads, so the server accepts the same public SkillHub schema fields, normalizes them to images[0]/images[1] with metadata.action=firstTailGenerate, and routes multi-frame Vidu-style submissions through the unified video generations endpoint. Constraint: Align server behavior with popiartcli v0.3.21 start/end-frame payloads Constraint: Keep existing single-image image2video and MiniMax paths compatible Rejected: Hardcode only the new fields in server seed skills | loading SkillHub input_schema.json keeps the remote catalog as the source of truth Confidence: medium Scope-risk: moderate Directive: Preserve first-frame then final-frame ordering when changing video reference handling Tested: go test ./... Tested: make build Tested: GOOS=linux GOARCH=amd64 go build -o dist/popiartserver-linux-amd64 ./cmd/popiartserver Not-tested: Live test-server deployment; current SSH key is rejected by 101.42.99.35 --- internal/server/newapi.go | 141 +++++++++++++++++++++++++ internal/server/server.go | 106 ++++++++++++++++++- internal/server/skillhub.go | 79 +++++++++++--- internal/server/store.go | 9 +- internal/server/video_test.go | 189 ++++++++++++++++++++++++++++++++++ 5 files changed, 500 insertions(+), 24 deletions(-) diff --git a/internal/server/newapi.go b/internal/server/newapi.go index bb381bf..7eef994 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -1003,6 +1003,137 @@ func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, m return taskID, nil } +func (c *newAPIClient) submitImageToVideoTaskWithReferences(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) (string, error) { + if len(refs) == 0 { + return "", errors.New("reference image content is required") + } + if len(refs) == 1 { + return c.submitImageToVideoTask(ctx, token, modelID, input, refs[0]) + } + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + prompt := strings.TrimSpace(stringValue(input["prompt"])) + if prompt == "" { + prompt = "Generate a short polished image-to-video clip from the provided start and end frames." + } + + images := make([]string, 0, len(refs)) + for _, ref := range refs { + encoded, err := encodeImageReference(ref) + if err != nil { + return "", err + } + images = append(images, encoded) + } + + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "images": images, + "duration": 5, + } + if duration := strings.TrimSpace(resolveVideoDurationSeconds(input)); duration != "" { + if parsed, err := strconv.Atoi(duration); err == nil && parsed > 0 { + payload["duration"] = parsed + } + } + if size := resolveVideoSize(modelID, input, refs[0]); size != "" { + payload["size"] = size + } + + metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) != "" && value != nil { + metadata[key] = value + } + } + } + if aspectRatio := resolveVideoAspectRatio(input, refs[0]); aspectRatio != "" { + if strings.TrimSpace(stringValue(metadata["aspect_ratio"])) == "" { + metadata["aspect_ratio"] = aspectRatio + } + } + if strings.TrimSpace(stringValue(metadata["action"])) == "" { + metadata["action"] = "firstTailGenerate" + } + payload["metadata"] = metadata + + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + taskID := decodeVideoSubmitTaskID(respBody) + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + +func decodeVideoSubmitTaskID(respBody []byte) string { + var decoded openAIVideoResponse + if err := json.Unmarshal(respBody, &decoded); err == nil { + taskID := strings.TrimSpace(decoded.ID) + if taskID == "" { + taskID = strings.TrimSpace(decoded.TaskID) + } + if taskID != "" { + return taskID + } + } + + var envelope taskEnvelopeResponse + if err := json.Unmarshal(respBody, &envelope); err == nil && len(envelope.Data) > 0 { + var task taskDTOResponse + if err := json.Unmarshal(envelope.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { + return strings.TrimSpace(task.TaskID) + } + } + + var simple struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Data struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &simple); err == nil { + for _, value := range []string{simple.ID, simple.TaskID, simple.Data.ID, simple.Data.TaskID} { + if taskID := strings.TrimSpace(value); taskID != "" { + return taskID + } + } + } + return "" +} + func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { if !c.enabled() { return nil, errors.New("PopiNewAPI base URL is not configured") @@ -1648,11 +1779,21 @@ func (c *newAPIClient) submitMiniMaxVideoTask(ctx context.Context, token, modelI } metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) != "" && value != nil { + metadata[key] = value + } + } + } for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark"} { if value, ok := input[key]; ok { metadata[key] = value } } + if value, ok := input["action"]; ok && value != nil { + metadata["action"] = value + } if len(metadata) > 0 { payload["metadata"] = metadata } diff --git a/internal/server/server.go b/internal/server/server.go index a8462a7..97df9eb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1284,6 +1284,7 @@ func (s *Server) executeImageToVideoJob(record *job) { defer cancelSubmit() var refsForMiniMax []imageEditReference + var refsForGateway []imageEditReference var ref imageEditReference var err error if useMiniMaxVideoGenerations(modelID) { @@ -1299,6 +1300,17 @@ func (s *Server) executeImageToVideoJob(record *job) { return } } + } else if hasStartEndFrameVideoInput(input) { + refsForGateway, err = s.resolveVideoReferences(submitCtx, record, input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{ + "skill_id": record.SkillID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } } else { ref, err = s.resolveImageToImageReference(submitCtx, record, input) if err != nil { @@ -1315,6 +1327,8 @@ func (s *Server) executeImageToVideoJob(record *job) { var upstreamTaskID string if useMiniMaxVideoGenerations(modelID) { upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax) + } else if len(refsForGateway) > 1 { + upstreamTaskID, err = s.newapi.submitImageToVideoTaskWithReferences(submitCtx, record.UpstreamKey, modelID, input, refsForGateway) } else { upstreamTaskID, err = s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref) } @@ -1527,7 +1541,23 @@ func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input if err != nil { return nil, err } - return []imageEditReference{ref}, nil + refs := []imageEditReference{ref} + if lastArtifactID := lastFrameArtifactID(input); lastArtifactID != "" { + lastRef, err := s.resolveImageToImageReference(ctx, record, map[string]any{ + "source_artifact_id": lastArtifactID, + }) + if err != nil { + return nil, err + } + refs = append(refs, lastRef) + } else if lastURL := lastFrameImageURL(input); lastURL != "" { + lastRef, err := s.downloadReferenceImage(ctx, record.SessionID, lastURL) + if err != nil { + return nil, err + } + refs = append(refs, lastRef) + } + return refs, nil } urls := extractStringValues(input["images"]) @@ -1540,6 +1570,11 @@ func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input urls = []string{refURL} } } + if len(urls) == 1 { + if lastURL := lastFrameImageURL(input); lastURL != "" && lastURL != urls[0] { + urls = append(urls, lastURL) + } + } if len(urls) == 0 { return nil, fmt.Errorf("reference image is required") } @@ -1665,6 +1700,7 @@ func buildImageToVideoInput(record *job) map[string]any { if input == nil { input = map[string]any{} } + normalizeStartEndFrameVideoInput(input) prompt := strings.TrimSpace(stringValue( input["prompt"], @@ -1698,6 +1734,74 @@ func buildImageToVideoInput(record *job) map[string]any { return input } +func normalizeStartEndFrameVideoInput(input map[string]any) { + if input == nil { + return + } + if value := lastFrameArtifactID(input); value != "" { + input["last_frame_artifact_id"] = value + input["end_frame_artifact_id"] = value + } + if value := lastFrameImageURL(input); value != "" { + input["last_frame_image_url"] = value + input["end_frame_image_url"] = value + } + + images := extractStringValues(input["images"]) + first := strings.TrimSpace(stringValue(input["image"], input["reference_image_url"], input["image_url"])) + last := lastFrameImageURL(input) + switch { + case len(images) == 0 && first != "" && last != "": + images = []string{first, last} + input["images"] = images + case len(images) == 1 && last != "" && images[0] != last: + images = append(images, last) + input["images"] = images + } + + if len(images) > 1 || last != "" || lastFrameArtifactID(input) != "" { + ensureInputMetadataAction(input, "firstTailGenerate") + } +} + +func hasStartEndFrameVideoInput(input map[string]any) bool { + return len(extractStringValues(input["images"])) > 1 || + lastFrameImageURL(input) != "" || + lastFrameArtifactID(input) != "" +} + +func lastFrameImageURL(input map[string]any) string { + if input == nil { + return "" + } + return strings.TrimSpace(stringValue( + input["last_frame_image_url"], + input["end_frame_image_url"], + input["last_frame_url"], + )) +} + +func lastFrameArtifactID(input map[string]any) string { + if input == nil { + return "" + } + return strings.TrimSpace(stringValue( + input["last_frame_artifact_id"], + input["end_frame_artifact_id"], + )) +} + +func ensureInputMetadataAction(input map[string]any, action string) { + metadata, _ := input["metadata"].(map[string]any) + if metadata == nil { + metadata = map[string]any{} + input["metadata"] = metadata + } + if strings.TrimSpace(stringValue(metadata["action"])) == "" { + metadata["action"] = action + } +} + func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string { parts := []string{ "PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image", diff --git a/internal/server/skillhub.go b/internal/server/skillhub.go index 71b28cb..9f7cfed 100644 --- a/internal/server/skillhub.go +++ b/internal/server/skillhub.go @@ -62,6 +62,18 @@ func loadSkills(skillhubDir string) ([]skill, error) { tags := tagsFromSkillID(entry.Name) name := defaultString(strings.TrimSpace(doc.Title), displayNameFromSkillID(entry.Name)) description := strings.TrimSpace(doc.Description) + inputSchema := inputSchemaForRoute(routeKey) + if schema, ok, err := readSkillSchemaFile(filepath.Join(skillhubDir, skillPath, "input_schema.json")); err != nil { + return nil, fmt.Errorf("read input schema for %s: %w", entry.Name, err) + } else if ok { + inputSchema = schema + } + outputSchema := defaultOutputSchema() + if schema, ok, err := readSkillSchemaFile(filepath.Join(skillhubDir, skillPath, "output_schema.json")); err != nil { + return nil, fmt.Errorf("read output schema for %s: %w", entry.Name, err) + } else if ok { + outputSchema = schema + } items = append(items, skill{ ID: defaultString(strings.TrimSpace(doc.Name), entry.Name), @@ -72,8 +84,8 @@ func loadSkills(skillhubDir string) ([]skill, error) { ModelType: category, RouteKey: routeKey, EstimatedDurationS: estimateDuration(routeKey), - InputSchema: inputSchemaForRoute(routeKey), - OutputSchema: defaultOutputSchema(), + InputSchema: inputSchema, + OutputSchema: outputSchema, }) } @@ -83,6 +95,24 @@ func loadSkills(skillhubDir string) ([]skill, error) { return items, nil } +func readSkillSchemaFile(path string) (map[string]any, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + var schema map[string]any + if err := json.Unmarshal(data, &schema); err != nil { + return nil, false, err + } + if len(schema) == 0 { + return nil, false, nil + } + return schema, true, nil +} + type skillDoc struct { Name string Description string @@ -249,19 +279,7 @@ func inputSchemaForRoute(routeKey string) map[string]any { }, } case "video.image2video": - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "source_artifact_id": map[string]any{"type": "string"}, - "image_url": map[string]any{"type": "string"}, - "prompt": map[string]any{"type": "string"}, - "motion_prompt": map[string]any{"type": "string"}, - "duration_s": map[string]any{"type": "integer"}, - "camera_motion": map[string]any{"type": "string"}, - "mood": map[string]any{"type": "string"}, - "aspect_ratio": map[string]any{"type": "string"}, - }, - } + return imageToVideoInputSchema() default: return map[string]any{ "type": "object", @@ -275,3 +293,34 @@ func inputSchemaForRoute(routeKey string) map[string]any { } } } + +func imageToVideoInputSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "source_artifact_id": map[string]any{"type": "string"}, + "image_url": map[string]any{"type": "string"}, + "reference_image_url": map[string]any{"type": "string"}, + "last_frame_image_url": map[string]any{"type": "string"}, + "end_frame_image_url": map[string]any{"type": "string"}, + "last_frame_artifact_id": map[string]any{"type": "string"}, + "end_frame_artifact_id": map[string]any{"type": "string"}, + "images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "metadata": map[string]any{"type": "object"}, + "prompt": map[string]any{"type": "string"}, + "motion_prompt": map[string]any{"type": "string"}, + "negative_prompt": map[string]any{"type": "string"}, + "duration_s": map[string]any{"type": "number"}, + "seconds": map[string]any{"type": "number"}, + "fps": map[string]any{"type": "number"}, + "camera_motion": map[string]any{"type": "string"}, + "motion_intensity": map[string]any{"type": "string"}, + "style": map[string]any{"type": "string"}, + "size": map[string]any{"type": "string"}, + "mood": map[string]any{"type": "string"}, + "aspect_ratio": map[string]any{"type": "string"}, + "seed": map[string]any{"type": "number"}, + "notes": map[string]any{"type": "string"}, + }, + } +} diff --git a/internal/server/store.go b/internal/server/store.go index 75af74e..76e5d1a 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -117,14 +117,7 @@ func seedSkills() []skill { ModelType: "video", RouteKey: "video.image2video", EstimatedDurationS: 90, - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "image_url": map[string]any{"type": "string"}, - "prompt": map[string]any{"type": "string"}, - }, - "required": []string{"image_url"}, - }, + InputSchema: imageToVideoInputSchema(), OutputSchema: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/internal/server/video_test.go b/internal/server/video_test.go index 91642a6..84471ae 100644 --- a/internal/server/video_test.go +++ b/internal/server/video_test.go @@ -140,6 +140,53 @@ func TestSubmitImageToVideoTaskUsesURLImagesForViduModels(t *testing.T) { } } +func TestSubmitImageToVideoTaskWithReferencesUsesGatewayForStartEndFrames(t *testing.T) { + var ( + gotPath string + gotBody map[string]any + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected application/json content type, got %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_start_end_123","status":"queued"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitImageToVideoTaskWithReferences(context.Background(), "sk-test", "viduq2-pro", map[string]any{ + "prompt": "transition smoothly from first to last frame", + "duration_s": 5, + "aspect_ratio": "16:9", + }, []imageEditReference{ + {URL: "https://example.com/first.png"}, + {URL: "https://example.com/last.png"}, + }) + if err != nil { + t.Fatalf("submitImageToVideoTaskWithReferences: %v", err) + } + if taskID != "task_start_end_123" { + t.Fatalf("expected task id task_start_end_123, got %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 2 || images[0] != "https://example.com/first.png" || images[1] != "https://example.com/last.png" { + t.Fatalf("unexpected images payload: %#v", gotBody["images"]) + } + metadata, ok := gotBody["metadata"].(map[string]any) + if !ok || metadata["action"] != "firstTailGenerate" || metadata["aspect_ratio"] != "16:9" { + t.Fatalf("unexpected metadata: %#v", gotBody["metadata"]) + } +} + func TestSubmitImageToVideoTaskUsesViduDefaultDurationWhenUnset(t *testing.T) { var gotBody map[string]any @@ -273,6 +320,57 @@ func TestResolveVideoReferencesSupportsImagesArray(t *testing.T) { } } +func TestResolveVideoReferencesSupportsSourceAndLastFrameArtifacts(t *testing.T) { + cfg := Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + srv := httptest.NewServer(server.Handler()) + defer srv.Close() + server.cfg.PublicBaseURL = srv.URL + + sessionToken, _, ok, err := server.store.createSession("sk-video-artifact-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + current, exists, err := server.store.session(sessionToken) + if err != nil { + t.Fatalf("load session: %v", err) + } + if !exists { + t.Fatal("expected stored session") + } + + first := uploadTestMediaArtifact(t, server, sessionToken, "first.png") + last := uploadTestMediaArtifact(t, server, sessionToken, "last.png") + + refs, err := server.resolveVideoReferences(context.Background(), &job{ + UserID: current.User.ID, + SessionID: current.Token, + UpstreamKey: current.UpstreamKey, + }, map[string]any{ + "source_artifact_id": first.ID, + "last_frame_artifact_id": last.ID, + }) + if err != nil { + t.Fatalf("resolveVideoReferences artifacts: %v", err) + } + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %#v", refs) + } + if refs[0].URL == "" || refs[1].URL == "" { + t.Fatalf("expected stable URLs for both refs, got %#v", refs) + } +} + func TestResolveVideoReferencesAcceptsCanonicalImageURL(t *testing.T) { cfg := Config{ SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), @@ -401,6 +499,61 @@ func TestResolveVideoReferencesAcceptsSignedSameOriginMediaURL(t *testing.T) { } } +func TestBuildImageToVideoInputNormalizesStartEndFrameFields(t *testing.T) { + input := buildImageToVideoInput(&job{Input: map[string]any{ + "image_url": "https://example.com/first.png", + "last_frame_image_url": "https://example.com/last.png", + "prompt": "smooth transition", + "end_frame_artifact_id": "art_last", + }}) + + images, ok := input["images"].([]string) + if !ok || len(images) != 2 || images[0] != "https://example.com/first.png" || images[1] != "https://example.com/last.png" { + t.Fatalf("unexpected normalized images: %#v", input["images"]) + } + if input["last_frame_artifact_id"] != "art_last" || input["end_frame_artifact_id"] != "art_last" { + t.Fatalf("expected last-frame artifact aliases to normalize, got %#v", input) + } + metadata, ok := input["metadata"].(map[string]any) + if !ok || metadata["action"] != "firstTailGenerate" { + t.Fatalf("expected firstTailGenerate metadata, got %#v", input["metadata"]) + } +} + +func TestLoadSkillsUsesSkillhubSchemaFiles(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "popiskill-video-image2video-basic-v1") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "index.json"), []byte(`{"version":1,"skills":[{"name":"popiskill-video-image2video-basic-v1","path":"skills/popiskill-video-image2video-basic-v1","category":"video","capability":"image2video"}]}`), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: popiskill-video-image2video-basic-v1\ndescription: Test image to video skill.\n---\n\n# Image To Video\n"), 0o644); err != nil { + t.Fatalf("write skill doc: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "input_schema.json"), []byte(`{"type":"object","properties":{"last_frame_image_url":{"type":"string"}}}`), 0o644); err != nil { + t.Fatalf("write input schema: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "output_schema.json"), []byte(`{"type":"object","properties":{"video_url":{"type":"string"}}}`), 0o644); err != nil { + t.Fatalf("write output schema: %v", err) + } + + skills, err := loadSkills(dir) + if err != nil { + t.Fatalf("loadSkills: %v", err) + } + if len(skills) != 1 { + t.Fatalf("expected one skill, got %#v", skills) + } + if _, ok := skills[0].InputSchema["properties"].(map[string]any)["last_frame_image_url"]; !ok { + t.Fatalf("expected input schema from file, got %#v", skills[0].InputSchema) + } + if _, ok := skills[0].OutputSchema["properties"].(map[string]any)["video_url"]; !ok { + t.Fatalf("expected output schema from file, got %#v", skills[0].OutputSchema) + } +} + func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -614,6 +767,42 @@ func tinyPNG(t *testing.T) []byte { return data } +func uploadTestMediaArtifact(t *testing.T, server *Server, sessionToken, filename string) artifact { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := part.Write(tinyPNG(t)); err != nil { + t.Fatalf("write form file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/artifacts/upload", &body) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected artifact upload 201, got %d body=%s", rec.Code, rec.Body.String()) + } + var envelope struct { + OK bool `json:"ok"` + Data artifact `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode artifact upload: %v body=%s", err, rec.Body.String()) + } + if !envelope.OK || envelope.Data.ID == "" { + t.Fatalf("expected artifact upload data, got %s", rec.Body.String()) + } + return envelope.Data +} + func makeEmptySkillhub(t *testing.T) string { t.Helper() dir := t.TempDir()