1// Package core implements the capture workflow over ports only.
2package core
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "math"
9 "time"
10
11 "github.com/bnema/nefercap/internal/ports"
12)
13
14// ErrInvalidSelection reports a selection rejected before any port is used.
15var ErrInvalidSelection = errors.New("invalid capture selection")
16
17// Service runs one capture selection. It does not own the Source lifetime:
18// the caller closes the Source after Run returns.
19type Service struct {
20 source ports.Source
21 png ports.ScreenshotWriter
22 video ports.VideoWriter
23}
24
25// New wires the capture workflow to its ports.
26func New(source ports.Source, png ports.ScreenshotWriter, video ports.VideoWriter) *Service {
27 return &Service{source: source, png: png, video: video}
28}
29
30// Run validates selection and then takes one screenshot or records silent
31// fixed-rate video. Captures and writes are sequential, so at most one is in
32// flight, and frame storage is reused without copying.
33//
34// A timed recording (Duration > 0) encodes exactly ceil(Duration*FPS) frames,
35// so its encoded length is Duration rounded up to a frame. Wall time may run
36// longer when Capture or Write is slow: missed slots repeat the current frame
37// (at most two repeats per fresh Capture) and the schedule then moves
38// forward, so a slow encoder lengthens the run instead of starving Capture.
39// The clock starts after Start and the first Write succeed.
40//
41// Cancelling ctx stops recording at the last frame already written and
42// finalizes through VideoWriter.Close. Nothing is written after cancellation.
43// Source and VideoWriter must report cancellation as ctx.Err() (or an error
44// wrapping only it); a failure joined with a context error, or a custom
45// cancellation cause, is treated as a real failure and returned.
46// A failure before the first frame is written aborts the file instead.
47func (s *Service) Run(ctx context.Context, selection ports.Selection) error {
48 if err := validateSelection(selection); err != nil {
49 return err
50 }
51 if err := ctx.Err(); err != nil {
52 return err
53 }
54 if selection.Mode == ports.Screenshot {
55 return s.screenshot(ctx, selection)
56 }
57 return s.record(ctx, selection)
58}
59
60func (s *Service) screenshot(ctx context.Context, sel ports.Selection) error {
61 frame, err := s.source.Capture(ctx, sel.Target)
62 if err != nil {
63 return fmt.Errorf("capture screenshot: %w", err)
64 }
65 if err := frame.Validate(); err != nil {
66 return fmt.Errorf("captured frame: %w", err)
67 }
68 if err := s.png.Save(ctx, frame, sel.Path); err != nil {
69 return fmt.Errorf("save screenshot: %w", err)
70 }
71 return nil
72}
73
74func (s *Service) record(ctx context.Context, sel ports.Selection) error {
75 frame, err := s.source.Capture(ctx, sel.Target)
76 if err != nil {
77 return fmt.Errorf("capture first frame: %w", err)
78 }
79 if err := frame.Validate(); err != nil {
80 return fmt.Errorf("captured frame: %w", err)
81 }
82 if err := s.video.Start(ctx, frame, sel.Path, sel.Video); err != nil {
83 return s.abort(fmt.Errorf("start video: %w", err))
84 }
85 if err := s.video.Write(ctx, frame); err != nil {
86 // Nothing was recorded, even when the cause is cancellation: discard
87 // the file instead of finalizing an empty recording.
88 return s.abort(fmt.Errorf("write first frame: %w", err))
89 }
90
91 loopErr := s.recordLoop(ctx, frame, sel)
92 // Graceful finalize on every exit after the first frame; the writer bounds it.
93 if closeErr := s.video.Close(); closeErr != nil {
94 return errors.Join(loopErr, fmt.Errorf("finalize video: %w", closeErr))
95 }
96 return loopErr
97}
98
99func (s *Service) abort(err error) error {
100 if abortErr := s.video.Abort(); abortErr != nil {
101 return errors.Join(err, fmt.Errorf("abort video: %w", abortErr))
102 }
103 return err
104}
105
106// maxRepeatSlots bounds how many missed slots repeat the current frame before
107// the next fresh Capture (so at most three identical consecutive writes). A
108// larger lag moves the schedule forward instead, so encoder backpressure
109// becomes wall-time overrun rather than a stale-frame spiral.
110const maxRepeatSlots = 2
111
112// schedule maps elapsed time since the first frame to fixed-rate frame slots.
113// Slot n is due at n/fps. total is the slot count of a timed recording, or 0
114// when recording until cancellation.
115type schedule struct {
116 start time.Time
117 fps int64
118 total int64
119}
120
121func newSchedule(start time.Time, sel ports.Selection) schedule {
122 sc := schedule{start: start, fps: int64(sel.Video.FPS)}
123 if sel.Duration > 0 {
124 // ceil(duration*fps) split into whole seconds and remainder so the
125 // arithmetic cannot overflow for any positive time.Duration.
126 secs, rem := int64(sel.Duration/time.Second), int64(sel.Duration%time.Second)
127 sc.total = secs*sc.fps + (rem*sc.fps+int64(time.Second)-1)/int64(time.Second)
128 }
129 return sc
130}
131
132// due returns the offset at which slot n starts.
133func (sc schedule) due(n int64) time.Duration {
134 q, r := n/sc.fps, n%sc.fps
135 return time.Duration(q)*time.Second + time.Duration(r)*time.Second/time.Duration(sc.fps)
136}
137
138// slot returns the slot containing now, unclamped.
139func (sc schedule) slot() int64 {
140 elapsed := time.Since(sc.start)
141 secs, rem := int64(elapsed/time.Second), int64(elapsed%time.Second)
142 return secs*sc.fps + rem*sc.fps/int64(time.Second)
143}
144
145// catchUp limits the lag of now behind slot next to maxRepeatSlots by moving
146// the schedule start forward. It returns the resulting current slot (>= next).
147func (sc *schedule) catchUp(next int64) int64 {
148 cur := max(sc.slot(), next)
149 if cur-next > maxRepeatSlots {
150 sc.start = sc.start.Add(time.Since(sc.start) - sc.due(next+maxRepeatSlots))
151 cur = max(sc.slot(), next)
152 }
153 return cur
154}
155
156// writeTimed writes frame and returns how long the encoder took.
157func (s *Service) writeTimed(ctx context.Context, frame ports.Frame) (time.Duration, error) {
158 began := time.Now()
159 err := s.video.Write(ctx, frame)
160 return time.Since(began), err
161}
162
163// recordLoop fills slots 1.. after the already written first frame. A slot
164// missed because Capture ran long reuses the current frame, which is still
165// valid until the next Capture, so the encoded duration stays exact even when
166// wall time overruns. Encoder time does not cause repeats: the full duration
167// of a repeated Write, and the part of a fresh Write beyond one frame
168// interval, is added to the schedule start, so only Capture delay is
169// duplicated. catchUp is a backstop that keeps at most maxRepeatSlots repeats
170// per fresh Capture. A timed recording ends after ceil(duration*fps)
171// frames, so a duration shorter than one frame interval encodes one frame and
172// returns without waiting. Cancellation ends the recording at the last frame
173// already written: nothing is written afterwards, because the writer is
174// cancelled with ctx and storage may be invalidated by an interrupted Capture.
175// Exact frame counts therefore hold only for timed recordings that are not
176// cancelled. Slot n < total is due before the duration, so due cannot overflow.
177func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {
178 sc := newSchedule(time.Now(), sel)
179 timer := time.NewTimer(time.Hour)
180 defer timer.Stop()
181 timer.Stop()
182
183 next := int64(1)
184 for sc.total == 0 || next < sc.total {
185 if wait := sc.due(next) - time.Since(sc.start); wait > 0 {
186 timer.Reset(wait)
187 select {
188 case <-ctx.Done():
189 return nil
190 case <-timer.C:
191 }
192 }
193 if ctx.Err() != nil {
194 return nil
195 }
196
197 cur := sc.catchUp(next)
198 past := sc.total > 0 && cur >= sc.total
199 if past {
200 cur = sc.total - 1
201 }
202 // Slots already missed repeat the frame before it is invalidated.
203 last := cur - 1
204 if past {
205 last = cur // window over: no capture for the final slot
206 }
207 for ; next <= last; next++ {
208 if ctx.Err() != nil {
209 return nil
210 }
211 took, err := s.writeTimed(ctx, frame)
212 if err != nil {
213 return writeResult(ctx, err)
214 }
215 sc.start = sc.start.Add(took) // repeated encoder time is not lag
216 }
217 if past {
218 return nil
219 }
220
221 var err error
222 if frame, err = s.source.Capture(ctx, sel.Target); err != nil {
223 if stopped(ctx, err) {
224 return nil
225 }
226 return fmt.Errorf("capture frame: %w", err)
227 }
228 if err := frame.Validate(); err != nil {
229 return fmt.Errorf("captured frame: %w", err)
230 }
231 if ctx.Err() != nil {
232 return nil
233 }
234 took, err := s.writeTimed(ctx, frame)
235 if err != nil {
236 return writeResult(ctx, err)
237 }
238 // Only the part of a fresh Write beyond its own slot is delay.
239 sc.start = sc.start.Add(max(0, took-sc.due(1)))
240 next++
241 }
242 return nil
243}
244
245func writeResult(ctx context.Context, err error) error {
246 if stopped(ctx, err) {
247 return nil
248 }
249 return fmt.Errorf("write frame: %w", err)
250}
251
252// stopped reports whether the run is ending and err consists only of context
253// cancellation or deadline leaves. A failure joined with cancellation is real.
254func stopped(ctx context.Context, err error) bool {
255 return ctx.Err() != nil && onlyContext(err)
256}
257
258func onlyContext(err error) bool {
259 switch e := err.(type) {
260 case nil:
261 return false
262 case interface{ Unwrap() []error }:
263 children := e.Unwrap()
264 for _, c := range children {
265 if !onlyContext(c) {
266 return false
267 }
268 }
269 return len(children) > 0
270 case interface{ Unwrap() error }:
271 return err == context.Canceled || err == context.DeadlineExceeded || onlyContext(e.Unwrap())
272 default:
273 return err == context.Canceled || err == context.DeadlineExceeded
274 }
275}
276
277func validateSelection(sel ports.Selection) error {
278 switch sel.Mode {
279 case ports.Screenshot, ports.Record:
280 default:
281 return fmt.Errorf("%w: unknown mode %q", ErrInvalidSelection, sel.Mode)
282 }
283 if sel.Target.OutputID == 0 {
284 return fmt.Errorf("%w: output id is zero", ErrInvalidSelection)
285 }
286 if err := validateRegion(sel.Target.Region); err != nil {
287 return err
288 }
289 if sel.Clipboard && sel.Mode != ports.Screenshot {
290 return fmt.Errorf("%w: clipboard requires screenshot", ErrInvalidSelection)
291 }
292 if sel.Path == "" && !sel.Clipboard {
293 return fmt.Errorf("%w: empty path", ErrInvalidSelection)
294 }
295 if sel.Duration < 0 {
296 return fmt.Errorf("%w: negative duration %s", ErrInvalidSelection, sel.Duration)
297 }
298 if sel.Mode == ports.Record {
299 return validateVideo(sel.Video)
300 }
301 return nil
302}
303
304// validateRegion accepts the zero region (full output) or a region with
305// positive size and nonnegative origin that fits in int32.
306func validateRegion(r ports.Region) error {
307 if r == (ports.Region{}) {
308 return nil
309 }
310 x, y, w, h := int64(r.X), int64(r.Y), int64(r.Width), int64(r.Height)
311 if x < 0 || y < 0 || w < 1 || h < 1 ||
312 x > math.MaxInt32 || y > math.MaxInt32 || w > math.MaxInt32 || h > math.MaxInt32 ||
313 x+w > math.MaxInt32 || y+h > math.MaxInt32 {
314 return fmt.Errorf("%w: %w: %+v", ErrInvalidSelection, ports.ErrInvalidRegion, r)
315 }
316 return nil
317}
318
319func validateVideo(v ports.VideoSettings) error {
320 if v.FPS < 1 || v.FPS > ports.MaxFPS {
321 return fmt.Errorf("%w: fps %d outside 1..%d", ErrInvalidSelection, v.FPS, ports.MaxFPS)
322 }
323 if v.Width == 0 && v.Height == 0 {
324 return nil
325 }
326 w, h := v.Width, v.Height
327 if w < 1 || h < 1 || w > ports.MaxDimension || h > ports.MaxDimension || w%2 != 0 || h%2 != 0 ||
328 w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
329 return fmt.Errorf("%w: invalid output size %dx%d", ErrInvalidSelection, w, h)
330 }
331 return nil
332}