diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index d06d3b3f..544938ac 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -251,6 +251,42 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe return oapi.StopRecording200Response{}, nil } +func (s *ApiService) MarkRecording(ctx context.Context, req oapi.MarkRecordingRequestObject) (oapi.MarkRecordingResponseObject, error) { + log := logger.FromContext(ctx) + + if req.Body == nil { + return oapi.MarkRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body is required"}}, nil + } + + recorderID := s.defaultRecorderID + if req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id + } + + rec, exists := s.recordManager.GetRecorder(recorderID) + if !exists { + log.Warn("attempted to mark non-existent recording session", "recorder_id", recorderID) + return oapi.MarkRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no such recording session"}}, nil + } + + name, offsetMs, err := rec.Mark(req.Body.Name) + if err != nil { + switch { + case errors.Is(err, recorder.ErrNotRecording): + log.Warn("attempted to mark recording session that is not recording", "recorder_id", recorderID) + return oapi.MarkRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording session is not active"}}, nil + case errors.Is(err, recorder.ErrInvalidMarkerName): + log.Warn("invalid marker name", "recorder_id", recorderID) + return oapi.MarkRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "marker name must be non-empty and at most 200 characters"}}, nil + default: + log.Error("failed to mark recording", "err", err, "recorder_id", recorderID) + return oapi.MarkRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to mark recording"}}, nil + } + } + + return oapi.MarkRecording201JSONResponse{Name: name, OffsetMs: offsetMs}, nil +} + const ( minRecordingSizeInBytes = 100 ) diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 42c00717..43291073 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -126,6 +126,71 @@ func TestApiService_StopRecording(t *testing.T) { }) } +func TestApiService_MarkRecording(t *testing.T) { + ctx := context.Background() + name := "checkpoint" + + t.Run("unknown session maps to 404", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc, err := newSvc(t, mgr) + require.NoError(t, err) + + resp, err := svc.MarkRecording(ctx, oapi.MarkRecordingRequestObject{Body: &oapi.MarkRecordingJSONRequestBody{Name: name}}) + require.NoError(t, err) + require.IsType(t, oapi.MarkRecording404JSONResponse{}, resp) + }) + + t.Run("missing body maps to 400", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc, err := newSvc(t, mgr) + require.NoError(t, err) + + resp, err := svc.MarkRecording(ctx, oapi.MarkRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.MarkRecording400JSONResponse{}, resp) + }) + + t.Run("not recording maps to 409", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "default", markErr: recorder.ErrNotRecording} + require.NoError(t, mgr.RegisterRecorder(ctx, rec)) + + svc, err := newSvc(t, mgr) + require.NoError(t, err) + resp, err := svc.MarkRecording(ctx, oapi.MarkRecordingRequestObject{Body: &oapi.MarkRecordingJSONRequestBody{Name: name}}) + require.NoError(t, err) + require.IsType(t, oapi.MarkRecording409JSONResponse{}, resp) + require.True(t, rec.markCalled) + }) + + t.Run("invalid name maps to 400", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "default", markErr: recorder.ErrInvalidMarkerName} + require.NoError(t, mgr.RegisterRecorder(ctx, rec)) + + svc, err := newSvc(t, mgr) + require.NoError(t, err) + resp, err := svc.MarkRecording(ctx, oapi.MarkRecordingRequestObject{Body: &oapi.MarkRecordingJSONRequestBody{Name: " "}}) + require.NoError(t, err) + require.IsType(t, oapi.MarkRecording400JSONResponse{}, resp) + }) + + t.Run("success returns 201 with offset", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "default", isRecordingFlag: true, markOffsetMs: 1234} + require.NoError(t, mgr.RegisterRecorder(ctx, rec)) + + svc, err := newSvc(t, mgr) + require.NoError(t, err) + resp, err := svc.MarkRecording(ctx, oapi.MarkRecordingRequestObject{Body: &oapi.MarkRecordingJSONRequestBody{Name: name}}) + require.NoError(t, err) + r, ok := resp.(oapi.MarkRecording201JSONResponse) + require.True(t, ok, "expected 201 response, got %T", resp) + assert.Equal(t, name, r.Name) + assert.Equal(t, int64(1234), r.OffsetMs) + }) +} + func TestApiService_DownloadRecording(t *testing.T) { ctx := context.Background() @@ -226,6 +291,11 @@ type mockRecorder struct { recordingErr error recordingData []byte deleted bool + + markCalled bool + markName string + markOffsetMs int64 + markErr error } func (m *mockRecorder) ID() string { return m.id } @@ -257,6 +327,14 @@ func (m *mockRecorder) ForceStop(ctx context.Context) error { return nil } +func (m *mockRecorder) Mark(name string) (string, int64, error) { + m.markCalled = true + if m.markErr != nil { + return "", 0, m.markErr + } + return name, m.markOffsetMs, nil +} + func (m *mockRecorder) IsRecording(ctx context.Context) bool { return m.isRecordingFlag } func (m *mockRecorder) Recording(ctx context.Context) (io.ReadCloser, *recorder.RecordingMetadata, error) { diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 1957ff7c..038414a3 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -3204,7 +3204,7 @@ type DeletePathRequest struct { // DeleteRecordingRequest defines model for DeleteRecordingRequest. type DeleteRecordingRequest struct { - // Id Identifier of the recording to delete. Alphanumeric or hyphen. + // Id Identifier of the recording session to delete, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the default recording session is deleted. Id *string `json:"id,omitempty"` } @@ -3339,6 +3339,24 @@ type LogEvent struct { Timestamp time.Time `json:"timestamp"` } +// MarkRecordingRequest defines model for MarkRecordingRequest. +type MarkRecordingRequest struct { + // Id Identifier of the recording session to mark, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the marker is added to the default recording session. + Id *string `json:"id,omitempty"` + + // Name Name of the marker, used as the MP4 chapter title. + Name string `json:"name"` +} + +// MarkRecordingResult defines model for MarkRecordingResult. +type MarkRecordingResult struct { + // Name Name of the recorded marker. + Name string `json:"name"` + + // OffsetMs Provisional offset of the marker from the recording start, in milliseconds, measured against the start time at mark time. The authoritative offset is the chapter start written at finalize. + OffsetMs int64 `json:"offset_ms"` +} + // MousePositionResponse defines model for MousePositionResponse. type MousePositionResponse struct { // X X coordinate of the cursor @@ -3677,7 +3695,7 @@ type StartRecordingRequest struct { // Framerate Recording framerate in fps (overrides server default) Framerate *int `json:"framerate,omitempty"` - // Id Optional identifier for the recording session. Alphanumeric or hyphen. + // Id Optional identifier for this recording session, used to target it from the other /recording endpoints (stop, mark, download, delete) and allowing multiple concurrent recordings. Alphanumeric or hyphen. When omitted, the default recording session is started. Id *string `json:"id,omitempty"` // MaxDurationInSeconds Maximum recording duration in seconds (overrides server default) @@ -3695,7 +3713,7 @@ type StopRecordingRequest struct { // ForceStop Immediately stop without graceful shutdown. This may result in a corrupted video file. ForceStop *bool `json:"forceStop,omitempty"` - // Id Identifier of the recorder to stop. Alphanumeric or hyphen. + // Id Identifier of the recording session to stop, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the default recording session is stopped. Id *string `json:"id,omitempty"` } @@ -3925,7 +3943,7 @@ type LogsStreamParamsSource string // DownloadRecordingParams defines parameters for DownloadRecording. type DownloadRecordingParams struct { - // Id Optional recorder identifier. When omitted, the server uses the default recorder. + // Id Identifier of the recording session to download, as passed to /recording/start. When omitted, the default recording session is downloaded. Id *string `form:"id,omitempty" json:"id,omitempty"` } @@ -4034,6 +4052,9 @@ type ProcessStdinJSONRequestBody = ProcessStdinRequest // DeleteRecordingJSONRequestBody defines body for DeleteRecording for application/json ContentType. type DeleteRecordingJSONRequestBody = DeleteRecordingRequest +// MarkRecordingJSONRequestBody defines body for MarkRecording for application/json ContentType. +type MarkRecordingJSONRequestBody = MarkRecordingRequest + // StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. type StartRecordingJSONRequestBody = StartRecordingRequest @@ -5271,6 +5292,11 @@ type ClientInterface interface { // ListRecorders request ListRecorders(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // MarkRecordingWithBody request with any body + MarkRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + MarkRecording(ctx context.Context, body MarkRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // StartRecordingWithBody request with any body StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -6173,6 +6199,30 @@ func (c *Client) ListRecorders(ctx context.Context, reqEditors ...RequestEditorF return c.Client.Do(req) } +func (c *Client) MarkRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewMarkRecordingRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) MarkRecording(ctx context.Context, body MarkRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewMarkRecordingRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewStartRecordingRequestWithBody(c.Server, contentType, body) if err != nil { @@ -8210,6 +8260,46 @@ func NewListRecordersRequest(server string) (*http.Request, error) { return req, nil } +// NewMarkRecordingRequest calls the generic MarkRecording builder with application/json body +func NewMarkRecordingRequest(server string, body MarkRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewMarkRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewMarkRecordingRequestWithBody generates requests for MarkRecording with any type of body +func NewMarkRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/mark") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewStartRecordingRequest calls the generic StartRecording builder with application/json body func NewStartRecordingRequest(server string, body StartRecordingJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -8788,6 +8878,11 @@ type ClientWithResponsesInterface interface { // ListRecordersWithResponse request ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) + // MarkRecordingWithBodyWithResponse request with any body + MarkRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MarkRecordingResponse, error) + + MarkRecordingWithResponse(ctx context.Context, body MarkRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*MarkRecordingResponse, error) + // StartRecordingWithBodyWithResponse request with any body StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) @@ -9924,6 +10019,32 @@ func (r ListRecordersResponse) StatusCode() int { return 0 } +type MarkRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *MarkRecordingResult + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON409 *ConflictError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r MarkRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r MarkRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type StartRecordingResponse struct { Body []byte HTTPResponse *http.Response @@ -10754,6 +10875,23 @@ func (c *ClientWithResponses) ListRecordersWithResponse(ctx context.Context, req return ParseListRecordersResponse(rsp) } +// MarkRecordingWithBodyWithResponse request with arbitrary body returning *MarkRecordingResponse +func (c *ClientWithResponses) MarkRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MarkRecordingResponse, error) { + rsp, err := c.MarkRecordingWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMarkRecordingResponse(rsp) +} + +func (c *ClientWithResponses) MarkRecordingWithResponse(ctx context.Context, body MarkRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*MarkRecordingResponse, error) { + rsp, err := c.MarkRecording(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMarkRecordingResponse(rsp) +} + // StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) @@ -12652,6 +12790,60 @@ func ParseListRecordersResponse(rsp *http.Response) (*ListRecordersResponse, err return response, nil } +// ParseMarkRecordingResponse parses an HTTP response from a MarkRecordingWithResponse call +func ParseMarkRecordingResponse(rsp *http.Response) (*MarkRecordingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &MarkRecordingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest MarkRecordingResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ConflictError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseStartRecordingResponse parses an HTTP response from a StartRecordingWithResponse call func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -13093,6 +13285,9 @@ type ServerInterface interface { // List all recorders // (GET /recording/list) ListRecorders(w http.ResponseWriter, r *http.Request) + // Add a named marker to an in-progress recording + // (POST /recording/mark) + MarkRecording(w http.ResponseWriter, r *http.Request) // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(w http.ResponseWriter, r *http.Request) @@ -13402,6 +13597,12 @@ func (_ Unimplemented) ListRecorders(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Add a named marker to an in-progress recording +// (POST /recording/mark) +func (_ Unimplemented) MarkRecording(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) func (_ Unimplemented) StartRecording(w http.ResponseWriter, r *http.Request) { @@ -14379,6 +14580,20 @@ func (siw *ServerInterfaceWrapper) ListRecorders(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// MarkRecording operation middleware +func (siw *ServerInterfaceWrapper) MarkRecording(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.MarkRecording(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // StartRecording operation middleware func (siw *ServerInterfaceWrapper) StartRecording(w http.ResponseWriter, r *http.Request) { @@ -14790,6 +15005,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/recording/list", wrapper.ListRecorders) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/recording/mark", wrapper.MarkRecording) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/start", wrapper.StartRecording) }) @@ -16802,6 +17020,59 @@ func (response ListRecorders500JSONResponse) VisitListRecordersResponse(w http.R return json.NewEncoder(w).Encode(response) } +type MarkRecordingRequestObject struct { + Body *MarkRecordingJSONRequestBody +} + +type MarkRecordingResponseObject interface { + VisitMarkRecordingResponse(w http.ResponseWriter) error +} + +type MarkRecording201JSONResponse MarkRecordingResult + +func (response MarkRecording201JSONResponse) VisitMarkRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type MarkRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response MarkRecording400JSONResponse) VisitMarkRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type MarkRecording404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response MarkRecording404JSONResponse) VisitMarkRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type MarkRecording409JSONResponse struct{ ConflictErrorJSONResponse } + +func (response MarkRecording409JSONResponse) VisitMarkRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type MarkRecording500JSONResponse struct{ InternalErrorJSONResponse } + +func (response MarkRecording500JSONResponse) VisitMarkRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type StartRecordingRequestObject struct { Body *StartRecordingJSONRequestBody } @@ -17271,6 +17542,9 @@ type StrictServerInterface interface { // List all recorders // (GET /recording/list) ListRecorders(ctx context.Context, request ListRecordersRequestObject) (ListRecordersResponseObject, error) + // Add a named marker to an in-progress recording + // (POST /recording/mark) + MarkRecording(ctx context.Context, request MarkRecordingRequestObject) (MarkRecordingResponseObject, error) // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(ctx context.Context, request StartRecordingRequestObject) (StartRecordingResponseObject, error) @@ -18688,6 +18962,37 @@ func (sh *strictHandler) ListRecorders(w http.ResponseWriter, r *http.Request) { } } +// MarkRecording operation middleware +func (sh *strictHandler) MarkRecording(w http.ResponseWriter, r *http.Request) { + var request MarkRecordingRequestObject + + var body MarkRecordingJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.MarkRecording(ctx, request.(MarkRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "MarkRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(MarkRecordingResponseObject); ok { + if err := validResponse.VisitMarkRecordingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StartRecording operation middleware func (sh *strictHandler) StartRecording(w http.ResponseWriter, r *http.Request) { var request StartRecordingRequestObject @@ -18953,375 +19258,382 @@ func (sh *strictHandler) StreamTelemetryEvents(w http.ResponseWriter, r *http.Re // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "B+eOHj0HcKP/3wAAAP//AuTSjPPJAQA=", + "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 5308d30d..f7961e9d 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -15,6 +15,7 @@ import ( "sync" "syscall" "time" + "unicode/utf8" "github.com/kernel/kernel-images/server/lib/logger" "github.com/kernel/kernel-images/server/lib/scaletozero" @@ -41,6 +42,18 @@ var ErrRecordingFinalizing = errors.New("recording is being finalized") // can use errors.Is to surface a client-facing error instead of a 500. var ErrInvalidParams = errors.New("invalid recording parameters") +// ErrNotRecording is returned by Mark when no recording is actively running. +// Callers can use errors.Is to surface a 409. +var ErrNotRecording = errors.New("no active recording") + +// ErrInvalidMarkerName is returned by Mark when the marker name is empty or too +// long. Callers can use errors.Is to surface a 400. +var ErrInvalidMarkerName = errors.New("invalid marker name") + +// maxMarkerNameLen bounds marker names (in characters) so a chapter title +// can't be abused to bloat the metadata file. +const maxMarkerNameLen = 200 + // FFmpegRecorder encapsulates an FFmpeg recording session with platform-specific screen capture. // It manages the lifecycle of a single FFmpeg process and provides thread-safe operations. type FFmpegRecorder struct { @@ -53,10 +66,16 @@ type FFmpegRecorder struct { outputPath string startTime time.Time endTime time.Time + // captureAnchor is the moment ffmpeg wrote its first output bytes, i.e. + // when the capture pipeline actually started producing media. It anchors + // chapter offsets to the media timeline; zero means never detected and + // callers fall back to startTime (stamped before the process spawned). + captureAnchor time.Time ffmpegErr error exitCode int exited chan struct{} deleted bool + markers []Marker stz *scaletozero.Oncer // flight coordinates concurrent operations using different keys: @@ -256,6 +275,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.ffmpegErr = nil fr.exitCode = exitCodeInitValue fr.startTime = time.Now() + fr.captureAnchor = time.Time{} fr.exited = make(chan struct{}) args, err := ffmpegArgs(fr.params, fr.outputPath) @@ -269,6 +289,12 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { } log.Info(fmt.Sprintf("%s %s", fr.binaryPath, strings.Join(args, " "))) + // Remove any stale output from a previous process so watchCaptureStart + // can't anchor on leftover bytes; ffmpeg recreates the file (-y). + if err := os.Remove(fr.outputPath); err != nil && !os.IsNotExist(err) { + log.Warn("failed to remove stale recording output", "path", fr.outputPath, "err", err) + } + cmd := exec.Command(fr.binaryPath, args...) // create process group to ensure all processes are signaled together cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -290,6 +316,10 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Launch background waiter to capture process completion. go fr.waitForCommand(ctx) + // Watch for ffmpeg's first output bytes to anchor chapter offsets to the + // media timeline rather than the pre-spawn startTime. + go fr.watchCaptureStart() + // Check for startup errors before returning if err := waitForChan(ctx, 250*time.Millisecond, fr.exited); err == nil { fr.mu.Lock() @@ -394,6 +424,9 @@ func (fr *FFmpegRecorder) finalizeRecording(ctx context.Context) error { } outputPath := fr.outputPath binaryPath := fr.binaryPath + markers := fr.markers + anchor := fr.anchorLocked() + durationMs := fr.endTime.Sub(anchor).Milliseconds() fr.mu.Unlock() // Check if the recording file exists @@ -409,29 +442,46 @@ func (fr *FFmpegRecorder) finalizeRecording(ctx context.Context) error { // Create temp file for the remuxed output tempPath := outputPath + ".tmp" - // Remux: copy streams without re-encoding, move moov atom to start with faststart - args := []string{ - "-i", outputPath, - "-c", "copy", - "-movflags", "+faststart", - "-f", "mp4", // Explicitly specify format since .tmp extension isn't recognized - "-y", - tempPath, + // When markers exist, build an ffmetadata file and inject it as a second + // input so the remux carries MP4 chapters. A failure here must never fail + // or corrupt the recording: fall back to the plain no-chapter remux. + metaPath := "" + if len(markers) > 0 { + path, ok, err := buildChapterMetadata(outputPath, markers, anchor, durationMs) + if err != nil { + log.Warn("failed to build chapter metadata, finalizing without chapters", "err", err) + } else if ok { + metaPath = path + defer os.Remove(metaPath) + } } - log.Info("finalizing recording", "cmd", fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " "))) + // Remux: copy streams without re-encoding, move moov atom to start with faststart + runRemux := func(metaPath string) error { + args := remuxArgs(outputPath, tempPath, metaPath) + log.Info("finalizing recording", "cmd", fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " "))) + // Use WithoutCancel to prevent context cancellation from aborting finalization, + // which would leave the recording in a corrupted/incomplete state. + cmd := exec.CommandContext(context.WithoutCancel(ctx), binaryPath, args...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + return cmd.Run() + } - // Use WithoutCancel to prevent context cancellation from aborting finalization, - // which would leave the recording in a corrupted/incomplete state. - cmd := exec.CommandContext(context.WithoutCancel(ctx), binaryPath, args...) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout + remuxErr := runRemux(metaPath) + if remuxErr != nil && metaPath != "" { + // The chapter input must never cost us the recording: retry once as a + // plain no-chapter remux before caching a finalize failure. + log.Warn("chaptered remux failed, retrying without chapters", "err", remuxErr) + os.Remove(tempPath) + remuxErr = runRemux("") + } var result error - if err := cmd.Run(); err != nil { + if remuxErr != nil { // Clean up temp file on error os.Remove(tempPath) - result = fmt.Errorf("failed to finalize recording: %w", err) + result = fmt.Errorf("failed to finalize recording: %w", remuxErr) } else if err := os.Rename(tempPath, outputPath); err != nil { os.Remove(tempPath) result = fmt.Errorf("failed to replace recording with finalized version: %w", err) @@ -449,6 +499,104 @@ func (fr *FFmpegRecorder) finalizeRecording(ctx context.Context) error { return err } +// Mark records a named marker at the current time. It returns the stored +// (trimmed) marker name and the offset from the recording start in +// milliseconds. The offset is provisional: it is measured against the capture +// anchor at mark time, while the authoritative value is the chapter start +// computed at finalize. +// +// Returns ErrNotRecording if the recording isn't actively running and +// ErrInvalidMarkerName if the name is empty or exceeds maxMarkerNameLen. +func (fr *FFmpegRecorder) Mark(name string) (string, int64, error) { + fr.mu.Lock() + defer fr.mu.Unlock() + + if fr.cmd == nil || fr.exitCode >= exitCodeProcessDoneMinValue { + return "", 0, ErrNotRecording + } + + name = strings.TrimSpace(name) + if name == "" || utf8.RuneCountInString(name) > maxMarkerNameLen { + return "", 0, ErrInvalidMarkerName + } + + now := time.Now() + fr.markers = append(fr.markers, Marker{Name: name, At: now}) + return name, now.Sub(fr.anchorLocked()).Milliseconds(), nil +} + +// watchCaptureStart stamps captureAnchor with the moment ffmpeg writes its +// first output bytes. The muxer only writes them once the capture pipeline is +// fully initialized (input opened, encoder ready, first frame in flight), so +// this tracks media t=0 far more closely than startTime, which is stamped +// before the process is even spawned. If no bytes ever appear the anchor +// stays zero and callers fall back to startTime. +func (fr *FFmpegRecorder) watchCaptureStart() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-fr.exited: + return + case <-ticker.C: + if info, err := os.Stat(fr.outputPath); err == nil && info.Size() > 0 { + now := time.Now() + fr.mu.Lock() + // Only stamp while the process is still running: exitCode and + // endTime are set together under fr.mu, so this guarantees the + // anchor always precedes endTime. Stamping after exit would + // yield a zero/negative duration and lose every chapter. + if fr.exitCode < exitCodeProcessDoneMinValue { + fr.captureAnchor = now + } + fr.mu.Unlock() + return + } + } + } +} + +// anchorLocked returns the reference time chapter offsets are measured from: +// captureAnchor when detected, otherwise startTime. Callers must hold fr.mu. +func (fr *FFmpegRecorder) anchorLocked() time.Time { + if !fr.captureAnchor.IsZero() { + return fr.captureAnchor + } + return fr.startTime +} + +// remuxArgs assembles the finalize remux arguments: copy streams without +// re-encoding and move the moov atom to the start with faststart. When +// metaPath is non-empty the chapter metadata input is injected via +// chapterInputArgs; when empty the arguments are byte-identical to the +// pre-marker remux command. +func remuxArgs(outputPath, tempPath, metaPath string) []string { + args := []string{"-i", outputPath} + args = append(args, chapterInputArgs(metaPath)...) + args = append(args, + "-c", "copy", + "-movflags", "+faststart", + "-f", "mp4", // Explicitly specify format since .tmp extension isn't recognized + "-y", + tempPath, + ) + return args +} + +// chapterInputArgs returns the extra remux arguments that pull MP4 chapters +// from the ffmetadata input at metaPath: the second input plus the stream and +// chapter mapping. When metaPath is empty it returns nil so the remux args are +// byte-identical to the no-marker path. The ffmetadata demuxer must be named +// explicitly with -f since the .ffmeta extension isn't auto-detected. -map 0 +// preserves all streams from the recording; -map_chapters 1 takes chapters from +// the metadata input. +func chapterInputArgs(metaPath string) []string { + if metaPath == "" { + return nil + } + return []string{"-f", "ffmetadata", "-i", metaPath, "-map", "0", "-map_chapters", "1"} +} + // IsRecording returns true if a recording is currently in progress. func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool { fr.mu.Lock() diff --git a/server/lib/recorder/markers.go b/server/lib/recorder/markers.go new file mode 100644 index 00000000..4d3f2a8d --- /dev/null +++ b/server/lib/recorder/markers.go @@ -0,0 +1,108 @@ +package recorder + +import ( + "fmt" + "os" + "sort" + "strings" + "time" +) + +// Marker records a named point in time during a recording. At finalize the +// markers are written into the output MP4 as chapters so they can be scrubbed +// to in any tool that reads MP4 chapter metadata. +type Marker struct { + Name string + At time.Time +} + +// sentinelChapterName is the synthetic first chapter covering the span before +// the first real marker. ffmpeg forces the first chapter to start at 0, so +// without it the first real marker's timestamp would be clamped to 0. +const sentinelChapterName = "_recording_start" + +// buildChapterMetadata writes an ffmetadata file describing one MP4 chapter per +// marker and returns its path. Each marker's chapter starts at its offset from +// startTime, clamped into [0, durationMs]: a marker can be timestamped +// slightly outside the media timeline (e.g. between process spawn and the +// first captured frame, or after capture stopped but before the process was +// reaped), and once Mark returned 201 its chapter must never be silently +// lost. durationMs is the recording length and becomes the END of the final +// chapter. Chapter ENDs are clamped to never precede their START, since +// ffmpeg rejects an END-before-START chapter outright and would fail the +// whole remux. +// +// ok is false when there are no chapters to write (no markers supplied, or a +// degenerate non-positive duration), signalling the caller to fall back to +// the normal no-chapter remux. The metadata file is written next to +// outputPath with a ".ffmeta" suffix. +func buildChapterMetadata(outputPath string, markers []Marker, startTime time.Time, durationMs int64) (string, bool, error) { + type chapter struct { + startMs int64 + title string + } + + if durationMs <= 0 { + return "", false, nil + } + + chapters := make([]chapter, 0, len(markers)) + for _, m := range markers { + startMs := m.At.Sub(startTime).Milliseconds() + if startMs < 0 { + startMs = 0 + } + if startMs > durationMs { + startMs = durationMs + } + chapters = append(chapters, chapter{startMs: startMs, title: m.Name}) + } + if len(chapters) == 0 { + return "", false, nil + } + + sort.SliceStable(chapters, func(i, j int) bool { + return chapters[i].startMs < chapters[j].startMs + }) + + // Prepend the sentinel so the first real marker keeps its true start. + if chapters[0].startMs > 0 { + chapters = append([]chapter{{startMs: 0, title: sentinelChapterName}}, chapters...) + } + + var b strings.Builder + b.WriteString("FFMETADATA1\n") + for i, c := range chapters { + endMs := durationMs + if i+1 < len(chapters) { + endMs = chapters[i+1].startMs + } + if endMs < c.startMs { + endMs = c.startMs + } + b.WriteString("[CHAPTER]\n") + b.WriteString("TIMEBASE=1/1000\n") + fmt.Fprintf(&b, "START=%d\n", c.startMs) + fmt.Fprintf(&b, "END=%d\n", endMs) + fmt.Fprintf(&b, "title=%s\n", escapeFFMetadata(c.title)) + } + + path := outputPath + ".ffmeta" + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return "", false, fmt.Errorf("failed to write chapter metadata: %w", err) + } + return path, true, nil +} + +// escapeFFMetadata escapes the characters ffmpeg treats specially in +// ffmetadata values: backslash, equals, semicolon, hash, and newline. +func escapeFFMetadata(s string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "=", "\\=", + ";", "\\;", + "#", "\\#", + "\n", "\\\n", + ) + return replacer.Replace(s) +} diff --git a/server/lib/recorder/markers_integration_test.go b/server/lib/recorder/markers_integration_test.go new file mode 100644 index 00000000..a2e2547d --- /dev/null +++ b/server/lib/recorder/markers_integration_test.go @@ -0,0 +1,119 @@ +package recorder + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/scaletozero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ffprobeChapters is the subset of `ffprobe -show_chapters -print_format json` +// we assert on. +type ffprobeChapters struct { + Chapters []struct { + TimeBase string `json:"time_base"` + Start int64 `json:"start"` + End int64 `json:"end"` + Tags struct { + Title string `json:"title"` + } `json:"tags"` + } `json:"chapters"` +} + +// TestFinalize_InjectsChaptersWithRealFFmpeg records a short synthetic clip with +// real ffmpeg, marks at known offsets, finalizes, and asserts via ffprobe that +// the chapters land at the expected millisecond offsets. It uses lavfi testsrc +// (not x11grab) so it needs no display, only ffmpeg/ffprobe on PATH. +func TestFinalize_InjectsChaptersWithRealFFmpeg(t *testing.T) { + ffmpegBin, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skipf("ffmpeg not available: %v", err) + } + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skipf("ffprobe not available: %v", err) + } + + const ( + fps = 25 + durationSecs = 4 + ) + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "rec.mp4") + + // Produce a fragmented MP4 the same shape finalize expects to remux. + genArgs := []string{ + "-f", "lavfi", + "-i", "testsrc=size=320x240:rate=25", + "-t", "4", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-movflags", "+frag_keyframe+empty_moov", + "-y", outputPath, + } + gen := exec.CommandContext(context.Background(), ffmpegBin, genArgs...) + out, err := gen.CombinedOutput() + require.NoError(t, err, "failed to generate test clip: %s", out) + + start := time.Now() + rec := &FFmpegRecorder{ + id: "finalize-integration", + binaryPath: ffmpegBin, + outputPath: outputPath, + startTime: start, + endTime: start.Add(durationSecs * time.Second), + exitCode: 0, + markers: []Marker{ + {Name: "intro", At: start.Add(1 * time.Second)}, + {Name: "middle", At: start.Add(2500 * time.Millisecond)}, + }, + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + + require.NoError(t, rec.finalizeRecording(context.Background())) + + // The temp metadata file must be cleaned up after the remux. + _, statErr := os.Stat(outputPath + ".ffmeta") + assert.True(t, os.IsNotExist(statErr), "metadata temp file should be removed") + + probe := exec.CommandContext(context.Background(), "ffprobe", + "-show_chapters", "-print_format", "json", "-v", "quiet", outputPath) + probeOut, err := probe.Output() + require.NoError(t, err) + + var parsed ffprobeChapters + require.NoError(t, json.Unmarshal(probeOut, &parsed)) + require.Len(t, parsed.Chapters, 3, "sentinel + two markers") + + assert.Equal(t, "1/1000", parsed.Chapters[0].TimeBase) + + // One frame interval in ms; chapters should land within ~1.5 frames. + const tolMs = int64(1000.0 / fps * 1.5) + assertNear := func(name string, got, want int64) { + assert.LessOrEqualf(t, abs(got-want), tolMs, "%s: got %dms want ~%dms", name, got, want) + } + + assert.Equal(t, sentinelChapterName, parsed.Chapters[0].Tags.Title) + assertNear("sentinel start", parsed.Chapters[0].Start, 0) + assertNear("sentinel end", parsed.Chapters[0].End, 1000) + + assert.Equal(t, "intro", parsed.Chapters[1].Tags.Title) + assertNear("intro start", parsed.Chapters[1].Start, 1000) + assertNear("intro end", parsed.Chapters[1].End, 2500) + + assert.Equal(t, "middle", parsed.Chapters[2].Tags.Title) + assertNear("middle start", parsed.Chapters[2].Start, 2500) +} + +func abs(v int64) int64 { + if v < 0 { + return -v + } + return v +} diff --git a/server/lib/recorder/markers_test.go b/server/lib/recorder/markers_test.go new file mode 100644 index 00000000..72bb6d08 --- /dev/null +++ b/server/lib/recorder/markers_test.go @@ -0,0 +1,414 @@ +package recorder + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/scaletozero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readMeta(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} + +func TestBuildChapterMetadata_SentinelAndOrdering(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{ + {Name: "second", At: start.Add(5 * time.Second)}, + {Name: "first", At: start.Add(2 * time.Second)}, + } + + path, ok, err := buildChapterMetadata(out, markers, start, 10000) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, out+".ffmeta", path) + + meta := readMeta(t, path) + assert.True(t, strings.HasPrefix(meta, "FFMETADATA1\n"), "must start with the ffmetadata header") + + // Sentinel covers [0, firstRealStart); markers are sorted ascending and the + // final chapter ends at durationMs. + expected := "FFMETADATA1\n" + + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=2000\ntitle=" + sentinelChapterName + "\n" + + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=2000\nEND=5000\ntitle=first\n" + + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=5000\nEND=10000\ntitle=second\n" + assert.Equal(t, expected, meta) +} + +func TestBuildChapterMetadata_ClampsNegativeOffsetsToZero(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{ + {Name: "before-start", At: start.Add(-3 * time.Second)}, + {Name: "kept", At: start.Add(4 * time.Second)}, + } + + path, ok, err := buildChapterMetadata(out, markers, start, 8000) + require.NoError(t, err) + require.True(t, ok) + + // The early marker is clamped to media t=0 instead of silently dropped, + // and takes the sentinel's place as the first chapter. + meta := readMeta(t, path) + assert.Contains(t, meta, "START=0\nEND=4000\ntitle=before-start") + assert.Contains(t, meta, "START=4000\nEND=8000\ntitle=kept") + assert.NotContains(t, meta, sentinelChapterName) + assert.Equal(t, 2, strings.Count(meta, "[CHAPTER]")) +} + +func TestStart_RemovesStaleOutput(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "stale.mp4") + require.NoError(t, os.WriteFile(outputPath, []byte("leftover from a previous run"), 0o644)) + + rec := &FFmpegRecorder{ + id: "stale-output", + binaryPath: mockBin, + params: defaultParams(tempDir), + outputPath: outputPath, + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + require.NoError(t, rec.Start(t.Context())) + t.Cleanup(func() { _ = rec.ForceStop(t.Context()) }) + + // The stale file is gone (the mock ffmpeg never writes one), so the + // capture anchor cannot be stamped from leftover bytes. + _, statErr := os.Stat(outputPath) + assert.True(t, os.IsNotExist(statErr), "stale output should be removed at start") + time.Sleep(50 * time.Millisecond) + rec.mu.Lock() + assert.True(t, rec.captureAnchor.IsZero(), "anchor must not come from stale bytes") + rec.mu.Unlock() +} + +func TestBuildChapterMetadata_EscapesSpecialChars(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{ + {Name: "a=b;c#d\\e", At: start.Add(1 * time.Second)}, + } + + path, ok, err := buildChapterMetadata(out, markers, start, 4000) + require.NoError(t, err) + require.True(t, ok) + assert.Contains(t, readMeta(t, path), `title=a\=b\;c\#d\\e`) +} + +func TestBuildChapterMetadata_ClampsMarkersPastDurationToEnd(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{ + {Name: "kept", At: start.Add(1 * time.Second)}, + {Name: "past-duration", At: start.Add(9 * time.Second)}, + } + + path, ok, err := buildChapterMetadata(out, markers, start, 4000) + require.NoError(t, err) + require.True(t, ok) + + // A marker timestamped past the media end (e.g. marked during the stop + // drain) is clamped to a zero-length chapter at the end rather than + // silently lost: Mark already returned 201 for it. + meta := readMeta(t, path) + assert.Contains(t, meta, "START=1000\nEND=4000\ntitle=kept") + assert.Contains(t, meta, "START=4000\nEND=4000\ntitle=past-duration") + // Sentinel + the two markers. + assert.Equal(t, 3, strings.Count(meta, "[CHAPTER]")) +} + +func TestBuildChapterMetadata_NonPositiveDurationFallsBack(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{{Name: "m", At: start.Add(1 * time.Second)}} + + for _, durationMs := range []int64{0, -250} { + path, ok, err := buildChapterMetadata(out, markers, start, durationMs) + require.NoError(t, err) + assert.False(t, ok, "duration %d must fall back to the plain remux", durationMs) + assert.Empty(t, path) + } +} + +func TestBuildChapterMetadata_NoUsableMarkers(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + + path, ok, err := buildChapterMetadata(out, nil, start, 4000) + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, path) + _, statErr := os.Stat(out + ".ffmeta") + assert.True(t, os.IsNotExist(statErr), "no metadata file should be written") +} + +func TestBuildChapterMetadata_NoSentinelWhenFirstAtZero(t *testing.T) { + start := time.Unix(1000, 0) + out := filepath.Join(t.TempDir(), "rec.mp4") + markers := []Marker{ + {Name: "at-zero", At: start}, + } + + path, ok, err := buildChapterMetadata(out, markers, start, 4000) + require.NoError(t, err) + require.True(t, ok) + meta := readMeta(t, path) + assert.NotContains(t, meta, sentinelChapterName) + assert.Equal(t, 1, strings.Count(meta, "[CHAPTER]")) + assert.Contains(t, meta, "START=0\nEND=4000\ntitle=at-zero") +} + +func TestMark_NotRecording(t *testing.T) { + rec := &FFmpegRecorder{ + id: "mark-idle", + exitCode: exitCodeInitValue, + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + _, _, err := rec.Mark("anything") + assert.ErrorIs(t, err, ErrNotRecording) +} + +func TestMark_AppendsAndReturnsOffset(t *testing.T) { + tempDir := t.TempDir() + rec := &FFmpegRecorder{ + id: "mark-active", + binaryPath: mockBin, + params: defaultParams(tempDir), + outputPath: filepath.Join(tempDir, "mark-active.mp4"), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + require.NoError(t, rec.Start(t.Context())) + t.Cleanup(func() { _ = rec.ForceStop(t.Context()) }) + + name, offset, err := rec.Mark(" checkpoint ") + require.NoError(t, err) + assert.Equal(t, "checkpoint", name, "name is trimmed") + assert.GreaterOrEqual(t, offset, int64(0)) + + rec.mu.Lock() + markers := rec.markers + rec.mu.Unlock() + require.Len(t, markers, 1) + assert.Equal(t, "checkpoint", markers[0].Name) +} + +func TestMark_RejectsBadNames(t *testing.T) { + tempDir := t.TempDir() + rec := &FFmpegRecorder{ + id: "mark-bad", + binaryPath: mockBin, + params: defaultParams(tempDir), + outputPath: filepath.Join(tempDir, "mark-bad.mp4"), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + require.NoError(t, rec.Start(t.Context())) + t.Cleanup(func() { _ = rec.ForceStop(t.Context()) }) + + _, _, err := rec.Mark(" ") + assert.ErrorIs(t, err, ErrInvalidMarkerName) + + _, _, err = rec.Mark(strings.Repeat("x", maxMarkerNameLen+1)) + assert.ErrorIs(t, err, ErrInvalidMarkerName) +} + +func TestMark_ConcurrentCallsAreSafe(t *testing.T) { + tempDir := t.TempDir() + rec := &FFmpegRecorder{ + id: "mark-race", + binaryPath: mockBin, + params: defaultParams(tempDir), + outputPath: filepath.Join(tempDir, "mark-race.mp4"), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + require.NoError(t, rec.Start(t.Context())) + t.Cleanup(func() { _ = rec.ForceStop(t.Context()) }) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + _, _, _ = rec.Mark("m") + }() + } + wg.Wait() + + rec.mu.Lock() + count := len(rec.markers) + rec.mu.Unlock() + assert.Equal(t, n, count) +} + +// chapterInputArgs is the only thing finalize adds for chapters; locking its +// output both ways guarantees the no-marker remux is byte-identical to before. +func TestChapterInputArgs(t *testing.T) { + assert.Nil(t, chapterInputArgs("")) + assert.Equal(t, []string{"-f", "ffmetadata", "-i", "/tmp/x.ffmeta", "-map", "0", "-map_chapters", "1"}, chapterInputArgs("/tmp/x.ffmeta")) +} + +func TestRemuxArgs_BackwardsCompat(t *testing.T) { + // No markers: byte-identical to the pre-feature remux command. + preFeature := []string{ + "-i", "/r/out.mp4", + "-c", "copy", + "-movflags", "+faststart", + "-f", "mp4", + "-y", + "/r/out.mp4.tmp", + } + assert.Equal(t, preFeature, remuxArgs("/r/out.mp4", "/r/out.mp4.tmp", "")) + + // Markers present: the chapter input and mapping are injected. + withChapters := remuxArgs("/r/out.mp4", "/r/out.mp4.tmp", "/r/out.mp4.ffmeta") + assert.Contains(t, withChapters, "-map_chapters") + assert.Contains(t, withChapters, "/r/out.mp4.ffmeta") +} + +// TestFinalize_RetriesWithoutChaptersOnRemuxFailure uses a fake ffmpeg that +// rejects the chaptered remux but succeeds on the plain one, proving a bad +// chapter input can never cost the recording its finalize. +func TestFinalize_RetriesWithoutChaptersOnRemuxFailure(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "rec.mp4") + require.NoError(t, os.WriteFile(outputPath, []byte("recording"), 0o644)) + + // Fails when the chapter metadata input is present, otherwise writes the + // output file (the last argument) like the real remux would. + script := "#!/usr/bin/env bash\n" + + "for a in \"$@\"; do if [[ \"$a\" == ffmetadata ]]; then exit 1; fi; done\n" + + "printf remuxed > \"${@: -1}\"\n" + bin := filepath.Join(tempDir, "fake_ffmpeg.sh") + require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) + + start := time.Now() + rec := &FFmpegRecorder{ + id: "retry-no-chapters", + binaryPath: bin, + outputPath: outputPath, + startTime: start, + endTime: start.Add(4 * time.Second), + exitCode: 0, + markers: []Marker{{Name: "m", At: start.Add(1 * time.Second)}}, + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + + require.NoError(t, rec.finalizeRecording(context.Background())) + + b, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, "remuxed", string(b), "plain retry output should replace the recording") + + _, statErr := os.Stat(outputPath + ".ffmeta") + assert.True(t, os.IsNotExist(statErr), "metadata temp file should be removed") +} + +func TestMark_NameLimitCountsCharactersNotBytes(t *testing.T) { + tempDir := t.TempDir() + rec := &FFmpegRecorder{ + id: "mark-runes", + binaryPath: mockBin, + params: defaultParams(tempDir), + outputPath: filepath.Join(tempDir, "mark-runes.mp4"), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), + } + require.NoError(t, rec.Start(t.Context())) + t.Cleanup(func() { _ = rec.ForceStop(t.Context()) }) + + // 200 multibyte characters (600 bytes) is within the 200-character limit. + name, _, err := rec.Mark(strings.Repeat("日", maxMarkerNameLen)) + require.NoError(t, err) + assert.Equal(t, strings.Repeat("日", maxMarkerNameLen), name) + + // 201 characters is over the limit, multibyte or not. + _, _, err = rec.Mark(strings.Repeat("日", maxMarkerNameLen+1)) + assert.ErrorIs(t, err, ErrInvalidMarkerName) +} + +func TestWatchCaptureStart_AnchorsToFirstOutputBytes(t *testing.T) { + rec := &FFmpegRecorder{ + id: "anchor-watch", + outputPath: filepath.Join(t.TempDir(), "anchor-watch.mp4"), + exitCode: exitCodeInitValue, + exited: make(chan struct{}), + } + defer close(rec.exited) + go rec.watchCaptureStart() + + // No output yet: the anchor must stay zero. + time.Sleep(50 * time.Millisecond) + rec.mu.Lock() + assert.True(t, rec.captureAnchor.IsZero(), "anchor set before any output bytes") + rec.mu.Unlock() + + // First bytes appear: the anchor is stamped at/after the write. + beforeWrite := time.Now() + require.NoError(t, os.WriteFile(rec.outputPath, []byte("ftyp"), 0o644)) + require.Eventually(t, func() bool { + rec.mu.Lock() + defer rec.mu.Unlock() + return !rec.captureAnchor.IsZero() + }, 2*time.Second, 5*time.Millisecond) + rec.mu.Lock() + assert.False(t, rec.captureAnchor.Before(beforeWrite), "anchor stamped at/after first write") + rec.mu.Unlock() +} + +func TestWatchCaptureStart_StopsOnExitWithoutOutput(t *testing.T) { + rec := &FFmpegRecorder{ + id: "anchor-exit", + outputPath: filepath.Join(t.TempDir(), "never.mp4"), + exitCode: exitCodeInitValue, + exited: make(chan struct{}), + } + close(rec.exited) + rec.watchCaptureStart() // must return promptly instead of polling forever + rec.mu.Lock() + defer rec.mu.Unlock() + assert.True(t, rec.captureAnchor.IsZero()) +} + +func TestWatchCaptureStart_NeverStampsAfterExit(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "late.mp4") + require.NoError(t, os.WriteFile(outputPath, []byte("bytes"), 0o644)) + + // Process already reaped (exitCode set, exited closed) but the file has + // bytes: stamping now would postdate endTime and zero out the duration. + rec := &FFmpegRecorder{ + id: "anchor-late", + outputPath: outputPath, + exitCode: 0, + exited: make(chan struct{}), + } + close(rec.exited) + rec.watchCaptureStart() + rec.mu.Lock() + defer rec.mu.Unlock() + assert.True(t, rec.captureAnchor.IsZero(), "anchor must never be stamped after exit") +} + +func TestMark_OffsetUsesCaptureAnchor(t *testing.T) { + start := time.Now() + rec := &FFmpegRecorder{ + id: "anchor-offset", + cmd: &exec.Cmd{}, + exitCode: exitCodeInitValue, + startTime: start.Add(-10 * time.Second), + captureAnchor: start.Add(-1 * time.Second), + } + _, offset, err := rec.Mark("m") + require.NoError(t, err) + assert.GreaterOrEqual(t, offset, int64(1000)) + assert.Less(t, offset, int64(5000), "offset measured from captureAnchor, not startTime") +} diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go index 8b3b53e3..ed028edd 100644 --- a/server/lib/recorder/recorder.go +++ b/server/lib/recorder/recorder.go @@ -12,6 +12,10 @@ type Recorder interface { Start(ctx context.Context) error Stop(ctx context.Context) error ForceStop(ctx context.Context) error + // Mark records a named marker at the current time. It returns the stored + // (trimmed) marker name and the provisional offset from the recording start + // in milliseconds. + Mark(name string) (string, int64, error) IsRecording(ctx context.Context) bool Metadata() *RecordingMetadata Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) diff --git a/server/openapi.yaml b/server/openapi.yaml index 5ef7d60e..aa693a1b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -232,7 +232,7 @@ paths: parameters: - name: id in: query - description: Optional recorder identifier. When omitted, the server uses the default recorder. + description: Identifier of the recording session to download, as passed to /recording/start. When omitted, the default recording session is downloaded. schema: type: string pattern: "^[a-zA-Z0-9-]+$" @@ -308,6 +308,36 @@ paths: $ref: "#/components/responses/ConflictError" "500": $ref: "#/components/responses/InternalError" + /recording/mark: + post: + summary: Add a named marker to an in-progress recording + description: >- + Records a named point in time during an active recording. At finalize + the markers are injected into the output MP4 as chapter markers. + operationId: markRecording + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MarkRecordingRequest" + responses: + "201": + description: Marker recorded + content: + application/json: + schema: + $ref: "#/components/schemas/MarkRecordingResult" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + description: No recording session exists with the given identifier + $ref: "#/components/responses/NotFoundError" + "409": + description: The recording session exists but is not actively recording + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" /computer/click_mouse: post: summary: Simulate a mouse click action on the host computer @@ -3165,7 +3195,7 @@ components: default: false id: type: string - description: Optional identifier for the recording session. Alphanumeric or hyphen. + description: Optional identifier for this recording session, used to target it from the other /recording endpoints (stop, mark, download, delete) and allowing multiple concurrent recordings. Alphanumeric or hyphen. When omitted, the default recording session is started. pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false StopRecordingRequest: @@ -3177,7 +3207,7 @@ components: default: false id: type: string - description: Identifier of the recorder to stop. Alphanumeric or hyphen. + description: Identifier of the recording session to stop, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the default recording session is stopped. pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false Error: @@ -3517,9 +3547,38 @@ components: properties: id: type: string - description: Identifier of the recording to delete. Alphanumeric or hyphen. + description: Identifier of the recording session to delete, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the default recording session is deleted. + pattern: "^[a-zA-Z0-9-]+$" + additionalProperties: false + MarkRecordingRequest: + type: object + required: [name] + properties: + name: + type: string + description: Name of the marker, used as the MP4 chapter title. + minLength: 1 + maxLength: 200 + id: + type: string + description: Identifier of the recording session to mark, as passed to /recording/start. Alphanumeric or hyphen. When omitted, the marker is added to the default recording session. pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false + MarkRecordingResult: + type: object + required: [name, offset_ms] + properties: + name: + type: string + description: Name of the recorded marker. + offset_ms: + type: integer + format: int64 + description: >- + Provisional offset of the marker from the recording start, in + milliseconds, measured against the start time at mark time. The + authoritative offset is the chapter start written at finalize. + additionalProperties: false FileInfo: type: object required: [name, path, size_bytes, is_dir, mod_time, mode]