diff --git a/.gitignore b/.gitignore index c30b3e6..a372e53 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,14 @@ open-source *.key *.vtt +### Coverage folders for testing +coverage +coverage-reports +source/test +source/test/* +source/coverage-reports +.scannerwork/ + ### ignore webapp compiled JS files source/webapp/common-bundle-dev.js source/webapp/solution-manifest.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e4ef2..a9e1f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0] - 2023-02-28 +### Added +- AppRegistry integration +- SonarQube properties file + +### Changed +- Code fixes for SonarQube + +### Contributors +* @sandimciin +* @eggoynes + ## [3.0.0] - 2022-02-01 ### Added - Amazon Rekognition Custom Labels model support diff --git a/README.md b/README.md index 09edb70..aeea0d9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AWS Media2Cloud on AWS Solution Version 3 +# AWS Media2Cloud Solution Version 3 ## Table of contents * [What's new in V3](#whats-new-in-v3) @@ -31,7 +31,7 @@ __ ## Introduction -The Media2Cloud on AWS solution is designed to demonstrate a serverless ingest and analysis framework that can quickly setup a baseline ingest and analysis workflow for placing video, image, audio, and document assets and associated metadata under management control of an AWS customer. The solution will setup the core building blocks that are common in an ingest and analysis strategy: +AWS Media2Cloud solution is designed to demonstrate a serverless ingest and analysis framework that can quickly setup a baseline ingest and analysis workflow for placing video, image, audio, and document assets and associated metadata under management control of an AWS customer. The solution will setup the core building blocks that are common in an ingest and analysis strategy: * Establish a storage policy that manages master materials as well as proxies generated by the ingest process. * Provide a unique identifier (UUID) for each master video asset. * Calculate and provide a MD5 checksum. @@ -122,14 +122,6 @@ __ __ -## Anonymous Metrics - -This solution collects anonymous operational metrics to help AWS improve the -quality of features of the solution. For more information, including how to disable -this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/media2cloud/appendix-i.html). - - - ## License Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/deployment/build-open-source-dist.sh b/deployment/build-open-source-dist.sh old mode 100644 new mode 100755 diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh old mode 100644 new mode 100755 diff --git a/deployment/media2cloud-backend-stack.yaml b/deployment/media2cloud-backend-stack.yaml index da0d020..f984c00 100644 --- a/deployment/media2cloud-backend-stack.yaml +++ b/deployment/media2cloud-backend-stack.yaml @@ -46,6 +46,9 @@ Mappings: AnalysisImage: Package: "%%PKG_ANALYSIS_IMAGE%%" Name: analysis-image + # Docker image containerized lambda + BlipModel: + Name: blip-model AnalysisDocument: Package: "%%PKG_ANALYSIS_DOCUMENT%%" Name: analysis-document @@ -203,15 +206,25 @@ Parameters: Description: DefaultMinConfidence MediaConvertEndpoint: Type: String - Description: DefaultMinConfidence + Description: MediaConvertEndpoint StartOnObjectCreation: Type: String Description: StartOnObjectCreation + AIOptionsS3Key: + Type: String + Description: AIOptionsS3Key + BlipImageArn: + Type: String + Description: BlipImageArn Conditions: bStartOnObjectCreation: !Equals - !Ref StartOnObjectCreation - "YES" + bBlipImageArn: !Not + - !Equals + - !Ref BlipImageArn + - "" Resources: ################################################################################ @@ -1087,7 +1100,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:PutItem @@ -1456,6 +1468,16 @@ Resources: - comprehend:StartSentimentDetectionJob - comprehend:StartTopicsDetectionJob Resource: "*" + # Transcribe + - + Effect: Allow + Action: transcribe:StartTranscriptionJob + Resource: "*" + # MediaConvert + - + Effect: Allow + Action: mediaConvert:CreateJob + Resource: !Sub arn:aws:mediaconvert:${AWS::Region}:${AWS::AccountId}:* # S3 - StreamConnectorLambda starts AI/ML job on behalf # which requires GetObject when the services assume role - @@ -1494,7 +1516,9 @@ Resources: # IAM - Comprehend/Transcribe pass data access role - Effect: Allow - Action: iam:PassRole + Action: + - iam:GetRole + - iam:PassRole Resource: !GetAtt ServiceDataAccessRole.Arn BacklogStreamConnectorLambda: @@ -1551,6 +1575,7 @@ Resources: ENV_BACKLOG_TOPIC_ROLE_ARN: !GetAtt BacklogTopicRole.Arn ENV_DATA_ACCESS_ROLE: !GetAtt ServiceDataAccessRole.Arn ENV_ATOMICLOCK_TABLE: !Ref AtomicLockTable + ENV_MEDIACONVERT_HOST: !Ref MediaConvertEndpoint BacklogStreamEventSource: Type: AWS::Lambda::EventSourceMapping @@ -1568,7 +1593,10 @@ Resources: # * Updater lambda role # * Updater lambda # * Amazon SNS (Amazon Rekognition, Amazon Textract) - # * Amazon CloudWatch Event (Amazon Step Functions of Custom Labels) + # * Amazon EventBridge / CloudWatch Event + # * AWS Elemental MediaConvert + # * Amazon Transcribe + # * Amazon Step Functions of Custom Labels # ################################################################################ BacklogStatusUpdaterLogGroup: @@ -1703,7 +1731,7 @@ Resources: ENV_ATOMICLOCK_TABLE: !Ref AtomicLockTable ################################################################################ - # Rekognition / Textract SNS Notification + # Backlog Rekognition / Textract SNS Notification BacklogTopicSubscription: Type: AWS::SNS::Subscription Properties: @@ -1720,7 +1748,77 @@ Resources: SourceArn: !Ref BacklogTopic ################################################################################ - # Rekognition / Textract SNS Notification + # Backlog MediaConvert Job Status Change Event + BacklogMediaConvertStatusChangeEvent: + Type: AWS::Events::Rule + Properties: + Name: !Sub ${ResourcePrefix}-MediaConvertJobStatusChangeEventBacklog + Description: !Sub "(${SolutionLowerCaseId}) MediaConvert Job Status Change Event (Backlog)" + EventPattern: + source: + - aws.mediaconvert + region: + - !Ref AWS::Region + detail-type: + - MediaConvert Job State Change + detail: + status: + - COMPLETE + - CANCELED + - ERROR + userMetadata: + solutionUuid: + - !Ref SolutionUuid + State: ENABLED + Targets: + - + Id: !Sub Id-${BacklogStatusUpdaterLambda} + Arn: !GetAtt BacklogStatusUpdaterLambda.Arn + + BacklogMediaConvertStatusChangePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BacklogStatusUpdaterLambda + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt BacklogMediaConvertStatusChangeEvent.Arn + + ################################################################################ + # Backlog Transcribe Job Status Change Event + BacklogTranscribeStatusChangeEvent: + Type: AWS::Events::Rule + Properties: + Name: !Sub ${ResourcePrefix}-TranscribeJobStateChangeBacklog + Description: !Sub (${SolutionLowerCaseId}) Transcribe Job State Change Event (Backlog) + EventPattern: + source: + - aws.transcribe + region: + - !Ref AWS::Region + detail-type: + - Transcribe Job State Change + detail: + TranscriptionJobStatus: + - COMPLETED + - FAILED + TranscriptionJobName: + - prefix: !Sub ${SolutionUuid} + State: ENABLED + Targets: + - + Id: !Sub Id-${BacklogStatusUpdaterLambda} + Arn: !GetAtt BacklogStatusUpdaterLambda.Arn + + BacklogTranscribeStatusChangePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BacklogStatusUpdaterLambda + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt BacklogTranscribeStatusChangeEvent.Arn + + ################################################################################ + # Backlog Custom Labels State Machine Event BacklogCustomLabelsStateMachineStatusEvent: Type: AWS::Events::Rule Properties: @@ -2224,12 +2322,17 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query + - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem Resource: !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ResourcePrefix}-* + # EventBridge (ServiceBacklog) + - + Effect: Allow + Action: events:PutEvents + Resource: !GetAtt EventBridgeBus.Arn IngestVideoLambda: Type: AWS::Lambda::Function @@ -2270,6 +2373,7 @@ Resources: - !Ref AwsSdkLayer - !Ref CoreLibLayer - !Ref MediaInfoLayer + - !Ref BacklogLibLayer TracingConfig: Mode: Active Environment: @@ -2285,7 +2389,12 @@ Resources: ENV_INGEST_BUCKET: !Ref IngestBucket ENV_PROXY_BUCKET: !Ref ProxyBucket ENV_MEDIACONVERT_HOST: !Ref MediaConvertEndpoint - ENV_MEDIACONVERT_ROLE: !GetAtt ServiceDataAccessRole.Arn + ENV_DATA_ACCESS_ROLE: !GetAtt ServiceDataAccessRole.Arn + ## Service Backlog ## + ENV_BACKLOG_EB_BUS: !GetAtt EventBridgeBus.Name + ENV_BACKLOG_TABLE: !Ref BacklogTable + ENV_BACKLOG_TOPIC_ARN: !Ref BacklogTopic + ENV_BACKLOG_TOPIC_ROLE_ARN: !GetAtt BacklogTopicRole.Arn IngestVideoStateMachine: Type: AWS::StepFunctions::StateMachine @@ -2438,12 +2547,17 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query + - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem Resource: !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ResourcePrefix}-* + # EventBridge (ServiceBacklog) + - + Effect: Allow + Action: events:PutEvents + Resource: !GetAtt EventBridgeBus.Arn IngestAudioLambda: Type: AWS::Lambda::Function @@ -2484,6 +2598,7 @@ Resources: - !Ref AwsSdkLayer - !Ref CoreLibLayer - !Ref MediaInfoLayer + - !Ref BacklogLibLayer TracingConfig: Mode: Active Environment: @@ -2499,7 +2614,12 @@ Resources: ENV_INGEST_BUCKET: !Ref IngestBucket ENV_PROXY_BUCKET: !Ref ProxyBucket ENV_MEDIACONVERT_HOST: !Ref MediaConvertEndpoint - ENV_MEDIACONVERT_ROLE: !GetAtt ServiceDataAccessRole.Arn + ENV_DATA_ACCESS_ROLE: !GetAtt ServiceDataAccessRole.Arn + ## Service Backlog ## + ENV_BACKLOG_EB_BUS: !GetAtt EventBridgeBus.Name + ENV_BACKLOG_TABLE: !Ref BacklogTable + ENV_BACKLOG_TOPIC_ARN: !Ref BacklogTopic + ENV_BACKLOG_TOPIC_ROLE_ARN: !GetAtt BacklogTopicRole.Arn IngestAudioStateMachine: Type: AWS::StepFunctions::StateMachine @@ -2638,7 +2758,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -2817,7 +2936,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -3016,7 +3134,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -3332,8 +3449,8 @@ Resources: # # Ingest Automation # * Ingest status updater - # * MediaConvert Job Status Change Event # * Ingest Table DDB Stream Event + # * Backlog Status Change Event (Ingest) supports AWS Elemental MediaConvert # ################################################################################ IngestStatusUpdaterLogGroup: @@ -3369,7 +3486,7 @@ Resources: - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess Policies: - - PolicyName: !Sub ${ResourcePrefix}-ingest-status-updater + PolicyName: !Sub ${ResourcePrefix}-BacklogEventBridgeEvent PolicyDocument: Version: "2012-10-17" Statement: @@ -3390,17 +3507,16 @@ Resources: Resource: - !Ref IngestVideoStateMachine - !Ref IngestAudioStateMachine - # DynamoDB - get token from ServiceToken / delete items from AIML table on REMOVE event + # DynamoDB - get and delete token from ServiceToken - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem - dynamodb:DeleteItem Resource: !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ResourcePrefix}-* - # IoT + # IoT (TO BE REMOVED) - Effect: Allow Action: iot:Publish @@ -3410,7 +3526,11 @@ Resources: Effect: Allow Action: sns:Publish Resource: !Ref SNSTopic - # (DDB stream events below) + - + PolicyName: !Sub ${ResourcePrefix}-DDBStreamEvent + PolicyDocument: + Version: "2012-10-17" + Statement: # DynamoDB stream permission - Effect: Allow @@ -3501,52 +3621,60 @@ Resources: ENV_ES_DOMAIN_ENDPOINT: !Ref OpenSearchDomainEndpoint ################################################################################ - # MediaConvert Job Status Change Event - MediaConvertStatusChangeEvent: + # DynamoDB Ingest Table DDB Stream Event + IngestTableStreamEventSource: + Type: AWS::Lambda::EventSourceMapping + Properties: + Enabled: true + EventSourceArn: !GetAtt IngestTable.StreamArn + FunctionName: !Ref IngestStatusUpdaterLambda + BatchSize: 1 + MaximumRetryAttempts: 10 + StartingPosition: LATEST + + ################################################################################ + # Backlog Ingest Status Change Event + # * events fired by our Service Backlog EventBus + IngestBacklogStatusChangeEvent: Type: AWS::Events::Rule Properties: - Name: !Sub ${ResourcePrefix}-MediaConvertJobStatusChangeEvent - Description: !Sub "(${SolutionLowerCaseId}) MediaConvert Job Status Change Event" + Name: !Sub ${ResourcePrefix}-IngestBacklogStatusChangeEvent + Description: !Sub "(${SolutionId}) Backlog Ingest Status Change Event" + EventBusName: !GetAtt EventBridgeBus.Name EventPattern: source: - - aws.mediaconvert + - !FindInMap + - EventBridge + - Rule + - Source region: - !Ref AWS::Region detail-type: - - MediaConvert Job State Change + - !FindInMap + - EventBridge + - Rule + - Detail detail: status: + # MediaConvert - COMPLETE - CANCELED - ERROR - userMetadata: - solutionUuid: - - !Ref SolutionUuid + serviceApi: + - mediaconvert:createjob State: ENABLED Targets: - Id: !Sub Id-${IngestStatusUpdaterLambda} Arn: !GetAtt IngestStatusUpdaterLambda.Arn - MediaConvertStatusChangePermission: + IngestBacklogStatusChangePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref IngestStatusUpdaterLambda Action: lambda:InvokeFunction Principal: events.amazonaws.com - SourceArn: !GetAtt MediaConvertStatusChangeEvent.Arn - - ################################################################################ - # DynamoDB Ingest Table DDB Stream Event - IngestTableStreamEventSource: - Type: AWS::Lambda::EventSourceMapping - Properties: - Enabled: true - EventSourceArn: !GetAtt IngestTable.StreamArn - FunctionName: !Ref IngestStatusUpdaterLambda - BatchSize: 1 - MaximumRetryAttempts: 10 - StartingPosition: LATEST + SourceArn: !GetAtt IngestBacklogStatusChangeEvent.Arn ################################################################################ # @@ -3722,7 +3850,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:PutItem @@ -4406,7 +4533,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:PutItem @@ -5084,7 +5210,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -5168,6 +5293,170 @@ Resources: ENV_PROXY_BUCKET: !Ref ProxyBucket ENV_ES_DOMAIN_ENDPOINT: !Ref OpenSearchDomainEndpoint + # BLIP model lambda + BlipModelLogGroup: + Type: AWS::Logs::LogGroup + Metadata: + cfn_nag: + rules_to_suppress: + - + id: W84 + reason: Use default encryption. Disable additional KMS encryption requirement. https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html + Properties: + LogGroupName: !Sub + - /aws/lambda/${ResourcePrefix}-${name} + - name: !FindInMap + - Workflow + - BlipModel + - Name + RetentionInDays: 7 + + BlipModelRole: + Type: AWS::IAM::Role + Metadata: + cfn_nag: + rules_to_suppress: + - + id: W11 + reason: Wildcard character is prefixed and scoped with the ResourcePrefix + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: Allow + Action: sts:AssumeRole + Principal: + Service: lambda.amazonaws.com + Path: !Sub /${ResourcePrefix}/ + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess + Policies: + - + PolicyName: !Sub ${ResourcePrefix}-analysis-image + PolicyDocument: + Version: "2012-10-17" + Statement: + # CloudWatch Logs + - + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !GetAtt BlipModelLogGroup.Arn + # S3 - proxy bucket + - + Effect: Allow + Action: s3:ListBucket + Resource: !Sub arn:aws:s3:::${ProxyBucket} + - + Effect: Allow + Action: s3:GetObject + Resource: !Sub arn:aws:s3:::${ProxyBucket}/* + + BlipModelLambda: + Condition: bBlipImageArn + Type: AWS::Lambda::Function + Metadata: + cfn_nag: + rules_to_suppress: + - + id: W89 + reason: Workflow not using VPC + - + id: W92 + reason: Workflow not limiting simultaneous executions + Properties: + FunctionName: !Sub + - ${ResourcePrefix}-${name} + - name: !FindInMap + - Workflow + - BlipModel + - Name + Description: !Sub (${SolutionLowerCaseId}) BLIP model lambda (5308MB) + MemorySize: 5308 + Timeout: 900 + PackageType: Image + Role: !GetAtt BlipModelRole.Arn + Code: + ImageUri: !Ref BlipImageArn + Architectures: + - x86_64 + TracingConfig: + Mode: Active + + # Placeholder if BlipImageArn not defined + BlipModelPlaceholderLogGroup: + Type: AWS::Logs::LogGroup + Metadata: + cfn_nag: + rules_to_suppress: + - + id: W84 + reason: Use default encryption. Disable additional KMS encryption requirement. https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html + Properties: + LogGroupName: !Sub + - /aws/lambda/${ResourcePrefix}-${name}-placeholder + - name: !FindInMap + - Workflow + - BlipModel + - Name + RetentionInDays: 7 + + BlipModelPlaceholderLambda: + Type: AWS::Lambda::Function + Metadata: + cfn_nag: + rules_to_suppress: + - + id: W89 + reason: Workflow not using VPC + - + id: W92 + reason: Workflow not limiting simultaneous executions + Properties: + FunctionName: !Sub + - ${ResourcePrefix}-${name}-placeholder + - name: !FindInMap + - Workflow + - BlipModel + - Name + Description: !Sub (${SolutionLowerCaseId}) BLIP model (placeholder) lambda (128MB) + Runtime: !FindInMap + - Node + - Runtime + - Version + MemorySize: 128 + Timeout: 3 + Handler: index.handler + Role: !GetAtt BlipModelRole.Arn + Code: + ZipFile: | + exports.handler = async (event, context) => { + return { + caption: undefined, + }; + }; + + # Allow AnalysisStateMachine to run Blip model + BlipModelAnalysisPolicy: + Type: AWS::IAM::Policy + Properties: + Roles: + - !Ref AnalysisStateMachineServiceRole + PolicyName: AllowAnalysisStateMachineInvokeBlipModel + PolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: Allow + Action: lambda:InvokeFunction + Resource: !If + - bBlipImageArn + - !GetAtt BlipModelLambda.Arn + - !GetAtt BlipModelPlaceholderLambda.Arn + AnalysisImageStateMachine: Type: AWS::StepFunctions::StateMachine Properties: @@ -5182,42 +5471,74 @@ Resources: !Sub - |- { - "StartAt": "Start image analysis", + "StartAt": "Run parallel states", "States": { - "Start image analysis": { - "Type": "Task", - "Resource": "${x0}", - "Parameters": { - "operation": "start-image-analysis", - "uuid.$": "$.uuid", - "status": "NOT_STARTED", - "progress": 0, - "input.$": "$.input", - "data.$": "$.data", - "stateExecution.$": "$$.Execution" - }, - "Next": "Index analysis results", - "Retry": [ + "Run parallel states": { + "Type": "Parallel", + "Branches": [ { - "ErrorEquals": [ - "States.ALL" - ], - "IntervalSeconds": 1, - "MaxAttempts": 6, - "BackoffRate": 1.1 + "StartAt": "Start image analysis", + "States": { + "Start image analysis": { + "Type": "Task", + "Resource": "${AnalysisImageLambda.Arn}", + "Parameters": { + "operation": "start-image-analysis", + "uuid.$": "$.uuid", + "status": "NOT_STARTED", + "progress": 0, + "input.$": "$.input", + "data.$": "$.data" + }, + "End": true, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 6, + "BackoffRate": 1.1 + } + ] + } + } + }, + { + "StartAt": "Run BLIP model", + "States": { + "Run BLIP model": { + "Type": "Task", + "Resource": "${blipLambda}", + "Parameters": { + "bucket.$": "$.input.destination.bucket", + "key.$": "$.input.image.key" + }, + "ResultPath": "$.data.image", + "End": true, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 2, + "BackoffRate": 1.1 + } + ] + } + } } - ] + ], + "Next": "Index analysis results" }, "Index analysis results": { "Type": "Task", - "Resource": "${x0}", + "Resource": "${AnalysisImageLambda.Arn}", "Parameters": { "operation": "index-analysis-results", - "uuid.$": "$.uuid", - "status": "NOT_STARTED", - "progress": 0, - "input.$": "$.input", - "data.$": "$.data" + "parallelStateOutputs.$": "$", + "stateExecution.$": "$$.Execution" }, "End": true, "Retry": [ @@ -5235,7 +5556,11 @@ Resources: } - { - x0: !GetAtt AnalysisImageLambda.Arn + blipLambda: !If [ + bBlipImageArn, + !GetAtt BlipModelLambda.Arn, + !GetAtt BlipModelPlaceholderLambda.Arn + ] } ################################################################################ @@ -5306,7 +5631,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -5530,7 +5854,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -5636,6 +5959,7 @@ Resources: ## Default AI/ML options ## ENV_DEFAULT_AI_OPTIONS: !Ref DefaultAIOptions ENV_DEFAULT_MINCONFIDENCE: !Ref DefaultMinConfidence + ENV_AI_OPTIONS_S3KEY: !Ref AIOptionsS3Key AnalysisMainStateMachine: Type: AWS::StepFunctions::StateMachine @@ -5862,10 +6186,9 @@ Resources: # # Analysis Automation # * Analysis status updater - # * Transcribe Job Status Change Event - # * Rekognition async operation status change (SUCCEEDED or FAILED) - # * Comprehend async operation status change (PROCESSING) - # * Custom Labels async operation status change (FAILED, ABORTED, TIMED_OUT, SUCCEEDED) + # * Backlog Status Change Event (Analysis) includes + # Amazon Rekognition, Amazon Transcribe, + # Amazon Comprehend, & Custom Labels state machine # ################################################################################ AnalysisStatusUpdaterLogGroup: @@ -5932,7 +6255,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -6002,44 +6324,13 @@ Resources: ENV_SNS_TOPIC_ARN: !Ref SNSTopic ################################################################################ - # Transcribe Job Status Change Event - TranscribeStatusChangeEvent: + # Analysis Backlog Status Change Event + # * events fired by our Service Backlog EventBus + AnalysisBacklogStatusChangeEvent: Type: AWS::Events::Rule Properties: - Name: !Sub ${ResourcePrefix}-TranscribeJobStateChange - Description: !Sub (${SolutionLowerCaseId}) Transcribe Job State Change Event - EventPattern: - source: - - aws.transcribe - region: - - !Ref AWS::Region - detail-type: - - Transcribe Job State Change - detail: - TranscriptionJobStatus: - - COMPLETED - - FAILED - State: ENABLED - Targets: - - - Id: !Sub Id-${AnalysisStatusUpdaterLambda} - Arn: !GetAtt AnalysisStatusUpdaterLambda.Arn - - TranscribeStatusChangePermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref AnalysisStatusUpdaterLambda - Action: lambda:InvokeFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt TranscribeStatusChangeEvent.Arn - - ################################################################################ - # Rekognition Job Status Change Event (Backlog) - RekognitionStatusChangeEvent: - Type: AWS::Events::Rule - Properties: - Name: !Sub ${ResourcePrefix}-RekognitionStatusChangeBacklog - Description: !Sub "(${SolutionId}) Rekognition Status Change Event (Backlog)" + Name: !Sub ${ResourcePrefix}-AnalysisBacklogStatusChangeEvent + Description: !Sub "(${SolutionId}) Backlog Analysis Status Change Event" EventBusName: !GetAtt EventBridgeBus.Name EventPattern: source: @@ -6056,9 +6347,18 @@ Resources: - Detail detail: status: + # Rekognition (SUCCEEDED, FAILED) - SUCCEEDED - FAILED + # Transcribe (COMPLETED, FAILED) + - COMPLETED + # Comprehend (PROCESSING) + - PROCESSING + # Custom Labels state machine (SUCCEEDED, FAILED, ABORTED, TIMED_OUT) + - ABORTED + - TIMED_OUT serviceApi: + # Rekognition APIs supported by backlog management - rekognition:startcontentmoderation - rekognition:startcelebrityrecognition - rekognition:startfacedetection @@ -6067,93 +6367,17 @@ Resources: - rekognition:startpersontracking - rekognition:startsegmentdetection - rekognition:starttextdetection - State: ENABLED - Targets: - - - Id: !Sub Id-${AnalysisStatusUpdaterLambda} - Arn: !GetAtt AnalysisStatusUpdaterLambda.Arn - - RekognitionStatusChangePermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref AnalysisStatusUpdaterLambda - Action: lambda:InvokeFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt RekognitionStatusChangeEvent.Arn - - ################################################################################ - # Comprehend Job Status Change Event (Backlog) - ComprehendStatusChangeEvent: - Type: AWS::Events::Rule - Properties: - Name: !Sub ${ResourcePrefix}-ComprehendStatusChangeBacklog - Description: !Sub "(${SolutionId}) Comprehend Status Change Event (Backlog)" - EventBusName: !GetAtt EventBridgeBus.Name - EventPattern: - source: - - !FindInMap - - EventBridge - - Rule - - Source - region: - - !Ref AWS::Region - detail-type: - - !FindInMap - - EventBridge - - Rule - - Detail - detail: - status: - - PROCESSING - serviceApi: + # Transcribe APIs supported by backlog management + - transcribe:startmedicaltranscriptionjob + - transcribe:starttranscriptionjob + # Comprehend APIs supported by backlog management - comprehend:startdocumentclassificationjob - comprehend:startdominantlanguagedetectionjob - comprehend:startentitiesdetectionjob - comprehend:startkeyphrasesdetectionjob - comprehend:startsentimentdetectionjob - comprehend:starttopicsdetectionjob - State: ENABLED - Targets: - - - Id: !Sub Id-${AnalysisStatusUpdaterLambda} - Arn: !GetAtt AnalysisStatusUpdaterLambda.Arn - - ComprehendStatusChangePermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref AnalysisStatusUpdaterLambda - Action: lambda:InvokeFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt ComprehendStatusChangeEvent.Arn - - ################################################################################ - # RekognitionCustomLabels Job Status Change Event (Backlog) - RekognitionCustomLabelsStatusChangeEvent: - Type: AWS::Events::Rule - Properties: - Name: !Sub ${ResourcePrefix}-RekognitionCustomLabelsStatusChangeBacklog - Description: !Sub "(${SolutionId}) RekognitionCustomLabels Status Change Event (Backlog)" - EventBusName: !GetAtt EventBridgeBus.Name - EventPattern: - source: - - !FindInMap - - EventBridge - - Rule - - Source - region: - - !Ref AWS::Region - detail-type: - - !FindInMap - - EventBridge - - Rule - - Detail - detail: - status: - - FAILED - - ABORTED - - TIMED_OUT - - SUCCEEDED - serviceApi: + # Custom Custom Labels API supported by backlog management - custom:startcustomlabelsdetection State: ENABLED Targets: @@ -6161,13 +6385,13 @@ Resources: Id: !Sub Id-${AnalysisStatusUpdaterLambda} Arn: !GetAtt AnalysisStatusUpdaterLambda.Arn - RekognitionCustomLabelsStatusChangePermission: + AnalysisBacklogStatusChangePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref AnalysisStatusUpdaterLambda Action: lambda:InvokeFunction Principal: events.amazonaws.com - SourceArn: !GetAtt RekognitionCustomLabelsStatusChangeEvent.Arn + SourceArn: !GetAtt AnalysisBacklogStatusChangeEvent.Arn ################################################################################ # @@ -6336,7 +6560,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Scan - dynamodb:Query - dynamodb:UpdateItem @@ -6558,14 +6781,38 @@ Resources: ENV_INGEST_BUCKET: !Ref IngestBucket ENV_PROXY_BUCKET: !Ref ProxyBucket - S3EventPermission: + IngestObjectCreatedEvent: + Type: AWS::Events::Rule + Properties: + Name: !Sub ${ResourcePrefix}-IngestObjectCreated + Description: !Sub (${SolutionLowerCaseId}) Ingest Bucket Object Created Event + EventPattern: + source: + - aws.s3 + region: + - !Ref AWS::Region + detail-type: + - Object Created + detail: + bucket: + name: + - !Ref IngestBucket + State: !If + - bStartOnObjectCreation + - ENABLED + - DISABLED + Targets: + - + Id: !Sub Id-${IngestS3EventLambda} + Arn: !GetAtt IngestS3EventLambda.Arn + + IngestObjectCreatedPermission: Type: AWS::Lambda::Permission Properties: - Action: lambda:InvokeFunction FunctionName: !Ref IngestS3EventLambda - Principal: s3.amazonaws.com - SourceAccount: !Ref AWS::AccountId - SourceArn: !Sub arn:aws:s3:::${IngestBucket} + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt IngestObjectCreatedEvent.Arn CustomResourcesPolicyBackend: Type: AWS::IAM::Policy @@ -6581,6 +6828,11 @@ Resources: Effect: Allow Action: s3:PutBucketNotification Resource: !Sub arn:aws:s3:::${IngestBucket} + # SageMaker - describe model endpoint + - + Effect: Allow + Action: sagemaker:DescribeEndpoint + Resource: !Sub arn:aws:sagemaker:${AWS::Region}:${AWS::AccountId}:endpoint/* ConfigureBucketNotification: Condition: bStartOnObjectCreation @@ -6591,12 +6843,8 @@ Resources: Data: Bucket: !Ref IngestBucket NotificationConfiguration: - LambdaFunctionConfigurations: - - - Id: !Sub Id-${IngestS3EventLambda} - Events: - - s3:ObjectCreated:* - LambdaFunctionArn: !GetAtt IngestS3EventLambda.Arn + EventBridgeConfiguration: + EventBridgeEnabled: !Ref AWS::NoValue Outputs: # SNS Topic diff --git a/deployment/media2cloud-core-stack.yaml b/deployment/media2cloud-core-stack.yaml index 3d860cf..4b7e792 100644 --- a/deployment/media2cloud-core-stack.yaml +++ b/deployment/media2cloud-core-stack.yaml @@ -16,7 +16,7 @@ Mappings: Name: core-lib OpenSearch: Engine: - Version: OpenSearch_1.0 + Version: OpenSearch_1.3 Node: Runtime: Version: nodejs14.x @@ -206,19 +206,13 @@ Resources: AbortIncompleteMultipartUpload: DaysAfterInitiation: 1 IntelligentTieringConfigurations: - - - Id: EnableGlacierTier - Prefix: / + - + Id: EnableArchiveTiers Status: Enabled Tierings: - AccessTier: ARCHIVE_ACCESS Days: 90 - - - Id: EnableDeepArchiveTier - Prefix: / - Status: Enabled - Tierings: - AccessTier: DEEP_ARCHIVE_ACCESS Days: 180 diff --git a/deployment/media2cloud-webapp-stack.yaml b/deployment/media2cloud-webapp-stack.yaml index d0f8859..ebf9c45 100644 --- a/deployment/media2cloud-webapp-stack.yaml +++ b/deployment/media2cloud-webapp-stack.yaml @@ -15,6 +15,11 @@ Mappings: Node: Runtime: Version: nodejs14.x + Cognito: + Group: + Viewer: viewer + Creator: creator + Admin: admin Parameters: S3Bucket: @@ -89,6 +94,9 @@ Parameters: DefaultMinConfidence: Type: String Description: DefaultMinConfidence + AIOptionsS3Key: + Type: String + Description: AIOptionsS3Key Resources: ################################################################################ @@ -301,6 +309,8 @@ Resources: ENV_ES_DOMAIN_ENDPOINT: !Ref OpenSearchDomainEndpoint ENV_DEFAULT_AI_OPTIONS: !Ref DefaultAIOptions ENV_DEFAULT_MINCONFIDENCE: !Ref DefaultMinConfidence + ENV_USER_POOL_ID: !Ref CognitoUserPool + ENV_AI_OPTIONS_S3KEY: !Ref AIOptionsS3Key Tags: - Key: SolutionId @@ -739,6 +749,9 @@ Resources: ################################################################################ # # Cognito resources + # * UserPool, IdentityPool, AppClient, & Authenticated role + # * UserGroups (viewer, creator, & admin) & IAM roles and policies mapped to UserGroup + # * Add Cognito Admin APIs permission to ApiRole # ################################################################################ CognitoUserPool: @@ -945,6 +958,220 @@ Resources: RequireUppercase: true UserPoolName: !Sub ${ResourcePrefix}-userpool + # Different usergroup(s) + UserPoolGroupAdmin: + Type: AWS::Cognito::UserPoolGroup + Properties: + Description: Administrator access. Allow view, upload, process assets and manage users. + GroupName: !FindInMap + - Cognito + - Group + - Admin + Precedence: 1 + UserPoolId: !Ref CognitoUserPool + + UserPoolGroupCreator: + Type: AWS::Cognito::UserPoolGroup + Properties: + Description: Creator group allows users to view, create, and process assets. + GroupName: !FindInMap + - Cognito + - Group + - Creator + Precedence: 98 + UserPoolId: !Ref CognitoUserPool + + UserPoolGroupViewer: + Type: AWS::Cognito::UserPoolGroup + Properties: + Description: Viewer group allows users to view assets. + GroupName: !FindInMap + - Cognito + - Group + - Viewer + Precedence: 99 + UserPoolId: !Ref CognitoUserPool + + # Usergroup IAM role(s) + UserPoolGroupViewerRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + cognito-identity.amazonaws.com:aud: !Ref CognitoIdentityPool + ForAnyValue:StringLike: + cognito-identity.amazonaws.com:amr: authenticated + Path: !Sub /${ResourcePrefix}/ + + UserPoolGroupCreatorRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + cognito-identity.amazonaws.com:aud: !Ref CognitoIdentityPool + ForAnyValue:StringLike: + cognito-identity.amazonaws.com:amr: authenticated + Path: !Sub /${ResourcePrefix}/ + + UserPoolGroupAdminRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - + Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + cognito-identity.amazonaws.com:aud: !Ref CognitoIdentityPool + ForAnyValue:StringLike: + cognito-identity.amazonaws.com:amr: authenticated + Path: !Sub /${ResourcePrefix}/ + + # Usergroup role polices + UserPoolGroupNonAdminDenyPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${ResourcePrefix}-UserPoolGroupNonAdminDenyPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + # Deny all HTTP methods on /users and /users/* + - + Effect: Deny + Action: execute-api:Invoke + Resource: + - !Sub + - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/*/users + - stageName: !FindInMap + - APIGW + - Stage + - Name + - !Sub + - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/*/users/* + - stageName: !FindInMap + - APIGW + - Stage + - Name + # Deny POST and DELETE methods on /settings/* + - + Effect: Deny + Action: execute-api:Invoke + Resource: + - !Sub + - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/POST/settings/* + - stageName: !FindInMap + - APIGW + - Stage + - Name + - !Sub + - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/DELETE/settings/* + - stageName: !FindInMap + - APIGW + - Stage + - Name + Roles: + - !Ref UserPoolGroupViewerRole + - !Ref UserPoolGroupCreatorRole + + UserPoolGroupViewerAccessPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${ResourcePrefix}-UserPoolGroupViewerAccessPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + # Cognito - allow to get identity id + - + Effect: Allow + Action: cognito-identity:GetId + Resource: !Sub arn:aws:cognito-identity:${AWS::Region}:${AWS::AccountId}:identitypool/${CognitoIdentityPool} + # S3 - Read/List on ingest bucket + - + Effect: Allow + Action: s3:ListBucket + Resource: !Sub arn:aws:s3:::${IngestBucket} + - + Effect: Allow + Action: s3:GetObject + Resource: !Sub arn:aws:s3:::${IngestBucket}/* + # S3 - Read/List on proxy bucket + - + Effect: Allow + Action: s3:ListBucket + Resource: !Sub arn:aws:s3:::${ProxyBucket} + - + Effect: Allow + Action: s3:GetObject + Resource: !Sub arn:aws:s3:::${ProxyBucket}/* + # API Gateway + - + Effect: Allow + Action: execute-api:Invoke + Resource: !Sub + - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/*/* + - stageName: !FindInMap + - APIGW + - Stage + - Name + # IoT + - + Effect: Allow + Action: iot:Connect + Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/* + - + Effect: Allow + Action: iot:Subscribe + Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topicfilter/${IotTopic} + - + Effect: Allow + Action: iot:Receive + Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topic/${IotTopic} + Roles: + - !Ref UserPoolGroupViewerRole + - !Ref UserPoolGroupCreatorRole + - !Ref UserPoolGroupAdminRole + + UserPoolGroupCreatorAccessPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${ResourcePrefix}-UserPoolGroupCreatorAccessPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + # S3 - Write on ingest bucket + - + Effect: Allow + Action: s3:PutObject + Resource: !Sub arn:aws:s3:::${IngestBucket}/* + # S3 - Write on proxy bucket + - + Effect: Allow + Action: s3:PutObject + Resource: !Sub arn:aws:s3:::${ProxyBucket}/* + Roles: + - !Ref UserPoolGroupCreatorRole + - !Ref UserPoolGroupAdminRole + CognitoAppClient: Type: AWS::Cognito::UserPoolClient Properties: @@ -992,56 +1219,13 @@ Resources: PolicyDocument: Version: "2012-10-17" Statement: - # Cognito - allow to get identity id - - - Effect: Allow - Action: cognito-identity:GetId - Resource: !Sub arn:aws:cognito-identity:${AWS::Region}:${AWS::AccountId}:identitypool/${CognitoIdentityPool} - # S3 - Read/Write/List on ingest bucket - Effect: Allow - Action: s3:ListBucket - Resource: !Sub arn:aws:s3:::${IngestBucket} - - - Effect: Allow - Action: - - s3:GetObject - - s3:PutObject - Resource: !Sub arn:aws:s3:::${IngestBucket}/* - # S3 - Read/Write/List on proxy bucket - - - Effect: Allow - Action: s3:ListBucket - Resource: !Sub arn:aws:s3:::${ProxyBucket} - - - Effect: Allow - Action: - - s3:GetObject - - s3:PutObject - Resource: !Sub arn:aws:s3:::${ProxyBucket}/* - # API Gateway - - - Effect: Allow - Action: execute-api:Invoke - Resource: !Sub - - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/${stageName}/*/* - - stageName: !FindInMap - - APIGW - - Stage - - Name - # IoT - - - Effect: Allow - Action: iot:Connect - Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/* - - - Effect: Allow - Action: iot:Subscribe - Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topicfilter/${IotTopic} - - - Effect: Allow - Action: iot:Receive - Resource: !Sub arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topic/${IotTopic} + Action: iam:PassRole + Resource: + - !GetAtt UserPoolGroupAdminRole.Arn + - !GetAtt UserPoolGroupCreatorRole.Arn + - !GetAtt UserPoolGroupViewerRole.Arn CognitoIdentityPoolRoleAttachment: Type: AWS::Cognito::IdentityPoolRoleAttachment @@ -1049,6 +1233,59 @@ Resources: IdentityPoolId: !Ref CognitoIdentityPool Roles: authenticated: !GetAtt CognitoAuthenticatedRole.Arn + RoleMappings: + userpool: + AmbiguousRoleResolution: Deny + IdentityProvider: !Sub ${CognitoUserPool.ProviderName}:${CognitoAppClient} + Type: Rules + RulesConfiguration: + Rules: + - + Claim: cognito:groups + MatchType: Contains + Value: !FindInMap + - Cognito + - Group + - Admin + RoleARN: !GetAtt UserPoolGroupAdminRole.Arn + - + Claim: cognito:groups + MatchType: Contains + Value: !FindInMap + - Cognito + - Group + - Creator + RoleARN: !GetAtt UserPoolGroupCreatorRole.Arn + - + Claim: cognito:groups + MatchType: Contains + Value: !FindInMap + - Cognito + - Group + - Viewer + RoleARN: !GetAtt UserPoolGroupViewerRole.Arn + + # Attach cognito admin access to ApiRole + ApiRolePolicyUserManagement: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub ${ResourcePrefix}-ApiRolePolicyUserManagement + PolicyDocument: + Version: "2012-10-17" + Statement: + # Cognito management + - + Effect: Allow + Action: + - cognito-idp:AdminCreateUser + - cognito-idp:AdminDeleteUser + - cognito-idp:AdminGetUser + - cognito-idp:AdminListGroupsForUser + - cognito-idp:AdminAddUserToGroup + - cognito-idp:ListUsers + Resource: !GetAtt CognitoUserPool.Arn + Roles: + - !Ref ApiRole ################################################################################ # @@ -1121,12 +1358,12 @@ Resources: Data: DistributionId: !Ref CloudFrontDistributionId Paths: + - /index.html - /app.min.js - /solution-manifest.js - - /css/app.css - - /src/lib/js/* + - /css* + - /src* - /third_party* - - /*.html LastUpdated: !GetAtt CopyWebContent.LastUpdated UpdateIngestCorsRule: diff --git a/deployment/media2cloud.yaml b/deployment/media2cloud.yaml index 3ad7429..64198a8 100644 --- a/deployment/media2cloud.yaml +++ b/deployment/media2cloud.yaml @@ -9,6 +9,9 @@ Mappings: LowerCaseId: "%%SOLUTION_ID_LOWERCASE%%" Version: "%%VERSION%%" CustomUserAgent: AWSSOLUTION/%%SOLUTION_ID%%/%%VERSION%% + SolutionName: "Media2Cloud on AWS" + AppRegistryApplicationName: "Media2CloudOnAws" + ApplicationType: "AWS-Solutions" Template: S3Bucket: "%%BUCKET_NAME%%" KeyPrefix: "%%KEYPREFIX%%" @@ -24,12 +27,19 @@ Mappings: AIML: Options: DefaultMinConfidence: 80 + # store aioptions to proxy bucket + S3Key: _settings/aioptions.json Node: Runtime: Version: nodejs14.x Send: AnonymousUsage: Data: "Yes" + Cognito: + Group: + Viewer: viewer + Creator: creator + Admin: admin Parameters: # User defined Ingest Bucket @@ -41,12 +51,12 @@ Parameters: OpenSearchCluster: Type: String Description: Configure Amazon OpenSearch cluster size - Default: Development and Testing (t3.small=0,m5.large=1,gp2=10,az=1) + Default: Development and Testing (t3.medium=0,m5.large=1,gp2=10,az=1) AllowedValues: - - Development and Testing (t3.small=0,m5.large=1,gp2=10,az=1) - - Suitable for Production Workload (t3.small=3,m5.large=2,gp2=20,az=2) - - Recommended for Production Workload (t3.small=3,m5.large=4,gp2=20,az=2) - - Recommended for Large Production Workload (t3.small=3,m5.large=6,gp2=40,az=3) + - Development and Testing (t3.medium=0,m5.large=1,gp2=10,az=1) + - Suitable for Production Workload (t3.medium=3,m5.large=2,gp2=20,az=2) + - Recommended for Production Workload (t3.medium=3,m5.large=4,gp2=20,az=2) + - Recommended for Large Production Workload (t3.medium=3,m5.large=6,gp2=40,az=3) # AI/ML Settings DefaultAIOptions: Type: String @@ -62,6 +72,7 @@ Parameters: - Celebrity recognition only (celeb) - Video segment detection only (segment) - Speech to text only (transcribe) + - Others - Celebrity and face matching (celeb,facematch) # SNS / Cognito parameters Email: Type: String @@ -81,10 +92,21 @@ Parameters: StartOnObjectCreation: Type: String Description: Start workflow when directly upload assets to ingest S3 bucket - Default: "NO" + Default: "YES" AllowedValues: - "NO" - "YES" + # Neptune Knowledge Graph endpoint + KnowledgeGraphEndpoint: + Type: String + Description: specify the API endpoint of the Knowledge Graph database. Leave it blank to disable the feature. + KnowledgeGraphApiKey: + Type: String + Description: specify the API Key of the Knowledge Graph API endpoint. Leave it blank to disable the feature. + # BLIP model (auto caption for image) + BlipImageArn: + Type: String + Description: specify the ECR image Arn of the Auto Captioning feature (BLIP model), ie., {{account}}.dkr.ecr.{{region}}.amazonaws.com/{{repo}}:{{tag}}. Leave it blank to disable the feature. Metadata: AWS::CloudFormation::Interface: @@ -111,10 +133,21 @@ Metadata: - DefaultAIOptions - Label: - default: (OPTIONAL) Advanced Customization + default: (OPTIONAL) Advanced Customerization Parameters: - UserDefinedIngestBucket - StartOnObjectCreation + - + Label: + default: (EXPERIMENTAL FEATURE) Knowledge Graph + Parameters: + - KnowledgeGraphEndpoint + - KnowledgeGraphApiKey + - + Label: + default: (EXPERIMENTAL FEATURE) Auto-Captioning for Images + Parameters: + - BlipImageArn ParameterLabels: Email: default: Email @@ -128,6 +161,12 @@ Metadata: default: User defined Amazon S3 Bucket for ingest StartOnObjectCreation: default: Allow autostart on ingest S3 bucket + KnowledgeGraphEndpoint: + default: Knowledge Graph API Endpoint + KnowledgeGraphApiKey: + default: Knowledge Graph API Endpoint API Key + BlipImageArn: + default: ECR Image ARN of the BLIP model Conditions: # Using email address for Cognito User Pool @@ -566,6 +605,10 @@ Resources: - AIML - Options - DefaultMinConfidence + AIOptionsS3Key: !FindInMap + - AIML + - Options + - S3Key ################################################################################ # @@ -671,6 +714,11 @@ Resources: - DefaultMinConfidence MediaConvertEndpoint: !GetAtt CoreStack.Outputs.MediaConvertEndpoint StartOnObjectCreation: !Ref StartOnObjectCreation + AIOptionsS3Key: !FindInMap + - AIML + - Options + - S3Key + BlipImageArn: !Ref BlipImageArn ################################################################################ # @@ -729,7 +777,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Query - dynamodb:Scan - dynamodb:UpdateItem @@ -741,7 +788,6 @@ Resources: - Effect: Allow Action: - - dynamodb:DescribeTable - dynamodb:Query - dynamodb:Scan Resource: !Sub ${BackendStack.Outputs.IngestTableArn}/index/* @@ -852,7 +898,24 @@ Resources: ClientId: !GetAtt WebAppStack.Outputs.CognitoAppClient IdentityPoolId: !GetAtt WebAppStack.Outputs.CognitoIdentityPool RedirectUri: !GetAtt WebAppStack.Outputs.HomePage + Group: + Viewer: !FindInMap + - Cognito + - Group + - Viewer + Creator: !FindInMap + - Cognito + - Group + - Creator + Admin: !FindInMap + - Cognito + - Group + - Admin LastUpdated: !GetAtt WebAppStack.Outputs.LastUpdated + # Knowledge Graph demo + KnowledgeGraph: + Endpoint: !Ref KnowledgeGraphEndpoint + ApiKey: !Ref KnowledgeGraphApiKey CognitoRegisterUser: DependsOn: CreateSolutionManifest @@ -889,6 +952,16 @@ Resources: - !Ref Email - !Ref AWS::NoValue + CognitoUserToGroup: + Type: AWS::Cognito::UserPoolUserToGroupAttachment + Properties: + GroupName: !FindInMap + - Cognito + - Group + - Admin + Username: !GetAtt CognitoRegisterUser.Username + UserPoolId: !GetAtt WebAppStack.Outputs.CognitoUserPool + SubscribeSNSTopic: Condition: bEmail DependsOn: CognitoRegisterUser @@ -921,6 +994,87 @@ Resources: OpenSearchCluster: !Ref OpenSearchCluster SolutionUuid: !GetAtt CoreStack.Outputs.SolutionUuid + ################################################################################ + # + # AppRegistry + # + ################################################################################ + Application: + Type: AWS::ServiceCatalogAppRegistry::Application + Properties: + Description: !Sub + - Service Catalog application to track and manage all your resources. The Solution ID is ${solutionId} and Solution Version is ${solutionVersion}. + - + solutionId: !FindInMap + - Solution + - Project + - Id + solutionVersion: !FindInMap + - Solution + - Project + - Version + Name: !Join + - "-" + - - !FindInMap + - Solution + - Project + - AppRegistryApplicationName + - !Ref AWS::Region + - !Ref AWS::AccountId + - !Ref AWS::StackName + Tags: { + "Solutions:SolutionID": !FindInMap [Solution, Project, Id], + "Solutions:SolutionName": !FindInMap [Solution, Project, SolutionName], + "Solutions:SolutionVersion": !FindInMap [Solution, Project, Version], + "Solutions:ApplicationType": !FindInMap [Solution, Project, ApplicationType], + } + + DefaultApplicationAttributes: + Type: AWS::ServiceCatalogAppRegistry::AttributeGroup + Properties: + Name: !Join ["-", [!Ref "AWS::Region", !Ref "AWS::StackName"]] + Description: Attribute group for solution information. + Attributes: { + "ApplicationType": !FindInMap [Solution, Project, ApplicationType], + "Version": !FindInMap [Solution, Project, Version], + "SolutionID": !FindInMap [Solution, Project, Id], + "SolutionName": !FindInMap [Solution, Project, SolutionName], + } + + AppRegistryApplicationAttributeAssociation: + Type: AWS::ServiceCatalogAppRegistry::AttributeGroupAssociation + Properties: + Application: !GetAtt Application.Id + AttributeGroup: !GetAtt DefaultApplicationAttributes.Id + + AppRegistryApplicationStackAssociation: + Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation + Properties: + Application: !GetAtt Application.Id + Resource: !Ref AWS::StackId + ResourceType: CFN_STACK + + AppRegistryApplicationStackAssociationCoreStack: + Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation + Properties: + Application: !GetAtt Application.Id + Resource: !Ref CoreStack + ResourceType: CFN_STACK + + AppRegistryApplicationStackAssociationWebAppStack: + Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation + Properties: + Application: !GetAtt Application.Id + Resource: !Ref WebAppStack + ResourceType: CFN_STACK + + AppRegistryApplicationStackAssociationBackendStack: + Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation + Properties: + Application: !GetAtt Application.Id + Resource: !Ref BackendStack + ResourceType: CFN_STACK + Outputs: HomePage: Value: !GetAtt WebAppStack.Outputs.HomePage diff --git a/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh old mode 100644 new mode 100755 index a4b2a77..f759534 --- a/deployment/run-unit-tests.sh +++ b/deployment/run-unit-tests.sh @@ -32,14 +32,9 @@ popd # Testing lambda packages # PACKAGES=(\ - "api" \ - "ingest" \ - "analysis-monitor" \ - "image-analysis" \ - "audio-analysis" \ - "video-analysis" \ - "document-analysis" \ - "gt-labeling" \ + "layers/core-lib" \ + "layers/mediainfo" \ + "layers/service-backlog-lib/" \ ) for package in "${PACKAGES[@]}"; do @@ -71,5 +66,29 @@ done echo "------------------------------------------------------------------------------" -echo "Installing Dependencies And Testing Complete" +echo "Running Unit Tests now. This may take a while." echo "------------------------------------------------------------------------------" + +[ "$DEBUG" == 'true' ] && set -x +set -e + +prepare_jest_coverage_report() { + local component_name=$1 + + if [ ! -d "coverage" ]; then + echo "ValidationError: Missing required directory coverage after running unit tests" + exit 129 + fi + + # prepare coverage reports + rm -fr coverage/lcov-report + mkdir -p $coverage_reports_top_path/jest + coverage_report_path=$coverage_reports_top_path/jest/$component_name + rm -fr $coverage_report_path + mv coverage $coverage_report_path +} + +# Get reference for all important folders +template_dir="$PWD" +source_dir="$template_dir/../source" +coverage_reports_top_path=$source_dir/test/coverage-reports diff --git a/source/api/index.spec.js b/source/api/index.spec.js new file mode 100644 index 0000000..40dbeca --- /dev/null +++ b/source/api/index.spec.js @@ -0,0 +1,238 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + ApiOps, + CommonUtils, + } = require('core-lib'); + +const ApiRequest = require('./lib/apiRequest'); + +// Import all of the API files +const AnalysisOp = require('./lib/operations/analysisOp'); +const AssetOp = require('./lib/operations/assetOp'); +const IotOp = require('./lib/operations/iotOp'); +const SearchOp = require('./lib/operations/searchOp'); +const StepOp = require('./lib/operations/stepOp'); +const RekognitionOp = require('./lib/operations/rekognitionOp'); +const TranscribeOp = require('./lib/operations/transcribeOp'); +const ComprehendOp = require('./lib/operations/comprehendOp'); +const StatsOp = require('./lib/operations/statsOp'); +const UsersOp = require('./lib/operations/usersOp'); +const SettingsOp = require('./lib/operations/settingsOp'); +const lambda = require('./index.js'); + +const { JobCompleted } = require('core-lib/lib/states'); + +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + +const media_info_sample_data = { + creatingLibrary: { + name: "MediaInfoLib", + version: "22.06", + url: "https://mediaarea.net/MediaInfo", + }, + media: { + "@ref": "VideoTurorial1.mp4", + track: [ + { + "@type": "General", + Count: "331", + StreamCount: "1", + StreamKind: "General", + StreamKind_String: "General", + StreamKindID: "0", + CompleteName: "VideoTurorial1.mp4", + FileNameExtension: "VideoTurorial1.mp4", + FileName: "VideoTurorial1", + FileExtension: "mp4", + Format: "MPEG-4", + Format_String: "MPEG-4", + Format_Extensions: + "braw mov mp4 m4v m4a m4b m4p m4r 3ga 3gpa 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma ismt f4a f4b f4v", + Format_Commercial: "MPEG-4", + Format_Profile: "Base Media / Version 2", + InternetMediaType: "video/mp4", + CodecID: "mp42", + CodecID_String: "mp42 (isom/mp41/mp42)", + CodecID_Url: "http://www.apple.com/quicktime/download/standalone.html", + CodecID_Compatible: "isom/mp41/mp42", + FileSize: "967309", + FileSize_String: "945 KiB", + FileSize_String1: "945 KiB", + FileSize_String2: "945 KiB", + FileSize_String3: "945 KiB", + FileSize_String4: "944.6 KiB", + StreamSize: "967309", + StreamSize_String: "945 KiB (100%)", + StreamSize_String1: "945 KiB", + StreamSize_String2: "945 KiB", + StreamSize_String3: "945 KiB", + StreamSize_String4: "944.6 KiB", + StreamSize_String5: "945 KiB (100%)", + StreamSize_Proportion: "1.00000", + HeaderSize: "36", + DataSize: "967273", + FooterSize: "0", + IsStreamable: "No", + File_Modified_Date: "UTC 2022-02-07 22:00:28", + File_Modified_Date_Local: "2022-02-07 14:00:28", + }, + ], + }, +}; + + +// jest.mock('CommonUtils', () => { +// return { +// object_names: ['track1', +// 'track2', +// 'track3', +// 'track4'] +// }}); + + + describe('Test API', () => { + + beforeAll(() => { + AWS.mock('S3', 'listObjectsV2', function(params, callback) { + callback(null, params); + }); + + AWS.mock('DynamoDB.DocumentClient', 'query', Promise.resolve(JSON.parse(JSON.stringify(ddbQueryResponse)))); + + AWS.mock('DynamoDB.DocumentClient', 'scan', function(params, callback) { + const response = JSON.parse(JSON.stringify(ddbQueryResponse)); + response.LastEvaluatedKey = undefined; + callback(null, response); + }); + + AWS.mock('DynamoDB.DocumentClient', 'update', function(params, callback) { + callback(null, params); + }); + + AWS.mock('DynamoDB.DocumentClient', 'delete', function(params, callback) { + callback(null, params); + }); + }); + + + beforeEach(() => { + }); + + test('Test AnalysisOp test loadTracks', async () => { + const analysisOp = new AnalysisOp() + const data = media_info_sample_data; + + AWS.mock('CommonUtils.listObjects', 'update', function(params, callback) { + callback(null, params); + }); + + const response_data = await analysisOp.loadTracks(data, "category"); + console.log(response_data); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(stateObj.input).toStrictEqual(testEvent.input); + expect(response_data.thing).toStringEqual(data); + expect(instance).toBeDefined(); + }); + + test('Test AnalysisOp loadTrackBasenames', async () => { + const analysisOp = new AnalysisOp(); + + const bucket = 'test_bucket'; + const prefix = 'test_prefix'; + + + const response_data = await analysisOp.loadTrackBasenames(bucket, prefix); + + console.log(response_data); + + expect(response_data.bucket).toBe(bucket); + + }); + + + test('Test AssetOp startIngestWorkflow', async () => { + const analysisOp = new AssetOp(); + + const params = { + input: "my_test_input" + } + + const response_data = await analysisOp.startIngestWorkflow(params); + + expect(response_data.input.input).toBe(params.input); + + }); + + + test('Test IotOp ', async () => { + const iotOp = new IotOp(); + + const params = { + input: "my_test_input" + } + + const response_data = await iotOp.onPost(); + + expect(response_data.status).toBe(StateData.Statuses.Completed); + + }); + + + + + test('Test SearchOp parseSearchResults', async () => { + const searchOp = new searchOp(); + + const indices = 'ingest'; + const params = media_info_sample_data; + + const response_data = await searchOp.parseSearchResults(indices, params); + + expect(response_data.status).toBe(StateData.Statuses.Completed); + + }); + + + test('Test TranscribeOp onGetCustomLanguageModels', async () => { + const transcribeOp = new TranscribeOp(); + + const response_data = await transcribeOp.onGetCustomLanguageModels(); + + expect(response_data.status).toBe(StateData.Statuses.Completed); + + }); + + test('Test TranscribeOp onGetCustomVocabularies', async () => { + const transcribeOp = new TranscribeOp(); + + + const response_data = await transcribeOp.onGetCustomVocabularies(); + + expect(response_data.status).toBe(StateData.Statuses.Completed); + + }); + +}); + + diff --git a/source/api/jest.config.js b/source/api/jest.config.js new file mode 100644 index 0000000..8a15fd9 --- /dev/null +++ b/source/api/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/api/lib/apiRequest.js b/source/api/lib/apiRequest.js index 4f4bd46..4e1b522 100644 --- a/source/api/lib/apiRequest.js +++ b/source/api/lib/apiRequest.js @@ -14,10 +14,13 @@ const RekognitionOp = require('./operations/rekognitionOp'); const TranscribeOp = require('./operations/transcribeOp'); const ComprehendOp = require('./operations/comprehendOp'); const StatsOp = require('./operations/statsOp'); +const UsersOp = require('./operations/usersOp'); +const SettingsOp = require('./operations/settingsOp'); const OP_REKOGNITION = 'rekognition'; const OP_TRANSCRIBE = 'transcribe'; const OP_COMPREHEND = 'comprehend'; +const OP_SETTINGS = ApiOps.AIOptionsSettings.split('/')[0]; class ApiRequest { constructor(event, context) { @@ -117,6 +120,12 @@ class ApiRequest { if (op === ApiOps.Stats) { return new StatsOp(this); } + if (op === ApiOps.Users) { + return new UsersOp(this); + } + if (op === OP_SETTINGS) { + return new SettingsOp(this); + } throw new Error(`operation '${(this.pathParameters || {}).operation}' not supported`); } } diff --git a/source/api/lib/operations/analysisOp.js b/source/api/lib/operations/analysisOp.js index d16e08c..49fa762 100644 --- a/source/api/lib/operations/analysisOp.js +++ b/source/api/lib/operations/analysisOp.js @@ -52,17 +52,20 @@ class AnalysisOp extends BaseOp { let responses = await Promise.all(types.analysis.map(x => db.fetch(uuid, x))); /* #3: load vtt and metadata tracks */ - responses = await Promise.all((responses || []).map(x => ( - (x.type === MEDIATYPE_VIDEO) - ? this.loadVideoTracks(x) - : (x.type === MEDIATYPE_AUDIO) - ? this.loadAudioTracks(x) - : (x.type === MEDIATYPE_IMAGE) - ? this.loadImageTracks(x) - : (x.type === MEDIATYPE_DOCUMENT) - ? this.loadDocumentTracks(x) - : undefined - ))); + responses = await Promise.all((responses || []).map(x => { + switch (x.type) { + case MEDIATYPE_VIDEO: + return this.loadVideoTracks(x); + case MEDIATYPE_AUDIO: + return this.loadAudioTracks(x); + case MEDIATYPE_IMAGE: + return this.loadImageTracks(x); + case MEDIATYPE_DOCUMENT: + return this.loadDocumentTracks(x); + default: + return undefined; + } + })); return super.onGET(responses.filter(x => x)); } @@ -196,8 +199,7 @@ class AnalysisOp extends BaseOp { while (keys.length) { const key = keys.shift(); const datasets = [].concat(data[category][key]); - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { const tracks = await Promise.all([ TRACK_METADATA, TRACK_TIMESERIES, diff --git a/source/api/lib/operations/jsonProvider/index.js b/source/api/lib/operations/jsonProvider/index.js index 4a49a17..ce8cb79 100644 --- a/source/api/lib/operations/jsonProvider/index.js +++ b/source/api/lib/operations/jsonProvider/index.js @@ -20,11 +20,14 @@ class JsonProvider { } static getProvider(data) { - return CloudfirstProvider.isSupported(data) - ? JsonProvider.Provider.CloudFirst - : DefaultProvider.isSupported(data) - ? JsonProvider.Provider.AWS - : undefined; + if (CloudfirstProvider.isSupported(data)) { + return JsonProvider.Provider.CloudFirst; + } + if (DefaultProvider.isSupported(data)) { + return JsonProvider.Provider.AWS; + } + + return undefined; } static async createProvider(params) { diff --git a/source/api/lib/operations/searchOp.js b/source/api/lib/operations/searchOp.js index d696065..5bf265b 100644 --- a/source/api/lib/operations/searchOp.js +++ b/source/api/lib/operations/searchOp.js @@ -30,7 +30,7 @@ const INGEST_SEARCH_FIELDS = [ const DEFAULT_PAGESIZE = 30; const DEFAULT_INNER_HITS_PAGESIZE = 1000; /* Reference: https://www.fileformat.info/info/unicode/category/Lu/list.htm */ -const UNICODE_CHARACTER_SETS = /[0-9A-Za-z\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC ,.'’-]{1,}/; +const UNICODE_CHARACTER_SETS = /[0-9A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC ,.'’-]+/; class SearchOp extends BaseOp { async onPOST() { diff --git a/source/api/lib/operations/settingsOp.js b/source/api/lib/operations/settingsOp.js new file mode 100644 index 0000000..0b47609 --- /dev/null +++ b/source/api/lib/operations/settingsOp.js @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = (() => { + try { + const AWSXRay = require('aws-xray-sdk'); + return AWSXRay.captureAWS(require('aws-sdk')); + } catch (e) { + return require('aws-sdk'); + } +})(); +const { + ApiOps, + AIML, + Environment, + CommonUtils, +} = require('core-lib'); +const BaseOp = require('./baseOp'); + +const SUBOP_AIOPTIONS = ApiOps.AIOptionsSettings.split('/').pop(); +const DEFAULT_AI_OPTIONS = process.env.ENV_DEFAULT_AI_OPTIONS; +const AI_OPTIONS_S3KEY = process.env.ENV_AI_OPTIONS_S3KEY; +const MIME_JSON = 'application/json'; + +class SettingsOp extends BaseOp { + async onPOST() { + const subop = this.request.pathParameters.uuid; + if (subop === SUBOP_AIOPTIONS) { + return super.onPOST(await this.onPostAIOptions()); + } + throw new Error('SettingsOp.onPOST not impl'); + } + + async onDELETE() { + const subop = this.request.pathParameters.uuid; + if (subop === SUBOP_AIOPTIONS) { + return super.onDELETE(await this.onDeleteAIOptions()); + } + throw new Error('SettingsOp.onDELETE not impl'); + } + + async onGET() { + const subop = this.request.pathParameters.uuid; + if (subop === SUBOP_AIOPTIONS) { + return super.onGET(await this.onGetAIOptions()); + } + throw new Error('invalid operation'); + } + + async onGetAIOptions() { + const aiOptions = { + ...AIML, + minConfidence: Environment.Rekognition.MinConfidence, + }; + + /* global options from stored by webapp (admin) */ + const bucket = Environment.Proxy.Bucket; + const key = AI_OPTIONS_S3KEY; + + const globalOptions = await CommonUtils.download(bucket, key, false) + .then((res) => + JSON.parse(res.Body.toString())) + .catch(() => + undefined); + + if (globalOptions !== undefined) { + return { + ...aiOptions, + ...globalOptions, + }; + } + + /* environment options during stack creation */ + DEFAULT_AI_OPTIONS.split(',') + .forEach((x) => { + aiOptions[x] = true; + }); + return aiOptions; + } + + async onPostAIOptions() { + const aiOptions = this.request.body || {}; + if (Object.keys(aiOptions).length === 0) { + return undefined; + } + + const bucket = Environment.Proxy.Bucket; + const key = AI_OPTIONS_S3KEY; + + return CommonUtils.upload({ + Bucket: bucket, + Key: key, + Body: JSON.stringify(aiOptions), + ContentType: MIME_JSON, + }).catch(() => + undefined); + } + + async onDeleteAIOptions() { + const bucket = Environment.Proxy.Bucket; + const key = AI_OPTIONS_S3KEY; + + return CommonUtils.deleteObject(bucket, key) + .catch(() => + undefined); + } +} + +module.exports = SettingsOp; diff --git a/source/api/lib/operations/statsOp.js b/source/api/lib/operations/statsOp.js index c211fc8..a56e694 100644 --- a/source/api/lib/operations/statsOp.js +++ b/source/api/lib/operations/statsOp.js @@ -169,8 +169,8 @@ class StatsOp extends BaseOp { async aggregateSearch(indices, size) { const availableIndices = Indexer.getIndices(); - for (let i = 0; i < indices.length; i++) { - if (availableIndices.indexOf(indices[i]) < 0) { + for (let index of indices) { + if (availableIndices.indexOf(index) < 0) { throw new Error('invalid aggregate value'); } } diff --git a/source/api/lib/operations/usersOp.js b/source/api/lib/operations/usersOp.js new file mode 100644 index 0000000..f0a08de --- /dev/null +++ b/source/api/lib/operations/usersOp.js @@ -0,0 +1,238 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = (() => { + try { + const AWSXRay = require('aws-xray-sdk'); + return AWSXRay.captureAWS(require('aws-sdk')); + } catch (e) { + return require('aws-sdk'); + } +})(); +const { + Environment, + CommonUtils, +} = require('core-lib'); +const BaseOp = require('./baseOp'); + +const OP_USERS = 'users'; +const STATUS_ADDED = 'added'; +const STATUS_REMOVED = 'removed'; +const STATUS_ERROR = 'error'; + +class UsersOp extends BaseOp { + static createInstance() { + return new AWS.CognitoIdentityServiceProvider({ + apiVersion: '2016-04-18', + customUserAgent: Environment.Solution.Metrics.CustomUserAgent, + }); + } + + async onPOST() { + const op = this.request.pathParameters.operation; + if (op === OP_USERS) { + return super.onPOST(await this.onPostUsers()); + } + throw new Error('UsersOp.onPOST not impl'); + } + + async onDELETE() { + const op = this.request.pathParameters.operation; + if (op === OP_USERS) { + return super.onDELETE(await this.onDeleteUsers()); + } + throw new Error('UsersOp.onDELETE not impl'); + } + + async onGET() { + const op = this.request.pathParameters.operation; + if (op === OP_USERS) { + return super.onGET(await this.onGetUsers()); + } + throw new Error('invalid operation'); + } + + async onGetUsers() { + const idp = UsersOp.createInstance(); + + let response; + const users = []; + do { + response = await idp.listUsers({ + UserPoolId: Environment.Cognito.UserPoolId, + Limit: 10, + PaginationToken: (response || {}).PaginationToken, + }).promise() + .catch((e) => { + console.log(`[ERR]: onGetUsers: listUsers: ${e.code} ${e.message}`); + return undefined; + }); + if (response && response.Users.length) { + let responses = await Promise.all(response.Users.map((user) => + idp.adminListGroupsForUser({ + UserPoolId: Environment.Cognito.UserPoolId, + Username: user.Username, + Limit: 10, + }).promise() + .then((res) => { + const group = res.Groups.sort((a, b) => + a.Precedence - b.Precedence)[0]; + if (group === undefined) { + return undefined; + } + const email = (user.Attributes.find((x) => + x.Name === 'email') || {}).Value; + if (email === undefined) { + return undefined; + } + return { + email, + group: group.GroupName, + lastModified: new Date(user.UserLastModifiedDate).getTime(), + username: user.Username, + status: user.UserStatus, + enabled: user.Enabled, + }; + }) + .catch((e) => { + console.log(`[ERR]: onGetUsers: adminListGroupsForUser: ${user.Username}: ${e.code} ${e.message}`); + return undefined; + }))); + responses = responses.flat() + .filter((x) => x); + users.splice(users.length, 0, ...responses); + } + } while ((response || {}).PaginationToken); + return users; + } + + async onPostUsers() { + const users = this.request.body || []; + const idp = UsersOp.createInstance(); + + return Promise.all(users.map((user) => + this.createUserInGroup(idp, { + userPoolId: Environment.Cognito.UserPoolId, + ...user, + }))); + } + + async createUserInGroup(idp, user) { + try { + if (!CommonUtils.validateEmailAddress(user.email)) { + const err = new Error('invalid email address'); + err.code = 'InvalidParameterError'; + throw err; + } + let username = user.username; + if (username === undefined || username.length === 0) { + username = user.email.split('@').filter(x => + x).shift(); + } + if (!CommonUtils.validateUsername(username)) { + const err = new Error('invalid username'); + err.code = 'InvalidParameterError'; + throw err; + } + + let response = await idp.adminCreateUser({ + UserPoolId: user.userPoolId, + Username: username, + DesiredDeliveryMediums: [ + 'EMAIL', + ], + UserAttributes: [ + { + Name: 'email', + Value: user.email, + }, + { + Name: 'email_verified', + Value: 'true', + }, + ], + }).promise() + .then((res) => + res.User) + .catch((e) => { + if (e.code === 'UsernameExistsException') { + return undefined; + } + throw e; + }); + + /* user already exits, gets the user information */ + if (response === undefined) { + response = await idp.adminGetUser({ + UserPoolId: user.userPoolId, + Username: username, + }).promise() + .catch((e) => { + console.log(`[ERR]: adminGetUser: ${username}: ${e.code} - ${e.message}`); + throw e; + }); + } + + if (!response) { + const err = new Error(`fail to add user, ${user.email}`); + err.code = 'UnknownError'; + throw err; + } + + /* add user to group */ + await idp.adminAddUserToGroup({ + GroupName: user.group, + UserPoolId: user.userPoolId, + Username: response.Username, + }).promise(); + + return { + email: user.email, + group: user.group, + status: response.UserStatus, + username: response.Username, + enabled: response.Enabled, + lastModified: new Date(response.UserLastModifiedDate).getTime(), + }; + } catch (e) { + return { + status: STATUS_ERROR, + error: `${e.code} - ${e.message}`, + email: user.email, + group: user.group, + }; + } + } + + async onDeleteUsers() { + const { + user, + } = this.request.queryString || {}; + + try { + const idp = UsersOp.createInstance(); + await idp.adminDeleteUser({ + UserPoolId: Environment.Cognito.UserPoolId, + Username: user, + }).promise() + .catch((e) => { + if (e.code === 'UserNotFoundException') { + return undefined; + } + throw e; + }); + return { + user, + status: STATUS_REMOVED, + }; + } catch (e) { + return { + user, + status: STATUS_ERROR, + error: `${e.code} - ${e.message}`, + }; + } + } +} + +module.exports = UsersOp; diff --git a/source/api/package.json b/source/api/package.json index a5feb30..c34f3a3 100644 --- a/source/api/package.json +++ b/source/api/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index*.js package.json lib dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,18 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../layers/core-lib" + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "aws-sdk": "2.831.0", + "aws-sdk-mock": "5.8.0", + "chai": "4.3.7", + "core-lib": "file:../layers/core-lib", + "jest": "^29.4.3" + }, + "dependencies": { + "adm-zip": "^0.5.9", + "aws-elasticsearch-connector": "^9.2.0", + "node-webvtt": "^1.9.4", + "sqlstring": "^2.3.3", + "tar-stream": "^2.2.0" } } diff --git a/source/api/setEnvVars.js b/source/api/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/api/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/backlog/custom-labels/README.md b/source/backlog/custom-labels/README.md index c9695b8..5594c34 100644 --- a/source/backlog/custom-labels/README.md +++ b/source/backlog/custom-labels/README.md @@ -145,7 +145,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:PutItem", diff --git a/source/backlog/custom-labels/index.spec.js b/source/backlog/custom-labels/index.spec.js new file mode 100644 index 0000000..5509320 --- /dev/null +++ b/source/backlog/custom-labels/index.spec.js @@ -0,0 +1,80 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment: { + StateMachines: { + States, + }, + }, + } = require('service-backlog-lib'); +const StateCheckProjectVersionStatus = require('./states/check-project-version-status'); +const StateStartProjectVersion = require('./states/start-project-version'); +const StateDetectCustomLabels = require('./states/detect-custom-labels'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + +const mock_event_StateCheckProjectVersionStatus = jest.fn(); + +const mock_event_StateStartProjectVersion = jest.fn(); + +const mock_event_StateDetectCustomLabels = jest.fn(); + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + + test('Test the StateCheckProjectVersionStatus', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, mock_event_StateCheckProjectVersionStatus, context); + + let instance = new StateCheckProjectVersionStatus(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateStartProjectVersion', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, mock_event_StateStartProjectVersion, context); + + let instance = new StateStartProjectVersion(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateDetectCustomLabels', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, mock_event_StateDetectCustomLabels, context); + + let instance = new StateDetectCustomLabels(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/backlog/custom-labels/jest.config.js b/source/backlog/custom-labels/jest.config.js new file mode 100644 index 0000000..8a15fd9 --- /dev/null +++ b/source/backlog/custom-labels/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/backlog/custom-labels/package.json b/source/backlog/custom-labels/package.json index 0ab3840..e12440d 100644 --- a/source/backlog/custom-labels/package.json +++ b/source/backlog/custom-labels/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "service-backlog-lib": "file:../../layers/service-backlog-lib" + "service-backlog-lib": "file:../../layers/service-backlog-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/backlog/custom-labels/setEnvVars.js b/source/backlog/custom-labels/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/backlog/custom-labels/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/backlog/custom-labels/states/shared/baseState.js b/source/backlog/custom-labels/states/shared/baseState.js index 425e137..8b75521 100644 --- a/source/backlog/custom-labels/states/shared/baseState.js +++ b/source/backlog/custom-labels/states/shared/baseState.js @@ -140,11 +140,11 @@ class BaseState { } testProjectArn(val) { - return /^arn:[a-z\d-]+:rekognition:[a-z\d-]+:\d{12}:project\/[a-zA-Z0-9_.-]{1,255}\/[0-9]+$/.test(val); + return /^arn:[a-z\d-]+:rekognition:[a-z\d-]+:\d{12}:project\/[a-zA-Z0-9_.-]{1,255}\/\d+$/.test(val); } testProjectVersionArn(val) { - return /^arn:[a-z\d-]+:rekognition:[a-z\d-]+:\d{12}:project\/[a-zA-Z0-9_.-]{1,255}\/version\/[a-zA-Z0-9_.-]{1,255}\/[0-9]+$/.test(val); + return /^arn:[a-z\d-]+:rekognition:[a-z\d-]+:\d{12}:project\/[a-zA-Z0-9_.-]{1,255}\/version\/[a-zA-Z0-9_.-]{1,255}\/\d+$/.test(val); } async process() { diff --git a/source/backlog/status-updater/index.spec.js b/source/backlog/status-updater/index.spec.js new file mode 100644 index 0000000..86d4e86 --- /dev/null +++ b/source/backlog/status-updater/index.spec.js @@ -0,0 +1,85 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment: { + StateMachines: { + States, + }, + }, + } = require('service-backlog-lib'); +const StateCheckProjectVersionStatus = require('./states/check-project-version-status'); +const StateStartProjectVersion = require('./states/start-project-version'); +const StateDetectCustomLabels = require('./states/detect-custom-labels'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateDetectCustomLabels', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateDetectCustomLabels, context); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + + test('Test the StateCheckProjectVersionStatus', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateCheckProjectVersionStatus, context); + + let instance = new StateCreateRecord(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateStartProjectVersion', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateStartProjectVersion, context); + + let instance = new StateFixityCompleted(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateDetectCustomLabels', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateDetectCustomLabels, context); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/backlog/status-updater/jest.config.js b/source/backlog/status-updater/jest.config.js new file mode 100644 index 0000000..8a15fd9 --- /dev/null +++ b/source/backlog/status-updater/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/backlog/status-updater/lib/cloudwatch/index.js b/source/backlog/status-updater/lib/cloudwatch/index.js index d90a350..efa9639 100644 --- a/source/backlog/status-updater/lib/cloudwatch/index.js +++ b/source/backlog/status-updater/lib/cloudwatch/index.js @@ -28,13 +28,21 @@ class CloudWatchStatus { } async process() { - const instance = (this.source === MediaConvertStatus.SourceType) - ? new MediaConvertStatus(this) - : (this.source === TranscribeStatus.SourceType) - ? new TranscribeStatus(this) - : (this.source === CustomLabelsStateMachineStatus.SourceType) - ? new CustomLabelsStateMachineStatus(this) - : undefined; + let instance; + switch (this.source) { + case MediaConvertStatus.SourceType: + instance = new MediaConvertStatus(this); + break; + case TranscribeStatus.SourceType: + instance = new TranscribeStatus(this); + break; + case CustomLabelsStateMachineStatus.SourceType: + instance = new CustomLabelsStateMachineStatus(this); + break; + default: + instance = undefined; + } + if (!instance) { throw new Error(`${this.source} not supported`); } diff --git a/source/backlog/status-updater/lib/cloudwatch/mediaconvertStatus.js b/source/backlog/status-updater/lib/cloudwatch/mediaconvertStatus.js index d3474b8..3e1cf69 100644 --- a/source/backlog/status-updater/lib/cloudwatch/mediaconvertStatus.js +++ b/source/backlog/status-updater/lib/cloudwatch/mediaconvertStatus.js @@ -38,9 +38,21 @@ class MediaConvertStatus { return this.detail.jobId; } + get errorMessage() { + return this.detail.errorMessage; + } + async process() { + /* optional output */ + let optional; + if (this.errorMessage) { + optional = { + ...optional, + errorMessage: this.errorMessage, + }; + } const backlog = new BacklogJob(); - return backlog.deleteJob(this.jobId, this.status); + return backlog.deleteJob(this.jobId, this.status, optional); } } diff --git a/source/backlog/status-updater/lib/cloudwatch/transcribeStatus.js b/source/backlog/status-updater/lib/cloudwatch/transcribeStatus.js index 88bc97f..fbf26cb 100644 --- a/source/backlog/status-updater/lib/cloudwatch/transcribeStatus.js +++ b/source/backlog/status-updater/lib/cloudwatch/transcribeStatus.js @@ -5,6 +5,9 @@ const { BacklogJob, } = require('service-backlog-lib'); +const STATUS_COMPLETED = 'COMPLETED'; +const STATUS_FAILED = 'FAILED'; + class TranscribeStatus { constructor(parent) { this.$parent = parent; @@ -31,16 +34,28 @@ class TranscribeStatus { } get status() { - return this.event.detail.TranscriptionJobStatus; + return this.detail.TranscriptionJobStatus; + } + + get failureReason() { + return this.detail.FailureReason; } get jobId() { - return this.event.detail.TranscriptionJobName; + return this.detail.TranscriptionJobName; } async process() { + /* optional output */ + let optional; + if (this.failureReason) { + optional = { + ...optional, + errorMessage: this.failureReason, + }; + } const backlog = new BacklogJob(); - return backlog.deleteJob(this.jobId, this.status); + return backlog.deleteJob(this.jobId, this.status, optional); } } diff --git a/source/backlog/status-updater/package.json b/source/backlog/status-updater/package.json index d0bf8c1..22cf21c 100644 --- a/source/backlog/status-updater/package.json +++ b/source/backlog/status-updater/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json lib dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-specialist-sa-emea", "license": "MIT-0", "devDependencies": { - "service-backlog-lib": "file:../../layers/service-backlog-lib" + "service-backlog-lib": "file:../../layers/service-backlog-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/backlog/status-updater/setEnvVars.js b/source/backlog/status-updater/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/backlog/status-updater/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/backlog/stream-connector/index.spec.js b/source/backlog/stream-connector/index.spec.js new file mode 100644 index 0000000..ce31d7d --- /dev/null +++ b/source/backlog/stream-connector/index.spec.js @@ -0,0 +1,84 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment: { + StateMachines: { + States, + }, + }, + } = require('service-backlog-lib'); +const StateCheckProjectVersionStatus = require('./states/check-project-version-status'); +const StateStartProjectVersion = require('./states/start-project-version'); +const StateDetectCustomLabels = require('./states/detect-custom-labels'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateDetectCustomLabels', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateDetectCustomLabels, context); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + + test('Test the StateCheckProjectVersionStatus', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateCheckProjectVersionStatus, context); + + let instance = new StateCreateRecord(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateStartProjectVersion', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateStartProjectVersion, context); + + let instance = new StateFixityCompleted(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateDetectCustomLabels', async () => { + const stateData = new StateData(Environment.StateMachines.StateDetectCustomLabels, event_StateDetectCustomLabels, context); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/backlog/stream-connector/jest.config.js b/source/backlog/stream-connector/jest.config.js new file mode 100644 index 0000000..8a15fd9 --- /dev/null +++ b/source/backlog/stream-connector/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/backlog/stream-connector/package.json b/source/backlog/stream-connector/package.json index 5cfd432..d6b25da 100644 --- a/source/backlog/stream-connector/package.json +++ b/source/backlog/stream-connector/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-specialist-sa-emea", "license": "MIT-0", "devDependencies": { - "service-backlog-lib": "file:../../layers/service-backlog-lib" + "service-backlog-lib": "file:../../layers/service-backlog-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/backlog/stream-connector/setEnvVars.js b/source/backlog/stream-connector/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/backlog/stream-connector/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/build/package.json b/source/build/package.json index 0f31fa8..5ce5dc9 100644 --- a/source/build/package.json +++ b/source/build/package.json @@ -13,5 +13,7 @@ "scripts": {}, "author": "aws-specialist-sa-emea", "license": "MIT-0", - "devDependencies": {} + "devDependencies": { + "jest": "^29.4.3" + } } diff --git a/source/build/post-build.js b/source/build/post-build.js index 21060d9..9828376 100644 --- a/source/build/post-build.js +++ b/source/build/post-build.js @@ -51,27 +51,32 @@ function parseCmdline() { while (args.length) { options[args.shift().slice(2)] = args.shift(); } - if (command === COMMAND.ROLLUP) { - if (!options.input) { - return usage('\'--input\' must be specified'); - } - if (!options.output) { - return usage('\'--output\' must be specified'); - } - } else if (command === COMMAND.BUILDHTML) { - if (!options.html) { - return usage('\'--html\' must be specified'); - } - } else if (command === COMMAND.MINIFY) { - if (!options.dir) { - return usage('\'--dir\' must be specified'); - } - } else if (command === COMMAND.INJECTSRI) { - if (!options.html) { - return usage('\'--html\' must be specified'); - } - } else { - return usage(`command '${command}' not supported`); + switch (command) { + case COMMAND.ROLLUP: + if (!options.input) { + return usage('\'--input\' must be specified'); + } + if (!options.output) { + return usage('\'--output\' must be specified'); + } + break; + case COMMAND.BUILDHTML: + if (!options.html) { + return usage('\'--html\' must be specified'); + } + break; + case COMMAND.MINIFY: + if (!options.dir) { + return usage('\'--dir\' must be specified'); + } + break; + case COMMAND.INJECTSRI: + if (!options.html) { + return usage('\'--html\' must be specified'); + } + break; + default: + return usage(`command '${command}' not supported`); } options.command = command; return options; @@ -231,7 +236,6 @@ function insertSRI(line, rootDir, regex) { function createBackupCopy(path) { const buffer = FS.readFileSync(path); - // FS.writeFileSync(`${path}.bak`, buffer); return buffer; } diff --git a/source/custom-resources/index.js b/source/custom-resources/index.js index 2a13811..bac0892 100644 --- a/source/custom-resources/index.js +++ b/source/custom-resources/index.js @@ -80,6 +80,10 @@ exports.handler = async (event, context) => { case 'InvalidateCache': handler = require('./lib/cloudfront').InvalidateCache; break; + /* sagemaker */ + case 'DescribeSageMakerEndpoint': + handler = require('./lib/sagemaker').DescribeSageMakerEndpoint; + break; default: break; } diff --git a/source/custom-resources/lib/cloudfront/index.js b/source/custom-resources/lib/cloudfront/index.js index e809ab6..5462d8e 100644 --- a/source/custom-resources/lib/cloudfront/index.js +++ b/source/custom-resources/lib/cloudfront/index.js @@ -33,7 +33,7 @@ exports.InvalidateCache = async (event, context) => { if (missing.length) { throw new Error(`missing ${missing.join(', ')}`); } - const reference = (data.LastUpdated || new Date().toISOString()).replace(/[^0-9]/g, ''); + const reference = (data.LastUpdated || new Date().toISOString()).replace(/\D/g, ''); const cf = new AWS.CloudFront({ apiVersion: '2020-05-31', customUserAgent: process.env.ENV_CUSTOM_USER_AGENT, diff --git a/source/custom-resources/lib/elastictranscoder/index.js b/source/custom-resources/lib/elastictranscoder/index.js index b00f102..5e070dd 100644 --- a/source/custom-resources/lib/elastictranscoder/index.js +++ b/source/custom-resources/lib/elastictranscoder/index.js @@ -89,9 +89,6 @@ class ETS extends mxBaseResponse(class {}) { return this.responseData; } - /** - * TODO: need to check OldResponseProperty to decide what to do... - */ async update() { await this.purge(); await this.create(); @@ -107,9 +104,12 @@ class ETS extends mxBaseResponse(class {}) { */ exports.CreatePipeline = async (event, context) => { const instance = new ETS(event, context); - return (instance.isRequestType('Delete')) - ? instance.purge() - : (instance.isRequestType('Update')) - ? instance.update() - : instance.create(); + if (instance.isRequestType('Delete')) { + return instance.purge(); + } + if (instance.isRequestType('Update')) { + return instance.update(); + } + + return instance.create(); }; diff --git a/source/custom-resources/lib/sagemaker/index.js b/source/custom-resources/lib/sagemaker/index.js new file mode 100644 index 0000000..0d46962 --- /dev/null +++ b/source/custom-resources/lib/sagemaker/index.js @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = (() => { + try { + const AWSXRay = require('aws-xray-sdk'); + return AWSXRay.captureAWS(require('aws-sdk')); + } catch (e) { + return require('aws-sdk'); + } +})(); +const mxBaseResponse = require('../shared/mxBaseResponse'); + +/** + * @function DescribeSageMakerEndpoint + * @param {object} event + * @param {object} context + */ +exports.DescribeSageMakerEndpoint = async (event, context) => { + try { + class X0 extends mxBaseResponse(class {}) {} + const x0 = new X0(event, context); + + /* not handle Delete event */ + if (x0.isRequestType('Delete')) { + x0.storeResponseData('Status', 'SKIPPED'); + return x0.responseData; + } + + const data = event.ResourceProperties.Data; + if (!data.EndpointName) { + throw new Error('missing EndpointName'); + } + + const sagemaker = new AWS.SageMaker({ + apiVersion: '2017-07-24', + customUserAgent: process.env.ENV_CUSTOM_USER_AGENT, + }); + + const response = await sagemaker.describeEndpoint({ + EndpointName: data.EndpointName, + }).promise(); + + if (!response.EndpointArn) { + throw new Error('invalid model EndpointArn'); + } + if (!response.EndpointStatus) { + throw new Error('invalid model EndpointStatus'); + } + + x0.storeResponseData('EndpointName', response.EndpointName); + x0.storeResponseData('EndpointArn', response.EndpointArn); + x0.storeResponseData('EndpointStatus', response.EndpointStatus); + x0.storeResponseData('Status', 'SUCCESS'); + + return x0.responseData; + } catch (e) { + e.message = `DescribeSageMakerEndpoint: ${e.message}`; + throw e; + } +}; diff --git a/source/custom-resources/lib/solution/index.js b/source/custom-resources/lib/solution/index.js index 0128bf9..08c59c8 100644 --- a/source/custom-resources/lib/solution/index.js +++ b/source/custom-resources/lib/solution/index.js @@ -42,7 +42,7 @@ exports.SendConfig = async (event, context) => { const data = event.ResourceProperties.Data; const key = (x0.isRequestType('Delete')) ? 'Deleted' : 'Launch'; const cluster = data.ElasticsearchCluster || data.OpenSearchCluster; - const matched = cluster.match(/([a-zA-z0-9 ]+)\s\(([a-zA-Z0-9.=,]+)\)/); + const matched = cluster.match(/([A-z0-9 ]+)\s\(([a-zA-Z0-9.=,]+)\)/); if (matched) { const config = matched[2].split(',').map(x => { const a0 = x.split('='); diff --git a/source/custom-resources/package.json b/source/custom-resources/package.json index 56611ef..eda5ffa 100644 --- a/source/custom-resources/package.json +++ b/source/custom-resources/package.json @@ -16,7 +16,7 @@ "author": "aws-mediaent-solutions", "dependencies": { "adm-zip": "^0.4.11", - "aws-sdk": "^2.1033.0", + "aws-sdk": "^2.1173.0", "aws-xray-sdk": "^3.3.4" }, "devDependencies": { diff --git a/source/jest.config.js b/source/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/layers/aws-sdk-layer/nodejs/package.json b/source/layers/aws-sdk-layer/nodejs/package.json index b2957d3..2142ed3 100644 --- a/source/layers/aws-sdk-layer/nodejs/package.json +++ b/source/layers/aws-sdk-layer/nodejs/package.json @@ -7,7 +7,7 @@ "author": "aws-mediaent-solutions", "license": "ISC", "dependencies": { - "aws-sdk": "^2.1033.0", + "aws-sdk": "^2.1173.0", "aws-xray-sdk": "^3.3.4" }, "scripts": { diff --git a/source/layers/core-lib/index.js b/source/layers/core-lib/index.js index 803d222..ad30f18 100644 --- a/source/layers/core-lib/index.js +++ b/source/layers/core-lib/index.js @@ -4,6 +4,7 @@ const SQL = require('sqlstring'); const AdmZip = require('adm-zip'); const NodeWebVtt = require('node-webvtt'); +const SigV4 = require('aws4'); const Environment = require('./lib/environment'); const AnalysisTypes = require('./lib/analysisTypes'); const AIML = require('./lib/aiml'); @@ -63,4 +64,5 @@ module.exports = { AdmZip, Indexer, NodeWebVtt, + SigV4, }; diff --git a/source/layers/core-lib/index.spec.js b/source/layers/core-lib/index.spec.js new file mode 100644 index 0000000..0b3b807 --- /dev/null +++ b/source/layers/core-lib/index.spec.js @@ -0,0 +1,1343 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const { + Environment, + AnalysisTypes, + AIML, + StateData, + DB, + CommonUtils, + Retry, + IotStatus, + ApiOps, + SNS, + Errors, + TimelineQ, + WebVttCue, + WebVttTrack, + Metrics, + ServiceAvailability, + ServiceToken, + SQL, + EDLComposer, + TimecodeUtils, + TarStreamHelper, + FrameCaptureMode, + FrameCaptureModeHelper, + AdmZip, + Indexer, + NodeWebVtt, + SigV4, +} = require('core-lib'); +const StringBuilder = require('node-stringbuilder'); + + +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +const stateMachine = 'test-state-machine'; +const accountId = '12345'; +const context = { + invokedFunctionArn: `arn:partition:service:region:${accountId}:resource-id`, + getRemainingTimeInMillis: 1000 +} + +const testEvent = { + "uuid": "b72fc9c0-58eb-83ef-42f2-dfceb342798f", + "stateMachine": stateMachine, + "operation": "collect-transcribe-results", + "status": "NO_DATA", + "progress": 100, + "input": { + "testInputKey": "testInputVal" + }, + "data": { + "testDataKey": "testDataVal" + } +} + +const ddbQueryResponse = { + Count: 1, + Items: [ + { + pkey: 'primarykey', + skey: 'sortkey', + att1: 'value1' + } + ], + LastEvaluatedKey: 'lastKey' +} + +const ddbScanIndexData = { + Name: 'testTable', + Key: 'pkey', + Value: 'primarykey' +} + +const edlData = { + title: 'Test Title', + events: [ + { + startTime: '1', + endTime: '10', + reelName: 'testReel1', + clipName: 'testClip1' + } + ] +} + +const iotStatusMessage = { + messageKey: "messageVal" +} + + +const item = { + name: 'itemName', + confidence: 2, + begin: 1, + end: 5, + boundingBox: { + Width: 10, + Height: 9, + Left: 0, + Top: 11 + }, + parentName: 'itemParent', + Timestamp: '111' +} +const celebrity = { + Name: 'celebrityName', + Confidence: 2, + BoundingBox: { + Width: 12, + Height: 11, + Left: 2, + Top: 13 + } +} +const label = { + Name: 'labelName', + Parents: [ + { + Name: 'parent1' + },{ + Name: 'parent2' + } + ], + Confidence: 3, + Instances: [ + { + BoundingBox: { + Width: 4, + Height: 13, + Left: 4, + Top: 15 + } + } + ] +} +const faceMatch = { + Similarity: 0 +} +const moderation = { + ParentName: '', + Name: '', + Confidence: 8 +} +const person = { + Index: 7, + Confidence: 4, + BoundingBox: { + Width: 6, + Height: 15, + Left: 6, + Top: 17 + } +} +const face = { + ExternalImageId: '', + Gender: { + Value: '' + }, + AgeRange: { + Low: 0, + High: 100 + }, + Confidence: 4, + Emotions: [], + BoundingBox: { + Width: 8, + Height: 17, + Left: 8, + Top: 19 + } +} +person.Face = face; +celebrity.Face = face; +faceMatch.Face = face; +const customLabel = { + Name: '', + Confidence: 3, + Geometry: { + BoundingBox: { + Width: 10, + Height: 19, + Left: 10, + Top: 21 + } + } +} +const textDetection = { + Type: '', + DetectedText: '', + Confidence: 3, + Geometry: { + BoundingBox: { + Width: 12, + Height: 21, + Left: 12, + Top: 23 + } + } +} + +const celebItem = {...item}; +celebItem.Celebrity = celebrity; + +const moderationItem = {...item}; +moderationItem.ModerationLabel = moderation; + +const labelItem = {...item}; +labelItem.Label = label; + +const faceMatchItem = { ...item }; +faceMatchItem.FaceMatches = [faceMatch]; +faceMatchItem.Person = person; + +const customLabelItem = { ...item }; +customLabelItem.CustomLabel = customLabel; + + +const personItem = { ...item }; +personItem.Person = person; + +const faceItem = { ...item }; +faceItem.Face = face; + +const textItem = { ...item }; +textItem.TextDetection = textDetection; + + +const cue1 = { + begin: '0', + end: '3445', + text: 'cue 1 text', + position: 'center' +} +const cue2 = { + begin: '5555', + end: '77777', + text: 'cue 2 text', + position: 'center' +} +const cue3 = { + begin: '88888', + end: '999999', + text: 'cue 3 text', + position: 'center' +} + + + +describe('Test StateData, StateMessage, States, Statuses', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test StateData and StateMessage constructors', () => { + let stateObj = new StateData(stateMachine, testEvent, context); + //StateMessage + expect(stateObj.uuid).toBe(testEvent.uuid); + expect(stateObj.stateMachine).toBe(stateMachine); + expect(stateObj.operation).toBe(testEvent.operation); + expect(stateObj.status).toBe(testEvent.status); + expect(stateObj.progress).toBe(testEvent.progress); + + //StateData + expect(stateObj.input).toStrictEqual(testEvent.input); + expect(stateObj.data).toStrictEqual(testEvent.data); + expect(stateObj.event).toStrictEqual(testEvent); + expect(stateObj.accountId).toBe(accountId); + expect(stateObj.getRemainingTime()).toBe(StateData.Constants.LambdaTimeoutThreshold * 2); + + }); + + test('Test StateData get/set', () => { + const testInput = 'test input val'; + const testOutput = 'test output val'; + let stateObj = new StateData(stateMachine, testEvent, context); + + stateObj.input = testInput; + expect(stateObj.input).toBe(testInput); + + stateObj.output = testOutput; + expect(stateObj.output).toBe(testOutput); + + stateObj.setData('transcribe', { + startTime: 5 + }); + expect(stateObj.data.transcribe.startTime).toBe(5); + + stateObj.resetData('transcribe'); + expect(stateObj.data).toStrictEqual(testEvent.data); + + stateObj.resetAllData(); + expect(stateObj.data).toBe(undefined); + + const response = stateObj.responseData; + expect(response.stateMachine).toBe(stateMachine); + }); + + test('Test StateMessage get/set', () => { + const testUuid = '12345'; + const testStateMachine = 'test new state machine'; + const testOperation = 'test operation'; + const testStatus = 'test status'; + const testErrorMessage = 'test error message'; + const testFailed = 'test failed'; + let stateObj = new StateData(stateMachine, testEvent, context); + + stateObj.uuid = testUuid; + expect(stateObj.uuid).toBe(testUuid); + + stateObj.stateMachine = testStateMachine; + expect(stateObj.stateMachine).toBe(testStateMachine); + + stateObj.operation = testOperation; + expect(stateObj.operation).toBe(testOperation); + + stateObj.status = testStatus; + expect(stateObj.status).toBe(testStatus); + + stateObj.progress = 7; + expect(stateObj.progress).toBe(7); + + stateObj.errorMessage = testErrorMessage; + expect(stateObj.errorMessage).toBe(testErrorMessage); + + stateObj.setStarted(); + expect(stateObj.status).toBe(StateData.Statuses.Started); + expect(stateObj.progress).toBe(0); + + stateObj.setCompleted(); + expect(stateObj.status).toBe(StateData.Statuses.Completed); + expect(stateObj.progress).toBe(100); + + stateObj.setProgress(12); + expect(stateObj.status).toBe(StateData.Statuses.InProgress); + expect(stateObj.progress).toBe(12); + + stateObj.setFailed(testFailed); + expect(stateObj.status).toBe(StateData.Statuses.Error); + expect(stateObj.errorMessage).toBe(testFailed); + + stateObj.setNoData(); + expect(stateObj.status).toBe(StateData.Statuses.NoData); + expect(stateObj.progress).toBe(100); + }); +}); + + + + +describe('Test DB', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.DocumentClient', 'query', Promise.resolve(JSON.parse(JSON.stringify(ddbQueryResponse)))); + + AWS.mock('DynamoDB.DocumentClient', 'scan', function(params, callback) { + const response = JSON.parse(JSON.stringify(ddbQueryResponse)); + response.LastEvaluatedKey = undefined; + callback(null, response); + }); + + AWS.mock('DynamoDB.DocumentClient', 'update', function(params, callback) { + callback(null, params); + }); + + AWS.mock('DynamoDB.DocumentClient', 'delete', function(params, callback) { + callback(null, params); + }); + }); + + afterEach(() => { + AWS.restore('DynamoDB.DocumentClient'); + }); + + test('Test scanIndex', async () => { + const db = new DB({ + Table: 'testTable', + PartitionKey: 'pkey', + SortKey: 'skey' + }); + + const response = await db.scanIndex(ddbScanIndexData); + expect(response.Items).toStrictEqual(ddbQueryResponse.Items); + + await db.scanIndex({ Name: 'test' }).catch(error => { + expect(error.message).toBe('scanIndex missing Key, Value'); + }); + }); + + test('Test Scan', async () => { + const db = new DB({ + Table: 'testTable', + PartitionKey: 'pkey', + SortKey: 'skey' + }); + + const response = await db.scan(); + expect(response).toStrictEqual(ddbQueryResponse.Items); + }); + + test('Test Update', async () => { + const db = new DB({ + Table: 'testTable', + PartitionKey: 'pkey', + SortKey: 'skey' + }); + const testPrimary = 'primarykey'; + const testSort = 'sortkey'; + const attributeKey = 'attributeKey'; + const attributeValue = 'attributeValue'; + + const updated = await db.update(testPrimary, testSort, { + attributeKey: attributeValue + }); + expect(updated.Key.pkey).toBe(testPrimary); + expect(updated.Key.skey).toBe(testSort); + expect(updated.AttributeUpdates[attributeKey].Value).toBe(attributeValue); + }); + + test('Test dropColumns', async () => { + const db = new DB({ + Table: 'testTable', + PartitionKey: 'pkey', + SortKey: 'skey' + }); + const testPrimary = 'primarykey'; + const testSort = 'sortkey'; + const column = 'att1'; + + let response = await db.dropColumns(testPrimary, testSort, column); + expect(response).toStrictEqual([column]); + + response = await db.dropColumns(testPrimary, testSort, db.partitionKey); + expect(response).toStrictEqual([]); + }); + + test('Test purge', async () => { + const db = new DB({ + Table: 'testTable', + PartitionKey: 'pkey', + SortKey: 'skey' + }); + const testPrimary = 'testPrimaryKey'; + const testSort = 'testSortKey'; + + const response = await db.purge(testPrimary, testSort); + expect(response.TableName).toBe(db.table); + expect(response.Key[db.partitionKey]).toBe(testPrimary); + expect(response.Key[db.sortKey]).toBe(testSort); + }); +}); + + +describe('Test SNS', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('SNS', 'publish', Promise.resolve()); + }); + + afterEach(() => { + AWS.restore('SNS'); + }); + + test('Test send success', async () => { + const response = await SNS.send('subject', 'message'); + expect(response).toBe(true); + }); + + test('Test send fail', async () => { + let response = await SNS.send('', '', ''); + expect(response).toBe(false); + + AWS.remock('SNS', 'publish', Promise.reject()); + response = await SNS.send('subject', 'message'); + expect(response).toBe(false); + }); + +}); + + +describe('Test EDLComposer', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test compose', () => { + const edl = new EDLComposer(edlData); + + const response = edl.compose(); + expect(response.includes(`TITLE: ${edlData.title.toUpperCase()}`)).toBe(true); + }); +}); + + +describe('Test FrameCaptureModeHelper', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test suggestFrameCaptureRate', () => { + let response = FrameCaptureModeHelper.suggestFrameCaptureRate('a', ''); + expect(response).toStrictEqual([]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, 0); + expect(response).toStrictEqual([]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1FPS); + expect(response).toStrictEqual([1000,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_ALL); + expect(response).toStrictEqual([1000,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_HALF_FPS); + expect(response).toStrictEqual([500,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_2S); + expect(response).toStrictEqual([500,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_5S); + expect(response).toStrictEqual([200,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_10S); + expect(response).toStrictEqual([100,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_30S); + expect(response).toStrictEqual([33,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_1MIN); + expect(response).toStrictEqual([16,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_2MIN); + expect(response).toStrictEqual([8,1000]); + + response = FrameCaptureModeHelper.suggestFrameCaptureRate(1, FrameCaptureMode.MODE_1F_EVERY_5MIN); + expect(response).toStrictEqual([3,1000]); + }); +}); + + +describe('Test IotStatus', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('IotData', 'publish', function(params, callback) { + callback(null, params); + }); + }); + + afterEach(() => { + AWS.restore('IotData'); + }); + + test('Test publish', async () => { + const response = await IotStatus.publish(iotStatusMessage); + expect(response.topic).toBe(Environment.Iot.Topic); + expect(response.payload).toBe(JSON.stringify(iotStatusMessage)); + }); +}); + + +describe('Test Retry', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test run', async () => { + jest.setTimeout(30000); + + const errorObject = {code: 'ProvisionedThroughputExceededException'}; + let fn = (params) => { + throw errorObject; + }; + expect(await Retry.run(fn, {})).toBe(undefined); + }); +}); + + +describe('Test ServiceAvailability', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test probe', async () => { + expect(await ServiceAvailability.probe('transcribe', 'us-east-1')).toBe(true); + + await ServiceAvailability.probe().catch(error => { + expect(error.message).toBe('service must be provided'); + }); + + expect(await ServiceAvailability.probe('invalid')).toBe(false); + }); +}); + + +describe('Test ServiceToken', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + + AWS.mock('DynamoDB.DocumentClient', 'query', Promise.resolve(JSON.parse(JSON.stringify(ddbQueryResponse)))); + + AWS.mock('DynamoDB.DocumentClient', 'update', function(params, callback) { + callback(null, params); + }); + + AWS.mock('DynamoDB.DocumentClient', 'delete', function(params, callback) { + callback(null, params); + }); + }); + + afterEach(() => { + AWS.restore('DynamoDB.DocumentClient'); + }); + + test('Test register', async () => { + const id = 'testId'; + const token = 'testToken'; + const service = 'testService'; + const api = 'testApi'; + const data = { testData: 'testDataVal'}; + const db = await ServiceToken.register(id, token, service, api, data); + + expect(db.TableName).toBe(Environment.DynamoDB.ServiceToken.Table); + expect(db.Key[Environment.DynamoDB.ServiceToken.PartitionKey]).toBe(id); + expect(db.Key[Environment.DynamoDB.ServiceToken.SortKey]).toBe(ServiceToken.Token.Name); + expect(db.AttributeUpdates['token']['Value']).toBe(token); + expect(db.AttributeUpdates['service']['Value']).toBe(service); + expect(db.AttributeUpdates['api']['Value']).toBe(api); + expect(db.AttributeUpdates['data']['Value']).toStrictEqual(data); + expect(db.AttributeUpdates['ttl']['Value']).toBeGreaterThan(0); + }); + + test('Test getData', async () => { + const response = await ServiceToken.getData('id'); + expect(response).toStrictEqual(ddbQueryResponse.Items[0]); + }); + + test('Test unregister', async () => { + const id = 'testId'; + + const response = await ServiceToken.unregister(id); + expect(response.TableName).toBe(Environment.DynamoDB.ServiceToken.Table); + expect(response.Key[Environment.DynamoDB.ServiceToken.PartitionKey]).toBe(id); + expect(response.Key[Environment.DynamoDB.ServiceToken.SortKey]).toBe(ServiceToken.Token.Name); + }); +}); + + +describe('Test TimelineQ', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test CelebItem', () => { + const celebResponse = TimelineQ.createTypedItem(celebItem, {}); + expect(celebResponse['$name']).toBe(celebItem.Celebrity.Name); + expect(celebResponse['$end']).toBe(celebItem.Timestamp); + expect(celebResponse['$begin']).toBe(celebItem.Timestamp); + expect(celebResponse['$boundingBox']).toStrictEqual(celebItem.Celebrity.BoundingBox); + expect(celebResponse['$confidence']).toBe(celebItem.Celebrity.Confidence); + expect(celebResponse.canUse()).toBe(true); + }); + + test('Test ModerationItem', () => { + const moderationResponse = TimelineQ.createTypedItem(moderationItem, {}); + expect(moderationResponse['$name']).toBe(moderationItem.ModerationLabel.ParentName); + expect(moderationResponse['$parentName']).toBe(moderationItem.ModerationLabel.Name); + expect(moderationResponse['$confidence']).toBe(moderationItem.ModerationLabel.Confidence); + expect(moderationResponse['$begin']).toBe(moderationItem.Timestamp); + expect(moderationResponse['$end']).toBe(moderationItem.Timestamp); + expect(moderationResponse.canUse()).toBe(false); + }); + + test('Test LabelItem', () => { + const labelResponse = TimelineQ.createTypedItem(labelItem, {}); + expect(labelResponse['$name']).toBe(labelItem.Label.Name); + expect(labelResponse['$confidence']).toBe(labelItem.Label.Confidence); + expect(labelResponse['$begin']).toBe(labelItem.Timestamp); + expect(labelResponse['$end']).toBe(labelItem.Timestamp); + expect(labelResponse['$boundingBox']).toStrictEqual(labelItem.Label.Instances[0].BoundingBox); + expect(labelResponse['$parentName']).toBe(labelItem.Label.Parents.map(x => x.Name).join(', ')); + expect(labelResponse.canUse()).toBe(true); + }); + + test('Test FaceMatchItem', () => { + const faceMatchResponse = TimelineQ.createTypedItem(faceMatchItem, {}); + expect(faceMatchResponse['$name']).toBe(faceMatchItem.FaceMatches[0].Face.ExternalImageId); + expect(faceMatchResponse['$confidence']).toBe(faceMatchItem.FaceMatches[0].Similarity); + expect(faceMatchResponse['$begin']).toBe(faceMatchItem.Timestamp); + expect(faceMatchResponse['$end']).toBe(faceMatchItem.Timestamp); + expect(faceMatchResponse['$boundingBox']).toStrictEqual(faceMatchItem.Person.BoundingBox); + expect(faceMatchResponse['$parentName']).toBe(`Index ${faceMatchItem.Person.Index}`); + expect(faceMatchResponse.canUse()).toBe(false); + }); + + test('Test CustomLabelItem', () => { + const customLabelResponse = TimelineQ.createTypedItem(customLabelItem, {}); + expect(customLabelResponse['$name']).toBe(customLabelItem.CustomLabel.Name); + expect(customLabelResponse['$confidence']).toBe(customLabelItem.CustomLabel.Confidence); + expect(customLabelResponse['$begin']).toBe(customLabelItem.Timestamp); + expect(customLabelResponse['$end']).toBe(customLabelItem.Timestamp); + expect(customLabelResponse['$boundingBox']).toStrictEqual(customLabelItem.CustomLabel.Geometry.BoundingBox); + expect(customLabelResponse.canUse()).toBe(true); + const cy = customLabelResponse['$cy']; + const cx = customLabelResponse['$cx']; + expect(customLabelResponse.cueAlignment).toBe(`align:center line:${Math.floor(cy * 100)}% position:${Math.floor(cx * 100)}% size:25%`); + customLabelResponse['$cx'] = undefined; + expect(customLabelResponse.cueAlignment).toBe('align:end line:0% position:100% size:25%'); + }); + + test('Test PersonItem', () => { + const personResponse = TimelineQ.createTypedItem(personItem, {}); + let expectParentName = [ + ((personItem.Person.Face || {}).Gender) ? personItem.Person.Face.Gender.Value : undefined, + ((personItem.Person.Face || {}).AgeRange) ? `(${personItem.Person.Face.AgeRange.Low} - ${personItem.Person.Face.AgeRange.High})` : undefined, + ].filter(x => x).join(' '); + expect(personResponse['$name']).toBe(personItem.Person.Index.toString()); + expect(personResponse['$confidence']).toBe(personItem.Person.Confidence); + expect(personResponse['$begin']).toBe(personItem.Timestamp); + expect(personResponse['$end']).toBe(personItem.Timestamp); + expect(personResponse['$boundingBox']).toStrictEqual(personItem.Person.BoundingBox); + expect(personResponse['$parentName']).toBe(expectParentName); + expect(personResponse.canUse()).toBe(true); + }); + + test('Test FaceItem', () => { + const faceResponse = TimelineQ.createTypedItem(faceItem, {}); + expectParentName = [ + (faceItem.Face.AgeRange) ? `(${faceItem.Face.AgeRange.Low} - ${faceItem.Face.AgeRange.High})` : undefined, + (faceItem.Face.Emotions.sort((a, b) => b.Confidence - a.Confidence)[0] || {}).Type, + ].filter(x => x).join(' '); + expect(faceResponse['$name']).toBe(faceItem.Face.Gender.Value); + expect(faceResponse['$confidence']).toBe(faceItem.Face.Confidence); + expect(faceResponse['$begin']).toBe(faceItem.Timestamp); + expect(faceResponse['$end']).toBe(faceItem.Timestamp); + expect(faceResponse['$boundingBox']).toStrictEqual(faceItem.Face.BoundingBox); + expect(faceResponse.canUse()).toBe(false); + }); + + test('Test TextItem', () => { + const textResponse = TimelineQ.createTypedItem(textItem, {}); + const expectName = (textItem.TextDetection.Type === 'LINE') ? textItem.TextDetection.DetectedText : undefined; + expect(textResponse['$name']).toBe(expectName); + expect(textResponse['$confidence']).toBe(textItem.TextDetection.Confidence); + expect(textResponse['$end']).toBe(textItem.Timestamp); + expect(textResponse['$begin']).toBe(textItem.Timestamp); + expect(textResponse['$boundingBox']).toStrictEqual(textItem.TextDetection.Geometry.BoundingBox); + expect(textResponse.canUse()).toBe(false); + }); + + test('Test createTypedItem fail', () => { + try { + TimelineQ.createTypedItem({}); + } + catch(error) { + expect(error.message).toBe('fail to create typed item'); + } + }); + + test('Test reduceAll', () => { + const queue = new TimelineQ(); + const item1 = TimelineQ.createTypedItem(personItem, {}); + const item2 = TimelineQ.createTypedItem(personItem, {}); + item2.end = '222'; + item1.parentName = 'parent1'; + queue.push(item1); + queue.push(item2); + + const mean = (values) => { + const power = 1 / values.length; + return values.reduce((a0, c0) => a0 * Math.pow(c0, power), 1); + }; + + const reduce = queue.reduceAll(); + expect(reduce['$name']).toBe(item1['$name']); + expect(reduce['$confidence']).toBe(mean([item1['$confidence'], item2['$confidence']])); + expect(reduce['$begin']).toBe(item1['$begin']); + expect(reduce['$end']).toBe(item2['$end']); + expect(reduce['$boundingBox'].Left).toBe(mean([item1['$boundingBox'].Left, item2['$boundingBox'].Left])); + expect(reduce['$boundingBox'].Top).toBe(mean([item1['$boundingBox'].Top, item2['$boundingBox'].Top])); + expect(reduce['$boundingBox'].Width).toBe(mean([item1['$boundingBox'].Width, item2['$boundingBox'].Width])); + expect(reduce['$boundingBox'].Height).toBe(mean([item1['$boundingBox'].Height, item2['$boundingBox'].Height])); + expect(reduce['$parentName']).toBe(item1['$parentName']); + expect(reduce['$count']).toBe(2); + + queue.pop(); + expect(queue.reduceAll()).toBe(undefined); + + expect(TimelineQ.computeGeometricMean([])).toBe(undefined); + }); + + test('Test timeDriftExceedThreshold', () => { + const item1 = TimelineQ.createTypedItem(personItem, {}); + const item2 = TimelineQ.createTypedItem(personItem, {}); + + expect(TimelineQ.timeDriftExceedThreshold(item2, item1)).toBe(false); + + item1['$begin'] = 100; + item1['$timeDriftThreshold'] = 5; + item2['$end'] = 1; + + expect(TimelineQ.timeDriftExceedThreshold(item2, item1)).toBe(true); + expect(TimelineQ.timeDriftExceedThreshold()).toBe(false); + }); + + test('Test positionDriftExceedThreshold', () => { + const item1 = TimelineQ.createTypedItem(personItem, {}); + const item2 = TimelineQ.createTypedItem(personItem, {}); + + expect(TimelineQ.positionDriftExceedThreshold(item2, item1)).toBe(false); + + item1['$cx'] = 1; + item1['$cy'] = 1; + item2['$cx'] = 5; + item2['$cy'] = 5; + item1['$positionDriftThreshold'] = 1; + + expect(TimelineQ.positionDriftExceedThreshold(item2, item1)).toBe(true); + expect(TimelineQ.positionDriftExceedThreshold()).toBe(false); + }); +}); + + +describe('Test WebVttTrack', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test cues operations', () => { + const track = new WebVttTrack(); + expect(track.length).toBe(0); + + const firstCue = new WebVttCue(cue1.begin, cue1.end, cue1.text, cue1.position); + track.push(firstCue); + expect(track.length).toBe(1); + + track.addCue(cue2.begin, cue2.end, cue2.text, cue2.position); + track.addCue(cue3.begin, cue3.end, cue3.text, cue3.position); + const shiftCue = track.shift(); + const popCue = track.pop(); + expect(shiftCue).toStrictEqual(firstCue); + expect(popCue['$text']).toBe(cue3.text); + }); + + test('Test track operations', () => { + const track = new WebVttTrack(); + track.addCue(cue1.begin, cue1.end, cue1.text, cue1.position); + track.addCue(cue2.begin, cue2.end, cue2.text, cue2.position); + + const parsed = WebVttTrack.parse(track.toString()); + expect(parsed['$cues'][0]['$text']).toBe(track.cues[0]['$text']); + expect(parsed['$cues'][1]['$text']).toBe(track.cues[1]['$text']); + + let lines = track.toString().split('\n'); + expect(lines.shift()).toBe('WEBVTT'); + expect(lines.shift()).toBe(''); + expect(lines.shift()).toBe('0'); + expect(lines.shift()).toBe(`${track.cues[0].toTimeString(cue1.begin)} --> ${track.cues[0].toTimeString(cue1.end)} ${cue1.position}`); + expect(lines.shift()).toBe(cue1.text); + expect(lines.shift()).toBe(''); + expect(lines.shift()).toBe('1'); + expect(lines.shift()).toBe(`${track.cues[1].toTimeString(cue2.begin)} --> ${track.cues[1].toTimeString(cue2.end)} ${cue2.position}`); + expect(lines.shift()).toBe(cue2.text); + + track.length = 5; + expect(track.length).toBe(5); + }); + + test('Test convertToMilliseconds', () => { + expect(WebVttTrack.convertToMilliseconds(['00', '11', '22', '000'])).toBeGreaterThan(0); + }); +}); + + +describe('Test mxCommonUtils', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('S3', 'headObject', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'listObjectsV2', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'getObject', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'putObject', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'deleteObject', Promise.resolve()); + + AWS.mock('S3', 'getObjectTagging', function(params, callback) { + const response = params; + response.TagSet = [ + { + Key: 'tag1', + Value: 'val1' + }, { + Key: 'tag3', + Value: 'val3' + } + ]; + callback(null, response); + }); + + AWS.mock('S3', 'putObjectTagging', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'selectObjectContent', function(params, callback) { + callback(new Error('error selectObjectContent'), null); + }); + + AWS.mock('S3', 'restoreObject', function(params, callback) { + callback(null, params); + }); + + AWS.mock('S3', 'copyObject', function (params, callback) { + callback(null, params); + }); + }); + + afterEach(() => { + AWS.restore('S3'); + }); + + test('Test unsignedUrl', () => { + const bucket = 'bucketname'; + const key = 'key'; + expect(CommonUtils.unsignedUrl(bucket, key)).toMatch(new RegExp(`https://${bucket}.s3.*.amazonaws.com/${key}`)); + }); + + test('Test headObject', async () => { + const bucket = 'bucketname'; + const key = 'key'; + const response = await CommonUtils.headObject(bucket, key); + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test listObjects', async () => { + const bucket = 'bucketname'; + const prefix = 'pre'; + const response = await CommonUtils.listObjects(bucket, prefix, {}); + expect(response.Bucket).toBe(bucket); + expect(response.Prefix).toBe(`${prefix}/`); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test download', async () => { + const bucket = 'bucketname'; + const key = 'key'; + const response = await CommonUtils.download(bucket, key, false); + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test upload', async () => { + const params = { + Bucket: 'bucketName', + Key: 'key', + Body: '{}', + ContentType: 'test' + }; + const response = await CommonUtils.upload(params); + expect(response.Bucket).toBe(params.Bucket); + expect(response.Key).toBe(params.Key); + expect(response.ContentType).toBe(params.ContentType); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test uploadFile', async () => { + const bucket = 'bucketname'; + const prefix = 'pre'; + const filename = 'file.txt'; + const response = await CommonUtils.uploadFile(bucket, prefix, filename, {}); + + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(`${prefix}/${filename}`); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test deleteObject', async () => { + const bucket = 'bucketname'; + const key = 'key'; + expect(await CommonUtils.deleteObject(bucket, key)).toBe(true); + }); + + test('Test getTags', async () => { + const bucket = 'bucketname'; + const key = 'key'; + const response = await CommonUtils.getTags(bucket, key); + + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test tagObject', async () => { + const bucket = 'bucketname'; + const key = 'key'; + const tagset = [ + { + Key: 'tag1', + Value: 'val1' + }, { + Key: 'tag2', + Value: 'val2' + } + ]; + const response = await CommonUtils.tagObject(bucket, key, tagset); + + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + expect(response.Tagging.TagSet).toContain(tagset[0]); + expect(response.Tagging.TagSet).toContain(tagset[1]); + }); + + test('Test createReadStream', async () => { + const stream = 'test stream'; + const bucket = 'bucketname'; + const key = 'key'; + AWS.remock('S3', 'getObject', Buffer.from(stream)); + const response = await CommonUtils.createReadStream(bucket, key, {}); + + expect(response.read().toString()).toBe(stream); + }); + + test('Test selectS3Content', async () => { + const bucket = 'bucketname'; + const key = 'key'; + + await CommonUtils.selectS3Content(bucket, key, '').catch(error => { + expect(error.message).toBe('error selectObjectContent'); + }); + }); + + test('Test restoreObject', async () => { + const bucket = 'bucketname'; + const key = 'key'; + const response = await CommonUtils.restoreObject(bucket, key, {}); + + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test copyObject', async () => { + const source = 'sourcename'; + const bucket = 'bucketname'; + const key = 'key'; + const response = await CommonUtils.copyObject(source, bucket, key, {}); + + expect(response.CopySource).toBe(source); + expect(response.Bucket).toBe(bucket); + expect(response.Key).toBe(key); + expect(response.ExpectedBucketOwner).toBe(process.env.ENV_EXPECTED_BUCKET_OWNER); + }); + + test('Test uuid4', () => { + expect(CommonUtils.uuid4()).toBeTruthy(); + + const str = 's'; + try { + CommonUtils.uuid4(str); + } + catch(error) { + expect(error.message).toBe(`failed to generate UUID from '${str}'`); + } + }); + + test('Test normalizeFileName', () => { + const name = ','; + expect(CommonUtils.normalizeFileName(name)).toBe('_'); + }); + + test('Test escape/unescape S3Characters', () => { + const key = 's s'; + const escape = CommonUtils.escapeS3Characters(key); + expect(escape).toBe('s+s'); + expect(CommonUtils.unescapeS3Character(escape)).toBe(key); + }); + + test('Test toMD5String', () => { + expect(CommonUtils.toMD5String('test')).toMatch(new RegExp(/[0-9A-Fa-f]{6}/g)); + expect(CommonUtils.toMD5String('test', 'base64')).toBe('test'); + expect(CommonUtils.toMD5String('')).toBe(undefined); + }); + + test('Test sanitizedKey/Path', () => { + expect(CommonUtils.sanitizedKey('/s')).toBe('s'); + + const dir = 'home/folder'; + const name = 'file'; + const ext = '.txt'; + const path = CommonUtils.sanitizedPath(`/${dir}/${name}${ext}`); + expect(path.dir).toBe(dir); + expect(path.base).toBe(`${name}${ext}`); + expect(path.ext).toBe(ext); + expect(path.name).toBe(name); + }); + + test('Test zero functions', () => { + const md5 = CommonUtils.zeroMD5(); + expect(md5.length).toBe(32); + expect(parseInt(md5)).toBe(0); + + const accountId = CommonUtils.zeroAccountId(); + expect(accountId.length).toBe(12); + expect(parseInt(accountId)).toBe(0); + + const uuid = CommonUtils.zeroUUID(); + expect(uuid.length).toBe(36); + expect(parseInt(uuid)).toBe(0); + }); + + test('Test pause', async () => { + expect(CommonUtils.pause(1000)).resolves.not.toThrow(); + }); + + test('Test toISODateTime', () => { + expect(CommonUtils.toISODateTime().length).toBeGreaterThan(0); + }); + + test('Test random', () => { + let min = 3; + let max = 4; + expect(CommonUtils.random(min, max)).toBe(min); + + max = 20; + const rand = CommonUtils.random(min, max); + expect(rand).toBeGreaterThanOrEqual(min); + expect(rand).toBeLessThan(max); + }); + + test('Test isJSON', () => { + const json = { key: 'val' }; + expect(CommonUtils.isJSON('s')).toBe(false); + expect(CommonUtils.isJSON(JSON.stringify(json))).toBe(true); + }); + + test('Test Mime functions', () => { + expect(CommonUtils.getMime('file.txt')).toBe('text/plain'); + expect(CommonUtils.getExtensionByMime('text/plain')).toBe('txt'); + expect(CommonUtils.parseMimeType('text/plain')).toBe('plain'); + }); + + test('Test capitalize', () =>{ + expect(CommonUtils.capitalize('name')).toBe('Name'); + }); + + test('Test timeToLiveInSecond', () => { + expect(CommonUtils.timeToLiveInSecond(0)).toBeLessThan(CommonUtils.timeToLiveInSecond()); + }); + + test('Test compress/decompressData', async () => { + const target = 'target'; + const compressed = await CommonUtils.compressData(target); + expect(compressed.length).toBeGreaterThan(0); + + const decompressed = await CommonUtils.decompressData(compressed); + expect(decompressed.toString()).toBe(target); + + await CommonUtils.decompressData('').catch(error => { + expect(error.message).toBe('target must be Array object'); + }); + }); + + test('Test flatten', () => { + const arr = [1, 1, [2, 2, [3, 3]]]; + expect(CommonUtils.flatten(arr)).toStrictEqual([1, 1, 2, 2, [3, 3]]); + expect(CommonUtils.flatten(arr, 2)).toStrictEqual([1, 1, 2, 2, 3, 3]); + }); + + test('Test makeSafeOutputPrefix', () => { + expect(CommonUtils.makeSafeOutputPrefix('uuid', '/prefix/file')).toBe('uuid/prefix/'); + }); + +}); + + +describe('Test mxNeat', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test neat', () => { + const json = { + key1: 'val1', + key2: undefined + }; + const neat = CommonUtils.neat(json); + delete json['key2']; + expect(neat).toStrictEqual(json); + + json['key1'] = undefined; + expect(CommonUtils.neat(json)).toBe(undefined); + }); +}); + + +describe('Test mxValidation', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test validateBucket', async () => { + const bucketName = new StringBuilder('a'); + expect(CommonUtils.validateBucket(bucketName.toString())).toBe(false); + + bucketName.repeat(2); //aaa + expect(CommonUtils.validateBucket(bucketName.toString())).toBe(true); + + bucketName.append('A'); //aaaA + expect(CommonUtils.validateBucket(bucketName.toString())).toBe(false); + + bucketName.replace(3,4,'_'); //aaa_ + expect(CommonUtils.validateBucket(bucketName.toString())).toBe(false); + + bucketName.deleteCharAt(3); + bucketName.repeat(22); //66 chars + expect(CommonUtils.validateBucket(bucketName.toString())).toBe(false); + }); + + test('Test validateUuid', async () => { + expect(CommonUtils.validateUuid('1234')).toBe(false); + expect(CommonUtils.validateUuid('12345678-1234-1234-1234-123456789abc')).toBe(true); + }); + + test('Test validateCognitoIdentityId', () => { + expect(CommonUtils.validateCognitoIdentityId(`us-east-1:${CommonUtils.zeroUUID()}`)).toBe(true); + expect(CommonUtils.validateCognitoIdentityId()).toBe(false); + }); + + test('Test validateBase64JsonToken', () => { + const json = { + key1: 'val1' + }; + const buf = Buffer.from(JSON.stringify(json)); + + expect(CommonUtils.validateBase64JsonToken(buf)).toBe(true); + expect(CommonUtils.validateBase64JsonToken()).toBe(false); + }); + + test('Test validateFaceCollectionId', () => { + expect(CommonUtils.validateFaceCollectionId('a1._-')).toBe(true); + expect(CommonUtils.validateFaceCollectionId('/')).toBe(false); + }); + + test('Test validateS3Uri', () => { + expect(CommonUtils.validateS3Uri('s3://bucketname/key')).toBe(true); + expect(CommonUtils.validateS3Uri('a3://bucketname/key')).toBe(false); + expect(CommonUtils.validateS3Uri('s3://bu/key')).toBe(false); + }); +}); \ No newline at end of file diff --git a/source/layers/core-lib/jest.config.js b/source/layers/core-lib/jest.config.js new file mode 100644 index 0000000..5a5ca00 --- /dev/null +++ b/source/layers/core-lib/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] +}; \ No newline at end of file diff --git a/source/layers/core-lib/lib/.version b/source/layers/core-lib/lib/.version index 4a36342..fd2a018 100644 --- a/source/layers/core-lib/lib/.version +++ b/source/layers/core-lib/lib/.version @@ -1 +1 @@ -3.0.0 +3.1.0 diff --git a/source/layers/core-lib/lib/apiOps.js b/source/layers/core-lib/lib/apiOps.js index b72403f..2c0b9f2 100644 --- a/source/layers/core-lib/lib/apiOps.js +++ b/source/layers/core-lib/lib/apiOps.js @@ -102,4 +102,18 @@ module.exports = { * method: GET */ Stats: 'stats', + + /** + * @description get a list of cognito users + * /users + * method: GET + */ + Users: 'users', + + /** + * @description manage ai/ml options settings + * /settings/aioptions + * method: GET, POST, DELETE + */ + AIOptionsSettings: 'settings/aioptions', }; diff --git a/source/layers/core-lib/lib/db.js b/source/layers/core-lib/lib/db.js index e1310de..562efff 100644 --- a/source/layers/core-lib/lib/db.js +++ b/source/layers/core-lib/lib/db.js @@ -38,7 +38,6 @@ class DB { this.$partitionKey = params.PartitionKey; this.$sortKey = params.SortKey; - this.$sortKeyType = undefined; this.$instance = new AWS.DynamoDB.DocumentClient({ apiVersion: '2012-08-10', @@ -72,14 +71,6 @@ class DB { return this.$sortKey; } - get sortKeyType() { - return this.$sortKeyType; - } - - set sortKeyType(val) { - this.$sortKeyType = val; - } - /** * @function update * @description update or create DB entry @@ -215,10 +206,6 @@ class DB { * @param {string} [projection] - selected field(s) to return */ async fetch(primaryValue, sortValue, projection) { - if (this.sortKey && this.sortKeyType === undefined) { - await this.describe(); - } - const params = { TableName: this.table, ExpressionAttributeNames: { @@ -233,7 +220,7 @@ class DB { if (this.sortKey) { params.ExpressionAttributeNames['#x1'] = this.sortKey; params.ExpressionAttributeValues[':v1'] = sortValue; - params.KeyConditionExpression = (this.sortKeyType === 'string') + params.KeyConditionExpression = (typeof sortValue === 'string') ? `${params.KeyConditionExpression} and begins_with(#x1, :v1)` : `${params.KeyConditionExpression} and #x1 >= :v1`; } @@ -277,38 +264,6 @@ class DB { return this.instance.delete(params).promise(); } - /** - * @function describe - * @description find out the primary and sort key attribute - */ - async describe() { - const instance = new AWS.DynamoDB({ - apiVersion: '2012-08-10', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - }); - - const response = await instance.describeTable({ - TableName: this.table, - }).promise(); - - const { - Table: { - AttributeDefinitions, - }, - } = response; - - if (this.sortKey) { - const sortKey = AttributeDefinitions.find(x => - x.AttributeName === this.sortKey); - - this.sortKeyType = (sortKey.AttributeType === 'S') - ? 'string' - : 'number'; - } - - return response; - } - async dropColumns(primaryValue, sortValue, attributes) { let items = Array.isArray(attributes) ? attributes : [attributes]; /* #1: remove primary and sort key from the list */ diff --git a/source/layers/core-lib/lib/edlComposer.js b/source/layers/core-lib/lib/edlComposer.js index 6ca4b1b..91114fd 100644 --- a/source/layers/core-lib/lib/edlComposer.js +++ b/source/layers/core-lib/lib/edlComposer.js @@ -63,7 +63,8 @@ class EDLComposer { const srcIn = event.startTime.replace(';', ':'); const srcOut = event.endTime.replace(';', ':'); // field 8/9 - const recordIn = (prev) ? prev.endTime.replace(';', ':') : srcIn; + // const recordIn = (prev) ? prev.endTime.replace(';', ':') : srcIn; + const recordIn = srcIn; const recordOut = srcOut; const clipName = event.clipName; // .toUpperCase().replace(/[^A-Z0-9\s_.-]/g, ' '); return [ diff --git a/source/layers/core-lib/lib/environment.js b/source/layers/core-lib/lib/environment.js index ec6739d..b2c8112 100644 --- a/source/layers/core-lib/lib/environment.js +++ b/source/layers/core-lib/lib/environment.js @@ -74,7 +74,6 @@ module.exports = { }, MediaConvert: { Host: process.env.ENV_MEDIACONVERT_HOST, - Role: process.env.ENV_MEDIACONVERT_ROLE, }, SNS: { Topic: process.env.ENV_SNS_TOPIC_ARN, @@ -116,4 +115,7 @@ module.exports = { S3: { ExpectedBucketOwner: process.env.ENV_EXPECTED_BUCKET_OWNER, }, + Cognito: { + UserPoolId: process.env.ENV_USER_POOL_ID, + }, }; diff --git a/source/layers/core-lib/lib/frameCaptureMode.js b/source/layers/core-lib/lib/frameCaptureMode.js index 3f53b51..b0a3a95 100644 --- a/source/layers/core-lib/lib/frameCaptureMode.js +++ b/source/layers/core-lib/lib/frameCaptureMode.js @@ -15,4 +15,9 @@ module.exports = { MODE_HALF_FPS: 1001, // half framerate MODE_1F_EVERY_2S: 1002, // 1 frame every 2 seconds MODE_1F_EVERY_5S: 1003, // 1 frame every 5 seconds + MODE_1F_EVERY_10S: 1004, // 1 frame every 10 seconds + MODE_1F_EVERY_30S: 1005, // 1 frame every 30 seconds + MODE_1F_EVERY_1MIN: 1011, // 1 frame every minute + MODE_1F_EVERY_2MIN: 1012, // 1 frame every 2 minutes + MODE_1F_EVERY_5MIN: 1013, // 1 frame every 5 minutes }; diff --git a/source/layers/core-lib/lib/frameCaptureModeHelper.js b/source/layers/core-lib/lib/frameCaptureModeHelper.js index f79b0fb..2645f03 100644 --- a/source/layers/core-lib/lib/frameCaptureModeHelper.js +++ b/source/layers/core-lib/lib/frameCaptureModeHelper.js @@ -39,6 +39,21 @@ class FrameCaptureModeHelper { case FrameCaptureMode.MODE_1F_EVERY_5S: numerator = (1 * 1000) / 5; break; + case FrameCaptureMode.MODE_1F_EVERY_10S: + numerator = (1 * 1000) / 10; + break; + case FrameCaptureMode.MODE_1F_EVERY_30S: + numerator = Math.floor((1 * 1000) / 30); + break; + case FrameCaptureMode.MODE_1F_EVERY_1MIN: + numerator = Math.floor((1 * 1000) / 60); + break; + case FrameCaptureMode.MODE_1F_EVERY_2MIN: + numerator = Math.floor((1 * 1000) / (60 * 2)); + break; + case FrameCaptureMode.MODE_1F_EVERY_5MIN: + numerator = Math.floor((1 * 1000) / (60 * 5)); + break; default: numerator = 0; } diff --git a/source/layers/core-lib/lib/indexer/index.js b/source/layers/core-lib/lib/indexer/index.js index 850fe8c..8518f93 100644 --- a/source/layers/core-lib/lib/indexer/index.js +++ b/source/layers/core-lib/lib/indexer/index.js @@ -17,6 +17,9 @@ const Environment = require('../environment'); const AnalysisTypes = require('../analysisTypes'); const MAPPINGS_INGEST = require('./mappings/ingest'); const MAPPINGS_ANALYSIS = require('./mappings/analysis'); +const { + pause, +} = require('../retry'); const DOMAIN_ENDPOINT = Environment.Elasticsearch.DomainEndpoint; /* exception types */ @@ -124,7 +127,7 @@ class Indexer { return this.client.cat.indices({ index: name, }).then((res) => - Indexer.parseIndexDecription(res.body)); + Indexer.parseIndexDescription(res.body)); } async describeAllIndices() { @@ -138,16 +141,27 @@ class Indexer { throw new Error('index name not specified'); } const body = Indexer.getMapping(name); - return this.client.indices.create({ - index: name, - body, - }).catch((e) => { - if (e.body.error.type === EXCEPTION_RESOURCE_ALREADY_EXISTS) { + /* retry logic */ + let tries = 5; + let response; + do { + response = await this.client.indices.create({ + index: name, + body, + }).catch((e) => + e); + + if (!(response instanceof Error)) { + return response; + } + if (response.body.error.type === EXCEPTION_RESOURCE_ALREADY_EXISTS) { console.log(`index '${name}' already exists`); return undefined; } - throw e; - }); + await pause(400); + } while ((tries--) > 0); + + throw response; } async batchCreateIndices(indices = INDICES) { @@ -160,7 +174,7 @@ class Indexer { .then(() => succeeded.push(name)) .catch((e) => - failed.push(e.body.error.reason)); + failed.push(((e.body || {}).error || {}).reason)); } if (failed.length) { throw new Error(failed.join('\n')); @@ -193,7 +207,7 @@ class Indexer { .then(() => succeeded.push(name)) .catch((e) => - failed.push(e.body.error.reason)); + failed.push(((e.body || {}).error || {}).reason)); } if (failed.length) { throw new Error(failed.join('\n')); @@ -308,7 +322,7 @@ class Indexer { async indexDocument(name, id, doc) { return this.update(name, id, doc) .catch((e) => { - if (e.body.error.type === EXCEPTION_DOCUMENT_MISSING) { + if (((e.body || {}).error || {}).type === EXCEPTION_DOCUMENT_MISSING) { return this.index(name, id, doc); } throw e; @@ -318,7 +332,7 @@ class Indexer { async deleteDocument(name, id) { return this.delete(name, id) .catch((e) => { - if (e.body.result === EXCEPTION_NOT_FOUND) { + if ((e.body || {}).result === EXCEPTION_NOT_FOUND) { return undefined; } throw e; diff --git a/source/layers/core-lib/lib/indexer/index.spec.js b/source/layers/core-lib/lib/indexer/index.spec.js new file mode 100644 index 0000000..7a8ac04 --- /dev/null +++ b/source/layers/core-lib/lib/indexer/index.spec.js @@ -0,0 +1,199 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const Indexer = require('./index.js'); + +const mockIndexOutput = 'index'; +const mockPutSettingsOutput = 'putSettings'; +const mockGetSettingsOutput = 'getSettings'; +const mockAggregateOutput = 'aggregate'; +const mockGetDocumentOutput = 'getDocument'; +const mockUpdateOutput = 'update'; +const mockDeleteOutput = 'delete'; +const mockHitsOutput = 5; + +jest.mock('@elastic/elasticsearch', () => { + return { + Client: jest.fn(() => { + return { + indices: { + create: (params, callback) => { return Promise.resolve(); }, + delete: (params, callback) => { return Promise.resolve(); }, + putSettings: (params, callback) => { return Promise.resolve(mockPutSettingsOutput); }, + getSettings: (params, callback) => { return Promise.resolve(mockGetSettingsOutput); } + }, + cat: { + indices: (params, callback) => { return Promise.resolve({ body: 'indices' }); } + }, + search: (params, callback) => { + return Promise.resolve({ + body: { + aggregations: mockAggregateOutput, + hits: mockHitsOutput + } + }); + }, + get: (params, callback) => { + return Promise.resolve({ + body: { + _source: mockGetDocumentOutput + } + }); + }, + update: (params, callback) => { return Promise.resolve(mockUpdateOutput); }, + delete: (params, callback) => { return Promise.resolve(mockDeleteOutput); }, + index: (params, callback) => { return Promise.resolve(mockIndexOutput); } + } + }) + }; +}); + + +jest.mock('aws-elasticsearch-connector', () => { + return jest.fn(); +}); + +describe('Test Indexer', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test constructor', () => { + try { + const indexer = new Indexer(''); + } + catch(error) { + expect(error.message).toBe('endpoint not specified'); + } + + const indexer = new Indexer(); + expect(indexer.client).toBeTruthy(); + }); + + test('Test index', async () => { + const indexer = new Indexer(); + let response = await indexer.index('name', 'id', 'body'); + expect(response).toBe(mockIndexOutput); + + await indexer.index('', '', '').catch(error => { + expect(error.message).toBe('name, id, or body not specified'); + }); + }); + + test('Test batchCreateIndices', async () => { + const indexer = new Indexer(); + let response = await indexer.batchCreateIndices(); + expect(response.length).toBeGreaterThan(0); + + await indexer.batchCreateIndices(['']).catch(error => { + expect(error.message).toBe(''); + }); + }); + + test('Test batchDeleteIndices', async () => { + const indexer = new Indexer(); + let response = await indexer.batchDeleteIndices(); + expect(response.length).toBeGreaterThan(0); + + await indexer.batchDeleteIndices(['']).catch(error => { + expect(error.message).toBe(''); + }); + }); + + test('Test describeAllIndices', async () => { + const indexer = new Indexer(); + const response = await indexer.describeAllIndices(); + expect(response.length).toBeGreaterThan(0); + }); + + test('Test describeIndex', async () => { + const indexer = new Indexer(); + const response = await indexer.describeIndex('name'); + expect(response.length).toBeGreaterThan(0); + + await indexer.describeIndex('').catch(error => { + expect(error.message).toBe('index name not specified'); + }); + }); + + test('Test updateSettings', async () => { + const indexer = new Indexer(); + const response = await indexer.updateSettings('name', 'settings'); + expect(response).toBe(mockPutSettingsOutput); + + await indexer.updateSettings('', '').catch(error => { + expect(error.message).toBe('index name or settings not specified'); + }); + }); + + test('Test getSettings', async () => { + const indexer = new Indexer(); + const response = await indexer.getSettings('name'); + expect(response).toBe(mockGetSettingsOutput); + + await indexer.getSettings('').catch(error => { + expect(error.message).toBe('index name not specified'); + }); + }); + + test('Test aggregate', async () => { + const indexer = new Indexer(); + const response = await indexer.aggregate('name'); + expect(response).toBe(mockAggregateOutput); + }); + + test('Test getDocument', async () => { + const indexer = new Indexer(); + const response = await indexer.getDocument('name', 'id'); + expect(response).toBe(mockGetDocumentOutput); + + await indexer.getDocument('', '').catch(error => { + expect(error.message).toBe('name or id not specified'); + }); + }); + + test('Test indexDocument', async () => { + const indexer = new Indexer(); + const response = await indexer.indexDocument('name', 'id', 'doc'); + expect(response).toBe(mockUpdateOutput); + + await indexer.indexDocument('', '', '').catch(error => { + expect(error.message).toBe('name, id, or doc not specified'); + }); + }); + + test('Test deleteDocument', async () => { + const indexer = new Indexer(); + const response = await indexer.deleteDocument('name', 'id'); + expect(response).toBe(mockDeleteOutput); + + await indexer.deleteDocument('', '').catch(error => { + expect(error.message).toBe('name or id not specified'); + }); + }); + + test('Test searchDocument', async () => { + const indexer = new Indexer(); + const response = await indexer.searchDocument({ index: 'idx'} ); + expect(response).toBe(mockHitsOutput); + + await indexer.searchDocument({}).catch(error => { + expect(error.message).toBe('index not specified'); + }); + }); + +}); \ No newline at end of file diff --git a/source/layers/core-lib/lib/mxCommonUtils.js b/source/layers/core-lib/lib/mxCommonUtils.js index c6c0aa6..bacd991 100644 --- a/source/layers/core-lib/lib/mxCommonUtils.js +++ b/source/layers/core-lib/lib/mxCommonUtils.js @@ -178,13 +178,17 @@ class M0 { ] = (mime || '').split('/').filter(x => x).map(x => x.toLowerCase()); // eslint-disable-next-line - return (type === 'video' || type === 'audio' || type === 'image') - ? type - : (subtype === 'mxf' || subtype === 'gxf') - ? 'video' - : (subtype === 'pdf') - ? 'document' - : subtype; + if (type === 'video' || type === 'audio' || type === 'image') { + return type; + } + if (subtype === 'mxf' || subtype === 'gxf'){ + return 'video'; + } + if (subtype === 'pdf') { + return 'document'; + } + + return subtype; } } @@ -885,11 +889,13 @@ const mxCommonUtils = Base => class extends Base { */ static async compressData(target, slice = 255) { const size = Math.max(10, slice); - const t = (target instanceof Buffer) - ? target - : (typeof target === 'string') - ? target - : JSON.stringify(target); + let t; + if (target instanceof Buffer || typeof target === 'string') { + t = target; + } + else { + t = JSON.stringify(target); + } let buf = await new Promise((resolve, reject) => { ZLIB.gzip(t, (err, res) => @@ -1035,7 +1041,7 @@ const mxValidation = Base => class extends Base { * @param {string} val - id */ static validateCognitoIdentityId(val = '') { - return /^[a-z]{2,}-[a-z]{2,}-[0-9]{1}:[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/.test(val); + return /^[a-z]{2,}-[a-z]{2,}-\d{1}:[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/.test(val); } /** @@ -1129,6 +1135,16 @@ const mxValidation = Base => class extends Base { return /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i.test(val); } + /** + * @static + * @function validateUsername + * @description validate username + * @param {string} val + */ + static validateUsername(val = '') { + return /^[a-zA-Z0-9._%+-]{1,128}$/.test(val); + } + /** * @static * @function validateGroupName diff --git a/source/layers/core-lib/lib/stateMessage.js b/source/layers/core-lib/lib/stateMessage.js index 7da4f57..dad83d8 100644 --- a/source/layers/core-lib/lib/stateMessage.js +++ b/source/layers/core-lib/lib/stateMessage.js @@ -57,11 +57,14 @@ class StateMessage { } get overallStatus() { - return (this.status.indexOf(Statuses.Error) >= 0) - ? Statuses.Error - : (this.status === Statuses.AnalysisCompleted) - ? Statuses.Completed - : Statuses.Processing; + if (this.status.indexOf(Statuses.Error) >= 0) { + return Statuses.Error; + } + if (this.status === Statuses.AnalysisCompleted) { + return Statuses.Completed; + } + + return Statuses.Processing; } get status() { diff --git a/source/layers/core-lib/lib/timecodeUtils.js b/source/layers/core-lib/lib/timecodeUtils.js index 20b9419..839a432 100644 --- a/source/layers/core-lib/lib/timecodeUtils.js +++ b/source/layers/core-lib/lib/timecodeUtils.js @@ -41,29 +41,36 @@ class TimecodeUtils { } static lookupFramerate(enumFPS) { - return enumFPS === EnumFrameRate.FPS_23_976 - ? [24000, 1001] - : enumFPS === EnumFrameRate.FPS_24 - ? [24000, 1000] - : enumFPS === EnumFrameRate.FPS_25 - ? [25000, 1000] - : enumFPS === EnumFrameRate.FPS_29_97 - ? [30000, 1001] - : enumFPS === EnumFrameRate.FPS_30 - ? [30000, 1000] - : enumFPS === EnumFrameRate.FPS_59_94 - ? [60000, 1001] - : enumFPS === EnumFrameRate.FPS_60 - ? [60000, 1000] - : undefined; + switch (enumFPS) { + case EnumFrameRate.FPS_23_976: + return [24000, 1001]; + case EnumFrameRate.FPS_24: + return [24000, 1000]; + case EnumFrameRate.FPS_25: + return [25000, 1000]; + case EnumFrameRate.FPS_29_97: + return [30000, 1001]; + case EnumFrameRate.FPS_30: + return [30000, 1000]; + case EnumFrameRate.FPS_59_94: + return [60000, 1001]; + case EnumFrameRate.FPS_60: + return [60000, 1000]; + default: + return undefined; + } } toTimecode(num) { - return this.dropFrame - ? this.enumFPS === EnumFrameRate.FPS_23_976 - ? this.toPseudoDropFrameTimecode(num) - : this.toDropFrameTimecode(num) - : this.toNonDropFrameTimecode(num); + if (this.dropFrame) { + if (this.enumFPS === EnumFrameRate.FPS_23_976) { + return this.toPseudoDropFrameTimecode(num); + } + else { + return this.toDropFrameTimecode(num); + } + } + return this.toNonDropFrameTimecode(num); } toNonDropFrameTimecode(num) { @@ -177,11 +184,16 @@ class TimecodeUtils { } fromTimecode(hours, minutes, seconds, frames) { - return this.dropFrame - ? this.enumFPS === EnumFrameRate.FPS_23_976 - ? this.fromPseudoDropFrameTimecode(hours, minutes, seconds, frames) - : this.fromDropFrameTimecode(hours, minutes, seconds, frames) - : this.fromNonDropFrameTimecode(hours, minutes, seconds, frames); + if (this.dropFrame) { + if (this.enumFPS === EnumFrameRate.FPS_23_976) { + return this.fromPseudoDropFrameTimecode(hours, minutes, seconds, frames); + } + else { + return this.fromDropFrameTimecode(hours, minutes, seconds, frames); + } + } + + return this.fromNonDropFrameTimecode(hours, minutes, seconds, frames); } fromNonDropFrameTimecode(hours, minutes, seconds, frames) { diff --git a/source/layers/core-lib/lib/timelineQ.js b/source/layers/core-lib/lib/timelineQ.js index e729874..d88db16 100644 --- a/source/layers/core-lib/lib/timelineQ.js +++ b/source/layers/core-lib/lib/timelineQ.js @@ -293,14 +293,6 @@ class ModerationItem extends BaseItem { get [Symbol.toStringTag]() { return 'ModerationItem'; } - - get cueText() { - return [ - this.name, - this.parentName ? `${this.parentName}` : undefined, - `(${Number.parseFloat(this.confidence).toFixed(2)})`, - ].filter(x => x).join('\n'); - } } class PersonItem extends BaseItem { diff --git a/source/layers/core-lib/lib/webVttTrack.js b/source/layers/core-lib/lib/webVttTrack.js index 88562c6..1708be2 100644 --- a/source/layers/core-lib/lib/webVttTrack.js +++ b/source/layers/core-lib/lib/webVttTrack.js @@ -14,7 +14,7 @@ class WebVttTrack { UnitInMilliseconds: 1, UnitInSeconds: 1000, Timecode: { - Regex: /^([0-9]{2}):([0-9]{2}):([0-9]{2})\.([0-9]{2,})\s+-->\s+([0-9]{2}):([0-9]{2}):([0-9]{2})\.([0-9]{2,})(.*)$/, + Regex: /^(\d{2}):(\d{2}):(\d{2})\.(\d{2,})\s+-->\s+(\d{2}):(\d{2}):(\d{2})\.(\d{2,})(.*)$/, }, }; } @@ -94,7 +94,7 @@ class WebVttTrack { let line = lines.shift(); /* look for index */ - if (!/[0-9]+/.test(line)) { + if (!/\d+/.test(line)) { continue; } diff --git a/source/layers/core-lib/package.json b/source/layers/core-lib/package.json index 8eccdaf..5050294 100644 --- a/source/layers/core-lib/package.json +++ b/source/layers/core-lib/package.json @@ -9,6 +9,7 @@ "@npcz/magic": "^1.3.11", "adm-zip": "^0.5.5", "aws-elasticsearch-connector": "^9.0.1", + "aws4": "^1.11.0", "mime": "^2.3.1", "node-webvtt": "^1.9.3", "sqlstring": "^2.3.1", @@ -16,7 +17,7 @@ }, "scripts": { "pretest": "npm install", - "test": "echo \"core-lib wraps common classes. skipping unit test...\"", + "test": "jest --coverage --coverageDirectory=../../test/coverage-reports/jest/layers/core-lib/", "build:clean": "rm -rf dist && mkdir -p dist/nodejs/node_modules/core-lib", "build:copy": "cp -rv index.js package.json lib dist/nodejs/node_modules/core-lib", "build:install": "cd dist/nodejs/node_modules/core-lib && npm install --only=prod --no-optional", @@ -24,5 +25,10 @@ "zip": "cd dist && zip -rq" }, "author": "aws-mediaent-solutions", - "devDependencies": {} + "devDependencies": { + "jest": "^29.3.1", + "aws-sdk": "2.831.0", + "aws-sdk-mock": "5.8.0", + "node-stringbuilder": "2.2.6" + } } diff --git a/source/layers/core-lib/setEnvVars.js b/source/layers/core-lib/setEnvVars.js new file mode 100644 index 0000000..56d3d18 --- /dev/null +++ b/source/layers/core-lib/setEnvVars.js @@ -0,0 +1,8 @@ +process.env.ENV_SOLUTION_ID = 'testSolutionId'; +process.env.ENV_RESOURCE_PREFIX = 'testResourcePrefix'; +process.env.ENV_SOLUTION_UUID = 'testSolutionUuid'; +process.env.ENV_SNS_TOPIC_ARN = 'testTopicArn'; +process.env.ENV_IOT_HOST = 'testIotHost'; +process.env.ENV_IOT_TOPIC = 'testIotTopic'; +process.env.ENV_EXPECTED_BUCKET_OWNER = 'bucketOwner'; +process.env.ENV_ES_DOMAIN_ENDPOINT = 'testDomain'; \ No newline at end of file diff --git a/source/layers/mediainfo/index.js b/source/layers/mediainfo/index.js index c1b4eae..fe2ffa5 100644 --- a/source/layers/mediainfo/index.js +++ b/source/layers/mediainfo/index.js @@ -149,6 +149,7 @@ class MediaInfoCommand { container: this.container.map(x => MediaInfoCommand.minifyPayload(x)), audio: this.audio.map(x => MediaInfoCommand.minifyPayload(x)), video: this.video.map(x => MediaInfoCommand.minifyPayload(x)), + timecode: this.timecode, }; } @@ -179,6 +180,24 @@ class MediaInfoCommand { && x.$.type.toLowerCase() !== 'video'); } + get timecode() { + if (!this.jsonData) { + return undefined; + } + const tracks = ((this.jsonData.mediaInfo.media || {}).track || []) + .filter((x) => + (x.type || '').toLowerCase() === 'time code' + && x.timeCodeFirstFrame !== undefined + && x.format !== undefined); + return (tracks.length === 0) + ? undefined + : { + type: tracks[0].type, + format: tracks[0].format, + timeCodeFirstFrame: tracks[0].timeCodeFirstFrame, + }; + } + toJSON() { return JSON.parse(JSON.stringify(this.jsonData)); } @@ -312,6 +331,7 @@ class MediaInfoCommand { ...process.env, LD_LIBRARY_PATH: ldLibraryPath, }, + maxBuffer: 20 * 1024 * 1024, }; const params = [ ...MediaInfoCommand.Constants.Command.Options, @@ -365,8 +385,7 @@ class MediaInfoCommand { const modified = Object.assign({}, data); modified.mediaInfo.media.$.ref = ref; - for (let i = 0; i < modified.mediaInfo.media.track.length; i++) { - const track = modified.mediaInfo.media.track[i]; + for (let track of modified.mediaInfo.media.track) { if (track.completeName !== undefined) { track.completeName = ref; } @@ -384,7 +403,7 @@ class MediaInfoCommand { function camelCaseKey(k0, obj) { const k1 = (k0 === '_' || k0 === '$') ? k0 - : k0.replace(/^([A-Za-z])|[\s-_.]{1,}(\w)/g, (ignored, p1, p2) => + : k0.replace(/^([A-Za-z])|[\s-_.]+(\w)/g, (ignored, p1, p2) => ((p2) ? p2.toUpperCase() : p1.toLowerCase())).replace(/[\s-_.]$/, ''); if (k1 !== k0) { obj[k1] = obj[k0]; @@ -417,7 +436,7 @@ class MediaInfoCommand { v1 = true; } else if (/^false$/i.test(v1)) { v1 = false; - } else if (/^[-|+]{0,1}\d+$/.test(v1) || /^[-|+]{0,1}\d+\.\d+$/.test(v1)) { + } else if (/^[-|+]?\d+$/.test(v1) || /^[-|+]?\d+\.\d+$/.test(v1)) { const num = Number.parseFloat(v1); if (num >= Number.MIN_SAFE_INTEGER && num <= Number.MAX_SAFE_INTEGER) { v1 = num; diff --git a/source/layers/mediainfo/index.spec.js b/source/layers/mediainfo/index.spec.js new file mode 100644 index 0000000..ac51002 --- /dev/null +++ b/source/layers/mediainfo/index.spec.js @@ -0,0 +1,153 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const { + MediaInfoCommand, + XBuilder +} = require('mediainfo'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +const obj = { + mediaInfo: { + media: { + $: { + ref: 'ref' + }, + track: [ + { + $: { + type: 'General' + }, + CompleteName: 'track1', + FileNameExtension: 'ext', + FileExtension: 'ext' + }, { + $: { + type: 'Audio' + }, + CompleteName: 'track2', + FileNameExtension: 'ext', + FileExtension: 'ext' + }, { + $: { + type: 'NoVideoTest' + }, + CompleteName: 'track3', + FileNameExtension: 'ext', + FileExtension: 'ext' + } + ], + testFlattenNum: [ + '-1', + '2' + ], + testFlattenTrue: [ + 'true' + ], + testFlattenFalse: [ + 'true', + 'false' + ] + } + } +}; + +const mockXml = (new XBuilder()).buildObject(obj); + +jest.mock('child_process', () => { + return { + spawnSync: jest.fn(() => { + return { + stdout: mockXml, + status: 0 + }; + }) + }; +}); + + +describe('Test MediaInfoCommand', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('S3', 'getSignedUrl', Promise.resolve('url')); + }); + + afterEach(() => { + AWS.restore('S3'); + }); + + test('Test analyze and miniData', async () => { + const bucketName = 'bucketname'; + const ext = 'mov'; + const key = `folder/file.${ext}`; + const ref = `s3://${bucketName}/${key}`; + + const mi = new MediaInfoCommand(); + + let response = await mi.analyze({ + Bucket: bucketName, + Key: key + }); + + expect(response.mediaInfo.media.$.ref).toBe(ref); + expect(response.mediaInfo.media.track[0]['completeName']).toBe(ref); + expect(response.mediaInfo.media.track[0]['fileNameExtension']).toBe(ext); + expect(response.mediaInfo.media.track[0]['fileExtension']).toBe(ext); + + const http = `https://www.url.com/path.${ext}`; + response = await mi.analyze(http); + expect(response.mediaInfo.media.$.ref).toBe(http); + expect(response.mediaInfo.media.track[0]['completeName']).toBe(http); + expect(response.mediaInfo.media.track[0]['fileNameExtension']).toBe(ext); + expect(response.mediaInfo.media.track[0]['fileExtension']).toBe(ext); + + const path = './index.js'; + response = await mi.analyze(path); + expect(response.mediaInfo.media.$.ref).toBe(path); + expect(response.mediaInfo.media.track[0]['completeName']).toBe(path); + expect(response.mediaInfo.media.track[0]['fileNameExtension']).toBe('js'); + expect(response.mediaInfo.media.track[0]['fileExtension']).toBe('js'); + + expect(response.mediaInfo.media.testFlattenNum).toBe(-1); + expect(response.mediaInfo.media.testFlattenTrue).toBe(true); + expect(response.mediaInfo.media.testFlattenFalse).toBe(false); + + response = mi.miniData; + expect(response.container.length).toBeGreaterThan(0); + expect(response.audio.length).toBeGreaterThan(0); + expect(response.video.length).toBe(0); + }); + + test('Test presign', async () => { + const mi = new MediaInfoCommand(); + + await mi.presign().catch(error => { + expect(error.message).toBe('missing params'); + }); + + const fail = 'fail'; + await mi.presign(fail).catch(error => { + expect(error.message).toBe(`invalid filename '${fail}' not supported`) + }); + + await mi.presign({}).catch(error => { + expect(error.message).toBe('missing Bucket and Key, {}'); + }); + }); +}); \ No newline at end of file diff --git a/source/layers/mediainfo/jest.config.js b/source/layers/mediainfo/jest.config.js new file mode 100644 index 0000000..70e3ac2 --- /dev/null +++ b/source/layers/mediainfo/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'] +}; \ No newline at end of file diff --git a/source/layers/mediainfo/package.json b/source/layers/mediainfo/package.json index 050754a..ac2b45f 100644 --- a/source/layers/mediainfo/package.json +++ b/source/layers/mediainfo/package.json @@ -11,12 +11,16 @@ }, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../test/coverage-reports/jest/layers/mediainfo/", "build:clean": "rm -rf dist && mkdir -p dist/nodejs/node_modules/mediainfo", "build:copy": "cp -rv package.json index.js build-mediainfo.sh amazon dist/nodejs/node_modules/mediainfo", "build:install": "cd dist/nodejs/node_modules/mediainfo && npm install --only=prod --no-optional", "build": "npm-run-all -s build:clean build:copy build:install", "zip": "cd dist && zip -rq" }, - "devDependencies": {} + "devDependencies": { + "jest": "^29.3.1", + "aws-sdk": "2.831.0", + "aws-sdk-mock": "5.8.0" + } } diff --git a/source/layers/service-backlog-lib/jest.config.js b/source/layers/service-backlog-lib/jest.config.js new file mode 100644 index 0000000..a7a380b --- /dev/null +++ b/source/layers/service-backlog-lib/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coverageDirectory: "../../test/coverage-reports/jest/layers/service-backlog-lib/", + coverageReporters: [['lcov', { projectRoot: '../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] +}; \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/backlog-table-stream/index.js b/source/layers/service-backlog-lib/lib/backlog-table-stream/index.js index dabf8d7..bf18023 100644 --- a/source/layers/service-backlog-lib/lib/backlog-table-stream/index.js +++ b/source/layers/service-backlog-lib/lib/backlog-table-stream/index.js @@ -103,19 +103,28 @@ class BacklogTableStream { async onREMOVE() { const serviceApi = this.oldImage.serviceApi; - const instance = RekognitionBacklogJob.isService(serviceApi) - ? new RekognitionBacklogJob() - : TranscribeBacklogJob.isService(serviceApi) - ? new TranscribeBacklogJob() - : ComprehendBacklogJob.isService(serviceApi) - ? new ComprehendBacklogJob() - : TextractBacklogJob.isService(serviceApi) - ? new TextractBacklogJob() - : MediaConvertBacklogJob.isService(serviceApi) - ? new MediaConvertBacklogJob() - : CustomBacklogJob.isService(serviceApi) - ? new CustomBacklogJob() - : undefined; + let instance; + if (RekognitionBacklogJob.isService(serviceApi)) { + instance = new RekognitionBacklogJob(); + } + else if (TranscribeBacklogJob.isService(serviceApi)) { + instance = new TranscribeBacklogJob(); + } + else if (ComprehendBacklogJob.isService(serviceApi)) { + instance = new ComprehendBacklogJob(); + } + else if (TextractBacklogJob.isService(serviceApi)) { + instance = new TextractBacklogJob(); + } + else if (MediaConvertBacklogJob.isService(serviceApi)) { + instance = new MediaConvertBacklogJob(); + } + else if (CustomBacklogJob.isService(serviceApi)) { + instance = new CustomBacklogJob(); + } + else { + instance = undefined; + } if (!instance) { return undefined; } diff --git a/source/layers/service-backlog-lib/lib/backlog-table-stream/index.spec.js b/source/layers/service-backlog-lib/lib/backlog-table-stream/index.spec.js new file mode 100644 index 0000000..6ba9039 --- /dev/null +++ b/source/layers/service-backlog-lib/lib/backlog-table-stream/index.spec.js @@ -0,0 +1,105 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const BacklogTableStream = require('./index.js'); + +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +const eventInsert = { + Records: [ + { + eventName: 'INSERT', + eventSource: 'aws:dynamodb', + dynamodb: { + Keys: 'keys', + OldImage: 'oldimage', + NewImage: 'newimage' + } + } + ] +}; +const eventModify = { + Records: [ + { + eventName: 'MODIFY', + eventSource: 'aws:dynamodb', + dynamodb: { + Keys: 'keys', + OldImage: 'oldimage', + NewImage: 'newimage' + } + } + ] +}; +const eventRemove = { + Records: [ + { + eventName: 'REMOVE', + eventSource: 'aws:dynamodb', + dynamodb: { + Keys: 'keys', + OldImage: 'oldimage', + NewImage: 'newimage' + } + } + ] +}; + + +describe('Test BacklogTableStream', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + }); + + test('Test Event Insert', async () => { + const backlog = new BacklogTableStream(eventInsert, {}); + + expect(await backlog.process()).toBe(undefined); + expect(backlog.eventName).toBe(eventInsert.Records[0]['eventName']); + }); + + test('Test Event Modify', async () => { + const backlog = new BacklogTableStream(eventModify, {}); + + expect(await backlog.process()).toBe(undefined); + expect(backlog.eventName).toBe(eventModify.Records[0]['eventName']); + }); + + test('Test Event Remove', async () => { + const backlog = new BacklogTableStream(eventRemove, {}); + + expect(await backlog.process()).toBe(undefined); + }); + + test('Test Event Invalid', async () => { + eventModify.Records[0]['eventName'] = 'invalid'; + const backlog = new BacklogTableStream(eventModify, {}); + + await backlog.process().catch(error => { + expect(error.message).toBe(`invalid event, ${eventModify.Records[0]['eventName']}`); + }); + }); +}); + + diff --git a/source/layers/service-backlog-lib/lib/client/backlogJob.js b/source/layers/service-backlog-lib/lib/client/backlogJob.js index 444e050..1b0def2 100644 --- a/source/layers/service-backlog-lib/lib/client/backlogJob.js +++ b/source/layers/service-backlog-lib/lib/client/backlogJob.js @@ -112,14 +112,16 @@ class BacklogJob { [ddb.sort]: item.serviceApi, }, ReturnValues: 'ALL_OLD', - }).then(data => data.Attributes).catch((e) => { - console.error(`ERR: BacklogTable.deleteJob: ${e.code}: ${e.message} (${item.id}) (${jobStatus}) (${jobId})`); - throw e; - }); + }) + .then((data) => + data.Attributes) + .catch((e) => { + console.error(`ERR: BacklogTable.deleteJob: ${e.code}: ${e.message} (${item.id}) (${jobStatus}) (${jobId})`); + throw e; + }); return EBHelper.send({ ...response, status: jobStatus, - jobStatus, ...output, }); } @@ -185,7 +187,6 @@ class BacklogJob { if (response instanceof Error) { if (this.noMoreQuotasException(response.code)) { status = STATUS_PENDING; - // console.log(`${status} ${response.code}: ${response.message} (${id})`); } else { console.error(`ERR: BacklogTable.startAndRegisterJob: ${response.code}: ${response.message} (${id})`); throw response; @@ -193,7 +194,6 @@ class BacklogJob { } else { status = STATUS_PROCESSING; jobId = this.parseJobId(response); - // console.log(`${status} ${response.JobId} (${id})`); } return this.createJobItem( @@ -227,7 +227,8 @@ class BacklogJob { metrics.notStarted.push(item.id); continue; } - response = await this.updateJobId(item, response.JobId) + const jobId = this.parseJobId(response); + response = await this.updateJobId(item, jobId) .catch(e => e); if (response instanceof Error) { if (response.code === 'ConditionalCheckFailedException') { diff --git a/source/layers/service-backlog-lib/lib/client/comprehend/index.js b/source/layers/service-backlog-lib/lib/client/comprehend/index.js index 2e55e16..6de83a9 100644 --- a/source/layers/service-backlog-lib/lib/client/comprehend/index.js +++ b/source/layers/service-backlog-lib/lib/client/comprehend/index.js @@ -93,19 +93,22 @@ class ComprehendBacklogJob extends BacklogJob { bindToFunc(serviceApi) { const comprehend = this.getComprehendInstance(); - return (serviceApi === ComprehendBacklogJob.ServiceApis.StartDocumentClassificationJob) - ? comprehend.startDocumentClassificationJob.bind(comprehend) - : (serviceApi === ComprehendBacklogJob.ServiceApis.StartDominantLanguageDetectionJob) - ? comprehend.startDominantLanguageDetectionJob.bind(comprehend) - : (serviceApi === ComprehendBacklogJob.ServiceApis.StartEntitiesDetectionJob) - ? comprehend.startEntitiesDetectionJob.bind(comprehend) - : (serviceApi === ComprehendBacklogJob.ServiceApis.StartKeyPhrasesDetectionJob) - ? comprehend.startKeyPhrasesDetectionJob.bind(comprehend) - : (serviceApi === ComprehendBacklogJob.ServiceApis.StartSentimentDetectionJob) - ? comprehend.startSentimentDetectionJob.bind(comprehend) - : (serviceApi === ComprehendBacklogJob.ServiceApis.StartTopicsDetectionJob) - ? comprehend.startTopicsDetectionJob.bind(comprehend) - : undefined; + switch (serviceApi) { + case ComprehendBacklogJob.ServiceApis.StartDocumentClassificationJob: + return comprehend.startDocumentClassificationJob.bind(comprehend); + case ComprehendBacklogJob.ServiceApis.StartDominantLanguageDetectionJob: + return comprehend.startDominantLanguageDetectionJob.bind(comprehend); + case ComprehendBacklogJob.ServiceApis.StartEntitiesDetectionJob: + return comprehend.startEntitiesDetectionJob.bind(comprehend); + case ComprehendBacklogJob.ServiceApis.StartKeyPhrasesDetectionJob: + return comprehend.startKeyPhrasesDetectionJob.bind(comprehend); + case ComprehendBacklogJob.ServiceApis.StartSentimentDetectionJob: + return comprehend.startSentimentDetectionJob.bind(comprehend); + case ComprehendBacklogJob.ServiceApis.StartTopicsDetectionJob: + return comprehend.startTopicsDetectionJob.bind(comprehend); + default: + return undefined; + } } async startAndRegisterJob(id, serviceApi, params) { diff --git a/source/layers/service-backlog-lib/lib/client/comprehend/index.spec.js b/source/layers/service-backlog-lib/lib/client/comprehend/index.spec.js new file mode 100644 index 0000000..9fd9b74 --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/comprehend/index.spec.js @@ -0,0 +1,107 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const ComprehendBacklogJob = require('./index.js'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +jest.mock('../../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve({ jobId: params.jobId }); + }) + }; +}); + + +describe('Test ComprehendBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + }); + + test('Test startDocumentClassificationJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testDocClassification' + }; + + const response = await comprehendJob.startDocumentClassificationJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartDocumentClassificationJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startDominantLanguageDetectionJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testDominantLang' + }; + + const response = await comprehendJob.startDominantLanguageDetectionJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartDominantLanguageDetectionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startEntitiesDetectionJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testEntitiesDetect' + }; + + const response = await comprehendJob.startEntitiesDetectionJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartEntitiesDetectionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startKeyPhrasesDetectionJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testKeyPhrase' + }; + + const response = await comprehendJob.startKeyPhrasesDetectionJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartKeyPhrasesDetectionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startSentimentDetectionJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testSentimentDetect' + }; + + const response = await comprehendJob.startSentimentDetectionJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartSentimentDetectionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startTopicsDetectionJob', async () => { + const comprehendJob = new ComprehendBacklogJob(); + const params = { + jobId: 'testTopicDetect' + }; + + const response = await comprehendJob.startTopicsDetectionJob('id', params); + expect(response.serviceApi).toBe(ComprehendBacklogJob.ServiceApis.StartTopicsDetectionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/custom/index.spec.js b/source/layers/service-backlog-lib/lib/client/custom/index.spec.js new file mode 100644 index 0000000..e94a7ef --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/custom/index.spec.js @@ -0,0 +1,126 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const CustomBacklogJob = require('./index.js'); +const Retry = require('../../shared/retry'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +const mockItems = [ + { + serviceApi: CustomBacklogJob.ServiceApis.StartCustomLabelsDetection, + serviceParams: { + jobId: 'jobid' + }, + id: 'itemId' + } +]; +const mockResponseItems = jest.fn((fn, params) => { + const response = { + jobId: params.jobId, + Items: mockItems + }; + return Promise.resolve(response); +}); +const mockResponseNoItems = jest.fn((fn, params) => { + const response = { + jobId: params.jobId, + Items: [] + }; + return Promise.resolve(response); +}); +const mockUpdateDeleteItem = jest.fn((fn, params) => { + return Promise.resolve(params); +}); +const mockUpdateItemReject = jest.fn((fn, params) => { + return Promise.reject({ code: 'ConditionalCheckFailedException' }); +}); + +jest.mock('../../shared/retry', () => { + return { + run: null + }; +}); + + +describe('Test CustomBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + jest.clearAllMocks(); + }); + + test('Test startCustomLabelsDetection', async () => { + const customJob = new CustomBacklogJob(); + const params = { + jobId: 'testCustomLabels', + input: { + projectVersionArn: 'arn' + } + }; + + Retry.run = mockResponseItems; + const response = await customJob.startCustomLabelsDetection('id', params); + expect(response.serviceApi).toBe(CustomBacklogJob.ServiceApis.StartCustomLabelsDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test fetchAndStartJobs', async () => { + const customJob = new CustomBacklogJob(); + const prev = { + serviceParams: { + input: { + projectVersionArn: 'arn' + } + } + }; + + Retry.run = mockResponseItems; + let response = await customJob.fetchAndStartJobs(CustomBacklogJob.ServiceApis.StartCustomLabelsDetection, prev); + expect(response.notStarted[0]).toBe(mockItems[0]['id']); + expect(response.total).toBe(mockItems.length); + + Retry.run = mockResponseNoItems; + response = await customJob.fetchAndStartJobs(CustomBacklogJob.ServiceApis.StartCustomLabelsDetection, prev); + expect(response.total).toBe(0); + }); + + test('Test AtomicLockTable functions', async () => { + const customJob = new CustomBacklogJob(); + const item = { + serviceParams: { + input: { + projectVersionArn: 'beforeDeleteJob' + } + } + }; + + Retry.run = mockUpdateDeleteItem; + let response = await customJob.beforeDeleteJob(item, ''); + expect(response.serviceParams.input.projectVersionArn).toBe(item.serviceParams.input.projectVersionArn); + + expect(await CustomBacklogJob.updateTTL('updateTTL', 10)).toBe(true); + + Retry.run = mockUpdateItemReject; + expect(await CustomBacklogJob.updateTTL('updateTTL', 10)).toBe(false); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/index.spec.js b/source/layers/service-backlog-lib/lib/client/index.spec.js new file mode 100644 index 0000000..3ef2f1f --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/index.spec.js @@ -0,0 +1,114 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const BacklogJob = require('./backlogJob'); +const EBHelper = require('../shared/ebHelper'); + +const mockResponse = { + jobId: 'jobId', + Items: [ + { + serviceApi: 'mockServiceApi', + serviceParams: { + jobId: 'job1' + }, + id: 'item1' + }, { + serviceApi: 'mockServiceApi', + serviceParams: { + jobId: 'job2' + }, + id: 'item2' + } + ], + Attributes: { + key: 'value' + } +}; + +jest.mock('../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve(mockResponse); + }) + }; +}); + + +describe('Test BacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Test fetchAndStartJobs', async () => { + const backlog = new BacklogJob(); + const prev = { + serviceParams: { + input: { + projectVersionArn: 'arn' + } + } + }; + + let response = await backlog.fetchAndStartJobs('serviceapi', prev); + expect(response.total).toBe(mockResponse.Items.length); + + backlog.bindToFunc = jest.fn().mockReturnValue(1); //override error + response = await backlog.fetchAndStartJobs('serviceapi', prev); + expect(response.total).toBe(mockResponse.Items.length); + }); + + test('Test deleteJob', async () => { + const backlog = new BacklogJob(); + const status = 'deleteJobStatus'; + const output = { jobOutput: 'deleteJobOutput' }; + + let response = await backlog.deleteJob('id', status, output); + expect(response.status).toBe(status); + expect(response).toEqual(expect.objectContaining(output)); + expect(response).toEqual(expect.objectContaining(mockResponse.Attributes)); + }); + + test('Test startAndRegisterJob failure', async () => { + const backlog = new BacklogJob(); + + await backlog.startAndRegisterJob('id', 'serviceApi', {}).catch(error => { + expect(error.message).toBe('subclass to implement bindToFunc'); + }); + }); +}); + +describe('Test EBHelper', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test send failure', async () => { + await EBHelper.send().catch(error => { + expect(error.message).toBe('message not defined'); + }); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/mediaconvert/index.js b/source/layers/service-backlog-lib/lib/client/mediaconvert/index.js index 17a003f..6cc4bb3 100644 --- a/source/layers/service-backlog-lib/lib/client/mediaconvert/index.js +++ b/source/layers/service-backlog-lib/lib/client/mediaconvert/index.js @@ -59,16 +59,14 @@ class MediaConvertBacklogJob extends BacklogJob { : undefined; } - async startJob(serviceApi, serviceParams) { - return super.startJob(serviceApi, serviceParams) - .then(data => ({ - JobId: data.Job.Id, - })); - } - async startAndRegisterJob(id, serviceApi, params) { const serviceParams = { ...params, + /* merge user metadata */ + UserMetadata: { + ...params.UserMetadata, + backlogId: id, + }, ClientRequestToken: id, Role: DataAccess.RoleArn, }; @@ -76,7 +74,10 @@ class MediaConvertBacklogJob extends BacklogJob { } noMoreQuotasException(code) { - return false; + return ( + (code === 'TooManyRequestsException') || + (code === 'LimitExceededException') + ); } parseJobId(data) { diff --git a/source/layers/service-backlog-lib/lib/client/mediaconvert/index.spec.js b/source/layers/service-backlog-lib/lib/client/mediaconvert/index.spec.js new file mode 100644 index 0000000..eaa2a86 --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/mediaconvert/index.spec.js @@ -0,0 +1,56 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const MediaConvertBacklogJob = require('./index.js'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +jest.mock('../../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve({ + Job: { + Id: params.jobId + } + }); + }) + }; +}); + + +describe('Test MediaConvertBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + }); + + test('Test createJob', async () => { + const mediaConvertJob = new MediaConvertBacklogJob(); + const params = { + jobId: 'testCreateJob' + }; + + const response = await mediaConvertJob.createJob('id', params); + expect(response.serviceApi).toBe(MediaConvertBacklogJob.ServiceApis.CreateJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/rekognition/index.js b/source/layers/service-backlog-lib/lib/client/rekognition/index.js index 8456a32..da178df 100644 --- a/source/layers/service-backlog-lib/lib/client/rekognition/index.js +++ b/source/layers/service-backlog-lib/lib/client/rekognition/index.js @@ -110,23 +110,26 @@ class RekognitionBacklogJob extends BacklogJob { bindToFunc(serviceApi) { const rekog = this.getRekognitionInstance(); - return (serviceApi === RekognitionBacklogJob.ServiceApis.StartCelebrityRecognition) - ? rekog.startCelebrityRecognition.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartContentModeration) - ? rekog.startContentModeration.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartFaceDetection) - ? rekog.startFaceDetection.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartFaceSearch) - ? rekog.startFaceSearch.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartLabelDetection) - ? rekog.startLabelDetection.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartPersonTracking) - ? rekog.startPersonTracking.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartSegmentDetection) - ? rekog.startSegmentDetection.bind(rekog) - : (serviceApi === RekognitionBacklogJob.ServiceApis.StartTextDetection) - ? rekog.startTextDetection.bind(rekog) - : undefined; + switch (serviceApi) { + case RekognitionBacklogJob.ServiceApis.StartCelebrityRecognition: + return rekog.startCelebrityRecognition.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartContentModeration: + return rekog.startContentModeration.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartFaceDetection: + return rekog.startFaceDetection.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartFaceSearch: + return rekog.startFaceSearch.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartLabelDetection: + return rekog.startLabelDetection.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartPersonTracking: + return rekog.startPersonTracking.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartSegmentDetection: + return rekog.startSegmentDetection.bind(rekog); + case RekognitionBacklogJob.ServiceApis.StartTextDetection: + return rekog.startTextDetection.bind(rekog); + default: + return undefined; + } } async startAndRegisterJob(id, serviceApi, params) { diff --git a/source/layers/service-backlog-lib/lib/client/rekognition/index.spec.js b/source/layers/service-backlog-lib/lib/client/rekognition/index.spec.js new file mode 100644 index 0000000..efc1dbf --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/rekognition/index.spec.js @@ -0,0 +1,129 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const RekognitionBacklogJob = require('./index.js'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +jest.mock('../../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve({ jobId: params.jobId }); + }) + }; +}); + + +describe('Test RekognitionBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + }); + + test('Test startCelebrityRecognition', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testCelebRekog' + }; + + const response = await rekognitionJob.startCelebrityRecognition('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartCelebrityRecognition); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startContentModeration', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testContentMod' + }; + + const response = await rekognitionJob.startContentModeration('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartContentModeration); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startFaceDetection', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testFaceDetect' + }; + + const response = await rekognitionJob.startFaceDetection('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartFaceDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startFaceSearch', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testFaceSearch' + }; + + const response = await rekognitionJob.startFaceSearch('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartFaceSearch); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startLabelDetection', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testLabelDetect' + }; + + const response = await rekognitionJob.startLabelDetection('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartLabelDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startPersonTracking', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testPersonTrack' + }; + + const response = await rekognitionJob.startPersonTracking('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartPersonTracking); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startSegmentDetection', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testSegmentDetect' + }; + + const response = await rekognitionJob.startSegmentDetection('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartSegmentDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startTextDetection', async () => { + const rekognitionJob = new RekognitionBacklogJob(); + const params = { + jobId: 'testTextDetect' + }; + + const response = await rekognitionJob.startTextDetection('id', params); + expect(response.serviceApi).toBe(RekognitionBacklogJob.ServiceApis.StartTextDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/textract/index.js b/source/layers/service-backlog-lib/lib/client/textract/index.js index 37ef9f4..df71e56 100644 --- a/source/layers/service-backlog-lib/lib/client/textract/index.js +++ b/source/layers/service-backlog-lib/lib/client/textract/index.js @@ -56,11 +56,14 @@ class TextractBacklogJob extends BacklogJob { bindToFunc(serviceApi) { const textract = this.getTextractInstance(); - return (serviceApi === TextractBacklogJob.ServiceApis.StartDocumentAnalysis) - ? textract.startDocumentAnalysis.bind(textract) - : (serviceApi === TextractBacklogJob.ServiceApis.StartDocumentTextDetection) - ? textract.startDocumentTextDetection.bind(textract) - : undefined; + switch (serviceApi) { + case TextractBacklogJob.ServiceApis.StartDocumentAnalysis: + return textract.startDocumentAnalysis.bind(textract); + case TextractBacklogJob.ServiceApis.StartDocumentTextDetection: + return textract.startDocumentTextDetection.bind(textract); + default: + return undefined; + } } async startAndRegisterJob(id, serviceApi, params) { diff --git a/source/layers/service-backlog-lib/lib/client/textract/index.spec.js b/source/layers/service-backlog-lib/lib/client/textract/index.spec.js new file mode 100644 index 0000000..9773776 --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/textract/index.spec.js @@ -0,0 +1,63 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const TextractBacklogJob = require('./index.js'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +jest.mock('../../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve({ jobId: params.jobId }); + }) + }; +}); + + +describe('Test TextractBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + }); + + test('Test startDocumentAnalysis', async () => { + const textractJob = new TextractBacklogJob(); + const params = { + jobId: 'testDocAnalysis' + }; + + const response = await textractJob.startDocumentAnalysis('id', params); + expect(response.serviceApi).toBe(TextractBacklogJob.ServiceApis.StartDocumentAnalysis); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startDocumentTextDetection', async () => { + const textractJob = new TextractBacklogJob(); + const params = { + jobId: 'testDocTextDetect' + }; + + const response = await textractJob.startDocumentTextDetection('id', params); + expect(response.serviceApi).toBe(TextractBacklogJob.ServiceApis.StartDocumentTextDetection); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/client/transcribe/index.js b/source/layers/service-backlog-lib/lib/client/transcribe/index.js index 0ba520c..5d99491 100644 --- a/source/layers/service-backlog-lib/lib/client/transcribe/index.js +++ b/source/layers/service-backlog-lib/lib/client/transcribe/index.js @@ -24,8 +24,8 @@ const BacklogJob = require('../backlogJob'); class TranscribeBacklogJob extends BacklogJob { static get ServiceApis() { return { - StartMedicalTranscriptionJob: 'transcribe:startMedicalTranscriptionJob', - StartTranscriptionJob: 'transcribe:startTranscriptionJob', + StartMedicalTranscriptionJob: 'transcribe:startmedicaltranscriptionjob', + StartTranscriptionJob: 'transcribe:starttranscriptionjob', }; } @@ -58,11 +58,14 @@ class TranscribeBacklogJob extends BacklogJob { bindToFunc(serviceApi) { const transcribe = this.getTranscribeInstance(); - return (serviceApi === TranscribeBacklogJob.ServiceApis.StartMedicalTranscriptionJob) - ? transcribe.startMedicalTranscriptionJob.bind(transcribe) - : (serviceApi === TranscribeBacklogJob.ServiceApis.StartTranscriptionJob) - ? transcribe.startTranscriptionJob.bind(transcribe) - : undefined; + switch (serviceApi) { + case TranscribeBacklogJob.ServiceApis.StartMedicalTranscriptionJob: + return transcribe.startMedicalTranscriptionJob.bind(transcribe); + case TranscribeBacklogJob.ServiceApis.StartTranscriptionJob: + return transcribe.startTranscriptionJob.bind(transcribe); + default: + return undefined; + } } async startAndRegisterJob(id, serviceApi, params) { @@ -86,11 +89,7 @@ class TranscribeBacklogJob extends BacklogJob { if (response instanceof Error) { response = await this.testJob(serviceApi, serviceParams, response); } - return { - JobId: (response.TranscriptionJob) - ? response.TranscriptionJob.TranscriptionJobName - : response.MedicalTranscriptionJob.MedicalTranscriptionJobName, - }; + return response; } async testJob(serviceApi, serviceParams, originalError) { @@ -131,6 +130,11 @@ class TranscribeBacklogJob extends BacklogJob { conflictException(code) { return (code === 'ConflictException'); } + + parseJobId(data) { + return (data.TranscriptionJob || {}).TranscriptionJobName + || (data.MedicalTranscriptionJob || {}).MedicalTranscriptionJobName; + } } module.exports = TranscribeBacklogJob; diff --git a/source/layers/service-backlog-lib/lib/client/transcribe/index.spec.js b/source/layers/service-backlog-lib/lib/client/transcribe/index.spec.js new file mode 100644 index 0000000..7b9de3a --- /dev/null +++ b/source/layers/service-backlog-lib/lib/client/transcribe/index.spec.js @@ -0,0 +1,91 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const TranscribeBacklogJob = require('./index.js'); +const AWS = require('aws-sdk-mock'); +const SDK = require('aws-sdk'); +AWS.setSDKInstance(SDK); + +jest.mock('../../shared/retry', () => { + return { + run: jest.fn((fn, params) => { + return Promise.resolve({ jobId: params.jobId }); + }) + }; +}); + + +describe('Test TranscribeBacklogJob', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + AWS.mock('DynamoDB.Converter', 'unmarshall', Promise.resolve({ serviceApi: 'test' })); + AWS.mock('TranscribeService', 'getTranscriptionJob', Promise.resolve({ + TranscriptionJob: { + TranscriptionJobStatus: 'COMPLETED' + } + })); + AWS.mock('TranscribeService', 'getMedicalTranscriptionJob', Promise.resolve({ + MedicalTranscriptionJob: { + TranscriptionJobStatus: 'COMPLETED' + } + })); + }); + + afterEach(() => { + AWS.restore('DynamoDB.Converter'); + AWS.restore('TranscribeService'); + }); + + test('Test startMedicalTranscriptionJob', async () => { + const transcribeJob = new TranscribeBacklogJob(); + const params = { + jobId: 'testMedicalTranscription' + }; + + const response = await transcribeJob.startMedicalTranscriptionJob('id', params); + expect(response.serviceApi).toBe(TranscribeBacklogJob.ServiceApis.StartMedicalTranscriptionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test startTranscriptionJob', async () => { + const transcribeJob = new TranscribeBacklogJob(); + const params = { + jobId: 'testTranscription' + }; + + const response = await transcribeJob.startTranscriptionJob('id', params); + expect(response.serviceApi).toBe(TranscribeBacklogJob.ServiceApis.StartTranscriptionJob); + expect(response.serviceParams.jobId).toBe(params.jobId); + }); + + test('Test testJob', async () => { + const transcribeJob = new TranscribeBacklogJob(); + const params = { + jobId: 'testTranscription', + TranscriptionJobName: 'jobName', + MedicalTranscriptionJobName: 'jobName' + }; + + await transcribeJob.testJob(TranscribeBacklogJob.ServiceApis.StartTranscriptionJob, params, { code: 'ConflictException' }).catch(error => { + expect(error['code']).toBe('ConflictException'); + }); + + await transcribeJob.testJob(TranscribeBacklogJob.ServiceApis.StartMedicalTranscriptionJob, params, { code: 'ConflictException' }).catch(error => { + expect(error['code']).toBe('ConflictException'); + }); + }); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/lib/shared/index.spec.js b/source/layers/service-backlog-lib/lib/shared/index.spec.js new file mode 100644 index 0000000..2457ae6 --- /dev/null +++ b/source/layers/service-backlog-lib/lib/shared/index.spec.js @@ -0,0 +1,36 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const Retry = require('./retry'); + +describe('Test Retry', () => { + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + }); + + test('Test run', async () => { + const errorObject = new Error(); + errorObject.code = 'ProvisionedThroughputExceededException'; + let fn = (params) => { + return Promise.reject(errorObject); + }; + + await Retry.run(fn, {}).catch(error => { + expect(error).toBe(errorObject); + }); + }, 30000); +}); \ No newline at end of file diff --git a/source/layers/service-backlog-lib/package.json b/source/layers/service-backlog-lib/package.json index 72bcc5a..a94288f 100644 --- a/source/layers/service-backlog-lib/package.json +++ b/source/layers/service-backlog-lib/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "echo \"service-backlog-lib library. skipping unit test...\"", + "test": "jest --coverage", "build:clean": "rm -rf dist && mkdir -p dist/nodejs/node_modules/service-backlog-lib", "build:copy": "cp -rv index.js package.json lib dist/nodejs/node_modules/service-backlog-lib", "build:install": "cd dist/nodejs/node_modules/service-backlog-lib && npm install --only=prod --no-optional", @@ -14,5 +14,9 @@ "zip": "cd dist && zip -rq" }, "author": "aws-specialist-sa-emea", - "devDependencies": {} + "devDependencies": { + "jest": "^29.3.1", + "aws-sdk": "2.831.0", + "aws-sdk-mock": "5.8.0" + } } diff --git a/source/layers/service-backlog-lib/setEnvVars.js b/source/layers/service-backlog-lib/setEnvVars.js new file mode 100644 index 0000000..0566420 --- /dev/null +++ b/source/layers/service-backlog-lib/setEnvVars.js @@ -0,0 +1,7 @@ +process.env.ENV_BACKLOG_TOPIC_ARN = 'topicArn'; +process.env.ENV_BACKLOG_TOPIC_ROLE_ARN = 'roleArn'; +process.env.ENV_BACKLOG_TABLE = 'tableName'; +process.env.ENV_BACKLOG_EB_BUS = 'eventBus'; +process.env.ENV_DATA_ACCESS_ROLE = 'dataAccessRole'; +process.env.ENV_MEDIACONVERT_HOST = 'mediaConvertEndpoint'; +process.env.ENV_ATOMICLOCK_TABLE = 'atomicLockTable'; \ No newline at end of file diff --git a/source/main/.babelrc b/source/main/.babelrc new file mode 100644 index 0000000..78e681f --- /dev/null +++ b/source/main/.babelrc @@ -0,0 +1,7 @@ +{ + "env": { + "test": { + "plugins": ["@babel/plugin-transform-modules-commonjs"] + } + } + } \ No newline at end of file diff --git a/source/main/analysis/audio/README.md b/source/main/analysis/audio/README.md index 00ac07c..9a53ec5 100644 --- a/source/main/analysis/audio/README.md +++ b/source/main/analysis/audio/README.md @@ -201,7 +201,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:PutItem", diff --git a/source/main/analysis/audio/index.js b/source/main/analysis/audio/index.js index 99a19be..c06283e 100644 --- a/source/main/analysis/audio/index.js +++ b/source/main/analysis/audio/index.js @@ -94,6 +94,8 @@ function parseEvent(event, context) { return new StateData(stateMachine, parsed, context); } +exports.parseEvent = parseEvent; + exports.handler = async (event, context) => { console.log(`event = ${JSON.stringify(event, null, 2)}; context = ${JSON.stringify(context, null, 2)};`); diff --git a/source/main/analysis/audio/index.spec.js b/source/main/analysis/audio/index.spec.js new file mode 100644 index 0000000..ef27b6b --- /dev/null +++ b/source/main/analysis/audio/index.spec.js @@ -0,0 +1,1148 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const { + Environment, + StateData, + AnalysisError, +} = require('core-lib'); + +// All the libraries to be tested +/* transcribe */ +const StateStartTranscribe = require('./states/start-transcribe'); +const StateCollectTranscribeResults = require('./states/collect-transcribe-results'); +const StateIndexTranscribeResults = require('./states/index-transcribe-results'); +/* comprehend entity */ +const StateStartEntity = require('./states/start-entity'); +const StateIndexEntityResults = require('./states/index-entity-results'); +/* comprehend keyphrase */ +const StateStartKeyphrase = require('./states/start-keyphrase'); +const StateIndexKeyphraseResults = require('./states/index-keyphrase-results'); +/* comprehend sentiment */ +const StateStartSentiment = require('./states/start-sentiment'); +const StateIndexSentimentResults = require('./states/index-sentiment-results'); +/* comprehend custom entity */ +const StateCheckCustomEntityCriteria = require('./states/check-custom-entity-criteria'); +const StateStartCustomEntity = require('./states/start-custom-entity'); +const StateCheckCustomEntityStatus = require('./states/check-custom-entity-status'); +const StateCreateCustomEntityTrack = require('./states/create-custom-entity-track'); +const StateIndexCustomEntityResults = require('./states/index-custom-entity-results'); +/* job completed */ +const StateJobCompleted = require('./states/job-completed'); + +const lambda = require('./index.js'); +// const parseEvent = + +const event_start_transcribe = { + parallelStateOutputs: false, + stateExecution: { + Input: { + input: 'fake_input', + uuid: "88298a59-e68d-4c87-9973-afe5877e9d39" + }, + StartTime: '0:00', + Id: 'my_id', + operation: "collect-transcribe-results", + status: "NOT_STARTED", + progress: 0 + } +} + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + +const event_for_collect_transcribe_results = { + "uuid": "b72fc9c0-58eb-83ef-42f2-dfceb342798f", + "stateMachine": "so0050-0a709c9ee415-analysis-audio", + "operation": "collect-transcribe-results", + "overallStatus": "PROCESSING", + "status": "NO_DATA", + "progress": 100, + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671613689920 + }, + "framerate": 59.874, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "b72fc9c0-58eb-83ef-42f2-dfceb342798f/surfers/" + }, + "video": { + "enabled": true, + "key": "b72fc9c0-58eb-83ef-42f2-dfceb342798f/surfers/transcode/aiml/surfers.mp4" + }, + "uuid": "b72fc9c0-58eb-83ef-42f2-dfceb342798f", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 34273, + "attributes": {}, + "audio": { + "enabled": true, + "key": "b72fc9c0-58eb-83ef-42f2-dfceb342798f/surfers/transcode/aiml/surfers.m4a" + }, + "metrics": { + "duration": 34273, + "requestTime": 1671613689920, + "startTime": 1671613691717 + }, + "key": "surfers/surfers.mp4", + "aiOptions": { + "sentiment": false, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "facematch": false, + "transcribe": true, + "face": false, + "customentity": false, + "person": false, + "minConfidence": 80, + "textract": true, + "moderation": false, + "segment": true, + "customlabel": false, + "text": false, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "b72fc9c0-58eb-83ef-42f2-dfceb342798f/surfers/raw/20221221T090809/transcribe/", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_b72fc9c0-58eb-83ef-42f2-dfceb342798f_5cfa9a20f7465a80", + "startTime": 1671613693615, + "endTime": 1671613699787, + "status": "NO_DATA", + "errorMessage": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_b72fc9c0-58eb-83ef-42f2-dfceb342798f_5cfa9a20f7465a80: Your audio file must have a speech segment long enough in duration to perform automatic language identification. Provide an audio file with someone speaking for a longer period of time and try your request again.;" + } + } +} + +const event_for_index_transcribe_results = { + "operation": "index-transcribe-results", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_start_transcribe_and_wait = { + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateCollectTranscribeResults = { + "operation": "collect-transcribe-results", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693446571 + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateIndexTranscribeResults = { + "operation": "index-transcribe-results", + "status": "STARTED", + "progress": 0, + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateStartSentiment = { + "status": "NOT_STARTED", + "operation": "start-sentiment", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateIndexSentimentResults = { + "status": "NOT_STARTED", + "progress": 0, + "operation": "index-sentiment-results", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + }, + "comprehend": { + "sentiment": { + "startTime": 1671693449896, + "endTime": 1671693450270, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/comprehend/sentiment/output.manifest", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/sentiment/output.json" + } + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateStartKeyphrase = { + "status": "NOT_STARTED", + "operation": "start-keyphrase", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateIndexKeyphraseResults = { + "status": "NOT_STARTED", + "progress": 0, + "operation": "index-keyphrase-results", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + }, + "comprehend": { + "keyphrase": { + "startTime": 1671693451052, + "endTime": 1671693451949, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/comprehend/keyphrase/output.manifest", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/keyphrase/output.json" + } + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateStartEntity = { + "status": "NOT_STARTED", + "operation": "start-entity", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const event_StateIndexEntityResults = { + "status": "NOT_STARTED", + "progress": 0, + "operation": "index-entity-results", + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + }, + "comprehend": { + "entity": { + "startTime": 1671693450998, + "endTime": 1671693451803, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/comprehend/entity/output.manifest", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/entity/output.json" + } + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + + describe('Main / Analysis / Audio', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + // These env varialbes are not needed to test functionality + process.env = { + ENV_SOLUTION_ID: 'Test_ENV_Variable', + ENV_RESOURCE_PREFIX: 'Test_ENV_Variable', + ENV_SOLUTION_UUID: 'Test_ENV_Variable', + ENV_ANONYMOUS_USAGE: 'Test_ENV_Variable', + ENV_IOT_HOST: 'Test_ENV_Variable', + ENV_IOT_TOPIC: 'Test_ENV_Variable', + ENV_PROXY_BUCKET: 'Test_ENV_Variable', + ENV_DATA_ACCESS_ROLE: 'Test_ENV_Variable', + ENV_ES_DOMAIN_ENDPOINT: 'Test_ENV_Variable' + }; + }); + + test('Test main lambda handler is able to complete ', async () => { + + const response = await lambda.handler(event_for_collect_transcribe_results, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('b72fc9c0-58eb-83ef-42f2-dfceb342798f'); + }); + test('Test the Start transcribe and wait state', async () => { + const stateData = lambda.parseEvent(event_start_transcribe_and_wait, context); + + let instance = new StateStartTranscribe(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the Collect transcribe results state', async () => { + const stateData = lambda.parseEvent(event_StateCollectTranscribeResults, context); + + let instance = new StateCollectTranscribeResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the Index transcribe results state', async () => { + const stateData = lambda.parseEvent(event_StateIndexTranscribeResults, context); + + let instance = new StateIndexTranscribeResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateStartSentiment', async () => { + const stateData = lambda.parseEvent(event_StateStartSentiment, context); + + let instance = new StateStartSentiment(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateIndexSentimentResults', async () => { + const stateData = lambda.parseEvent(event_StateIndexSentimentResults, context); + + let instance = new StateIndexSentimentResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + test('Test StateStartKeyphrase', async () => { + const stateData = lambda.parseEvent(event_StateStartKeyphrase, context); + + let instance = new StateStartKeyphrase(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + test('Test StateIndexKeyphraseResults', async () => { + const stateData = lambda.parseEvent(event_StateIndexKeyphraseResults, context); + + let instance = new StateIndexKeyphraseResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateStartEntity', async () => { + const stateData = lambda.parseEvent(event_StateStartEntity, context); + + let instance = new StateStartEntity(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + test('Test StateIndexEntityResults', async () => { + const stateData = lambda.parseEvent(event_StateIndexEntityResults, context); + + let instance = new StateIndexEntityResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + +// /* comprehend entity */ +// /* comprehend keyphrase */ +// /* comprehend sentiment */ + +// /* comprehend custom entity */ +// const StateCheckCustomEntityCriteria = require('./states/check-custom-entity-criteria'); +// const StateStartCustomEntity = require('./states/start-custom-entity'); +// const StateCheckCustomEntityStatus = require('./states/check-custom-entity-status'); +// const StateCreateCustomEntityTrack = require('./states/create-custom-entity-track'); +// const StateIndexCustomEntityResults = require('./states/index-custom-entity-results'); +// /* job completed */ +// const StateJobCompleted = require('./states/job-completed'); + + // test('Analysis Audio State Machine: Index transcribe results', async () => { + // const Environment = jest.fn(); + // Environment.mockImplementation(() => { + // return { + // Elasticsearch: { + // DomainEndpoint: jest.fn().mockReturnValue('https://endpoint.example.com'), + // } + // }; + // }); + + // const response = await lambda.handler(event_for_collect_transcribe_results, context); + // // Expected JSON response with correct ID. + // expect(response.uuid).toBe('b72fc9c0-58eb-83ef-42f2-dfceb342798f'); + // }); + + // test('Analysis Audio State Machine: Index entity results', async () => { + + +}); + + diff --git a/source/main/analysis/audio/jest.config.js b/source/main/analysis/audio/jest.config.js new file mode 100644 index 0000000..d0a7808 --- /dev/null +++ b/source/main/analysis/audio/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageDirectory: "../../../test/coverage-reports/jest/layers/main/analysis/audio/", + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/audio/package.json b/source/main/analysis/audio/package.json index 76e704c..4896337 100644 --- a/source/main/analysis/audio/package.json +++ b/source/main/analysis/audio/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "service-backlog-lib": "file:../../../layers/service-backlog-lib" + "service-backlog-lib": "file:../../../layers/service-backlog-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/analysis/audio/setEnvVars.js b/source/main/analysis/audio/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/analysis/audio/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/audio/states/check-custom-entity-status/index.js b/source/main/analysis/audio/states/check-custom-entity-status/index.js index 8c91d4f..90a2ee9 100644 --- a/source/main/analysis/audio/states/check-custom-entity-status/index.js +++ b/source/main/analysis/audio/states/check-custom-entity-status/index.js @@ -60,11 +60,14 @@ class StateCheckCustomEntityStatus extends BaseStateStartComprehend { }).promise(); const jobStatus = response.EntitiesDetectionJobProperties.JobStatus; - return (STATUS_PROCESSING.indexOf(jobStatus) >= 0) - ? this.onProgress(response) - : (STATUS_FAILED.indexOf(jobStatus) >= 0) - ? this.onError(response) - : this.onCompleted(response); + if (STATUS_PROCESSING.indexOf(jobStatus) >= 0) { + return this.onProgress(response); + } + if (STATUS_FAILED.indexOf(jobStatus) >= 0) { + return this.onError(response); + } + + return this.onCompleted(response); } async onCompleted(data) { diff --git a/source/main/analysis/audio/states/index-transcribe-results/index.js b/source/main/analysis/audio/states/index-transcribe-results/index.js index 86e64d1..5e17250 100644 --- a/source/main/analysis/audio/states/index-transcribe-results/index.js +++ b/source/main/analysis/audio/states/index-transcribe-results/index.js @@ -1,10 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - +const PATH = require('path'); const { AnalysisTypes, AnalysisError, NodeWebVtt, + CommonUtils, } = require('core-lib'); const BaseStateIndexer = require('../shared/baseStateIndexer'); @@ -12,6 +13,7 @@ const SUB_CATEGORY = AnalysisTypes.Transcribe; class StateIndexTranscribeResults extends BaseStateIndexer { constructor(stateData) { super(stateData, SUB_CATEGORY); + this.$sanitizedVtt = undefined; } get [Symbol.toStringTag]() { @@ -22,6 +24,32 @@ class StateIndexTranscribeResults extends BaseStateIndexer { return this.stateData.data[SUB_CATEGORY].vtt; } + get sanitizedVtt() { + return this.$sanitizedVtt; + } + + set sanitizedVtt(val) { + this.$sanitizedVtt = val; + } + + async process() { + const response = await super.process(); + await this.workaroundInvalidVttTimestamps(); + return response; + } + + async workaroundInvalidVttTimestamps() { + /* workaround invalid timestamp WebVTT file generated by Amazon Transcribe */ + if (this.sanitizedVtt !== undefined) { + const bucket = this.stateData.input.destination.bucket; + const key = this.dataKey; + const parsed = PATH.parse(key); + await CommonUtils.uploadFile(bucket, parsed.dir, parsed.base, this.sanitizedVtt) + .catch(() => + undefined); + } + } + parseDataset(datasets) { if (!datasets) { return undefined; @@ -50,10 +78,31 @@ class StateIndexTranscribeResults extends BaseStateIndexer { } parseWebVtt(vtt) { - const parsed = NodeWebVtt.parse(vtt); + const parsed = NodeWebVtt.parse(vtt, { + meta: true, + strict: false, + }); if (!parsed.valid) { throw new AnalysisError('failed to parse vtt'); } + + /* fixing the webvtt */ + const cues = []; + cues.push(parsed.cues.shift()); + while (parsed.cues.length) { + const cue = parsed.cues.shift(); + if (cue.start > 0 && cue.end > 0 && (cue.end - cue.start) > 0) { + cues.push(cue); + } + } + if (cues.length !== parsed.cues.length) { + parsed.cues = cues.map((cue, idx) => ({ + ...cue, + identifier: String(idx), + })); + this.sanitizedVtt = NodeWebVtt.compile(parsed); + } + parsed.cues = parsed.cues.map((cue) => { const lines = cue.text.split('\n').map((x) => { const matched = x.match(/^--\s(.*)/); diff --git a/source/main/analysis/audio/states/start-transcribe/index.js b/source/main/analysis/audio/states/start-transcribe/index.js index 89f80ce..d86064b 100644 --- a/source/main/analysis/audio/states/start-transcribe/index.js +++ b/source/main/analysis/audio/states/start-transcribe/index.js @@ -1,14 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - -const AWS = (() => { - try { - const AWSXRay = require('aws-xray-sdk'); - return AWSXRay.captureAWS(require('aws-sdk')); - } catch (e) { - return require('aws-sdk'); - } -})(); const PATH = require('path'); const CRYPTO = require('crypto'); const { @@ -18,8 +9,14 @@ const { CommonUtils, ServiceToken, } = require('core-lib'); +const { + BacklogClient: { + TranscribeBacklogJob, + }, +} = require('service-backlog-lib'); const CATEGORY = 'transcribe'; +const JOBNAME_MAXLEN = 200; class StateStartTranscribe { constructor(stateData) { @@ -39,23 +36,30 @@ class StateStartTranscribe { async process() { const params = await this.makeParams(); + const id = params.TranscriptionJobName; + + /* start transcription job */ + const transcribe = new TranscribeBacklogJob(); + await transcribe.startTranscriptionJob(id, params); + this.stateData.setData(CATEGORY, { jobId: params.TranscriptionJobName, output: params.OutputKey, startTime: new Date().getTime(), }); this.stateData.setStarted(); + /* register token to dynamodb table */ + const stateData = this.stateData.toJSON(); await ServiceToken.register( params.TranscriptionJobName, this.stateData.event.token, CATEGORY, CATEGORY, - this.stateData.toJSON() + stateData ); - /* start transcribe */ - await this.retryStartTranscriptionJob(params); - return this.stateData.toJSON(); + + return stateData; } async makeParams() { @@ -66,6 +70,7 @@ class StateStartTranscribe { } const aiOptions = this.stateData.input.aiOptions; const id = this.makeUniqueJobName(); + const mediaFileUri = `s3://${PATH.join(bucket, key)}`; const outPrefix = this.makeOutputPrefix(); const modelSettings = (aiOptions.customLanguageModel) ? { @@ -82,7 +87,7 @@ class StateStartTranscribe { return { TranscriptionJobName: id, Media: { - MediaFileUri: undefined, + MediaFileUri: mediaFileUri, }, JobExecutionSettings: { AllowDeferredExecution: true, @@ -105,7 +110,17 @@ class StateStartTranscribe { } makeUniqueJobName() { - return `${Environment.Solution.Metrics.Uuid}_${this.stateData.uuid}_${CRYPTO.randomBytes(8).toString('hex')}`; + /* https://docs.aws.amazon.com/transcribe/latest/APIReference/API_StartTranscriptionJob.html#transcribe-StartTranscriptionJob-request-TranscriptionJobName */ + const solutionUuid = Environment.Solution.Metrics.Uuid; + const randomId = CRYPTO.randomBytes(4).toString('hex'); + const maxLen = JOBNAME_MAXLEN - solutionUuid.length - randomId.length - 2; + let name = PATH.parse(this.stateData.input.audio.key).name; + name = name.replace(/[^0-9a-zA-Z._-]/g, '').slice(0, maxLen); + return [ + solutionUuid, + name, + randomId, + ].join('_'); } makeOutputPrefix() { @@ -122,49 +137,6 @@ class StateStartTranscribe { } return prefix; } - - async retryStartTranscriptionJob(data) { - const bucket = this.stateData.input.destination.bucket; - const key = this.stateData.input.audio.key; - const hostname = [ - (process.env.AWS_REGION === 'us-east-1') ? 's3' : `s3-${process.env.AWS_REGION}`, - 'amazonaws.com', - ].join('.'); - const attempts = [ - `s3://${bucket}/${key}`, - // TODO: TO REMOVE! - `https://${hostname}/${bucket}/${key}`, - `https://${hostname}/${bucket}/${encodeURIComponent(key)}`, - `https://${hostname}/${bucket}/${encodeURIComponent(key)}`.replace(/%20/g, '+'), - `https://${hostname}/${bucket}/${encodeURI(key)}`, - `https://${hostname}/${bucket}/${encodeURI(key)}`.replace(/%20/g, '+'), - ]; - - const transcribe = new AWS.TranscribeService({ - apiVersion: '2017-10-26', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - }); - let response; - while (attempts.length) { - const uri = attempts.shift(); - const params = { - ...data, - Media: { - MediaFileUri: uri, - }, - }; - response = await transcribe.startTranscriptionJob(params).promise().catch(e => e); - console.log(JSON.stringify(response, null, 2)); - if (!(response instanceof Error)) { - console.log(`startTranscriptionJob(${this.stateData.uuid}) = ${JSON.stringify(params, null, 2)}`); - break; - } - } - if (response instanceof Error) { - throw response; - } - return response; - } } module.exports = StateStartTranscribe; diff --git a/source/main/analysis/automation/status-updater/README.md b/source/main/analysis/automation/status-updater/README.md index d2b35c6..7b29fb8 100644 --- a/source/main/analysis/automation/status-updater/README.md +++ b/source/main/analysis/automation/status-updater/README.md @@ -34,7 +34,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/analysis/automation/status-updater/index.spec.js b/source/main/analysis/automation/status-updater/index.spec.js new file mode 100644 index 0000000..f90a04c --- /dev/null +++ b/source/main/analysis/automation/status-updater/index.spec.js @@ -0,0 +1,151 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +const { + Environment, + StateData, + AnalysisError, +} = require('core-lib'); + +const lambda = require('./index.js'); + + +const event_for_index_transcribe_results = { + "operation": "index-transcribe-results", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "image": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "framerate": 29.97, + "document": { + "enabled": false + }, + "destination": { + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "bucket": "so0050-0a709c9ee415-193234372883-us-west-2-ingest", + "duration": 284653, + "attributes": {}, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + }, + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "transcribe": { + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.json", + "jobId": "cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c", + "startTime": 1671693346948, + "endTime": 1671693449247, + "languageCode": "en-US", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/transcribe/cee070fe-d7e5-cde0-1c51-d0cc6dbf59a9_ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8_34009be0978b435c.vtt" + } + }, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Automation::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + // These env varialbes are not needed to test functionality + process.env = { + ENV_SOLUTION_ID: 'Test_ENV_Variable', + ENV_RESOURCE_PREFIX: 'Test_ENV_Variable', + ENV_SOLUTION_UUID: 'Test_ENV_Variable', + ENV_ANONYMOUS_USAGE: 'Test_ENV_Variable', + ENV_IOT_HOST: 'Test_ENV_Variable', + ENV_IOT_TOPIC: 'Test_ENV_Variable', + ENV_PROXY_BUCKET: 'Test_ENV_Variable', + ENV_DATA_ACCESS_ROLE: 'Test_ENV_Variable', + ENV_ES_DOMAIN_ENDPOINT: 'Test_ENV_Variable' + }; + }); + + + test('Automation State Machine: Test instance filter ', async () => { + + lambda.CloudWatchStatus = jest.fn().mockImplementation(() => 'hello'); + + const response = await lambda.handler(event_for_index_transcribe_results, context); + // Expected JSON response with correct ID. + console.log(response) + expect(response.uuid).toBe('b72fc9c0-58eb-83ef-42f2-dfceb342798f'); + }); + + + + +}); + + diff --git a/source/main/analysis/automation/status-updater/jest.config.js b/source/main/analysis/automation/status-updater/jest.config.js new file mode 100644 index 0000000..c3d4801 --- /dev/null +++ b/source/main/analysis/automation/status-updater/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageDirectory: "../../../../test/coverage-reports/jest/layers/main/analysis/automation/status-updater/", + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/backlogStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/backlogStatusChangeEvent.js index 3933098..32f917c 100644 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/backlogStatusChangeEvent.js +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/backlogStatusChangeEvent.js @@ -68,19 +68,20 @@ class BacklogStatusChangeEvent { return this.detail.jobId; } - get jobStatus() { - return this.detail.jobStatus; + get errorMessage() { + return this.detail.errorMessage; } async process() { if (this.status !== STATUS_SUCCEEDED && this.status !== STATUS_FAILED) { - console.error(`ERR: ${this.status} status not handled`); + console.error(`BacklogStatusChangeEvent.process: ${this.status} status not handled`); return undefined; } /* #1: get state data from service token table */ - const response = await ServiceToken.getData(this.backlogId).catch(() => undefined); - + const response = await ServiceToken.getData(this.backlogId) + .catch(() => + undefined); if (!response || !response.service || !response.token || !response.api) { throw new JobStatusError(`fail to get token, ${this.backlogId}`); } @@ -98,17 +99,21 @@ class BacklogStatusChangeEvent { this.token = response.token; /* #3: send task result to state machine execution */ - if (this.jobStatus === STATUS_SUCCEEDED) { + if (this.status === STATUS_SUCCEEDED) { this.stateData.setCompleted(); await this.parent.sendTaskSuccess(); - } else if (this.jobStatus === STATUS_FAILED) { - const error = new JobStatusError(`${this.jobId} ${this.jobStatus}`); + } else if (this.status === STATUS_FAILED) { + const error = (this.errorMessage) + ? new JobStatusError(this.errorMessage) + : new JobStatusError(`${this.jobId} ${this.status}`); this.stateData.setFailed(error); await this.parent.sendTaskFailure(error); } /* #4: remove record from service token table */ - await ServiceToken.unregister(this.backlogId).catch(() => undefined); + await ServiceToken.unregister(this.backlogId) + .catch(() => + undefined); return this.stateData.toJSON(); } } diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/comprehendStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/comprehendStatusChangeEvent.js index 39d125d..a0e133a 100644 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/comprehendStatusChangeEvent.js +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/comprehendStatusChangeEvent.js @@ -14,7 +14,7 @@ const STATUS_PROCESSING = 'PROCESSING'; class ComprehendStatusChangeEvent extends BacklogStatusChangeEvent { async process() { if (this.status !== STATUS_PROCESSING) { - console.error(`ERR: ComprehendStatusChangeEvent.process: ${this.status} status not handled`); + console.error(`ComprehendStatusChangeEvent.process: ${this.status} status not handled`); return undefined; } /* #1: get state data from service token table */ diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/customLabelsStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/customLabelsStatusChangeEvent.js index c602e14..b446484 100644 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/customLabelsStatusChangeEvent.js +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/customLabelsStatusChangeEvent.js @@ -23,7 +23,7 @@ const ALLOWED_STATUSES = [ class CustomLabelsStatusChangeEvent extends BacklogStatusChangeEvent { async process() { if (ALLOWED_STATUSES.indexOf(this.status) < 0) { - console.error(`ERR: CustomLabelsStatusChangeEvent.process: ${this.status} status not handled`); + console.error(`CustomLabelsStatusChangeEvent.process: ${this.status} status not handled`); return undefined; } /* #1: get state data from service token table */ diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/rekognitionStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/rekognitionStatusChangeEvent.js index 830056a..96caf78 100644 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/rekognitionStatusChangeEvent.js +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/rekognitionStatusChangeEvent.js @@ -15,13 +15,14 @@ const STATUS_FAILED = 'FAILED'; class RekognitionStatusChangeEvent extends BacklogStatusChangeEvent { async process() { if (this.status !== STATUS_SUCCEEDED && this.status !== STATUS_FAILED) { - console.error(`ERR: RekognitionStatusChangeEvent.process: ${this.status} status not handled`); + console.error(`RekognitionStatusChangeEvent.process: ${this.status} status not handled`); return undefined; } /* #1: get state data from service token table */ - const response = await ServiceToken.getData(this.backlogId).catch(() => undefined); - + const response = await ServiceToken.getData(this.backlogId) + .catch(() => + undefined); if (!response || !response.service || !response.token || !response.api) { throw new JobStatusError(`fail to get token, ${this.backlogId}`); } @@ -38,11 +39,11 @@ class RekognitionStatusChangeEvent extends BacklogStatusChangeEvent { this.token = response.token; /* #3: send task result to state machine execution */ - if (this.jobStatus === STATUS_SUCCEEDED) { + if (this.status === STATUS_SUCCEEDED) { this.stateData.setCompleted(); await this.parent.sendTaskSuccess(); - } else if (this.jobStatus === STATUS_FAILED) { - const error = new JobStatusError(`${this.jobId} ${this.jobStatus}`); + } else if (this.status === STATUS_FAILED) { + const error = new JobStatusError(`${this.jobId} ${this.status}`); this.stateData.setFailed(error); await this.parent.sendTaskFailure(error); } diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/transcribeStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/transcribeStatusChangeEvent.js new file mode 100644 index 0000000..4e3c962 --- /dev/null +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/backlog/transcribeStatusChangeEvent.js @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const { + Environment, + StateData, + JobStatusError, + ServiceToken, +} = require('core-lib'); +const BacklogStatusChangeEvent = require('./backlogStatusChangeEvent'); + +const STATUS_SUCCEEDED = 'COMPLETED'; +const STATUS_FAILED = 'FAILED'; + +class TranscribeStatusChangeEvent extends BacklogStatusChangeEvent { + async process() { + if (this.status !== STATUS_SUCCEEDED && this.status !== STATUS_FAILED) { + console.error(`TranscribeStatusChangeEvent.process: ${this.status} status not handled`); + return undefined; + } + + const response = await ServiceToken.getData(this.backlogId) + .catch(() => + undefined); + if (!response || !response.service || !response.token || !response.api) { + throw new JobStatusError(`fail to get token, ${this.backlogId}`); + } + + const category = response.service; + response.data.data[category].jobId = this.jobId; + response.data.data[category].endTime = this.timestamp; + this.stateData = new StateData( + Environment.StateMachines.AudioAnalysis, + response.data, + this.context + ); + this.token = response.token; + + /* #3: send task result to state machine execution */ + if (this.status === STATUS_SUCCEEDED) { + this.stateData.setCompleted(); + await this.parent.sendTaskSuccess(); + } else if (this.status === STATUS_FAILED) { + /* special handling: if language identification is enabled and fails, */ + /* it is likely that the file contains no dialogue. Set it as NO_DATA */ + const identifyLanguageEnabled = !!(this.detail.serviceParams || {}).IdentifyLanguage; + if (identifyLanguageEnabled) { + this.stateData.setNoData(); + await this.parent.sendTaskSuccess(); + } else { + const error = (this.errorMessage) + ? new JobStatusError(this.errorMessage) + : new JobStatusError(`${this.jobId} ${this.status}`); + this.stateData.setFailed(error); + await this.parent.sendTaskFailure(error); + } + } + /* #4: remove record from service token table */ + await ServiceToken.unregister(this.backlogId) + .catch(() => + undefined); + return this.stateData.toJSON(); + } +} + +module.exports = TranscribeStatusChangeEvent; diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/index.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/index.js index ab33fe9..42fcb23 100644 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/index.js +++ b/source/main/analysis/automation/status-updater/lib/cloudwatch/index.js @@ -13,16 +13,24 @@ const { JobStatusError, Environment, } = require('core-lib'); -const TranscribeStatusChangeEvent = require('./transcribeStatusChangeEvent'); const BacklogStatusChangeEvent = require('./backlog/backlogStatusChangeEvent'); const RekognitionStatusChangeEvent = require('./backlog/rekognitionStatusChangeEvent'); +const TranscribeStatusChangeEvent = require('./backlog/transcribeStatusChangeEvent'); const ComprehendStatusChangeEvent = require('./backlog/comprehendStatusChangeEvent'); const CustomLabelsStatusChangeEvent = require('./backlog/customLabelsStatusChangeEvent'); const CATEGORY_REKOGNITION = 'rekognition:'; +const CATEGORY_TRANSCRIBE = 'transcribe:'; const CATEGORY_COMPREHEND = 'comprehend:'; const CATEGORY_CUSTOM = 'custom:'; +const EXCEPTION_TASK_TIMEOUT = 'TaskTimedOut'; +const EXCEPTION_TASK_NOTEXIST = 'TaskDoesNotExist'; +const IGNORED_EXECEPTION_LIST = [ + EXCEPTION_TASK_TIMEOUT, + EXCEPTION_TASK_NOTEXIST, +]; + class CloudWatchStatus { constructor(event, context) { this.$event = event; @@ -73,11 +81,11 @@ class CloudWatchStatus { async process() { let instance; - if (this.source === TranscribeStatusChangeEvent.SourceType) { - instance = new TranscribeStatusChangeEvent(this); - } else if (this.source === BacklogStatusChangeEvent.SourceType) { + if (this.source === BacklogStatusChangeEvent.SourceType) { if (this.detail.serviceApi.indexOf(CATEGORY_REKOGNITION) === 0) { instance = new RekognitionStatusChangeEvent(this); + } else if (this.detail.serviceApi.indexOf(CATEGORY_TRANSCRIBE) === 0) { + instance = new TranscribeStatusChangeEvent(this); } else if (this.detail.serviceApi.indexOf(CATEGORY_COMPREHEND) === 0) { instance = new ComprehendStatusChangeEvent(this); } else if (this.detail.serviceApi.indexOf(CATEGORY_CUSTOM) === 0) { @@ -97,7 +105,14 @@ class CloudWatchStatus { })).sendTaskSuccess({ output: JSON.stringify(this.stateData.toJSON()), taskToken: this.token, - }).promise(); + }).promise() + .catch((e) => { + if (IGNORED_EXECEPTION_LIST.indexOf(e.code) >= 0) { + return undefined; + } + console.log(`[ERR]: sendTaskSuccess: ${e.code}: ${e.message}`, JSON.stringify(this.stateData.toJSON())); + throw e; + }); } async sendTaskFailure(error) { @@ -108,7 +123,14 @@ class CloudWatchStatus { taskToken: this.token, error: error.name, cause: error.message, - }).promise(); + }).promise() + .catch((e) => { + if (e.code === EXCEPTION_TASK_TIMEOUT) { + return undefined; + } + console.log(`[ERR]: sendTaskFailure: ${e.code}: ${e.message}`, error.name, error.message); + throw e; + }); } } diff --git a/source/main/analysis/automation/status-updater/lib/cloudwatch/transcribeStatusChangeEvent.js b/source/main/analysis/automation/status-updater/lib/cloudwatch/transcribeStatusChangeEvent.js deleted file mode 100644 index 3a2bd27..0000000 --- a/source/main/analysis/automation/status-updater/lib/cloudwatch/transcribeStatusChangeEvent.js +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -const AWS = (() => { - try { - const AWSXRay = require('aws-xray-sdk'); - return AWSXRay.captureAWS(require('aws-sdk')); - } catch (e) { - return require('aws-sdk'); - } -})(); -const { - Environment, - StateData, - JobStatusError, - Retry, - ServiceToken, -} = require('core-lib'); - -class TranscribeStatusChangeEvent { - constructor(parent) { - if (!(parent.detail || {}).TranscriptionJobName - || !(parent.detail || {}).TranscriptionJobStatus) { - throw new Error('missing event.detail.TranscriptionJobName or TranscriptionJobStatus. exiting...'); - } - this.$parent = parent; - this.$service = undefined; - this.$api = undefined; - } - - static get SourceType() { - return 'aws.transcribe'; - } - - static get Mapping() { - return { - COMPLETED: StateData.Statuses.Completed, - FAILED: StateData.Statuses.Error, - }; - } - - static get Event() { - return { - Completed: 'COMPLETED', - Failed: 'FAILED', - }; - } - - /** - * @static - * @function ValidateJobName - * @description Job name must be in this format, __<16-hex> - * @param {*} name - */ - static ValidateJobName(name) { - return /^([a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}_){2}[a-fA-F0-9]{16}$/.test(name); - } - - get parent() { - return this.$parent; - } - - get api() { - return this.$api; - } - - set api(val) { - this.$api = val; - } - - get service() { - return this.$service; - } - - set service(val) { - this.$service = val; - } - - get event() { - return this.parent.event; - } - - get context() { - return this.parent.context; - } - - get detail() { - return this.parent.detail; - } - - get token() { - return this.parent.token; - } - - set token(val) { - this.parent.token = val; - } - - get stateData() { - return this.parent.stateData; - } - - set stateData(val) { - this.parent.stateData = val; - } - - get status() { - return this.event.detail.TranscriptionJobStatus; - } - - get jobName() { - return this.event.detail.TranscriptionJobName; - } - - get timestamp() { - return this.parent.timestamp; - } - - async process() { - /* make sure the event is meant for M2C */ - if (!TranscribeStatusChangeEvent.ValidateJobName(this.jobName)) { - return undefined; - } - - const response = await ServiceToken.getData(this.jobName).catch(() => undefined); - if (!response || !response.service || !response.token || !response.api) { - throw new JobStatusError(`fail to get token, ${this.jobName}`); - } - - this.token = response.token; - this.service = response.service; - this.api = response.api; - - this.stateData = new StateData( - Environment.StateMachines.AudioAnalysis, - response.data, - this.context - ); - - switch (this.status) { - case TranscribeStatusChangeEvent.Event.Completed: - await this.onCompleted(); - break; - case TranscribeStatusChangeEvent.Event.Failed: - default: - await this.onError(); - break; - } - await ServiceToken.unregister(this.jobName).catch(() => undefined); - return this.stateData.toJSON(); - } - - async onCompleted() { - const instance = new AWS.TranscribeService({ - apiVersion: '2017-10-26', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - }); - - const fn = instance.getTranscriptionJob.bind(instance); - const response = await Retry.run(fn, { - TranscriptionJobName: this.jobName, - }).catch((e) => { - throw new JobStatusError(`(${this.jobName}) ${e.message}`); - }); - - this.stateData.setData(this.service, { - ...this.stateData.data[this.service], - startTime: new Date(response.TranscriptionJob.CreationTime).getTime(), - endTime: new Date(response.TranscriptionJob.CompletionTime).getTime(), - }); - - this.stateData.setCompleted(); - return this.parent.sendTaskSuccess(); - } - - async onError() { - const instance = new AWS.TranscribeService({ - apiVersion: '2017-10-26', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - }); - - const fn = instance.getTranscriptionJob.bind(instance); - const response = await Retry.run(fn, { - TranscriptionJobName: this.jobName, - }).catch(() => undefined); - - /* special handling: trascribe could fail if the audio doesn't contain speech */ - if (((response || {}).TranscriptionJob || {}).IdentifyLanguage) { - this.stateData.setData(this.service, { - ...this.stateData.data[this.service], - endTime: this.timestamp, - status: StateData.Statuses.NoData, - }); - this.stateData.setNoData(); - return this.parent.sendTaskSuccess(); - } - - const error = (((response || {}).TranscriptionJob || {}).FailureReason) - ? new JobStatusError(`${response.TranscriptionJob.FailureReason}`) - : new JobStatusError(`${this.jobName} ${this.status}`); - - this.stateData.setData(this.service, { - ...this.stateData.data[this.service], - endTime: this.timestamp, - status: TranscribeStatusChangeEvent.Mapping[this.status] || StateData.Statuses.Error, - }); - - this.stateData.setFailed(error); - return this.parent.sendTaskFailure(error); - } -} - -module.exports = TranscribeStatusChangeEvent; diff --git a/source/main/analysis/automation/status-updater/package.json b/source/main/analysis/automation/status-updater/package.json index 0e9b3ae..c0e2366 100644 --- a/source/main/analysis/automation/status-updater/package.json +++ b/source/main/analysis/automation/status-updater/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json lib dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../../../../layers/core-lib" + "core-lib": "file:../../../../layers/core-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/analysis/automation/status-updater/setEnvVars.js b/source/main/analysis/automation/status-updater/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/analysis/automation/status-updater/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/document/README.md b/source/main/analysis/document/README.md index 03e76db..da51e0e 100644 --- a/source/main/analysis/document/README.md +++ b/source/main/analysis/document/README.md @@ -77,7 +77,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/analysis/document/index.spec.js b/source/main/analysis/document/index.spec.js new file mode 100644 index 0000000..4de1a9a --- /dev/null +++ b/source/main/analysis/document/index.spec.js @@ -0,0 +1,334 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); + const StateIndexAnalysisResults = require('./states/index-analysis-results'); + const StateStartDocumentAnalysis = require('./states/start-document-analysis'); + + +const lambda = require('./index.js'); + + +const event_StateIndexAnalysisResults = { + "operation": "index-analysis-results", + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": { + "document": { + "status": "COMPLETED", + "executionArn": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-document:673359b3-1020-47b9-a4c6-7e189b6a5cab", + "startTime": 1672908613005, + "endTime": 1672908619938, + "textract": { + "output": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/raw/20230105T085011/textract/", + "numOutputs": 1, + "textlist": "textlist.json" + } + } + }, + "progress": 100, + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "status": "COMPLETED" + } + +const event_StateStartDocumentAnalysis = { + "operation": "start-document-analysis", + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "stateExecution": { + "Id": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-document:673359b3-1020-47b9-a4c6-7e189b6a5cab", + "Input": { + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": {}, + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097" + }, + "StartTime": "2023-01-05T08:50:13.005Z", + "Name": "673359b3-1020-47b9-a4c6-7e189b6a5cab", + "RoleArn": "arn:aws:iam::account-number:role/so0050-0a709c9ee415/MyMedia2CloudTest-Backend-AnalysisStateMachineServ-15UVTS9SYPLZU" + }, + "data": {}, + "progress": 0, + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "status": "NOT_STARTED" + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Document::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + // These env varialbes are not needed to test functionality + process.env = { + ENV_SOLUTION_ID: 'Test_ENV_Variable', + ENV_RESOURCE_PREFIX: 'Test_ENV_Variable', + ENV_SOLUTION_UUID: 'Test_ENV_Variable', + ENV_ANONYMOUS_USAGE: 'Test_ENV_Variable', + ENV_IOT_HOST: 'Test_ENV_Variable', + ENV_IOT_TOPIC: 'Test_ENV_Variable', + ENV_PROXY_BUCKET: 'Test_ENV_Variable', + ENV_DATA_ACCESS_ROLE: 'Test_ENV_Variable', + ENV_ES_DOMAIN_ENDPOINT: 'Test_ENV_Variable' + }; + + process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; + + }); + + test('Test Lambda handler for state start document ', async () => { + + const response = await lambda.handler(event_StateStartDocumentAnalysis, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6cbb8b34-4c02-a3bb-a790-ea3d58350097'); + }); + + + + test('Test Lambda handler for state index analysis document ', async () => { + + const response = await lambda.handler(event_StateIndexAnalysisResults, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6cbb8b34-4c02-a3bb-a790-ea3d58350097'); + }); + + test('Test StateIndexAnalysisResults', async () => { + // const stateData = lambda.parseEvent(event_StateIndexAnalysisResults, context); + const stateData = new StateData(Environment.StateMachines.DocumentAnalysis, event_StateIndexAnalysisResults, context); + + let instance = new StateIndexAnalysisResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateStartDocumentAnalysis', async () => { + // const stateData = lambda.parseEvent(event_StateIndexAnalysisResults, context); + const stateData = new StateData(Environment.StateMachines.DocumentAnalysis, event_StateStartDocumentAnalysis, context); + + let instance = new StateStartDocumentAnalysis(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/main/analysis/document/jest.config.js b/source/main/analysis/document/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/analysis/document/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/document/package.json b/source/main/analysis/document/package.json index 51b7362..ee5680c 100644 --- a/source/main/analysis/document/package.json +++ b/source/main/analysis/document/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../../../layers/core-lib" + "core-lib": "file:../../../layers/core-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/analysis/document/setEnvVars.js b/source/main/analysis/document/setEnvVars.js new file mode 100644 index 0000000..05aad4d --- /dev/null +++ b/source/main/analysis/document/setEnvVars.js @@ -0,0 +1,9 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/image/README.md b/source/main/analysis/image/README.md index 234668b..8d6b9b2 100644 --- a/source/main/analysis/image/README.md +++ b/source/main/analysis/image/README.md @@ -93,7 +93,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/analysis/image/index.js b/source/main/analysis/image/index.js index ea671b4..77eb866 100644 --- a/source/main/analysis/image/index.js +++ b/source/main/analysis/image/index.js @@ -19,6 +19,55 @@ const REQUIRED_ENVS = [ 'ENV_PROXY_BUCKET', ]; +function parseEvent(event, context) { + const stateMachine = Environment.StateMachines.ImageAnalysis; + let parsed = event; + if (!parsed.parallelStateOutputs) { + return new StateData(stateMachine, parsed, context); + } + if (!parsed.stateExecution) { + throw new Error('fail to parse event.stateExecution'); + } + /* parse execution input object */ + const uuid = parsed.stateExecution.Input.uuid; + const input = parsed.stateExecution.Input.input; + const startTime = parsed.stateExecution.StartTime; + const executionArn = parsed.stateExecution.Id; + delete parsed.stateExecution; + if (!uuid || !input) { + throw new Error('fail to find uuid or input from event.stateExecution'); + } + /* parse parallel state outputs */ + const parallelStateOutputs = parsed.parallelStateOutputs; + delete parsed.parallelStateOutputs; + + /* merging data.image output */ + let merged = {}; + while (parallelStateOutputs.length) { + const stateOutput = parallelStateOutputs.shift(); + merged = { + ...merged, + ...stateOutput.data.image, + }; + } + + parsed = { + ...parsed, + uuid, + input, + progress: 0, + data: { + image: { + ...merged, + startTime, + executionArn, + status: StateData.Statuses.NotStarted, + }, + }, + }; + return new StateData(stateMachine, parsed, context); +} + exports.handler = async (event, context) => { console.log(`event = ${JSON.stringify(event, null, 2)}; context = ${JSON.stringify(context, null, 2)};`); @@ -28,7 +77,8 @@ exports.handler = async (event, context) => { throw new AnalysisError(`missing enviroment variables, ${missing.join(', ')}`); } - const stateData = new StateData(Environment.StateMachines.ImageAnalysis, event, context); + /* merge parallel state outputs */ + const stateData = parseEvent(event, context); /* state routing */ let instance; diff --git a/source/main/analysis/image/index.spec.js b/source/main/analysis/image/index.spec.js new file mode 100644 index 0000000..c3d3f05 --- /dev/null +++ b/source/main/analysis/image/index.spec.js @@ -0,0 +1,352 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); + const StateStartImageAnalysis = require('./states/start-image-analysis'); + const StateIndexAnalysisResults = require('./states/index-analysis-results'); + +const lambda = require('./index.js'); + + +const event_StateIndexAnalysisResults = { + "operation": "index-analysis-results", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "TestingImage/TestingImage.png", + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": true, + "key": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/transcode/proxy/TestingImage.jpg" + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1673234981669 + }, + "metrics": { + "duration": 0, + "requestTime": 1673234981669, + "startTime": 1673234983381 + } + }, + "data": { + "image": { + "status": "COMPLETED", + "startTime": 1673234983880, + "endTime": 1673234988372, + "executionArn": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-image:98937baf-d231-4012-b0f0-407bb0f0a757", + "rekog-image": { + "celeb": { + "output": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/raw/20230109T032941/rekog-image/celeb/output.json", + "startTime": 1673234985251, + "endTime": 1673234986018 + }, + "face": { + "output": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/raw/20230109T032941/rekog-image/face/output.json", + "startTime": 1673234985351, + "endTime": 1673234986010 + }, + "label": { + "output": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/raw/20230109T032941/rekog-image/label/output.json", + "startTime": 1673234985371, + "endTime": 1673234986016 + }, + "moderation": { + "output": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/raw/20230109T032941/rekog-image/moderation/output.json", + "startTime": 1673234985374, + "endTime": 1673234986000 + }, + "text": { + "output": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/raw/20230109T032941/rekog-image/text/output.json", + "startTime": 1673234985391, + "endTime": 1673234988372 + } + } + } + }, + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749" + } + +const event_StateStartImageAnalysis = { + "operation": "start-image-analysis", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "TestingImage/TestingImage.png", + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": true, + "key": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/transcode/proxy/TestingImage.jpg" + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1673234981669 + }, + "metrics": { + "duration": 0, + "requestTime": 1673234981669, + "startTime": 1673234983381 + } + }, + "stateExecution": { + "Id": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-image:98937baf-d231-4012-b0f0-407bb0f0a757", + "Input": { + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "TestingImage/TestingImage.png", + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": true, + "key": "6c56edc5-a973-3485-c9eb-16292d709749/TestingImage/transcode/proxy/TestingImage.jpg" + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1673234981669 + }, + "metrics": { + "duration": 0, + "requestTime": 1673234981669, + "startTime": 1673234983381 + } + }, + "data": {}, + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749" + }, + "StartTime": "2023-01-09T03:29:43.880Z", + "Name": "98937baf-d231-4012-b0f0-407bb0f0a757", + "RoleArn": "arn:aws:iam::account-number:role/so0050-0a709c9ee415/MyMedia2CloudTest-Backend-AnalysisStateMachineServ-15UVTS9SYPLZU" + }, + "data": {}, + "uuid": "6c56edc5-a973-3485-c9eb-16292d709749" + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Image::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + // These env varialbes are not needed to test functionality + process.env = { + ENV_SOLUTION_ID: 'Test_ENV_Variable', + ENV_RESOURCE_PREFIX: 'Test_ENV_Variable', + ENV_SOLUTION_UUID: 'Test_ENV_Variable', + ENV_ANONYMOUS_USAGE: 'Test_ENV_Variable', + ENV_IOT_HOST: 'Test_ENV_Variable', + ENV_IOT_TOPIC: 'Test_ENV_Variable', + ENV_PROXY_BUCKET: 'Test_ENV_Variable', + ENV_DATA_ACCESS_ROLE: 'Test_ENV_Variable', + ENV_ES_DOMAIN_ENDPOINT: 'Test_ENV_Variable' + }; + + process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; + + }); + + test('Test Lambda handler for state start ', async () => { + + const response = await lambda.handler(event_StateStartImageAnalysis, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6c56edc5-a973-3485-c9eb-16292d709749'); + }); + + + + test('Test Lambda handler for state index analysis ', async () => { + + const response = await lambda.handler(event_StateIndexAnalysisResults, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6c56edc5-a973-3485-c9eb-16292d709749'); + }); + + test('Test StateIndexResults', async () => { + // const stateData = lambda.parseEvent(event_StateIndexAnalysisResults, context); + const stateData = new StateData(Environment.StateMachines.DocumentAnalysis, event_StateIndexAnalysisResults, context); + + let instance = new StateIndexAnalysisResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateStarAnalysis', async () => { + // const stateData = lambda.parseEvent(event_StateIndexAnalysisResults, context); + const stateData = new StateData(Environment.StateMachines.DocumentAnalysis, event_StateStartImageAnalysis, context); + + let instance = new StateStartImageAnalysis(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/main/analysis/image/jest.config.js b/source/main/analysis/image/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/analysis/image/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/image/package.json b/source/main/analysis/image/package.json index 4664259..e548f97 100644 --- a/source/main/analysis/image/package.json +++ b/source/main/analysis/image/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "image-process-lib": "file:../../../layers/image-process-lib" + "image-process-lib": "file:../../../layers/image-process-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/analysis/image/setEnvVars.js b/source/main/analysis/image/setEnvVars.js new file mode 100644 index 0000000..05aad4d --- /dev/null +++ b/source/main/analysis/image/setEnvVars.js @@ -0,0 +1,9 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/image/stateMachineDefinition.json b/source/main/analysis/image/stateMachineDefinition.json index 2caa3de..5d61612 100644 --- a/source/main/analysis/image/stateMachineDefinition.json +++ b/source/main/analysis/image/stateMachineDefinition.json @@ -1,40 +1,72 @@ { - "StartAt": "Start image analysis", + "StartAt": "Run parallel states", "States": { - "Start image analysis": { - "Type": "Task", - "Resource": "${x0}", - "Parameters": { - "operation": "start-image-analysis", - "uuid.$": "$.uuid", - "status": "NOT_STARTED", - "progress": 0, - "input.$": "$.input", - "data.$": "$.data", - "stateExecution.$": "$$.Execution" - }, - "Next": "Index analysis results", - "Retry": [ + "Run parallel states": { + "Type": "Parallel", + "Branches": [ { - "ErrorEquals": [ - "States.ALL" - ], - "IntervalSeconds": 1, - "MaxAttempts": 6, - "BackoffRate": 1.1 + "StartAt": "Start image analysis", + "States": { + "Start image analysis": { + "Type": "Task", + "Resource": "${AnalysisImageLambda.Arn}", + "Parameters": { + "operation": "start-image-analysis", + "uuid.$": "$.uuid", + "status": "NOT_STARTED", + "progress": 0, + "input.$": "$.input", + "data.$": "$.data" + }, + "End": true, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 6, + "BackoffRate": 1.1 + } + ] + } + } + }, + { + "StartAt": "Run BLIP model", + "States": { + "Run BLIP model": { + "Type": "Task", + "Resource": "${blipLambda}", + "Parameters": { + "bucket.$": "$.input.destination.bucket", + "key.$": "$.input.image.key" + }, + "ResultPath": "$.data.image", + "End": true, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 2, + "BackoffRate": 1.1 + } + ] + } + } } - ] + ], + "Next": "Index analysis results" }, "Index analysis results": { "Type": "Task", - "Resource": "${x0}", + "Resource": "${AnalysisImageLambda.Arn}", "Parameters": { "operation": "index-analysis-results", - "uuid.$": "$.uuid", - "status": "NOT_STARTED", - "progress": 0, - "input.$": "$.input", - "data.$": "$.data" + "parallelStateOutputs.$": "$", + "stateExecution.$": "$$.Execution" }, "End": true, "Retry": [ diff --git a/source/main/analysis/image/states/index-analysis-results/index.js b/source/main/analysis/image/states/index-analysis-results/index.js index 9cf81af..fad9030 100644 --- a/source/main/analysis/image/states/index-analysis-results/index.js +++ b/source/main/analysis/image/states/index-analysis-results/index.js @@ -46,19 +46,29 @@ class StateIndexAnalysisResults { JSON.parse(res.Body)) .catch((e) => console.error(`[ERR]: CommonUtils.download: ${subCategory}: ${bucket}/${key}: ${e.code} ${e.message}`)); - const datasets = (subCategory === AnalysisTypes.Rekognition.Celeb) - ? this.parseCeleb(data) - : (subCategory === AnalysisTypes.Rekognition.Face) - ? this.parseFace(data) - : (subCategory === AnalysisTypes.Rekognition.FaceMatch) - ? this.parseFaceMatch(data) - : (subCategory === AnalysisTypes.Rekognition.Label) - ? this.parseLabel(data) - : (subCategory === AnalysisTypes.Rekognition.Moderation) - ? this.parseModeration(data) - : (subCategory === AnalysisTypes.Rekognition.Text) - ? this.parseText(data) - : undefined; + let datasets; + switch (subCategory) { + case AnalysisTypes.Rekognition.Celeb: + datasets = this.parseCeleb(data); + break; + case AnalysisTypes.Rekognition.Face: + datasets = this.parseFace(data); + break; + case AnalysisTypes.Rekognition.FaceMatch: + datasets = this.parseFaceMatch(data); + break; + case AnalysisTypes.Rekognition.Label: + datasets = this.parseLabel(data); + break; + case AnalysisTypes.Rekognition.Moderation: + datasets = this.parseModeration(data); + break; + case AnalysisTypes.Rekognition.Text: + datasets = this.parseText(data); + break; + default: + datasets = undefined; + } if (datasets && datasets.length > 0) { const uuid = this.stateData.uuid; const indexer = new Indexer(); @@ -129,9 +139,20 @@ class StateIndexAnalysisResults { const texts = ((data || {}).TextDetections || []) .map((x) => x.DetectedText.trim()); - return [...new Set(texts)].map((name) => ({ + + const uniques = [ + ...new Set(texts), + ].map((name) => ({ name, })); + + const caption = this.stateData.data[ANALYSIS_TYPE].caption; + if (caption && caption.length > 0) { + uniques.push({ + name: caption, + }); + } + return uniques; } setCompleted() { diff --git a/source/main/analysis/image/states/start-image-analysis/index.js b/source/main/analysis/image/states/start-image-analysis/index.js index aa38435..677c632 100644 --- a/source/main/analysis/image/states/start-image-analysis/index.js +++ b/source/main/analysis/image/states/start-image-analysis/index.js @@ -18,6 +18,9 @@ const { Retry, Environment, } = require('core-lib'); +const { + SigV4, +} = require('core-lib'); const ANALYSIS_TYPE = 'image'; const CATEGORY = 'rekog-image'; @@ -55,7 +58,7 @@ class StateStartImageAnalysis { async process() { const aiOptions = this.stateData.input.aiOptions; - let results = await Promise.all([ + let results = Promise.all([ this.startCeleb(aiOptions), this.startFace(aiOptions), this.startFaceMatch(aiOptions), @@ -63,17 +66,17 @@ class StateStartImageAnalysis { this.startModeration(aiOptions), this.startText(aiOptions), ]); - results = results.filter(x => x).reduce((acc, cur) => ({ - ...acc, - ...cur, - }), {}); - const stateExecution = this.stateData.event.stateExecution; + results = (await results) + .filter((x) => + x) + .reduce((acc, cur) => ({ + ...acc, + ...cur, + }), {}); + this.stateData.setData(ANALYSIS_TYPE, { status: StateData.Statuses.Completed, - startTime: new Date(stateExecution.StartTime).getTime(), - endTime: new Date().getTime(), - executionArn: stateExecution.Id, [CATEGORY]: results, }); this.stateData.setCompleted(); diff --git a/source/main/analysis/main/README.md b/source/main/analysis/main/README.md index e78b6bc..49fea99 100644 --- a/source/main/analysis/main/README.md +++ b/source/main/analysis/main/README.md @@ -186,7 +186,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/analysis/main/index.spec.js b/source/main/analysis/main/index.spec.js new file mode 100644 index 0000000..a3e8bb7 --- /dev/null +++ b/source/main/analysis/main/index.spec.js @@ -0,0 +1,465 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); + const StatePrepareAnalysis = require('./states/prepare-analysis'); + const StateCollectAnalysisResults = require('./states/collect-analysis-results'); + const StateJobCompleted = require('./states/job-completed'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateCollectAnalysisResults = { + "operation": "collect-analysis-results", + "status": "NOT_STARTED", + "progress": 0, + "parallelStateOutputs": [ + { + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "stateMachine": "so0050-0a709c9ee415-analysis-main", + "operation": "prepare-analysis", + "overallStatus": "PROCESSING", + "status": "ANALYSIS_STARTED", + "progress": 100, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": {} + }, + { + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "stateMachine": "so0050-0a709c9ee415-analysis-main", + "operation": "prepare-analysis", + "overallStatus": "PROCESSING", + "status": "ANALYSIS_STARTED", + "progress": 100, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": {} + }, + { + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "stateMachine": "so0050-0a709c9ee415-analysis-main", + "operation": "prepare-analysis", + "overallStatus": "PROCESSING", + "status": "ANALYSIS_STARTED", + "progress": 100, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": {} + }, + { + "ExecutionArn": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-document:673359b3-1020-47b9-a4c6-7e189b6a5cab", + "Input": "{\"status\":\"NOT_STARTED\",\"progress\":0,\"input\":{\"bucket\":\"so0050-0a709c9ee415-account-number-us-west-2-ingest\",\"key\":\"Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf\",\"uuid\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097\",\"aiOptions\":{\"sentiment\":true,\"textROI\":[false,false,false,false,false,false,false,false,false],\"framebased\":false,\"celeb\":true,\"frameCaptureMode\":0,\"keyphrase\":true,\"label\":true,\"languageCode\":\"en-US\",\"facematch\":false,\"transcribe\":true,\"face\":true,\"customentity\":false,\"person\":true,\"minConfidence\":80,\"textract\":true,\"moderation\":true,\"segment\":true,\"customlabel\":false,\"text\":true,\"entity\":true,\"customLabelModels\":[]},\"attributes\":{},\"destination\":{\"bucket\":\"so0050-0a709c9ee415-account-number-us-west-2-proxy\",\"prefix\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/\"},\"video\":{\"enabled\":false},\"audio\":{\"enabled\":false},\"image\":{\"enabled\":false},\"document\":{\"enabled\":true,\"prefix\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy\",\"numPages\":2},\"request\":{\"timestamp\":1672908611250},\"metrics\":{\"duration\":0,\"requestTime\":1672908611250,\"startTime\":1672908612571}},\"data\":{},\"uuid\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097\"}", + "InputDetails": { + "Included": true + }, + "Name": "673359b3-1020-47b9-a4c6-7e189b6a5cab", + "Output": "{\"uuid\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097\",\"stateMachine\":\"so0050-0a709c9ee415-analysis-document\",\"operation\":\"index-analysis-results\",\"overallStatus\":\"PROCESSING\",\"status\":\"COMPLETED\",\"progress\":100,\"input\":{\"bucket\":\"so0050-0a709c9ee415-account-number-us-west-2-ingest\",\"key\":\"Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf\",\"uuid\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097\",\"aiOptions\":{\"sentiment\":true,\"textROI\":[false,false,false,false,false,false,false,false,false],\"framebased\":false,\"celeb\":true,\"frameCaptureMode\":0,\"keyphrase\":true,\"label\":true,\"languageCode\":\"en-US\",\"facematch\":false,\"transcribe\":true,\"face\":true,\"customentity\":false,\"person\":true,\"minConfidence\":80,\"textract\":true,\"moderation\":true,\"segment\":true,\"customlabel\":false,\"text\":true,\"entity\":true,\"customLabelModels\":[]},\"attributes\":{},\"destination\":{\"bucket\":\"so0050-0a709c9ee415-account-number-us-west-2-proxy\",\"prefix\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/\"},\"video\":{\"enabled\":false},\"audio\":{\"enabled\":false},\"image\":{\"enabled\":false},\"document\":{\"enabled\":true,\"prefix\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy\",\"numPages\":2},\"request\":{\"timestamp\":1672908611250},\"metrics\":{\"duration\":0,\"requestTime\":1672908611250,\"startTime\":1672908612571}},\"data\":{\"document\":{\"status\":\"COMPLETED\",\"executionArn\":\"arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-document:673359b3-1020-47b9-a4c6-7e189b6a5cab\",\"startTime\":1672908613005,\"endTime\":1672908619938,\"textract\":{\"output\":\"6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/raw/20230105T085011/textract/\",\"numOutputs\":1,\"textlist\":\"textlist.json\"}}}}", + "OutputDetails": { + "Included": true + }, + "StartDate": 1672908613005, + "StateMachineArn": "arn:aws:states:us-west-2:account-number:stateMachine:so0050-0a709c9ee415-analysis-document", + "Status": "SUCCEEDED", + "StopDate": 1672908620622 + } + ] + } + +const event_StatePrepareAnalysis = { + "operation": "prepare-analysis", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 0, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + } + }, + "executionArn": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-main:a4f8decc-f31e-4fdf-8d8b-3f73fb0e778c" + } + +const event_StateJobCompleted = { + "operation": "job-completed", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "Airport to Scala 7 Interno 5 Maps/Airport to Scala 7 Interno 5 Maps.pdf", + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/" + }, + "video": { + "enabled": false + }, + "audio": { + "enabled": false + }, + "image": { + "enabled": false + }, + "document": { + "enabled": true, + "prefix": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/transcode/proxy", + "numPages": 2 + }, + "request": { + "timestamp": 1672908611250 + }, + "metrics": { + "duration": 0, + "requestTime": 1672908611250, + "startTime": 1672908612571 + } + }, + "data": { + "document": { + "status": "COMPLETED", + "executionArn": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-document:673359b3-1020-47b9-a4c6-7e189b6a5cab", + "startTime": 1672908613005, + "endTime": 1672908619938, + "textract": { + "output": "6cbb8b34-4c02-a3bb-a790-ea3d58350097/Airport_to_Scala_7_Interno_5_Maps/raw/20230105T085011/textract/", + "numOutputs": 1, + "textlist": "textlist.json" + } + } + }, + "uuid": "6cbb8b34-4c02-a3bb-a790-ea3d58350097" + } + +const event_StateStart = { + +} + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + + test('Test Lambda handler for state index analysis ', async () => { + + const response = await lambda.handler(event_StateCollectAnalysisResults, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6cbb8b34-4c02-a3bb-a790-ea3d58350097'); + }); + + test('Test StateIndexAnalysisResults', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateCollectAnalysisResults, context); + + let instance = new StateCollectAnalysisResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateIndexAnalysisResults', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StatePrepareAnalysis, context); + + let instance = new StatePrepareAnalysis(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test StateStartAnalysis', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateJobCompleted, context); + + let instance = new StateJobCompleted(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + +}); + + diff --git a/source/main/analysis/main/jest.config.js b/source/main/analysis/main/jest.config.js new file mode 100644 index 0000000..89a3aeb --- /dev/null +++ b/source/main/analysis/main/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageDirectory: "../../../test/coverage-reports/jest/layers/main/analysis/main/", + coverageReporters: [['lcov', { projectRoot: '../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/main/package.json b/source/main/analysis/main/package.json index 96c53f9..45105a8 100644 --- a/source/main/analysis/main/package.json +++ b/source/main/analysis/main/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../../../layers/core-lib" + "core-lib": "file:../../../layers/core-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/analysis/main/setEnvVars.js b/source/main/analysis/main/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/analysis/main/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/main/states/job-completed/index.js b/source/main/analysis/main/states/job-completed/index.js index 386b6b4..e25ce4d 100644 --- a/source/main/analysis/main/states/job-completed/index.js +++ b/source/main/analysis/main/states/job-completed/index.js @@ -36,8 +36,6 @@ class StateJobCompleted { /* update ingest table */ const attrib = await this.updateIngestTable(types); - /* TODO: review this 'src' thing */ - /* TODO: review this 'src' thing */ this.stateData.setData('src', { bucket: attrib.bucket, key: attrib.key, diff --git a/source/main/analysis/main/states/prepare-analysis/index.js b/source/main/analysis/main/states/prepare-analysis/index.js index bb1dd4f..668c422 100644 --- a/source/main/analysis/main/states/prepare-analysis/index.js +++ b/source/main/analysis/main/states/prepare-analysis/index.js @@ -22,6 +22,9 @@ const { FrameCaptureMode, } = require('core-lib'); +const DEFAULT_AI_OPTIONS = process.env.ENV_DEFAULT_AI_OPTIONS; +const AI_OPTIONS_S3KEY = process.env.ENV_AI_OPTIONS_S3KEY; + const TYPE_REKOGNITION_IMAGE = [ AnalysisTypes.Rekognition.Celeb, AnalysisTypes.Rekognition.Face, @@ -64,13 +67,6 @@ class StatePrepareAnalysis { throw new AnalysisError('stateData not StateData object'); } this.$stateData = stateData; - this.$defaultAIOptions = { - ...AIML, - minConfidence: Environment.Rekognition.MinConfidence, - }; - process.env.ENV_DEFAULT_AI_OPTIONS.split(',').filter(x => x).forEach((x) => { - this.$defaultAIOptions[x] = true; - }); this.$timestamp = ((this.stateData.input || {}).request || {}).timestamp || (new Date()).getTime(); } @@ -83,10 +79,6 @@ class StatePrepareAnalysis { return this.$stateData; } - get defaultAIOptions() { - return this.$defaultAIOptions; - } - get timestamp() { return this.$timestamp; } @@ -161,11 +153,46 @@ class StatePrepareAnalysis { return this.stateData.toJSON(); } + async getDefaultAIOptions() { + const aiOptions = { + ...AIML, + minConfidence: Environment.Rekognition.MinConfidence, + }; + + /* global options from stored by webapp (admin) */ + const bucket = Environment.Proxy.Bucket; + const key = AI_OPTIONS_S3KEY; + const globalOptions = await CommonUtils.download(bucket, key, false) + .then((x) => + JSON.parse(x.Body.toString())) + .catch(() => + undefined); + + if (globalOptions !== undefined) { + return { + ...aiOptions, + ...globalOptions, + }; + } + + /* environment options during stack creation */ + DEFAULT_AI_OPTIONS.split(',') + .forEach((x) => { + aiOptions[x] = true; + }); + + return aiOptions; + } + async parseAIOptions(requested) { const aiOptions = requested || {}; - Object.keys(this.defaultAIOptions).forEach((x) => { + + const defaultAIOptions = await this.getDefaultAIOptions(); + + /* merge requested and default aioptions */ + Object.keys(defaultAIOptions).forEach((x) => { if (aiOptions[x] === undefined) { - aiOptions[x] = this.defaultAIOptions[x]; + aiOptions[x] = defaultAIOptions[x]; } }); return this.mergeServiceOptions(aiOptions); diff --git a/source/main/analysis/video/README.md b/source/main/analysis/video/README.md index b132885..9089596 100644 --- a/source/main/analysis/video/README.md +++ b/source/main/analysis/video/README.md @@ -351,7 +351,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:PutItem", diff --git a/source/main/analysis/video/index.js b/source/main/analysis/video/index.js index 3f4729a..7e88448 100644 --- a/source/main/analysis/video/index.js +++ b/source/main/analysis/video/index.js @@ -65,7 +65,7 @@ function parseEvent(event, context) { /* i.e., customlabel */ const merged = {}; const iterators = parallelStateOutputs.filter(x => - ((x.data || {}).iterators || {}).length > 0) + ((x.data || {}).iterators || []).length > 0) .reduce((a0, c0) => a0.concat(c0.data.iterators.reduce((a1, c1) => a1.concat({ diff --git a/source/main/analysis/video/index.spec.js b/source/main/analysis/video/index.spec.js new file mode 100644 index 0000000..cda6898 --- /dev/null +++ b/source/main/analysis/video/index.spec.js @@ -0,0 +1,754 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +/* frame-based analysis */ +const StatePrepareFrameDetectionIterators = require('./states/prepare-frame-detection-iterators'); +const StateDetectFrameIterator = require('./states/detect-frame-iterator'); +const StatePrepareFrameTrackIterators = require('./states/prepare-frame-track-iterators'); +/* video-based analysis */ +const StatePrepareVideoDetectionIterators = require('./states/prepare-video-detection-iterators'); +/* custom analysis */ +const StatePrepareCustomDetectionIterators = require('./states/prepare-custom-detection-iterators'); +/* shared */ +const StateStartDetectionIterator = require('./states/start-detection-iterator'); +const StateCollectResultsIterator = require('./states/collect-results-iterator'); +const StateCreateTrackIterator = require('./states/create-track-iterator'); +const StateIndexAnalysisIterator = require('./states/index-analysis-iterator'); +/* job completed */ +const StateJobCompleted = require('./states/job-completed'); + + +const lambda = require('./index.js'); + +/* frame-based analysis */ +// prepare-frame-detection-iterators +const event_StatePrepareFrameDetectionIterators = { + "operation": "prepare-frame-detection-iterators", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } + +// detect-frame-iterator +const event_StateDetectFrameIterator = +// prepare-frame-track-iterators +const event_StatePrepareFrameTrackIterators = { + "operation": "prepare-video-detection-iterators", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } +/* video-based analysis */ +// prepare-video-detection-iterators +const event_StatePrepareVideoDetectionIterators = { + "operation": "prepare-video-detection-iterators", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } +/* custom analysis */ +// prepare-custom-detection-iterators +const event_StatePrepareCustomDetectionIterators = { + "operation": "prepare-custom-detection-iterators", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + } +/* shared */ +// start-detection-iterator +const event_StateStartDetectionIterator = { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "status": "NOT_STARTED", + "progress": 0, + "data": { + "segment": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/", + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4", + "duration": 284653, + "framerate": 29.97, + "requestTime": 1671693342864, + "minConfidence": 80, + "cursor": 0, + "numOutputs": 0 + } + } + } +// collect-results-iterator +const event_StateCollectResultsIterator = { + "operation": "collect-results-iterator", + "data": { + "segment": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "duration": 284653, + "requestTime": 1671693342864, + "cursor": 0, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-segment-3c193375", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/", + "framerate": 29.97, + "minConfidence": 80, + "startTime": 1671693347323, + "numOutputs": 0, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4", + "jobId": "ab5065a7a83e9f81273f90b9d0449b3494030c53e89d83ea72cd76c5671e6add", + "endTime": 1671693410000 + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "status": "COMPLETED" + } +// create-track-iterator +const event_StateCreateTrackIterator = { + "operation": "create-track-iterator", + "data": { + "segment": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "duration": 284653, + "requestTime": 1671693342864, + "cursor": 0, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-segment-3c193375", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/", + "framerate": 29.97, + "minConfidence": 80, + "startTime": 1671693347323, + "numOutputs": 1, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4", + "jobId": "ab5065a7a83e9f81273f90b9d0449b3494030c53e89d83ea72cd76c5671e6add", + "endTime": 1671693410000 + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "status": "COMPLETED" + } +// index-analysis-iterator +const event_StateIndexAnalysisIterator = { + "operation": "index-analysis-results", + "data": { + "segment": { + "startTime": 1671693347323, + "endTime": 1671693410000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-segment-3c193375", + "jobId": "ab5065a7a83e9f81273f90b9d0449b3494030c53e89d83ea72cd76c5671e6add", + "numOutputs": 1, + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/segment/", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/segment/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/segment/", + "edl": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/edl/segment/" + } + }, + "progress": 100, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "status": "COMPLETED" + } +// job-completed +const event_StateJobCompleted = { + "operation": "job-completed", + "stateExecution": { + "Id": "arn:aws:states:us-west-2:account-number:execution:so0050-0a709c9ee415-analysis-video:3bc3cdb8-44ae-4e7d-8839-f43e3f12ae9b", + "Input": { + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-ingest", + "key": "JUMANJI_Interview/JUMANJI_Interview.mp4", + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "aiOptions": { + "sentiment": true, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "celeb": true, + "frameCaptureMode": 0, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": false, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a709c9ee415-account-number-us-west-2-proxy", + "prefix": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/" + }, + "duration": 284653, + "framerate": 29.97, + "video": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.mp4" + }, + "audio": { + "enabled": true, + "key": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/transcode/aiml/JUMANJI_Interview.m4a" + }, + "image": { + "enabled": false + }, + "document": { + "enabled": false + }, + "request": { + "timestamp": 1671693342864 + }, + "metrics": { + "duration": 284653, + "requestTime": 1671693342864, + "startTime": 1671693344722 + } + }, + "data": {}, + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8" + }, + "StartTime": "2022-12-22T07:15:45.207Z", + "Name": "3bc3cdb8-44ae-4e7d-8839-f43e3f12ae9b", + "RoleArn": "arn:aws:iam::account-number:role/so0050-0a709c9ee415/MyMedia2CloudTest-Backend-AnalysisStateMachineServ-15UVTS9SYPLZU" + }, + "parallelStateOutputs": [ + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "prepare-frame-track-iterators", + "overallStatus": "PROCESSING", + "status": "NOT_STARTED", + "progress": 0, + "data": { + "iterators": [] + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "prepare-video-detection-iterators", + "overallStatus": "PROCESSING", + "status": "NOT_STARTED", + "progress": 0, + "data": { + "iterators": [ + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "celeb": { + "startTime": 1671693347300, + "endTime": 1671693408000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-celeb-9d504a2b", + "jobId": "1da867cde610043c68703babe8ab0833907123aab791e21e6363ca964b8baf46", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/celeb/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/celeb/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/celeb/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/celeb/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "face": { + "startTime": 1671693348465, + "endTime": 1671693451000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-face-7e71b798", + "jobId": "489e381f1e04455241dab9c6d176ba78b8ed27865d84824a37906e50d39e97a0", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/face/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/face/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/face/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/face/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "label": { + "startTime": 1671693347644, + "endTime": 1671693420000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-label-f07df949", + "jobId": "de150001a40f02c5001b30cce62a2e2e1b0d24578ec13243e6a86155a1bf2b0d", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/label/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/label/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/label/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/label/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "moderation": { + "startTime": 1671693348388, + "endTime": 1671693452000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-moderation-00850110", + "jobId": "1a64331d9f1ab72f1d18e4d0a3a936995b4ec337bb16126838a3c3240b038ce4", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/moderation/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/moderation/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/moderation/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/moderation/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "text": { + "startTime": 1671693348376, + "endTime": 1671693418000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-text-336a55e7", + "jobId": "1f07b0a9fb1fd47f67a0a25156582a696405313a26aad0cc7e11a85ff10b6240", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/text/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/text/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/text/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "person": { + "startTime": 1671693347332, + "endTime": 1671693736000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-person-659d1b6d", + "jobId": "e0e1746f94a5ca5832fd6d40726249b17038e07e022064a499d03ad56a1478b1", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/person/mapFile.json", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/person/", + "timeseries": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/timeseries/person/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/person/" + } + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "index-analysis-results", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "data": { + "segment": { + "startTime": 1671693347323, + "endTime": 1671693410000, + "backlogId": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8-segment-3c193375", + "jobId": "ab5065a7a83e9f81273f90b9d0449b3494030c53e89d83ea72cd76c5671e6add", + "numOutputs": 1, + "output": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/raw/20221222T071542/rekognition/segment/", + "metadata": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/metadata/segment/", + "vtt": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/vtt/segment/", + "edl": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8/JUMANJI_Interview/edl/segment/" + } + } + } + ] + } + }, + { + "uuid": "ae67fe18-c8f8-7cff-662a-d8ea2bacd5c8", + "stateMachine": "so0050-0a709c9ee415-analysis-video", + "operation": "prepare-custom-detection-iterators", + "overallStatus": "PROCESSING", + "status": "NOT_STARTED", + "progress": 0, + "data": { + "iterators": [] + } + } + ] + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + describe('#Main/Analysis/Video::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + // These env varialbes are not needed to test functionality + process.env = { + ENV_SOLUTION_ID: 'Test_ENV_Variable', + ENV_RESOURCE_PREFIX: 'Test_ENV_Variable', + ENV_SOLUTION_UUID: 'Test_ENV_Variable', + ENV_ANONYMOUS_USAGE: 'Test_ENV_Variable', + ENV_IOT_HOST: 'Test_ENV_Variable', + ENV_IOT_TOPIC: 'Test_ENV_Variable', + ENV_PROXY_BUCKET: 'Test_ENV_Variable', + ENV_DATA_ACCESS_ROLE: 'Test_ENV_Variable', + ENV_ES_DOMAIN_ENDPOINT: 'Test_ENV_Variable' + }; + + process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; + + }); + + test('Test Lambda handler for state start document ', async () => { + + const response = await lambda.handler(event_StateStartImageAnalysis, context); + // Expected JSON response with correct ID. + expect(response.uuid).toBe('6c56edc5-a973-3485-c9eb-16292d709749'); + }); + + + test('Test StateIndexAnalysisResults', async () => { + // const stateData = lambda.parseEvent(event_StateIndexAnalysisResults, context); + const stateData = new StateData(Environment.StateMachines.DocumentAnalysis, event_StateIndexAnalysisResults, context); + + let instance = new StateIndexAnalysisResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + +}); + + diff --git a/source/main/analysis/video/jest.config.js b/source/main/analysis/video/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/analysis/video/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/analysis/video/setEnvVars.js b/source/main/analysis/video/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/analysis/video/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/analysis/video/states/collect-results-iterator/index.js b/source/main/analysis/video/states/collect-results-iterator/index.js index 616614f..834fa95 100644 --- a/source/main/analysis/video/states/collect-results-iterator/index.js +++ b/source/main/analysis/video/states/collect-results-iterator/index.js @@ -44,25 +44,37 @@ class StateCollectResultsIterator { async process() { const data = this.stateData.data; - const iterator = (data[SUBCATEGORY_CELEB]) - ? new CollectCelebIterator(this.stateData) - : (data[SUBCATEGORY_FACE]) - ? new CollectFaceIterator(this.stateData) - : (data[SUBCATEGORY_FACEMATCH]) - ? new CollectFaceMatchIterator(this.stateData) - : (data[SUBCATEGORY_LABEL]) - ? new CollectLabelIterator(this.stateData) - : (data[SUBCATEGORY_MODERATION]) - ? new CollectModerationIterator(this.stateData) - : (data[SUBCATEGORY_PERSON]) - ? new CollectPersonIterator(this.stateData) - : (data[SUBCATEGORY_SEGMENT]) - ? new CollectSegmentIterator(this.stateData) - : (data[SUBCATEGORY_TEXT]) - ? new CollectTextIterator(this.stateData) - : (data[SUBCATEGORY_CUSTOMLABEL]) - ? new CollectCustomLabelIterator(this.stateData) - : undefined; + let iterator; + if (data[SUBCATEGORY_CELEB]) { + iterator = new CollectCelebIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACE]) { + iterator = new CollectFaceIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACEMATCH]) { + iterator = new CollectFaceMatchIterator(this.stateData); + } + else if (data[SUBCATEGORY_LABEL]) { + iterator = new CollectLabelIterator(this.stateData); + } + else if (data[SUBCATEGORY_MODERATION]) { + iterator = new CollectModerationIterator(this.stateData); + } + else if (data[SUBCATEGORY_PERSON]) { + iterator = new CollectPersonIterator(this.stateData); + } + else if (data[SUBCATEGORY_SEGMENT]) { + iterator = new CollectSegmentIterator(this.stateData); + } + else if (data[SUBCATEGORY_TEXT]) { + iterator = new CollectTextIterator(this.stateData); + } + else if (data[SUBCATEGORY_CUSTOMLABEL]) { + iterator = new CollectCustomLabelIterator(this.stateData); + } + else { + iterator = undefined; + } if (!iterator) { const e = `iterator '${Object.keys(data).join(',')}' not impl`; console.error(e); diff --git a/source/main/analysis/video/states/collect-results-iterator/iterators/collect-text/index.js b/source/main/analysis/video/states/collect-results-iterator/iterators/collect-text/index.js index 266799d..81681bd 100644 --- a/source/main/analysis/video/states/collect-results-iterator/iterators/collect-text/index.js +++ b/source/main/analysis/video/states/collect-results-iterator/iterators/collect-text/index.js @@ -49,9 +49,14 @@ class CollectTextIterator extends BaseCollectResultsIterator { keys = [...new Set(keys)]; while (keys.length) { const key = keys.shift(); - const unique = new Set(mapData[key]); - unique.add(seqFile); - mapData[key] = [...unique]; + try { + /* property name such as 'constructor' will throw error */ + const unique = new Set(mapData[key]); + unique.add(seqFile); + mapData[key] = [...unique]; + } catch (e) { + console.log('[ERR]: mapUniqueNameToSequenceFile: invalid text:', key, e.message); + } } return mapData; } diff --git a/source/main/analysis/video/states/collect-results-iterator/iterators/shared/baseCollectResultsIterator.js b/source/main/analysis/video/states/collect-results-iterator/iterators/shared/baseCollectResultsIterator.js index cdea6c4..0f94509 100644 --- a/source/main/analysis/video/states/collect-results-iterator/iterators/shared/baseCollectResultsIterator.js +++ b/source/main/analysis/video/states/collect-results-iterator/iterators/shared/baseCollectResultsIterator.js @@ -92,7 +92,6 @@ class BaseCollectResultsIterator { /* make sure we allocate enough time for the next iteration */ const remained = this.stateData.getRemainingTime(); const consumed = new Date() - t0; - console.log(`COMPLETED: frame #${data.cursor - 1} [Consumed/Remained: ${consumed / 1000}s / ${remained / 1000}s]`); if (this.stateData.quitNow() || (remained - (consumed * 1.2) <= 0)) { break; } diff --git a/source/main/analysis/video/states/create-track-iterator/index.js b/source/main/analysis/video/states/create-track-iterator/index.js index f1f010e..2cc1275 100644 --- a/source/main/analysis/video/states/create-track-iterator/index.js +++ b/source/main/analysis/video/states/create-track-iterator/index.js @@ -44,25 +44,37 @@ class StateCreateTrackIterator { async process() { const data = this.stateData.data; - const iterator = (data[SUBCATEGORY_CELEB]) - ? new CreateCelebTrackIterator(this.stateData) - : (data[SUBCATEGORY_FACE]) - ? new CreateFaceTrackIterator(this.stateData) - : (data[SUBCATEGORY_FACEMATCH]) - ? new CreateFaceMatchTrackIterator(this.stateData) - : (data[SUBCATEGORY_LABEL]) - ? new CreateLabelTrackIterator(this.stateData) - : (data[SUBCATEGORY_MODERATION]) - ? new CreateModerationTrackIterator(this.stateData) - : (data[SUBCATEGORY_PERSON]) - ? new CreatePersonTrackIterator(this.stateData) - : (data[SUBCATEGORY_SEGMENT]) - ? new CreateSegmentTrackIterator(this.stateData) - : (data[SUBCATEGORY_TEXT]) - ? new CreateTextTrackIterator(this.stateData) - : (data[SUBCATEGORY_CUSTOMLABEL]) - ? new CreateCustomLabelTrackIterator(this.stateData) - : undefined; + let iterator; + if (data[SUBCATEGORY_CELEB]) { + iterator = new CreateCelebTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACE]) { + iterator = new CreateFaceTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACEMATCH]) { + iterator = new CreateFaceMatchTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_LABEL]) { + iterator = new CreateLabelTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_MODERATION]) { + iterator = new CreateModerationTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_PERSON]) { + iterator = new CreatePersonTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_SEGMENT]) { + iterator = new CreateSegmentTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_TEXT]) { + iterator = new CreateTextTrackIterator(this.stateData); + } + else if (data[SUBCATEGORY_CUSTOMLABEL]) { + iterator = new CreateCustomLabelTrackIterator(this.stateData); + } + else { + iterator = undefined; + } if (!iterator) { const e = `iterator '${Object.keys(data).join(',')}' not impl`; console.error(e); diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-celeb-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-celeb-track/index.js index 9127c5d..ae94c7a 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-celeb-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-celeb-track/index.js @@ -35,8 +35,7 @@ class CreateCelebTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { let desc; const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { const box = dataset.Celebrity.BoundingBox || (dataset.Celebrity.Face || {}).BoundingBox; if (!box) { continue; diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-custom-label-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-custom-label-track/index.js index 60dc4dd..a34a40e 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-custom-label-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-custom-label-track/index.js @@ -36,8 +36,7 @@ class CreateCustomLabelTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { const box = ((dataset.CustomLabel.Geometry || {}).BoundingBox) ? { w: Number.parseFloat(dataset.CustomLabel.Geometry.BoundingBox.Width.toFixed(4)), diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-face-match-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-face-match-track/index.js index 14a6102..3c6ffff 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-face-match-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-face-match-track/index.js @@ -26,8 +26,7 @@ class CreateFaceMatchTrackIterator extends BaseCreateTrackIterator { const datasets = await CommonUtils.download(bucket, key) .then(x => JSON.parse(x)); const selected = []; - for (let i = 0; i < datasets.Persons.length; i++) { - const item = datasets.Persons[i]; + for (let item of datasets.Persons) { if (item.FaceMatches && item.FaceMatches.length) { const bestMatch = item.FaceMatches.filter(x => x.Face.ExternalImageId === name) @@ -46,8 +45,7 @@ class CreateFaceMatchTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { const timestamps = {}; let desc; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { if (!(dataset.FaceMatches || [])[0]) { continue; } diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-face-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-face-track/index.js index 72d36eb..9dca4f7 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-face-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-face-track/index.js @@ -31,8 +31,7 @@ class CreateFaceTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { if (!dataset.Face.BoundingBox || !dataset.Face.Gender) { continue; } diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-label-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-label-track/index.js index 9dc8b71..f435ac1 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-label-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-label-track/index.js @@ -35,8 +35,7 @@ class CreateLabelTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { let desc; const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { if (!desc && (dataset.Label.Parents || []).length > 0) { desc = dataset.Label.Parents.map(x => x.Name).join(';'); } diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-moderation-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-moderation-track/index.js index 85f4c65..444d21f 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-moderation-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-moderation-track/index.js @@ -35,8 +35,7 @@ class CreateModerationTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { let desc; const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { if (!desc && dataset.ModerationLabel.Name) { desc = dataset.ModerationLabel.Name; } diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-person-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-person-track/index.js index 97027fe..72c714d 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-person-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-person-track/index.js @@ -35,8 +35,7 @@ class CreatePersonTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { const timestamps = {}; let desc; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { const details = dataset.Person; if (!details.BoundingBox) { continue; diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/create-text-track/index.js b/source/main/analysis/video/states/create-track-iterator/iterators/create-text-track/index.js index 13d7786..7510d6a 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/create-text-track/index.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/create-text-track/index.js @@ -35,8 +35,7 @@ class CreateTextTrackIterator extends BaseCreateTrackIterator { createTimeseriesData(name, datasets) { const timestamps = {}; - for (let i = 0; i < datasets.length; i++) { - const dataset = datasets[i]; + for (let dataset of datasets) { const box = dataset.TextDetection.Geometry.BoundingBox; if (!box) { continue; diff --git a/source/main/analysis/video/states/create-track-iterator/iterators/shared/baseCreateTrackIterator.js b/source/main/analysis/video/states/create-track-iterator/iterators/shared/baseCreateTrackIterator.js index a4047fc..f04236d 100644 --- a/source/main/analysis/video/states/create-track-iterator/iterators/shared/baseCreateTrackIterator.js +++ b/source/main/analysis/video/states/create-track-iterator/iterators/shared/baseCreateTrackIterator.js @@ -175,8 +175,8 @@ class BaseCreateTrackIterator { : undefined; const timelines = []; const queue = new TimelineQ(); - for (let i = 0; i < dataset.length; i++) { - const item = TimelineQ.createTypedItem(dataset[i], options); + for (let data of dataset) { + const item = TimelineQ.createTypedItem(data, options); if (!item.canUse()) { continue; } diff --git a/source/main/analysis/video/states/detect-frame-iterator/index.js b/source/main/analysis/video/states/detect-frame-iterator/index.js index 76a3b45..a111a65 100644 --- a/source/main/analysis/video/states/detect-frame-iterator/index.js +++ b/source/main/analysis/video/states/detect-frame-iterator/index.js @@ -40,23 +40,33 @@ class StateDetectFrameIterator { async process() { const data = this.stateData.data; - const iterator = (data[SUBCATEGORY_LABEL]) - ? new DetectLabelIterator(this.stateData) - : (data[SUBCATEGORY_MODERATION]) - ? new DetectModerationIterator(this.stateData) - : (data[SUBCATEGORY_TEXT]) - ? new DetectTextIterator(this.stateData) - /* use combo detection */ - : (data[SUBCATEGORY_FACE] && (data[SUBCATEGORY_CELEB] || data[SUBCATEGORY_FACEMATCH])) - ? new DetectIdentityComboIterator(this.stateData) - /* no combo detection */ - : (data[SUBCATEGORY_CELEB]) - ? new DetectCelebIterator(this.stateData) - : (data[SUBCATEGORY_FACE]) - ? new DetectFaceIterator(this.stateData) - : (data[SUBCATEGORY_FACEMATCH]) - ? new DetectFaceMatchIterator(this.stateData) - : undefined; + let iterator; + if (data[SUBCATEGORY_LABEL]) { + iterator = new DetectLabelIterator(this.stateData); + } + else if (data[SUBCATEGORY_MODERATION]) { + iterator = new DetectModerationIterator(this.stateData); + } + else if (data[SUBCATEGORY_TEXT]) { + iterator = new DetectTextIterator(this.stateData); + } + /* use combo detection */ + else if (data[SUBCATEGORY_FACE] && (data[SUBCATEGORY_CELEB] || data[SUBCATEGORY_FACEMATCH])) { + iterator = new DetectIdentityComboIterator(this.stateData); + } + else if (data[SUBCATEGORY_CELEB]) { + iterator = new DetectCelebIterator(this.stateData) + } + /* no combo detection */ + else if (data[SUBCATEGORY_FACE]) { + iterator = new DetectFaceIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACEMATCH]) { + iterator = new DetectFaceMatchIterator(this.stateData); + } + else { + iterator = undefined; + } if (!iterator) { const e = `iterator '${Object.keys(data).join(',')}' not impl`; console.error(e); diff --git a/source/main/analysis/video/states/detect-frame-iterator/iterators/detect-face-match/index.js b/source/main/analysis/video/states/detect-frame-iterator/iterators/detect-face-match/index.js index f8c8f97..be060f0 100644 --- a/source/main/analysis/video/states/detect-frame-iterator/iterators/detect-face-match/index.js +++ b/source/main/analysis/video/states/detect-frame-iterator/iterators/detect-face-match/index.js @@ -53,7 +53,7 @@ class DetectFaceMatchIterator extends BaseDetectFrameIterator { FrameNumber: frameNo, Person: { /* use ExternalImageId to generate an unique integer */ - Index: CRYPTO.createHash('md5').update(id).digest() + Index: CRYPTO.createHash('sha256').update(id).digest() .reduce((a0, c0) => a0 + c0, 0), BoundingBox: boundingbox || data.SearchedFaceBoundingBox, Confidence: data.SearchedFaceConfidence, diff --git a/source/main/analysis/video/states/detect-frame-iterator/iterators/shared/baseDetectFrameIterator.js b/source/main/analysis/video/states/detect-frame-iterator/iterators/shared/baseDetectFrameIterator.js index 17ca02e..4b48e5c 100644 --- a/source/main/analysis/video/states/detect-frame-iterator/iterators/shared/baseDetectFrameIterator.js +++ b/source/main/analysis/video/states/detect-frame-iterator/iterators/shared/baseDetectFrameIterator.js @@ -126,7 +126,6 @@ class BaseDetectFrameIterator { ); const name = BaseDetectFrameIterator.makeFrameCaptureFileName(idx); const key = PATH.join(frameCapture.prefix, name); - // console.log(`PROCESSING: [#${idx}]: ${name} (${frameNo} / ${timestamp})`); const dataset = await this.detectFrame(data.bucket, key, frameNo, timestamp); if (dataset) { this.dataset.splice(this.dataset.length, 0, ...dataset); diff --git a/source/main/analysis/video/states/index-analysis-iterator/index.js b/source/main/analysis/video/states/index-analysis-iterator/index.js index fbd1446..87b6d25 100644 --- a/source/main/analysis/video/states/index-analysis-iterator/index.js +++ b/source/main/analysis/video/states/index-analysis-iterator/index.js @@ -44,25 +44,37 @@ class StateIndexAnalysisIterator { async process() { const data = this.stateData.data; - const iterator = (data[SUBCATEGORY_CELEB]) - ? new IndexCelebIterator(this.stateData) - : (data[SUBCATEGORY_FACE]) - ? new IndexFaceIterator(this.stateData) - : (data[SUBCATEGORY_FACEMATCH]) - ? new IndexFaceMatchIterator(this.stateData) - : (data[SUBCATEGORY_LABEL]) - ? new IndexLabelIterator(this.stateData) - : (data[SUBCATEGORY_MODERATION]) - ? new IndexModerationIterator(this.stateData) - : (data[SUBCATEGORY_PERSON]) - ? new IndexPersonIterator(this.stateData) - : (data[SUBCATEGORY_SEGMENT]) - ? new IndexSegmentIterator(this.stateData) - : (data[SUBCATEGORY_TEXT]) - ? new IndexTextIterator(this.stateData) - : (data[SUBCATEGORY_CUSTOMLABEL]) - ? new IndexCustomLabelIterator(this.stateData) - : undefined; + let iterator; + if (data[SUBCATEGORY_CELEB]) { + iterator = new IndexCelebIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACE]) { + iterator = new IndexFaceIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACEMATCH]) { + iterator = new IndexFaceMatchIterator(this.stateData); + } + else if (data[SUBCATEGORY_LABEL]) { + iterator = new IndexLabelIterator(this.stateData); + } + else if (data[SUBCATEGORY_MODERATION]) { + iterator = new IndexModerationIterator(this.stateData); + } + else if (data[SUBCATEGORY_PERSON]) { + iterator = new IndexPersonIterator(this.stateData); + } + else if (data[SUBCATEGORY_SEGMENT]) { + iterator = new IndexSegmentIterator(this.stateData); + } + else if (data[SUBCATEGORY_TEXT]) { + iterator = new IndexTextIterator(this.stateData); + } + else if (data[SUBCATEGORY_CUSTOMLABEL]) { + iterator = new IndexCustomLabelIterator(this.stateData); + } + else { + iterator = undefined; + } if (!iterator) { const e = `iterator '${Object.keys(data).join(',')}' not impl`; console.error(e); diff --git a/source/main/analysis/video/states/start-detection-iterator/index.js b/source/main/analysis/video/states/start-detection-iterator/index.js index bdffb38..119190a 100644 --- a/source/main/analysis/video/states/start-detection-iterator/index.js +++ b/source/main/analysis/video/states/start-detection-iterator/index.js @@ -44,25 +44,37 @@ class StateStartDetectionIterator { async process() { const data = this.stateData.data; - const iterator = (data[SUBCATEGORY_CELEB]) - ? new StartCelebIterator(this.stateData) - : (data[SUBCATEGORY_FACE]) - ? new StartFaceIterator(this.stateData) - : (data[SUBCATEGORY_FACEMATCH]) - ? new StartFaceMatchIterator(this.stateData) - : (data[SUBCATEGORY_LABEL]) - ? new StartLabelIterator(this.stateData) - : (data[SUBCATEGORY_MODERATION]) - ? new StartModerationIterator(this.stateData) - : (data[SUBCATEGORY_PERSON]) - ? new StartPersonIterator(this.stateData) - : (data[SUBCATEGORY_SEGMENT]) - ? new StartSegmentIterator(this.stateData) - : (data[SUBCATEGORY_TEXT]) - ? new StartTextIterator(this.stateData) - : (data[SUBCATEGORY_CUSTOMLABEL]) - ? new StartCustomLabelIterator(this.stateData) - : undefined; + let iterator; + if (data[SUBCATEGORY_CELEB]) { + iterator = new StartCelebIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACE]) { + iterator = new StartFaceIterator(this.stateData); + } + else if (data[SUBCATEGORY_FACEMATCH]) { + iterator = new StartFaceMatchIterator(this.stateData); + } + else if (data[SUBCATEGORY_LABEL]) { + iterator = new StartLabelIterator(this.stateData); + } + else if (data[SUBCATEGORY_MODERATION]) { + iterator = new StartModerationIterator(this.stateData); + } + else if (data[SUBCATEGORY_PERSON]) { + iterator = new StartPersonIterator(this.stateData); + } + else if (data[SUBCATEGORY_SEGMENT]) { + iterator = new StartSegmentIterator(this.stateData); + } + else if (data[SUBCATEGORY_TEXT]) { + iterator = new StartTextIterator(this.stateData); + } + else if (data[SUBCATEGORY_CUSTOMLABEL]) { + iterator = new StartCustomLabelIterator(this.stateData); + } + else { + iterator = undefined; + } if (!iterator) { const e = `iterator '${Object.keys(data).join(',')}' not impl`; console.error(e); diff --git a/source/main/automation/error-handler/README.md b/source/main/automation/error-handler/README.md index 5d4ee61..0e82e4e 100644 --- a/source/main/automation/error-handler/README.md +++ b/source/main/automation/error-handler/README.md @@ -74,7 +74,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/automation/error-handler/index.js b/source/main/automation/error-handler/index.js index f6b99f9..24e044e 100644 --- a/source/main/automation/error-handler/index.js +++ b/source/main/automation/error-handler/index.js @@ -105,11 +105,17 @@ exports.handler = async (event, context) => { const stateMachine = event.detail.executionArn.split(':')[6]; const uuid = input.uuid || (input.input || {}).uuid; const overallStatus = StateData.Statuses.Error; - const status = (stateMachine === Environment.StateMachines.Ingest) - ? StateData.Statuses.IngestError - : (stateMachine === Environment.StateMachines.Analysis) - ? StateData.Statuses.AnalysisError - : StateData.Statuses.Error; + let status; + switch (stateMachine) { + case Environment.StateMachines.Ingest: + status = StateData.Statuses.IngestError; + break; + case Environment.StateMachines.Analysis: + status = StateData.Statuses.AnalysisError; + break; + default: + status = StateData.Statuses.Error; + } if (uuid) { /* update status */ diff --git a/source/main/automation/error-handler/jest.config.js b/source/main/automation/error-handler/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/automation/error-handler/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/automation/error-handler/package.json b/source/main/automation/error-handler/package.json index d6696bd..1b5430a 100644 --- a/source/main/automation/error-handler/package.json +++ b/source/main/automation/error-handler/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index*.js package.json dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../../../layers/core-lib" + "core-lib": "file:../../../layers/core-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/automation/s3event/index.js b/source/main/automation/s3event/index.js index 16a9923..afbf6e6 100644 --- a/source/main/automation/s3event/index.js +++ b/source/main/automation/s3event/index.js @@ -166,13 +166,13 @@ exports.handler = async (event, context) => { throw new Error('accountId not found'); } - const data = event.Records[0]; - const bucket = data.s3.bucket.name; + const bucket = event.detail.bucket.name; /* unescape 'space' character */ - const key = decodeURIComponent(data.s3.object.key.replace(/\+/g, '%20')); + const key = event.detail.object.key; + const size = event.detail.object.size; /* zero byte size (ie. folder), skip */ - if (data.s3.object.size === 0) { + if (size === 0) { return undefined; } diff --git a/source/main/ingest/audio/README.md b/source/main/ingest/audio/README.md index c2824ac..af7d6a2 100644 --- a/source/main/ingest/audio/README.md +++ b/source/main/ingest/audio/README.md @@ -99,7 +99,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/audio/index.spec.js b/source/main/ingest/audio/index.spec.js new file mode 100644 index 0000000..dd4cf1c --- /dev/null +++ b/source/main/ingest/audio/index.spec.js @@ -0,0 +1,230 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); + const RunMediainfo = require('./states/run-mediainfo'); + const StartTranscode = require('./states/start-transcode'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_RunMediainfo = { + "operation": "run-mediainfo", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "type": "audio" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 2722840, + "computed": "ac5cfe51b37d5711de590746ba461bfe", + "storeChecksumOnTagging": true, + "startTime": 1675409787118, + "endTime": 1675409787334, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + } + }, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500" + } + +const event_StartTranscode = { + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "stateMachine": "so0050-0a9ab6b1a00f-ingest-main", + "operation": "run-mediainfo", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "type": "audio", + "duration": 453799 + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 2722840, + "computed": "ac5cfe51b37d5711de590746ba461bfe", + "storeChecksumOnTagging": true, + "startTime": 1675409787118, + "endTime": 1675409787334, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + }, + "mediainfo": { + "container": [ + { + "format": "MPEG Audio", + "fileSize": 2722840, + "duration": 453.799, + "overallBitRate": 48000 + } + ], + "audio": [ + { + "format": "MPEG Audio", + "bitRateMode": "CBR", + "bitRate": 48000, + "channels": 1, + "samplesPerFrame": 576, + "samplingRate": 22050 + } + ], + "video": [], + "output": [ + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.json", + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.xml" + ] + } + } + } + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the run mediainfo', async () => { + const stateData = new StateData(Environment.StateMachines.AudioIngest, event_RunMediainfo, context); + + let instance = new RunMediainfo(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the run mediainfo', async () => { + const stateData = new StateData(Environment.StateMachines.AudioIngest, event_StartTranscode, context); + + let instance = new StartTranscode(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + +}); + + diff --git a/source/main/ingest/audio/jest.config.js b/source/main/ingest/audio/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/audio/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/audio/package.json b/source/main/ingest/audio/package.json index 006727a..7a857a4 100644 --- a/source/main/ingest/audio/package.json +++ b/source/main/ingest/audio/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,9 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "mediainfo": "file:../../../layers/mediainfo" + "service-backlog-lib": "file:../../../layers/service-backlog-lib", + "mediainfo": "file:../../../layers/mediainfo", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/audio/setEnvVars.js b/source/main/ingest/audio/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/audio/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/audio/states/start-transcode/index.js b/source/main/ingest/audio/states/start-transcode/index.js index 3f07932..62f1ab4 100644 --- a/source/main/ingest/audio/states/start-transcode/index.js +++ b/source/main/ingest/audio/states/start-transcode/index.js @@ -1,14 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - -const AWS = (() => { - try { - const AWSXRay = require('aws-xray-sdk'); - return AWSXRay.captureAWS(require('aws-sdk')); - } catch (e) { - return require('aws-sdk'); - } -})(); const FS = require('fs'); const PATH = require('path'); const { @@ -16,7 +7,13 @@ const { StateData, ServiceToken, TranscodeError, + CommonUtils, } = require('core-lib'); +const { + BacklogClient: { + MediaConvertBacklogJob, + }, +} = require('service-backlog-lib'); const CATEGORY = 'transcode'; const API_NAME = 'audio'; @@ -67,40 +64,47 @@ class StateStartTranscode { if (!data.mediainfo) { throw new TranscodeError('missing mediainfo'); } - const response = await this.createJob(); + + const params = await this.createJobTemplate(); /* done with mediainfo, remove mediainfo block to reduce the stateData payload size */ [ 'video', 'audio', 'container', - ].forEach(x => delete data.mediainfo[x]); + ].forEach(x => + delete data.mediainfo[x]); + + const stateOutput = await this.createJob(params); const output = this.makeOutputPrefix(dest.prefix); this.stateData.setStarted(); this.stateData.setData(CATEGORY, { - startTime: new Date().getTime(), - jobId: response.Job.Id, + ...stateOutput, output, }); + const id = stateOutput.backlogId; + const responseData = this.stateData.toJSON(); await ServiceToken.register( - this.stateData.data[CATEGORY].jobId, + id, this.stateData.event.token, CATEGORY, API_NAME, - this.stateData.toJSON() + responseData ); - return this.stateData.toJSON(); + return responseData; } - async createJob() { - const template = await this.createJobTemplate(); - const mediaconvert = new AWS.MediaConvert({ - apiVersion: '2017-08-29', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - endpoint: Environment.MediaConvert.Host, - }); - return mediaconvert.createJob(template).promise(); + async createJob(params) { + /* use backlog system */ + const uniqueId = CommonUtils.uuid4(); + + const backlog = new MediaConvertBacklogJob(); + return backlog.createJob(uniqueId, params) + .then(() => ({ + startTime: new Date().getTime(), + backlogId: uniqueId, + })); } async createJobTemplate() { @@ -108,7 +112,7 @@ class StateStartTranscode { const outputGroup = this.makeOutputGroup(); const audioSelectorName = 'Audio Selector 1'; const template = { - Role: Environment.MediaConvert.Role, + Role: Environment.DataAccess.RoleArn, Settings: { OutputGroups: outputGroup, AdAvailOffset: 0, diff --git a/source/main/ingest/automation/README.md b/source/main/ingest/automation/README.md index 0ab97f4..8f362c2 100644 --- a/source/main/ingest/automation/README.md +++ b/source/main/ingest/automation/README.md @@ -105,7 +105,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/automation/status-updater/index.js b/source/main/ingest/automation/status-updater/index.js index 9628b26..0171b0a 100644 --- a/source/main/ingest/automation/status-updater/index.js +++ b/source/main/ingest/automation/status-updater/index.js @@ -28,7 +28,8 @@ const DDB_STREAM_SOURCE = 'aws:dynamodb'; exports.handler = async (event, context) => { console.log(`event = ${JSON.stringify(event, null, 2)}; context = ${JSON.stringify(context, null, 2)};`); try { - const missing = REQUIRED_ENVS.filter(x => process.env[x] === undefined); + const missing = REQUIRED_ENVS.filter(x => + process.env[x] === undefined); if (missing.length) { throw new Error(`missing env, ${missing.join(', ')}`); } diff --git a/source/main/ingest/automation/status-updater/index.spec.js b/source/main/ingest/automation/status-updater/index.spec.js new file mode 100644 index 0000000..7aaf9be --- /dev/null +++ b/source/main/ingest/automation/status-updater/index.spec.js @@ -0,0 +1,103 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +const CloudWatchStatus = require('./lib/cloudwatch'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_CloudWatchStatus = { + "operation": "create-record", + "input": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + } + }, + "executionArn": "arn:aws:states:us-east-1:account-number:execution:so0050-0a9ab6b1a00f-ingest-main:b13ecca9-ad1e-440a-9773-1e1bc6095a0f" + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the CloudWatchStatus', async () => { + const stateData = new StateData(Environment.StateMachines.CloudWatchStatus, event_CloudWatchStatus, context); + + let instance = new CloudWatchStatus(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + + +}); + + diff --git a/source/main/ingest/automation/status-updater/jest.config.js b/source/main/ingest/automation/status-updater/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/automation/status-updater/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/automation/status-updater/lib/cloudwatch/index.js b/source/main/ingest/automation/status-updater/lib/cloudwatch/index.js index d4a0f54..756b5a6 100644 --- a/source/main/ingest/automation/status-updater/lib/cloudwatch/index.js +++ b/source/main/ingest/automation/status-updater/lib/cloudwatch/index.js @@ -15,6 +15,16 @@ const { } = require('core-lib'); const MediaConvertStatusChangeEvent = require('./mediaConvertStatusChangeEvent'); +const BACKLOG_SOURCE_TYPE = 'custom.servicebacklog'; +const SERVICE_MEDIACONVERT = 'mediaconvert:'; + +const EXCEPTION_TASK_TIMEOUT = 'TaskTimedOut'; +const EXCEPTION_TASK_NOTEXIST = 'TaskDoesNotExist'; +const IGNORED_EXECEPTION_LIST = [ + EXCEPTION_TASK_TIMEOUT, + EXCEPTION_TASK_NOTEXIST, +]; + class CloudWatchStatus { constructor(event, context) { this.$event = event; @@ -65,11 +75,13 @@ class CloudWatchStatus { async process() { let instance; - if (this.source === MediaConvertStatusChangeEvent.SourceType) { - instance = new MediaConvertStatusChangeEvent(this); + if (this.source === BACKLOG_SOURCE_TYPE) { + if (this.detail.serviceApi.indexOf(SERVICE_MEDIACONVERT) === 0) { + instance = new MediaConvertStatusChangeEvent(this); + } } if (!instance) { - throw new JobStatusError(`${this.source} not supported`); + throw new JobStatusError(`${this.source}: ${this.detail.serviceApi}: not supported`); } return instance.process(); } @@ -81,7 +93,14 @@ class CloudWatchStatus { })).sendTaskSuccess({ output: JSON.stringify(this.stateData.toJSON()), taskToken: this.token, - }).promise(); + }).promise() + .catch((e) => { + if (IGNORED_EXECEPTION_LIST.indexOf(e.code) >= 0) { + return undefined; + } + console.log(`[ERR]: sendTaskSuccess: ${e.code}: ${e.message}`, JSON.stringify(this.stateData.toJSON())); + throw e; + }); } async sendTaskFailure(error) { @@ -92,7 +111,14 @@ class CloudWatchStatus { taskToken: this.token, error: error.name, cause: error.message, - }).promise(); + }).promise() + .catch((e) => { + if (e.code === EXCEPTION_TASK_TIMEOUT) { + return undefined; + } + console.log(`[ERR]: sendTaskFailure: ${e.code}: ${e.message}`, error.name, error.message); + throw e; + }); } } diff --git a/source/main/ingest/automation/status-updater/lib/cloudwatch/mediaConvertStatusChangeEvent.js b/source/main/ingest/automation/status-updater/lib/cloudwatch/mediaConvertStatusChangeEvent.js index 86d8838..0b0e445 100644 --- a/source/main/ingest/automation/status-updater/lib/cloudwatch/mediaConvertStatusChangeEvent.js +++ b/source/main/ingest/automation/status-updater/lib/cloudwatch/mediaConvertStatusChangeEvent.js @@ -8,57 +8,25 @@ const { ServiceToken, } = require('core-lib'); +const STATUS_SUCCEEDED = 'COMPLETE'; +const STATUS_FAILED = [ + 'CANCELED', + 'ERROR', +]; +const ALLOWED_STATUSES = [ + ...STATUS_FAILED, + STATUS_SUCCEEDED, +]; + class MediaConvertStatusChangeEvent { constructor(parent) { this.$parent = parent; - this.$service = undefined; - this.$api = undefined; - } - - static get SourceType() { - return 'aws.mediaconvert'; - } - - static get Mapping() { - return { - SUBMITTED: StateData.Statuses.Started, - PROGRESSING: StateData.Statuses.InProgress, - STATUS_UPDATE: StateData.Statuses.InProgress, - COMPLETE: StateData.Statuses.Completed, - CANCELED: StateData.Statuses.Error, - ERROR: StateData.Statuses.Error, - }; - } - - static get Event() { - return { - Completed: 'COMPLETE', - Canceled: 'CANCELED', - Error: 'ERROR', - InProgress: 'STATUS_UPDATE', - }; } get parent() { return this.$parent; } - get api() { - return this.$api; - } - - set api(val) { - this.$api = val; - } - - get service() { - return this.$service; - } - - set service(val) { - this.$service = val; - } - get event() { return this.parent.event; } @@ -87,104 +55,66 @@ class MediaConvertStatusChangeEvent { this.parent.stateData = val; } - get status() { - return this.detail.status; + get backlogId() { + return this.detail.id; } get jobId() { return this.detail.jobId; } - get timestamp() { - return this.detail.timestamp; - } - - get outputGroupDetails() { - return this.detail.outputGroupDetails; - } - - get jobPercentComplete() { - return (this.detail.jobProgress || {}).jobPercentComplete || 0; - } - - get errorMessage() { - return this.detail.errorMessage; + get status() { + return this.detail.status; } - get errorCode() { - return this.detail.errorCode; + get timestamp() { + return new Date(this.event.time).getTime(); } async process() { - const response = await ServiceToken.getData(this.jobId).catch(() => undefined); + if (ALLOWED_STATUSES.indexOf(this.status) < 0) { + console.error(`ERR: MediaConvertStatusChangeEvent.process: ${this.status} status not handled`); + return undefined; + } + + const response = await ServiceToken.getData(this.backlogId) + .catch(() => + undefined); if (!response || !response.service || !response.token || !response.api) { throw new JobStatusError(`fail to get token, ${this.jobId}`); } - this.token = response.token; - this.service = response.service; - this.api = response.api; - + const stateMachine = (response.api === 'audio') + ? Environment.StateMachines.AudioIngest + : Environment.StateMachines.VideoIngest; + response.data.data[response.service] = { + ...response.data.data[response.service], + jobId: this.jobId, + endTime: this.timestamp, + }; this.stateData = new StateData( - Environment.StateMachines.Ingest, + stateMachine, response.data, this.context ); + this.token = response.token; - let completed = true; - switch (this.status) { - case MediaConvertStatusChangeEvent.Event.Completed: - await this.onCompleted(); - break; - case MediaConvertStatusChangeEvent.Event.InProgress: - await this.onProgress(); - completed = false; - break; - case MediaConvertStatusChangeEvent.Event.Canceled: - case MediaConvertStatusChangeEvent.Event.Error: - default: - await this.onError(); - break; - } - if (completed) { - await ServiceToken.unregister(this.jobId).catch(() => undefined); + if (this.status === STATUS_SUCCEEDED) { + this.stateData.setCompleted(); + await this.parent.sendTaskSuccess(); + } else { + const error = (this.errorMessage) + ? new JobStatusError(this.errorMessage) + : new JobStatusError(`${this.jobId} ${this.status}`); + this.stateData.setFailed(error); + await this.parent.sendTaskFailure(error); } + /* #4: remove record from service token table */ + await ServiceToken.unregister(this.backlogId) + .catch(() => + undefined); return this.stateData.toJSON(); } - - async onCompleted() { - this.stateData.setData(this.service, { - ...this.stateData.data[this.service], - jobId: this.jobId, - endTime: this.timestamp, - }); - - this.stateData.setCompleted(); - return this.parent.sendTaskSuccess(); - } - - async onError() { - const error = (this.status === MediaConvertStatusChangeEvent.Event.Canceled) - ? new JobStatusError('user canceled job') - : (this.status === MediaConvertStatusChangeEvent.Event.Error) - ? new JobStatusError(`${this.errorMessage} (${this.errorCode})`) - : new JobStatusError(); - - this.stateData.setData(this.service, { - ...this.stateData.data[this.service], - jobId: this.jobId, - timestamp: this.timestamp, - status: MediaConvertStatusChangeEvent.Mapping[this.status] || StateData.Statuses.Error, - }); - - this.stateData.setFailed(error); - return this.parent.sendTaskFailure(error); - } - - async onProgress() { - this.stateData.setProgress(this.jobPercentComplete); - return undefined; - } } module.exports = MediaConvertStatusChangeEvent; diff --git a/source/main/ingest/automation/status-updater/lib/ddbstream/index.js b/source/main/ingest/automation/status-updater/lib/ddbstream/index.js index e884f53..7d45a23 100644 --- a/source/main/ingest/automation/status-updater/lib/ddbstream/index.js +++ b/source/main/ingest/automation/status-updater/lib/ddbstream/index.js @@ -98,8 +98,8 @@ class DDBStreamEvent { return false; } /* if object is one of the prefixes, don't delete the object */ - for (let i = 0; i < PROXY_PREFIXES.length; i++) { - if (x.Key.indexOf(PROXY_PREFIXES[i]) > 0) { + for (let prefix of PROXY_PREFIXES) { + if (x.Key.indexOf(prefix) > 0) { return false; } } @@ -214,14 +214,21 @@ class DDBStreamEvent { } async process() { - const responses = await Promise.all(this.records.map((x) => - ((x.event === EVENT_REMOVE) - ? this.onRemoveEvent(x) - : (x.event === EVENT_INSERT) - ? this.onInsertEvent(x) - : (x.event === EVENT_MODIFY) - ? this.onModifyEvent(x) - : undefined))); + const responses = await Promise.all(this.records.map((x) => { + switch (x.event) { + case EVENT_REMOVE: + this.onRemoveEvent(x); + break; + case EVENT_INSERT: + this.onInsertEvent(x); + break; + case EVENT_MODIFY: + this.onModifyEvent(x); + break; + default: + return undefined; + } + })); return responses; } } diff --git a/source/main/ingest/automation/status-updater/package.json b/source/main/ingest/automation/status-updater/package.json index e1a1bbf..18a2a08 100644 --- a/source/main/ingest/automation/status-updater/package.json +++ b/source/main/ingest/automation/status-updater/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json lib dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -15,6 +15,8 @@ }, "author": "aws-mediaent-solutions", "devDependencies": { - "core-lib": "file:../../../../layers/core-lib" + "core-lib": "file:../../../../layers/core-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/automation/status-updater/setEnvVars.js b/source/main/ingest/automation/status-updater/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/automation/status-updater/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/document/README.md b/source/main/ingest/document/README.md index 5a251b8..01432b1 100644 --- a/source/main/ingest/document/README.md +++ b/source/main/ingest/document/README.md @@ -59,7 +59,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/document/index.js b/source/main/ingest/document/index.js index db1a116..51661e4 100644 --- a/source/main/ingest/document/index.js +++ b/source/main/ingest/document/index.js @@ -27,12 +27,8 @@ exports.handler = async (event, context) => { const stateData = new StateData(Environment.StateMachines.Ingest, event, context); let instance; - switch (event.operation) { - case StateData.States.RunDocInfo: - instance = new StateRunDocInfo(stateData); - break; - default: - break; + if (event.operation === StateData.States.RunDocInfo) { + instance = new StateRunDocInfo(stateData); } if (!instance) { throw new IngestError(`${event.operation} not supported`); diff --git a/source/main/ingest/document/index.spec.js b/source/main/ingest/document/index.spec.js new file mode 100644 index 0000000..f0d5148 --- /dev/null +++ b/source/main/ingest/document/index.spec.js @@ -0,0 +1,124 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); + const StateRunDocInfo = require('./states/run-docinfo'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateRunDocInfo = { + "operation": "run-docinfo", + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "2022_Columbia Sportswear_Employee Store Invite/2022_Columbia Sportswear_Employee Store Invite.pdf", + "uuid": "cf9a0540-4efb-c826-c03d-3c969e4015ab", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "cf9a0540-4efb-c826-c03d-3c969e4015ab/2022_Columbia_Sportswear_Employee_Store_Invite/" + }, + "type": "document" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675386493721, + "endTime": 1675386493721 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 780806, + "computed": "86814cf7dcc707d6b1598d5309d42e20", + "storeChecksumOnTagging": true, + "startTime": 1675386493942, + "endTime": 1675386494082, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + } + }, + "progress": 0, + "uuid": "cf9a0540-4efb-c826-c03d-3c969e4015ab", + "status": "NOT_STARTED" + } + + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateRunDocInfo ingest state', async () => { + const stateData = new StateData(Environment.StateMachines.DocumentIngest, event_StateRunDocInfo, context); + + let instance = new RunMediainfo(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/main/ingest/document/jest.config.js b/source/main/ingest/document/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/document/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/document/package.json b/source/main/ingest/document/package.json index 5f8a791..dee9189 100644 --- a/source/main/ingest/document/package.json +++ b/source/main/ingest/document/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -17,6 +17,8 @@ "devDependencies": { "canvas": "file:../../../layers/canvas-lib", "core-lib": "file:../../../layers/core-lib", - "pdfjs-dist": "file:../../../layers/pdf-lib" + "pdfjs-dist": "file:../../../layers/pdf-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/document/setEnvVars.js b/source/main/ingest/document/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/document/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/fixity/index.spec.js b/source/main/ingest/fixity/index.spec.js new file mode 100644 index 0000000..82a727e --- /dev/null +++ b/source/main/ingest/fixity/index.spec.js @@ -0,0 +1,248 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +const StateCheckRestoreStatus = require('./states/check-restore-status'); +const StateComputeChecksum = require('./states/compute-checksum'); +const StateValidateChecksum = require('./states/validate-checksum'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateCheckRestoreStatus = { + "operation": "check-restore-status", + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "type": "audio" + }, + "data": {}, + "progress": 100, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "status": "INGEST_STARTED" + } + +const event_StateComputeChecksum = { + "operation": "compute-checksum", + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "type": "audio" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + } + }, + "progress": 100, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "status": "COMPLETED" + } + +const event_StateValidateChecksum = { + "operation": "validate-checksum", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "type": "audio" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 2722840, + "computed": "ac5cfe51b37d5711de590746ba461bfe", + "storeChecksumOnTagging": true, + "startTime": 1675409787118, + "endTime": 1675409787334 + } + }, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500" + } + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateCheckRestoreStatus', async () => { + const stateData = new StateData(Environment.StateMachines.FixityIngest, event_StateCheckRestoreStatus, context); + + let instance = new StateCheckRestoreStatus(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateComputeChecksum', async () => { + const stateData = new StateData(Environment.StateMachines.FixityIngest, event_StateComputeChecksum, context); + + let instance = new StateComputeChecksum(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateValidateChecksum', async () => { + const stateData = new StateData(Environment.StateMachines.FixityIngest, event_StateValidateChecksum, context); + + let instance = new StateValidateChecksum(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + +}); + + diff --git a/source/main/ingest/fixity/jest.config.js b/source/main/ingest/fixity/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/fixity/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/fixity/package.json b/source/main/ingest/fixity/package.json index 4f93c80..28a1585 100644 --- a/source/main/ingest/fixity/package.json +++ b/source/main/ingest/fixity/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "fixity-lib": "file:../../../layers/fixity-lib" + "fixity-lib": "file:../../../layers/fixity-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/fixity/setEnvVars.js b/source/main/ingest/fixity/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/fixity/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/fixity/states/compute-checksum/algorithm/base.js b/source/main/ingest/fixity/states/compute-checksum/algorithm/base.js index bedc1b5..6e975eb 100644 --- a/source/main/ingest/fixity/states/compute-checksum/algorithm/base.js +++ b/source/main/ingest/fixity/states/compute-checksum/algorithm/base.js @@ -20,7 +20,7 @@ class BaseLib { this.$intermediateHash = checksum.intermediateHash || undefined; this.$computed = checksum.computed || undefined; this.$expected = checksum.expected || undefined; - this.$storeChecksumOnTagging = !(checksum.storeChecksumOnTagging === false); + this.$storeChecksumOnTagging = (checksum.storeChecksumOnTagging !== false); this.$bytesRead = 0; this.$startTime = checksum.startTime || new Date().getTime(); } diff --git a/source/main/ingest/fixity/states/validate-checksum/index.js b/source/main/ingest/fixity/states/validate-checksum/index.js index e31753c..c092ffe 100644 --- a/source/main/ingest/fixity/states/validate-checksum/index.js +++ b/source/main/ingest/fixity/states/validate-checksum/index.js @@ -31,7 +31,7 @@ class StateValidateChecksum { this.$computed = checksum.computed; this.$algorithm = checksum.algorithm || undefined; this.$expected = checksum.expected || undefined; - this.$storeChecksumOnTagging = !(checksum.storeChecksumOnTagging === false); + this.$storeChecksumOnTagging = (checksum.storeChecksumOnTagging !== false); this.$comparedWith = checksum.expected ? TYPE_API : TYPE_NONE; this.$comparedResult = RESULT_SKIPPED; this.$tagUpdated = false; @@ -93,11 +93,15 @@ class StateValidateChecksum { this.tags = await this.getTags(); /* #1: compared checksum result */ const refChecksum = this.expected || await this.bestGuessChecksum(); - this.comparedResult = (!refChecksum) - ? RESULT_SKIPPED - : (refChecksum.toLowerCase() === this.computed.toLowerCase()) - ? RESULT_MATCHED - : RESULT_NOTMATCHED; + if (!refChecksum) { + this.comparedResult = RESULT_SKIPPED; + } + else if (refChecksum.toLowerCase() === this.computed.toLowerCase()) { + this.comparedResult = RESULT_MATCHED; + } + else { + this.comparedResult = RESULT_NOTMATCHED; + } /* #2: store checksum to tagging if MATCHED or SKIPPED */ if (this.storeChecksumOnTagging && this.comparedResult !== RESULT_NOTMATCHED) { diff --git a/source/main/ingest/image/README.md b/source/main/ingest/image/README.md index c1ca00e..54172ef 100644 --- a/source/main/ingest/image/README.md +++ b/source/main/ingest/image/README.md @@ -49,7 +49,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/image/index.js b/source/main/ingest/image/index.js index 4ae4e58..9f5b6bf 100644 --- a/source/main/ingest/image/index.js +++ b/source/main/ingest/image/index.js @@ -27,12 +27,8 @@ exports.handler = async (event, context) => { const stateData = new StateData(Environment.StateMachines.Ingest, event, context); let instance; - switch (event.operation) { - case StateData.States.RunImageInfo: - instance = new StateRunImageInfo(stateData); - break; - default: - break; + if (event.operation === StateData.States.RunImageInfo) { + instance = new StateRunImageInfo(stateData); } if (!instance) { throw new IngestError(`${event.operation} not supported`); diff --git a/source/main/ingest/image/index.spec.js b/source/main/ingest/image/index.spec.js new file mode 100644 index 0000000..1211113 --- /dev/null +++ b/source/main/ingest/image/index.spec.js @@ -0,0 +1,123 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +const StateRunImageInfo = require('./states/run-imageinfo'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateRunImageInfo = { + "operation": "run-imageinfo", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "bigtree/bigtree.jpeg", + "uuid": "6c3d2a4b-4cbb-2563-c693-b4514c0c0a49", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "6c3d2a4b-4cbb-2563-c693-b4514c0c0a49/bigtree/" + }, + "type": "image" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675386439171, + "endTime": 1675386439171 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 236348, + "computed": "023ed34e2a016a1b080a60b3aa79665c", + "storeChecksumOnTagging": true, + "startTime": 1675386439358, + "endTime": 1675386439456, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + } + }, + "uuid": "6c3d2a4b-4cbb-2563-c693-b4514c0c0a49" + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateRunDocInfo ingest state', async () => { + const stateData = new StateData(Environment.StateMachines.ImageIngest, event_StateRunImageInfo, context); + + let instance = new StateRunImageInfo(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/main/ingest/image/jest.config.js b/source/main/ingest/image/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/image/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/image/package.json b/source/main/ingest/image/package.json index 7c4d12d..cbb4e3a 100644 --- a/source/main/ingest/image/package.json +++ b/source/main/ingest/image/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "image-process-lib": "file:../../../layers/image-process-lib" + "image-process-lib": "file:../../../layers/image-process-lib", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/image/setEnvVars.js b/source/main/ingest/image/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/image/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/image/states/run-imageinfo/imageProcess.js b/source/main/ingest/image/states/run-imageinfo/imageProcess.js index 6b488dc..1fa83ba 100644 --- a/source/main/ingest/image/states/run-imageinfo/imageProcess.js +++ b/source/main/ingest/image/states/run-imageinfo/imageProcess.js @@ -65,7 +65,7 @@ class ImageProcess { } if (x0.indexOf('rotate' >= 0)) { - const matched = x0.match(/rotate\s([0-9]+)/); + const matched = x0.match(/rotate\s(\d+)/); if (matched) { response.rotate = Number.parseInt(matched[1], 10); } @@ -95,15 +95,6 @@ class ImageProcess { image = image.scale(factor); } - /* - if (orient.flipH || orient.flipV) { - image = image.mirror(orient.flipH, orient.flipV); - } - if (orient.rotate) { - image = image.rotate(orient.rotate); - } - */ - /* Max image size allowed for Rekognition is 15MB */ let buf = await image.getBufferAsync(Jimp.MIME_JPEG); if (buf.byteLength > MAX_IMAGE_SIZE) { diff --git a/source/main/ingest/main/README.md b/source/main/ingest/main/README.md index 01e162a..82e6aef 100644 --- a/source/main/ingest/main/README.md +++ b/source/main/ingest/main/README.md @@ -150,7 +150,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/main/index.js b/source/main/ingest/main/index.js index 4415367..d3c46e3 100644 --- a/source/main/ingest/main/index.js +++ b/source/main/ingest/main/index.js @@ -33,17 +33,22 @@ exports.handler = async (event, context) => { if (missing.length) { throw new IngestError(`missing enviroment variables, ${missing.join(', ')}`); } - const modified = (event.nestedStateOutput === undefined) - ? event - : (event.nestedStateOutput.ExecutionArn) - ? { - ...JSON.parse(event.nestedStateOutput.Output), - operation: event.operation, - } - : { - ...event.nestedStateOutput, - operation: event.operation, - }; + let modified; + if (event.nestedStateOutput === undefined) { + modified = event; + } + else if (event.nestedStateOutput.ExecutionArn) { + modified = { + ...JSON.parse(event.nestedStateOutput.Output), + operation: event.operation, + }; + } + else { + modified = { + ...event.nestedStateOutput, + operation: event.operation, + }; + } const stateData = new StateData(Environment.StateMachines.Ingest, modified, context); diff --git a/source/main/ingest/main/index.spec.js b/source/main/ingest/main/index.spec.js new file mode 100644 index 0000000..831754f --- /dev/null +++ b/source/main/ingest/main/index.spec.js @@ -0,0 +1,364 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +const StateCreateRecord = require('./states/create-record'); +const StateFixityCompleted = require('./states/fixity-completed'); +const StateIndexIngestResults = require('./states/index-ingest-results'); +const StateJobCompleted = require('./states/job-completed'); +const StateUpdateRecord = require('./states/update-record'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateCreateRecord = { + "operation": "create-record", + "input": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-ingest", + "key": "speech/speech.mp3", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + } + }, + "executionArn": "arn:aws:states:us-east-1:account-number:execution:so0050-0a9ab6b1a00f-ingest-main:b13ecca9-ad1e-440a-9773-1e1bc6095a0f" + } + +const event_StateFixityCompleted = { + "operation": "fixity-completed", + "nestedStateOutput": { + "ExecutionArn": "arn:aws:states:us-east-1:account-number:execution:so0050-0a9ab6b1a00f-ingest-fixity:ae770da8-284d-4361-b2d0-f42d05cb4f74", + "Input": "{\"operation\":\"check-restore-status\",\"input\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-ingest\",\"key\":\"speech/speech.mp3\",\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"aiOptions\":{\"celeb\":true,\"face\":true,\"facematch\":true,\"label\":true,\"moderation\":true,\"person\":true,\"text\":true,\"segment\":true,\"customlabel\":false,\"minConfidence\":80,\"customLabelModels\":[],\"frameCaptureMode\":1003,\"textROI\":[true,true,true,true,true,true,true,true,true],\"framebased\":true,\"transcribe\":true,\"keyphrase\":true,\"entity\":true,\"sentiment\":true,\"customentity\":false,\"textract\":true,\"languageCode\":\"en-US\"},\"attributes\":{},\"destination\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-proxy\",\"prefix\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/\"},\"type\":\"audio\"},\"data\":{},\"progress\":100,\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"status\":\"INGEST_STARTED\"}", + "InputDetails": { + "Included": true + }, + "Name": "ae770da8-284d-4361-b2d0-f42d05cb4f74", + "Output": "{\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"stateMachine\":\"so0050-0a9ab6b1a00f-ingest-main\",\"operation\":\"validate-checksum\",\"overallStatus\":\"PROCESSING\",\"status\":\"COMPLETED\",\"progress\":100,\"input\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-ingest\",\"key\":\"speech/speech.mp3\",\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"aiOptions\":{\"celeb\":true,\"face\":true,\"facematch\":true,\"label\":true,\"moderation\":true,\"person\":true,\"text\":true,\"segment\":true,\"customlabel\":false,\"minConfidence\":80,\"customLabelModels\":[],\"frameCaptureMode\":1003,\"textROI\":[true,true,true,true,true,true,true,true,true],\"framebased\":true,\"transcribe\":true,\"keyphrase\":true,\"entity\":true,\"sentiment\":true,\"customentity\":false,\"textract\":true,\"languageCode\":\"en-US\"},\"attributes\":{},\"destination\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-proxy\",\"prefix\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/\"},\"type\":\"audio\"},\"data\":{\"restore\":{\"tier\":\"Bulk\",\"startTime\":1675409786919,\"endTime\":1675409786919},\"checksum\":{\"algorithm\":\"md5\",\"fileSize\":2722840,\"computed\":\"ac5cfe51b37d5711de590746ba461bfe\",\"storeChecksumOnTagging\":true,\"startTime\":1675409787118,\"endTime\":1675409787334,\"comparedWith\":\"object-metadata\",\"comparedResult\":\"MATCHED\",\"tagUpdated\":true}}}", + "OutputDetails": { + "Included": true + }, + "StartDate": 1675409785710, + "StateMachineArn": "arn:aws:states:us-east-1:account-number:stateMachine:so0050-0a9ab6b1a00f-ingest-fixity", + "Status": "SUCCEEDED", + "StopDate": 1675409787811 + } + } + +const event_StateIndexIngestResults = { + "operation": "index-ingest-results", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-ingest", + "duration": 453799, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "attributes": {}, + "type": "audio", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "key": "speech/speech.mp3", + "aiOptions": { + "sentiment": true, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "celeb": true, + "frameCaptureMode": 1003, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": true, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "checksum": { + "comparedResult": "MATCHED", + "storeChecksumOnTagging": true, + "computed": "ac5cfe51b37d5711de590746ba461bfe", + "fileSize": 2722840, + "startTime": 1675409787118, + "endTime": 1675409787334, + "comparedWith": "object-metadata", + "tagUpdated": true, + "algorithm": "md5" + }, + "transcode": { + "output": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/transcode/", + "jobId": "1675409793856-6whu0q", + "startTime": 1675409794011, + "endTime": 1675409805251 + }, + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + }, + "mediainfo": { + "output": [ + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.json", + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.xml" + ] + } + }, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500" + } + +const event_StateJobCompleted = { + "operation": "job-completed", + "input": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-ingest", + "duration": 453799, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-account-number-us-east-1-proxy", + "prefix": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/" + }, + "attributes": {}, + "type": "audio", + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "key": "speech/speech.mp3", + "aiOptions": { + "sentiment": true, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "celeb": true, + "frameCaptureMode": 1003, + "keyphrase": true, + "label": true, + "languageCode": "en-US", + "facematch": true, + "transcribe": true, + "face": true, + "customentity": false, + "person": true, + "minConfidence": 80, + "textract": true, + "moderation": true, + "segment": true, + "customlabel": false, + "text": true, + "entity": true, + "customLabelModels": [] + } + }, + "data": { + "checksum": { + "comparedResult": "MATCHED", + "storeChecksumOnTagging": true, + "computed": "ac5cfe51b37d5711de590746ba461bfe", + "fileSize": 2722840, + "startTime": 1675409787118, + "endTime": 1675409787334, + "comparedWith": "object-metadata", + "tagUpdated": true, + "algorithm": "md5" + }, + "transcode": { + "output": "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/transcode/", + "jobId": "1675409793856-6whu0q", + "startTime": 1675409794011, + "endTime": 1675409805251 + }, + "restore": { + "tier": "Bulk", + "startTime": 1675409786919, + "endTime": 1675409786919 + }, + "mediainfo": { + "output": [ + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.json", + "60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.xml" + ] + }, + "indexer": { + "terms": [ + "overallStatus", + "lastModified", + "status", + "timestamp", + "basename", + "attributes", + "bucket", + "fileSize", + "mime", + "uuid", + "key", + "duration", + "md5", + "type" + ] + } + }, + "progress": 100, + "uuid": "60aa12c0-b046-1db7-b9e2-3a3aac69b500", + "status": "COMPLETED" + } + +const event_StateUpdateRecord = { + "operation": "update-record", + "nestedStateOutput": { + "ExecutionArn": "arn:aws:states:us-east-1:account-number:execution:so0050-0a9ab6b1a00f-ingest-audio:a86a921b-6d7d-4774-9c8c-bb80e590bc3b", + "Input": "{\"operation\":\"run-mediainfo\",\"status\":\"NOT_STARTED\",\"progress\":0,\"input\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-ingest\",\"key\":\"speech/speech.mp3\",\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"aiOptions\":{\"celeb\":true,\"face\":true,\"facematch\":true,\"label\":true,\"moderation\":true,\"person\":true,\"text\":true,\"segment\":true,\"customlabel\":false,\"minConfidence\":80,\"customLabelModels\":[],\"frameCaptureMode\":1003,\"textROI\":[true,true,true,true,true,true,true,true,true],\"framebased\":true,\"transcribe\":true,\"keyphrase\":true,\"entity\":true,\"sentiment\":true,\"customentity\":false,\"textract\":true,\"languageCode\":\"en-US\"},\"attributes\":{},\"destination\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-proxy\",\"prefix\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/\"},\"type\":\"audio\"},\"data\":{\"restore\":{\"tier\":\"Bulk\",\"startTime\":1675409786919,\"endTime\":1675409786919},\"checksum\":{\"algorithm\":\"md5\",\"fileSize\":2722840,\"computed\":\"ac5cfe51b37d5711de590746ba461bfe\",\"storeChecksumOnTagging\":true,\"startTime\":1675409787118,\"endTime\":1675409787334,\"comparedWith\":\"object-metadata\",\"comparedResult\":\"MATCHED\",\"tagUpdated\":true}},\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\"}", + "InputDetails": { + "Included": true + }, + "Name": "a86a921b-6d7d-4774-9c8c-bb80e590bc3b", + "Output": "{\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"stateMachine\":\"so0050-0a9ab6b1a00f-ingest-main\",\"operation\":\"start-transcode\",\"overallStatus\":\"PROCESSING\",\"status\":\"COMPLETED\",\"progress\":100,\"input\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-ingest\",\"duration\":453799,\"destination\":{\"bucket\":\"so0050-0a9ab6b1a00f-account-number-us-east-1-proxy\",\"prefix\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/\"},\"attributes\":{},\"type\":\"audio\",\"uuid\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500\",\"key\":\"speech/speech.mp3\",\"aiOptions\":{\"sentiment\":true,\"textROI\":[true,true,true,true,true,true,true,true,true],\"framebased\":true,\"celeb\":true,\"frameCaptureMode\":1003,\"keyphrase\":true,\"label\":true,\"languageCode\":\"en-US\",\"facematch\":true,\"transcribe\":true,\"face\":true,\"customentity\":false,\"person\":true,\"minConfidence\":80,\"textract\":true,\"moderation\":true,\"segment\":true,\"customlabel\":false,\"text\":true,\"entity\":true,\"customLabelModels\":[]}},\"data\":{\"checksum\":{\"comparedResult\":\"MATCHED\",\"storeChecksumOnTagging\":true,\"computed\":\"ac5cfe51b37d5711de590746ba461bfe\",\"fileSize\":2722840,\"startTime\":1675409787118,\"endTime\":1675409787334,\"comparedWith\":\"object-metadata\",\"tagUpdated\":true,\"algorithm\":\"md5\"},\"transcode\":{\"output\":\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/transcode/\",\"jobId\":\"1675409793856-6whu0q\",\"startTime\":1675409794011,\"endTime\":1675409805251},\"restore\":{\"tier\":\"Bulk\",\"startTime\":1675409786919,\"endTime\":1675409786919},\"mediainfo\":{\"output\":[\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.json\",\"60aa12c0-b046-1db7-b9e2-3a3aac69b500/speech/mediainfo/mediainfo.xml\"]}}}", + "OutputDetails": { + "Included": true + }, + "StartDate": 1675409789768, + "StateMachineArn": "arn:aws:states:us-east-1:account-number:stateMachine:so0050-0a9ab6b1a00f-ingest-audio", + "Status": "SUCCEEDED", + "StopDate": 1675409806663 + } + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateCreateRecord', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateCreateRecord, context); + + let instance = new StateCreateRecord(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateFixityCompleted', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateFixityCompleted, context); + + let instance = new StateFixityCompleted(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateIndexIngestResults', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateIndexIngestResults, context); + + let instance = new StateIndexIngestResults(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateJobCompleted', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateJobCompleted, context); + + let instance = new StateJobCompleted(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateUpdateRecord', async () => { + const stateData = new StateData(Environment.StateMachines.Main, event_StateUpdateRecord, context); + + let instance = new StateUpdateRecord(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + +}); + + diff --git a/source/main/ingest/main/jest.config.js b/source/main/ingest/main/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/main/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/main/package.json b/source/main/ingest/main/package.json index c3461fb..43facfd 100644 --- a/source/main/ingest/main/package.json +++ b/source/main/ingest/main/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,8 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "mediainfo": "file:../../../layers/mediainfo" + "mediainfo": "file:../../../layers/mediainfo", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/main/setEnvVars.js b/source/main/ingest/main/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/main/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/video/README.md b/source/main/ingest/video/README.md index c597bd3..7fdc1cd 100644 --- a/source/main/ingest/video/README.md +++ b/source/main/ingest/video/README.md @@ -99,7 +99,6 @@ __ }, { "Action": [ - "dynamodb:DescribeTable", "dynamodb:Scan", "dynamodb:Query", "dynamodb:UpdateItem", diff --git a/source/main/ingest/video/index.js b/source/main/ingest/video/index.js index 7ef51ae..8a72b1e 100644 --- a/source/main/ingest/video/index.js +++ b/source/main/ingest/video/index.js @@ -17,7 +17,7 @@ const REQUIRED_ENVS = [ 'ENV_IOT_HOST', 'ENV_IOT_TOPIC', 'ENV_MEDIACONVERT_HOST', - 'ENV_MEDIACONVERT_ROLE', + 'ENV_DATA_ACCESS_ROLE', 'ENV_INGEST_BUCKET', 'ENV_PROXY_BUCKET', ]; diff --git a/source/main/ingest/video/index.spec.js b/source/main/ingest/video/index.spec.js new file mode 100644 index 0000000..23f3700 --- /dev/null +++ b/source/main/ingest/video/index.spec.js @@ -0,0 +1,253 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ + const { + Environment, + StateData, + AnalysisError, + } = require('core-lib'); +const StateRunMediaInfo = require('./states/run-mediainfo'); +const StateStartTranscode = require('./states/start-transcode'); + +const lambda = require('./index.js'); +const { JobCompleted } = require('core-lib/lib/states'); + + +const event_StateRunMediaInfo = { + "operation": "run-mediainfo", + "status": "NOT_STARTED", + "progress": 0, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "thenewshort/thenewshort.mp4", + "uuid": "3168c4e2-be37-8395-ad0b-62e5636eeef8", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "3168c4e2-be37-8395-ad0b-62e5636eeef8/thenewshort/" + }, + "type": "video" + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675386392708, + "endTime": 1675386392709 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 191767238, + "computed": "b9b4ae670c88b8d0d1d51a39dcc0da0c", + "storeChecksumOnTagging": true, + "startTime": 1675386392929, + "endTime": 1675386395275, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + } + }, + "uuid": "3168c4e2-be37-8395-ad0b-62e5636eeef8" + } + +const event_StateStartTranscode = { + "uuid": "3168c4e2-be37-8395-ad0b-62e5636eeef8", + "stateMachine": "so0050-0a9ab6b1a00f-ingest-main", + "operation": "run-mediainfo", + "overallStatus": "PROCESSING", + "status": "COMPLETED", + "progress": 100, + "input": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-ingest", + "key": "thenewshort/thenewshort.mp4", + "uuid": "3168c4e2-be37-8395-ad0b-62e5636eeef8", + "aiOptions": { + "celeb": true, + "face": true, + "facematch": true, + "label": true, + "moderation": true, + "person": true, + "text": true, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 1003, + "textROI": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "framebased": true, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": true, + "customentity": false, + "textract": true, + "languageCode": "en-US" + }, + "attributes": {}, + "destination": { + "bucket": "so0050-0a9ab6b1a00f-193234372883-us-east-1-proxy", + "prefix": "3168c4e2-be37-8395-ad0b-62e5636eeef8/thenewshort/" + }, + "type": "video", + "duration": 300033, + "framerate": 59.94 + }, + "data": { + "restore": { + "tier": "Bulk", + "startTime": 1675386392708, + "endTime": 1675386392709 + }, + "checksum": { + "algorithm": "md5", + "fileSize": 191767238, + "computed": "b9b4ae670c88b8d0d1d51a39dcc0da0c", + "storeChecksumOnTagging": true, + "startTime": 1675386392929, + "endTime": 1675386395275, + "comparedWith": "object-metadata", + "comparedResult": "MATCHED", + "tagUpdated": true + }, + "mediainfo": { + "container": [ + { + "format": "MPEG-4", + "fileSize": 191767238, + "duration": 300.033, + "frameRate": 59.94, + "overallBitRate": 5113231 + } + ], + "audio": [ + { + "streamOrder": 1, + "format": "AAC", + "codecID": "mp4a-40-2", + "bitRateMode": "VBR", + "bitRate": 96000, + "channels": 2, + "channelLayout": "L R", + "samplesPerFrame": 1024, + "samplingRate": 48000, + "iD": 2 + } + ], + "video": [ + { + "streamOrder": 0, + "format": "AVC", + "formatProfile": "Main", + "formatLevel": 3.2, + "codecID": "avc1", + "bitRateMode": "CBR", + "bitRate": 5000000, + "width": 1280, + "height": 720, + "pixelAspectRatio": 1, + "displayAspectRatio": 1.778, + "frameRate": 59.94, + "bitDepth": 8, + "scanType": "Progressive", + "iD": 1 + } + ], + "output": [ + "3168c4e2-be37-8395-ad0b-62e5636eeef8/thenewshort/mediainfo/mediainfo.json", + "3168c4e2-be37-8395-ad0b-62e5636eeef8/thenewshort/mediainfo/mediainfo.xml" + ] + } + } + } + + +const context = { + invokedFunctionArn: 'arn:partition:service:region:account-id:resource-id', + getRemainingTimeInMillis: 1000 +} + + + describe('#Main/Analysis/Main::', () => { + + beforeAll(() => { + // Mute console.log output for internal functions + console.log = jest.fn(); + }); + + + beforeEach(() => { + }); + + test('Test the StateRunDocInfo ingest state', async () => { + const stateData = new StateData(Environment.StateMachines.VideoIngest, event_StateRunMediaInfo, context); + + let instance = new StateRunMediaInfo(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + + test('Test the StateRunDocInfo ingest state', async () => { + const stateData = new StateData(Environment.StateMachines.VideoIngest, event_StateStartTranscode, context); + + let instance = new StateStartTranscode(stateData); + console.log(instance); + + expect(instance).toBeDefined(); + }); + +}); + + diff --git a/source/main/ingest/video/jest.config.js b/source/main/ingest/video/jest.config.js new file mode 100644 index 0000000..ea7e24a --- /dev/null +++ b/source/main/ingest/video/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + setupTestFrameworkScriptFile: 'tests/setup.js', + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + roots: [''], + testMatch: ['**/*.spec.js'], + coveragePathIgnorePatterns: ['/lib/utils.test.js'], + coverageReporters: [['lcov', { projectRoot: '../../../../' }], 'text'], + setupFiles: ['/setEnvVars.js'] + }; \ No newline at end of file diff --git a/source/main/ingest/video/package.json b/source/main/ingest/video/package.json index de03297..0293fa4 100644 --- a/source/main/ingest/video/package.json +++ b/source/main/ingest/video/package.json @@ -6,7 +6,7 @@ "private": true, "scripts": { "pretest": "npm install", - "test": "mocha *.spec.js", + "test": "jest --coverage --coverageDirectory=../../../coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv index.js package.json states dist/", "build:install": "cd dist && npm install --only=prod --no-optional", @@ -16,6 +16,9 @@ "author": "aws-mediaent-solutions", "devDependencies": { "core-lib": "file:../../../layers/core-lib", - "mediainfo": "file:../../../layers/mediainfo" + "service-backlog-lib": "file:../../../layers/service-backlog-lib", + "mediainfo": "file:../../../layers/mediainfo", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" } } diff --git a/source/main/ingest/video/setEnvVars.js b/source/main/ingest/video/setEnvVars.js new file mode 100644 index 0000000..b29b464 --- /dev/null +++ b/source/main/ingest/video/setEnvVars.js @@ -0,0 +1,12 @@ +process.env.ENV_ES_DOMAIN_ENDPOINT = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_ID = 'Test_ENV_Variable'; +process.env.ENV_RESOURCE_PREFIX = 'Test_ENV_Variable'; +process.env.ENV_SOLUTION_UUID = 'Test_ENV_Variable'; +process.env.ENV_ANONYMOUS_USAGE = 'Test_ENV_Variable'; +process.env.ENV_IOT_HOST = 'Test_ENV_Variable'; +process.env.ENV_IOT_TOPIC = 'Test_ENV_Variable'; +process.env.ENV_PROXY_BUCKET = 'Test_ENV_Variable'; +process.env.ENV_DATA_ACCESS_ROLE = 'Test_ENV_Variable'; +process.env.ENV_SNS_TOPIC_ARN = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_AI_OPTIONS = 'Test_ENV_Variable'; +process.env.ENV_DEFAULT_MINCONFIDENCE = 'Test_ENV_Variable'; \ No newline at end of file diff --git a/source/main/ingest/video/states/start-transcode/index.js b/source/main/ingest/video/states/start-transcode/index.js index ffe9395..7090a81 100644 --- a/source/main/ingest/video/states/start-transcode/index.js +++ b/source/main/ingest/video/states/start-transcode/index.js @@ -1,14 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 - -const AWS = (() => { - try { - const AWSXRay = require('aws-xray-sdk'); - return AWSXRay.captureAWS(require('aws-sdk')); - } catch (e) { - return require('aws-sdk'); - } -})(); const FS = require('fs'); const PATH = require('path'); const { @@ -19,6 +10,11 @@ const { FrameCaptureModeHelper, TranscodeError, } = require('core-lib'); +const { + BacklogClient: { + MediaConvertBacklogJob, + }, +} = require('service-backlog-lib'); const CATEGORY = 'transcode'; const API_NAME = 'video'; @@ -84,40 +80,48 @@ class StateStartTranscode { if (!data.mediainfo) { throw new TranscodeError('missing mediainfo'); } - const response = await this.createJob(); + + const params = await this.createJobTemplate(); /* done with mediainfo, remove mediainfo block to reduce the stateData payload size */ [ 'video', 'audio', 'container', - ].forEach(x => delete this.stateData.data.mediainfo[x]); + ].forEach(x => + delete this.stateData.data.mediainfo[x]); + + const stateOutput = await this.createJob(params); const output = this.makeOutputPrefix(dest.prefix); this.stateData.setStarted(); this.stateData.setData(CATEGORY, { - startTime: new Date().getTime(), - jobId: response.Job.Id, + ...stateOutput, output, }); + const id = stateOutput.backlogId; + const responseData = this.stateData.toJSON(); await ServiceToken.register( - this.stateData.data[CATEGORY].jobId, + id, this.stateData.event.token, CATEGORY, API_NAME, - this.stateData.toJSON() + responseData ); - return this.stateData.toJSON(); + + return responseData; } - async createJob() { - const template = await this.createJobTemplate(); - const mediaconvert = new AWS.MediaConvert({ - apiVersion: '2017-08-29', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - endpoint: Environment.MediaConvert.Host, - }); - return mediaconvert.createJob(template).promise(); + async createJob(params) { + /* use backlog system */ + const uniqueId = CommonUtils.uuid4(); + + const backlog = new MediaConvertBacklogJob(); + return backlog.createJob(uniqueId, params) + .then(() => ({ + startTime: new Date().getTime(), + backlogId: uniqueId, + })); } async createJobTemplate() { @@ -136,7 +140,7 @@ class StateStartTranscode { } const template = { - Role: Environment.MediaConvert.Role, + Role: Environment.DataAccess.RoleArn, Settings: { OutputGroups: ogs.filter(x => x), AdAvailOffset: 0, @@ -204,15 +208,15 @@ class StateStartTranscode { return [audio[0].iD]; } /* #3: multiple audio tracks and contain stereo track */ - for (let i = 0; i < audio.length; i++) { - if (this.getChannels(audio[i]) >= 2) { - return [audio[i].iD]; + for (let track of audio) { + if (this.getChannels(track) >= 2) { + return [track.iD]; } } /* #4: multiple audio tracks and contain Dolby E track */ - for (let i = 0; i < audio.length; i++) { - if (audio[i].format === 'Dolby E') { - return [audio[i].iD]; + for (let track of audio) { + if (track.format === 'Dolby E') { + return [track.iD]; } } /* #5: multiple PCM mono audio tracks, take the first 2 mono tracks */ @@ -261,15 +265,15 @@ class StateStartTranscode { return [reordered[0].trackIdx]; } /* #3: multiple audio tracks and contain stereo track */ - for (let i = 0; i < reordered.length; i++) { - if (this.getChannels(reordered[i]) >= 2) { - return [reordered[i].trackIdx]; + for (let track of reordered) { + if (this.getChannels(track) >= 2) { + return [track.trackIdx]; } } /* #4: multiple audio tracks and contain Dolby E track */ - for (let i = 0; i < reordered.length; i++) { - if (reordered[i].format === 'Dolby E') { - return [reordered[i].trackIdx]; + for (let track of reordered) { + if (track.format === 'Dolby E') { + return [track.trackIdx]; } } /* #5: multiple PCM mono audio tracks, take the first 2 mono tracks */ @@ -308,9 +312,9 @@ class StateStartTranscode { width, height, ] = this.downscaleOutput(); - for (let i = 0; i < outputs.length; i++) { - outputs[i].VideoDescription.Width = width; - outputs[i].VideoDescription.Height = height; + for (let output of outputs) { + output.VideoDescription.Width = width; + output.VideoDescription.Height = height; } } /* make sure each output has at least one output stream */ @@ -361,14 +365,13 @@ class StateStartTranscode { if (!queue) { return undefined; } - const mediaconvert = new AWS.MediaConvert({ - apiVersion: '2017-08-29', - customUserAgent: Environment.Solution.Metrics.CustomUserAgent, - endpoint: Environment.MediaConvert.Host, - }); + const mediaconvert = (new MediaConvertBacklogJob()) + .getMediaConvertInstance(); const response = await mediaconvert.getQueue({ Name: queue, - }).promise().catch(() => undefined); + }).promise() + .catch(() => + undefined); return ((response || {}).Queue || {}).Arn; } diff --git a/source/main/tests/app.spec.js b/source/main/tests/app.spec.js new file mode 100644 index 0000000..8239cbc --- /dev/null +++ b/source/main/tests/app.spec.js @@ -0,0 +1,46 @@ +/********************************************************************************************************************* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * + * * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * + * with the License. A copy of the License is located at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * + * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * + * and limitations under the License. * + *********************************************************************************************************************/ +// import SolutionManifest from '../solution-manifest.js'; +jest.mock( + '/solution-manifest.js', + () => ({ + Version: 'test', + LastUpdated: 'test' + }), + { virtual: true } + ); + +// import app from '../src/lib/js/app.js' +import AppUtils from '../../webapp/src/lib/js/app/shared/appUtils.js'; +import LocalStoreDB from '../../webapp/src/lib/js/app/shared/localCache/localStoreDB.js'; +import MainView from '../../webapp/src/lib/js/app/mainView.js'; +import SignInFlow from '../../webapp/src/lib/js/app/signInFlow.js'; + +test('example of how to mock an ES6 module', () => { + AppUtils(); + +}); + +describe + +/** + * Tests +// */ +describe('#API_Operations::', () => { + + decribe('should return "responseData" when create GET request is successful', async () => { + const response = await recommendCategorySlideComponent.createSimilaritySearchForm(); + expect(response.Id).to.equal(''); + }); + +}); \ No newline at end of file diff --git a/source/main/tests/setup.js b/source/main/tests/setup.js new file mode 100644 index 0000000..29b9c19 --- /dev/null +++ b/source/main/tests/setup.js @@ -0,0 +1 @@ +Object.defineProperty(window, 'yourVar', { value: 'yourValue' }); \ No newline at end of file diff --git a/source/main/tests/solution-manifest.js b/source/main/tests/solution-manifest.js new file mode 100644 index 0000000..9c30dc0 --- /dev/null +++ b/source/main/tests/solution-manifest.js @@ -0,0 +1,135 @@ +const SolutionManifest = { + "S3": { + "UseAccelerateEndpoint": true, + "ExpectedBucketOwner": "111111111111" + }, + "IotHost": "a2ys8h4dnleiq4-ats.iot.us-west-2.amazonaws.com", + "StateMachines": { + "Ingest": "so0050-0a709c9ee415-ingest-main", + "Analysis": "so0050-0a709c9ee415-analysis-main", + "Main": "so0050-0a709c9ee415-main" + }, + "ApiEndpoint": "https://test.execute-api.us-west-2.amazonaws.com/demo", + "IotTopic": "so0050-0a709c9ee415/status", + "Proxy": { + "Bucket": "so0050-0a709c9ee415-111111111111-us-west-2-proxy" + }, + "Ingest": { + "Bucket": "so0050-0a709c9ee415-111111111111-us-west-2-ingest" + }, + "SolutionId": "SO0050", + "Version": "v3.0.0", + "Region": "us-west-2", + "Cognito": { + "UserPoolId": "us-west-2_KXaPys2Gu", + "ClientId": "3smf5n7m04jl5sotsp1mtpud39", + "IdentityPoolId": "us-west-2:b081e92e-a6d4-422d-b456-6594572bed6e", + "RedirectUri": "https://d3tte2vdm23ihw.cloudfront.net" + }, + "LastUpdated": "2022-12-02T07:42:53.379Z", + "CustomUserAgent": "", + "StackName": "so0050-0a709c9ee415", + "AIML": { + "celeb": true, + "face": false, + "facematch": false, + "label": true, + "moderation": false, + "person": false, + "text": false, + "segment": true, + "customlabel": false, + "minConfidence": 80, + "customLabelModels": [], + "frameCaptureMode": 0, + "textROI": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "framebased": false, + "transcribe": true, + "keyphrase": true, + "entity": true, + "sentiment": false, + "customentity": false, + "textract": true + }, + "ApiOps": { + "Assets": "assets", + "Analysis": "analysis", + "Search": "search", + "Execution": "execution", + "AttachPolicy": "attach-policy", + "FaceCollections": "rekognition/face-collections", + "FaceCollection": "rekognition/face-collection", + "Faces": "rekognition/faces", + "Face": "rekognition/face", + "CustomLabelModels": "rekognition/custom-label-models", + "CustomVocabularies": "transcribe/custom-vocabularies", + "CustomLanguageModels": "transcribe/custom-language-models", + "CustomEntityRecognizers": "comprehend/custom-entity-recognizers", + "Stats": "stats" + }, + "Statuses": { + "Processing": "PROCESSING", + "Completed": "COMPLETED", + "Error": "ERROR", + "None": "NONE", + "NotStarted": "NOT_STARTED", + "Started": "STARTED", + "InProgress": "IN_PROGRESS", + "NoData": "NO_DATA", + "Removed": "REMOVED", + "IngestStarted": "INGEST_STARTED", + "IngestCompleted": "INGEST_COMPLETED", + "IngestError": "INGEST_ERROR", + "AnalysisStarted": "ANALYSIS_STARTED", + "AnalysisCompleted": "ANALYSIS_COMPLETED", + "AnalysisError": "ANALYSIS_ERROR" + }, + "FrameCaptureMode": { + "MODE_NONE": 0, + "MODE_1FPS": 1, + "MODE_2FPS": 2, + "MODE_3FPS": 3, + "MODE_4FPS": 4, + "MODE_5FPS": 5, + "MODE_10FPS": 10, + "MODE_12FPS": 12, + "MODE_15FPS": 15, + "MODE_ALL": 1000, + "MODE_HALF_FPS": 1001, + "MODE_1F_EVERY_2S": 1002, + "MODE_1F_EVERY_5S": 1003 + }, + "AnalysisTypes": { + "Rekognition": { + "Celeb": "celeb", + "Face": "face", + "FaceMatch": "facematch", + "Label": "label", + "Moderation": "moderation", + "Person": "person", + "Text": "text", + "Segment": "segment", + "CustomLabel": "customlabel" + }, + "Transcribe": "transcribe", + "Comprehend": { + "Keyphrase": "keyphrase", + "Entity": "entity", + "Sentiment": "sentiment", + "CustomEntity": "customentity" + }, + "Textract": "textract" + } + }; + + export default SolutionManifest; \ No newline at end of file diff --git a/source/webapp/css/app.css b/source/webapp/css/app.css index 41a08fd..f6249ee 100644 --- a/source/webapp/css/app.css +++ b/source/webapp/css/app.css @@ -239,12 +239,17 @@ input:disabled + .slider { padding-bottom: 10px; top: 10px; font-size: 80%; - max-height: 360px; + max-height: 90%; overflow-y: auto; } .carousel-indicators li { background-color: rgba(0, 0, 0, 0.5); + width: 1.4rem; + height: 1.4rem; + border-radius: 0.7rem; + border-top: 0rem solid transparent; + border-bottom: 0rem solid transparent; } .carousel-indicators .active { @@ -445,10 +450,6 @@ a.member-anchor:hover { border-radius: .2rem; } -.carousel-content { - max-height: 90%; -} - .editable:hover { animation: shake 0.8s; animation-iteration-count: infinite; @@ -468,14 +469,6 @@ a.member-anchor:hover { 100% { transform: translate(1px, -2px) rotate(-1deg); } } -.carousel-indicators li { - width: 1.4rem; - height: 1.4rem; - border-radius: 0.7rem; - border-top: 0rem solid transparent; - border-bottom: 0rem solid transparent; -} - .shake-sm { -webkit-animation: kf_shake 0.4s 1 linear; -moz-animation: kf_shake 0.4s 1 linear; @@ -907,6 +900,10 @@ a.member-anchor:hover { font-weight: 200; } +.lead-xxl { + font-size: 4.5rem; +} + .icon-play-4 { position: relative; margin: auto!important; @@ -953,10 +950,6 @@ a.member-anchor:hover { /* box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); */ } -.sidebar .nav-link { - color: #333; -} - .sidebar .nav-link { margin-right: 4px; padding: 0.15rem 1rem; @@ -1021,6 +1014,16 @@ dt { overflow-y: scroll; } +.max-h40r { + max-height: 40rem; + overflow-y: scroll; +} + +.max-h56r { + max-height: 56rem; + overflow-y: scroll; +} + .custom-bg { z-index: 0; position: absolute; @@ -1043,10 +1046,6 @@ dt { } } -.img-contain { - object-fit: contain; -} - div[data-media-type="video"] .cropper-container { position: absolute; top: 0; @@ -1228,6 +1227,10 @@ div[data-media-type="video"] .cropper-container { width: 400px; } +.knowledge-graph { + min-height: 70vh; +} + .media-processing, .media-error { opacity: 0.7; } @@ -1304,7 +1307,6 @@ div[data-media-type="video"] .cropper-container { .search-thumbnail { object-fit: cover; height: 96px; - width: 100% !important; min-width: 96px; aspect-ratio: 16/9; } @@ -1324,3 +1326,123 @@ div[data-media-type="video"] .cropper-container { top: 1em; left: 1em; } + +.vh-8 { + min-height: 8; +} + +.vh-10 { + min-height: 10vh; +} + +.vh-20 { + min-height: 20vh; +} + +.vh-30 { + min-height: 30vh; +} + +.vh-40 { + min-height: 40vh; +} + +.vh-50 { + min-height: 50vh; +} + +.vh-60 { + min-height: 60vh; +} + +.vh-70 { + min-height: 70vh; +} + +.vh-80 { + min-height: 80vh; +} + +.w-96min { + min-width: 96px; +} + +.p-overflow { + overflow-y: auto; + max-height: 70px; + margin: 0; + padding: 0; +} + +.image-container { + position: relative; +} + +.image-container > .overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.image-container > .overlay:hover { + opacity: 0.6; +} + +.image-container > .preview { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + height: 100%; + width: 100%; + opacity: 0; + transition: .5s ease; + background-color: #333333; + cursor: pointer; +} + +.image-container > .preview:hover { + opacity: 0.8; +} + +.image-container > .preview > .center { + position: relative; + top: 50%; + transform: translate(0, -50%); + -ms-transform: translate(0, -50%); + text-align: center; +} + +.no-border td { + border: none; +} + +.graph-tooltip { + width: 18rem; +} + +.graph-tooltip img { + width: 100%; + object-fit: cover; + background-color: #aaaaaaaa; + aspect-ratio: 16/9; +} + +.graph-tooltip p { + font-size: 0.8rem; + text-align: center; + overflow: hidden; + word-wrap: break-word; + overflow-wrap: break-word; + padding: 0; + margin: 0; +} + +.graph-avatar { + width: 3rem; + font-size: 2rem; + border-width: 10px; + border-color: #606060; +} diff --git a/source/webapp/identity.html b/source/webapp/identity.html new file mode 100644 index 0000000..59026dd --- /dev/null +++ b/source/webapp/identity.html @@ -0,0 +1,22 @@ + + + + Amazon Neptune Identity Graph demo + + + + + + + + + + + + + + + + + + diff --git a/source/webapp/identity/css/app.css b/source/webapp/identity/css/app.css new file mode 100644 index 0000000..c5ce9a6 --- /dev/null +++ b/source/webapp/identity/css/app.css @@ -0,0 +1,115 @@ +/* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +.btn, .form-control, .custom-select, select { + border-radius: 0; +} + +.vh-8 { + min-height: 8; +} + +.vh-10 { + min-height: 10vh; +} + +.vh-20 { + min-height: 20vh; +} + +.vh-30 { + min-height: 30vh; +} + +.vh-40 { + min-height: 40vh; +} + +.vh-50 { + min-height: 50vh; +} + +.vh-60 { + min-height: 60vh; +} + +.vh-70 { + min-height: 70vh; +} + +.vh-80 { + min-height: 80vh; +} + +.vh-90 { + min-height: 90vh; +} + +.lead-xl { + font-size: 2.0rem; +} + +.lead-lg { + font-size: 1.5rem; +} + +.lead-m { + font-size: 1.1rem; +} + +.lead-s, .lead-sm { + font-size: 1.0rem; +} + +.lead-xs { + font-size: 0.9rem; +} + +.lead-xxs { + font-size: 0.8rem; +} + +.lead-s { + font-weight: 400; +} + +.lead-xl, .lead-lg { + font-weight: 300; +} + +.lead-sm, .lead-xs, .lead-xxs { + font-weight: 200; +} + +.knowledge-graph { + min-height: 70vh; +} + +.popover { + border-radius: 0; + max-width: 70%; +} + +/* Absolute Center Spinner */ +.loading, .loading-3, .loading-4 { + position: fixed; + z-index: 999; + height: 4em; + width: 4em; + overflow: visible; + margin: auto; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.loading-3 { + height: 3em; + width: 3em; +} + +.loading-4 { + height: 4em; + width: 4em; +} \ No newline at end of file diff --git a/source/webapp/identity/js/app.js b/source/webapp/identity/js/app.js new file mode 100644 index 0000000..64ee3a8 --- /dev/null +++ b/source/webapp/identity/js/app.js @@ -0,0 +1,160 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import AppUtils from './shared/appUtils.js'; +import BrandInteractionTab from './tabs/brandInteractionTab.js'; +import HouseholdTab from './tabs/householdTab.js'; + +const ID_DEMOAPP = 'demo-app'; +const TITLE_DEMO = 'Amazon Neptune Identity Graph Demo'; +const DEMO_DESC = 'This demo presents how we use Amazon Neptune to create an identity graph and how we can query (walkthrough) the graph for specific use cases.'; +const DEMO_DATASET_DESC = '

The dataset used for this demo comes from CIKM Cup 2016 Track 1: Cross Device Entity Linking Challenge, https://competitions.codalab.org/competitions/11171

The dataset contains an anonymized browse log for a set of anonymized userIDs representing the same user across multiple devices, as well as obfuscated site URLs and HTML titles those users visited. There are not much of user attributes, so they had to be generated artificially.

The graph contains roughly 15M nodes such as Household (identityGroup), User ID (persistentId), Device ID (transientId), website, and IAB Category (websiteGroup). It also constructs over 80M edges (relationships) among nodes.

'; + +export default class DemoApp { + constructor(parentId) { + this.$parentId = parentId; + this.$tabControllers = [ + new HouseholdTab(true), + new BrandInteractionTab(), + ]; + } + + get parentId() { + return this.$parentId; + } + + get tabControllers() { + return this.$tabControllers; + } + + async show() { + const parent = $(`#${this.parentId}`); + + const container = $('
').addClass('row no-gutters'); + parent.append(container); + + const title = this.createTitle(); + container.append(title); + + const tablist = this.createTabList(); + container.append(tablist); + + return parent; + } + + async hide() { + this.graphs.forEach((graph) => { + if (graph) { + graph.dispose(); + } + }); + this.graphs.length = 0; + } + + resize() { + this.graphs.forEach((graph) => { + if (graph) { + graph.resize(); + } + }); + } + + static randomHexstring() { + const rnd = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(rnd); + return rnd[0].toString(16); + } + + createTitle() { + const container = $('
') + .addClass('col-9 m-0 p-0 mx-auto'); + + const title = $('

') + .addClass('mt-4 text-center') + .append(TITLE_DEMO); + container.append(title); + + const desc = $('

') + .addClass('lead mt-4') + .html(DEMO_DESC); + container.append(desc); + + const datasetDesc = $('

') + .addClass('h4 my-4') + .append('Dataset'); + container.append(datasetDesc); + + container.append(DEMO_DATASET_DESC); + + return container; + } + + createTabList() { + const container = $('

') + .addClass('col-9 m-0 p-0 mx-auto mt-4'); + + const id = AppUtils.randomHexstring(); + const tabItems = this.tabControllers + .map((tabController, idx) => { + const num = idx + 1; + const itemId = `usercase-${id}-${num}`; + const contentId = `usercase-content-${id}-${num}`; + const name = `Usecase ${num}: ${tabController.title}`; + const anchor = $('') + .addClass('nav-link') + .attr('id', itemId) + .attr('data-toggle', 'tab') + .attr('href', `#${contentId}`) + .attr('role', 'tab') + .attr('aria-controls', contentId) + .attr('aria-selected', tabController.isDefault.toString()) + .append($('') + .addClass('lead-s') + .append(name)); + const tabLink = $('
  • ').addClass('nav-item') + .append(anchor); + + const content = tabController.createContent(); + const tabContent = $('
    ').addClass('tab-pane fade') + .attr('id', contentId) + .attr('role', 'tabpanel') + .attr('aria-labelledby', itemId) + .append(content); + if (tabController.isDefault) { + anchor.addClass('active'); + tabContent.addClass('show active'); + } + return [ + tabLink, + tabContent, + ]; + }); + + const tabListId = `tab-${id}`; + const tabList = $('
      ') + .addClass('nav nav-tabs') + .attr('id', tabListId) + .attr('role', 'tablist'); + container.append(tabList); + + const tabContentId = `tabcontent-${id}`; + const tabContent = $('
      ') + .addClass('tab-content') + .attr('id', tabContentId); + tabList.append(tabItems.map((x) => x[0])); + tabContent.append(tabItems.map((x) => x[1])); + container.append(tabContent); + + return container; + } +} + +$(document).ready(async () => { + const demoApp = new DemoApp(ID_DEMOAPP); + + $(window).on('unload', async () => { + console.log('app unloading...'); + await demoApp.hide(); + }); + await demoApp.show(); +}); diff --git a/source/webapp/identity/js/shared/appUtils.js b/source/webapp/identity/js/shared/appUtils.js new file mode 100644 index 0000000..5d48284 --- /dev/null +++ b/source/webapp/identity/js/shared/appUtils.js @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export default class AppUtils { + static randomHexstring() { + const rnd = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(rnd); + return rnd[0].toString(16); + } + + static capitalize(name) { + return name.replace(/_/g, ' ') + .replace(/\b\w/g, (c) => + c.toUpperCase()); + } + + static shorten(data, len = 40) { + switch (typeof data) { + case 'number': + case 'boolean': + case 'symbol': + case 'undefined': + return data; + default: + break; + } + + const s0 = Array.isArray(data) + ? data.join(', ') + : data.toString(); + + const length = Math.max(len, 10); + if (s0.length <= length) { + return s0; + } + const start = Math.floor((length / 2) - 1); + const end = s0.length - Math.floor((length / 2) - 1); + + return `${s0.substring(0, start)}..${s0.substring(end)}`; + } +} diff --git a/source/webapp/identity/js/shared/identityGraph.js b/source/webapp/identity/js/shared/identityGraph.js new file mode 100644 index 0000000..72932d4 --- /dev/null +++ b/source/webapp/identity/js/shared/identityGraph.js @@ -0,0 +1,681 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SolutionManifest from '/solution-manifest.js'; +import NodeTypes from './nodeTypes.js'; +import AppUtils from './appUtils.js'; +import Spinner from './spinner.js'; + +const GRAPH_W = 1200; +const GRAPH_H = 800; + +const GRAPH_MAPPING = Object.keys(NodeTypes.NODE_SIZE_BY_TYPE) + .map((x) => ({ + name: x, + })); + +export default class IdentityGraph { + constructor(container, parent) { + this.$parent = parent; + + this.$id = AppUtils.randomHexstring(); + const graphId = `graph-${this.$id}`; + this.$graphContainer = $('
      ') + .addClass('knowledge-graph') + .attr('id', graphId); + container.append(this.$graphContainer); + + this.$graph = undefined; + this.$graphContainer.ready(async () => { + this.$graph = await this.buildGraph(this.$graphContainer); + }); + } + + get id() { + return this.$id; + } + + get parent() { + return this.$parent; + } + + get graphContainer() { + return this.$graphContainer; + } + + set graphContainer(val) { + this.$graphContainer = val; + } + + get graph() { + return this.$graph; + } + + set graph(val) { + this.$graph = val; + } + + get graphId() { + return this.graphContainer.prop('id'); + } + + loading(enabled) { + return Spinner.loading(enabled); + } + + getGraphContainer() { + return this.graphContainer; + } + + static async graphApi(query) { + const url = new URL(SolutionManifest.KnowledgeGraph.Endpoint); + Object.keys(query) + .forEach((x) => { + if (query[x] !== undefined) { + url.searchParams.append(x, query[x]); + } + }); + const options = { + method: 'GET', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': SolutionManifest.KnowledgeGraph.ApiKey, + }, + }; + let tries = 4; + while (tries--) { + const response = fetch(url, options) + .then((res) => { + if (!res.ok) { + return new Error(); + } + return res.json(); + }); + if (!(response instanceof Error)) { + return response; + } + } + return undefined; + } + + async buildGraph(container) { + const height = Math.max(Math.round(container.height()), GRAPH_H); + const width = Math.max(Math.round(container.width()), GRAPH_W); + + const graph = echarts.init(container[0], null, { + renderer: 'canvas', + useDirtyRect: false, + height, + width, + }); + + /* dblclick always receives a single click event */ + /* delay processing single click event by 300ms */ + let timer; + graph.on('click', ((event) => { + if (event.event.event.detail === 1) { + timer = setTimeout(async () => { + try { + this.loading(true); + const parsed = event.data.value[0]; + + let options = { + start: 0, + end: 20, + }; + + /* beginning of the route */ + if (event.data.name === NodeTypes.NODE_IDENTITY_GROUP) { + return; + } + switch (parsed.label) { + case NodeTypes.NODE_WEBSITE_GROUP: + options = { + ...options, + label: NodeTypes.NODE_WEBSITE, + end: 200, + direction: 'out', + }; + break; + case NodeTypes.NODE_WEBSITE: + options = { + ...options, + label: NodeTypes.NODE_TRANSIENT_ID, + direction: 'in', + }; + break; + case NodeTypes.NODE_TRANSIENT_ID: + options = { + ...options, + label: [ + NodeTypes.NODE_PERSISTENT_ID, + NodeTypes.NODE_IP, + ].join(','), + direction: 'both', + }; + break; + case NodeTypes.NODE_PERSISTENT_ID: + options = { + ...options, + label: NodeTypes.NODE_IDENTITY_GROUP, + direction: 'both', + }; + break; + default: //do nothing + } + const dataset = await this.getConnectedNodes(parsed, options); + this.updateGraph(dataset); + } catch (e) { + console.error(e); + } finally { + this.loading(false); + } + }, 300); + } + })); + + graph.on('dblclick', async (event) => { + if (event.event.event.detail === 2) { + clearTimeout(timer); + try { + this.loading(true); + const parsed = event.data.value[0]; + let options = { + start: 0, + end: 20, + }; + switch (parsed.label) { + /* end of the route */ + case NodeTypes.NODE_WEBSITE_GROUP: + return; + case NodeTypes.NODE_TRANSIENT_ID: + options = { + ...options, + label: NodeTypes.NODE_WEBSITE, + end: 200, + }; + break; + case NodeTypes.NODE_WEBSITE: + options = { + ...options, + label: NodeTypes.NODE_WEBSITE_GROUP, + direction: 'in', + }; + break; + default: //do nothing + } + const dataset = await this.getConnectedNodes(parsed, options); + this.updateGraph(dataset); + } catch (e) { + console.error(e); + } finally { + this.loading(false); + } + } + }); + + try { + this.loading(true); + + const graphOptions = { + nodes: [], + links: [], + categories: GRAPH_MAPPING, + }; + + const options = this.makeGraphOptions(graphOptions); + graph.setOption(options); + return graph; + } catch (e) { + console.error(e); + return graph; + } finally { + this.loading(false); + } + } + + getGraphSeries() { + if (!this.graph) { + return undefined; + } + return this.graph.getOption().series[0]; + } + + resetGraph() { + if (this.graph) { + const series = this.graph.getOption().series[0]; + series.data = []; + series.links = []; + /* update graph */ + this.graph.setOption({ + series: [series], + }); + } + } + + updateGraph(dataset) { + if (!this.graph) { + return undefined; + } + + const series = this.graph.getOption().series[0]; + while (dataset.length) { + const data = dataset.shift(); + if (Array.isArray(data)) { + const src = data[0]; + const link = data[1]; + const dest = data[2]; + const srcNode = this.createNodeIfNotExist(src, series); + const destNode = this.createNodeIfNotExist(dest, series); + if (srcNode !== undefined && destNode !== undefined) { + this.createRelationship(srcNode, destNode, link, series); + } + } else { + this.createNodeIfNotExist(data, series); + } + } + + /* update graph */ + this.graph.setOption({ + series: [series], + }); + return series; + } + + createNodeIfNotExist(data, series) { + if (data === undefined) { + return undefined; + } + const nodes = series.nodes || series.data; + let idx = nodes.findIndex((x) => + x.value[0].id === data.id); + if (idx >= 0) { + return nodes[idx]; + } + + idx = nodes.length; + const node = this.createNode(data, idx); + nodes.push(node); + return node; + } + + createNode(data, idx) { + const id = data.id; + const label = data.label; + const name = NodeTypes.NODE_NAME_BY_TYPE[label]; + return this.parseNode({ + id, + label, + name, + ...this.parseIdentityGroupData(data), + ...this.parsePersistIdData(data), + ...this.parseTransientData(data), + ...this.parseIPData(data), + ...this.parseWebsiteData(data), + ...this.parseWebsiteGroupData(data), + }, idx); + } + + parseNode(nodeData, idx) { + const label = nodeData.label; + const stats = {}; + return { + id: idx, + name: label, + symbolSize: NodeTypes.NODE_SIZE_BY_TYPE[label] || 10, + ...this.computeXYCoord(), + value: [ + nodeData, + stats, + ], + category: GRAPH_MAPPING.findIndex((x) => + x.name === label), + }; + } + + parseTransientData(data) { + let parsed; + if (data.label === NodeTypes.NODE_TRANSIENT_ID) { + const type = (data.type !== undefined) + ? data.type[0] + : undefined; + const userAgent = (data.user_agent !== undefined) + ? data.user_agent[0] + : undefined; + const device = (data.device !== undefined) + ? data.device[0] + : undefined; + const os = (data.os !== undefined) + ? data.os[0] + : undefined; + const browser = (data.browser !== undefined) + ? data.browser[0] + : undefined; + const email = (data.email !== undefined) + ? data.email[0] + : undefined; + parsed = { + type, + userAgent, + device, + os, + browser, + email, + name: device, + }; + } + return parsed; + } + + parseIPData(data) { + let parsed; + if (data.label === NodeTypes.NODE_IP) { + const state = (data.state !== undefined) + ? data.state[0] + : undefined; + const city = (data.city !== undefined) + ? data.city[0] + : undefined; + const ipAddress = (data.ip_address !== undefined) + ? data.ip_address[0] + : undefined; + parsed = { + state, + city, + ipAddress, + name: `${city}, ${state}`, + }; + } + return parsed; + } + + parseWebsiteData(data) { + let parsed; + if (data.label === NodeTypes.NODE_WEBSITE) { + const url = (data.url !== undefined) + ? data.url[0] + : undefined; + parsed = { + url, + // name: url, + }; + } + return parsed; + } + + parseWebsiteGroupData(data) { + let parsed; + if (data.label === NodeTypes.NODE_WEBSITE_GROUP) { + const url = (data.url !== undefined) + ? data.url[0] + : undefined; + const category = (data.category !== undefined) + ? data.category[0] + : undefined; + const categoryCode = (data.categoryCode !== undefined) + ? data.categoryCode[0] + : undefined; + parsed = { + url, + category, + categoryCode, + name: `${category} (${categoryCode})`, + }; + } + return parsed; + } + + parsePersistIdData(data) { + let parsed; + if (data.label === NodeTypes.NODE_PERSISTENT_ID) { + const pid = (data.pid !== undefined) + ? data.pid[0] + : undefined; + parsed = { + pid, + name: `User (${AppUtils.shorten(pid, 8)})`, + }; + } + return parsed; + } + + parseIdentityGroupData(data) { + let parsed; + if (data.label === NodeTypes.NODE_IDENTITY_GROUP) { + const igid = (data.igid !== undefined) + ? data.igid[0] + : undefined; + const type = (data.type !== undefined) + ? data.type[0] + : undefined; + parsed = { + igid, + type, + name: `${AppUtils.capitalize(type)} (${AppUtils.shorten(igid, 8)})`, + }; + } + return parsed; + } + + createRelationship(from, to, connection, series) { + const idx = series.links.findIndex((x) => + (x.value[0].id === connection.id) + || (x.source === from.id && x.target === to.id)); + if (idx >= 0) { + return series.links[idx]; + } + + const link = { + source: from.id, + target: to.id, + value: [ + { + id: connection.id, + label: connection.label, + desc: `${AppUtils.shorten(from.value[0].name, 20)} ${connection.label} ${AppUtils.shorten(to.value[0].name, 20)}`, + }, + ], + }; + series.links.push(link); + + /* store relationship to 'from' and 'to' nodes */ + let stats = from.value[1]; + if (stats[connection.label] === undefined) { + stats[connection.label] = []; + } + stats[connection.label].push(to.id); + + stats = to.value[1]; + if (stats[connection.label] === undefined) { + stats[connection.label] = []; + } + stats[connection.label].push(from.id); + + return link; + } + + async getConnectedNodes(data, options = {}) { + let params = { + op: 'identity', + type: 'vertice', + id: data.id, + ...options, + }; + if (!params.direction) { + params.direction = 'both'; + } + const dataset = await IdentityGraph.graphApi(params); + + /* if dest is website, make another query to get IAB nodes */ + const first = ((dataset || [])[0] || [])[2]; + if (!first || first.label !== NodeTypes.NODE_WEBSITE) { + return dataset; + } + + const ids = dataset.map((x) => + x[2].id); + params = { + ...params, + id: ids.join(','), + start: undefined, + end: undefined, + label: NodeTypes.NODE_WEBSITE_GROUP, + direction: 'in', + }; + const iabs = await IdentityGraph.graphApi(params); + return [ + ...dataset, + ...iabs, + ]; + } + + async createAdjacentNode(data, series) { + const nodes = series.nodes || series.data; + let idx = nodes.findIndex((x) => + x.value[0].id === data.id); + if (idx >= 0) { + return nodes[idx]; + } + + idx = nodes.length; + const node = this.createNode(data, idx); + nodes.push(node); + return node; + } + + computeXYCoord() { + let start = 0 - GRAPH_W; + let end = GRAPH_W; + let random = end - start + 10; + const x = Math.floor(Math.random() * random + start); + + start = 0 - GRAPH_H; + end = GRAPH_H; + random = end - start + 10; + const y = Math.floor(Math.random() * random + start); + + return { + x, + y, + }; + } + + makeGraphOptions(dataset) { + const title = {}; + const tooltip = { + show: true, + trigger: 'item', + enterable: true, + alwaysShowContent: false, + padding: 0, + extraCssText: 'border-radius: 0', + formatter: ((x) => { + if (x.dataType === 'edge') { + const container = $('
      ') + .addClass('my-2'); + const desc = $('

      ') + .addClass('text-truncate') + .append(x.value[0].desc || x.name); + container.append(desc); + return container.prop('outerHTML'); + } + if (x.dataType === 'node') { + const container = $('

      ') + .addClass('mx-2 my-2'); + + const table = $(''); + container.append(table); + + const tbody = $(''); + table.append(tbody); + + const rows = Object.keys(x.value[0]).map((key) => + $('') + .append($('
      ') + .addClass('font-weight-bold') + .append(key)) + .append($('') + .addClass('px-2') + .append(AppUtils.shorten(x.value[0][key], 32)))); + tbody.append(rows); + + return container.prop('outerHTML'); + } + return x.value; + }), + }; + const legend = { + data: dataset.categories + .map((x) => + x.name), + formatter: ((x) => + NodeTypes.NODE_NAME_BY_TYPE[x]), + }; + const animationDuration = 1500; + const animationEasingUpdate = 'quinticInOut'; + const series = [ + { + name: 'Graph database', + type: 'graph', + layout: 'none', + data: dataset.nodes, + links: dataset.links, + categories: dataset.categories, + roam: 'move', + label: { + show: true, + position: 'right', + formatter: ((x) => + x.value[0].name), + }, + lineStyle: { + color: 'source', + curveness: 0.3, + }, + emphasis: { + focus: 'adjacency', + lineStyle: { + width: 10, + }, + }, + }, + ]; + + return { + title, + tooltip, + legend, + animationDuration, + animationEasingUpdate, + series, + }; + } + + destroy() { + if (this.graph) { + this.graph.dispose(); + } + this.graph = undefined; + if (this.graphContainer) { + this.graphContainer.remove(); + } + this.graphContainer = undefined; + if (this.datasets) { + this.datasets.length = 0; + } + } + + resize() { + return this.graph.resize(); + } + + on(event, fn) { + return this.graphContainer.on(event, fn); + } + + off(event) { + return this.graphContainer.off(event); + } +} diff --git a/source/webapp/identity/js/shared/nodeTypes.js b/source/webapp/identity/js/shared/nodeTypes.js new file mode 100644 index 0000000..755f91a --- /dev/null +++ b/source/webapp/identity/js/shared/nodeTypes.js @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const NODE_IP = 'IP'; +const NODE_TRANSIENT_ID = 'transientId'; +const NODE_PERSISTENT_ID = 'persistentId'; +const NODE_IDENTITY_GROUP = 'identityGroup'; +const NODE_WEBSITE = 'website'; +const NODE_WEBSITE_GROUP = 'websiteGroup'; + +const NODE_SIZE_BY_TYPE = { + [NODE_IDENTITY_GROUP]: 45, + [NODE_PERSISTENT_ID]: 30, + [NODE_TRANSIENT_ID]: 20, + [NODE_WEBSITE]: 10, + [NODE_WEBSITE_GROUP]: 20, + [NODE_IP]: 10, +}; +const NODE_NAME_BY_TYPE = { + [NODE_IDENTITY_GROUP]: 'Household', + [NODE_PERSISTENT_ID]: 'User', + [NODE_TRANSIENT_ID]: 'Device', + [NODE_WEBSITE]: 'Website', + [NODE_WEBSITE_GROUP]: 'IAB Category', + [NODE_IP]: 'IP', +}; + +const NodeTypes = { + NODE_SIZE_BY_TYPE, + NODE_NAME_BY_TYPE, + NODE_IDENTITY_GROUP, + NODE_PERSISTENT_ID, + NODE_TRANSIENT_ID, + NODE_WEBSITE, + NODE_WEBSITE_GROUP, + NODE_IP, +}; + +export default NodeTypes; diff --git a/source/webapp/identity/js/shared/spinner.js b/source/webapp/identity/js/shared/spinner.js new file mode 100644 index 0000000..15a8c32 --- /dev/null +++ b/source/webapp/identity/js/shared/spinner.js @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import AppUtils from './appUtils.js'; + +const SPINNER_ID = `spinner-${AppUtils.randomHexstring()}`; + +export default class Spinner { + static loading(enabled = true) { + const spinner = $(`#${SPINNER_ID}`); + if (enabled) { + return spinner.removeClass('collapse'); + } + return spinner.addClass('collapse'); + } + + static createLoading() { + const spinner = $(`#${SPINNER_ID}`); + if (spinner.length > 0) { + return; + } + + const body = $('body'); + const loading = $('
      ') + .attr('id', SPINNER_ID) + .addClass('spinner-grow text-secondary loading-4 collapse') + .append($('') + .addClass('lead-sm sr-only') + .html('Loading...')); + body.append(loading); + } +} diff --git a/source/webapp/identity/js/tabs/baseTab.js b/source/webapp/identity/js/tabs/baseTab.js new file mode 100644 index 0000000..00ac4d4 --- /dev/null +++ b/source/webapp/identity/js/tabs/baseTab.js @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import AppUtils from '../shared/appUtils.js'; +import Spinner from '../shared/spinner.js'; + +export default class BaseTab { + constructor(title, defaultTab = false) { + this.$defaultTab = defaultTab; + this.$title = title; + this.$identityGraph = undefined; + this.$id = AppUtils.randomHexstring(); + this.$container = $('
      ') + .attr('id', `container-${this.$id}`) + .addClass('row no-gutter') + .addClass('mt-4'); + } + + get isDefault() { + return this.$defaultTab; + } + + get title() { + return this.$title; + } + + get identityGraph() { + return this.$identityGraph; + } + + set identityGraph(val) { + this.$identityGraph = val; + } + + get id() { + return this.$id; + } + + get container() { + return this.$container; + } + + createContent() { + Spinner.createLoading(); + return this.container; + } + + loading(enabled = true) { + return Spinner.loading(enabled); + } +} diff --git a/source/webapp/identity/js/tabs/brandInteractionTab.js b/source/webapp/identity/js/tabs/brandInteractionTab.js new file mode 100644 index 0000000..3373fdb --- /dev/null +++ b/source/webapp/identity/js/tabs/brandInteractionTab.js @@ -0,0 +1,427 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import NodeTypes from '../shared/nodeTypes.js'; +import IdentityGraph from '../shared/identityGraph.js'; +import AppUtils from '../shared/appUtils.js'; +import BaseTab from './baseTab.js'; + +const TITLE = 'Brand interactions'; +const DESC_TITLE = 'Advertisers want to generate audiences for demand-side platform (DSP) platform targeting. Specific audience could be the users who are interested in specific topics'; +const DESC_DETAILS = 'Given a domain (ie. UKTV Play), traverse nodes to all device IDs, and traverse to all sub-pages from each device that has visited. This can be used to understand a specific journey such as a subscription flow that the audience went through; i.e, who has started the subscription process but never completed.'; + +export default class BrandInteractionTab extends BaseTab { + constructor(defaultTab) { + super(TITLE, defaultTab); + } + + createContent() { + this.container.children().remove(); + + const desc = this.createDescription(); + this.container.append(desc); + + const select = this.createSelectForm(); + this.container.append(select); + + const identityGraph = this.createIdentityGraph(); + this.container.append(identityGraph); + + return super.createContent(); + } + + createDescription() { + const container = $('
      ') + .addClass('col-12 mt-4'); + + let desc = $('

      ') + .addClass('font-weight-bold') + .append(DESC_TITLE); + container.append(desc); + + desc = $('

      ') + .addClass('font-weight-bold') + .append(''); + container.append(desc); + desc = $('

      ') + .addClass('text-muted') + .html(DESC_DETAILS); + container.append(desc); + + return container; + } + + createSelectForm() { + const container = $('

      ') + .addClass('col-12'); + + const formContainer = $('
      ') + .addClass('px-0 form-inline needs-validation') + .attr('novalidate', 'novalidate') + .attr('role', 'form'); + container.append(formContainer); + + const select = $('') + .addClass('custom-select custom-select-sm col-4 mr-1') + .data('default', 'Choose a website to compare...'); + btnGroup.append(selectFrom); + + selectFrom.ready(async () => { + await this.updateWebsiteSelectOptions(selectFrom); + }); + + /* select 'to' */ + const selectTo = $('') + .addClass('table table-striped'); + popoverContainer.append(table); + + const tbody = $(''); + table.append(tbody); + + /* total users */ + const totalNodes = seriesData.filter((x) => + x.value[0].label === NodeTypes.NODE_TRANSIENT_ID); + let row = this.makeStatsRow('Total users visited the domain', totalNodes.length, totalNodes); + tbody.append(row); + + let fromNode; + let fromConnected; + let toNode; + let toConnected; + if (from !== 'undefined') { + fromNode = seriesData[Number(from)]; + fromConnected = (fromNode.value[1].visited || []) + .map((x) => + seriesData[x]); + row = this.makeStatsRow('Site A', fromNode.value[0].id, fromConnected); + tbody.append(row); + } + if (to !== 'undefined') { + toNode = seriesData[Number(to)]; + toConnected = (toNode.value[1].visited || []) + .map((x) => + seriesData[x]); + row = this.makeStatsRow('Site B', toNode.value[0].id, toConnected); + tbody.append(row); + } + + /* users visited both */ + if (fromConnected && toConnected) { + const fromIds = fromConnected.map((x) => + x.value[0].id); + const toIds = toConnected.map((x) => + x.value[0].id); + + const visitedBoth = fromConnected.filter((x) => + toIds.includes(x.value[0].id)); + row = this.makeStatsRowBase('Users visited A and B', visitedBoth.length, visitedBoth); + tbody.append(row); + + const visitedFrom = fromConnected.filter((x) => + !toIds.includes(x.value[0].id)); + row = this.makeStatsRowBase('Users visited A only', visitedFrom.length, visitedFrom); + tbody.append(row); + + const visitedTo = toConnected.filter((x) => + !fromIds.includes(x.value[0].id)); + row = this.makeStatsRowBase('Users visited B only', visitedTo.length, visitedTo); + tbody.append(row); + } + } + + makeStatsRow(scope, id, item) { + const text = `${AppUtils.shorten(id, 24)} (${item.length})`; + + return this.makeStatsRowBase(scope, text, item); + } + + makeStatsRowBase(scope, text, item) { + let devices = {}; + item.forEach((x) => { + if (devices[x.value[0].device] === undefined) { + devices[x.value[0].device] = 0; + } + devices[x.value[0].device] += 1; + }); + devices = Object.keys(devices) + .sort((a, b) => + a.localeCompare(b)) + .map((x) => + `${x} (${devices[x]})`) + .join(', '); + + let users = item + .sort((a, b) => + b.value[1].visited.length - a.value[1].visited.length) + .map((x) => ({ + email: x.value[0].email, + visited: x.value[1].visited.length, + })); + users = users.slice(0, 5) + .map((x) => + `${x.email} (${x.visited})`) + .join(', '); + + return $('') + .append($('
      ') + .attr('scope', 'row') + .append(scope)) + .append($('') + .append(text)) + .append($('') + .append(`${devices} ...`)) + .append($('') + .append(`${users} ...`)); + } + + async getWebsiteGroups() { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + label: NodeTypes.NODE_WEBSITE_GROUP, + start: 0, + end: 400, + aggregate: true, + maxResults: 30, + }); + } + + async getWebsites(url) { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + label: NodeTypes.NODE_WEBSITE, + textP: JSON.stringify({ + url, + }), + start: 0, + end: 50, + }); + } +} diff --git a/source/webapp/identity/js/tabs/householdTab.js b/source/webapp/identity/js/tabs/householdTab.js new file mode 100644 index 0000000..f0bf147 --- /dev/null +++ b/source/webapp/identity/js/tabs/householdTab.js @@ -0,0 +1,436 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import NodeTypes from '../shared/nodeTypes.js'; +import IdentityGraph from '../shared/identityGraph.js'; +import BaseTab from './baseTab.js'; + +const TITLE = 'Household members'; +const DESC_TITLE = 'Advertisers want to find out information about user interests to provide an accurate targeting. The data should be based on the activity of the user across all devices.'; +const DESC_DETAILS = 'Given a device ID (ie. iPad), traverse that graph from a device to a user (owner) and to a user group (Household or Company). Then, traverse from a household to all users belongs to the same household, to all devices, to all visited websites, and to the corresponding IAB categories.'; + +export default class HouseholdTab extends BaseTab { + constructor(defaultTab) { + super(TITLE, defaultTab); + } + + createContent() { + this.container.children().remove(); + + const desc = this.createDescription(); + this.container.append(desc); + + const select = this.createSelectForm(); + this.container.append(select); + + const identityGraph = this.createIdentityGraph(); + this.container.append(identityGraph); + + return super.createContent(); + } + + createDescription() { + const container = $('
      ') + .addClass('col-12 mt-4'); + + let desc = $('

      ') + .addClass('font-weight-bold') + .append(DESC_TITLE); + container.append(desc); + + desc = $('

      ') + .addClass('font-weight-bold') + .append(''); + container.append(desc); + desc = $('

      ') + .addClass('text-muted') + .html(DESC_DETAILS); + container.append(desc); + + return container; + } + + createSelectForm() { + const container = $('

      ') + .addClass('col-12'); + + const formContainer = $('') + .addClass('px-0 form-inline needs-validation') + .attr('novalidate', 'novalidate') + .attr('role', 'form'); + container.append(formContainer); + + const select = $('') + .addClass('table table-striped'); + popoverContainer.append(table); + + const tbody = $(''); + table.append(tbody); + + let row; + const userNodes = seriesData + .filter((x) => + x.value[0].label === NodeTypes.NODE_PERSISTENT_ID); + row = this.makeStatsRow('>', 'Total users', userNodes.length); + tbody.append(row); + + const deviceNodes = seriesData + .filter((x) => + x.value[0].label === NodeTypes.NODE_TRANSIENT_ID); + row = this.makeStatsRow('>', 'Total devices', deviceNodes.length); + tbody.append(row); + + let devices = {}; + deviceNodes.forEach((x) => { + if (devices[x.value[0].device] === undefined) { + devices[x.value[0].device] = 0; + } + devices[x.value[0].device] += 1; + }); + devices = Object.keys(devices) + .sort((a, b) => + a.localeCompare(b)) + .map((x) => + `${x} (${devices[x]})`) + .join(', '); + row = this.makeStatsRow('>', 'Device types', devices); + tbody.append(row); + + const websiteGroupNodes = seriesData + .filter((x) => + x.value[0].label === NodeTypes.NODE_WEBSITE_GROUP) + .sort((a, b) => + b.value[1].links_to.length - a.value[1].links_to.length); + row = this.makeStatsRow('#', 'Total IAB categories', websiteGroupNodes.length); + tbody.append(row); + + const rows = websiteGroupNodes + .slice(0, 10) + .map((x, idx) => + this.makeStatsRow(idx + 1, x.value[0].name, x.value[1].links_to.length)); + tbody.append(rows); + } + + makeStatsRow(scope, key, value) { + return $('') + .append($(''); table.append(tbody); + if (!searchResults.indices.length) { + return container.append($('').addClass('lead') + .append(Localization.Messages.SearchQueryFailed)); + } + /* search in document */ const indices = searchResults.indices.filter((x) => x !== INDEX_INGEST) @@ -54,16 +59,12 @@ export default class SearchResultTab extends BaseAnalysisTab { [c0]: true, }), {}); const uuid = this.previewComponent.media.uuid; - const results = await ApiHelper.searchInDocument(uuid, { + const results = (await ApiHelper.searchInDocument(uuid, { ...indices, query: searchResults.query, exact: searchResults.exact, }).then((res) => res.indices) - .catch((e) => undefined); - if (!results) { - return container.append($('').addClass('lead') - .append(Localization.Messages.SearchQueryFailed)); - } + .catch(() => undefined)) || {}; /* known faces */ const knownFaces = this.makeTableRowItem([ @@ -196,8 +197,7 @@ export default class SearchResultTab extends BaseAnalysisTab { mergeResults(categories, searchResults) { const matched = {}; let containTimecodes = false; - for (let i = 0; i < categories.length; i++) { - const category = categories[i]; + for (let category of categories) { const keys = Object.keys(searchResults[category] || {}); while (keys.length) { const key = keys.shift(); diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/transcribe/transcribeTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/transcribe/transcribeTab.js index 51b3c83..9f57080 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/transcribe/transcribeTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/transcribe/transcribeTab.js @@ -32,13 +32,7 @@ export default class TranscribeTab extends BaseAnalysisTab { .html(Localization.Messages.SubtitleSwitch))); const view = this.previewComponent.getSubtitleView(); - view.on(VideoPreview.Events.Track.Loaded, (event, track) => { - /* - if (this.previewComponent.trackIsSub(track)) { - input.prop('checked', true); - } - */ - }); + return col.append(toggle).append(view); } } diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysisComponent.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysisComponent.js index 63e2043..53221e0 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysisComponent.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysisComponent.js @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import CognitoConnector from '../../../../shared/cognitoConnector.js'; import AnalysisTypes from '../../../../shared/analysis/analysisTypes.js'; /* analysis summary */ import StatisticsTab from './analysis/statistics/statisticsTab.js'; @@ -14,6 +15,7 @@ import PersonTab from './analysis/rekognition/video/personTab.js'; import SegmentTab from './analysis/rekognition/video/segmentTab.js'; import TextTab from './analysis/rekognition/video/textTab.js'; /* rekog image */ +import ImageCaptionTab from './analysis/rekognition/image/imageCaption.js'; import CelebImageTab from './analysis/rekognition/image/celebImageTab.js'; import LabelImageTab from './analysis/rekognition/image/labelImageTab.js'; import FaceMatchImageTab from './analysis/rekognition/image/faceMatchImageTab.js'; @@ -34,86 +36,131 @@ import CustomLabelTab from './analysis/rekognition/video/customLabelTab.js'; import SearchResultTab from './analysis/searchResult/searchResultTab.js'; /* ReAnalyze */ import ReAnalyzeTab from './analysis/reAnalyze/reAnalzeTab.js'; +/* knowledge graph */ +import KnowledgeGraphTab from './analysis/knowledgeGraph/knowledgeGraphTab.js'; export default class AnalysisComponent { constructor(previewComponent) { + let defaultStats = true; this.$tabControllers = []; if (previewComponent.searchResults) { this.$tabControllers.push(new SearchResultTab(previewComponent, true)); - } else { - this.$tabControllers.push(new StatisticsTab(previewComponent, true)); - if (previewComponent.media.getTranscribeResults()) { - this.$tabControllers.push(new TranscribeTab(previewComponent)); - } - const rekog = previewComponent.media.getRekognitionResults(); - let types = Object.keys(rekog || {}); - types.forEach((type) => { - const datas = [].concat(rekog[type]); - datas.forEach((data) => { - const controller = type === AnalysisTypes.Rekognition.Celeb - ? new CelebTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Label - ? new LabelTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.FaceMatch - ? new FaceMatchTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Face - ? new FaceTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Person - ? new PersonTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Moderation - ? new ModerationTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Segment - ? new SegmentTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.CustomLabel - ? new CustomLabelTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Text - ? new TextTab(previewComponent, data) - : undefined; - if (controller) { - this.$tabControllers.push(controller); - } - }); - }); - const rekogImage = previewComponent.media.getRekognitionImageResults(); - types = Object.keys(rekogImage || {}); - types.forEach((type) => { - const datas = [].concat(rekogImage[type]); - datas.forEach((data) => { - const controller = type === AnalysisTypes.Rekognition.Celeb - ? new CelebImageTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Label - ? new LabelImageTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.FaceMatch - ? new FaceMatchImageTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Face - ? new FaceImageTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Text - ? new TextImageTab(previewComponent, data) - : type === AnalysisTypes.Rekognition.Moderation - ? new ModerationImageTab(previewComponent, data) - : undefined; - if (controller) { - this.$tabControllers.push(controller); - } - }); + defaultStats = false; + } + this.$tabControllers.push(new StatisticsTab(previewComponent, defaultStats)); + if (KnowledgeGraphTab.canSupport()) { + this.$tabControllers.push(new KnowledgeGraphTab(previewComponent)); + } + if (previewComponent.media.getTranscribeResults()) { + this.$tabControllers.push(new TranscribeTab(previewComponent)); + } + const rekog = previewComponent.media.getRekognitionResults(); + let types = Object.keys(rekog || {}); + types.forEach((type) => { + const datas = [].concat(rekog[type]); + datas.forEach((data) => { + let controller; + switch (type) { + case AnalysisTypes.Rekognition.Celeb: + controller = new CelebTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Label: + controller = new LabelTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.FaceMatch: + controller = new FaceMatchTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Face: + controller = new FaceTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Person: + controller = new PersonTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Moderation: + controller = new ModerationTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Segment: + controller = new SegmentTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.CustomLabel: + controller = new CustomLabelTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Text: + controller = new TextTab(previewComponent, data); + break; + default: + controller = undefined; + } + if (controller) { + this.$tabControllers.push(controller); + } }); - const comprehend = previewComponent.media.getComprehendResults(); - Object.keys(comprehend || {}).forEach((type) => { - const controller = type === AnalysisTypes.Comprehend.Keyphrase - ? new KeyphraseTab(previewComponent) - : type === AnalysisTypes.Comprehend.Entity - ? new EntityTab(previewComponent) - : type === AnalysisTypes.Comprehend.Sentiment - ? new SentimentTab(previewComponent) - : undefined; + }); + /* BLIP model */ + const caption = previewComponent.media.getImageAutoCaptioning(); + if (caption) { + this.$tabControllers.push(new ImageCaptionTab(previewComponent)); + } + const rekogImage = previewComponent.media.getRekognitionImageResults(); + types = Object.keys(rekogImage || {}); + types.forEach((type) => { + const datas = [].concat(rekogImage[type]); + datas.forEach((data) => { + let controller; + switch (type) { + case AnalysisTypes.Rekognition.Celeb: + controller = new CelebImageTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Label: + controller = new LabelImageTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.FaceMatch: + controller = new FaceMatchImageTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Face: + controller = new FaceImageTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Text: + controller = new TextImageTab(previewComponent, data); + break; + case AnalysisTypes.Rekognition.Moderation: + controller = new ModerationImageTab(previewComponent, data); + break; + default: + controller = undefined; + } if (controller) { this.$tabControllers.push(controller); } }); - const textract = previewComponent.media.getTextractResults(); - if (textract) { - this.$tabControllers.push(new TextractTab(previewComponent)); + }); + const comprehend = previewComponent.media.getComprehendResults(); + Object.keys(comprehend || {}).forEach((type) => { + let controller; + switch (type) { + case AnalysisTypes.Comprehend.Keyphrase: + controller = new KeyphraseTab(previewComponent); + break; + case AnalysisTypes.Comprehend.Entity: + controller = new EntityTab(previewComponent); + break; + case AnalysisTypes.Comprehend.Sentiment: + controller = new SentimentTab(previewComponent); + break; + default: + controller = undefined; + } + if (controller) { + this.$tabControllers.push(controller); } + }); + const textract = previewComponent.media.getTextractResults(); + if (textract) { + this.$tabControllers.push(new TextractTab(previewComponent)); + } + /* permission */ + const canWrite = CognitoConnector.getSingleton().canWrite(); + if (canWrite) { this.$tabControllers.push(new ReAnalyzeTab(previewComponent)); } } diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/descriptionList.js b/source/webapp/src/lib/js/app/mainView/collection/base/descriptionList.js index dad75dd..5b1baaf 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/descriptionList.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/descriptionList.js @@ -56,20 +56,25 @@ export default class DescriptionList extends mxReadable(class {}) { } createDetailGroup(name, indent = 0) { - const css = (!indent) - ? { + let css; + if (!indent) { + css = { margin: '', lead: 'lead-s', - } - : (indent === 1) - ? { - margin: 'ml-2', - lead: 'lead-s', - } - : { - margin: 'ml-4', - lead: 'lead-s', - }; + }; + } + else if (indent === 1) { + css = { + margin: 'ml-2', + lead: 'lead-s', + }; + } + else { + css = { + margin: 'ml-4', + lead: 'lead-s', + }; + } const details = $('
      ').addClass(css.margin) .append($('').addClass('my-2') .append($('').addClass(`${css.lead} text-capitalize`) diff --git a/source/webapp/src/lib/js/app/mainView/collection/contentTab.js b/source/webapp/src/lib/js/app/mainView/collection/contentTab.js index 2cd2f37..835e924 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/contentTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/contentTab.js @@ -26,8 +26,6 @@ export default class ContentTab extends mxSpinner(BaseTabPlugins) { this.$previewComponent = new PreviewSlideComponent(); this.$categoryComponent = new CategorySlideComponent(); this.$mediaManager = MediaManager.getSingleton(); - // const dropdown = this.createDropdownMenu(plugins); - // plugins.append(dropdown); } get ids() { diff --git a/source/webapp/src/lib/js/app/mainView/collection/photo/photoPreviewSlideComponent.js b/source/webapp/src/lib/js/app/mainView/collection/photo/photoPreviewSlideComponent.js index 24396a5..749b66c 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/photo/photoPreviewSlideComponent.js +++ b/source/webapp/src/lib/js/app/mainView/collection/photo/photoPreviewSlideComponent.js @@ -2,44 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import BasePreviewSlideComponent from '../base/basePreviewSlideComponent.js'; -import SnapshotComponent from '../base/components/snapshotComponent.js'; export default class PhotoPreviewSlideComponent extends BasePreviewSlideComponent { - constructor() { - super(); - this.$snapshotComponent = undefined; - } - - get snapshotComponent() { - return this.$snapshotComponent; - } - - set snapshotComponent(val) { - this.$snapshotComponent = val; - } - - async setMedia(media, optionalSearchResults) { - await super.setMedia(media, optionalSearchResults); - if (!this.snapshotComponent) { - this.snapshotComponent = new SnapshotComponent(this.previewComponent); - } - } - - async hide() { - if (this.snapshotComponent) { - await this.snapshotComponent.hide(); - } - this.snapshotComponent = undefined; - return super.hide(); - } - - createPreview() { - const preview = $('
      ').addClass('col-12 p-0 m-0') - .append(this.previewComponent.container); - const controls = this.snapshotComponent.createComponent(); - return [ - preview, - controls, - ]; - } } diff --git a/source/webapp/src/lib/js/app/mainView/collection/recommend/knowledgeGraph.js b/source/webapp/src/lib/js/app/mainView/collection/recommend/knowledgeGraph.js new file mode 100644 index 0000000..1a4d07f --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/collection/recommend/knowledgeGraph.js @@ -0,0 +1,433 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import mxReadable from '../../../mixins/mxReadable.js'; +import AppUtils from '../../../shared/appUtils.js'; +import IABCategories from '../../../shared/iabCategories.js'; + +const EVENT_DATA_SELECTED = 'knowledge:data:selected'; +const EVENT_LEGEND_CHANGED = 'knowledge:legend:changed'; + +const CAT_IDENTITYGROUP = 'Household'; +const CAT_PERSISTENTID = 'User'; +const CAT_TRANSIENTID = 'Device'; +const CAT_WEBSITEPATH = 'Website'; +const CAT_CATEGORYCODE = 'IAB_Category'; +const CAT_WEBSITEGROUP = 'WebsiteGroup'; + +const NODESIZE_IDENTITYGROUP = 60; +const NODESIZE_PERSISTENTID = 40; +const NODESIZE_TRANSIENTID = 30; +const NODESIZE_CATEGORYCODE = 20; +const NODESIZE_WEBSITEPATH = 10; +const NODESIZE_WEBSITEGROUP = 50; + +export default class KnowledgeGraph extends mxReadable(class {}) { + constructor(datasets, container) { + super(); + this.$datasets = datasets; + this.$graphContainer = $('
      ') + .addClass('knowledge-graph') + .attr('id', `knowledge-${AppUtils.randomHexstring()}`); + container.append(this.$graphContainer); + + this.$graph = undefined; + this.$graphContainer.ready(() => { + console.log('graphContainer ready'); + this.$graph = this.buildGraph(this.$graphContainer, this.datasets); + }); + } + + get graphContainer() { + return this.$graphContainer; + } + + set graphContainer(val) { + this.$graphContainer = val; + } + + get graph() { + return this.$graph; + } + + set graph(val) { + this.$graph = val; + } + + get datasets() { + return this.$datasets; + } + + set datasets(val) { + this.$datasets = val; + } + + get graphId() { + return this.graphContainer.prop('id'); + } + + getGraphContainer() { + return this.graphContainer; + } + + buildGraph(container, dataset) { + const height = Math.max(Math.round(container.height()), 800); + const width = Math.max(Math.round(container.width()), 800); + + const graph = echarts.init(container[0], null, { + renderer: 'canvas', + useDirtyRect: false, + height, + width, + }); + const parsed = this.parseGraphData(dataset); + const options = this.makeGraphOptions(parsed); + graph.setOption(options); + return graph; + } + + parseGraphData(graphData) { + const dataset = { + nodes: [], + links: [], + categories: [ + { + name: CAT_IDENTITYGROUP, + }, + { + name: CAT_PERSISTENTID, + }, + { + name: CAT_TRANSIENTID, + }, + { + name: CAT_WEBSITEPATH, + }, + { + name: CAT_CATEGORYCODE, + }, + ], + }; + const identityGroup = graphData[0]; + const { + websiteNodes, + } = this.parseWebsitePath(identityGroup, dataset); + this.parseIdentityGroupNode(identityGroup, dataset, websiteNodes); + + return dataset; + } + + parseWebsitePath(root, dataset) { + const modified = dataset; + const randomWebsitePaths = root.persistentIds + .map((persistentId) => + persistentId.transientIds + .map((transientId) => + transientId.randomWebsitePaths)) + .flat(2); + + /* add category nodes */ + const categoryNodes = []; + [ + ...new Set(randomWebsitePaths + .map((x) => + x.categoryCode)), + ].forEach((code) => { + const node = { + id: String(modified.nodes.length), + name: code, + symbolSize: NODESIZE_CATEGORYCODE, + ...this.randomXYCoord(NODESIZE_CATEGORYCODE), + value: this.lookupIABCategoryDesc(code), + category: modified.categories.findIndex((x) => + x.name === CAT_CATEGORYCODE), + }; + modified.nodes.push(node); + categoryNodes.push(node); + }); + + /* add website nodes and links to category */ + const uniqueUrls = [ + ...new Set(randomWebsitePaths + .map((x) => + x.url)), + ]; + const websiteNodes = []; + while (randomWebsitePaths.length) { + const path = randomWebsitePaths.shift(); + const idx = uniqueUrls.findIndex((x) => + x === path.url); + if (idx < 0) { + continue; + } + const node = { + id: String(modified.nodes.length), + name: 'website', + symbolSize: NODESIZE_WEBSITEPATH, + ...this.randomXYCoord(NODESIZE_WEBSITEPATH), + value: path.url, + category: modified.categories.findIndex((x) => + x.name === CAT_WEBSITEPATH), + }; + modified.nodes.push(node); + websiteNodes.push(node); + uniqueUrls.splice(idx, 1); + + /* create link */ + const category = categoryNodes.find((x) => + x.name === path.categoryCode); + if (category) { + const source = node.id; + const target = category.id; + modified.links.push({ + source, + target, + }); + } else { + console.log('ERR: fail to find link', path.url, path.categoryCode); + } + } + + return { + categoryNodes, + websiteNodes, + }; + } + + randomXYCoord(symbolSize) { + const start = 0 - 1200; + const end = 1200; + const random = end - start + 10; + + const x = Math.floor(Math.random() * random + start); + const y = Math.floor(Math.random() * random + start); + return { + x, + y, + }; + } + + lookupIABCategoryDesc(code) { + let desc = 'Uncategorized'; + const cat = code.split('-'); + if (!cat[0]) { + return desc; + } + const rootCategory = IABCategories.find((x) => + x.root_category_code === cat[0]); + if (!rootCategory) { + return desc; + } + desc = rootCategory.root_category_name; + if (!cat[1]) { + return desc; + } + const leafCategory = rootCategory.leaf_categories.find((x) => + x.leaf_category_code === code); + if (!leafCategory) { + return desc; + } + desc = `${desc}, ${leafCategory.leaf_category_value}`; + return desc; + } + + parseIdentityGroupNode(identityGroup, dataset, websiteNodes) { + const node = { + id: String(dataset.nodes.length), + name: identityGroup.label, + symbolSize: NODESIZE_IDENTITYGROUP, + ...this.randomXYCoord(NODESIZE_IDENTITYGROUP), + value: `${identityGroup.identityGroupId} (${identityGroup.persistentIds.length} members)`, + category: dataset.categories.findIndex((x) => + x.name === CAT_IDENTITYGROUP), + }; + dataset.nodes.push(node); + + identityGroup.persistentIds.forEach((persistentId) => { + this.parsePersistentIdNode(persistentId, node, dataset, websiteNodes); + }); + return dataset; + } + + parsePersistentIdNode(persistentId, srcNode, dataset, websiteNodes) { + const node = { + id: String(dataset.nodes.length), + name: persistentId.label, + symbolSize: NODESIZE_PERSISTENTID, + ...this.randomXYCoord(NODESIZE_PERSISTENTID), + value: `${persistentId.persistentId} (${persistentId.transientIds.length} devices)`, + category: dataset.categories.findIndex((x) => + x.name === CAT_PERSISTENTID), + }; + dataset.nodes.push(node); + + dataset.links.push({ + source: srcNode.id, + target: node.id, + }); + /* parse transientId */ + persistentId.transientIds.forEach((transientId) => { + this.parseTransientIdNode(transientId, node, dataset, websiteNodes); + }); + return dataset; + } + + parseTransientIdNode(transientId, srcNode, dataset, websiteNodes) { + const ipLocation = transientId.ipLocations[0]; + const value = [ + { + key: 'Device', + val: transientId.device, + }, + { + key: 'Email', + val: transientId.email, + }, + { + key: 'Address', + val: `${ipLocation.city}, ${ipLocation.state}`, + }, + { + key: 'IP', + val: ipLocation.ipAddress, + }, + { + key: 'OS', + val: transientId.os, + }, + { + key: 'Browser/UserAgent', + val: `${transientId.browser}/${transientId.userAgent.split(' ')[0]}`, + }, + { + key: 'TransientId', + val: transientId.transientId, + }, + ]; + const node = { + id: String(dataset.nodes.length), + name: transientId.label, + symbolSize: NODESIZE_TRANSIENTID, + ...this.randomXYCoord(NODESIZE_TRANSIENTID), + value: value + .map((x) => + `${x.key}: ${x.val}`) + .join('
      '), + category: dataset.categories.findIndex((x) => + x.name === CAT_TRANSIENTID), + }; + dataset.nodes.push(node); + + dataset.links.push({ + source: srcNode.id, + target: node.id, + }); + + /* create links to websites */ + transientId.randomWebsitePaths + .map((x) => + x.url) + .forEach((url) => { + const found = websiteNodes + .find((x) => + x.value === url); + if (found) { + dataset.links.push({ + source: node.id, + target: found.id, + }); + } + }); + return dataset; + } + + makeGraphOptions(dataset) { + const title = {}; + const tooltip = { + show: true, + trigger: 'item', + formatter: ((x) => { + if (x.dataType === 'edge') { + return `${dataset.nodes[Number(x.data.source)].name} > ${dataset.nodes[Number(x.data.target)].name}`; + } + return x.value; + }), + }; + const legend = [ + { + data: dataset.categories.map(function (a) { + return a.name; + }), + }, + ]; + const animationDuration = 1500; + const animationEasingUpdate = 'quinticInOut'; + const series = [ + { + name: 'Graph database', + type: 'graph', + layout: 'none', + data: dataset.nodes, + links: dataset.links, + categories: dataset.categories, + roam: 'move', + label: { + position: 'right', + formatter: '{b}', + }, + lineStyle: { + color: 'source', + curveness: 0.3, + }, + emphasis: { + focus: 'adjacency', + lineStyle: { + width: 10, + }, + }, + }, + ]; + + dataset.nodes.forEach((node) => { + /* eslint-disable-next-line */ + node.label = { + show: node.symbolSize >= 20, + }; + }); + + return { + title, + tooltip, + legend, + animationDuration, + animationEasingUpdate, + series, + }; + } + + destroy() { + if (this.graph) { + this.graph.dispose(); + } + this.graph = undefined; + if (this.graphContainer) { + this.graphContainer.remove(); + } + this.graphContainer = undefined; + if (this.datasets) { + this.datasets.length = 0; + } + } + + resize() { + return this.graph.resize(); + } + + on(event, fn) { + return this.graphContainer.on(event, fn); + } + + off(event) { + return this.graphContainer.off(event); + } +} diff --git a/source/webapp/src/lib/js/app/mainView/collection/recommend/recommendCategorySlideComponent.js b/source/webapp/src/lib/js/app/mainView/collection/recommend/recommendCategorySlideComponent.js new file mode 100644 index 0000000..3281cf5 --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/collection/recommend/recommendCategorySlideComponent.js @@ -0,0 +1,972 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SolutionManifest from '/solution-manifest.js'; +import AnalysisTypes from '../../../shared/analysis/analysisTypes.js'; +import Localization from '../../../shared/localization.js'; +import MediaTypes from '../../../shared/media/mediaTypes.js'; +import AppUtils from '../../../shared/appUtils.js'; +import MediaManager from '../../../shared/media/mediaManager.js'; +import SettingStore from '../../../shared/localCache/settingStore.js'; +import ImageStore from '../../../shared/localCache/imageStore.js'; +import CategorySlideEvents from '../base/categorySlideComponentEvents.js'; +import BaseSlideComponent from '../../../shared/baseSlideComponent.js'; + +const DESC = [ + 'Search similar items using Amazon Neptune and Amazon Neptune ML that creates embeddings from the graph database. The embeddings are then indexed into an Amazon OpenSearch Service along with the K-nearest neighbors (KNN) plugin.', + 'Or, traverse the knowledge graph to find items based on genre, category, keyword, topic, and so forth.', +].map((x) => + `

      ${x}

      `); +const ID_SIMILARITY_LIST = `similarity-list-${AppUtils.randomHexstring()}`; +const ID_GRAPH = `graph-${AppUtils.randomHexstring()}`; +const ID_RELEVANT_ITEMS = `relevant-items-${AppUtils.randomHexstring()}`; +const ID_LESS_RELEVANT_ITEMS = `less-relevant-items-${AppUtils.randomHexstring()}`; +const GRAPH_NODE_TYPES = 'graph-node-types'; +const NODE_VIDEO = 'Video'; +const NODE_CELEBRITY = 'celebrity'; +const NODE_INDUSTRY = 'industry'; +const NODE_PRODUCTS = 'products'; +const NODE_SERVICES = 'services'; +const NODE_EVENT_TYPE = 'event_type'; +const NODESIZE_BY_TYPE = { + [NODE_EVENT_TYPE]: 50, + [NODE_VIDEO]: 45, + [NODE_INDUSTRY]: 40, + [NODE_CELEBRITY]: 35, + [NODE_PRODUCTS]: 30, + [NODE_SERVICES]: 25, +}; + +const PAGESIZE = 10; +const SIMILARITY_SIZE = 5; + +const ID_SEARCHRESULT_LIST = `results-list-${AppUtils.randomHexstring()}`; +const ID_SEARCHRESULT_CONTAINER = `results-container-${AppUtils.randomHexstring()}`; +const KEY_SEARCHOPTIONS = 'search-options'; +const OPTKEY_EXACT = 'exact'; +const OPTKEY_QUERY = 'query'; +const OPTKEY_PAGESIZE = 'pageSize'; +const OPTVAL_PAGESIZE10 = 10; +const OPTVAL_PAGESIZE30 = 30; +const OPTVAL_PAGESIZE50 = 50; +const INDEX_INGEST = 'ingest'; +const DEFAULT_OPTIONS = { + [MediaTypes.Video]: true, + [MediaTypes.Photo]: true, + [MediaTypes.Podcast]: true, + [MediaTypes.Document]: true, + [OPTKEY_EXACT]: false, + [OPTKEY_PAGESIZE]: OPTVAL_PAGESIZE10, + [OPTKEY_QUERY]: undefined, + [AnalysisTypes.Transcribe]: true, + [AnalysisTypes.Rekognition.Celeb]: true, + [AnalysisTypes.Rekognition.FaceMatch]: true, + [AnalysisTypes.Rekognition.Label]: true, + [AnalysisTypes.Rekognition.CustomLabel]: true, + [AnalysisTypes.Rekognition.Moderation]: true, + [AnalysisTypes.Rekognition.Text]: true, + [AnalysisTypes.Textract]: true, + [AnalysisTypes.Comprehend.Keyphrase]: true, + [AnalysisTypes.Comprehend.Entity]: true, + [AnalysisTypes.Comprehend.CustomEntity]: true, + [INDEX_INGEST]: true, +}; +const DATA_UUID = 'data-uuid'; +const DATA_SEARCHTOKEN = 'data-token'; +/* Reference: https://www.fileformat.info/info/unicode/category/Lu/list.htm */ +const UNICODE_CHARACTER_SETS = '[0-9A-Za-z\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC ,.\'’-]{1,}'; +const NUM_SEARCH_ITEM_SHOW = 2; + +export default class RecommendCategorySlideComponent extends BaseSlideComponent { + constructor() { + super(); + this.$mediaManager = MediaManager.getSingleton(); + this.$settingStore = SettingStore.getSingleton(); + this.$imageStore = ImageStore.getSingleton(); + this.$graph = undefined; + this.$graphMapping = undefined; + } + + get mediaManager() { + return this.$mediaManager; + } + + get settingStore() { + return this.$settingStore; + } + + get imageStore() { + return this.$imageStore; + } + + get graph() { + return this.$graph; + } + + set graph(val) { + this.$graph = val; + } + + get graphMapping() { + return this.$graphMapping; + } + + set graphMapping(val) { + this.$graphMapping = val; + } + + async show() { + if (!this.initialized) { + const container = $('
      ') + .addClass('row no-gutters'); + this.slide.append(container); + + const desc = this.createDescription(); + container.append(desc); + + const searchSimilarityForm = this.createSimilaritySearchForm(); + container.append(searchSimilarityForm); + + const interactiveGraphSearch = this.createInteractiveGraphSearch(); + container.append(interactiveGraphSearch); + + const loading = this.createLoading(); + container.append(loading); + } + return super.show(); + } + + createDescription() { + const container = $('
      ') + .addClass('col-9 p-0 mx-auto mt-4'); + + const desc = $('

      ') + .addClass('lead') + .html(DESC); + container.append(desc); + + return container; + } + + createSimilaritySearchForm() { + const section = $('

      ') + .addClass('col-12 bg-light'); + + const container = $('
      ') + .addClass('col-9 p-0 mx-auto my-4'); + section.append(container); + + const title = $('

      ') + .addClass('lead-m') + .append('Search similar items (Amazon Neptune ML embeddings)'); + container.append(title); + + const formContainer = $('') + .addClass('px-0 form-inline needs-validation') + .attr('novalidate', 'novalidate') + .attr('role', 'form'); + container.append(formContainer); + + const searchInput = this.createSearchInput(formContainer); + formContainer.append(searchInput); + + const submitBtn = this.createSubmitButton(formContainer); + formContainer.append(submitBtn); + + const resultContainer = $('

      ') + .attr('id', ID_SIMILARITY_LIST); + container.append(resultContainer); + + return section; + } + + createSearchInput(form) { + const input = $('') + .addClass('form-control mr-2 col-4') + .attr('type', 'search') + .attr('pattern', UNICODE_CHARACTER_SETS) + .attr('placeholder', Localization.Messages.Search); + + input.keypress(async (event) => { + if (event.which === 13) { + event.preventDefault(); + const btn = input.siblings('button[type="submit"]'); + return btn.trigger('click'); + } + return true; + }); + return input; + } + + createSubmitButton(form) { + const btn = $('
      ') + .attr('scope', 'row') + .append(scope)) + .append($('') + .append(key)) + .append($('') + .append(value)); + } + + async renderAll() { + try { + this.loading(true); + if (!this.identityGraph) { + return; + } + + let series = this.identityGraph.getGraphSeries() || {}; + let seriesData = series.nodes || series.data; + if (!seriesData) { + return; + } + + let dataset; + /* find houshold first */ + let householdNode = seriesData.find((x) => + x.value[0].label === NodeTypes.NODE_IDENTITY_GROUP); + if (!householdNode) { + const userNode = seriesData.find((x) => + x.value[0].label === NodeTypes.NODE_PERSISTENT_ID); + if (!userNode) { + console.log('[ERR]: renderAll: cannot find any node to continue'); + return; + } + dataset = await this.getHousehold(userNode.value[0].id); + series = this.identityGraph.updateGraph(dataset); + seriesData = series.nodes || series.data; + + householdNode = seriesData.find((x) => + x.value[0].label === NodeTypes.NODE_IDENTITY_GROUP); + if (!householdNode) { + console.log('[ERR]: renderAll: still cannot find household node'); + return; + } + } + + /* from houshold, find all users */ + dataset = await this.getConnectedUsers(householdNode); + series = this.identityGraph.updateGraph(dataset); + seriesData = series.nodes || series.data; + + /* from users, find all devices */ + const userNodes = seriesData.filter((x) => + x.value[0].label === NodeTypes.NODE_PERSISTENT_ID); + dataset = await this.getConnectedDevices(userNodes); + series = this.identityGraph.updateGraph(dataset); + seriesData = series.nodes || series.data; + + /* from devices, find all websites */ + const deviceNodes = seriesData.filter((x) => + x.value[0].label === NodeTypes.NODE_TRANSIENT_ID); + dataset = await this.getConnectedWebsites(deviceNodes); + series = this.identityGraph.updateGraph(dataset); + seriesData = series.nodes || series.data; + + /* from websites, find all IAB category */ + const websiteNodes = seriesData.filter((x) => + x.value[0].label === NodeTypes.NODE_WEBSITE); + dataset = await this.getConnectedWebsiteGroup(websiteNodes); + series = this.identityGraph.updateGraph(dataset); + seriesData = series.nodes || series.data; + } catch (e) { + console.error(e); + } finally { + this.loading(false); + } + } + + async getHousehold(id) { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id, + start: 0, + end: 10, + label: NodeTypes.NODE_IDENTITY_GROUP, + direction: 'both', + }); + } + + async getConnectedUsers(householdNode) { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id: householdNode.value[0].id, + label: NodeTypes.NODE_PERSISTENT_ID, + start: 0, + end: 10, + direction: 'out', + }); + } + + async getConnectedDevices(userNodes) { + const ids = userNodes.map((x) => + x.value[0].id); + + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id: ids.join(','), + label: NodeTypes.NODE_TRANSIENT_ID, + start: 0, + end: 200, + direction: 'both', + }); + } + + async getConnectedWebsites(deviceNodes) { + const ids = deviceNodes.map((x) => + x.value[0].id); + + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id: ids.join(','), + label: NodeTypes.NODE_WEBSITE, + direction: 'out', + start: 0, + end: 1000, + }); + } + + async getConnectedWebsiteGroup(websiteNodes) { + const ids = websiteNodes.map((x) => + x.value[0].id); + + const dataset = []; + while (ids.length) { + const spliced = ids.splice(0, 40); + const subset = await IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id: spliced.join(','), + label: NodeTypes.NODE_WEBSITE_GROUP, + direction: 'in', + }); + dataset.splice(dataset.length, 0, ...subset); + } + return dataset; + } + + async getDeviceIds() { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + label: NodeTypes.NODE_TRANSIENT_ID, + start: 0, + end: 50, + }); + } + + async getUserId(id) { + return IdentityGraph.graphApi({ + op: 'identity', + type: 'vertice', + id, + label: [ + NodeTypes.NODE_PERSISTENT_ID, + NodeTypes.NODE_IP, + ].join(','), + start: 0, + end: 20, + direction: 'both', + }); + } +} diff --git a/source/webapp/index.html b/source/webapp/index.html index 108fde7..a5c2092 100644 --- a/source/webapp/index.html +++ b/source/webapp/index.html @@ -31,6 +31,6 @@ - + diff --git a/source/webapp/package.json b/source/webapp/package.json index 8df6065..e29675c 100644 --- a/source/webapp/package.json +++ b/source/webapp/package.json @@ -2,16 +2,26 @@ "name": "m2c-webapp", "version": "2.0.0", "description": "media2cloud webapp", - "main": "index.js", + "main": "src/lib/js/app.js", + "type": "module", "private": true, "scripts": { "pretest": "npm install", - "test": "echo \"no test\"", + "test": "jest --coverage", "build:clean": "rm -rf dist && mkdir -p dist", "build:copy": "cp -rv css images src favicon.ico index.html 404.html ./dist", "build": "npm-run-all -s build:clean build:copy", "zip": "cd dist && zip -rq" }, + "jest": { + "setupTestFrameworkScriptFile": "tests/setup.js", + "transform": { + "^.+\\.jsx?$": "babel-jest" + } + }, "author": "aws-mediaent-solutions", - "devDependencies": {} + "devDependencies": { + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "jest": "^29.3.1" + } } diff --git a/source/webapp/src/lib/js/app.js b/source/webapp/src/lib/js/app.js index e7ad5b4..f49fdf5 100644 --- a/source/webapp/src/lib/js/app.js +++ b/source/webapp/src/lib/js/app.js @@ -14,15 +14,16 @@ export default class DemoApp { container: `app-${AppUtils.randomHexstring()}`, }; const view = $('
      ').attr('id', this.ids.container); - const mainView = new MainView(); - mainView.appendTo(view); const signIn = new SignInFlow(); signIn.appendTo(view); + signIn.view.on(SignInFlow.Events.View.Hidden, () => - setTimeout(async () => - mainView.show(), 10)); + setTimeout(async () => { + const mainView = new MainView(); + mainView.appendTo(view); + mainView.show(); + }, 10)); this.$signInFlow = signIn; - this.$mainView = mainView; this.$view = view; } @@ -34,10 +35,6 @@ export default class DemoApp { return this.$view; } - get mainView() { - return this.$mainView; - } - get signInFlow() { return this.$signInFlow; } diff --git a/source/webapp/src/lib/js/app/mainView.js b/source/webapp/src/lib/js/app/mainView.js index 3add73c..c601626 100644 --- a/source/webapp/src/lib/js/app/mainView.js +++ b/source/webapp/src/lib/js/app/mainView.js @@ -11,6 +11,7 @@ import ProcessingTab from './mainView/processingTab.js'; import StatsTab from './mainView/statsTab.js'; import FaceCollectionTab from './mainView/faceCollectionTab.js'; import SettingsTab from './mainView/settingsTab.js'; +import UserManagementTab from './mainView/userManagementTab.js'; const ID_MAIN_CONTAINER = `main-${AppUtils.randomHexstring()}`; const ID_MAIN_TOASTLIST = `main-${AppUtils.randomHexstring()}`; @@ -23,14 +24,7 @@ export default class MainView { constructor() { this.$view = $('
      ').attr('id', ID_MAIN_CONTAINER); this.$cognito = CognitoConnector.getSingleton(); - this.$tabControllers = [ - new CollectionTab(true), - new UploadTab(), - new ProcessingTab(), - new StatsTab(), - new FaceCollectionTab(), - new SettingsTab(), - ]; + this.$tabControllers = this.initTabControllersByGroup(); } get view() { @@ -45,6 +39,37 @@ export default class MainView { return this.$tabControllers; } + initTabControllersByGroup() { + const tabControllers = {}; + /* read only access */ + if (this.cognito.canRead()) { + tabControllers.collection = new CollectionTab(true); + tabControllers.processing = new ProcessingTab(); + tabControllers.stats = new StatsTab(); + } + /* read/write access */ + if (this.cognito.canWrite()) { + tabControllers.upload = new UploadTab(); + tabControllers.faceCollection = new FaceCollectionTab(); + tabControllers.settings = new SettingsTab(); + } + /* read/write/modify access */ + if (this.cognito.canModify()) { + tabControllers.userManagement = new UserManagementTab(); + } + + return [ + tabControllers.collection, + tabControllers.upload, + tabControllers.processing, + tabControllers.stats, + tabControllers.faceCollection, + tabControllers.settings, + tabControllers.userManagement, + ].filter((x) => + x !== undefined); + } + appendTo(parent) { return parent.append(this.view); } diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/baseCategorySlideComponent.js b/source/webapp/src/lib/js/app/mainView/collection/base/baseCategorySlideComponent.js index c0f68b5..d723237 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/baseCategorySlideComponent.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/baseCategorySlideComponent.js @@ -300,8 +300,8 @@ export default class BaseCategorySlideComponent extends BaseSlideComponent { this.loading(true); const medias = await this.mediaManager.scanRecordsByCategory(this.mediaType); if (medias) { - for (let i = 0; i < medias.length; i++) { - await this.onMediaAdded(medias[i]); + for (let media of medias) { + await this.onMediaAdded(media); } } this.loading(false); diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/baseMediaTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/baseMediaTab.js index c4d0853..51c4256 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/baseMediaTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/baseMediaTab.js @@ -3,12 +3,15 @@ import SolutionManifest from '/solution-manifest.js'; import Localization from '../../../shared/localization.js'; +import CognitoConnector from '../../../shared/cognitoConnector.js'; import AppUtils from '../../../shared/appUtils.js'; import mxSpinner from '../../../mixins/mxSpinner.js'; import BaseTabPlugins from '../../../shared/baseTabPlugins.js'; import BaseCategorySlideComponent from './baseCategorySlideComponent.js'; import BasePreviewSlideComponent from './basePreviewSlideComponent.js'; +const ERR_DELETE_MEDIA_NOT_ALLOWED = Localization.Alerts.DeleteMediaNotAllowed; + export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { constructor(defaultTab, tabName, plugins) { super(tabName, { @@ -23,6 +26,7 @@ export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { }; this.$categorySlideComponent = undefined; this.$previewSlideComponent = undefined; + this.$canWrite = CognitoConnector.getSingleton().canWrite(); } get categorySlideComponent() { @@ -33,6 +37,10 @@ export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { return this.$previewSlideComponent; } + get canWrite() { + return this.$canWrite; + } + async show() { if (!this.initialized) { const carousel = await this.createCarousel(); @@ -47,6 +55,9 @@ export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { async createCarousel() { this.categorySlideComponent.on(BaseCategorySlideComponent.Events.Media.Removing, async (event, media) => { + if (!this.canWrite) { + return this.showPermissionErrorDialog(media); + } if (media.overallStatus === SolutionManifest.Statuses.Processing) { return this.showProcessingDialog(media); } @@ -59,11 +70,12 @@ export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { return undefined; }); this.categorySlideComponent.on(BaseCategorySlideComponent.Events.Media.Selected, async (event, media, optionalSearchResults) => { - if (media.overallStatus === SolutionManifest.Statuses.Processing) { - return this.showProcessingDialog(media); - } - if (media.overallStatus === SolutionManifest.Statuses.Error) { - return this.showErrorDialog(media); + switch (media.overallStatus) { + case SolutionManifest.Statuses.Processing: + return this.showProcessingDialog(media); + case SolutionManifest.Statuses.Error: + return this.showErrorDialog(media); + default: //do nothing } this.loading(true); await this.previewSlideComponent.setMedia(media, optionalSearchResults); @@ -99,17 +111,22 @@ export default class BaseMediaTab extends mxSpinner(BaseTabPlugins) { carousel.on('slide.bs.carousel', async (event) => { const id = $(event.relatedTarget).prop('id'); - if (id === this.previewSlideComponent.slideId) { - return this.previewSlideComponent.show(); - } - if (id === this.categorySlideComponent.slideId) { - return this.categorySlideComponent.show(); + switch (id) { + case this.previewSlideComponent.slideId: + return this.previewSlideComponent.show(); + case this.categorySlideComponent.slideId: + return this.categorySlideComponent.show(); + default: + return undefined; } - return undefined; }); return carousel; } + async showPermissionErrorDialog(media) { + return this.showDialog(media.basename, ERR_DELETE_MEDIA_NOT_ALLOWED); + } + async showProcessingDialog(media) { const message = Localization.Messages.MediaInProcess .replace('{{BASENAME}}', media.basename) diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/basePreviewSlideComponent.js b/source/webapp/src/lib/js/app/mainView/collection/base/basePreviewSlideComponent.js index 2c8ed15..ea6f3ed 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/basePreviewSlideComponent.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/basePreviewSlideComponent.js @@ -2,10 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import Localization from '../../../shared/localization.js'; +import CognitoConnector from '../../../shared/cognitoConnector.js'; import MediaFactory from '../../../shared/media/mediaFactory.js'; +import MediaTypes from '../../../shared/media/mediaTypes.js'; import PreviewSlideEvents from './previewSlideComponentEvents.js'; import TechnicalComponentHelper from './components/technicalComponentHelper.js'; import AnalysisComponent from './components/analysisComponent.js'; +import SnapshotComponent from './components/snapshotComponent.js'; import BaseSlideComponent from '../../../shared/baseSlideComponent.js'; const BKGD_PREVIEW = 'bg-white'; @@ -21,6 +24,8 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { super(); this.$previewComponent = undefined; this.$analysisComponent = undefined; + this.$snapshotComponent = undefined; + this.$canWrite = CognitoConnector.getSingleton().canWrite(); } static get Events() { @@ -43,10 +48,22 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { this.$analysisComponent = val; } + get snapshotComponent() { + return this.$snapshotComponent; + } + + set snapshotComponent(val) { + this.$snapshotComponent = val; + } + get media() { return (this.$previewComponent || {}).media; } + get canWrite() { + return this.$canWrite; + } + async setMedia(media, optionalSearchResults) { if (optionalSearchResults !== undefined || this.media !== media) { @@ -54,6 +71,12 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { this.previewComponent = await MediaFactory.createPreviewComponent(media, optionalSearchResults); this.analysisComponent = new AnalysisComponent(this.previewComponent); } + if (media.type === MediaTypes.Video + || media.type === MediaTypes.Photo) { + if (this.canWrite && !this.snapshotComponent) { + this.snapshotComponent = new SnapshotComponent(this.previewComponent); + } + } } async show() { @@ -72,6 +95,10 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { } async hide() { + if (this.snapshotComponent) { + await this.snapshotComponent.hide(); + } + this.snapshotComponent = undefined; if (this.previewComponent) { await this.previewComponent.unload(); } @@ -105,7 +132,15 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { createPreview() { const preview = $('
      ').addClass('col-12 p-0 m-0') .append(this.previewComponent.container); - return preview; + + let controls; + if (this.snapshotComponent) { + controls = this.snapshotComponent.createComponent(); + } + return [ + preview, + controls, + ]; } createTechnicalView() { @@ -144,7 +179,7 @@ export default class BasePreviewSlideComponent extends BaseSlideComponent { height, } = this.previewComponent.getContainerDimensions(); const tech = this.slide.find(`div[${DATA_VIEW}="${VIEW_TECH}"]`); - // tech.css('height', height); + return this; } } diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/baseAnalysisTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/baseAnalysisTab.js index c54c634..877dbdd 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/baseAnalysisTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/baseAnalysisTab.js @@ -124,20 +124,25 @@ export default class BaseAnalysisTab extends mxReadable(mxSpinner(BaseTab)) { } createGrouping(name, indent = 0) { - const css = (!indent) - ? { + let css; + if (!indent) { + css = { margin: '', lead: 'lead-sm', - } - : (indent === 1) - ? { - margin: 'ml-2', - lead: 'lead-xs', - } - : { - margin: 'ml-4', - lead: 'lead-xxs', - }; + }; + } + else if (indent === 1) { + css = { + margin: 'ml-2', + lead: 'lead-xs', + }; + } + else { + css = { + margin: 'ml-4', + lead: 'lead-xxs', + }; + } const details = $('
      ').addClass(css.margin) .append($('').addClass('my-2') .append($('').addClass(`${css.lead} text-capitalize`) diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/knowledgeGraph.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/knowledgeGraph.js new file mode 100644 index 0000000..65d96ba --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/knowledgeGraph.js @@ -0,0 +1,489 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SolutionManifest from '/solution-manifest.js'; +import mxReadable from '../../../../../../mixins/mxReadable.js'; +import AppUtils from '../../../../../../shared/appUtils.js'; +import ImageStore from '../../../../../../shared/localCache/imageStore.js'; +import MediaManager from '../../../../../../shared/media/mediaManager.js'; + +const NODE_VIDEO = 'Video'; +const NODE_CELEBRITY = 'celebrity'; +const NODE_INDUSTRY = 'industry'; +const NODE_PRODUCTS = 'products'; +const NODE_SERVICES = 'services'; +const NODE_EVENT_TYPE = 'event_type'; +const NODESIZE_BY_TYPE = { + [NODE_VIDEO]: 45, + [NODE_EVENT_TYPE]: 20, + [NODE_INDUSTRY]: 20, + [NODE_CELEBRITY]: 20, + [NODE_PRODUCTS]: 20, + [NODE_SERVICES]: 20, +}; +const GRAPH_MAPPING = Object.keys(NODESIZE_BY_TYPE) + .map((x) => ({ + name: x, + })); +const PAGESIZE = 10; + +export default class KnowledgeGraph extends mxReadable(class {}) { + constructor(container, media, parent) { + super(); + this.$parent = parent; + this.$mediaManager = MediaManager.getSingleton(); + this.$imageStore = ImageStore.getSingleton(); + this.$media = media; + this.$graphContainer = $('
      ') + .addClass('knowledge-graph') + .attr('id', `knowledge-${AppUtils.randomHexstring()}`); + container.append(this.$graphContainer); + + this.$graph = undefined; + this.$graphContainer.ready(async () => { + console.log('graphContainer ready'); + this.$graph = await this.buildGraph(this.$graphContainer); + }); + } + + get parent() { + return this.$parent; + } + + get mediaManager() { + return this.$mediaManager; + } + + get imageStore() { + return this.$imageStore; + } + + get graphContainer() { + return this.$graphContainer; + } + + set graphContainer(val) { + this.$graphContainer = val; + } + + get graph() { + return this.$graph; + } + + set graph(val) { + this.$graph = val; + } + + get graphId() { + return this.graphContainer.prop('id'); + } + + get media() { + return this.$media; + } + + loading(enabled) { + return this.parent.loading(enabled); + } + + getGraphContainer() { + return this.graphContainer; + } + + async buildGraph(container) { + const height = Math.max(Math.round(container.height()), 800); + const width = Math.max(Math.round(container.width()), 800); + + const graph = echarts.init(container[0], null, { + renderer: 'canvas', + useDirtyRect: false, + height, + width, + }); + + graph.on('dblclick', async (event) => { + if (event.event.event.detail === 2) { + try { + this.loading(true); + const type = event.name; + const data = event.data; + const parsed = data.value[0]; + console.log('dblclick', event, parsed); + if (parsed.traversed !== true) { + const connectedNodes = await this.getConnectedNodes(type, parsed.id, parsed.token); + if (connectedNodes === undefined) { + return; + } + if (connectedNodes.length === 0) { + parsed.traversed = true; + } else { + parsed.traversed = false; + parsed.token = (parsed.token || 0) + connectedNodes.length; + } + + const graphSeries = this.graph.getOption().series[event.seriesIndex]; + while (connectedNodes.length) { + const connected = connectedNodes.shift(); + let from; + let to; + if (connected.IN.id === parsed.id) { + from = data; + to = await this.createAdjacentNode(connected.OUT, graphSeries); + } else if (connected.OUT.id === parsed.id) { + to = data; + from = await this.createAdjacentNode(connected.IN, graphSeries); + } + if (from !== undefined && to !== undefined) { + this.createRelationship(from, to, connected, graphSeries); + } + } + /* update graph */ + this.graph.setOption({ + series: [graphSeries], + }); + } + } catch (e) { + console.error(e); + } finally { + this.loading(false); + } + } + }); + + try { + this.loading(true); + + const dataset = await this.getConnectedNodes(NODE_VIDEO, this.media.uuid); + const graphOptions = { + nodes: [], + links: [], + categories: GRAPH_MAPPING, + }; + + let srcNode = dataset.find((x) => + x.OUT.id === this.media.uuid); + srcNode = await this.createNode({ + ...srcNode.OUT, + token: dataset.length, + traversed: false, + }, 0); + graphOptions.nodes.push(srcNode); + + while (dataset.length) { + const connected = dataset.shift(); + let from; + let to; + if (connected.IN.id === srcNode.value[0].id) { + from = srcNode; + to = await this.createAdjacentNode(connected.OUT, graphOptions); + } else if (connected.OUT.id === srcNode.value[0].id) { + to = srcNode; + from = await this.createAdjacentNode(connected.IN, graphOptions); + } + if (from !== undefined && to !== undefined) { + this.createRelationship(from, to, connected, graphOptions); + } + } + const options = this.makeGraphOptions(graphOptions); + graph.setOption(options); + return graph; + } catch (e) { + console.error(e); + return graph; + } finally { + this.loading(false); + } + } + + async getConnectedNodes(label, id, token = 0) { + return this.graphApi({ + op: 'query', + type: 'vertice', + label, + id, + token, + pagesize: PAGESIZE, + }).then((res) => { + if (!res) { + return undefined; + } + const edges = []; + while (res.length) { + const items = res.shift(); + const idx = items.findIndex((x) => + x.IN !== undefined); + if (idx < 0) { + continue; + } + const found = items.splice(idx, 1).shift(); + let name = (items.find((x) => + x.id === found.IN.id) || {}).name; + found.IN.name = name; + + name = (items.find((x) => + x.id === found.OUT.id) || {}).name; + found.OUT.name = name; + edges.push(found); + } + return edges; + }); + } + + async graphApi(query) { + const url = new URL(SolutionManifest.KnowledgeGraph.Endpoint); + Object.keys(query) + .forEach((x) => { + url.searchParams.append(x, query[x]); + }); + const options = { + method: 'GET', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': SolutionManifest.KnowledgeGraph.ApiKey, + }, + }; + let tries = 4; + while (tries--) { + const response = fetch(url, options) + .then((res) => { + if (!res.ok) { + return new Error(); + } + return res.json(); + }); + if (!(response instanceof Error)) { + return response; + } + } + return undefined; + } + + async createNode(data, idx) { + let image; + let name = data.name; + if (data.label === NODE_VIDEO) { + const media = await this.mediaManager.lazyGetByUuid(data.id); + if (media !== undefined) { + image = await media.getThumbnail(); + name = media.basename; + } + } else { + const prefix = data.label + .trim() + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/-{2,}/g, '-') + .toLowerCase(); + const basename = data.name + .split('-')[0] + .trim() + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/-{2,}/g, '-') + .toLowerCase(); + const extension = (data.label === NODE_CELEBRITY) + ? '.png' + : '.svg'; + image = await this.imageStore + .getBlob(`./images/kg/${prefix}/${basename}${extension}`); + } + return this.parseNode({ + ...data, + name, + image, + }, idx); + } + + parseNode(nodeData, idx) { + const label = nodeData.label; + return { + id: idx, + name: label, + symbolSize: NODESIZE_BY_TYPE[label] || 10, + ...this.computeXYCoord(), + value: [ + nodeData, + ], + category: GRAPH_MAPPING.findIndex((x) => + x.name === label), + }; + } + + async createAdjacentNode(data, series) { + const nodes = series.nodes || series.data; + let idx = nodes.findIndex((x) => + x.value[0].id === data.id); + if (idx >= 0) { + return nodes[idx]; + } + + idx = nodes.length; + const node = await this.createNode(data, idx); + nodes.push(node); + return node; + } + + createRelationship(from, to, connection, series) { + const idx = series.links.findIndex((x) => + x.value[0].id === connection.id); + if (idx >= 0) { + return series.links[idx]; + } + + const link = { + source: from.id, + target: to.id, + value: [ + { + id: connection.id, + desc: `${AppUtils.shorten(from.value[0].name, 32)} > ${AppUtils.shorten(to.value[0].name, 32)}`, + }, + ], + }; + series.links.push(link); + return link; + } + + computeXYCoord() { + const start = 0 - 1200; + const end = 1200; + const random = end - start + 10; + + const x = Math.floor(Math.random() * random + start); + const y = Math.floor(Math.random() * random + start); + return { + x, + y, + }; + } + + makeGraphOptions(dataset) { + const title = {}; + const tooltip = { + show: true, + trigger: 'item', + enterable: true, + alwaysShowContent: false, + padding: 0, + extraCssText: 'border-radius: 0', + formatter: ((x) => { + if (x.dataType === 'edge') { + const container = $('
      ') + .addClass('my-2'); + const desc = $('

      ') + .addClass('text-truncate') + .append(x.value[0].desc || x.name); + container.append(desc); + return container.prop('outerHTML'); + } + if (x.dataType === 'node') { + const parsed = x.value[0]; + const container = $('

      '); + if (x.name === NODE_VIDEO) { + container.addClass('graph-tooltip'); + const img = $('') + .attr('src', parsed.image); + container.append(img); + + const desc = $('

      ') + .addClass('mx-2 my-2 text-truncate') + .append(parsed.name); + container.append(desc); + + return container.prop('outerHTML'); + } + container.addClass('row no-gutters'); + let avatar = $('') + .addClass('ml-2 my-2') + .addClass('far fa-question-circle') + .addClass('graph-avatar'); + if (parsed.image) { + avatar = $('') + .addClass('mx-2 my-auto') + .addClass('graph-avatar') + .attr('src', parsed.image); + } + container.append(avatar); + + const desc = $('

      ') + .addClass('my-auto mr-2 text-truncate') + .append(parsed.name); + container.append(desc); + return container.prop('outerHTML'); + } + return x.value; + }), + }; + const legend = [ + { + data: dataset.categories + .map((x) => + x.name), + }, + ]; + const animationDuration = 1500; + const animationEasingUpdate = 'quinticInOut'; + const series = [ + { + name: 'Graph database', + type: 'graph', + layout: 'none', + data: dataset.nodes, + links: dataset.links, + categories: dataset.categories, + roam: 'move', + label: { + show: true, + position: 'right', + formatter: ((x) => + AppUtils.shorten(x.value[0].name, 32)), + }, + lineStyle: { + color: 'source', + curveness: 0.3, + }, + emphasis: { + focus: 'adjacency', + lineStyle: { + width: 10, + }, + }, + }, + ]; + + return { + title, + tooltip, + legend, + animationDuration, + animationEasingUpdate, + series, + }; + } + + destroy() { + if (this.graph) { + this.graph.dispose(); + } + this.graph = undefined; + if (this.graphContainer) { + this.graphContainer.remove(); + } + this.graphContainer = undefined; + if (this.datasets) { + this.datasets.length = 0; + } + } + + resize() { + return this.graph.resize(); + } + + on(event, fn) { + return this.graphContainer.on(event, fn); + } + + off(event) { + return this.graphContainer.off(event); + } +} diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/scatterGraph.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/scatterGraph.js index 3847c88..3f0293d 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/scatterGraph.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/base/scatterGraph.js @@ -11,25 +11,13 @@ export default class ScatterGraph extends mxReadable(class {}) { constructor(datasets) { super(); this.$datasets = datasets.filter(x => x.data.length > 0); + this.$labels = this.$datasets.map((x) => + x.label); this.$graphContainer = $('

      ').addClass('scatter-graph') .attr('id', `scatter-${AppUtils.randomHexstring()}`); const options = this.makeGraphOptions(this.$datasets); - options.series = this.$datasets.map(x => ({ - name: x.label, - type: 'scatter', - large: true, - largeThreshold: 5000, - data: x.data.map(d => ([d.x, d.y])), - })); const graph = echarts.init(this.$graphContainer[0]); - const onDataPoint = this.onDataPointClickEvent.bind(this, this.$datasets); - const onLegendChanged = this.onLegendSelectChangedEvent.bind(this, this.$datasets); - const onInverseLegends = this.onInverseLegendsEvent.bind(this, this.$datasets); - const onRendered = this.onRenderedEvent.bind(this, graph); - graph.on('click', async (event) => onDataPoint(event)); - graph.on('legendselectchanged', async (event) => onLegendChanged(event)); - graph.on('legendinverseselect', async (event) => onInverseLegends(event)); - graph.on('rendered', async () => onRendered()); + this.registerGraphEvents(this.$datasets, graph); graph.setOption(options); this.$graph = graph; } @@ -69,6 +57,10 @@ export default class ScatterGraph extends mxReadable(class {}) { this.$datasets = val; } + get labels() { + return this.$labels; + } + get graphId() { return this.graphContainer.prop('id'); } @@ -78,9 +70,17 @@ export default class ScatterGraph extends mxReadable(class {}) { } makeGraphOptions(datasets) { + const legends = datasets + .sort((a, b) => + b.appearance - a.appearance) + .map((x) => ({ + name: x.label, + })); + const legend = { type: 'scroll', orient: 'horizontal', + data: legends, selected: datasets.reduce((a0, c0) => ({ ...a0, [c0.label]: false, @@ -134,13 +134,16 @@ export default class ScatterGraph extends mxReadable(class {}) { min: 0, interval: 1, }; + + let pencentage = Math.round((Math.min(datasets[0].duration, 60 * 1000) / datasets[0].duration) * 100); + pencentage = Math.max(pencentage, 10); const dataZoom = [ { type: 'slider', show: true, xAxisIndex: [0], start: 0, - end: 10, + end: pencentage, minValueSpan: 2 * 1000, labelFormatter: (x) => AppUtils.readableDuration(x, true), }, @@ -151,6 +154,13 @@ export default class ScatterGraph extends mxReadable(class {}) { formatter: ((x) => `${x.seriesName} (x${x.data[1]})
      at ${AppUtils.readableDuration(x.data[0], true)}`), }; + const series = datasets.map(x => ({ + name: x.label, + type: 'scatter', + large: true, + largeThreshold: 5000, + data: x.data.map(d => ([d.x, d.y])), + })); return { legend, grid, @@ -158,6 +168,7 @@ export default class ScatterGraph extends mxReadable(class {}) { yAxis, dataZoom, tooltip, + series, }; } @@ -237,4 +248,60 @@ export default class ScatterGraph extends mxReadable(class {}) { type: 'legendInverseSelect', }); } + + registerGraphEvents(datasets, graph) { + const onRendered = this.onRenderedEvent.bind(this, graph); + graph.off('rendered').on('rendered', async () => + onRendered()); + + const onDataPoint = this.onDataPointClickEvent.bind(this, datasets); + graph.off('click').on('click', async (event) => + onDataPoint(event)); + + const onLegendChanged = this.onLegendSelectChangedEvent.bind(this, datasets); + graph.off('legendselectchanged').on('legendselectchanged', async (event) => + onLegendChanged(event)); + + const onInverseLegends = this.onInverseLegendsEvent.bind(this, datasets); + graph.off('legendinverseselect').on('legendinverseselect', async (event) => + onInverseLegends(event)); + } + + updateLegends(legends) { + const datasets = this.datasets; + const graphOption = this.graph.getOption(); + + /* get a list of labels that are currently selected */ + const selected = []; + for (let legend of graphOption.legend) { + const labels = Object.keys(legend.selected); + while (labels.length) { + const label = labels.shift(); + if (legend.selected[label] === true) { + const found = datasets.find((x) => + x.label === label); + if (found) { + selected.push({ + name: found.label, + enabled: false, + basename: found.basename, + }); + } + legend.selected[label] = false; + } + } + /* update legends */ + this.graph.setOption({ + legend: { + data: legends, + selected: legend.selected, + }, + }); + } + + /* send events to update other components */ + if (selected.length > 0) { + this.graphContainer.trigger(ScatterGraph.Events.Legend.Changed, [selected]); + } + } } diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/knowledgeGraph/knowledgeGraphTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/knowledgeGraph/knowledgeGraphTab.js new file mode 100644 index 0000000..012621e --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/knowledgeGraph/knowledgeGraphTab.js @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SolutionManifest from '/solution-manifest.js'; +import Localization from '../../../../../../shared/localization.js'; +import KnowledgeGraph from '../base/knowledgeGraph.js'; +import BaseAnalysisTab from '../base/baseAnalysisTab.js'; + +const TITLE = 'Knowledge Graph'; +const DESC = 'Demonstrates a graph representation of the content and how it is related to other contents in your archive library'; +const NO_DATA = Localization.Messages.NoData; +const COL_TAB = 'col-11'; + +export default class KnowledgeGraphTab extends BaseAnalysisTab { + constructor(previewComponent, defaultTab = false) { + super(TITLE, previewComponent, defaultTab); + } + + static canSupport() { + return ( + SolutionManifest.KnowledgeGraph && + SolutionManifest.KnowledgeGraph.Endpoint && + SolutionManifest.KnowledgeGraph.ApiKey + ); + } + + async createContent() { + const container = $('
      ').addClass(`${COL_TAB} my-4 max-h56r`); + this.delayContentLoad(container); + return container; + } + + delayContentLoad(container) { + container.ready(async () => { + let section; + try { + this.loading(true); + + const descContainer = $('
      ') + .addClass('col-9 mx-auto'); + container.append(descContainer); + + const desc = $('

      ') + .addClass('lead-sm') + .append(DESC); + descContainer.append(desc); + + section = $('

      ') + .addClass('mt-4'); + container.append(section); + + const knowledgeGraph = await this.createKnowledgeGraph(section); + if (!knowledgeGraph) { + section.append(NO_DATA); + return; + } + } catch (e) { + console.error(e); + if (section) { + section.append(e.message); + } + } finally { + this.loading(false); + } + }); + } + + async createKnowledgeGraph(container) { + return new KnowledgeGraph(container, this.media, this); + } + + async graphApi(query) { + const url = new URL(SolutionManifest.KnowledgeGraph.Endpoint); + Object.keys(query) + .forEach((x) => { + if (query[x] !== undefined) { + url.searchParams.append(x, query[x]); + } + }); + + return fetch(url, { + method: 'GET', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': SolutionManifest.KnowledgeGraph.ApiKey, + }, + }).then((res) => { + if (!res.ok) { + return undefined; + } + return res.json(); + }); + } +} diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/baseRekognitionImageTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/baseRekognitionImageTab.js index 9e71f36..0089b51 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/baseRekognitionImageTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/baseRekognitionImageTab.js @@ -7,25 +7,38 @@ import BaseAnalysisTab from '../../base/baseAnalysisTab.js'; export default class BaseRekognitionImageTab extends BaseAnalysisTab { constructor(category, previewComponent, data, defaultTab = false) { - const tabName = category === AnalysisTypes.Rekognition.Celeb - ? Localization.Messages.CelebTab - : category === AnalysisTypes.Rekognition.Label - ? Localization.Messages.LabelTab - : category === AnalysisTypes.Rekognition.Face - ? Localization.Messages.FaceTab - : category === AnalysisTypes.Rekognition.FaceMatch - ? Localization.Messages.FaceMatchTab - : category === AnalysisTypes.Rekognition.Moderation - ? Localization.Messages.ModerationTab - : category === AnalysisTypes.Rekognition.Person - ? Localization.Messages.PersonTab - : category === AnalysisTypes.Rekognition.Text - ? Localization.Messages.TextTab - : category === AnalysisTypes.Rekognition.Segment - ? Localization.Messages.SegmentTab - : category === AnalysisTypes.Rekognition.CustomLabel - ? Localization.Messages.CustomLabelTab - : 'Unknown'; + let tabName; + switch (category) { + case AnalysisTypes.Rekognition.Celeb: + tabName = Localization.Messages.CelebTab; + break; + case AnalysisTypes.Rekognition.Label: + tabName = Localization.Messages.LabelTab; + break; + case AnalysisTypes.Rekognition.Face: + tabName = Localization.Messages.FaceTab; + break; + case AnalysisTypes.Rekognition.FaceMatch: + tabName = Localization.Messages.FaceMatchTab; + break; + case AnalysisTypes.Rekognition.Moderation: + tabName = Localization.Messages.ModerationTab; + break; + case AnalysisTypes.Rekognition.Person: + tabName = Localization.Messages.PersonTab; + break; + case AnalysisTypes.Rekognition.Text: + tabName = Localization.Messages.TextTab; + break; + case AnalysisTypes.Rekognition.Segment: + tabName = Localization.Messages.SegmentTab; + break; + case AnalysisTypes.Rekognition.CustomLabel: + tabName = Localization.Messages.CustomLabelTab; + break; + default: + tabName = 'Unknown'; + } super(tabName, previewComponent, defaultTab); this.$category = category; this.$data = data; diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/imageCaption.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/imageCaption.js new file mode 100644 index 0000000..83150a9 --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/image/imageCaption.js @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Localization from '../../../../../../../shared/localization.js'; +import BaseAnalysisTab from '../../base/baseAnalysisTab.js'; + +const TITLE = Localization.Messages.ImageCaptionTab; +const DESC = Localization.Messages.ImageCaptionDesc; +const NO_DATA = Localization.Messages.NoData; + +export default class ImageCaptionTab extends BaseAnalysisTab { + constructor(previewComponent, defaultTab = false) { + super(TITLE, previewComponent, defaultTab); + } + + async createContent() { + const container = $('
      ') + .addClass('col-9 my-4 max-h36r'); + + const caption = this.previewComponent.media.getImageAutoCaptioning() + || NO_DATA; + + const desc = $('

      ') + .addClass('lead-sm font-italic') + .append(DESC); + container.append(desc); + + const message = $('

      ') + .addClass('lead') + .append(caption); + container.append(message); + + return container; + } +} diff --git a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/video/baseRekognitionTab.js b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/video/baseRekognitionTab.js index b62425f..7eac8dd 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/video/baseRekognitionTab.js +++ b/source/webapp/src/lib/js/app/mainView/collection/base/components/analysis/rekognition/video/baseRekognitionTab.js @@ -5,8 +5,11 @@ import Localization from '../../../../../../../shared/localization.js'; import DatasetStore from '../../../../../../../shared/localCache/datasetStore.js'; import ScatterGraph from '../../base/scatterGraph.js'; import BaseRekognitionImageTab from '../image/baseRekognitionImageTab.js'; +import AppUtils from '../../../../../../../shared/appUtils.js'; const COL_TAB = 'col-11'; +const TOGGLE_ALL = Localization.Messages.ToggleAll; +const SEARCH_SPECIFIC_LABEL = Localization.Messages.SearchSpecificLabel; export default class BaseRekognitionTab extends BaseRekognitionImageTab { constructor(category, previewComponent, data, defaultTab = false) { @@ -252,17 +255,130 @@ export default class BaseRekognitionTab extends BaseRekognitionImageTab { } createToggleAll() { - const input = $('').attr('type', 'checkbox'); - input.off('click').on('click', async (event) => - this.scatterGraph.toggleAllLegends(input.prop('checked'))); - - const toggle = $('

      ').addClass('form-group px-0 mt-2 mb-2') - .append($('
      ').addClass('input-group') - .append($('
      ') + .addClass('table table-sm lead-xxs text-center no-border'); + itemContainer.append(table); + + const tbody = $(''); + table.append(tbody); + + const thumbnail = await this.makeItemThumbnail(media); + thumbnail.on('click', async (event) => { + event.preventDefault(); + let actual = this.mediaManager.findMediaByUuid(media.uuid); + if (actual === undefined) { + actual = await this.mediaManager.insertMedia({ + uuid: media.uuid, + }); + this.mediaManager.addMediaToCollection(actual); + } + if (actual) { + this.slide.trigger(CategorySlideEvents.Media.Selected, [actual]); + } + }); + tbody.append(thumbnail); + + const caption = this.makeItemCaption(media.basename); + tbody.append(caption); + } + + async makeItemThumbnail(media) { + const src = await media.getThumbnail(); + const tr = $(''); + const td = $(''); + const td = $('
      '); + tr.append(td); + + const container = $('
      ') + .addClass('image-container'); + td.append(container); + + const overlay = $('
      ') + .addClass('overlay'); + container.append(overlay); + + const icon = $('') + .addClass('far fa-image lead-xxl text-white'); + overlay.append(icon); + + const img = $('') + .addClass('w-100') + .css('background-color', '#aaa'); + container.append(img); + + if (src !== undefined) { + img.attr('src', src); + overlay.addClass('collapse'); + } + + const preview = $('
      ') + .addClass('preview'); + container.append(preview); + + const play = $('') + .addClass('far fa-play-circle center lead-xxl text-white'); + preview.append(play); + + return tr; + } + + makeItemCaption(title) { + const tr = $('
      ') + .addClass('lead-xxs'); + tr.append(td); + + const desc = $('') + .attr('data-toggle', 'tooltip') + .attr('data-placement', 'bottom') + .attr('title', title) + .html(AppUtils.shorten(title, 32)) + .tooltip({ + trigger: 'hover', + }); + td.append(desc); + + return tr; + } + + createInteractiveGraphSearch() { + const section = $('
      ') + .addClass('col-12'); + + const container = $('
      ') + .addClass('col-9 p-0 mx-auto my-4'); + section.append(container); + + const title = $('

      ') + .addClass('lead-m') + .append('Traverse knowledge graph (Amazon Neptune) interactively'); + container.append(title); + + const formContainer = $('') + .addClass('px-0 form-inline needs-validation') + .attr('novalidate', 'novalidate') + .attr('role', 'form'); + container.append(formContainer); + + const nodeSelection = this.createSelectOptions(formContainer); + formContainer.append(nodeSelection); + + const loadMoreBtn = this.createLoadMoreButton(formContainer); + formContainer.append(loadMoreBtn); + + const graphContainer = $('

      ') + .addClass('mt-4') + .addClass('knowledge-graph') + .attr('id', ID_GRAPH); + container.append(graphContainer); + + return section; + } + + createSelectOptions(form) { + const select = $('
      ').addClass('h-100 align-middle text-center b-300'); + const td = $('') + .addClass('w-96min align-middle text-center b-300'); if (name && name.length) { return td.append(name); } @@ -468,15 +469,23 @@ export default class SearchCategorySlideComponent extends BaseSlideComponent { } makeTableRowItemByMediaType(type) { - const bkgdColor = (type === MediaTypes.Video) - ? 'bg-success' - : (type === MediaTypes.Podcast) - ? 'bg-primary' - : (type === MediaTypes.Photo) - ? 'bg-secondary' - : (type === MediaTypes.Document) - ? 'bg-warning' - : undefined; + let bkgdColor; + switch (type) { + case MediaTypes.Video: + bkgdColor = 'bg-success'; + break; + case MediaTypes.Podcast: + bkgdColor = 'bg-primary'; + break; + case MediaTypes.Photo: + bkgdColor = 'bg-secondary'; + break; + case MediaTypes.Document: + bkgdColor = 'bg-warning'; + break; + default: + bkgdColor = undefined; + } return this.makeTableRowItem(type) .addClass(bkgdColor) .addClass('text-white'); @@ -493,14 +502,18 @@ export default class SearchCategorySlideComponent extends BaseSlideComponent { metadata.basename, ...Object.values(metadata.attributes || {}), ].filter((x) => { - for (let i = 0; i < terms.length; i++) { - if (x.toLowerCase().indexOf(terms[i]) >= 0) { + for (let term of terms) { + if (String(x).toLowerCase().indexOf(term) >= 0) { return true; } } return false; }); - return this.makeTableRowItem(matched); + + const container = $('

      ') + .addClass('p-overflow') + .append(matched); + return this.makeTableRowItem(container); } async createThumbnail(media) { diff --git a/source/webapp/src/lib/js/app/mainView/collection/video/videoPreviewSlideComponent.js b/source/webapp/src/lib/js/app/mainView/collection/video/videoPreviewSlideComponent.js index 0621438..4da6ebd 100644 --- a/source/webapp/src/lib/js/app/mainView/collection/video/videoPreviewSlideComponent.js +++ b/source/webapp/src/lib/js/app/mainView/collection/video/videoPreviewSlideComponent.js @@ -2,44 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import BasePreviewSlideComponent from '../base/basePreviewSlideComponent.js'; -import SnapshotComponent from '../base/components/snapshotComponent.js'; export default class VideoPreviewSlideComponent extends BasePreviewSlideComponent { - constructor() { - super(); - this.$snapshotComponent = undefined; - } - - get snapshotComponent() { - return this.$snapshotComponent; - } - - set snapshotComponent(val) { - this.$snapshotComponent = val; - } - - async setMedia(media, optionalSearchResults) { - await super.setMedia(media, optionalSearchResults); - if (!this.snapshotComponent) { - this.snapshotComponent = new SnapshotComponent(this.previewComponent); - } - } - - async hide() { - if (this.snapshotComponent) { - await this.snapshotComponent.hide(); - } - this.snapshotComponent = undefined; - return super.hide(); - } - - createPreview() { - const preview = $('

      ').addClass('col-12 p-0 m-0') - .append(this.previewComponent.container); - const controls = this.snapshotComponent.createComponent(); - return [ - preview, - controls, - ]; - } } diff --git a/source/webapp/src/lib/js/app/mainView/collectionTab.js b/source/webapp/src/lib/js/app/mainView/collectionTab.js index 65d1c92..a9535b7 100644 --- a/source/webapp/src/lib/js/app/mainView/collectionTab.js +++ b/source/webapp/src/lib/js/app/mainView/collectionTab.js @@ -8,6 +8,7 @@ import PhotoTab from './collection/photoTab.js'; import PodcastTab from './collection/podcastTab.js'; import DocumentTab from './collection/documentTab.js'; import SearchTab from './collection/searchTab.js'; +import RecommendTab from './collection/recommendTab.js'; import BaseTab from '../shared/baseTab.js'; export default class CollectionTab extends BaseTab { @@ -32,10 +33,15 @@ export default class CollectionTab extends BaseTab { new PodcastTab(false, plugins), new DocumentTab(false, plugins), new SearchTab(false, plugins), - ].reduce((acc, cur) => ({ - ...acc, - [cur.tabId]: cur, - }), {}); + ]; + if (RecommendTab.canSupport()) { + this.$tabControllers.push(new RecommendTab(false, plugins)); + } + this.$tabControllers = this.$tabControllers + .reduce((acc, cur) => ({ + ...acc, + [cur.tabId]: cur, + }), {}); this.$plugins = plugins; } diff --git a/source/webapp/src/lib/js/app/mainView/processingTab.js b/source/webapp/src/lib/js/app/mainView/processingTab.js index 0531f77..0ad6334 100644 --- a/source/webapp/src/lib/js/app/mainView/processingTab.js +++ b/source/webapp/src/lib/js/app/mainView/processingTab.js @@ -269,11 +269,17 @@ export default class ProcessingTab extends mxSpinner(BaseTab) { 'timestamp', 'status', ].forEach(name => helper.appendTableList(dl, media, name)); - const bkgdColor = media.overallStatus === SolutionManifest.Statuses.Error - ? 'bg-danger' - : media.overallStatus === SolutionManifest.Statuses.Processing - ? 'bg-primary' - : 'bg-success'; + let bkgdColor; + switch (media.overallStatus) { + case SolutionManifest.Statuses.Error: + bkgdColor = 'bg-danger'; + break; + case SolutionManifest.Statuses.Processing: + bkgdColor = 'bg-primary'; + break; + default: + bkgdColor = 'bg-success'; + } const status = $('').addClass('lead-xxs mx-auto my-auto text-white') .html(media.overallStatus); diff --git a/source/webapp/src/lib/js/app/mainView/statsTab.js b/source/webapp/src/lib/js/app/mainView/statsTab.js index 4aa75b8..f4d6d3a 100644 --- a/source/webapp/src/lib/js/app/mainView/statsTab.js +++ b/source/webapp/src/lib/js/app/mainView/statsTab.js @@ -5,7 +5,6 @@ import AnalysisTypes from '../shared/analysis/analysisTypes.js'; import Localization from '../shared/localization.js'; import ApiHelper from '../shared/apiHelper.js'; import AppUtils from '../shared/appUtils.js'; -import MediaFactory from '../shared/media/mediaFactory.js'; import PieGraph from './stats/pieGraph.js'; import mxSpinner from '../mixins/mxSpinner.js'; import BaseTab from '../shared/baseTab.js'; @@ -403,17 +402,26 @@ export default class StatsTab extends mxSpinner(BaseTab) { name: c0.name, value: c0.count, }), []); - const title = (type === AGGS_TYPE_KNOWNFACES) - ? Localization.Messages.TopKnownFaces - : (type === AGGS_TYPE_LABELS) - ? Localization.Messages.TopLabels - : (type === AGGS_TYPE_MODERATIONS) - ? Localization.Messages.TopModerations - : (type === AGGS_TYPE_KEYPHRASES) - ? Localization.Messages.TopKeyphrases - : (type === AGGS_TYPE_ENTITIES) - ? Localization.Messages.TopEntities - : undefined; + let title; + switch (type) { + case AGGS_TYPE_KNOWNFACES: + title = Localization.Messages.TopKnownFaces; + break; + case AGGS_TYPE_LABELS: + title = Localization.Messages.TopLabels; + break; + case AGGS_TYPE_MODERATIONS: + title = Localization.Messages.TopModerations; + break; + case AGGS_TYPE_KEYPHRASES: + title = Localization.Messages.TopKeyphrases; + break; + case AGGS_TYPE_ENTITIES: + title = Localization.Messages.TopEntities; + break; + default: + title = undefined; + } const graph = new PieGraph(title, datasets, { formatter: '{b}: {c} documents', }); diff --git a/source/webapp/src/lib/js/app/mainView/upload/attributeSlideComonent.js b/source/webapp/src/lib/js/app/mainView/upload/attributeSlideComonent.js index f2ad529..560f8e0 100644 --- a/source/webapp/src/lib/js/app/mainView/upload/attributeSlideComonent.js +++ b/source/webapp/src/lib/js/app/mainView/upload/attributeSlideComonent.js @@ -120,6 +120,18 @@ export default class AttributeSlideComponent extends BaseUploadSlideComponent { }); next.off('click').on('click', async (event) => { + /* prevent slide switch if it fails validation */ + let validity = true; + const forms = this.slide.find('form.needs-validation'); + forms.each((idx, form) => { + validity = form[0].checkValidity(); + return validity; + }); + if (validity === false) { + event.preventDefault(); + event.stopPropagation(); + return; + } await this.saveData(); this.slide.trigger(AttributeSlideComponent.Controls.Next); }); diff --git a/source/webapp/src/lib/js/app/mainView/upload/fileItem.js b/source/webapp/src/lib/js/app/mainView/upload/fileItem.js index 7ad9282..2def7cb 100644 --- a/source/webapp/src/lib/js/app/mainView/upload/fileItem.js +++ b/source/webapp/src/lib/js/app/mainView/upload/fileItem.js @@ -77,7 +77,6 @@ class BaseFile extends mxReadable(class {}) { boundary: 'window', }); btnRemove.off('click').on('click', (event) => { - // btnRemove.tooltip('hide'); li.trigger(BaseFile.Events.File.Remove, [this]); }); @@ -110,27 +109,39 @@ class BaseFile extends mxReadable(class {}) { subtype, ] = (this.mime || '').split('/'); - return (type === 'video') - ? 'far fa-file-video' - : (type === 'audio') - ? 'far fa-file-audio' - : (type === 'image') - ? 'far fa-file-image' - : (subtype === 'pdf') - ? 'far fa-file-pdf' - : (subtype === 'msword' || subtype === 'vnd.openxmlformats-officedocument.wordprocessingml.document') - ? 'far fa-file-word' - : (subtype === 'vnd.ms-powerpoint' || subtype === 'vnd.openxmlformats-officedocument.presentationml.presentation') - ? 'far fa-file-powerpoint' - : (subtype === 'vnd.ms-excel' || subtype === 'vnd.openxmlformats-officedocument.spreadsheetml.sheet') - ? 'far fa-file-excel' - : (subtype === 'zip' || subtype === 'x-7z-compressed' || subtype === 'vnd.rar' || subtype === 'gzip') - ? 'far fa-file-archive' - : (subtype === 'csv') - ? 'fas fa-file-csv' - : (subtype === 'json' || subtype === 'xml') - ? 'far fa-file-code' - : 'far fa-file-alt'; + switch (type) { + case 'video': + return 'far fa-file-video'; + case 'audio': + return 'far fa-file-audio'; + case 'image': + return 'far fa-file-image'; + default: //do nothing + } + + if (subtype === 'pdf') { + return 'far fa-file-pdf'; + } + if (subtype === 'msword' || subtype === 'vnd.openxmlformats-officedocument.wordprocessingml.document') { + return 'far fa-file-word'; + } + if (subtype === 'vnd.ms-powerpoint' || subtype === 'vnd.openxmlformats-officedocument.presentationml.presentation') { + return 'far fa-file-powerpoint'; + } + if (subtype === 'vnd.ms-excel' || subtype === 'vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + return 'far fa-file-excel'; + } + if (subtype === 'zip' || subtype === 'x-7z-compressed' || subtype === 'vnd.rar' || subtype === 'gzip') { + return 'far fa-file-archive'; + } + if (subtype === 'csv') { + return 'fas fa-file-csv'; + } + if (subtype === 'json' || subtype === 'xml') { + return 'far fa-file-code'; + } + + return 'far fa-file-alt'; } } diff --git a/source/webapp/src/lib/js/app/mainView/upload/finalizeSlideComponent.js b/source/webapp/src/lib/js/app/mainView/upload/finalizeSlideComponent.js index c52b466..1298104 100644 --- a/source/webapp/src/lib/js/app/mainView/upload/finalizeSlideComponent.js +++ b/source/webapp/src/lib/js/app/mainView/upload/finalizeSlideComponent.js @@ -687,13 +687,6 @@ export default class FinalizeSlideComponent extends BaseUploadSlideComponent { this.slide.on(eStarted, async (event, fileId) => { this.updateBadge(Localization.Statuses.Processing, fileId); const anchor = this.slide.find(`a[href="#tab-${fileId}"]`); - /* TODO: auto-scroll to focus the processing item - const tablist = this.slide.children().first(); - const top = anchor.position().top; - const viewBottom = tablist.innerHeight(); - const visible = top < viewBottom; - console.log(`tab.visible: ${top},${viewBottom},${visible}`); - */ anchor.tab('show'); this.slide.off(eStarted); }); diff --git a/source/webapp/src/lib/js/app/mainView/upload/mxDropzone.js b/source/webapp/src/lib/js/app/mainView/upload/mxDropzone.js index 7aefd29..c653a81 100644 --- a/source/webapp/src/lib/js/app/mainView/upload/mxDropzone.js +++ b/source/webapp/src/lib/js/app/mainView/upload/mxDropzone.js @@ -70,8 +70,8 @@ export default Base => class extends Base { async useGetAsEntry(data) { const promiseFiles = []; const promiseDirs = []; - for (let i = 0; i < data.items.length; i++) { - const entry = data.items[i].webkitGetAsEntry(); + for (let item of data.items) { + const entry = item.webkitGetAsEntry(); if (entry.isFile) { promiseFiles.push(this.readFileEntry(entry)); } else { @@ -139,8 +139,8 @@ export default Base => class extends Base { async useFileReader(data) { const promises = []; - for (let i = 0; i < data.files.length; i++) { - promises.push(this.readFile(data.files[i])); + for (let file of data.files) { + promises.push(this.readFile(file)); } const files = await Promise.all(promises); return files; diff --git a/source/webapp/src/lib/js/app/mainView/uploadTab.js b/source/webapp/src/lib/js/app/mainView/uploadTab.js index 116b0ef..fa101d5 100644 --- a/source/webapp/src/lib/js/app/mainView/uploadTab.js +++ b/source/webapp/src/lib/js/app/mainView/uploadTab.js @@ -92,16 +92,6 @@ export default class UploadTab extends mxAlert(BaseTab) { .attr('id', this.ids.carousel.container) .append(inner); - /* - carousel.on('slide.bs.carousel', async (event) => { - const id = $(event.relatedTarget).prop('id'); - if (id === this.analysisComponent.slideId) { - this.analysisComponent.reloadAnalysisSettings(); - } - return true; - }); - */ - return $('
      ').addClass('col-9 col-sm-9 col-md-9 mx-auto mt-4') .append(carousel); } diff --git a/source/webapp/src/lib/js/app/mainView/userManagementTab.js b/source/webapp/src/lib/js/app/mainView/userManagementTab.js new file mode 100644 index 0000000..4ce1d45 --- /dev/null +++ b/source/webapp/src/lib/js/app/mainView/userManagementTab.js @@ -0,0 +1,439 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import SolutionManifest from '/solution-manifest.js'; +import Localization from '../shared/localization.js'; +import ApiHelper from '../shared/apiHelper.js'; +import AppUtils from '../shared/appUtils.js'; +import { + AWSConsoleCongito, +} from '../shared/awsConsole.js'; +import mxSpinner from '../mixins/mxSpinner.js'; +import mxAlert from '../mixins/mxAlert.js'; +import BaseTab from '../shared/baseTab.js'; + +const TITLE = Localization.Messages.UserManagementTab; +const DESCRIPTION = Localization.Messages.UserManagementDesc; +const USERNAME = Localization.Messages.Username; +const TABLE_HEADER = [ + USERNAME, + Localization.Messages.Email, + Localization.Messages.Group, + Localization.Messages.Status, + Localization.Messages.LastModified, + Localization.Messages.RemoveUser, +]; +const PERMISSION_VIEWER = Localization.Messages.PermissionViewer; +const PERMISSION_CREATOR = Localization.Messages.PermissionCreator; +const PERMISSION_ADMIN = Localization.Messages.PermissionAdmin; +const TOOLTIP_REMOVE_USER = Localization.Tooltips.RemoveUserFromCognito; +const TOOLTIP_REFRESH_USER_TABLE = Localization.Tooltips.RefreshUserTable; +const LIST_OF_CURRENT_USERS = Localization.Messages.CurrentUsers; +const CREATE_NEW_USERS = Localization.Messages.CreateNewUsers; +const CREATE_NEW_USERS_DESC = Localization.Messages.CreateNewUsersDesc; +const BTN_ADD_EMAIL = Localization.Buttons.AddEmail; +const BTN_CONFIRM_AND_ADD = Localization.Buttons.ConfirmAndAddUsers; +const BTN_REFRESH = Localization.Buttons.Refresh; +const OOPS = Localization.Alerts.Oops; +const ERR_INVALID_EMAIL_ADDRESS = Localization.Alerts.InvalidEmailAddress; +const ERR_INVALID_USERNAME = Localization.Alerts.UsernameConformance; +const ERR_NO_USER_TO_ADD = Localization.Alerts.NoNewUsers; +const ERR_FAIL_ADDIND_USERS = Localization.Alerts.FailAddingUsers; + +export default class UserManagementTab extends mxAlert(mxSpinner(BaseTab)) { + constructor(defaultTab = false) { + super(TITLE, { + selected: defaultTab, + }); + this.$uid = AppUtils.randomHexstring(); + } + + get uid() { + return this.$uid; + } + + async show() { + if (!this.initialized) { + const content = await this.createContent(); + this.tabContent.append(content); + } + return super.show(); + } + + async createContent() { + const container = $('
      ') + .addClass('row no-gutters'); + + const sectionDesc = this.createDescriptionSection(); + container.append(sectionDesc); + + const sectionUserTable = await this.createUserTableSection(); + container.append(sectionUserTable); + + const sectionAddUsers = this.createAddUsersSection(); + container.append(sectionAddUsers); + + const loading = this.createLoading(); + container.append(loading); + + return container; + } + + createDescriptionSection() { + const container = $('
      ') + .addClass('col-9 p-0 mx-auto mt-4'); + + const url = AWSConsoleCongito.getUserPoolLink(SolutionManifest.Cognito.UserPoolId); + let desc = DESCRIPTION.replace('{{CONSOLE_USERPOOL}}', url); + desc = $('

      ').addClass('lead') + .html(desc); + container.append(desc); + + return container; + } + + async createUserTableSection() { + const usersPromise = ApiHelper.getUsers(); + + const container = $('

      ') + .addClass('col-9 p-0 mx-auto mt-4 vh-30'); + + const sectionTitle = $('
      ') + .addClass('col-12 p-0 mb-4 d-flex justify-content-between'); + container.append(sectionTitle); + + const heading = $('').addClass('lead') + .append(LIST_OF_CURRENT_USERS); + sectionTitle.append(heading); + + const refresh = $('