main 3d41bc5

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close