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 }