add stable media support and sync skillhub UI

This commit is contained in:
wtgoku
2026-04-08 23:17:42 +08:00
parent ff4f370763
commit 4b20dc5e37
32 changed files with 6269 additions and 393 deletions
+105 -2
View File
@@ -30,6 +30,7 @@ type Config struct {
NewAPIToken string
DefaultImageModel string
DefaultVideoModel string
PublicBaseURL string
DataDir string
SQLitePath string
SessionSecret string
@@ -42,11 +43,16 @@ func ConfigFromEnv() Config {
NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")),
DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")),
DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_MODEL")),
PublicBaseURL: strings.TrimSpace(os.Getenv("POPIART_PUBLIC_BASE_URL")),
DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")),
SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")),
SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")),
SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")),
}
return normalizeConfig(cfg)
}
func normalizeConfig(cfg Config) Config {
if cfg.NewAPIBaseURL == "" {
cfg.NewAPIBaseURL = "http://127.0.0.1:3000"
}
@@ -57,7 +63,11 @@ func ConfigFromEnv() Config {
cfg.DefaultVideoModel = "viduq2"
}
if cfg.DataDir == "" {
cfg.DataDir = "./data"
if strings.TrimSpace(cfg.SQLitePath) != "" {
cfg.DataDir = filepath.Dir(cfg.SQLitePath)
} else {
cfg.DataDir = "./data"
}
}
if cfg.SQLitePath == "" {
cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db")
@@ -131,6 +141,7 @@ type imageEditReference struct {
Filename string
ContentType string
Content []byte
URL string
}
type openAIVideoResponse struct {
@@ -587,7 +598,7 @@ func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, model
"generationConfig": generationConfig,
}
if len(imageConfig) > 0 {
payload["imageConfig"] = imageConfig
generationConfig["imageConfig"] = imageConfig
}
body, err := json.Marshal(payload)
@@ -629,6 +640,9 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
if prompt == "" {
prompt = "Generate a short polished image-to-video clip from the provided reference image."
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "vidu") && strings.TrimSpace(ref.URL) != "" {
return c.submitImageToVideoTaskByURL(ctx, token, modelID, input, ref, prompt)
}
if len(ref.Content) == 0 {
return "", errors.New("reference image content is required")
}
@@ -713,6 +727,71 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI
return taskID, nil
}
func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference, prompt string) (string, error) {
payload := map[string]any{
"model": modelID,
"prompt": prompt,
"images": []string{strings.TrimSpace(ref.URL)},
"duration": 5,
}
if duration := strings.TrimSpace(resolveVideoDurationSeconds(input)); duration != "" {
if parsed, err := strconv.Atoi(duration); err == nil && parsed > 0 {
payload["duration"] = parsed
}
}
if size := resolveVideoSize(modelID, input, ref); size != "" {
payload["size"] = size
}
if aspectRatio := resolveVideoAspectRatio(input, ref); aspectRatio != "" {
payload["metadata"] = map[string]any{
"aspect_ratio": aspectRatio,
}
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var decoded openAIVideoResponse
if err := json.Unmarshal(respBody, &decoded); err != nil {
return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err)
}
if resp.StatusCode >= 400 {
return "", decodeTaskAPIError(respBody, resp.StatusCode)
}
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
return "", errors.New(decoded.Error.Message)
}
taskID := strings.TrimSpace(decoded.ID)
if taskID == "" {
taskID = strings.TrimSpace(decoded.TaskID)
}
if taskID == "" {
return "", errors.New("PopiNewAPI returned no task id")
}
return taskID, nil
}
func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
if !c.enabled() {
return nil, errors.New("PopiNewAPI base URL is not configured")
@@ -997,6 +1076,12 @@ func resolveGeminiImageSize(input map[string]any) string {
func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) {
switch ref.Kind {
case "local_path":
file, err := os.Open(ref.LocalPath)
if err != nil {
return "", 0, nil, err
}
return defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream"), ref.SizeBytes, file, nil
case "data_url":
contentType, content, err := decodeDataURL(ref.DataURL)
if err != nil {
@@ -1137,6 +1222,24 @@ func resolveVideoSize(modelID string, input map[string]any, ref imageEditReferen
return size
}
func resolveVideoAspectRatio(input map[string]any, ref imageEditReference) string {
if input != nil {
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
return aspectRatio
}
}
if len(ref.Content) > 0 {
cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content))
if err == nil && cfg.Width > 0 && cfg.Height > 0 {
if cfg.Width >= cfg.Height {
return "16:9"
}
return "9:16"
}
}
return ""
}
func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string {
if input != nil {
if size := strings.TrimSpace(stringValue(input["size"])); size != "" {