You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2860 lines
83 KiB
2860 lines
83 KiB
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Server struct {
|
|
store *store
|
|
mux *http.ServeMux
|
|
cfg Config
|
|
newapi *newAPIClient
|
|
}
|
|
|
|
func New() *Server {
|
|
svc, err := NewWithConfig(ConfigFromEnv())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
func NewWithConfig(cfg Config) (*Server, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
store, err := newStore(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Server{
|
|
store: store,
|
|
mux: http.NewServeMux(),
|
|
cfg: cfg,
|
|
newapi: newNewAPIClient(cfg),
|
|
}
|
|
s.routes()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.mux
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("/health", s.handleHealth)
|
|
s.mux.HandleFunc("/v1/auth/login", s.handleAuthLogin)
|
|
s.mux.HandleFunc("/v1/auth/me", s.handleAuthMe)
|
|
s.mux.HandleFunc("/v1/auth/logout", s.handleAuthLogout)
|
|
s.mux.HandleFunc("/v1/auth/token/rotate", s.handleAuthTokenRotate)
|
|
s.mux.HandleFunc("/v1/auth/gateway/bind", s.handleAuthGatewayBind)
|
|
s.mux.HandleFunc("/v1/skills", s.handleSkills)
|
|
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", s.handleArtifacts)
|
|
s.mux.HandleFunc("/v1/artifacts/upload", s.handleArtifactUpload)
|
|
s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact)
|
|
s.mux.HandleFunc("/v1/budget", s.handleBudget)
|
|
s.mux.HandleFunc("/v1/budget/usage", s.handleBudgetUsage)
|
|
s.mux.HandleFunc("/v1/budget/limits", s.handleBudgetLimits)
|
|
s.mux.HandleFunc("/v1/billing/catalog", s.handleBillingCatalog)
|
|
s.mux.HandleFunc("/v1/billing/subscription/plans", s.handleBillingSubscriptionPlans)
|
|
s.mux.HandleFunc("/v1/billing/points/packages", s.handleBillingPointPackages)
|
|
s.mux.HandleFunc("/v1/billing/subscription", s.handleBillingSubscription)
|
|
s.mux.HandleFunc("/v1/billing/credits", s.handleBillingCredits)
|
|
s.mux.HandleFunc("/v1/billing/invoices", s.handleBillingInvoices)
|
|
s.mux.HandleFunc("/v1/billing/checkout/subscription", s.handleBillingCheckoutSubscription)
|
|
s.mux.HandleFunc("/v1/billing/checkout/points", s.handleBillingCheckoutPoints)
|
|
s.mux.HandleFunc("/v1/billing/checkout/status", s.handleBillingCheckoutStatus)
|
|
s.mux.HandleFunc("/v1/projects", s.handleProjects)
|
|
s.mux.HandleFunc("/v1/projects/", s.handleProject)
|
|
s.mux.HandleFunc("/v1/models", s.handleModels)
|
|
s.mux.HandleFunc("/v1/models/routes", s.handleModelRoutes)
|
|
s.mux.HandleFunc("/v1/models/routes/overrides", s.handleModelRouteOverrides)
|
|
s.mux.HandleFunc("/v1/models/routes/overrides/unset", s.handleModelRouteOverrideUnset)
|
|
s.mux.HandleFunc("/v1/models/infer", s.handleModelsInfer)
|
|
s.mux.HandleFunc("/v1/video/generations", s.handleVideoGenerations)
|
|
s.mux.HandleFunc("/v1/video/generations/", s.handleVideoGeneration)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"service": "popiart-server-dev",
|
|
"version": "0.2.1",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
var req struct {
|
|
Key string `json:"key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.Key = strings.TrimSpace(req.Key)
|
|
if req.Key == "" {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key is required", nil)
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "PopiNewAPI base URL is not configured", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
if err := s.newapi.verifyKey(ctx, req.Key); err != nil {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
key, u, ok, err := s.store.createSession(req.Key)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create session", err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"key": key,
|
|
"token": key,
|
|
"user": u,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
gatewayAccessTokenMasked := ""
|
|
if strings.TrimSpace(current.GatewayAccessToken) != "" {
|
|
gatewayAccessTokenMasked = maskSecret(current.GatewayAccessToken)
|
|
}
|
|
writeData(w, http.StatusOK, authSessionView{
|
|
User: current.User,
|
|
SessionKey: current.Token,
|
|
UpstreamKeyMasked: maskSecret(current.UpstreamKey),
|
|
GatewayUserID: current.GatewayUserID,
|
|
GatewayAccessTokenMasked: gatewayAccessTokenMasked,
|
|
GatewayBound: current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
token, ok := bearerToken(r)
|
|
if ok {
|
|
_ = s.store.deleteSession(token)
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"logged_out": true})
|
|
}
|
|
|
|
func (s *Server) handleAuthTokenRotate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key", nil)
|
|
return
|
|
}
|
|
newToken, u, exists, err := s.store.rotateSession(token)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to rotate session", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"key": newToken,
|
|
"token": newToken,
|
|
"user": u,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthGatewayBind(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
var req struct {
|
|
GatewayUserID int `json:"gateway_user_id"`
|
|
GatewayAccessToken string `json:"gateway_access_token"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.GatewayUserID <= 0 || strings.TrimSpace(req.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "gateway_user_id and gateway_access_token are required", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
self, err := s.newapi.getGatewayUserSelf(ctx, req.GatewayUserID, req.GatewayAccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "GATEWAY_BIND_FAILED", "failed to validate gateway user binding", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if self.ID != req.GatewayUserID {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "gateway user id does not match gateway access token", nil)
|
|
return
|
|
}
|
|
if err := s.store.bindGatewaySession(current.Token, req.GatewayUserID, req.GatewayAccessToken); err != nil {
|
|
writeInternalError(w, "failed to persist gateway binding", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"gateway_user_id": self.ID,
|
|
"gateway_username": self.Username,
|
|
"gateway_display_name": self.DisplayName,
|
|
"gateway_email": self.Email,
|
|
"gateway_bound": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tag := r.URL.Query().Get("tag")
|
|
search := r.URL.Query().Get("search")
|
|
limit := intQuery(r, "limit", 50)
|
|
offset := intQuery(r, "offset", 0)
|
|
items, total := s.store.listSkills(tag, search, limit, offset)
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSkill(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/skills/")
|
|
if path == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if strings.HasSuffix(path, "/schema") {
|
|
id := strings.TrimSuffix(strings.TrimSuffix(path, "/schema"), "/")
|
|
item, exists := s.store.getSkill(id)
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"input_schema": item.InputSchema,
|
|
"output_schema": item.OutputSchema,
|
|
})
|
|
return
|
|
}
|
|
|
|
item, exists := s.store.getSkill(strings.Trim(path, "/"))
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var req struct {
|
|
SkillID string `json:"skill_id"`
|
|
Input map[string]any `json:"input"`
|
|
ProjectID string `json:"project_id"`
|
|
Priority string `json:"priority"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.SkillID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "skill_id is required", nil)
|
|
return
|
|
}
|
|
item, exists := s.store.getSkill(req.SkillID)
|
|
if !exists {
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "skill not found", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(item.RouteKey)
|
|
modelID := s.resolveModelID(routeKey, req.ProjectID)
|
|
record, statusCode, err := s.store.createJob(
|
|
req.SkillID,
|
|
routeKey,
|
|
modelID,
|
|
routeExecMode(routeKey),
|
|
req.Input,
|
|
req.ProjectID,
|
|
defaultString(req.Priority, "normal"),
|
|
req.IdempotencyKey,
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create job", err)
|
|
return
|
|
}
|
|
if statusCode == http.StatusAccepted {
|
|
go s.dispatchJob(record)
|
|
}
|
|
writeData(w, statusCode, map[string]any{
|
|
"job_id": record.JobID,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt,
|
|
})
|
|
case http.MethodGet:
|
|
status := r.URL.Query().Get("status")
|
|
skillID := r.URL.Query().Get("skill_id")
|
|
projectID := r.URL.Query().Get("project_id")
|
|
limit := intQuery(r, "limit", 20)
|
|
offset := intQuery(r, "offset", 0)
|
|
items, total, err := s.store.listJobs(current.User.ID, status, skillID, projectID, limit, offset)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list jobs", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleJob(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
jobID := parts[0]
|
|
|
|
if len(parts) == 1 && r.Method == http.MethodGet {
|
|
record, exists, err := s.store.getJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load job", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, record)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "cancel" && r.Method == http.MethodPost {
|
|
record, exists, conflict, err := s.store.cancelJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to cancel job", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if conflict {
|
|
writeError(w, http.StatusConflict, "CONFLICT", "job already in terminal state", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, record)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet {
|
|
logs, exists, err := s.store.jobLogs(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load job logs", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
flusher, _ := w.(http.Flusher)
|
|
for _, item := range logs {
|
|
fmt.Fprintf(w, "data: %s\n\n", mustJSON(item))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
time.Sleep(150 * time.Millisecond)
|
|
}
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, logs)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "artifacts" && r.Method == http.MethodGet {
|
|
items, exists, err := s.store.artifactsForJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list job artifacts", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": items})
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleArtifacts(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
|
|
jobID := strings.TrimSpace(r.URL.Query().Get("job_id"))
|
|
limit := intQuery(r, "limit", 20)
|
|
offset := intQuery(r, "offset", 0)
|
|
|
|
items, total, err := s.store.listArtifacts(current.User.ID, projectID, jobID, limit, offset)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list artifacts", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": artifactViews(s.cfg, items, time.Now().UTC()),
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 20<<20)
|
|
if err := r.ParseMultipartForm(20 << 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
|
|
}
|
|
|
|
var metadata any
|
|
metadataJSON := strings.TrimSpace(r.FormValue("metadata_json"))
|
|
if metadataJSON != "" {
|
|
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 = "artifact" + extensionFromContentType(contentType)
|
|
}
|
|
filename = sanitizeFilename(filename)
|
|
visibility := strings.TrimSpace(r.FormValue("visibility"))
|
|
|
|
input := map[string]any{
|
|
"filename": filename,
|
|
"content_type": contentType,
|
|
"size_bytes": len(content),
|
|
"role": strings.TrimSpace(r.FormValue("role")),
|
|
}
|
|
projectID := strings.TrimSpace(r.FormValue("project_id"))
|
|
if projectID != "" {
|
|
input["project_id"] = projectID
|
|
}
|
|
if metadata != nil {
|
|
input["metadata"] = metadata
|
|
}
|
|
|
|
record, _, err := s.store.createJob(
|
|
"popiskill-artifact-upload-local-v1",
|
|
"artifact.upload",
|
|
"",
|
|
"local_upload",
|
|
input,
|
|
projectID,
|
|
"normal",
|
|
"",
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create upload job", err)
|
|
return
|
|
}
|
|
|
|
refMedia, err := s.store.persistMediaContent(
|
|
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: "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)
|
|
return
|
|
}
|
|
|
|
item, _, exists, err := s.store.artifactRef(current.User.ID, buildArtifactID(record.JobID, 0))
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load uploaded artifact", err)
|
|
return
|
|
}
|
|
if !exists || item == nil {
|
|
writeInternalError(w, "uploaded artifact missing after persistence", errors.New("artifact not found"))
|
|
return
|
|
}
|
|
writeData(w, http.StatusCreated, artifactView(s.cfg, *item, time.Now().UTC()))
|
|
}
|
|
|
|
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 := s.store.persistMediaContent(
|
|
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, mediaView(s.cfg, record, time.Now().UTC()))
|
|
}
|
|
|
|
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 := s.store.getMedia(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 {
|
|
if !validateSignedMediaAccess(s.cfg, record.ID, r.URL.Query().Get("exp"), r.URL.Query().Get("sig"), time.Now().UTC()) {
|
|
current, ok, err := s.sessionFromRequest(r)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load session", err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key or signed media url", nil)
|
|
return
|
|
}
|
|
if current.User.ID != record.UserID {
|
|
notFound(w)
|
|
return
|
|
}
|
|
}
|
|
|
|
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, mediaView(s.cfg, *record, time.Now().UTC()))
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/artifacts/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
artifactID := parts[0]
|
|
item, ref, exists, err := s.store.artifactRef(current.User.ID, artifactID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load artifact", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 1 && r.Method == http.MethodGet {
|
|
writeData(w, http.StatusOK, artifactView(s.cfg, *item, time.Now().UTC()))
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "content" && r.Method == http.MethodGet {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
contentType, sizeBytes, reader, err := s.newapi.openResultRef(ctx, current.UpstreamKey, ref)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to read artifact content", err)
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
w.Header().Set("Content-Type", defaultString(contentType, item.ContentType))
|
|
if sizeBytes > 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(sizeBytes, 10))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := io.Copy(w, reader); err != nil {
|
|
log.Printf("popiartServer: streaming artifact %s failed: %v", artifactID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleBudget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"period": map[string]any{
|
|
"start": time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339),
|
|
"end": time.Now().UTC().Format(time.RFC3339),
|
|
},
|
|
"used": map[string]any{
|
|
"tokens": 12000,
|
|
"cost_usd": 4.62,
|
|
},
|
|
"limit": map[string]any{
|
|
"monthly_tokens": 1000000,
|
|
"monthly_cost_usd": 200,
|
|
},
|
|
"remaining": map[string]any{
|
|
"tokens": 988000,
|
|
"cost_usd": 195.38,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBudgetUsage(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"rows": []map[string]any{
|
|
{"dimension": "popiskill-image-text2image-basic-v1", "tokens_used": 3000, "cost_usd": 1.2, "job_count": 3},
|
|
{"dimension": "popiskill-video-image2video-basic-v1", "tokens_used": 9000, "cost_usd": 3.42, "job_count": 2},
|
|
},
|
|
"total": map[string]any{"tokens_used": 12000, "cost_usd": 4.62, "job_count": 5},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBudgetLimits(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"rate_limits": []map[string]any{
|
|
{"window": "1m", "limit": 60, "remaining": 59, "reset_at": time.Now().UTC().Add(time.Minute).Format(time.RFC3339)},
|
|
},
|
|
"quota": map[string]any{"monthly_tokens": 1000000, "monthly_cost_usd": 200},
|
|
})
|
|
}
|
|
|
|
func billingCatalogSeed() []billingPlan {
|
|
return []billingPlan{
|
|
{
|
|
ID: "creator-pro-monthly",
|
|
ProductType: "subscription",
|
|
Badge: "Recommended",
|
|
Name: "Creator Plan",
|
|
Price: "¥69.99",
|
|
Cadence: "/month",
|
|
Summary: "For solo creators and small teams that need a stable monthly credit baseline.",
|
|
Features: []string{
|
|
"12,000 monthly credits",
|
|
"Official skill catalog access",
|
|
"Project-level usage visibility",
|
|
"Shared web console and CLI auth flow",
|
|
},
|
|
CTA: "Choose Creator Plan",
|
|
Highlight: true,
|
|
},
|
|
{
|
|
ID: "credits-top-up-50000",
|
|
ProductType: "top_up",
|
|
Badge: "Flexible",
|
|
Name: "Credits Top-up",
|
|
Price: "¥199",
|
|
Cadence: "/pack",
|
|
Summary: "Add extra spendable credits when video, batch, or campaign work spikes.",
|
|
Features: []string{
|
|
"50,000 extra credits",
|
|
"Stacks on top of subscriptions",
|
|
"Visible in the same billing center",
|
|
"Designed for bursty production workloads",
|
|
},
|
|
CTA: "Buy credits",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleBillingCatalog(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"plans": billingCatalogSeed(),
|
|
"capabilities": map[string]any{
|
|
"subscription_status": false,
|
|
"credit_balance": false,
|
|
"invoices": false,
|
|
"checkout": false,
|
|
},
|
|
"source": "popiartServer",
|
|
"note": "Catalog is product-layer data. Checkout, subscription, credits, and invoices are not wired yet.",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBillingSubscription(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
item, err := s.newapi.getGatewaySubscriptionSelf(ctx, current.GatewayUserID, current.GatewayAccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription data from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleBillingSubscriptionPlans(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
items, err := s.newapi.listGatewaySubscriptionPlans(ctx, current.GatewayUserID, current.GatewayAccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription plans from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (s *Server) handleBillingPointPackages(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
items, err := s.newapi.listGatewayPointPackages(ctx, current.GatewayUserID, current.GatewayAccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point packages from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (s *Server) handleBillingCredits(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
item, err := s.newapi.getGatewayPointsMine(ctx, current.GatewayUserID, current.GatewayAccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load credit data from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleBillingInvoices(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
query := r.URL.Query()
|
|
params := make(url.Values)
|
|
if value := strings.TrimSpace(query.Get("keyword")); value != "" {
|
|
params.Set("keyword", value)
|
|
}
|
|
if value := strings.TrimSpace(query.Get("p")); value != "" {
|
|
params.Set("p", value)
|
|
}
|
|
if value := strings.TrimSpace(query.Get("page_size")); value != "" {
|
|
params.Set("page_size", value)
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
subscriptions, err := s.newapi.listGatewaySubscriptionOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode())
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription orders from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
points, err := s.newapi.listGatewayPointOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode())
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point orders from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"subscription_orders": subscriptions,
|
|
"point_orders": points,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBillingCheckoutSubscription(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
var req struct {
|
|
PlanID int `json:"plan_id"`
|
|
Provider string `json:"provider"`
|
|
ReturnURL string `json:"return_url"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.PlanID <= 0 || strings.TrimSpace(req.Provider) == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "plan_id and provider are required", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
item, err := s.newapi.createGatewaySubscriptionPayment(
|
|
ctx,
|
|
current.GatewayUserID,
|
|
current.GatewayAccessToken,
|
|
strings.TrimSpace(strings.ToLower(req.Provider)),
|
|
req.PlanID,
|
|
strings.TrimSpace(req.ReturnURL),
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create subscription payment", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleBillingCheckoutPoints(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
var req struct {
|
|
PackageID int `json:"package_id"`
|
|
Provider string `json:"provider"`
|
|
ReturnURL string `json:"return_url"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.PackageID <= 0 || strings.TrimSpace(req.Provider) == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "package_id and provider are required", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
item, err := s.newapi.createGatewayPointsPayment(
|
|
ctx,
|
|
current.GatewayUserID,
|
|
current.GatewayAccessToken,
|
|
strings.TrimSpace(strings.ToLower(req.Provider)),
|
|
req.PackageID,
|
|
strings.TrimSpace(req.ReturnURL),
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create points payment", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleBillingCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" {
|
|
writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil)
|
|
return
|
|
}
|
|
kind := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("kind")))
|
|
provider := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("provider")))
|
|
tradeNo := strings.TrimSpace(r.URL.Query().Get("trade_no"))
|
|
if kind == "" || provider == "" || tradeNo == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind, provider, and trade_no are required", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
var (
|
|
item gatewayPaymentStatus
|
|
err error
|
|
)
|
|
switch kind {
|
|
case "subscription":
|
|
item, err = s.newapi.queryGatewaySubscriptionPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo)
|
|
case "points":
|
|
item, err = s.newapi.queryGatewayPointsPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo)
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind must be subscription or points", nil)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to query payment status", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": s.store.projects,
|
|
"total": len(s.store.projects),
|
|
"limit": intQuery(r, "limit", 20),
|
|
"offset": 0,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/projects/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
projectID := parts[0]
|
|
|
|
var found *project
|
|
for _, item := range s.store.projects {
|
|
if item.ID == projectID {
|
|
copyItem := item
|
|
found = ©Item
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 1 {
|
|
writeData(w, http.StatusOK, found)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "context" {
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load project routes", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project": found,
|
|
"budget": map[string]any{
|
|
"used": map[string]any{"tokens": 12000, "cost_usd": 4.62},
|
|
"limit": map[string]any{"monthly_tokens": 1000000, "monthly_cost_usd": 200},
|
|
"remaining": map[string]any{"tokens": 988000, "cost_usd": 195.38},
|
|
},
|
|
"route_overrides": routes,
|
|
"available_skills": s.store.skills,
|
|
})
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "MODEL_LIST_UNAVAILABLE", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
items, err := s.newapi.listModels(ctx, current.UpstreamKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "MODEL_LIST_FAILED", "failed to load models from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
modelType := r.URL.Query().Get("type")
|
|
provider := r.URL.Query().Get("provider")
|
|
filtered := make([]model, 0, len(items))
|
|
for _, item := range items {
|
|
if modelType != "" && item.Type != modelType {
|
|
continue
|
|
}
|
|
if provider != "" && item.Provider != provider {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": filtered})
|
|
}
|
|
|
|
func (s *Server) handleModelRoutes(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID := r.URL.Query().Get("project_id")
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load route overrides", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"global": map[string]any{
|
|
"image": s.cfg.DefaultImageModel,
|
|
"video": s.cfg.DefaultVideoModel,
|
|
"audio": "",
|
|
},
|
|
"overrides": map[string]any{
|
|
projectID: routes,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModelRouteOverrides(w http.ResponseWriter, r *http.Request) {
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
projectID := r.URL.Query().Get("project_id")
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load route overrides", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": projectID,
|
|
"items": routes,
|
|
})
|
|
case http.MethodPost:
|
|
var req struct {
|
|
ProjectID string `json:"project_id"`
|
|
SkillType string `json:"skill_type"`
|
|
ModelID string `json:"model_id"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ProjectID == "" || req.SkillType == "" || req.ModelID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "project_id, skill_type, and model_id are required", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(req.SkillType)
|
|
if err := s.store.setRouteOverride(req.ProjectID, routeKey, req.ModelID); err != nil {
|
|
writeInternalError(w, "failed to save route override", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": req.ProjectID,
|
|
"skill_type": req.SkillType,
|
|
"route_key": routeKey,
|
|
"model_id": req.ModelID,
|
|
})
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleModelRouteOverrideUnset(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ProjectID string `json:"project_id"`
|
|
SkillType string `json:"skill_type"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ProjectID == "" || req.SkillType == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "project_id and skill_type are required", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(req.SkillType)
|
|
if err := s.store.unsetRouteOverride(req.ProjectID, routeKey); err != nil {
|
|
writeInternalError(w, "failed to delete route override", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": req.ProjectID,
|
|
"skill_type": req.SkillType,
|
|
"route_key": routeKey,
|
|
"unset": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ModelID string `json:"model_id"`
|
|
ModelType string `json:"model_type"`
|
|
Input map[string]any `json:"input"`
|
|
ProjectID string `json:"project_id"`
|
|
Priority string `json:"priority"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ModelID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model_id is required", nil)
|
|
return
|
|
}
|
|
routeKey := inferRouteKeyForModelType(req.ModelID, req.ModelType, req.Input)
|
|
record, statusCode, err := s.store.createJob(
|
|
"",
|
|
routeKey,
|
|
req.ModelID,
|
|
routeExecMode(routeKey),
|
|
req.Input,
|
|
req.ProjectID,
|
|
defaultString(req.Priority, "normal"),
|
|
req.IdempotencyKey,
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create model job", err)
|
|
return
|
|
}
|
|
if statusCode == http.StatusAccepted {
|
|
go s.dispatchJob(record)
|
|
}
|
|
writeData(w, statusCode, map[string]any{
|
|
"job_id": record.JobID,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVideoGenerations(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/video/generations" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
|
|
var req map[string]any
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
modelID := strings.TrimSpace(stringValue(req["model"]))
|
|
if modelID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model is required", nil)
|
|
return
|
|
}
|
|
if !isSeedanceVideoModel(modelID) {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "only Seedance video generation is supported on this gateway-compatible route", map[string]any{
|
|
"model": modelID,
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
taskID, err := s.newapi.submitSeedanceVideoTask(ctx, current.UpstreamKey, modelID, req)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to submit Seedance video task", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusAccepted, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "PENDING",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVideoGeneration(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
taskID := strings.TrimPrefix(r.URL.Path, "/v1/video/generations/")
|
|
taskID = strings.Trim(taskID, "/")
|
|
if taskID == "" || strings.Contains(taskID, "/") {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
result, err := s.newapi.fetchVideoTask(ctx, current.UpstreamKey, taskID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to fetch Seedance video task", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, videoGenerationResponse(result))
|
|
}
|
|
|
|
func videoGenerationResponse(result *videoTaskResult) map[string]any {
|
|
if result == nil {
|
|
return map[string]any{}
|
|
}
|
|
status := strings.ToUpper(strings.TrimSpace(result.Status))
|
|
switch status {
|
|
case "COMPLETED":
|
|
status = "SUCCESS"
|
|
case "QUEUED":
|
|
status = "PENDING"
|
|
case "IN_PROGRESS":
|
|
status = "RUNNING"
|
|
case "":
|
|
status = "PENDING"
|
|
}
|
|
out := map[string]any{
|
|
"task_id": result.TaskID,
|
|
"status": status,
|
|
}
|
|
if strings.TrimSpace(result.Progress) != "" {
|
|
out["progress"] = result.Progress
|
|
}
|
|
metadata := map[string]any{}
|
|
if strings.TrimSpace(result.URL) != "" {
|
|
out["result_url"] = strings.TrimSpace(result.URL)
|
|
metadata["url"] = strings.TrimSpace(result.URL)
|
|
}
|
|
if strings.TrimSpace(result.LastFrameURL) != "" {
|
|
out["last_frame_url"] = strings.TrimSpace(result.LastFrameURL)
|
|
metadata["last_frame_url"] = strings.TrimSpace(result.LastFrameURL)
|
|
}
|
|
if strings.TrimSpace(result.Format) != "" {
|
|
metadata["format"] = strings.TrimSpace(result.Format)
|
|
}
|
|
if len(metadata) > 0 {
|
|
out["metadata"] = metadata
|
|
}
|
|
if strings.TrimSpace(result.ErrorCode) != "" || strings.TrimSpace(result.ErrorReason) != "" {
|
|
out["error"] = map[string]any{
|
|
"code": strings.TrimSpace(result.ErrorCode),
|
|
"message": strings.TrimSpace(result.ErrorReason),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) dispatchJob(record *job) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
|
|
switch normalizeRouteKey(record.RouteKey) {
|
|
case "image.text2image":
|
|
s.executeTextToImageJob(record)
|
|
case "image.img2img":
|
|
s.executeImageToImageJob(record)
|
|
case "video.image2video":
|
|
s.executeImageToVideoJob(record)
|
|
case "music.generate":
|
|
s.executeMusicGenerationJob(record)
|
|
case "speech.synthesize":
|
|
s.executeSpeechSynthesisJob(record)
|
|
default:
|
|
s.executeUnsupportedSkill(record)
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeTextToImageJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
refs, usage, err := s.newapi.generateImageRefs(ctx, record.UpstreamKey, modelID, record.Input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
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{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeImageToImageJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
input := buildImageToImageInput(record)
|
|
imageRefs, err := s.resolveImageToImageReferences(ctx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
log.Printf(
|
|
"popiartServer: image job %s using model=%s refs=%s preserve_composition=%t strength=%v",
|
|
record.JobID,
|
|
modelID,
|
|
summarizeImageEditReferenceRoles(imageRefs),
|
|
boolValue(input["preserve_composition"]),
|
|
input["strength"],
|
|
)
|
|
|
|
var (
|
|
resultRefs []resultRef
|
|
usage map[string]any
|
|
)
|
|
if useMiniMaxImageGenerations(modelID) {
|
|
resultRefs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
|
} else {
|
|
resultRefs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
|
}
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
resultRefs, err = s.persistResultRefs(ctx, record, resultRefs)
|
|
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, resultRefs, 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{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeImageToVideoJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
|
|
input := buildImageToVideoInput(record)
|
|
submitCtx, cancelSubmit := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancelSubmit()
|
|
|
|
var refsForMiniMax []imageEditReference
|
|
var refsForGateway []imageEditReference
|
|
var ref imageEditReference
|
|
var err error
|
|
if useSeedanceVideoGenerations(modelID) {
|
|
// Seedance routes through unified JSON video generations and can accept
|
|
// text-only, image, video, and audio references directly from input.
|
|
} else if useJimengVideoGenerations(modelID) {
|
|
if err := validateJimengVideoInput(modelID, input); err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "VALIDATION_ERROR", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
"model_id": modelID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
} else if useMiniMaxVideoGenerations(modelID) {
|
|
if hasVideoReferenceInput(input) {
|
|
refsForMiniMax, err = s.resolveVideoReferences(submitCtx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
} else if hasStartEndFrameVideoInput(input) {
|
|
refsForGateway, err = s.resolveVideoReferences(submitCtx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
ref, err = s.resolveImageToImageReference(submitCtx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
var upstreamTaskID string
|
|
if useSeedanceVideoGenerations(modelID) {
|
|
upstreamTaskID, err = s.newapi.submitSeedanceVideoTask(submitCtx, record.UpstreamKey, modelID, input)
|
|
} else if useJimengVideoGenerations(modelID) {
|
|
upstreamTaskID, err = s.newapi.submitJimengVideoTask(submitCtx, record.UpstreamKey, modelID, input)
|
|
} else if useMiniMaxVideoGenerations(modelID) {
|
|
upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax)
|
|
} else if len(refsForGateway) > 1 {
|
|
upstreamTaskID, err = s.newapi.submitImageToVideoTaskWithReferences(submitCtx, record.UpstreamKey, modelID, input, refsForGateway)
|
|
} else {
|
|
upstreamTaskID, err = s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref)
|
|
}
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"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.linkUpstreamTask(record.JobID, upstreamTaskID); err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "TASK_LINK_PERSIST_FAILED", "failed to persist upstream task id", map[string]any{
|
|
"details": err.Error(),
|
|
"newapi_task_id": upstreamTaskID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
pollCtx, cancelPoll := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancelPoll()
|
|
|
|
taskResult, err := s.waitForVideoTask(pollCtx, record.UpstreamKey, upstreamTaskID)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "TASK_POLL_FAILED", err.Error(), map[string]any{
|
|
"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
|
|
}
|
|
|
|
refs := []resultRef{buildVideoResultRef(s.newapi.baseURL, upstreamTaskID, modelID, taskResult)}
|
|
usage := map[string]any{
|
|
"newapi_task_id": upstreamTaskID,
|
|
"status": taskResult.Status,
|
|
}
|
|
if strings.TrimSpace(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 {
|
|
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{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasVideoReferenceInput(input map[string]any) bool {
|
|
if input == nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(stringValue(
|
|
input["source_artifact_id"],
|
|
input["image"],
|
|
input["image_url"],
|
|
input["reference_image_url"],
|
|
)) != "" || len(extractStringValues(input["images"])) > 0 || len(extractStringValues(input["videos"])) > 0
|
|
}
|
|
|
|
func validateJimengVideoInput(modelID string, input map[string]any) error {
|
|
images := extractStringValues(input["images"])
|
|
videos := extractStringValues(input["videos"])
|
|
action := strings.TrimSpace(stringValue(nestedMetadataValue(input, "action")))
|
|
if action == "" && useJimengDreamActorModel(modelID) {
|
|
action = "actionGenerate"
|
|
}
|
|
switch action {
|
|
case "actionGenerate":
|
|
if len(images) != 1 || len(videos) == 0 || strings.TrimSpace(videos[0]) == "" {
|
|
return fmt.Errorf("jimeng actionGenerate requires exactly one image and videos[0]")
|
|
}
|
|
default:
|
|
// Let PopiNewAPI / upstream enforce model-specific image counts for
|
|
// text, first-frame, first-tail, and recamera Jimeng models.
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) executeUnsupportedSkill(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
if err := s.store.failJob(record.JobID, "NOT_IMPLEMENTED", "skill runtime is not connected yet", map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
"exec_mode": record.ExecMode,
|
|
}); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeMusicGenerationJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
refs, usage, err := s.newapi.generateMusicRefs(ctx, record.UpstreamKey, modelID, record.Input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
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 audio", 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)
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeSpeechSynthesisJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
refs, usage, err := s.newapi.generateSpeechRefs(ctx, record.UpstreamKey, modelID, record.Input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
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 speech", 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)
|
|
}
|
|
}
|
|
|
|
func (s *Server) resolveModelID(routeKey, projectID string) string {
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err == nil {
|
|
if override := routes[normalizeRouteKey(routeKey)]; override != "" {
|
|
return override
|
|
}
|
|
}
|
|
switch normalizeRouteKey(routeKey) {
|
|
case "video.image2video":
|
|
return s.cfg.DefaultVideoModel
|
|
default:
|
|
return s.cfg.DefaultImageModel
|
|
}
|
|
}
|
|
|
|
func inferRouteKeyForModel(modelID string, input map[string]any) string {
|
|
return inferRouteKeyForModelType(modelID, "", input)
|
|
}
|
|
|
|
func inferRouteKeyForModelType(modelID, modelType string, input map[string]any) string {
|
|
switch strings.ToLower(strings.TrimSpace(modelType)) {
|
|
case "music", "audio.music":
|
|
return "music.generate"
|
|
case "speech", "audio.speech", "tts":
|
|
return "speech.synthesize"
|
|
}
|
|
if isMusicModelID(modelID) {
|
|
return "music.generate"
|
|
}
|
|
if isSpeechModelID(modelID) {
|
|
return "speech.synthesize"
|
|
}
|
|
if modelType, _ := classifyModelIDFallback(modelID); modelType == "video" {
|
|
return "video.image2video"
|
|
}
|
|
if input != nil {
|
|
if ref := strings.TrimSpace(stringValue(
|
|
input["source_artifact_id"],
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
)); ref != "" {
|
|
return "image.img2img"
|
|
}
|
|
}
|
|
return "image.text2image"
|
|
}
|
|
|
|
func isSpeechModelID(modelID string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(modelID))
|
|
return strings.HasPrefix(normalized, "speech-") || strings.Contains(normalized, "tts")
|
|
}
|
|
|
|
func isMusicModelID(modelID string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(modelID))
|
|
return normalized == "music-2.6" ||
|
|
normalized == "music-2.6-free" ||
|
|
normalized == "music-cover" ||
|
|
normalized == "music-cover-free"
|
|
}
|
|
|
|
func isSeedanceVideoModel(modelID string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(modelID))
|
|
return strings.Contains(normalized, "doubao-seedance") ||
|
|
strings.Contains(normalized, "seedance")
|
|
}
|
|
|
|
func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
|
if record == nil {
|
|
return nil, fmt.Errorf("job record is required")
|
|
}
|
|
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
|
|
sourceRef, err := s.resolveArtifactImageReference(ctx, record, artifactID, "source")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := []imageEditReference{sourceRef}
|
|
seen := map[string]struct{}{artifactID: {}}
|
|
var appendArtifactRefs = func(role string, values []string) error {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
ref, err := s.resolveArtifactImageReference(ctx, record, value, role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seen[value] = struct{}{}
|
|
refs = append(refs, ref)
|
|
}
|
|
return nil
|
|
}
|
|
if err := appendArtifactRefs("identity", stringSliceValue(input["identity_reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := appendArtifactRefs("style", stringSliceValue(input["style_reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := appendArtifactRefs("reference", stringSliceValue(input["reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
refURL := strings.TrimSpace(stringValue(
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
))
|
|
if refURL == "" && strings.Contains(record.SkillID, "popistudio-alice-showcase") {
|
|
refURL = defaultAliceReferenceURL
|
|
}
|
|
if refURL == "" {
|
|
return nil, fmt.Errorf("reference image is required")
|
|
}
|
|
ref, err := s.downloadReferenceImage(ctx, record.SessionID, refURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref.Role = "source"
|
|
return []imageEditReference{ref}, nil
|
|
}
|
|
|
|
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
|
|
refs, err := s.resolveImageToImageReferences(ctx, record, input)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if len(refs) == 0 {
|
|
return imageEditReference{}, fmt.Errorf("reference image is required")
|
|
}
|
|
return refs[0], nil
|
|
}
|
|
|
|
func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
|
if record == nil {
|
|
return nil, fmt.Errorf("job record is required")
|
|
}
|
|
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
|
|
ref, err := s.resolveImageToImageReference(ctx, record, map[string]any{
|
|
"source_artifact_id": artifactID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := []imageEditReference{ref}
|
|
if lastArtifactID := lastFrameArtifactID(input); lastArtifactID != "" {
|
|
lastRef, err := s.resolveImageToImageReference(ctx, record, map[string]any{
|
|
"source_artifact_id": lastArtifactID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs = append(refs, lastRef)
|
|
} else if lastURL := lastFrameImageURL(input); lastURL != "" {
|
|
lastRef, err := s.downloadReferenceImage(ctx, record.SessionID, lastURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs = append(refs, lastRef)
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
urls := extractStringValues(input["images"])
|
|
if len(urls) == 0 {
|
|
if refURL := strings.TrimSpace(stringValue(
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
)); refURL != "" {
|
|
urls = []string{refURL}
|
|
}
|
|
}
|
|
if len(urls) == 1 {
|
|
if lastURL := lastFrameImageURL(input); lastURL != "" && lastURL != urls[0] {
|
|
urls = append(urls, lastURL)
|
|
}
|
|
}
|
|
if len(urls) == 0 {
|
|
return nil, fmt.Errorf("reference image is required")
|
|
}
|
|
|
|
refs := make([]imageEditReference, 0, len(urls))
|
|
for _, rawURL := range urls {
|
|
ref, err := s.downloadReferenceImage(ctx, record.SessionID, rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs = append(refs, ref)
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
func extractStringValues(value any) []string {
|
|
switch typed := value.(type) {
|
|
case []string:
|
|
out := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
if text := strings.TrimSpace(item); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
if text := strings.TrimSpace(stringValue(item)); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *Server) waitForVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
result, err := s.newapi.fetchVideoTask(ctx, token, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch result.Status {
|
|
case "completed":
|
|
return result, nil
|
|
case "failed":
|
|
message := defaultString(strings.TrimSpace(result.ErrorReason), "video task failed")
|
|
return nil, errors.New(message)
|
|
case "queued", "in_progress", "":
|
|
default:
|
|
if strings.TrimSpace(result.Status) != "" {
|
|
return nil, fmt.Errorf("unexpected video task status: %s", result.Status)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) downloadReferenceImage(ctx context.Context, sessionToken, rawURL string) (imageEditReference, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if sessionToken = strings.TrimSpace(sessionToken); sessionToken != "" && shouldAttachAuthHeader(publicBaseURL(s.cfg), rawURL) {
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
}
|
|
resp, err := s.newapi.httpClient.Do(req)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return imageEditReference{}, fmt.Errorf("download reference image failed: %s", strings.TrimSpace(string(body)))
|
|
}
|
|
content, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
contentType := defaultString(strings.TrimSpace(resp.Header.Get("Content-Type")), http.DetectContentType(content))
|
|
return imageEditReference{
|
|
Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)),
|
|
ContentType: contentType,
|
|
Content: content,
|
|
URL: strings.TrimSpace(rawURL),
|
|
}, nil
|
|
}
|
|
|
|
func buildImageToImageInput(record *job) map[string]any {
|
|
input := cloneMap(record.Input)
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
|
|
prompt := strings.TrimSpace(stringValue(
|
|
input["prompt"],
|
|
input["scene_prompt"],
|
|
))
|
|
|
|
if strings.Contains(record.SkillID, "popistudio-alice-showcase") {
|
|
prompt = buildAliceShowcasePrompt(input, prompt)
|
|
} else {
|
|
prompt = buildGenericImageToImagePrompt(input, prompt)
|
|
}
|
|
|
|
input["prompt"] = prompt
|
|
delete(input, "scene_prompt")
|
|
return input
|
|
}
|
|
|
|
func buildImageToVideoInput(record *job) map[string]any {
|
|
input := cloneMap(record.Input)
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
normalizeStartEndFrameVideoInput(input)
|
|
|
|
prompt := strings.TrimSpace(stringValue(
|
|
input["prompt"],
|
|
input["motion_prompt"],
|
|
input["scene_prompt"],
|
|
))
|
|
|
|
parts := make([]string, 0, 6)
|
|
if prompt != "" {
|
|
parts = append(parts, prompt)
|
|
}
|
|
if cameraMotion := strings.TrimSpace(stringValue(input["camera_motion"])); cameraMotion != "" {
|
|
parts = append(parts, "camera motion "+cameraMotion)
|
|
}
|
|
if shotType := strings.TrimSpace(stringValue(input["shot_type"])); shotType != "" {
|
|
parts = append(parts, shotType)
|
|
}
|
|
if mood := strings.TrimSpace(stringValue(input["mood"])); mood != "" {
|
|
parts = append(parts, mood)
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
if len(parts) == 0 && !isActionGenerateInput(input) {
|
|
parts = append(parts, "Generate a short polished cinematic motion clip that preserves the reference image subject identity.")
|
|
}
|
|
|
|
if len(parts) > 0 {
|
|
input["prompt"] = strings.Join(parts, ", ")
|
|
} else {
|
|
delete(input, "prompt")
|
|
}
|
|
delete(input, "motion_prompt")
|
|
delete(input, "scene_prompt")
|
|
return input
|
|
}
|
|
|
|
func normalizeStartEndFrameVideoInput(input map[string]any) {
|
|
if input == nil {
|
|
return
|
|
}
|
|
if value := lastFrameArtifactID(input); value != "" {
|
|
input["last_frame_artifact_id"] = value
|
|
input["end_frame_artifact_id"] = value
|
|
}
|
|
if value := lastFrameImageURL(input); value != "" {
|
|
input["last_frame_image_url"] = value
|
|
input["end_frame_image_url"] = value
|
|
}
|
|
|
|
images := extractStringValues(input["images"])
|
|
first := strings.TrimSpace(stringValue(input["image"], input["reference_image_url"], input["image_url"]))
|
|
last := lastFrameImageURL(input)
|
|
switch {
|
|
case len(images) == 0 && first != "" && last != "":
|
|
images = []string{first, last}
|
|
input["images"] = images
|
|
case len(images) == 1 && last != "" && images[0] != last:
|
|
images = append(images, last)
|
|
input["images"] = images
|
|
}
|
|
|
|
if len(images) > 1 || last != "" || lastFrameArtifactID(input) != "" {
|
|
ensureInputMetadataAction(input, "firstTailGenerate")
|
|
}
|
|
}
|
|
|
|
func hasStartEndFrameVideoInput(input map[string]any) bool {
|
|
return len(extractStringValues(input["images"])) > 1 ||
|
|
lastFrameImageURL(input) != "" ||
|
|
lastFrameArtifactID(input) != ""
|
|
}
|
|
|
|
func lastFrameImageURL(input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(stringValue(
|
|
input["last_frame_image_url"],
|
|
input["end_frame_image_url"],
|
|
input["last_frame_url"],
|
|
))
|
|
}
|
|
|
|
func lastFrameArtifactID(input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(stringValue(
|
|
input["last_frame_artifact_id"],
|
|
input["end_frame_artifact_id"],
|
|
))
|
|
}
|
|
|
|
func ensureInputMetadataAction(input map[string]any, action string) {
|
|
metadata, _ := input["metadata"].(map[string]any)
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
input["metadata"] = metadata
|
|
}
|
|
if strings.TrimSpace(stringValue(metadata["action"])) == "" {
|
|
metadata["action"] = action
|
|
}
|
|
}
|
|
|
|
func isActionGenerateInput(input map[string]any) bool {
|
|
return strings.TrimSpace(stringValue(nestedMetadataValue(input, "action"), input["action"])) == "actionGenerate"
|
|
}
|
|
|
|
func nestedMetadataValue(input map[string]any, key string) any {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
metadata, ok := input["metadata"].(map[string]any)
|
|
if !ok || metadata == nil {
|
|
return nil
|
|
}
|
|
return metadata[key]
|
|
}
|
|
|
|
func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string {
|
|
parts := []string{
|
|
"PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image",
|
|
"preserve the same anime facial structure, recognizability, hairstyle, hair color, clothing language, and main palette",
|
|
"Alice remains the visual center of the frame",
|
|
}
|
|
|
|
if scenePrompt != "" {
|
|
parts = append(parts, scenePrompt)
|
|
}
|
|
if shotType := strings.TrimSpace(stringValue(input["shot_type"])); shotType != "" {
|
|
parts = append(parts, shotType)
|
|
}
|
|
if camera := strings.TrimSpace(stringValue(input["camera"])); camera != "" {
|
|
parts = append(parts, camera)
|
|
}
|
|
if mood := strings.TrimSpace(stringValue(input["mood"])); mood != "" {
|
|
parts = append(parts, mood)
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
|
|
parts = append(parts,
|
|
"real-world modern Chinese living scene",
|
|
"Sony photo realism",
|
|
"natural light",
|
|
"warm realistic tone",
|
|
"high detail",
|
|
"clean composition",
|
|
)
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (s *Server) resolveArtifactImageReference(ctx context.Context, record *job, artifactID, role string) (imageEditReference, error) {
|
|
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if !exists {
|
|
return imageEditReference{}, fmt.Errorf("%s artifact not found: %s", defaultString(role, "reference"), artifactID)
|
|
}
|
|
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
defer reader.Close()
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
return imageEditReference{
|
|
Role: role,
|
|
Filename: item.Filename,
|
|
ContentType: defaultString(contentType, item.ContentType),
|
|
Content: content,
|
|
URL: artifactView(s.cfg, *item, time.Now().UTC()).URL,
|
|
}, nil
|
|
}
|
|
|
|
func buildGenericImageToImagePrompt(input map[string]any, prompt string) string {
|
|
parts := make([]string, 0, 6)
|
|
if prompt != "" {
|
|
parts = append(parts, prompt)
|
|
}
|
|
if refURL := strings.TrimSpace(stringValue(input["image"], input["reference_image_url"], input["image_url"])); refURL != "" {
|
|
parts = append(parts, "preserve the same main subject identity and key visual traits from the reference image")
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "preserve the same main subject identity and render a polished high-detail image"
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func summarizeImageEditReferenceRoles(refs []imageEditReference) string {
|
|
if len(refs) == 0 {
|
|
return "none"
|
|
}
|
|
parts := make([]string, 0, len(refs))
|
|
for _, ref := range refs {
|
|
role := strings.TrimSpace(ref.Role)
|
|
if role == "" {
|
|
role = "reference"
|
|
}
|
|
parts = append(parts, role)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
func stringValue(values ...any) string {
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
|
return text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boolValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func stringSliceValue(value any) []string {
|
|
switch typed := value.(type) {
|
|
case []string:
|
|
return cleanedStringValues(typed)
|
|
case []any:
|
|
items := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
items = append(items, strings.TrimSpace(fmt.Sprint(item)))
|
|
}
|
|
return cleanedStringValues(items)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func cleanedStringValues(values []string) []string {
|
|
items := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
items = append(items, trimmed)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func buildVideoResultRef(baseURL, taskID, modelID string, task *videoTaskResult) resultRef {
|
|
urlValue := strings.TrimSpace(task.URL)
|
|
if urlValue == "" {
|
|
urlValue = strings.TrimRight(baseURL, "/") + "/v1/videos/" + taskID + "/content"
|
|
}
|
|
|
|
contentType := "video/mp4"
|
|
ext := ".mp4"
|
|
if format := strings.TrimSpace(task.Format); format != "" {
|
|
switch strings.ToLower(strings.TrimPrefix(format, ".")) {
|
|
case "mov", "quicktime":
|
|
contentType = "video/quicktime"
|
|
ext = ".mov"
|
|
case "webm":
|
|
contentType = "video/webm"
|
|
ext = ".webm"
|
|
case "mp4":
|
|
contentType = "video/mp4"
|
|
ext = ".mp4"
|
|
}
|
|
}
|
|
filename := filenameFromURL(urlValue, "")
|
|
if filename == "" || filename == "content" || !strings.Contains(filepath.Base(filename), ".") {
|
|
filename = sanitizeFilename(defaultString(modelID, "video")) + "-" + sanitizeFilename(taskID) + ext
|
|
}
|
|
|
|
if strings.HasPrefix(urlValue, "data:") {
|
|
return resultRef{
|
|
Kind: "data_url",
|
|
DataURL: urlValue,
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
}
|
|
}
|
|
return resultRef{
|
|
Kind: "url",
|
|
URL: urlValue,
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
}
|
|
}
|
|
|
|
const defaultAliceReferenceURL = "http://8.136.121.101:8790/media/Character_id_card/alice.jpg"
|
|
|
|
func (s *Server) authenticateSession(w http.ResponseWriter, r *http.Request) (session, bool) {
|
|
current, ok, err := s.sessionFromRequest(r)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load session", err)
|
|
return session{}, false
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key", nil)
|
|
return session{}, false
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func (s *Server) sessionFromRequest(r *http.Request) (session, bool, error) {
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
return session{}, false, nil
|
|
}
|
|
current, exists, err := s.store.session(token)
|
|
if err != nil {
|
|
return session{}, false, err
|
|
}
|
|
if !exists {
|
|
return session{}, false, nil
|
|
}
|
|
return current, true, nil
|
|
}
|
|
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")), true
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
defer r.Body.Close()
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read request body", nil)
|
|
return false
|
|
}
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
body = []byte(`{}`)
|
|
}
|
|
if err := json.Unmarshal(body, dst); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid JSON body", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func intQuery(r *http.Request, key string, fallback int) int {
|
|
value := r.URL.Query().Get(key)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n < 0 {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func writeData(w http.ResponseWriter, status int, data any) {
|
|
writeJSON(w, status, map[string]any{
|
|
"ok": true,
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
func buildDataURL(contentType string, content []byte) string {
|
|
return "data:" + defaultString(strings.TrimSpace(contentType), "application/octet-stream") + ";base64," + base64.StdEncoding.EncodeToString(content)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string, details map[string]any) {
|
|
errBody := map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
}
|
|
for key, value := range details {
|
|
errBody[key] = value
|
|
}
|
|
writeJSON(w, status, map[string]any{
|
|
"ok": false,
|
|
"error": errBody,
|
|
})
|
|
}
|
|
|
|
func writeInternalError(w http.ResponseWriter, message string, err error) {
|
|
details := map[string]any{}
|
|
if err != nil {
|
|
details["details"] = err.Error()
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", message, details)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func notFound(w http.ResponseWriter) {
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "resource not found", nil)
|
|
}
|
|
|
|
func methodNotAllowed(w http.ResponseWriter) {
|
|
writeError(w, http.StatusMethodNotAllowed, "BAD_REQUEST", "method not allowed", nil)
|
|
}
|
|
|
|
func mustJSON(value any) string {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return `{"level":"error","message":"marshal failed"}`
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|