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:
@@ -1003,6 +1003,137 @@ func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, m
|
|||||||
return taskID, nil
|
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) {
|
func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
||||||
if !c.enabled() {
|
if !c.enabled() {
|
||||||
return nil, errors.New("PopiNewAPI base URL is not configured")
|
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{}
|
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"} {
|
for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark"} {
|
||||||
if value, ok := input[key]; ok {
|
if value, ok := input[key]; ok {
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if value, ok := input["action"]; ok && value != nil {
|
||||||
|
metadata["action"] = value
|
||||||
|
}
|
||||||
if len(metadata) > 0 {
|
if len(metadata) > 0 {
|
||||||
payload["metadata"] = metadata
|
payload["metadata"] = metadata
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-1
@@ -1284,6 +1284,7 @@ func (s *Server) executeImageToVideoJob(record *job) {
|
|||||||
defer cancelSubmit()
|
defer cancelSubmit()
|
||||||
|
|
||||||
var refsForMiniMax []imageEditReference
|
var refsForMiniMax []imageEditReference
|
||||||
|
var refsForGateway []imageEditReference
|
||||||
var ref imageEditReference
|
var ref imageEditReference
|
||||||
var err error
|
var err error
|
||||||
if useMiniMaxVideoGenerations(modelID) {
|
if useMiniMaxVideoGenerations(modelID) {
|
||||||
@@ -1299,6 +1300,17 @@ func (s *Server) executeImageToVideoJob(record *job) {
|
|||||||
return
|
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 {
|
} else {
|
||||||
ref, err = s.resolveImageToImageReference(submitCtx, record, input)
|
ref, err = s.resolveImageToImageReference(submitCtx, record, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1315,6 +1327,8 @@ func (s *Server) executeImageToVideoJob(record *job) {
|
|||||||
var upstreamTaskID string
|
var upstreamTaskID string
|
||||||
if useMiniMaxVideoGenerations(modelID) {
|
if useMiniMaxVideoGenerations(modelID) {
|
||||||
upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax)
|
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 {
|
} else {
|
||||||
upstreamTaskID, err = s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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"])
|
urls := extractStringValues(input["images"])
|
||||||
@@ -1540,6 +1570,11 @@ func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input
|
|||||||
urls = []string{refURL}
|
urls = []string{refURL}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(urls) == 1 {
|
||||||
|
if lastURL := lastFrameImageURL(input); lastURL != "" && lastURL != urls[0] {
|
||||||
|
urls = append(urls, lastURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(urls) == 0 {
|
if len(urls) == 0 {
|
||||||
return nil, fmt.Errorf("reference image is required")
|
return nil, fmt.Errorf("reference image is required")
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1700,7 @@ func buildImageToVideoInput(record *job) map[string]any {
|
|||||||
if input == nil {
|
if input == nil {
|
||||||
input = map[string]any{}
|
input = map[string]any{}
|
||||||
}
|
}
|
||||||
|
normalizeStartEndFrameVideoInput(input)
|
||||||
|
|
||||||
prompt := strings.TrimSpace(stringValue(
|
prompt := strings.TrimSpace(stringValue(
|
||||||
input["prompt"],
|
input["prompt"],
|
||||||
@@ -1698,6 +1734,74 @@ func buildImageToVideoInput(record *job) map[string]any {
|
|||||||
return input
|
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 {
|
func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string {
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image",
|
"PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image",
|
||||||
|
|||||||
+64
-15
@@ -62,6 +62,18 @@ func loadSkills(skillhubDir string) ([]skill, error) {
|
|||||||
tags := tagsFromSkillID(entry.Name)
|
tags := tagsFromSkillID(entry.Name)
|
||||||
name := defaultString(strings.TrimSpace(doc.Title), displayNameFromSkillID(entry.Name))
|
name := defaultString(strings.TrimSpace(doc.Title), displayNameFromSkillID(entry.Name))
|
||||||
description := strings.TrimSpace(doc.Description)
|
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{
|
items = append(items, skill{
|
||||||
ID: defaultString(strings.TrimSpace(doc.Name), entry.Name),
|
ID: defaultString(strings.TrimSpace(doc.Name), entry.Name),
|
||||||
@@ -72,8 +84,8 @@ func loadSkills(skillhubDir string) ([]skill, error) {
|
|||||||
ModelType: category,
|
ModelType: category,
|
||||||
RouteKey: routeKey,
|
RouteKey: routeKey,
|
||||||
EstimatedDurationS: estimateDuration(routeKey),
|
EstimatedDurationS: estimateDuration(routeKey),
|
||||||
InputSchema: inputSchemaForRoute(routeKey),
|
InputSchema: inputSchema,
|
||||||
OutputSchema: defaultOutputSchema(),
|
OutputSchema: outputSchema,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +95,24 @@ func loadSkills(skillhubDir string) ([]skill, error) {
|
|||||||
return items, nil
|
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 {
|
type skillDoc struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
@@ -249,19 +279,7 @@ func inputSchemaForRoute(routeKey string) map[string]any {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
case "video.image2video":
|
case "video.image2video":
|
||||||
return map[string]any{
|
return imageToVideoInputSchema()
|
||||||
"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"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,14 +117,7 @@ func seedSkills() []skill {
|
|||||||
ModelType: "video",
|
ModelType: "video",
|
||||||
RouteKey: "video.image2video",
|
RouteKey: "video.image2video",
|
||||||
EstimatedDurationS: 90,
|
EstimatedDurationS: 90,
|
||||||
InputSchema: map[string]any{
|
InputSchema: imageToVideoInputSchema(),
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"image_url": map[string]any{"type": "string"},
|
|
||||||
"prompt": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []string{"image_url"},
|
|
||||||
},
|
|
||||||
OutputSchema: map[string]any{
|
OutputSchema: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
|
|||||||
@@ -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) {
|
func TestSubmitImageToVideoTaskUsesViduDefaultDurationWhenUnset(t *testing.T) {
|
||||||
var gotBody map[string]any
|
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) {
|
func TestResolveVideoReferencesAcceptsCanonicalImageURL(t *testing.T) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
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) {
|
func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -614,6 +767,42 @@ func tinyPNG(t *testing.T) []byte {
|
|||||||
return data
|
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 {
|
func makeEmptySkillhub(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user