feat(ml): auto-commit con 14 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:22:02 +02:00
parent 49a924bb34
commit 8284afcba5
14 changed files with 1302 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
package ml
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"fn-registry/functions/core"
)
// SdcliProgressCallback es una funcion llamada cada vez que se parsea una linea
// de progreso del proceso sd. Puede ser nil.
type SdcliProgressCallback func(p SdcliProgress)
// SdcliGenerate ejecuta el binario sd para generar una imagen y escribe el
// resultado en outPath.
//
// Flujo:
// 1. Construye args con GenconfigToSdcliArgs(cfg) + ["-o", outPath].
// 2. Lanza el proceso via SubprocessStream.
// 3. Goroutine interna lee eventos: lineas stderr se pasan a SdcliParseProgress;
// si onProgress != nil y hay progreso reconocible, llama onProgress(p).
// 4. Espera el resultado. ExitCode != 0 => error con las ultimas 10 lineas de stderr.
// 5. Lee outPath y retorna ImageGenResult con bytes, meta y duration_ms.
//
// ctx controla el timeout y cancelacion: se pasa directamente a SubprocessStream,
// que maneja SIGTERM -> grace 2s -> SIGKILL.
func SdcliGenerate(
ctx context.Context,
bin SdcliBinary,
cfg GenerationConfig,
outPath string,
onProgress SdcliProgressCallback,
) (ImageGenResult, error) {
start := time.Now()
args := GenconfigToSdcliArgs(cfg)
args = append(args, "-o", outPath)
events, results := core.SubprocessStream(ctx, bin.Path, args, nil, nil)
// Consumir eventos en goroutine: parsear progreso y acumular stderr para
// mensajes de error utiles.
type collectedStderr struct {
lines []string
}
stderrCh := make(chan collectedStderr, 1)
go func() {
var stderrLines []string
for ev := range events {
if ev.Stream == "stderr" {
stderrLines = append(stderrLines, ev.Line)
if onProgress != nil {
if p, ok := SdcliParseProgress(ev.Line); ok {
onProgress(p)
}
}
}
}
stderrCh <- collectedStderr{lines: stderrLines}
}()
res := <-results
collected := <-stderrCh
if res.Err != nil {
return ImageGenResult{}, fmt.Errorf("sdcli subprocess: %w", res.Err)
}
if res.ExitCode != 0 {
tail := stderrTail(collected.lines, 10)
return ImageGenResult{}, fmt.Errorf(
"sdcli exited with code %d:\n%s",
res.ExitCode, tail,
)
}
imageBytes, err := os.ReadFile(outPath)
if err != nil {
return ImageGenResult{}, fmt.Errorf("sdcli: reading output image %q: %w", outPath, err)
}
durationMs := time.Since(start).Milliseconds()
meta := map[string]any{
"backend": "sdcli",
"binary_path": bin.Path,
"model": cfg.Model.Name,
"seed": cfg.Seed,
"steps": cfg.Steps,
"cfg_scale": cfg.CfgScale,
"sampler": cfg.Sampler,
"width": cfg.Width,
"height": cfg.Height,
}
if bin.Version != "" {
meta["version"] = bin.Version
}
if cfg.Model.Path != "" {
meta["model_path"] = cfg.Model.Path
}
if cfg.Model.ModelType != "" {
meta["model_type"] = cfg.Model.ModelType
}
if cfg.Model.Quantization != "" {
meta["quantization"] = cfg.Model.Quantization
}
if len(cfg.Loras) > 0 {
loras := make([]string, len(cfg.Loras))
for i, l := range cfg.Loras {
loras[i] = l.Path + ":" + strconv.FormatFloat(l.Weight, 'f', -1, 64)
}
meta["loras"] = strings.Join(loras, ",")
}
return ImageGenResult{
ImageBytes: imageBytes,
Format: "png",
Meta: meta,
DurationMs: durationMs,
}, nil
}
// stderrTail retorna las ultimas n lineas de lines, unidas con newline.
func stderrTail(lines []string, n int) string {
if len(lines) <= n {
return strings.Join(lines, "\n")
}
return strings.Join(lines[len(lines)-n:], "\n")
}
+92
View File
@@ -0,0 +1,92 @@
---
name: sdcli_generate
kind: function
lang: go
domain: ml
version: "1.0.0"
purity: impure
signature: "func SdcliGenerate(ctx context.Context, bin SdcliBinary, cfg GenerationConfig, outPath string, onProgress SdcliProgressCallback) (ImageGenResult, error)"
description: "Ejecuta el binario sd de stable-diffusion.cpp para generar una imagen. Construye los args CLI via GenconfigToSdcliArgs, lanza el proceso via SubprocessStream, parsea el progreso de stderr en tiempo real via SdcliParseProgress, y retorna ImageGenResult con los bytes PNG, metadata y duration_ms."
tags: [ml, sdcli, stablediffusion, imagegen, subprocess, inference, cpp]
uses_functions:
- subprocess_stream_go_core
- genconfig_to_sdcli_args_go_ml
- sdcli_parse_progress_go_ml
uses_types:
- generation_config_go_ml
- image_gen_result_go_ml
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["context", "fmt", "os", "strconv", "strings", "time", "fn-registry/functions/core"]
params:
- name: ctx
desc: "Context para cancelacion y timeout. Se pasa a SubprocessStream que gestiona SIGTERM -> grace 2s -> SIGKILL."
- name: bin
desc: "Binario sd resuelto via SdcliResolveBinary. Contiene path absoluto y version."
- name: cfg
desc: "Parametros de generacion: prompt, seed, steps, sampler, model, loras, etc."
- name: outPath
desc: "Path donde sd escribe la imagen PNG generada. El archivo se lee y se incluye en ImageGenResult.ImageBytes."
- name: onProgress
desc: "Callback opcional llamado con cada SdcliProgress parseado de stderr. Nil es valido."
output: "ImageGenResult con ImageBytes (bytes del PNG), Format='png', Meta (backend, binary_path, model, seed, steps, etc.) y DurationMs medido desde el inicio de la llamada."
tested: true
tests:
- "integration test skipped when sd binary not in PATH"
test_file_path: "functions/ml/sdcli_test.go"
file_path: "functions/ml/sdcli_generate.go"
---
## Ejemplo
```go
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
bin, err := SdcliResolveBinary("")
if err != nil {
log.Fatal(err)
}
cfg := GenerationConfig{
Prompt: "a red apple on a wooden table",
Seed: 42,
Steps: 20,
CfgScale: 7.0,
Sampler: "euler_a",
Width: 512,
Height: 512,
Model: ModelRef{
Name: "sd15",
ModelType: "sd15",
Quantization: "fp16",
Path: "/path/to/model.safetensors",
},
}
result, err := SdcliGenerate(ctx, bin, cfg, "/tmp/out.png", func(p SdcliProgress) {
fmt.Printf("step %d/%d (%.1f%%)\n", p.Step, p.TotalSteps, p.Percent)
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("generated %d bytes in %dms\n", len(result.ImageBytes), result.DurationMs)
```
## Notas
El binario `sd` de stable-diffusion.cpp escribe la imagen directamente en disco
(`-o outPath`). La funcion lee el archivo tras la finalizacion del proceso y
carga los bytes en `ImageGenResult.ImageBytes`.
Si el proceso termina con `ExitCode != 0`, el error incluye las ultimas 10 lineas
de stderr para facilitar el diagnostico.
El callback `onProgress` se llama desde la goroutine de lectura de eventos.
Si el callback hace I/O o es lento, considera usar un canal con buffer para
desacoplar.
Para modelos SD Turbo / SDXL Turbo con `steps <= 4` y `cfg_scale = 1.0`, el
sampler `euler_a` es el recomendado. Para SD 1.5 estandar usar `euler` o
`dpm++2m` con `steps >= 20`.
+88
View File
@@ -0,0 +1,88 @@
package ml
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// SdcliBinary describe el binario sd resuelto: su path absoluto, version detectada
// y como fue localizado.
type SdcliBinary struct {
Path string `json:"path"`
Version string `json:"version,omitempty"`
Source string `json:"source"` // "config" | "path"
}
// SdcliResolveBinary localiza el binario sd / sd-cli de stable-diffusion.cpp.
//
// Orden de busqueda:
// 1. Si hint != "" y el archivo existe y es ejecutable: usar como "config".
// 2. exec.LookPath("sd"): primer candidato en PATH.
// 3. exec.LookPath("sd-cli"): segundo candidato en PATH.
// 4. Error descriptivo si no se encuentra ninguno.
//
// Tras localizar el binario, intenta obtener la version ejecutando
// `<bin> --version` con timeout de 3 segundos. Si el comando falla
// o no produce output, Version queda vacia (no es error fatal).
func SdcliResolveBinary(hint string) (SdcliBinary, error) {
var binPath string
var source string
if hint != "" {
info, err := os.Stat(hint)
if err != nil {
return SdcliBinary{}, fmt.Errorf("sdcli hint %q: %w", hint, err)
}
if info.Mode()&0o111 == 0 {
return SdcliBinary{}, fmt.Errorf("sdcli hint %q: file exists but is not executable", hint)
}
binPath = hint
source = "config"
}
if binPath == "" {
if p, err := exec.LookPath("sd"); err == nil {
binPath = p
source = "path"
}
}
if binPath == "" {
if p, err := exec.LookPath("sd-cli"); err == nil {
binPath = p
source = "path"
}
}
if binPath == "" {
return SdcliBinary{}, fmt.Errorf(
"sd binary not found in PATH (hint: install from leejet/stable-diffusion.cpp)",
)
}
version := sdcliProbeVersion(binPath)
return SdcliBinary{
Path: binPath,
Version: version,
Source: source,
}, nil
}
// sdcliProbeVersion ejecuta `<bin> --version` con timeout 3s y retorna
// la primera linea de la salida. Retorna "" si el comando falla o no
// produce output; no propaga el error (version es best-effort).
func sdcliProbeVersion(binPath string) string {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binPath, "--version").Output()
if err != nil {
return ""
}
line := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
return line
}
+55
View File
@@ -0,0 +1,55 @@
---
name: sdcli_resolve_binary
kind: function
lang: go
domain: ml
version: "1.0.0"
purity: impure
signature: "func SdcliResolveBinary(hint string) (SdcliBinary, error)"
description: "Localiza el binario sd / sd-cli de stable-diffusion.cpp. Busca en orden: hint explicito, LookPath('sd'), LookPath('sd-cli'). Detecta la version ejecutando --version con timeout 3s. Retorna SdcliBinary con path, version y fuente de resolucion."
tags: [ml, sdcli, stablediffusion, binary, subprocess, imagegen]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["context", "fmt", "os", "os/exec", "strings", "time"]
params:
- name: hint
desc: "Path explicito al binario sd. Si es string vacio se busca en PATH. Si no es vacio debe existir y ser ejecutable."
output: "SdcliBinary con Path absoluto, Version detectada (puede ser vacia si --version falla) y Source ('config' si viene de hint, 'path' si viene de LookPath)."
tested: true
tests:
- "missing binary returns error when PATH empty"
- "hint path resolves to config source"
test_file_path: "functions/ml/sdcli_test.go"
file_path: "functions/ml/sdcli_resolve_binary.go"
---
## Ejemplo
```go
// Buscar automaticamente en PATH
bin, err := SdcliResolveBinary("")
if err != nil {
log.Fatal(err)
}
fmt.Printf("sd found at %s (version: %s)\n", bin.Path, bin.Version)
// Hint explicito (ej. desde config de usuario)
bin, err = SdcliResolveBinary("/opt/stable-diffusion/sd")
```
## Notas
`SdcliBinary` es el token de resolucion que se pasa a `SdcliGenerate`. Separar
la resolucion de la ejecucion permite validar el binario al arrancar la app sin
lanzar una generacion.
La deteccion de version es best-effort: si `sd --version` no existe o falla,
`Version` queda vacia y no se propaga error. Algunos builds de stable-diffusion.cpp
no implementan `--version`; en ese caso `Version == ""` es el comportamiento
esperado.
`Source` distingue binarios configurados explicitamente (`"config"`) de los
encontrados en PATH (`"path"`), util para logging y diagnostico.
+114
View File
@@ -0,0 +1,114 @@
package ml
import (
"context"
"os"
"testing"
"time"
)
// TestSdcliResolveBinary_NotFound verifica que SdcliResolveBinary retorna error
// cuando no hay binario en PATH ni hint. Forzamos PATH="" para que LookPath
// no encuentre nada, lo que hace el test determinista independientemente del
// entorno del desarrollador.
func TestSdcliResolveBinary_NotFound(t *testing.T) {
t.Setenv("PATH", "")
_, err := SdcliResolveBinary("")
if err == nil {
t.Fatal("expected error when sd not in PATH, got nil")
}
}
// TestSdcliResolveBinary_Hint verifica que un hint valido (archivo ejecutable)
// se resuelve con Source="config" sin necesidad de PATH.
func TestSdcliResolveBinary_Hint(t *testing.T) {
// Crear archivo temporal ejecutable que simula el binario sd.
// El script simplemente sale con 0; --version devolvera string vacio
// pero eso no es error (version es best-effort).
f, err := os.CreateTemp("", "sd-test-*")
if err != nil {
t.Fatalf("creating temp file: %v", err)
}
defer os.Remove(f.Name())
f.Close()
script := []byte("#!/bin/sh\necho 'sd-test 0.1'\n")
if err := os.WriteFile(f.Name(), script, 0o755); err != nil {
t.Fatalf("writing temp file: %v", err)
}
if err := os.Chmod(f.Name(), 0o755); err != nil {
t.Fatalf("chmod temp file: %v", err)
}
bin, err := SdcliResolveBinary(f.Name())
if err != nil {
t.Fatalf("SdcliResolveBinary(hint): %v", err)
}
if bin.Source != "config" {
t.Fatalf("expected source=config, got %q", bin.Source)
}
if bin.Path != f.Name() {
t.Fatalf("expected path=%q, got %q", f.Name(), bin.Path)
}
}
// TestSdcliGenerate_RequiresBinary es un test de integracion que salta si el
// binario sd no esta instalado en PATH. Si esta disponible, tambien requiere
// el modelo SD Turbo en el vault para ejecutar una generacion real.
func TestSdcliGenerate_RequiresBinary(t *testing.T) {
bin, err := SdcliResolveBinary("")
if err != nil {
t.Skipf("sd binary not in PATH, skipping integration test: %v", err)
}
modelPath := "/home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors"
if _, err := os.Stat(modelPath); err != nil {
t.Skipf("SD Turbo model not in vault (%s), skipping: %v", modelPath, err)
}
cfg := GenerationConfig{
Prompt: "a red apple",
Seed: 42,
Steps: 4,
CfgScale: 1.0,
Sampler: "euler_a",
Width: 512,
Height: 512,
Model: ModelRef{
Name: "sd-turbo",
ModelType: "sd15",
Quantization: "fp16",
Path: modelPath,
},
}
outPath := t.TempDir() + "/out.png"
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var progressCalled bool
res, err := SdcliGenerate(ctx, bin, cfg, outPath, func(p SdcliProgress) {
progressCalled = true
t.Logf("progress: step %d/%d (%.1f%%) %.2fit/s",
p.Step, p.TotalSteps, p.Percent, p.ItPerSec)
})
if err != nil {
t.Fatalf("SdcliGenerate: %v", err)
}
if len(res.ImageBytes) == 0 {
t.Fatal("expected non-empty image bytes")
}
if res.Format != "png" {
t.Fatalf("expected format=png, got %q", res.Format)
}
if res.DurationMs <= 0 {
t.Fatalf("expected positive duration_ms, got %d", res.DurationMs)
}
if res.Meta["backend"] != "sdcli" {
t.Fatalf("expected meta.backend=sdcli, got %v", res.Meta["backend"])
}
t.Logf("generated %d bytes in %dms (progress_called=%v)",
len(res.ImageBytes), res.DurationMs, progressCalled)
}