package blendfile import ( "compress/gzip" "fmt" "io" "os" "os/exec" ) // ParseVersionFromReader parses the Blender version from a reader. // Returns major and minor version numbers. // // Blend file header layout (12 bytes): // // "BLENDER" (7) + pointer-size (1: '-'=64, '_'=32) + endian (1: 'v'=LE, 'V'=BE) // + version (3 digits, e.g. "402" = 4.02) // // Supports uncompressed, gzip-compressed, and zstd-compressed blend files. func ParseVersionFromReader(r io.ReadSeeker) (major, minor int, err error) { header := make([]byte, 12) n, err := r.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read blend file header: %w", err) } if string(header[:7]) != "BLENDER" { r.Seek(0, 0) return parseCompressedVersion(r) } return parseVersionDigits(header[9:12]) } // ParseVersionFromFile opens a blend file and parses the Blender version. func ParseVersionFromFile(blendPath string) (major, minor int, err error) { file, err := os.Open(blendPath) if err != nil { return 0, 0, fmt.Errorf("failed to open blend file: %w", err) } defer file.Close() return ParseVersionFromReader(file) } // VersionString returns a formatted version string like "4.2". func VersionString(major, minor int) string { return fmt.Sprintf("%d.%d", major, minor) } func parseVersionDigits(versionBytes []byte) (major, minor int, err error) { if len(versionBytes) != 3 { return 0, 0, fmt.Errorf("expected 3 version digits, got %d", len(versionBytes)) } fmt.Sscanf(string(versionBytes[0]), "%d", &major) fmt.Sscanf(string(versionBytes[1:3]), "%d", &minor) return major, minor, nil } func parseCompressedVersion(r io.ReadSeeker) (major, minor int, err error) { magic := make([]byte, 4) if _, err := r.Read(magic); err != nil { return 0, 0, err } r.Seek(0, 0) // gzip: 0x1f 0x8b if magic[0] == 0x1f && magic[1] == 0x8b { gzReader, err := gzip.NewReader(r) if err != nil { return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err) } defer gzReader.Close() header := make([]byte, 12) n, err := gzReader.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format") } return parseVersionDigits(header[9:12]) } // zstd: 0x28 0xB5 0x2F 0xFD if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd { return parseZstdVersion(r) } return 0, 0, fmt.Errorf("unknown blend file format") } func parseZstdVersion(r io.ReadSeeker) (major, minor int, err error) { r.Seek(0, 0) cmd := exec.Command("zstd", "-d", "-c") cmd.Stdin = r stdout, err := cmd.StdoutPipe() if err != nil { return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err) } if err := cmd.Start(); err != nil { return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err) } header := make([]byte, 12) n, readErr := io.ReadFull(stdout, header) cmd.Process.Kill() cmd.Wait() if readErr != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format in zstd archive") } return parseVersionDigits(header[9:12]) }