chore: auto-commit (95 archivos)
- cmd/fn/doctor.go - cmd/fn/main.go - cpp/apps/primitives_gallery/playground/tables/CMakeLists.txt - cpp/apps/primitives_gallery/playground/tables/data_table.cpp - cpp/apps/primitives_gallery/playground/tables/data_table_logic.cpp - cpp/apps/primitives_gallery/playground/tables/data_table_logic.h - cpp/apps/primitives_gallery/playground/tables/self_test.cpp - cpp/apps/primitives_gallery/playground/tables/tql.cpp - cpp/apps/primitives_gallery/playground/tables/viz.cpp - cpp/apps/primitives_gallery/playground/tables/viz.h - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package ml
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GenconfigMarshal serializa un GenerationConfig a JSON canonico con indent de 2 espacios.
|
||||
// El formato es identico al de Python json.dumps(indent=2, sort_keys=False):
|
||||
// keys en el orden de declaracion del struct, snake_case, campos omitempty ausentes si zero.
|
||||
func GenconfigMarshal(cfg GenerationConfig) ([]byte, error) {
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// GenconfigUnmarshal deserializa JSON (compacto o con indent) a GenerationConfig.
|
||||
// Los campos JSON deben usar snake_case: negative_prompt, cfg_scale, model_type, etc.
|
||||
func GenconfigUnmarshal(data []byte) (GenerationConfig, error) {
|
||||
var cfg GenerationConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return GenerationConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: genconfig_json_marshal
|
||||
kind: function
|
||||
lang: go
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func GenconfigMarshal(cfg GenerationConfig) ([]byte, error)\nfunc GenconfigUnmarshal(data []byte) (GenerationConfig, error)"
|
||||
description: "Wrappers json.Marshal/Unmarshal para GenerationConfig con formato canonico (MarshalIndent 2 espacios). Garantiza roundtrip identico al Python: json.dumps(indent=2, sort_keys=False). Campos JSON en snake_case."
|
||||
tags: [ml, json, marshal, unmarshal, serialization, generation, canonical]
|
||||
uses_functions: []
|
||||
uses_types: [generation_config_go_ml]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json"]
|
||||
params:
|
||||
- name: cfg
|
||||
desc: "GenerationConfig a serializar. Campos omitempty (negative_prompt, loras, clip_skip) se omiten si son zero/nil/empty."
|
||||
- name: data
|
||||
desc: "JSON bytes a deserializar. Acepta formato compacto o con indent. Keys deben ser snake_case (negative_prompt, cfg_scale, model_type, etc.)."
|
||||
output: "GenconfigMarshal: bytes JSON con indent 2 espacios, orden de campos segun declaracion del struct (prompt, negative_prompt, seed, steps, cfg_scale, sampler, width, height, model, loras, clip_skip). GenconfigUnmarshal: GenerationConfig poblado o error de parsing."
|
||||
tested: true
|
||||
tests:
|
||||
- "roundtrip marshal unmarshal produce config igual"
|
||||
- "json cross-language snake_case keys se deserializan correctamente"
|
||||
test_file_path: "functions/ml/genconfig_test.go"
|
||||
file_path: "functions/ml/genconfig_json_marshal.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := ml.GenerationConfig{
|
||||
Prompt: "a mountain at sunset",
|
||||
Seed: 1234,
|
||||
Steps: 30,
|
||||
CfgScale: 7.0,
|
||||
Sampler: "euler",
|
||||
Width: 768,
|
||||
Height: 512,
|
||||
Model: ml.ModelRef{Name: "sdxl-base", ModelType: "sdxl", Quantization: "fp16"},
|
||||
}
|
||||
|
||||
b, err := ml.GenconfigMarshal(cfg)
|
||||
// b == {
|
||||
// "prompt": "a mountain at sunset",
|
||||
// "seed": 1234,
|
||||
// ...
|
||||
// }
|
||||
|
||||
cfg2, err := ml.GenconfigUnmarshal(b)
|
||||
// cfg2 == cfg (DeepEqual)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
### Formato canonico y compatibilidad con Python
|
||||
|
||||
`GenconfigMarshal` usa `json.MarshalIndent(cfg, "", " ")`. El formato resultante es identico al que produce Python con `model.model_dump_json()` o `json.dumps(data, indent=2)` cuando `sort_keys=False`:
|
||||
|
||||
- Keys en orden de declaracion del struct (no alfabetico).
|
||||
- Indent de 2 espacios, sin trailing whitespace.
|
||||
- Campos omitempty ausentes si zero: `negative_prompt` ausente si `""`, `loras` ausente si `[]`, `clip_skip` ausente si `nil`.
|
||||
|
||||
### Keys JSON (snake_case obligatorio)
|
||||
|
||||
| Campo Go | Key JSON |
|
||||
|---|---|
|
||||
| `Prompt` | `"prompt"` |
|
||||
| `NegativePrompt` | `"negative_prompt"` |
|
||||
| `Seed` | `"seed"` |
|
||||
| `Steps` | `"steps"` |
|
||||
| `CfgScale` | `"cfg_scale"` |
|
||||
| `Sampler` | `"sampler"` |
|
||||
| `Width` | `"width"` |
|
||||
| `Height` | `"height"` |
|
||||
| `Model.ModelType` | `"model_type"` |
|
||||
| `Model.Quantization` | `"quantization"` |
|
||||
| `ClipSkip` | `"clip_skip"` |
|
||||
|
||||
### Por que impure
|
||||
|
||||
Los errores de `json.Unmarshal` son errores de parsing del input externo, no de I/O, pero se modelan como `(T, error)` para forzar manejo explicito en el caller. Marcado `impure` con `error_type: error_go_core` por convencion del registry.
|
||||
@@ -0,0 +1,260 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestGenconfigToSdcliArgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenconfigToSdcliArgs(t *testing.T) {
|
||||
clipSkip := 2
|
||||
|
||||
t.Run("config basico sin loras ni clip_skip", func(t *testing.T) {
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "a cat",
|
||||
Seed: 42,
|
||||
Steps: 20,
|
||||
CfgScale: 7.5,
|
||||
Sampler: "euler",
|
||||
Width: 512,
|
||||
Height: 512,
|
||||
Model: ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16"},
|
||||
}
|
||||
args := GenconfigToSdcliArgs(cfg)
|
||||
|
||||
want := []string{
|
||||
"--prompt", "a cat",
|
||||
"--seed", "42",
|
||||
"--steps", "20",
|
||||
"--cfg-scale", "7.5",
|
||||
"--width", "512",
|
||||
"--height", "512",
|
||||
"--sampling-method", "euler",
|
||||
}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Errorf("got %v\nwant %v", args, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loras se emiten como pares path:weight", func(t *testing.T) {
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "portrait",
|
||||
Seed: 1,
|
||||
Steps: 10,
|
||||
CfgScale: 7.0,
|
||||
Sampler: "euler",
|
||||
Width: 512,
|
||||
Height: 512,
|
||||
Model: ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16", Path: "/models/v1.safetensors"},
|
||||
Loras: []LoraRef{
|
||||
{Path: "/loras/detail.safetensors", Weight: 0.8},
|
||||
{Path: "/loras/style.safetensors", Weight: 0.5},
|
||||
},
|
||||
ClipSkip: &clipSkip,
|
||||
}
|
||||
args := GenconfigToSdcliArgs(cfg)
|
||||
|
||||
// Verificar que existen los pares --lora para ambas loras
|
||||
loraIdx := indexAll(args, "--lora")
|
||||
if len(loraIdx) != 2 {
|
||||
t.Fatalf("esperaba 2 flags --lora, got %d en %v", len(loraIdx), args)
|
||||
}
|
||||
wantLoras := []string{
|
||||
"/loras/detail.safetensors:0.8",
|
||||
"/loras/style.safetensors:0.5",
|
||||
}
|
||||
for i, idx := range loraIdx {
|
||||
if idx+1 >= len(args) {
|
||||
t.Fatalf("--lora[%d] sin valor siguiente", i)
|
||||
}
|
||||
if args[idx+1] != wantLoras[i] {
|
||||
t.Errorf("lora[%d]: got %q, want %q", i, args[idx+1], wantLoras[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar --model y --clip-skip presentes
|
||||
if !containsPair(args, "--model", "/models/v1.safetensors") {
|
||||
t.Errorf("--model no encontrado en %v", args)
|
||||
}
|
||||
if !containsPair(args, "--clip-skip", "2") {
|
||||
t.Errorf("--clip-skip no encontrado en %v", args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sampler dpm++2m se traduce a dpmpp2m", func(t *testing.T) {
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "x",
|
||||
Seed: 0,
|
||||
Steps: 1,
|
||||
CfgScale: 1.0,
|
||||
Sampler: "dpm++2m",
|
||||
Width: 64,
|
||||
Height: 64,
|
||||
Model: ModelRef{Name: "m", ModelType: "sd15", Quantization: "fp16"},
|
||||
}
|
||||
args := GenconfigToSdcliArgs(cfg)
|
||||
if !containsPair(args, "--sampling-method", "dpmpp2m") {
|
||||
t.Errorf("sampler no traducido; args=%v", args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative_prompt vacio no genera flag", func(t *testing.T) {
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "x",
|
||||
NegativePrompt: "",
|
||||
Seed: 0,
|
||||
Steps: 1,
|
||||
CfgScale: 1.0,
|
||||
Sampler: "euler",
|
||||
Width: 64,
|
||||
Height: 64,
|
||||
Model: ModelRef{Name: "m", ModelType: "sd15", Quantization: "fp16"},
|
||||
}
|
||||
args := GenconfigToSdcliArgs(cfg)
|
||||
for _, a := range args {
|
||||
if a == "--negative-prompt" {
|
||||
t.Errorf("flag --negative-prompt presente aunque NegativePrompt es vacio")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestGenconfigMarshalRoundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenconfigMarshalRoundtrip(t *testing.T) {
|
||||
t.Run("roundtrip marshal unmarshal produce config igual", func(t *testing.T) {
|
||||
clip := 2
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "sunset over the mountains",
|
||||
NegativePrompt: "blurry, low quality",
|
||||
Seed: 99,
|
||||
Steps: 30,
|
||||
CfgScale: 7.5,
|
||||
Sampler: "dpm++2m",
|
||||
Width: 768,
|
||||
Height: 512,
|
||||
Model: ModelRef{
|
||||
Name: "sdxl-base",
|
||||
ModelType: "sdxl",
|
||||
Quantization: "fp16",
|
||||
Path: "/models/sdxl.safetensors",
|
||||
},
|
||||
Loras: []LoraRef{
|
||||
{Path: "/loras/detail.safetensors", Weight: 0.8},
|
||||
},
|
||||
ClipSkip: &clip,
|
||||
}
|
||||
|
||||
b, err := GenconfigMarshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("GenconfigMarshal: %v", err)
|
||||
}
|
||||
|
||||
got, err := GenconfigUnmarshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("GenconfigUnmarshal: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cfg, got) {
|
||||
t.Errorf("roundtrip diverge\norig: %+v\ngot: %+v", cfg, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestGenconfigCrossLanguageJSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenconfigCrossLanguageJSON(t *testing.T) {
|
||||
// Fixture escrito a mano replicando lo que generaria Python:
|
||||
// json.dumps(config.model_dump(), indent=2)
|
||||
// Keys en snake_case, orden de declaracion del dataclass Python.
|
||||
fixture := `{
|
||||
"prompt": "a dragon",
|
||||
"negative_prompt": "ugly",
|
||||
"seed": 1234,
|
||||
"steps": 25,
|
||||
"cfg_scale": 7.0,
|
||||
"sampler": "euler_a",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"model": {
|
||||
"name": "v1-5",
|
||||
"model_type": "sd15",
|
||||
"quantization": "fp16"
|
||||
},
|
||||
"loras": [
|
||||
{
|
||||
"path": "/loras/dragon.safetensors",
|
||||
"weight": 0.9
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
t.Run("json cross-language snake_case keys se deserializan correctamente", func(t *testing.T) {
|
||||
cfg, err := GenconfigUnmarshal([]byte(fixture))
|
||||
if err != nil {
|
||||
t.Fatalf("GenconfigUnmarshal fixture: %v", err)
|
||||
}
|
||||
|
||||
// Verificar campos clave
|
||||
if cfg.Prompt != "a dragon" {
|
||||
t.Errorf("Prompt: got %q", cfg.Prompt)
|
||||
}
|
||||
if cfg.NegativePrompt != "ugly" {
|
||||
t.Errorf("NegativePrompt: got %q", cfg.NegativePrompt)
|
||||
}
|
||||
if cfg.CfgScale != 7.0 {
|
||||
t.Errorf("CfgScale: got %v", cfg.CfgScale)
|
||||
}
|
||||
if cfg.Model.ModelType != "sd15" {
|
||||
t.Errorf("Model.ModelType: got %q", cfg.Model.ModelType)
|
||||
}
|
||||
if len(cfg.Loras) != 1 || cfg.Loras[0].Weight != 0.9 {
|
||||
t.Errorf("Loras: got %+v", cfg.Loras)
|
||||
}
|
||||
|
||||
// Re-marshal y verificar que las keys snake_case siguen presentes
|
||||
b, err := GenconfigMarshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("GenconfigMarshal: %v", err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, key := range []string{"negative_prompt", "cfg_scale", "model_type", "quantization"} {
|
||||
if !strings.Contains(s, `"`+key+`"`) {
|
||||
t.Errorf("key %q ausente en JSON re-serializado:\n%s", key, s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// indexAll retorna todos los indices de val en slice.
|
||||
func indexAll(slice []string, val string) []int {
|
||||
var out []int
|
||||
for i, s := range slice {
|
||||
if s == val {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsPair verifica que flag seguido de value aparece en slice.
|
||||
func containsPair(slice []string, flag, value string) bool {
|
||||
for i := 0; i+1 < len(slice); i++ {
|
||||
if slice[i] == flag && slice[i+1] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// samplerMap traduce nombres canonicos del dominio ml a flags de stable-diffusion.cpp.
|
||||
var samplerMap = map[string]string{
|
||||
"euler": "euler",
|
||||
"euler_a": "euler_a",
|
||||
"dpm++2m": "dpmpp2m",
|
||||
"dpm++2m_v2": "dpmpp2mv2",
|
||||
"heun": "heun",
|
||||
"dpm2": "dpm2",
|
||||
"lcm": "lcm",
|
||||
}
|
||||
|
||||
// GenconfigToSdcliArgs convierte un GenerationConfig en una lista de argumentos
|
||||
// CLI para stable-diffusion.cpp (sd.exe / sd binario).
|
||||
// Espejo Go de genconfig_to_sdcpp_args_py_ml.
|
||||
//
|
||||
// Loras se emiten como pares repetidos "--lora" "path:weight".
|
||||
// Si el sampler no existe en samplerMap se usa el valor literal sin traducir.
|
||||
// La funcion es pura: sin I/O, sin estado, determinista.
|
||||
func GenconfigToSdcliArgs(cfg GenerationConfig) []string {
|
||||
args := []string{
|
||||
"--prompt", cfg.Prompt,
|
||||
"--seed", strconv.FormatInt(cfg.Seed, 10),
|
||||
"--steps", strconv.Itoa(cfg.Steps),
|
||||
"--cfg-scale", strconv.FormatFloat(cfg.CfgScale, 'f', -1, 64),
|
||||
"--width", strconv.Itoa(cfg.Width),
|
||||
"--height", strconv.Itoa(cfg.Height),
|
||||
}
|
||||
|
||||
if cfg.NegativePrompt != "" {
|
||||
args = append(args, "--negative-prompt", cfg.NegativePrompt)
|
||||
}
|
||||
|
||||
sampler := cfg.Sampler
|
||||
if mapped, ok := samplerMap[sampler]; ok {
|
||||
sampler = mapped
|
||||
}
|
||||
args = append(args, "--sampling-method", sampler)
|
||||
|
||||
if cfg.Model.Path != "" {
|
||||
args = append(args, "--model", cfg.Model.Path)
|
||||
}
|
||||
|
||||
if cfg.ClipSkip != nil {
|
||||
args = append(args, "--clip-skip", strconv.Itoa(*cfg.ClipSkip))
|
||||
}
|
||||
|
||||
for _, lora := range cfg.Loras {
|
||||
args = append(args, "--lora", fmt.Sprintf("%s:%g", lora.Path, lora.Weight))
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: genconfig_to_sdcli_args
|
||||
kind: function
|
||||
lang: go
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func GenconfigToSdcliArgs(cfg GenerationConfig) []string"
|
||||
description: "Convierte un GenerationConfig en argumentos CLI para stable-diffusion.cpp. Espejo Go de genconfig_to_sdcpp_args_py_ml. Loras se emiten como pares repetidos --lora path:weight. Sampler traducido via samplerMap canonico."
|
||||
tags: [ml, stable-diffusion, cli, args, generation, pure]
|
||||
uses_functions: []
|
||||
uses_types: [generation_config_go_ml]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["fmt", "strconv"]
|
||||
params:
|
||||
- name: cfg
|
||||
desc: "Parametros completos de generacion de imagen. Sampler debe ser uno de los valores de SamplerName. Model.Path se emite como --model si no esta vacio."
|
||||
output: "Slice de strings listos para pasar a exec.Command o similar. Incluye --prompt, --seed, --steps, --cfg-scale, --width, --height, --sampling-method, opcionales --negative-prompt / --model / --clip-skip, y pares --lora path:weight por cada LoraRef."
|
||||
tested: true
|
||||
tests:
|
||||
- "config basico sin loras ni clip_skip"
|
||||
- "loras se emiten como pares path:weight"
|
||||
- "sampler dpm++2m se traduce a dpmpp2m"
|
||||
- "negative_prompt vacio no genera flag"
|
||||
test_file_path: "functions/ml/genconfig_test.go"
|
||||
file_path: "functions/ml/genconfig_to_sdcli_args.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
clip := 2
|
||||
cfg := ml.GenerationConfig{
|
||||
Prompt: "a cat",
|
||||
Seed: 42,
|
||||
Steps: 20,
|
||||
CfgScale: 7.5,
|
||||
Sampler: "dpm++2m",
|
||||
Width: 512,
|
||||
Height: 512,
|
||||
Model: ml.ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16", Path: "/models/v1-5.safetensors"},
|
||||
Loras: []ml.LoraRef{{Path: "/loras/detail.safetensors", Weight: 0.8}},
|
||||
ClipSkip: &clip,
|
||||
}
|
||||
args := ml.GenconfigToSdcliArgs(cfg)
|
||||
// args == ["--prompt","a cat","--seed","42","--steps","20",
|
||||
// "--cfg-scale","7.5","--width","512","--height","512",
|
||||
// "--sampling-method","dpmpp2m","--model","/models/v1-5.safetensors",
|
||||
// "--clip-skip","2","--lora","/loras/detail.safetensors:0.8"]
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `samplerMap` traduce nombres canonicos del dominio ml a los identificadores que acepta stable-diffusion.cpp. Si el sampler no esta en el mapa se usa el valor literal.
|
||||
- El flag de modelo (`--model`) solo se emite si `cfg.Model.Path != ""`.
|
||||
- `%g` en `fmt.Sprintf` para el peso de la lora elimina ceros insignificantes: `0.800000` → `0.8`.
|
||||
- Funcion pura: misma entrada, misma salida. Sin I/O ni estado global.
|
||||
@@ -0,0 +1,18 @@
|
||||
package ml
|
||||
|
||||
// GenerationConfig parametriza una solicitud de generacion de imagen.
|
||||
// Espejo JSON-compatible de GenerationConfig_py_ml: los tags json coinciden
|
||||
// con los campos snake_case del dataclass Python para roundtrip sin perdida.
|
||||
type GenerationConfig struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Seed int64 `json:"seed"`
|
||||
Steps int `json:"steps"`
|
||||
CfgScale float64 `json:"cfg_scale"`
|
||||
Sampler string `json:"sampler"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Model ModelRef `json:"model"`
|
||||
Loras []LoraRef `json:"loras,omitempty"`
|
||||
ClipSkip *int `json:"clip_skip,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ml
|
||||
|
||||
// ImageGenResult contiene la imagen generada y su metadata de ejecucion.
|
||||
// ImageBytes transporta los bytes raw del PNG y se excluye del JSON
|
||||
// (campo json:"-") porque viaja por canal binario separado.
|
||||
type ImageGenResult struct {
|
||||
ImageBytes []byte `json:"-"`
|
||||
Format string `json:"format"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
VramPeakMb *int `json:"vram_peak_mb,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ml
|
||||
|
||||
import "context"
|
||||
|
||||
// ImageGenerator define el contrato para cualquier backend de generacion de imagenes.
|
||||
// Las implementaciones pueden ser locales (ComfyUI, diffusers) o remotas (API).
|
||||
type ImageGenerator interface {
|
||||
Generate(ctx context.Context, cfg GenerationConfig) (ImageGenResult, error)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ml
|
||||
|
||||
// LoraRef referencia un adaptador LoRA con su peso de fusión y escala opcional.
|
||||
type LoraRef struct {
|
||||
Path string `json:"path"`
|
||||
Weight float64 `json:"weight"`
|
||||
Scale *float64 `json:"scale,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ml
|
||||
|
||||
// ModelRef identifica un modelo de generacion de imagenes por nombre, tipo,
|
||||
// cuantizacion y path opcional en disco.
|
||||
type ModelRef struct {
|
||||
Name string `json:"name"`
|
||||
ModelType string `json:"model_type"` // sd15|sdxl|flux_dev|...
|
||||
Quantization string `json:"quantization"` // fp16|q8_0|...
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SdcliProgress contiene el estado de progreso parseado de una linea de stderr de sd-cli.
|
||||
type SdcliProgress struct {
|
||||
Step int `json:"step"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
ItPerSec float64 `json:"it_per_sec"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
// reProgress1 parsea el formato compacto: " 3/30 | 0.84it/s | 10%"
|
||||
var reProgress1 = regexp.MustCompile(`\s*(\d+)\s*/\s*(\d+)\s*\|[^|]*?([\d.]+)\s*it/s[^|]*?\|\s*([\d.]+)\s*%`)
|
||||
|
||||
// reProgress2 parsea el formato verbose: "sampling: step 3 of 30 (0.84 it/s)"
|
||||
var reProgress2 = regexp.MustCompile(`step\s+(\d+)\s+of\s+(\d+)\s*\(\s*([\d.]+)\s*it/s\)`)
|
||||
|
||||
// reProgress3 parsea el formato minimal: "step 3/30" o "progress: 3/30"
|
||||
var reProgress3 = regexp.MustCompile(`(?:progress[:\s]+)?(\d+)\s*/\s*(\d+)`)
|
||||
|
||||
// SdcliParseProgress parsea una linea de stderr de stable-diffusion.cpp / sd-cli
|
||||
// y extrae el estado de progreso. Retorna (SdcliProgress, true) si la linea
|
||||
// contiene informacion de progreso reconocible; (zero, false) en caso contrario.
|
||||
// Funcion pura: sin I/O, sin estado mutable, determinista.
|
||||
func SdcliParseProgress(line string) (SdcliProgress, bool) {
|
||||
// Formato 1: " 3/30 | 0.84it/s | 10%"
|
||||
if m := reProgress1.FindStringSubmatch(line); m != nil {
|
||||
step, err1 := strconv.Atoi(m[1])
|
||||
total, err2 := strconv.Atoi(m[2])
|
||||
itPerSec, err3 := strconv.ParseFloat(m[3], 64)
|
||||
pct, err4 := strconv.ParseFloat(m[4], 64)
|
||||
if err1 == nil && err2 == nil && err3 == nil && err4 == nil {
|
||||
return SdcliProgress{
|
||||
Step: step,
|
||||
TotalSteps: total,
|
||||
ItPerSec: itPerSec,
|
||||
Percent: pct,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
|
||||
// Formato 2: "sampling: step 3 of 30 (0.84 it/s)"
|
||||
if m := reProgress2.FindStringSubmatch(line); m != nil {
|
||||
step, err1 := strconv.Atoi(m[1])
|
||||
total, err2 := strconv.Atoi(m[2])
|
||||
itPerSec, err3 := strconv.ParseFloat(m[3], 64)
|
||||
if err1 == nil && err2 == nil && err3 == nil && total > 0 {
|
||||
pct := 100.0 * float64(step) / float64(total)
|
||||
return SdcliProgress{
|
||||
Step: step,
|
||||
TotalSteps: total,
|
||||
ItPerSec: itPerSec,
|
||||
Percent: pct,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
|
||||
// Formato 3: "step 3/30" o "progress: 3/30" sin velocidad
|
||||
if m := reProgress3.FindStringSubmatch(line); m != nil {
|
||||
step, err1 := strconv.Atoi(m[1])
|
||||
total, err2 := strconv.Atoi(m[2])
|
||||
if err1 == nil && err2 == nil && total > 0 {
|
||||
pct := 100.0 * float64(step) / float64(total)
|
||||
return SdcliProgress{
|
||||
Step: step,
|
||||
TotalSteps: total,
|
||||
ItPerSec: 0,
|
||||
Percent: pct,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
|
||||
return SdcliProgress{}, false
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: sdcli_parse_progress
|
||||
kind: function
|
||||
lang: go
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SdcliParseProgress(line string) (SdcliProgress, bool)"
|
||||
description: "Parsea una linea de stderr de stable-diffusion.cpp / sd-cli y extrae el estado de progreso. Soporta el formato compacto '3/30 | 0.84it/s | 10%', el formato verbose 'sampling: step 3 of 30 (0.84 it/s)', y el formato minimal 'progress: 3/30'. Retorna (zero, false) si la linea no contiene informacion de progreso reconocible."
|
||||
tags: [ml, stable-diffusion, sdcli, progress, parser, stderr, pure]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["regexp", "strconv"]
|
||||
params:
|
||||
- name: line
|
||||
desc: "Una linea de stderr emitida por sd-cli / stable-diffusion.cpp durante la fase de sampling. Puede contener espacios al inicio o final."
|
||||
output: "Par (SdcliProgress, bool). bool=true si se reconocio un patron de progreso; SdcliProgress contiene Step (paso actual), TotalSteps (pasos totales), ItPerSec (iteraciones por segundo, 0 si no disponible) y Percent (porcentaje 0-100 calculado o leido de la linea). bool=false y struct zero si la linea no contiene progreso."
|
||||
tested: true
|
||||
tests:
|
||||
- "formato estandar compacto step/total/itpersec/percent"
|
||||
- "linea sin patron retorna false"
|
||||
- "formato sampling verbose con velocidad"
|
||||
file_path: "functions/ml/sdcli_parse_progress.go"
|
||||
test_file_path: "functions/ml/sdcli_parse_progress_test.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
p, ok := ml.SdcliParseProgress(" 3/30 | 0.84it/s | 10%")
|
||||
// ok = true
|
||||
// p = SdcliProgress{Step:3, TotalSteps:30, ItPerSec:0.84, Percent:10.0}
|
||||
|
||||
p2, ok2 := ml.SdcliParseProgress("sampling: step 15 of 30 (1.2 it/s)")
|
||||
// ok2 = true
|
||||
// p2 = SdcliProgress{Step:15, TotalSteps:30, ItPerSec:1.2, Percent:50.0}
|
||||
|
||||
_, ok3 := ml.SdcliParseProgress("loading model...")
|
||||
// ok3 = false
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Regexps precompiladas como vars de paquete (se compilan una sola vez al init del paquete).
|
||||
- Tolerante a variaciones de espaciado gracias a `\s*` en los patrones.
|
||||
- El campo `Percent` en el formato verbose se calcula como `100 * step / total` (no se lee de la linea porque ese formato no lo emite).
|
||||
- Funcion pura: sin I/O, sin estado mutable, determinista.
|
||||
@@ -0,0 +1,103 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSdcliParseProgress_StandardFormat(t *testing.T) {
|
||||
line := " 3/30 | 0.84it/s | 10%"
|
||||
got, ok := SdcliParseProgress(line)
|
||||
if !ok {
|
||||
t.Fatalf("expected match, got false")
|
||||
}
|
||||
if got.Step != 3 {
|
||||
t.Errorf("Step: got %d, want 3", got.Step)
|
||||
}
|
||||
if got.TotalSteps != 30 {
|
||||
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
|
||||
}
|
||||
if math.Abs(got.ItPerSec-0.84) > 1e-9 {
|
||||
t.Errorf("ItPerSec: got %v, want 0.84", got.ItPerSec)
|
||||
}
|
||||
if math.Abs(got.Percent-10.0) > 1e-9 {
|
||||
t.Errorf("Percent: got %v, want 10.0", got.Percent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSdcliParseProgress_NoMatch(t *testing.T) {
|
||||
cases := []string{
|
||||
"loading model...",
|
||||
"",
|
||||
"error: out of memory",
|
||||
"clip model loaded",
|
||||
"generating image...",
|
||||
}
|
||||
for _, line := range cases {
|
||||
_, ok := SdcliParseProgress(line)
|
||||
if ok {
|
||||
t.Errorf("expected no match for %q, but got match", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSdcliParseProgress_AltFormat(t *testing.T) {
|
||||
t.Run("formato sampling verbose", func(t *testing.T) {
|
||||
line := "sampling: step 3 of 30 (0.84 it/s)"
|
||||
got, ok := SdcliParseProgress(line)
|
||||
if !ok {
|
||||
t.Fatalf("expected match, got false")
|
||||
}
|
||||
if got.Step != 3 {
|
||||
t.Errorf("Step: got %d, want 3", got.Step)
|
||||
}
|
||||
if got.TotalSteps != 30 {
|
||||
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
|
||||
}
|
||||
if math.Abs(got.ItPerSec-0.84) > 1e-9 {
|
||||
t.Errorf("ItPerSec: got %v, want 0.84", got.ItPerSec)
|
||||
}
|
||||
expectedPct := 100.0 * 3.0 / 30.0
|
||||
if math.Abs(got.Percent-expectedPct) > 1e-6 {
|
||||
t.Errorf("Percent: got %v, want %v", got.Percent, expectedPct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formato step/total sin velocidad", func(t *testing.T) {
|
||||
line := "progress: 15/20"
|
||||
got, ok := SdcliParseProgress(line)
|
||||
if !ok {
|
||||
t.Fatalf("expected match, got false")
|
||||
}
|
||||
if got.Step != 15 {
|
||||
t.Errorf("Step: got %d, want 15", got.Step)
|
||||
}
|
||||
if got.TotalSteps != 20 {
|
||||
t.Errorf("TotalSteps: got %d, want 20", got.TotalSteps)
|
||||
}
|
||||
if got.ItPerSec != 0 {
|
||||
t.Errorf("ItPerSec: got %v, want 0", got.ItPerSec)
|
||||
}
|
||||
expectedPct := 75.0
|
||||
if math.Abs(got.Percent-expectedPct) > 1e-6 {
|
||||
t.Errorf("Percent: got %v, want %v", got.Percent, expectedPct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formato con espacios variables y mayor velocidad", func(t *testing.T) {
|
||||
line := " 20/30 | 12.50it/s | 66%"
|
||||
got, ok := SdcliParseProgress(line)
|
||||
if !ok {
|
||||
t.Fatalf("expected match, got false")
|
||||
}
|
||||
if got.Step != 20 {
|
||||
t.Errorf("Step: got %d, want 20", got.Step)
|
||||
}
|
||||
if got.TotalSteps != 30 {
|
||||
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
|
||||
}
|
||||
if math.Abs(got.ItPerSec-12.5) > 1e-9 {
|
||||
t.Errorf("ItPerSec: got %v, want 12.5", got.ItPerSec)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user