feat(ml): auto-commit con 14 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user