add stable media support and sync skillhub UI
This commit is contained in:
+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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user