1package authoring23import (4 "context"5 "fmt"6 "mime"7 "os"8 "path/filepath"9 "sort"10 "strings"1112 "github.com/FelineStateMachine/atlas/format/vnext"13)1415type Event struct {16 Stage string `json:"stage"`17 Message string `json:"message"`18 Current int `json:"current,omitempty"`19 Total int `json:"total,omitempty"`20 Bytes int64 `json:"bytes,omitempty"`21 Cached bool `json:"cached,omitempty"`22 Artifact string `json:"artifact,omitempty"`23}2425type BuildOptions struct {26 ProjectPath string27 CacheDir string28 LibraryDir string29 Offline bool30 ReplayPath string31 PlanOnly bool32 Event func(Event)33}3435type BuildResult struct {36 Plan Plan `json:"plan"`37 Descriptor vnext.Descriptor `json:"descriptor,omitempty"`38 Path string `json:"path,omitempty"`39 Present bool `json:"present,omitempty"`40}4142func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {43 project, err := LoadProject(options.ProjectPath)44 if err != nil {45 return BuildResult{}, err46 }47 cache := OpenCache(options.CacheDir)48 plan, err := PlanProject(project, cache, options.LibraryDir)49 if err != nil {50 return BuildResult{}, err51 }52 emit(options, Event{Stage: "plan", Message: "build planned", Total: len(plan.Requests), Bytes: plan.Estimated})53 result := BuildResult{Plan: plan}54 if options.PlanOnly {55 return result, nil56 }57 var evidence buildEvidence58 if options.ReplayPath != "" {59 selection, err := loadEvidenceSelection(options.ReplayPath)60 if err != nil {61 return result, err62 }63 evidence, err = replayPlanEvidence(plan, selection, cache)64 if err != nil {65 return result, err66 }67 } else {68 evidence, err = acquirePlanEvidence(ctx, plan, cache, options.Offline)69 if err != nil {70 return result, err71 }72 }73 for index, planned := range plan.Requests {74 set := evidence.Sets[planned.ID]75 emit(options, Event{Stage: "capture", Message: planned.Source, Current: index + 1, Total: len(plan.Requests), Bytes: set.TotalBytes, Cached: evidence.Cached[planned.ID]})76 }77 captures := evidence.Captures78 var observations []observation79 for sourceIndex, source := range project.Sources {80 planned, ok := featurePlan(plan, source.ID)81 if !ok {82 return result, fmt.Errorf("source %s has no planned request", source.ID)83 }84 root, ok := captureRootForRequest(evidence.Sets[planned.ID], planned.Request)85 if !ok {86 return result, fmt.Errorf("source %s has no captured root", source.ID)87 }88 for _, capture := range root.Pages {89 body, err := os.ReadFile(cache.BlobPath(capture.Body))90 if err != nil {91 return result, fmt.Errorf("read captured source %s: %w", source.ID, err)92 }93 items, err := observeCapturedPage(project, sourceIndex, source, capture, body)94 if err != nil {95 return result, err96 }97 observations = append(observations, items...)98 }99 emit(options, Event{Stage: "observe", Message: source.ID, Current: sourceIndex + 1, Total: len(project.Sources)})100 }101 volume, err := assemble(project, observations)102 if err != nil {103 return result, err104 }105 if err := enforceSemanticBudgets(volume, plan.Budgets); err != nil {106 return result, err107 }108 if err := attachAssets(project, plan, captures, cache, &volume); err != nil {109 return result, err110 }111 rasterBlobs, err := attachRasters(project, plan, captures, cache, &volume.Worlds[0])112 if err != nil {113 return result, err114 }115 receipt, createdAt, err := buildReceiptSets(plan, captures, evidence.Sets)116 if err != nil {117 return result, err118 }119 volume.Assets = append(volume.Assets, vnext.Asset{120 ID: "build-receipt", MediaType: "application/json", Data: receipt,121 Provenance: project.ID,122 })123 emit(options, Event{Stage: "assemble", Message: "native semantic volume assembled"})124 bundle, err := vnext.Compile(volume)125 if err != nil {126 return result, fmt.Errorf("compile native volume: %w", err)127 }128 bundle.Blobs = append(bundle.Blobs, rasterBlobs...)129 bundle.Release.CreatedAt = createdAt130 bundle.Release.Revision = project.Release.Revision131 if bundle.Release.Revision == 0 {132 bundle.Release.Revision = 1133 }134 descriptor, path, present, err := writeBuild(options.LibraryDir, bundle)135 if err != nil {136 return result, err137 }138 result.Descriptor, result.Path, result.Present = descriptor, path, present139 emit(options, Event{Stage: "publish", Message: "native build installed", Artifact: path})140 return result, nil141}142143func emit(options BuildOptions, event Event) {144 if options.Event != nil {145 options.Event(event)146 }147}148149func featurePlan(plan Plan, source string) (PlannedRequest, bool) {150 for _, request := range plan.Requests {151 if request.Kind == RequestFeatures && request.Source == source {152 return request, true153 }154 }155 return PlannedRequest{}, false156}157158func attachRasters(project Project, plan Plan, captures map[string]Capture, cache *Cache, world *vnext.World) ([]vnext.Blob, error) {159 var blobs []vnext.Blob160 for _, source := range project.Rasters {161 var requests []PlannedRequest162 for _, request := range plan.Requests {163 if request.Kind == RequestRaster && request.Raster == source.ID {164 requests = append(requests, request)165 }166 }167 if len(requests) == 0 {168 return nil, fmt.Errorf("raster %s has no planned requests", source.ID)169 }170 if source.Adapter == "raster-file" {171 capture := captures[requests[0].ID]172 pyramid, derived, err := deriveRasterFile(source, capture, cache)173 if err != nil {174 return nil, err175 }176 pyramid.ID = world.ID + "/raster/" + source.ID177 world.RasterPyramids = append(world.RasterPyramids, pyramid)178 blobs = append(blobs, derived...)179 continue180 }181 sort.Slice(requests, func(i, j int) bool {182 if requests[i].Zoom != requests[j].Zoom {183 return requests[i].Zoom < requests[j].Zoom184 }185 if requests[i].Y != requests[j].Y {186 return requests[i].Y < requests[j].Y187 }188 return requests[i].X < requests[j].X189 })190 minZoom, maxZoom := requests[0].Zoom, requests[len(requests)-1].Zoom191 formats := make([]string, maxZoom-minZoom+1)192 codec := "application/octet-stream"193 for _, request := range requests {194 capture := captures[request.ID]195 format := formatOf(capture.Body.MediaType, request.IdentityLocator)196 if held := formats[request.Zoom-minZoom]; held != "" && held != format {197 return nil, fmt.Errorf("raster %s mixes formats at zoom %d", source.ID, request.Zoom)198 }199 formats[request.Zoom-minZoom] = format200 codec = capture.Body.MediaType201 blobs = append(blobs, vnext.Blob{202 Name: fmt.Sprintf("tiles/%s/%d/%d/%d.%s", source.ID, request.Zoom, request.X, request.Y, format),203 Path: cache.BlobPath(capture.Body),204 })205 }206 for index := range formats {207 if formats[index] == "" {208 return nil, fmt.Errorf("raster %s omits zoom %d", source.ID, int64(index)+minZoom)209 }210 }211 fullZoom := source.FullZoom212 if fullZoom == 0 {213 fullZoom = maxZoom214 }215 world.RasterPyramids = append(world.RasterPyramids, vnext.RasterPyramid{216 ID: world.ID + "/raster/" + source.ID, Name: source.Name, Codec: codec,217 TileSize: source.TileSize, MinZoom: minZoom, MaxZoom: maxZoom, FullZoom: fullZoom,218 SourceZoom: source.SourceZoom, Template: "tiles/" + source.ID + "/{z}/{x}/{y}.{format}",219 Formats: formats, Bounds: nativeRect(source.Bounds), Surface: nativeRect(source.Surface),220 Interpolate: source.Interpolate, Background: source.Background, Shard: source.Shard,221 })222 }223 sort.Slice(world.RasterPyramids, func(i, j int) bool { return world.RasterPyramids[i].ID < world.RasterPyramids[j].ID })224 sort.Slice(blobs, func(i, j int) bool { return blobs[i].Name < blobs[j].Name })225 return blobs, nil226}227228func formatOf(mediaType, locator string) string {229 extension := strings.TrimPrefix(filepath.Ext(locator), ".")230 if extension == "" {231 extensions, _ := mime.ExtensionsByType(mediaType)232 if len(extensions) > 0 {233 extension = strings.TrimPrefix(extensions[0], ".")234 }235 }236 if extension == "jpeg" {237 extension = "jpg"238 }239 if extension == "" {240 extension = "bin"241 }242 return strings.ToLower(extension)243}244245func writeBuild(library string, bundle vnext.Bundle) (vnext.Descriptor, string, bool, error) {246 if library == "" {247 return vnext.Descriptor{}, "", false, fmt.Errorf("build has no Atlas library")248 }249 if err := os.MkdirAll(library, 0o755); err != nil {250 return vnext.Descriptor{}, "", false, err251 }252 file, err := os.CreateTemp(library, ".building-*.atlas")253 if err != nil {254 return vnext.Descriptor{}, "", false, err255 }256 stage := file.Name()257 defer os.Remove(stage)258 if err := vnext.Write(file, bundle); err != nil {259 file.Close()260 return vnext.Descriptor{}, "", false, err261 }262 if err := file.Sync(); err != nil {263 file.Close()264 return vnext.Descriptor{}, "", false, err265 }266 if err := file.Close(); err != nil {267 return vnext.Descriptor{}, "", false, err268 }269 installed, present, err := vnext.InstallWithStatus(library, stage, vnext.StandardSchema())270 if err != nil {271 return vnext.Descriptor{}, "", false, fmt.Errorf("publish native build: %w", err)272 }273 return installed, installed.Locator, present, nil274}