* Canvas recorder class

This commit is contained in:
Michaël Van Canneyt 2025-05-10 10:54:07 +02:00
parent f000e8eb00
commit b50a840e9f
2 changed files with 400 additions and 0 deletions

View File

@ -0,0 +1,3 @@
{$DEFINE FPC_DOTTEDUNITS}
unit Fcl.CanvasRecorder;
{$i canvasrecorder.pas}

View File

@ -0,0 +1,397 @@
{
This file is part of the Free Pascal run time library.
Copyright (c) 2025 by the Free Pascal development team
Class to record canvas commands and replay them.
See the file COPYING.FPC, included in this distribution,
for details about the copyright.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
**********************************************************************}
{$IFNDEF FPC_DOTTEDUNITS}
unit canvasrecorder;
{$ENDIF}
{$mode ObjFPC}
{$modeswitch externalclass}
interface
uses
{$IFDEF FPC_DOTTEDUNITS}
System.SysUtils, System.Types, JSApi.JS, BrowserApi.WebOrWorker;
{$ELSE}
SysUtils, JS, Types, WebOrWorker;
{$ENDIF}
Type
ECanvasRecorder = class(Exception);
TCommandObject = class external name 'Object' (TJSObject)
typ : string;
prop : string;
timestamp : TJSDOMHighResTimeStamp;
end;
TCommandObjectDynArray = array of TCommandObject;
TCallObject = class external name 'Object' (TCommandObject)
args : TJSValueDynArray;
end;
{ TCallObjectHelper }
TCallObjectHelper = class helper for TCallObject
class function create(aProp : String; aArgs : TJSValueDynArray) : TCallObject; static;
function tostring : string;
end;
TSetObject = class external name 'Object' (TCommandObject)
value : JSValue;
end;
{ TSetObjectHelper }
TSetObjectHelper = class helper for TSetObject
class function create(aProp : String; aValue : JSValue) : TSetObject; static;
function tostring : string;
end;
{ TCanvasRecorder }
TCanvasRecorderLogEvent = procedure(const aMsg : string) of object;
TCanvasRecorder = Class(TObject)
Type
TReplay = record
FromIndex,ToIndex : Integer;
end;
Private
FOnLog: TCanvasRecorderLogEvent;
FCommands: TCommandObjectDynArray;
FRecording : Boolean;
FCurrentCommand,
FMaxCommand : Integer;
FSourceContext,
FProxyContext,
FTargetContext :TJSBaseCanvasRenderingContext2D;
FIntervalID : NativeInt;
function GetCommand(aIndex : Integer): TCommandObject;
function GetCommandCount: Integer;
function GetDuration: TJSDOMHighResTimeStamp;
procedure logMessage(aMessage: String);
protected
function CreateRecordingProxy(aSourceContext: TJSBaseCanvasRenderingContext2D): TJSBaseCanvasRenderingContext2D; virtual;
Public
constructor Create;
// Set the canvas context to render. Returns a proxified version of the canvas, which must be used as canvas.
function CaptureCanvas(aSource : TJSBaseCanvasRenderingContext2D) : TJSBaseCanvasRenderingContext2D;
// Set the context on which to replay the commands.
Procedure SetReplayContext(aTarget : TJSBaseCanvasRenderingContext2D);
// Start recording. Resets the command array
procedure StartRecording;
// Stop recording. Resets the command array
procedure StopRecording;
// Replay commands from index aFrom to aTo, inclusive.
// If aInterval is given, it is an interval in milliseconds between commands.
procedure ReplayRange(aFrom, aTo: Integer; aInterval: Integer = 0);
// Replay all commands. If aInterval is given, it is an interval in milliseconds between commands.
procedure Replay(aInterval : Integer = 0);
// Replay a single command, the command at CurrentCommandIndex
procedure ReplayCommand;
// Cancel replay: resets the start/stop/interval settings
procedure CancelReplay;
// Convert a relative DOMHighResTimeStamp to the index in the array of commands.
function TimeToIndex(aRelativeTime: TJSDOMHighResTimeStamp) : Integer;
// Are there still commands to be replayed ?
function HaveReplayCommand : Boolean;
// is a replay in progress ?
function ReplayInProgress : Boolean;
// Return the array of commands, resets the commands
function ExtractCommands : TCommandObjectDynArray;
// Number of recorded commands
property CommandCount : Integer Read GetCommandCount;
// Current command index during replay.
property CurrentCommandIndex : Integer Read FCurrentCommand;
// Indexed access to all commands.
property Commands[aIndex : Integer] : TCommandObject read GetCommand;
// Total duration of the commands.
property Duration : TJSDOMHighResTimeStamp read GetDuration;
// Logs the commands that are being replayed.
property OnLog : TCanvasRecorderLogEvent read FOnLog Write FOnLog;
end;
implementation
{ TCallObjectHelper }
class function TCallObjectHelper.create(aProp: String; aArgs: TJSValueDynArray): TCallObject;
begin
Result:=TCallObject.New;
Result.typ:='call';
Result.timestamp:=self_.Performance.now;
Result.prop:=aProp;
Result.args:=aArgs;
end;
function TCallObjectHelper.tostring: string;
begin
Result:='Call '+Prop+'('+TJSJSON.stringify(args)+')';
end;
{ TSetObjectHelper }
class function TSetObjectHelper.create(aProp: String; aValue: JSValue): TSetObject;
begin
Result:=TSetObject.New;
Result.typ:='set';
Result.timestamp:=self_.Performance.now;
Result.prop:=aProp;
Result.value:=aValue;
end;
function TSetObjectHelper.tostring: string;
begin
Result:='Set '+prop+' = ' +TJSJSON.stringify(value);
end;
constructor TCanvasRecorder.Create;
begin
FCommands:=[];
FCurrentCommand:=0;
end;
function TCanvasRecorder.CaptureCanvas(aSource : TJSBaseCanvasRenderingContext2D) : TJSBaseCanvasRenderingContext2D;
begin
FSourceContext:=aSource;
FProxyContext:=CreateRecordingProxy(FSourceContext);
FCommands:=[];
FCurrentCommand:=0;
Result:=FProxyContext;
end;
procedure TCanvasRecorder.StartRecording;
begin
if FProxyContext=Nil then
raise ECanvasRecorder.Create('No canvas to record');
FRecording:=True;
end;
procedure TCanvasRecorder.StopRecording;
begin
FRecording:=False;
FMaxCommand:=CommandCount-1;
end;
function TCanvasRecorder.CreateRecordingProxy(aSourceContext: TJSBaseCanvasRenderingContext2D) : TJSBaseCanvasRenderingContext2D;
function handleGet (aTarget : TJSObject; aProperty: string) : JSValue;
var
aValue : JSValue;
aFunc : TJSFunction absolute aValue;
begin
aValue:=aTarget[aproperty];
if (jsTypeOf(aValue)<>'function') then
exit(aValue);
// Construct wrapper
Result:=Function () : JSValue
var
args : TJSValueDynArray;
rec : TJSObject;
begin
asm
args=arguments;
end;
if (FRecording) then
begin
rec:=TCallObject.Create(aProperty,args);
TJSArray(FCommands).push(rec);
end;
Result:=aFunc.apply(aTarget,args);
end;
end;
function handleSet (aTarget : TJSObject; aProperty : string; aValue : JSValue) : JSValue;
var
rec : TJSObject;
begin
aTarget[aProperty]:=aValue;
if (FRecording) then
begin
rec:=TSetObject.Create(aProperty,aValue);
TJSArray(FCommands).push(rec);
end;
Result:=True;
end;
var
aHandler: TJSObject;
begin
aHandler:=TJSObject.New;
aHandler['get']:=@handleGet;
aHandler['set']:=@handleSet;
Result:=TJSBaseCanvasRenderingContext2D(TJSProxy.New(aSourceContext,aHandler));
end;
procedure TCanvasRecorder.SetReplayContext(aTarget: TJSBaseCanvasRenderingContext2D);
begin
FTargetContext:=aTarget;
end;
function TCanvasRecorder.TimeToIndex(aRelativeTime: TJSDOMHighResTimeStamp): Integer;
var
lMin,lMax : integer;
begin
Result:=-1;
lMax:=CommandCount;
if lMax=0 then exit;
aRelativeTime:=aRelativeTime+FCommands[0].timestamp;
lMin:=0;
Dec(lMax);
While lMin<lMax do
begin
Result:=Trunc((lMin+lMax) div 2);
if (aRelativeTime<FCommands[Result].Timestamp) then
lMax:=Result-1
else if (aRelativeTime>FCommands[Result].Timestamp) then
lMin:=Result+1
end;
if FCommands[Result].Timestamp>aRelativeTime then
Result:=-1;
end;
function TCanvasRecorder.HaveReplayCommand: Boolean;
begin
Result:=FCurrentCommand<=FMaxCommand
end;
function TCanvasRecorder.ReplayInProgress: Boolean;
begin
Result:=(FIntervalID>0);
end;
function TCanvasRecorder.ExtractCommands: TCommandObjectDynArray;
begin
Result:=FCommands;
FCommands:=Nil;
end;
procedure TCanvasRecorder.logMessage(aMessage: String);
begin
if Assigned(FOnLog) then
FOnLog(aMessage);
end;
function TCanvasRecorder.GetCommandCount: Integer;
begin
Result:=Length(FCommands);
end;
function TCanvasRecorder.GetCommand(aIndex : Integer): TCommandObject;
begin
if (aIndex>=0) and (aIndex<Length(FCommands)) then
Result:=FCommands[aIndex]
else
Result:=Nil;
end;
function TCanvasRecorder.GetDuration: TJSDOMHighResTimeStamp;
var
lCount : integer;
begin
Result:=0;
lCount:=CommandCount;
if lCount=0 then
exit;
Result:=FCommands[lCount-1].timestamp-FCommands[0].timestamp;
end;
procedure TCanvasRecorder.Replay(aInterval: Integer);
begin
ReplayRange(0,CommandCount-1,aInterval);
end;
procedure TCanvasRecorder.ReplayRange(aFrom,aTo: Integer; aInterval : Integer);
procedure DoStep;
begin
if HaveReplayCommand then
ReplayCommand
else
CancelReplay;
end;
begin
if FRecording then
exit;
if ReplayInProgress then
raise ECanvasRecorder.Create('Replay is already in progress');
FCurrentCommand:=aFrom;
FMaxCommand:=aTo;
if aInterval=0 then
begin
while HaveReplayCommand do
ReplayCommand;
end
else
FIntervalID:=self_.setInterval(@DoStep,aInterval);
end;
procedure TCanvasRecorder.ReplayCommand;
var
lStep : TCommandObject;
lCall : TCallObject absolute lStep;
lSet : TSetObject absolute lStep;
begin
if FRecording then
exit;
if (FCurrentCommand>=CommandCount) then
exit;
lStep:=FCommands[FCurrentCommand];
inc(FCurrentCommand);
if (lStep.typ='call') then
begin
try
TJSFunction(FtargetContext[lStep.prop]).apply(FtargetContext,lCall.args);
logMessage('Call['+IntTostr(FCurrentCommand)+']: '+lCall.ToString);
except
// cannot be pascal error
on E : TJSError do
logMessage('Error calling '+lStep.prop+': '+E.Message);
end;
end
else if (lStep.typ='set') then
begin
try
FtargetContext[lStep.prop]:=lSet.value;
logMessage('Set['+IntTostr(FCurrentCommand)+']: '+lSet.ToString);
except
on E : TJSError do
logMessage('Error calling '+lStep.prop+': '+E.Message);
end;
end;
end;
procedure TCanvasRecorder.CancelReplay;
begin
FCurrentCommand:=0;
FMaxCommand:=CommandCount-1;
if FIntervalID=0 then
exit;
self_.clearInterval(FIntervalID);
FIntervalID:=0;
end;
end.