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
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user