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`
|
- `run`
|
||||||
- `jobs`
|
- `jobs`
|
||||||
- `artifacts`
|
- `artifacts`
|
||||||
|
- `media`
|
||||||
- basic `budget`, `project`, and `models` stubs
|
- basic `budget`, `project`, and `models` stubs
|
||||||
|
|
||||||
## Project Relationship
|
## Project Relationship
|
||||||
@@ -61,7 +62,8 @@ In local development, `/tmp/Popiart_skillhub` is used automatically when it exis
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Local persistence is intentionally thin: `sessions`, `job refs`, and project route overrides live in SQLite.
|
- 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.
|
- `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`.
|
- The local development backend verifies your login key against the local `PopiNewAPI`.
|
||||||
- This server is a development backend, not the final production architecture.
|
- This server is a development backend, not the final production architecture.
|
||||||
|
|||||||
+24
-9
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
所以 `popiartServer` 不再重复持有:
|
所以 `popiartServer` 不再重复持有:
|
||||||
|
|
||||||
- 本地 artifact blob store
|
|
||||||
- 本地 artifact metadata table
|
- 本地 artifact metadata table
|
||||||
- 本地 job_logs table
|
- 本地 job_logs table
|
||||||
|
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
- `sessions`
|
- `sessions`
|
||||||
- `jobs`(更准确说是 job refs)
|
- `jobs`(更准确说是 job refs)
|
||||||
- `skill_routes`
|
- `skill_routes`
|
||||||
|
- `media blobs + metadata`
|
||||||
|
|
||||||
## 当前关系
|
## 当前关系
|
||||||
|
|
||||||
@@ -30,14 +30,16 @@
|
|||||||
session -> jobs
|
session -> jobs
|
||||||
jobs -> result_refs_json
|
jobs -> result_refs_json
|
||||||
project -> skill_routes
|
project -> skill_routes
|
||||||
|
media -> local files + metadata json
|
||||||
```
|
```
|
||||||
|
|
||||||
其中:
|
其中:
|
||||||
|
|
||||||
- `session` 保存 PopiArt 登录态和对应的 `PopiNewAPI token`
|
- `session` 保存 PopiArt 登录态和对应的 `PopiNewAPI token`
|
||||||
- `jobs` 保存 skill 语义、用户归属、上游引用和同步结果引用
|
- `jobs` 保存 skill 语义、用户归属、上游引用和同步结果引用
|
||||||
- `result_refs_json` 保存同步能力的返回引用,例如 `data_url` 或远端 `url`
|
- `result_refs_json` 保存同步能力的结果引用;新结果会优先 re-host 到本地 media store
|
||||||
- `skill_routes` 保存项目级路由覆盖
|
- `skill_routes` 保存项目级路由覆盖
|
||||||
|
- `media` 保存稳定 URL 所需的本地文件与元数据
|
||||||
|
|
||||||
## 存储位置
|
## 存储位置
|
||||||
|
|
||||||
@@ -66,7 +68,10 @@ POPIART_SQLITE_PATH=./data/popiart.db
|
|||||||
2. `JobRepository`
|
2. `JobRepository`
|
||||||
3. `RouteRepository`
|
3. `RouteRepository`
|
||||||
|
|
||||||
没有单独的 blob storage 抽象。
|
本地开发版没有对象存储依赖,但现在有一个轻量 media 存储层:
|
||||||
|
|
||||||
|
- blob 文件保存在 `POPIART_DATA_DIR/media/blobs/`
|
||||||
|
- metadata JSON 保存在 `POPIART_DATA_DIR/media/meta/`
|
||||||
|
|
||||||
## SQLite Schema
|
## SQLite Schema
|
||||||
|
|
||||||
@@ -141,15 +146,24 @@ CREATE TABLE skill_routes (
|
|||||||
### 完成同步结果 job
|
### 完成同步结果 job
|
||||||
|
|
||||||
1. 如果 `PopiNewAPI` 直接返回同步结果,例如 `b64_json` 或 `url`
|
1. 如果 `PopiNewAPI` 直接返回同步结果,例如 `b64_json` 或 `url`
|
||||||
2. `popiartServer` 只把它转换成 `result_refs_json`
|
2. `popiartServer` 会先把结果 re-host 到本地 media store
|
||||||
3. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表
|
3. 再把本地 `local_path + media_id + stable url` 写进 `result_refs_json`
|
||||||
|
4. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表
|
||||||
|
|
||||||
### 拉取 artifact
|
### 拉取 artifact
|
||||||
|
|
||||||
1. server 从 `artifact_id` 反推出 `job_id + result index`
|
1. server 从 `artifact_id` 反推出 `job_id + result index`
|
||||||
2. 读取 `jobs.result_refs_json`
|
2. 读取 `jobs.result_refs_json`
|
||||||
3. 如果是 `data_url`,直接解码并流式返回
|
3. 如果是本地 `local_path`,直接读取本地文件并流式返回
|
||||||
4. 如果是远端 `url`,server 代理下载并返回
|
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` 会保留
|
- `session` 会保留
|
||||||
- `job ref` 会保留
|
- `job ref` 会保留
|
||||||
- `artifact` 通过 `result_refs_json` 继续可读
|
- `artifact` 通过 `result_refs_json` 继续可读
|
||||||
|
- 新 `media` 文件和 metadata 会继续可读
|
||||||
- 已存在的 `pending/running` job 仍然不会自动恢复执行
|
- 已存在的 `pending/running` job 仍然不会自动恢复执行
|
||||||
- 视频类 `upstream_task` 路径只预留了字段,后续再接 `PopiNewAPI` task 查询
|
- 视频类 `upstream_task` 路径只预留了字段,后续再接 `PopiNewAPI` task 查询
|
||||||
- 对同步图像结果来说,`data_url` 仍然会落在 SQLite 的 `result_refs_json`
|
- 旧数据里仍可能存在 `data_url` 或上游 `url`
|
||||||
这是当前 `PopiNewAPI` 没有统一 file id 的现实折中,但已经不再有本地 artifact 表和 blob store
|
- 新写入路径优先落本地 media store,从而给 artifact 补出稳定 `url`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -21,6 +22,9 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewWithConfig: %v", err)
|
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")
|
sessionToken, _, ok, err := server.store.createSession("sk-upload-user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,9 +84,34 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T)
|
|||||||
if envelope.Data.ID == "" {
|
if envelope.Data.ID == "" {
|
||||||
t.Fatalf("expected artifact id, got %#v", envelope.Data)
|
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" {
|
if envelope.Data.ContentType != "image/png" {
|
||||||
t.Fatalf("expected image/png, got %q", envelope.Data.ContentType)
|
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{
|
editRef, err := server.resolveImageToImageReference(context.Background(), &job{
|
||||||
UserID: current.User.ID,
|
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
|
NewAPIToken string
|
||||||
DefaultImageModel string
|
DefaultImageModel string
|
||||||
DefaultVideoModel string
|
DefaultVideoModel string
|
||||||
|
PublicBaseURL string
|
||||||
DataDir string
|
DataDir string
|
||||||
SQLitePath string
|
SQLitePath string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
@@ -42,11 +43,16 @@ func ConfigFromEnv() Config {
|
|||||||
NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")),
|
NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")),
|
||||||
DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")),
|
DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")),
|
||||||
DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_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")),
|
DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")),
|
||||||
SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")),
|
SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")),
|
||||||
SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")),
|
SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")),
|
||||||
SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")),
|
SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")),
|
||||||
}
|
}
|
||||||
|
return normalizeConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeConfig(cfg Config) Config {
|
||||||
if cfg.NewAPIBaseURL == "" {
|
if cfg.NewAPIBaseURL == "" {
|
||||||
cfg.NewAPIBaseURL = "http://127.0.0.1:3000"
|
cfg.NewAPIBaseURL = "http://127.0.0.1:3000"
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,11 @@ func ConfigFromEnv() Config {
|
|||||||
cfg.DefaultVideoModel = "viduq2"
|
cfg.DefaultVideoModel = "viduq2"
|
||||||
}
|
}
|
||||||
if cfg.DataDir == "" {
|
if cfg.DataDir == "" {
|
||||||
cfg.DataDir = "./data"
|
if strings.TrimSpace(cfg.SQLitePath) != "" {
|
||||||
|
cfg.DataDir = filepath.Dir(cfg.SQLitePath)
|
||||||
|
} else {
|
||||||
|
cfg.DataDir = "./data"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.SQLitePath == "" {
|
if cfg.SQLitePath == "" {
|
||||||
cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db")
|
cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db")
|
||||||
@@ -131,6 +141,7 @@ type imageEditReference struct {
|
|||||||
Filename string
|
Filename string
|
||||||
ContentType string
|
ContentType string
|
||||||
Content []byte
|
Content []byte
|
||||||
|
URL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIVideoResponse struct {
|
type openAIVideoResponse struct {
|
||||||
@@ -587,7 +598,7 @@ func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, model
|
|||||||
"generationConfig": generationConfig,
|
"generationConfig": generationConfig,
|
||||||
}
|
}
|
||||||
if len(imageConfig) > 0 {
|
if len(imageConfig) > 0 {
|
||||||
payload["imageConfig"] = imageConfig
|
generationConfig["imageConfig"] = imageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
@@ -629,6 +640,9 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
|
|||||||
if prompt == "" {
|
if prompt == "" {
|
||||||
prompt = "Generate a short polished image-to-video clip from the provided reference image."
|
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 {
|
if len(ref.Content) == 0 {
|
||||||
return "", errors.New("reference image content is required")
|
return "", errors.New("reference image content is required")
|
||||||
}
|
}
|
||||||
@@ -713,6 +727,71 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
|
|||||||
return taskID, nil
|
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) {
|
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")
|
||||||
@@ -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) {
|
func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) {
|
||||||
switch ref.Kind {
|
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":
|
case "data_url":
|
||||||
contentType, content, err := decodeDataURL(ref.DataURL)
|
contentType, content, err := decodeDataURL(ref.DataURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1137,6 +1222,24 @@ func resolveVideoSize(modelID string, input map[string]any, ref imageEditReferen
|
|||||||
return size
|
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 {
|
func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string {
|
||||||
if input != nil {
|
if input != nil {
|
||||||
if size := strings.TrimSpace(stringValue(input["size"])); size != "" {
|
if size := strings.TrimSpace(stringValue(input["size"])); size != "" {
|
||||||
|
|||||||
+205
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,6 +32,7 @@ func New() *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewWithConfig(cfg Config) (*Server, error) {
|
func NewWithConfig(cfg Config) (*Server, error) {
|
||||||
|
cfg = normalizeConfig(cfg)
|
||||||
store, err := newStore(cfg)
|
store, err := newStore(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -59,6 +61,8 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/v1/skills/", s.handleSkill)
|
s.mux.HandleFunc("/v1/skills/", s.handleSkill)
|
||||||
s.mux.HandleFunc("/v1/jobs", s.handleJobs)
|
s.mux.HandleFunc("/v1/jobs", s.handleJobs)
|
||||||
s.mux.HandleFunc("/v1/jobs/", s.handleJob)
|
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/upload", s.handleArtifactUpload)
|
||||||
s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact)
|
s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact)
|
||||||
s.mux.HandleFunc("/v1/budget", s.handleBudget)
|
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 = "artifact" + extensionFromContentType(contentType)
|
||||||
}
|
}
|
||||||
filename = sanitizeFilename(filename)
|
filename = sanitizeFilename(filename)
|
||||||
|
visibility := strings.TrimSpace(r.FormValue("visibility"))
|
||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -499,12 +504,33 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
ref := resultRef{
|
||||||
Kind: "data_url",
|
Kind: "local_path",
|
||||||
DataURL: buildDataURL(contentType, content),
|
URL: refMedia.URL,
|
||||||
Filename: filename,
|
LocalPath: refMedia.LocalPath,
|
||||||
ContentType: contentType,
|
MediaID: refMedia.ID,
|
||||||
SizeBytes: int64(len(content)),
|
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 {
|
if err := s.store.completeJobWithResults(record.JobID, []resultRef{ref}, nil); err != nil {
|
||||||
writeInternalError(w, "failed to persist upload artifact", err)
|
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)
|
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) {
|
func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
current, ok := s.authenticateSession(w, r)
|
current, ok := s.authenticateSession(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -958,6 +1118,17 @@ func (s *Server) executeTextToImageJob(record *job) {
|
|||||||
return
|
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 {
|
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
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{
|
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
|
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 {
|
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
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{
|
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
|
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 {
|
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
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{
|
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,
|
Filename: item.Filename,
|
||||||
ContentType: defaultString(contentType, item.ContentType),
|
ContentType: defaultString(contentType, item.ContentType),
|
||||||
Content: content,
|
Content: content,
|
||||||
|
URL: item.URL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1436,7 @@ func (s *Server) downloadReferenceImage(ctx context.Context, rawURL string) (ima
|
|||||||
Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)),
|
Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
URL: strings.TrimSpace(rawURL),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,15 +360,31 @@ func buildArtifacts(record *job) []artifact {
|
|||||||
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx))
|
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx))
|
||||||
}
|
}
|
||||||
contentType := defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream")
|
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{
|
items = append(items, artifact{
|
||||||
ID: buildArtifactID(record.JobID, idx),
|
ID: buildArtifactID(record.JobID, idx),
|
||||||
JobID: record.JobID,
|
JobID: record.JobID,
|
||||||
Filename: filename,
|
MediaID: strings.TrimSpace(ref.MediaID),
|
||||||
ContentType: contentType,
|
Filename: filename,
|
||||||
SizeBytes: ref.SizeBytes,
|
ContentType: contentType,
|
||||||
CreatedAt: createdAt,
|
SizeBytes: ref.SizeBytes,
|
||||||
ExpiresAt: expiresAt,
|
CreatedAt: createdAt,
|
||||||
Ref: ref,
|
ExpiresAt: expiresAt,
|
||||||
|
URL: strings.TrimSpace(ref.URL),
|
||||||
|
Visibility: strings.TrimSpace(ref.Visibility),
|
||||||
|
SHA256: strings.TrimSpace(ref.SHA256),
|
||||||
|
StorageStatus: storageStatus,
|
||||||
|
Ref: ref,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
|||||||
+37
-14
@@ -46,12 +46,17 @@ type jobError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resultRef struct {
|
type resultRef struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
DataURL string `json:"data_url,omitempty"`
|
DataURL string `json:"data_url,omitempty"`
|
||||||
Filename string `json:"filename,omitempty"`
|
LocalPath string `json:"local_path,omitempty"`
|
||||||
ContentType string `json:"content_type,omitempty"`
|
MediaID string `json:"media_id,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes,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 {
|
type job struct {
|
||||||
@@ -85,14 +90,32 @@ type logEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type artifact struct {
|
type artifact struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
Filename string `json:"filename"`
|
MediaID string `json:"media_id,omitempty"`
|
||||||
ContentType string `json:"content_type"`
|
Filename string `json:"filename"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
ContentType string `json:"content_type"`
|
||||||
CreatedAt string `json:"created_at"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Ref resultRef `json:"-"`
|
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 {
|
type project struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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) {
|
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 {
|
||||||
@@ -176,6 +229,7 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewWithConfig: %v", err)
|
t.Fatalf("NewWithConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
server.cfg.PublicBaseURL = "http://127.0.0.1:8080"
|
||||||
|
|
||||||
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,6 +295,15 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
|||||||
if item.ContentType != "video/mp4" {
|
if item.ContentType != "video/mp4" {
|
||||||
t.Fatalf("expected video/mp4 artifact, got %q", item.ContentType)
|
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") {
|
if !strings.HasSuffix(item.Filename, ".mp4") {
|
||||||
t.Fatalf("expected .mp4 filename, got %q", item.Filename)
|
t.Fatalf("expected .mp4 filename, got %q", item.Filename)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user