Browse Source

Add stable media URLs for artifacts and uploads

codex/stable-media-url-v1
jiajia 3 months ago
parent
commit
6d14e54e36
  1. 4
      README.md
  2. 33
      docs/persistence.md
  3. 29
      internal/server/artifact_upload_test.go
  4. 193
      internal/server/media.go
  5. 106
      internal/server/media_test.go
  6. 107
      internal/server/newapi.go
  7. 210
      internal/server/server.go
  8. 32
      internal/server/store.go
  9. 51
      internal/server/types.go
  10. 63
      internal/server/video_test.go

4
README.md

@ -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.

33
docs/persistence.md

@ -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`

29
internal/server/artifact_upload_test.go

@ -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,

193
internal/server/media.go

@ -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
}

106
internal/server/media_test.go

@ -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")
}
}

107
internal/server/newapi.go

@ -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 != "" {

210
internal/server/server.go

@ -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
}

32
internal/server/store.go

@ -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

51
internal/server/types.go

@ -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 {

63
internal/server/video_test.go

@ -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)
}

Loading…
Cancel
Save