Ctrl K
ComunidadGuías…

internal/autogen/video_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autogen

import (
	"strings"
	"testing"
)

// Detection has to survive the two traps described in video.go: a model with no
// metadata at all must still be found, and arch "wan" must NOT be treated as
// video (it is ERNIE-Image-Turbo, an image model).
func TestIsVideoModel(t *testing.T) {
	h3 := Metadata{VideoKind: VideoFamilyMinimaxH3, HasAudioOut: true}
	if !IsVideoModel(h3) {
		t.Error("MiniMax-H3 (no arch, video tensors) should classify as video")
	}
	ernie := Metadata{Architecture: "wan"}
	if IsVideoModel(ernie) {
		t.Error("arch=wan alone is ERNIE-Image-Turbo, an IMAGE model, not video")
	}
	if !isImageArch(ernie.Architecture) {
		t.Error("arch=wan must keep taking the image path")
	}
}

func TestEmitVideoModel_MinimaxH3(t *testing.T) {
	var b strings.Builder
	var emitted []string
	onDisk(t, "h3_video_vae.safetensors", "h3_audio_vae.safetensors", "qwen3vl32b.gguf")
	enc := EncoderSet{VideoVae: "h3_video_vae.safetensors", AudioVae: "h3_audio_vae.safetensors", QwenLlm: "qwen3vl32b.gguf"}
	s := Settings{SdServerExe: "sd-server", TtlSec: 600, TargetVramGB: 24, VramOverheadGB: 1, Threads: 7, Encoders: enc}
	row := GgufRow{FullPath: `D:\models\minimax_h3-Q4_K_M.gguf`, SizeGB: 8.0}
	vid := videoInfo{Kind: VideoFamilyMinimaxH3, AudioOut: true}

	// Arch is empty on purpose: H3's gguf carries no KVs whatsoever.
	emitVideoModel(&b, s, row, &Override{}, "minimax-h3", "", vid, 5120, &emitted)
	out := b.String()

	for _, want := range []string{
		"sd-server",
		"--diffusion-model D:/models/minimax_h3-Q4_K_M.gguf",
		"--vae h3_video_vae.safetensors",
		"--audio-vae h3_audio_vae.safetensors",
		"--llm qwen3vl32b.gguf",
		// Both of these are load-bearing, not preferences: the built-in
		// cfg-scale is 7.0 (H3 conditions at 1.0) and --video-frames is 1.
		// 56 is on H3's 17k+5 alignment grid; 25 would become 39.
		"--cfg-scale 1",
		"--video-frames 56",
		"--fps 24",
		"out: [video, audio]",
		"family=minimax_h3",
		"(none declared)",
		"checkEndpoint: /",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("emit missing %q:\n%s", want, out)
		}
	}
	if strings.Contains(out, "WARNING") {
		t.Errorf("complete component set should not warn:\n%s", out)
	}
	// H3 must NOT pin --steps. The base model is not distilled, so sd-server's
	// own 20 has to stand: the 4-step figure belongs to the turbo LoRAs, which
	// arrive per request and cannot be known at launch.
	if strings.Contains(out, "--steps") {
		t.Errorf("H3 must not pin --steps (base model is not distilled):\n%s", out)
	}
	// 8GB weights + 4GB video compute overhead fits a 23GB budget.
	if strings.Contains(out, "--offload-to-cpu") {
		t.Errorf("model that fits should not offload:\n%s", out)
	}
	if len(emitted) != 1 || emitted[0] != "minimax-h3" {
		t.Errorf("emitted = %v, want [minimax-h3]", emitted)
	}
}

// A video model whose components are not on disk must SAY so in the config
// rather than emit a command that fails at the first request.
func TestEmitVideoModel_MissingComponents(t *testing.T) {
	var b strings.Builder
	var emitted []string
	s := Settings{SdServerExe: "sd-server", TtlSec: 600, TargetVramGB: 8, VramOverheadGB: 1, Threads: 4}
	row := GgufRow{FullPath: `D:\models\h3.gguf`, SizeGB: 8.0}

	emitVideoModel(&b, s, row, &Override{}, "h3", "", videoInfo{Kind: VideoFamilyMinimaxH3, AudioOut: true}, 5120, &emitted)
	out := b.String()
	for _, want := range []string{"WARNING", "vae", "llm", "audio_vae"} {
		if !strings.Contains(out, want) {
			t.Errorf("missing-component emit should name %q:\n%s", want, out)
		}
	}
	// The roles also travel as data, so the Load path can log them.
	if !strings.Contains(out, "missingComponents: [vae, llm, audio_vae]") {
		t.Errorf("missing roles should be emitted as missingComponents:\n%s", out)
	}
	// 8GB + 4GB video overhead blows a 7GB budget.
	if !strings.Contains(out, "--offload-to-cpu") {
		t.Errorf("oversized video model should offload:\n%s", out)
	}
}

// An audio-less video family wires no --audio-vae and declares no audio out.
func TestEmitVideoModel_WanNoAudio(t *testing.T) {
	onDisk(t, "umt5.gguf")
	var b strings.Builder
	var emitted []string
	s := Settings{SdServerExe: "sd-server", TtlSec: 600, TargetVramGB: 24, VramOverheadGB: 1, Threads: 7,
		Encoders: EncoderSet{T5: "umt5.gguf"}}
	row := GgufRow{FullPath: `D:\models\wan22.gguf`, SizeGB: 9.0}

	emitVideoModel(&b, s, row, &Override{}, "wan22", "wan", videoInfo{Kind: VideoFamilyWan}, 0, &emitted)
	out := b.String()
	if strings.Contains(out, "--audio-vae") {
		t.Errorf("Wan has no audio branch, should wire no audio vae:\n%s", out)
	}
	if !strings.Contains(out, "out: [video]") {
		t.Errorf("Wan should declare video-only output:\n%s", out)
	}
	for _, want := range []string{"--t5xxl umt5.gguf", "--video-frames 81", "--fps 16"} {
		if !strings.Contains(out, want) {
			t.Errorf("emit missing %q:\n%s", want, out)
		}
	}
	// Wan is not distilled, so steps/cfg stay at sd-server's own defaults.
	if strings.Contains(out, "--cfg-scale") || strings.Contains(out, "--steps") {
		t.Errorf("Wan should not pin steps/cfg:\n%s", out)
	}
}

// Per-model overrides still win over the family profile.
func TestVideoDefaults_OverrideWins(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4}
	row := GgufRow{FullPath: `D:\models\h3.gguf`, SizeGB: 8.0}
	ov := &Override{DefaultFrames: 49, DefaultFps: 30, DefaultCfg: 1.5, AudioVaePath: "ov_audio.safetensors"}

	lines, _, _, _, _ := imageCmdLines(s, row, ov, "", "h3", 5120, videoInfo{Kind: VideoFamilyMinimaxH3, AudioOut: true})
	joined := lines.Effective
	for _, want := range []string{"--video-frames 49", "--fps 30", "--cfg-scale 1.5", "--audio-vae ov_audio.safetensors"} {
		if !strings.Contains(joined, want) {
			t.Errorf("override missing %q in: %s", want, joined)
		}
	}
	if strings.Contains(joined, "--video-frames 56") {
		t.Errorf("family default should have been replaced: %s", joined)
	}
}

// An image model must emit exactly what it did before video existed: no frames,
// no fps, and only the overheads/defaults it always had.
func TestImageCmdLines_UntouchedByVideo(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4,
		Encoders: EncoderSet{FluxVae: "ae.safetensors", ClipL: "cl.safetensors", T5: "t5.gguf"}}
	row := GgufRow{FullPath: `D:\models\flux.gguf`, SizeGB: 6.0}

	lines, _, _, _, _ := imageCmdLines(s, row, &Override{}, "flux", "flux1-dev", 0, videoInfo{})
	joined := lines.Effective
	for _, unwant := range []string{"--video-frames", "--fps", "--audio-vae", "--cfg-scale", "--steps"} {
		if strings.Contains(joined, unwant) {
			t.Errorf("image cmd should not carry %q: %s", unwant, joined)
		}
	}
}

// The two long-clip VRAM levers are ON by default for a video model and emitted
// for nothing else. --vae-tiling caps the decode spatially, which is the whole
// story for a still; a clip's decode also grows along TIME, and only
// --temporal-tiling chunks that axis. --stream-layers frees the weight residency
// the sampler then spends on latents.
//
// The row here is deliberately sized to OFFLOAD (20GB against a 23GB budget),
// because --stream-layers has a precondition the other lever does not: sd.cpp
// ignores it outright unless the diffusion params live in RAM, which only
// --offload-to-cpu arranges. A resident-weights fixture would assert a flag the
// backend logs and discards. See TestStreamLayers_FollowsOffload.
//
// Wan is the family used here because it is the one whose VAE implements a tiled
// decode: see TestTemporalTiling_GatedOnVaeSupport for the other half.
func TestVideoVramLevers_DefaultOnForVideoOnly(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4,
		Encoders: EncoderSet{T5: "umt5.safetensors"}}
	row := GgufRow{FullPath: `D:\models\wan2.2-t2v.gguf`, SizeGB: 20.0}
	vid := videoInfo{Kind: VideoFamilyWan}

	lines, _, _, _, _ := imageCmdLines(s, row, &Override{}, "", "wan", 0, vid)
	joined := lines.Effective
	for _, want := range []string{"--temporal-tiling", "--stream-layers"} {
		if !strings.Contains(joined, want) {
			t.Errorf("video cmd missing default %q: %s", want, joined)
		}
	}

	// Each toggles off independently: they are separate knobs precisely because
	// --stream-layers trades PCIe traffic for VRAM and --temporal-tiling does not.
	off, _, _, _, _ := imageCmdLines(s, row, &Override{TemporalTiling: "off"}, "", "wan", 0, vid)
	j := off.Effective
	if strings.Contains(j, "--temporal-tiling") {
		t.Errorf("temporalTiling=off must suppress the flag: %s", j)
	}
	if !strings.Contains(j, "--stream-layers") {
		t.Errorf("temporalTiling=off must not touch --stream-layers: %s", j)
	}
	off2, _, _, _, _ := imageCmdLines(s, row, &Override{StreamLayers: "off"}, "", "wan", 0, vid)
	j2 := off2.Effective
	if strings.Contains(j2, "--stream-layers") {
		t.Errorf("streamLayers=off must suppress the flag: %s", j2)
	}
	if !strings.Contains(j2, "--temporal-tiling") {
		t.Errorf("streamLayers=off must not touch --temporal-tiling: %s", j2)
	}

	// An image model has no temporal axis to tile and no long-clip peak to
	// relieve, so neither flag belongs in its launch line at all.
	img := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4,
		Encoders: EncoderSet{FluxVae: "ae.safetensors", ClipL: "cl.safetensors", T5: "t5.gguf"}}
	imgRow := GgufRow{FullPath: `D:\models\flux.gguf`, SizeGB: 6.0}
	iLines, _, _, _, _ := imageCmdLines(img, imgRow, &Override{}, "flux", "flux1-dev", 0, videoInfo{})
	iJoined := iLines.Effective
	for _, unwant := range []string{"--temporal-tiling", "--stream-layers"} {
		if strings.Contains(iJoined, unwant) {
			t.Errorf("image cmd should not carry %q: %s", unwant, iJoined)
		}
	}
}

// The hand-declared path has no tensor scan, so it cannot tell a video model
// from an image one: there the levers are explicit opt-in, not default-on.
func TestExtraImageVramLevers_OptIn(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4}

	plain := extraImageCmdLines(s, ExtraImageModel{
		Name: "h3-st", ModelPath: `D:\models\h3.safetensors`, ModelFlag: "--diffusion-model",
	}).Effective
	for _, unwant := range []string{"--temporal-tiling", "--stream-layers"} {
		if strings.Contains(plain, unwant) {
			t.Errorf("extra model must not default %q on: %s", unwant, plain)
		}
	}

	on := extraImageCmdLines(s, ExtraImageModel{
		Name: "h3-st", ModelPath: `D:\models\h3.safetensors`, ModelFlag: "--diffusion-model",
		TemporalTiling: "on", StreamLayers: "on",
	}).Effective
	for _, want := range []string{"--temporal-tiling", "--stream-layers"} {
		if !strings.Contains(on, want) {
			t.Errorf("extra model opt-in missing %q: %s", want, on)
		}
	}
}

// --temporal-tiling is emitted only for a family whose VAE can actually decode
// in windows along time. sd-server takes the flag from anyone and falls back
// with "does not support temporal tiling ...; processing the full temporal
// dimension", so an ungated flag would put a VRAM lever in the launch line that
// the decode never applies: not an error, just a lie about what is running.
func TestTemporalTiling_GatedOnVaeSupport(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4,
		Encoders: EncoderSet{VideoVae: "vae.safetensors", QwenLlm: "qwen.gguf"}}
	row := GgufRow{FullPath: `D:\models\h3.gguf`, SizeGB: 8.0}
	h3 := videoInfo{Kind: VideoFamilyMinimaxH3}

	// H3's VAE is a transformer autoencoder with no tiled decode path.
	def, _, _, _, _ := imageCmdLines(s, row, &Override{}, "", "h3", 5120, h3)
	j := def.Effective
	if strings.Contains(j, "--temporal-tiling") {
		t.Errorf("H3 VAE cannot tile along time, flag must not be emitted: %s", j)
	}
	// The other lever is untouched by the VAE question, but it has a precondition
	// of its own: this row fits resident (8GB against 23GB), so the weights are
	// not in RAM and sd.cpp would ignore --stream-layers. Absent is correct here.
	if strings.Contains(j, "--stream-layers") {
		t.Errorf("--stream-layers is ignored without offload, do not emit it: %s", j)
	}

	// An explicit "on" forces it anyway, so a backend build that gains support
	// needs an override row rather than a recompile.
	on, _, _, _, _ := imageCmdLines(s, row, &Override{TemporalTiling: "on"}, "", "h3", 5120, h3)
	if !strings.Contains(on.Effective, "--temporal-tiling") {
		t.Errorf("explicit temporalTiling=on must force the flag: %s", on.Effective)
	}
}

// --stream-layers is emitted only where it does something. sd.cpp: "--stream-layers
// has no effect unless diffusion params backend is cpu; ignoring", and the only
// thing that puts the diffusion params in RAM is --offload-to-cpu. So the flag
// tracks the offload decision rather than the video/image one, and a video model
// that fits resident must not carry it.
//
// This is not cosmetic. The flag reads in a launch line like a VRAM lever that
// has already been pulled, so emitting it beside resident weights is how a clip
// that OOMs looks like it was already doing everything it could.
func TestStreamLayers_FollowsOffload(t *testing.T) {
	s := Settings{SdServerExe: "sd-server", TargetVramGB: 24, VramOverheadGB: 1, Threads: 4,
		Encoders: EncoderSet{T5: "umt5.safetensors"}}
	vid := videoInfo{Kind: VideoFamilyWan}

	// 8GB + the 4GB video compute pad fits a 23GB budget: weights stay resident.
	fits := GgufRow{FullPath: `D:\models\wan-small.gguf`, SizeGB: 8.0}
	j := strings.Join(mustLines(imageCmdLines(s, fits, &Override{}, "", "wan", 0, vid)), " ")
	if strings.Contains(j, "--stream-layers") {
		t.Errorf("resident weights: sd.cpp ignores --stream-layers, do not emit it: %s", j)
	}
	if strings.Contains(j, "--offload-to-cpu") {
		t.Fatalf("fixture no longer exercises the resident path: %s", j)
	}

	// 20GB + 4GB does not: --offload-to-cpu puts the params in RAM, which is the
	// one configuration where streaming them back is a real lever.
	big := GgufRow{FullPath: `D:\models\wan-big.gguf`, SizeGB: 20.0}
	j2 := strings.Join(mustLines(imageCmdLines(s, big, &Override{}, "", "wan", 0, vid)), " ")
	if !strings.Contains(j2, "--offload-to-cpu") {
		t.Fatalf("fixture no longer exercises the offload path: %s", j2)
	}
	if !strings.Contains(j2, "--stream-layers") {
		t.Errorf("offloaded weights: --stream-layers must be emitted: %s", j2)
	}

	// An explicit "on" still forces it through, the escape hatch for a backend
	// build that widens the rule.
	j3 := strings.Join(mustLines(imageCmdLines(s, fits, &Override{StreamLayers: "on"}, "", "wan", 0, vid)), " ")
	if !strings.Contains(j3, "--stream-layers") {
		t.Errorf("explicit streamLayers=on must force the flag: %s", j3)
	}
}

// mustLines drops imageCmdLines' sizing returns, which these tests read off the
// rendered argv instead.
func mustLines(cmd ComposedCmd, _, _ float64, _ bool, _ []string) []string { return cmd.Lines }

// --max-vram is a graph-cut budget, not a VRAM cap, so it prices the headroom
// left AFTER everything we told sd-server to keep on the card. Handing it the
// whole budget while the weights sit in that same VRAM is what let a 24GB card
// fail a 2.7GiB compute-buffer allocation on LTX-2.5.
func TestGraphBudget_PricesHeadroomNotTheCard(t *testing.T) {
	row := GgufRow{SizeGB: 14.6}
	comp := imageComponents{}

	// Resident weights come off the top.
	if got := graphBudget(22.3, row, comp, nil, false); got != 7.7 {
		t.Errorf("resident: graphBudget = %v, want 7.7", got)
	}
	// Offloaded weights do not: they are in RAM, so the card is the graph's.
	if got := graphBudget(22.3, row, comp, nil, true); got != 22.3 {
		t.Errorf("offloaded: graphBudget = %v, want 22.3", got)
	}
	// No headroom left hands the question to sd-server rather than guessing a
	// small positive number: -1 is the RESERVE form ("live free VRAM, sparing
	// 1 GiB"). Neither 0 (which means "disable graph splitting") nor a literal
	// 1 (a one-gigabyte graph budget, which GGML_ASSERTs on a video model) is
	// the answer, and the planner knows where the params actually landed.
	if got := graphBudget(14.0, row, comp, nil, false); got != -1 {
		t.Errorf("over budget: graphBudget = %v, want the -1 reserve form", got)
	}
}