RenderJobController.java

package org.fresnel.backend.api;

import jakarta.validation.Valid;
import org.fresnel.backend.jobs.RenderJob;
import org.fresnel.backend.jobs.RenderJobService;
import org.fresnel.optics.HexMacroCellRenderer;
import org.fresnel.optics.PngExporter;
import org.fresnel.optics.RenderResult;
import org.fresnel.optics.WindowFoilRenderer;
import org.fresnel.optics.ZonePlateRenderer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;

/**
 * Async render-job endpoints. Submit a job → poll status / subscribe SSE → fetch
 * the resulting PNG by job id.
 *
 * <p>Designed for renders that exceed the synchronous {@link DesignController#MAX_PREVIEW_PX}
 * cap (e.g. window-foil sheets at production DPI, large hex macro cells).
 */
@RestController
@RequestMapping("/api/jobs")
public class RenderJobController {

    private final RenderJobService jobs;

    public RenderJobController(RenderJobService jobs) {
        this.jobs = jobs;
    }

    // -------- Submit --------

    @PostMapping(value = "/single",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, String> submitSingle(@Valid @RequestBody SingleZonePlateRequest req) {
        RenderJob job = jobs.submit("single", j -> {
            j.reportProgress(0.05, "rendering");
            RenderResult r = ZonePlateRenderer.render(req.toParameters());
            j.reportProgress(1.0, "done");
            return r;
        });
        return Map.of("jobId", job.id());
    }

    @PostMapping(value = "/hex",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, String> submitHex(@Valid @RequestBody HexMacroCellRequest req) {
        RenderJob job = jobs.submit("hex", j -> {
            j.reportProgress(0.05, "rendering hex macro cell");
            RenderResult r = HexMacroCellRenderer.render(req.toParameters());
            j.reportProgress(1.0, "done");
            return r;
        });
        return Map.of("jobId", job.id());
    }

    @PostMapping(value = "/foil",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, String> submitFoil(@Valid @RequestBody WindowFoilRequest req) {
        var params = req.toParameters();
        RenderJob job = jobs.submit("foil", j -> {
            j.reportProgress(0.05, "rendering window foil");
            RenderResult r = WindowFoilRenderer.render(params);
            j.reportProgress(1.0, "done");
            return r;
        });
        return Map.of("jobId", job.id());
    }

    // -------- Poll --------

    @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String, Object>> status(@PathVariable("id") String id) {
        RenderJob job = jobs.get(id);
        if (job == null) return ResponseEntity.notFound().build();
        return ResponseEntity.ok(toStatus(job));
    }

    private static Map<String, Object> toStatus(RenderJob j) {
        return Map.of(
                "jobId", j.id(),
                "label", j.label(),
                "state", j.state().name(),
                "progress", j.progress(),
                "message", j.message(),
                "error", j.error() == null ? "" : String.valueOf(j.error().getMessage()));
    }

    // -------- SSE progress stream --------

    @GetMapping(value = "/{id}/events")
    public SseEmitter events(@PathVariable("id") String id) {
        RenderJob job = jobs.get(id);
        if (job == null) {
            // Use a proper 404 instead of an SSE stream that completes with an error,
            // so clients can distinguish a missing job from a transient stream error.
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.NOT_FOUND, "unknown job id: " + id);
        }
        SseEmitter emitter = new SseEmitter(0L);
        java.util.function.Consumer<RenderJob> listener = j -> {
            try {
                emitter.send(SseEmitter.event().name("progress").data(toStatus(j)));
                if (j.isTerminal()) emitter.complete();
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        };
        job.addListener(listener);
        emitter.onCompletion(() -> job.removeListener(listener));
        emitter.onTimeout(() -> job.removeListener(listener));
        // Send the current state immediately so late subscribers get the latest snapshot.
        try {
            emitter.send(SseEmitter.event().name("progress").data(toStatus(job)));
            if (job.isTerminal()) emitter.complete();
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
        return emitter;
    }

    // -------- Result --------

    @GetMapping(value = "/{id}/result.png", produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> result(@PathVariable("id") String id) throws IOException {
        RenderJob job = jobs.get(id);
        if (job == null) return ResponseEntity.notFound().build();
        if (job.state() != RenderJob.State.COMPLETED) {
            return ResponseEntity.status(409)
                    .contentType(MediaType.TEXT_PLAIN)
                    .body(("job not yet complete (state=" + job.state() + ")").getBytes());
        }
        byte[] png;
        RenderResult r = job.result();
        if (r != null) {
            // Live in-memory result.
            double dpi = 25.4 / r.pixelSizeMm();
            png = PngExporter.toPngBytes(r, dpi);
        } else {
            // Rehydrated from DB (no in-memory image) — serve the persisted PNG.
            png = jobs.resultPng(id).orElse(null);
            if (png == null) return ResponseEntity.notFound().build();
        }
        HttpHeaders h = new HttpHeaders();
        h.setContentType(MediaType.IMAGE_PNG);
        h.setContentDisposition(org.springframework.http.ContentDisposition.attachment()
                .filename("fresnel-job-" + id + ".png").build());
        return new ResponseEntity<>(png, h, 200);
    }
}