Add stable media URLs for artifacts and uploads
This commit is contained in:
@@ -13,6 +13,7 @@ It is meant to validate these product-facing APIs first:
|
||||
- `run`
|
||||
- `jobs`
|
||||
- `artifacts`
|
||||
- `media`
|
||||
- basic `budget`, `project`, and `models` stubs
|
||||
|
||||
## Project Relationship
|
||||
@@ -61,7 +62,8 @@ In local development, `/tmp/Popiart_skillhub` is used automatically when it exis
|
||||
## Notes
|
||||
|
||||
- Local persistence is intentionally thin: `sessions`, `job refs`, and project route overrides live in SQLite.
|
||||
- `artifact` no longer has a local blob store. For sync image results, the server stores `result_refs_json` in the job row and derives artifact metadata on demand.
|
||||
- The local backend now keeps a lightweight media store under `POPIART_DATA_DIR/media/` and exposes stable `media` URLs for uploaded files and persisted job outputs.
|
||||
- `artifact` metadata is still derived from `result_refs_json`, but new artifacts bind to a local `media_id` and a stable `url`.
|
||||
- `job logs` are synthesized from job state transitions instead of being stored as a separate table.
|
||||
- The local development backend verifies your login key against the local `PopiNewAPI`.
|
||||
- This server is a development backend, not the final production architecture.
|
||||
|
||||
+24
-9
@@ -14,7 +14,6 @@
|
||||
|
||||
所以 `popiartServer` 不再重复持有:
|
||||
|
||||
- 本地 artifact blob store
|
||||
- 本地 artifact metadata table
|
||||
- 本地 job_logs table
|
||||
|
||||
@@ -23,6 +22,7 @@
|
||||
- `sessions`
|
||||
- `jobs`(更准确说是 job refs)
|
||||
- `skill_routes`
|
||||
- `media blobs + metadata`
|
||||
|
||||
## 当前关系
|
||||
|
||||
@@ -30,14 +30,16 @@
|
||||
session -> jobs
|
||||
jobs -> result_refs_json
|
||||
project -> skill_routes
|
||||
media -> local files + metadata json
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `session` 保存 PopiArt 登录态和对应的 `PopiNewAPI token`
|
||||
- `jobs` 保存 skill 语义、用户归属、上游引用和同步结果引用
|
||||
- `result_refs_json` 保存同步能力的返回引用,例如 `data_url` 或远端 `url`
|
||||
- `result_refs_json` 保存同步能力的结果引用;新结果会优先 re-host 到本地 media store
|
||||
- `skill_routes` 保存项目级路由覆盖
|
||||
- `media` 保存稳定 URL 所需的本地文件与元数据
|
||||
|
||||
## 存储位置
|
||||
|
||||
@@ -66,7 +68,10 @@ POPIART_SQLITE_PATH=./data/popiart.db
|
||||
2. `JobRepository`
|
||||
3. `RouteRepository`
|
||||
|
||||
没有单独的 blob storage 抽象。
|
||||
本地开发版没有对象存储依赖,但现在有一个轻量 media 存储层:
|
||||
|
||||
- blob 文件保存在 `POPIART_DATA_DIR/media/blobs/`
|
||||
- metadata JSON 保存在 `POPIART_DATA_DIR/media/meta/`
|
||||
|
||||
## SQLite Schema
|
||||
|
||||
@@ -141,15 +146,24 @@ CREATE TABLE skill_routes (
|
||||
### 完成同步结果 job
|
||||
|
||||
1. 如果 `PopiNewAPI` 直接返回同步结果,例如 `b64_json` 或 `url`
|
||||
2. `popiartServer` 只把它转换成 `result_refs_json`
|
||||
3. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表
|
||||
2. `popiartServer` 会先把结果 re-host 到本地 media store
|
||||
3. 再把本地 `local_path + media_id + stable url` 写进 `result_refs_json`
|
||||
4. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表
|
||||
|
||||
### 拉取 artifact
|
||||
|
||||
1. server 从 `artifact_id` 反推出 `job_id + result index`
|
||||
2. 读取 `jobs.result_refs_json`
|
||||
3. 如果是 `data_url`,直接解码并流式返回
|
||||
4. 如果是远端 `url`,server 代理下载并返回
|
||||
3. 如果是本地 `local_path`,直接读取本地文件并流式返回
|
||||
4. 如果是旧的 `data_url`,直接解码并流式返回
|
||||
5. 如果是旧的远端 `url`,server 代理下载并返回
|
||||
|
||||
### 读取 media
|
||||
|
||||
1. `POST /v1/media/upload` 会把本地文件写入 `media/blobs/`
|
||||
2. server 同时写一份 metadata JSON 到 `media/meta/`
|
||||
3. `GET /v1/media/:id` 返回 media 元数据
|
||||
4. `GET /v1/media/:id/content` 返回可供模型或客户端直接 fetch 的稳定内容路径
|
||||
|
||||
## 当前边界
|
||||
|
||||
@@ -157,7 +171,8 @@ CREATE TABLE skill_routes (
|
||||
- `session` 会保留
|
||||
- `job ref` 会保留
|
||||
- `artifact` 通过 `result_refs_json` 继续可读
|
||||
- 新 `media` 文件和 metadata 会继续可读
|
||||
- 已存在的 `pending/running` job 仍然不会自动恢复执行
|
||||
- 视频类 `upstream_task` 路径只预留了字段,后续再接 `PopiNewAPI` task 查询
|
||||
- 对同步图像结果来说,`data_url` 仍然会落在 SQLite 的 `result_refs_json`
|
||||
这是当前 `PopiNewAPI` 没有统一 file id 的现实折中,但已经不再有本地 artifact 表和 blob store
|
||||
- 旧数据里仍可能存在 `data_url` 或上游 `url`
|
||||
- 新写入路径优先落本地 media store,从而给 artifact 补出稳定 `url`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -21,6 +22,9 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T)
|
||||
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-upload-user")
|
||||
if err != nil {
|
||||
@@ -80,9 +84,34 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T)
|
||||
if envelope.Data.ID == "" {
|
||||
t.Fatalf("expected artifact id, got %#v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.MediaID == "" {
|
||||
t.Fatalf("expected media id, got %#v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.ContentType != "image/png" {
|
||||
t.Fatalf("expected image/png, got %q", envelope.Data.ContentType)
|
||||
}
|
||||
if envelope.Data.URL == "" {
|
||||
t.Fatalf("expected stable artifact url, got %#v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.StorageStatus != "ready" {
|
||||
t.Fatalf("expected storage status ready, got %#v", envelope.Data.StorageStatus)
|
||||
}
|
||||
|
||||
contentResp, err := http.Get(envelope.Data.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET media content: %v", err)
|
||||
}
|
||||
defer contentResp.Body.Close()
|
||||
if contentResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected media content 200, got %d", contentResp.StatusCode)
|
||||
}
|
||||
streamed, err := io.ReadAll(contentResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read media content: %v", err)
|
||||
}
|
||||
if !bytes.Equal(streamed, imageBytes) {
|
||||
t.Fatal("expected stable media url to serve uploaded bytes")
|
||||
}
|
||||
|
||||
editRef, err := server.resolveImageToImageReference(context.Background(), &job{
|
||||
UserID: current.User.ID,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mediaRecord struct {
|
||||
media
|
||||
UserID string `json:"user_id"`
|
||||
LocalPath string `json:"local_path"`
|
||||
}
|
||||
|
||||
func mediaDir(cfg Config) string {
|
||||
return filepath.Join(cfg.DataDir, "media")
|
||||
}
|
||||
|
||||
func mediaBlobDir(cfg Config) string {
|
||||
return filepath.Join(mediaDir(cfg), "blobs")
|
||||
}
|
||||
|
||||
func mediaMetaDir(cfg Config) string {
|
||||
return filepath.Join(mediaDir(cfg), "meta")
|
||||
}
|
||||
|
||||
func mediaMetaPath(cfg Config, mediaID string) string {
|
||||
return filepath.Join(mediaMetaDir(cfg), mediaID+".json")
|
||||
}
|
||||
|
||||
func mediaBlobPath(cfg Config, mediaID, filename string) string {
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
ext = extensionFromContentType("")
|
||||
}
|
||||
return filepath.Join(mediaBlobDir(cfg), mediaID+ext)
|
||||
}
|
||||
|
||||
func publicBaseURL(cfg Config) string {
|
||||
base := strings.TrimSpace(cfg.PublicBaseURL)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(os.Getenv("POPIART_SERVER_ADDR"))
|
||||
}
|
||||
if base == "" {
|
||||
base = "127.0.0.1:8080"
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
base = strings.Replace(base, "0.0.0.0", "127.0.0.1", 1)
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
func mediaContentURL(cfg Config, mediaID string) string {
|
||||
return publicBaseURL(cfg) + "/v1/media/" + url.PathEscape(strings.TrimSpace(mediaID)) + "/content"
|
||||
}
|
||||
|
||||
func persistMediaContent(cfg Config, userID, projectID, artifactID, filename, contentType, visibility string, content []byte, mediaID string) (mediaRecord, error) {
|
||||
if len(content) == 0 {
|
||||
return mediaRecord{}, fmt.Errorf("media content is required")
|
||||
}
|
||||
filename = sanitizeFilename(strings.TrimSpace(filename))
|
||||
if filename == "" || filename == "artifact" {
|
||||
filename = "artifact" + extensionFromContentType(contentType)
|
||||
}
|
||||
contentType = strings.TrimSpace(contentType)
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
visibility = strings.TrimSpace(visibility)
|
||||
if visibility == "" {
|
||||
visibility = "unlisted"
|
||||
}
|
||||
mediaID = strings.TrimSpace(mediaID)
|
||||
if mediaID == "" {
|
||||
mediaID = "med_" + randomID()
|
||||
}
|
||||
if err := os.MkdirAll(mediaBlobDir(cfg), 0o755); err != nil {
|
||||
return mediaRecord{}, err
|
||||
}
|
||||
if err := os.MkdirAll(mediaMetaDir(cfg), 0o755); err != nil {
|
||||
return mediaRecord{}, err
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(content)
|
||||
blobPath := mediaBlobPath(cfg, mediaID, filename)
|
||||
if err := os.WriteFile(blobPath, content, 0o644); err != nil {
|
||||
return mediaRecord{}, err
|
||||
}
|
||||
|
||||
record := mediaRecord{
|
||||
media: media{
|
||||
ID: mediaID,
|
||||
ArtifactID: strings.TrimSpace(artifactID),
|
||||
ProjectID: strings.TrimSpace(projectID),
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(content)),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
URL: mediaContentURL(cfg, mediaID),
|
||||
Visibility: visibility,
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
},
|
||||
UserID: strings.TrimSpace(userID),
|
||||
LocalPath: blobPath,
|
||||
}
|
||||
|
||||
metaBytes, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return mediaRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(mediaMetaPath(cfg, mediaID), metaBytes, 0o644); err != nil {
|
||||
return mediaRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func loadMediaRecord(cfg Config, mediaID string) (*mediaRecord, bool, error) {
|
||||
mediaID = strings.TrimSpace(mediaID)
|
||||
if mediaID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
data, err := os.ReadFile(mediaMetaPath(cfg, mediaID))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var record mediaRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &record, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) persistResultRefs(ctx context.Context, record *job, refs []resultRef) ([]resultRef, error) {
|
||||
if record == nil || len(refs) == 0 {
|
||||
return refs, nil
|
||||
}
|
||||
items := make([]resultRef, 0, len(refs))
|
||||
for idx, ref := range refs {
|
||||
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, readErr := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
filename := strings.TrimSpace(ref.Filename)
|
||||
if filename == "" {
|
||||
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx))
|
||||
}
|
||||
artifactID := buildArtifactID(record.JobID, idx)
|
||||
mediaRecord, err := persistMediaContent(
|
||||
s.cfg,
|
||||
record.UserID,
|
||||
record.ProjectID,
|
||||
artifactID,
|
||||
filename,
|
||||
defaultString(strings.TrimSpace(contentType), strings.TrimSpace(ref.ContentType)),
|
||||
defaultString(strings.TrimSpace(ref.Visibility), "unlisted"),
|
||||
content,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, resultRef{
|
||||
Kind: "local_path",
|
||||
URL: mediaRecord.URL,
|
||||
LocalPath: mediaRecord.LocalPath,
|
||||
MediaID: mediaRecord.ID,
|
||||
Filename: mediaRecord.Filename,
|
||||
ContentType: mediaRecord.ContentType,
|
||||
SizeBytes: mediaRecord.SizeBytes,
|
||||
Visibility: mediaRecord.Visibility,
|
||||
SHA256: mediaRecord.SHA256,
|
||||
StorageStatus: "ready",
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaUploadGetAndContent(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-media-user")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected session creation to succeed")
|
||||
}
|
||||
|
||||
imageBytes := tinyPNG(t)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("project_id", "proj_media_demo"); err != nil {
|
||||
t.Fatalf("write project_id field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("visibility", "public"); err != nil {
|
||||
t.Fatalf("write visibility field: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", "poster.png")
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(imageBytes); 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/media/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 status 201, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data media `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode upload response: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !envelope.OK {
|
||||
t.Fatalf("expected ok upload response, got %s", rec.Body.String())
|
||||
}
|
||||
if envelope.Data.ID == "" {
|
||||
t.Fatalf("expected media id, got %#v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.URL == "" {
|
||||
t.Fatalf("expected stable url, got %#v", envelope.Data)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil)
|
||||
getReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
||||
getRec := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected media get 200, got %d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
contentResp, err := http.Get(envelope.Data.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET media content: %v", err)
|
||||
}
|
||||
defer contentResp.Body.Close()
|
||||
if contentResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected media content 200, got %d", contentResp.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(contentResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read content body: %v", err)
|
||||
}
|
||||
if !bytes.Equal(content, imageBytes) {
|
||||
t.Fatal("expected media content bytes to match uploaded file")
|
||||
}
|
||||
}
|
||||
+105
-2
@@ -30,6 +30,7 @@ type Config struct {
|
||||
NewAPIToken string
|
||||
DefaultImageModel string
|
||||
DefaultVideoModel string
|
||||
PublicBaseURL string
|
||||
DataDir string
|
||||
SQLitePath string
|
||||
SessionSecret string
|
||||
@@ -42,11 +43,16 @@ func ConfigFromEnv() Config {
|
||||
NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")),
|
||||
DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")),
|
||||
DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_MODEL")),
|
||||
PublicBaseURL: strings.TrimSpace(os.Getenv("POPIART_PUBLIC_BASE_URL")),
|
||||
DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")),
|
||||
SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")),
|
||||
SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")),
|
||||
SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")),
|
||||
}
|
||||
return normalizeConfig(cfg)
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
if cfg.NewAPIBaseURL == "" {
|
||||
cfg.NewAPIBaseURL = "http://127.0.0.1:3000"
|
||||
}
|
||||
@@ -57,7 +63,11 @@ func ConfigFromEnv() Config {
|
||||
cfg.DefaultVideoModel = "viduq2"
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = "./data"
|
||||
if strings.TrimSpace(cfg.SQLitePath) != "" {
|
||||
cfg.DataDir = filepath.Dir(cfg.SQLitePath)
|
||||
} else {
|
||||
cfg.DataDir = "./data"
|
||||
}
|
||||
}
|
||||
if cfg.SQLitePath == "" {
|
||||
cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db")
|
||||
@@ -131,6 +141,7 @@ type imageEditReference struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Content []byte
|
||||
URL string
|
||||
}
|
||||
|
||||
type openAIVideoResponse struct {
|
||||
@@ -587,7 +598,7 @@ func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, model
|
||||
"generationConfig": generationConfig,
|
||||
}
|
||||
if len(imageConfig) > 0 {
|
||||
payload["imageConfig"] = imageConfig
|
||||
generationConfig["imageConfig"] = imageConfig
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
@@ -629,6 +640,9 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
|
||||
if prompt == "" {
|
||||
prompt = "Generate a short polished image-to-video clip from the provided reference image."
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "vidu") && strings.TrimSpace(ref.URL) != "" {
|
||||
return c.submitImageToVideoTaskByURL(ctx, token, modelID, input, ref, prompt)
|
||||
}
|
||||
if len(ref.Content) == 0 {
|
||||
return "", errors.New("reference image content is required")
|
||||
}
|
||||
@@ -713,6 +727,71 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference, prompt string) (string, error) {
|
||||
payload := map[string]any{
|
||||
"model": modelID,
|
||||
"prompt": prompt,
|
||||
"images": []string{strings.TrimSpace(ref.URL)},
|
||||
"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, ref); size != "" {
|
||||
payload["size"] = size
|
||||
}
|
||||
if aspectRatio := resolveVideoAspectRatio(input, ref); aspectRatio != "" {
|
||||
payload["metadata"] = map[string]any{
|
||||
"aspect_ratio": aspectRatio,
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", 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
|
||||
}
|
||||
|
||||
var decoded openAIVideoResponse
|
||||
if err := json.Unmarshal(respBody, &decoded); err != nil {
|
||||
return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", decodeTaskAPIError(respBody, resp.StatusCode)
|
||||
}
|
||||
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
||||
return "", errors.New(decoded.Error.Message)
|
||||
}
|
||||
|
||||
taskID := strings.TrimSpace(decoded.ID)
|
||||
if taskID == "" {
|
||||
taskID = strings.TrimSpace(decoded.TaskID)
|
||||
}
|
||||
if taskID == "" {
|
||||
return "", errors.New("PopiNewAPI returned no task id")
|
||||
}
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -997,6 +1076,12 @@ func resolveGeminiImageSize(input map[string]any) string {
|
||||
|
||||
func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) {
|
||||
switch ref.Kind {
|
||||
case "local_path":
|
||||
file, err := os.Open(ref.LocalPath)
|
||||
if err != nil {
|
||||
return "", 0, nil, err
|
||||
}
|
||||
return defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream"), ref.SizeBytes, file, nil
|
||||
case "data_url":
|
||||
contentType, content, err := decodeDataURL(ref.DataURL)
|
||||
if err != nil {
|
||||
@@ -1137,6 +1222,24 @@ func resolveVideoSize(modelID string, input map[string]any, ref imageEditReferen
|
||||
return size
|
||||
}
|
||||
|
||||
func resolveVideoAspectRatio(input map[string]any, ref imageEditReference) string {
|
||||
if input != nil {
|
||||
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
||||
return aspectRatio
|
||||
}
|
||||
}
|
||||
if len(ref.Content) > 0 {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content))
|
||||
if err == nil && cfg.Width > 0 && cfg.Height > 0 {
|
||||
if cfg.Width >= cfg.Height {
|
||||
return "16:9"
|
||||
}
|
||||
return "9:16"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string {
|
||||
if input != nil {
|
||||
if size := strings.TrimSpace(stringValue(input["size"])); size != "" {
|
||||
|
||||
+205
-5
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -31,6 +32,7 @@ func New() *Server {
|
||||
}
|
||||
|
||||
func NewWithConfig(cfg Config) (*Server, error) {
|
||||
cfg = normalizeConfig(cfg)
|
||||
store, err := newStore(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,6 +61,8 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/v1/skills/", s.handleSkill)
|
||||
s.mux.HandleFunc("/v1/jobs", s.handleJobs)
|
||||
s.mux.HandleFunc("/v1/jobs/", s.handleJob)
|
||||
s.mux.HandleFunc("/v1/media/upload", s.handleMediaUpload)
|
||||
s.mux.HandleFunc("/v1/media/", s.handleMedia)
|
||||
s.mux.HandleFunc("/v1/artifacts/upload", s.handleArtifactUpload)
|
||||
s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact)
|
||||
s.mux.HandleFunc("/v1/budget", s.handleBudget)
|
||||
@@ -468,6 +472,7 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
||||
filename = "artifact" + extensionFromContentType(contentType)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
visibility := strings.TrimSpace(r.FormValue("visibility"))
|
||||
|
||||
input := map[string]any{
|
||||
"filename": filename,
|
||||
@@ -499,12 +504,33 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
refMedia, err := persistMediaContent(
|
||||
s.cfg,
|
||||
current.User.ID,
|
||||
projectID,
|
||||
buildArtifactID(record.JobID, 0),
|
||||
filename,
|
||||
contentType,
|
||||
visibility,
|
||||
content,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to persist upload media", err)
|
||||
return
|
||||
}
|
||||
|
||||
ref := resultRef{
|
||||
Kind: "data_url",
|
||||
DataURL: buildDataURL(contentType, content),
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(content)),
|
||||
Kind: "local_path",
|
||||
URL: refMedia.URL,
|
||||
LocalPath: refMedia.LocalPath,
|
||||
MediaID: refMedia.ID,
|
||||
Filename: refMedia.Filename,
|
||||
ContentType: refMedia.ContentType,
|
||||
SizeBytes: refMedia.SizeBytes,
|
||||
Visibility: refMedia.Visibility,
|
||||
SHA256: refMedia.SHA256,
|
||||
StorageStatus: "ready",
|
||||
}
|
||||
if err := s.store.completeJobWithResults(record.JobID, []resultRef{ref}, nil); err != nil {
|
||||
writeInternalError(w, "failed to persist upload artifact", err)
|
||||
@@ -523,6 +549,140 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeData(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
current, ok := s.authenticateSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "failed to parse multipart form", map[string]any{
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "file is required", nil)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read upload file", map[string]any{
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(content) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "uploaded file is empty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
metadataJSON := strings.TrimSpace(r.FormValue("metadata_json"))
|
||||
if metadataJSON != "" {
|
||||
var metadata any
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "metadata_json must be valid JSON", map[string]any{
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(r.FormValue("filename"))
|
||||
if filename == "" {
|
||||
filename = strings.TrimSpace(header.Filename)
|
||||
}
|
||||
contentType := strings.TrimSpace(r.FormValue("content_type"))
|
||||
if contentType == "" {
|
||||
contentType = strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||
}
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
contentType = http.DetectContentType(content)
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "media" + extensionFromContentType(contentType)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
record, err := persistMediaContent(
|
||||
s.cfg,
|
||||
current.User.ID,
|
||||
strings.TrimSpace(r.FormValue("project_id")),
|
||||
"",
|
||||
filename,
|
||||
contentType,
|
||||
strings.TrimSpace(r.FormValue("visibility")),
|
||||
content,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to persist media", err)
|
||||
return
|
||||
}
|
||||
writeData(w, http.StatusCreated, record.media)
|
||||
}
|
||||
|
||||
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1/media/")
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
record, exists, err := loadMediaRecord(s.cfg, parts[0])
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to load media", err)
|
||||
return
|
||||
}
|
||||
if !exists || record == nil {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 2 && parts[1] == "content" && r.Method == http.MethodGet {
|
||||
file, err := os.Open(record.LocalPath)
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to read media content", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w.Header().Set("Content-Type", defaultString(record.ContentType, "application/octet-stream"))
|
||||
if record.SizeBytes > 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(record.SizeBytes, 10))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
log.Printf("popiartServer: streaming media %s failed: %v", record.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
current, ok := s.authenticateSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if current.User.ID != record.UserID {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
||||
writeData(w, http.StatusOK, record.media)
|
||||
return
|
||||
}
|
||||
|
||||
notFound(w)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
current, ok := s.authenticateSession(w, r)
|
||||
if !ok {
|
||||
@@ -958,6 +1118,17 @@ func (s *Server) executeTextToImageJob(record *job) {
|
||||
return
|
||||
}
|
||||
|
||||
refs, err = s.persistResultRefs(ctx, record, refs)
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
||||
"details": err.Error(),
|
||||
"model_id": modelID,
|
||||
}); repoErr != nil {
|
||||
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
||||
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
||||
@@ -1010,6 +1181,18 @@ func (s *Server) executeImageToImageJob(record *job) {
|
||||
return
|
||||
}
|
||||
|
||||
refs, err = s.persistResultRefs(ctx, record, refs)
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
||||
"details": err.Error(),
|
||||
"model_id": modelID,
|
||||
"route_key": record.RouteKey,
|
||||
}); repoErr != nil {
|
||||
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
||||
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
||||
@@ -1097,6 +1280,21 @@ func (s *Server) executeImageToVideoJob(record *job) {
|
||||
usage["format"] = taskResult.Format
|
||||
}
|
||||
|
||||
persistCtx, cancelPersist := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancelPersist()
|
||||
refs, err = s.persistResultRefs(persistCtx, record, refs)
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
||||
"details": err.Error(),
|
||||
"model_id": modelID,
|
||||
"route_key": record.RouteKey,
|
||||
"newapi_task_id": upstreamTaskID,
|
||||
}); repoErr != nil {
|
||||
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
||||
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
||||
@@ -1168,6 +1366,7 @@ func (s *Server) resolveImageToImageReference(ctx context.Context, record *job,
|
||||
Filename: item.Filename,
|
||||
ContentType: defaultString(contentType, item.ContentType),
|
||||
Content: content,
|
||||
URL: item.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1237,6 +1436,7 @@ func (s *Server) downloadReferenceImage(ctx context.Context, rawURL string) (ima
|
||||
Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)),
|
||||
ContentType: contentType,
|
||||
Content: content,
|
||||
URL: strings.TrimSpace(rawURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -360,15 +360,31 @@ func buildArtifacts(record *job) []artifact {
|
||||
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx))
|
||||
}
|
||||
contentType := defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream")
|
||||
storageStatus := strings.TrimSpace(ref.StorageStatus)
|
||||
if storageStatus == "" {
|
||||
switch {
|
||||
case ref.LocalPath != "":
|
||||
storageStatus = "ready"
|
||||
case ref.URL != "":
|
||||
storageStatus = "upstream"
|
||||
case ref.DataURL != "":
|
||||
storageStatus = "embedded"
|
||||
}
|
||||
}
|
||||
items = append(items, artifact{
|
||||
ID: buildArtifactID(record.JobID, idx),
|
||||
JobID: record.JobID,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: ref.SizeBytes,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Ref: ref,
|
||||
ID: buildArtifactID(record.JobID, idx),
|
||||
JobID: record.JobID,
|
||||
MediaID: strings.TrimSpace(ref.MediaID),
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: ref.SizeBytes,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
URL: strings.TrimSpace(ref.URL),
|
||||
Visibility: strings.TrimSpace(ref.Visibility),
|
||||
SHA256: strings.TrimSpace(ref.SHA256),
|
||||
StorageStatus: storageStatus,
|
||||
Ref: ref,
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
+37
-14
@@ -46,12 +46,17 @@ type jobError struct {
|
||||
}
|
||||
|
||||
type resultRef struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url,omitempty"`
|
||||
DataURL string `json:"data_url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url,omitempty"`
|
||||
DataURL string `json:"data_url,omitempty"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
StorageStatus string `json:"storage_status,omitempty"`
|
||||
}
|
||||
|
||||
type job struct {
|
||||
@@ -85,14 +90,32 @@ type logEntry struct {
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Ref resultRef `json:"-"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
StorageStatus string `json:"storage_status,omitempty"`
|
||||
Ref resultRef `json:"-"`
|
||||
}
|
||||
|
||||
type media struct {
|
||||
ID string `json:"id"`
|
||||
ArtifactID string `json:"artifact_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
URL string `json:"url"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
type project struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -85,6 +86,58 @@ func TestSubmitImageToVideoTaskUsesMultipartInputReference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitImageToVideoTaskUsesURLImagesForViduModels(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/videos" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
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_vidu_url_123","status":"queued"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL})
|
||||
taskID, err := client.submitImageToVideoTask(context.Background(), "sk-test", "viduq2-pro-fast", map[string]any{
|
||||
"prompt": "animate this still gently",
|
||||
"duration_s": 5,
|
||||
"aspect_ratio": "9:16",
|
||||
}, imageEditReference{
|
||||
Filename: "reference.png",
|
||||
ContentType: "image/png",
|
||||
URL: "https://media.popi.test/m/demo/reference.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submitImageToVideoTask: %v", err)
|
||||
}
|
||||
|
||||
if taskID != "task_vidu_url_123" {
|
||||
t.Fatalf("expected task id task_vidu_url_123, got %q", taskID)
|
||||
}
|
||||
if gotBody["model"] != "viduq2-pro-fast" {
|
||||
t.Fatalf("unexpected model: %#v", gotBody["model"])
|
||||
}
|
||||
images, ok := gotBody["images"].([]any)
|
||||
if !ok || len(images) != 1 || images[0] != "https://media.popi.test/m/demo/reference.png" {
|
||||
t.Fatalf("expected one image url, got %#v", gotBody["images"])
|
||||
}
|
||||
if gotBody["duration"] != float64(5) {
|
||||
t.Fatalf("unexpected duration: %#v", gotBody["duration"])
|
||||
}
|
||||
metadata, ok := gotBody["metadata"].(map[string]any)
|
||||
if !ok || metadata["aspect_ratio"] != "9:16" {
|
||||
t.Fatalf("unexpected metadata: %#v", gotBody["metadata"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
@@ -176,6 +229,7 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithConfig: %v", err)
|
||||
}
|
||||
server.cfg.PublicBaseURL = "http://127.0.0.1:8080"
|
||||
|
||||
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
||||
if err != nil {
|
||||
@@ -241,6 +295,15 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
||||
if item.ContentType != "video/mp4" {
|
||||
t.Fatalf("expected video/mp4 artifact, got %q", item.ContentType)
|
||||
}
|
||||
if item.MediaID == "" {
|
||||
t.Fatalf("expected media id on persisted artifact, got %#v", item)
|
||||
}
|
||||
if item.URL == "" {
|
||||
t.Fatalf("expected stable url on persisted artifact, got %#v", item)
|
||||
}
|
||||
if item.StorageStatus != "ready" {
|
||||
t.Fatalf("expected ready storage status, got %#v", item.StorageStatus)
|
||||
}
|
||||
if !strings.HasSuffix(item.Filename, ".mp4") {
|
||||
t.Fatalf("expected .mp4 filename, got %q", item.Filename)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user