AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >-
  HyperFrames distributed rendering — Step Functions standard workflow with
  one Lambda function handling Plan, RenderChunk (fan-out via Map state),
  and Assemble. One S3 bucket, alarms for runaway concurrency, Lambda
  errors, and Step Functions execution failures.

  Built from the handler ZIP at packages/aws-lambda/dist/handler.zip.

  See:
    - packages/aws-lambda/README.md (handler architecture)
    - examples/aws-lambda/README.md (this directory's deploy guide)

Parameters:
  ProjectName:
    Type: String
    Default: hyperframes
    Description: Name prefix applied to all created resources.

  LambdaMemoryMb:
    Type: Number
    Default: 10240
    AllowedValues: [2048, 3072, 4096, 5120, 6144, 7168, 8192, 9216, 10240]
    Description: >-
      Lambda memory in MB. Render workloads are CPU-bound; bumping memory
      proportionally bumps the CPU share Lambda gives the function. 10 GB
      (the max) is recommended for 1080p renders.

  LambdaTimeoutSec:
    Type: Number
    Default: 900
    MinValue: 60
    MaxValue: 900
    Description: >-
      Per-invocation Lambda timeout. Render chunks at the default
      chunkSize=240 frames complete in seconds; 15 minutes is the Lambda
      hard ceiling and the default here to absorb cold-start variance.

  ReservedConcurrency:
    Type: Number
    Default: -1
    Description: >-
      Lambda reserved concurrency cap. Set to a positive integer to bound
      simultaneous chunk renders (e.g. 50 to limit cost). -1 means
      unreserved (account default).

  ChromeSource:
    Type: String
    Default: sparticuz
    AllowedValues: [sparticuz, chrome-headless-shell]
    Description: >-
      Which Chrome runtime the bundled ZIP was built with. Must match the
      `--source=` flag passed to `build-zip.ts`. The handler reads this
      via the HYPERFRAMES_LAMBDA_CHROME_SOURCE env var at boot.

  ChunkInvocationAlarmThreshold:
    Type: Number
    Default: 1000
    Description: >-
      CloudWatch alarm threshold for total RenderChunk invocations per
      hour. The runaway-Map state pathology would fan out far more
      chunks than expected; an alarm at 10× the typical workload
      protects against billing surprises.

Conditions:
  HasReservedConcurrency: !Not [!Equals [!Ref ReservedConcurrency, -1]]

Globals:
  Function:
    Runtime: nodejs22.x
    MemorySize: !Ref LambdaMemoryMb
    Timeout: !Ref LambdaTimeoutSec
    # x86_64 is required for @sparticuz/chromium — its prebuilt
    # Chromium ships x86_64-only. Adopters who switch to a custom
    # ARM-built chrome-headless-shell can change this to `arm64`, but
    # the default ZIP build will fail to launch on Graviton.
    Architectures: [x86_64]
    # Lambda function-level X-Ray tracing. The state machine already
    # has Tracing.Enabled: true; without this, X-Ray traces would
    # terminate at the Step Functions → Lambda boundary instead of
    # following into per-function spans.
    Tracing: Active
    # Cost-allocation tags. Setting these at the Globals level applies
    # to every AWS::Serverless::Function in the template — there's
    # only one today, but the contract is portable to multi-function
    # variants. Bucket + state-machine carry the same tags resource-
    # locally because Globals only covers functions.
    Tags:
      Project: !Ref ProjectName
      HyperFramesComponent: lambda-renderer
    Environment:
      Variables:
        NODE_OPTIONS: "--enable-source-maps"
        HYPERFRAMES_LAMBDA_CHROME_SOURCE: !Ref ChromeSource

Resources:
  # ── S3 bucket for plan tarballs, chunk outputs, and final renders ───────
  RenderBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      # BucketName omitted — CloudFormation generates a unique name like
      # "<stack-name>-renderbucket-<random>". S3 bucket names are capped at
      # 63 chars; a static !Sub expression including ProjectName +
      # AWS::AccountId + AWS::Region trips that limit when ProjectName
      # carries a timestamp (e.g. the smoke script's per-run stack name).
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        # `Suspended` keeps storage costs flat — versions are not
        # retained on overwrites. Tradeoff: if an adopter writes their
        # final rendered mp4 to this bucket and a re-render overwrites
        # the same key, the prior version is gone. Adopters who treat
        # the final mp4 as user-keepable should set this to `Enabled`
        # (intermediates under `renders/` still expire via the
        # lifecycle rule below regardless).
        Status: Suspended
      LifecycleConfiguration:
        Rules:
          - Id: ExpireIntermediates
            Status: Enabled
            Prefix: renders/
            # Plan tarballs and chunk outputs are intermediate artifacts.
            # Users keep the final mp4 (different key prefix); the rest
            # can age out after a week to keep storage costs flat.
            ExpirationInDays: 7
      Tags:
        - Key: Project
          Value: !Ref ProjectName
        - Key: HyperFramesComponent
          Value: lambda-renderer

  # ── Single Lambda function handling all three roles ──────────────────────
  RenderFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub "${ProjectName}-render"
      Description: >-
        HyperFrames distributed render handler. Dispatches on event.Action.
      Handler: handler.handler
      # Local path is resolved by `sam build` + `sam deploy --resolve-s3`
      # (or `--s3-bucket`); the resulting CodeUri rewrites to s3://.
      CodeUri: ../../packages/aws-lambda/dist/handler.zip
      PackageType: Zip
      ReservedConcurrentExecutions: !If
        - HasReservedConcurrency
        - !Ref ReservedConcurrency
        - !Ref AWS::NoValue
      EphemeralStorage:
        Size: 10240
      Environment:
        Variables:
          # Lambda's Node 22 runtime sets these by default; explicit for
          # clarity + so users can override during local SAM invoke.
          TMPDIR: /tmp
      Policies:
        - S3CrudPolicy:
            BucketName: !Ref RenderBucket
      # CloudWatch Logs perms are covered by SAM's default
      # AWSLambdaBasicExecutionRole — explicit `CloudWatchLogsFullAccess`
      # would be overscope (`logs:*` on `*`, including DeleteLogGroup +
      # CreateExportTask). Reference templates shouldn't leak overbroad
      # IAM into adopters' accounts.

  # ── CloudWatch log group for the state machine ──────────────────────────
  # SAM doesn't auto-create one when `LoggingConfiguration` is set, so we
  # define it explicitly — that way the IAM grant on the state-machine
  # role has a destination to write to.
  RenderStateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/states/${ProjectName}-render"
      RetentionInDays: 30

  # ── Step Functions state machine: Plan → Map(N) RenderChunk → Assemble ──
  RenderStateMachine:
    Type: AWS::Serverless::StateMachine
    Properties:
      Name: !Sub "${ProjectName}-render"
      Type: STANDARD
      Tracing:
        Enabled: true
      Logging:
        # Without this, the `WriteCloudwatchLogs` grant on the state
        # machine role would be unused — operators would see zero
        # execution history outside the Step Functions console.
        # `Level: ERROR` keeps log volume low; bump to `ALL` for
        # heavy debugging.
        Level: ERROR
        IncludeExecutionData: false
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt RenderStateMachineLogGroup.Arn
      Definition:
        Comment: >-
          HyperFrames distributed render orchestration: Plan → Map(N)
          RenderChunk → Assemble.
        # Defensive 1-hour ceiling on the whole choreography. The
        # individual states already have retries + per-task timeouts;
        # this catches pathological runaways (Plan-retry storm,
        # stuck-state-machine bugs) at the top before per-task budgets
        # compound into a multi-hour execution. The longest legitimate
        # render observed in PR 880's eval was ~3 minutes.
        TimeoutSeconds: 3600
        StartAt: Plan
        States:
          Plan:
            Type: Task
            Resource: arn:aws:states:::lambda:invoke
            Parameters:
              FunctionName: !GetAtt RenderFunction.Arn
              Payload:
                Action: plan
                ProjectS3Uri.$: "$.ProjectS3Uri"
                PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix"
                Config.$: "$.Config"
            ResultSelector:
              PlanS3Uri.$: "$.Payload.PlanS3Uri"
              PlanHash.$: "$.Payload.PlanHash"
              ChunkCount.$: "$.Payload.ChunkCount"
              Format.$: "$.Payload.Format"
              HasAudio.$: "$.Payload.HasAudio"
              AudioS3Uri.$: "$.Payload.AudioS3Uri"
            ResultPath: $.Plan
            Retry:
              - ErrorEquals:
                  # These error names are thrown by the producer's plan
                  # stage when retrying can never help — version skew,
                  # determinism violations, GPU misconfiguration, font
                  # fetch failures, plan-size cap, unsupported format.
                  # Fail fast rather than burning ~120s of retry budget.
                  - FFMPEG_VERSION_MISMATCH
                  - PLAN_HASH_MISMATCH
                  - BROWSER_GPU_NOT_SOFTWARE
                  - FONT_FETCH_FAILED
                  - PLAN_TOO_LARGE
                  - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
                MaxAttempts: 0
              - ErrorEquals: [States.ALL]
                IntervalSeconds: 2
                MaxAttempts: 4
                BackoffRate: 2
                MaxDelaySeconds: 60
            Next: BuildChunkList

          BuildChunkList:
            # Translate ChunkCount into an array `[0, 1, ..., N-1]` so the
            # Map state below has something to iterate. Range is the
            # idiomatic Step Functions intrinsic for this; no Lambda call
            # required.
            Type: Pass
            Parameters:
              ChunkIndexes.$: "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)"
            ResultPath: $.Iterator
            Next: AssertChunkCount

          AssertChunkCount:
            # Defensive gate: `resolveChunkPlan` guarantees ChunkCount ≥ 1,
            # but if some future regression let a zero-chunk plan through,
            # `RenderChunks` (Map state) would iterate zero times and
            # `Assemble` would receive an empty `ChunkS3Uris` array — silently
            # producing an empty output. Fail fast instead.
            Type: Choice
            Choices:
              - Variable: $.Plan.ChunkCount
                NumericGreaterThan: 0
                Next: RenderChunks
            Default: PlanProducedZeroChunks

          PlanProducedZeroChunks:
            Type: Fail
            Error: PLAN_TOO_LARGE
            Cause: Plan returned ChunkCount=0 — non-retryable producer-side invariant violation.

          RenderChunks:
            Type: Map
            ItemsPath: $.Iterator.ChunkIndexes
            ItemSelector:
              ChunkIndex.$: "$$.Map.Item.Value"
              PlanS3Uri.$: "$.Plan.PlanS3Uri"
              PlanHash.$: "$.Plan.PlanHash"
              ChunkOutputS3Prefix.$: "$.PlanOutputS3Prefix"
              Format.$: "$.Plan.Format"
            # Map fan-out cap derives from the Plan's chunkCount so
            # caller-supplied `Config.maxParallelChunks` (which
            # `plan()` honours when sizing the chunk list) is the
            # single source of truth. A hardcoded value here would
            # silently throttle adopters who scale up the chunk count
            # in their event payload.
            MaxConcurrencyPath: $.Plan.ChunkCount
            ResultPath: $.Chunks
            ItemProcessor:
              ProcessorConfig:
                Mode: INLINE
              StartAt: RenderChunk
              States:
                RenderChunk:
                  Type: Task
                  Resource: arn:aws:states:::lambda:invoke
                  Parameters:
                    FunctionName: !GetAtt RenderFunction.Arn
                    Payload:
                      Action: renderChunk
                      ChunkIndex.$: "$.ChunkIndex"
                      PlanS3Uri.$: "$.PlanS3Uri"
                      PlanHash.$: "$.PlanHash"
                      ChunkOutputS3Prefix.$: "$.ChunkOutputS3Prefix"
                      Format.$: "$.Format"
                  ResultSelector:
                    ChunkS3Uri.$: "$.Payload.ChunkS3Uri"
                    ChunkIndex.$: "$.Payload.ChunkIndex"
                    Sha256.$: "$.Payload.Sha256"
                  Retry:
                    - ErrorEquals:
                        - FFMPEG_VERSION_MISMATCH
                        - PLAN_HASH_MISMATCH
                        - BROWSER_GPU_NOT_SOFTWARE
                      MaxAttempts: 0
                    - ErrorEquals: [States.ALL]
                      IntervalSeconds: 2
                      MaxAttempts: 4
                      BackoffRate: 2
                      MaxDelaySeconds: 60
                  End: true
            Next: Assemble

          Assemble:
            Type: Task
            Resource: arn:aws:states:::lambda:invoke
            Parameters:
              FunctionName: !GetAtt RenderFunction.Arn
              Payload:
                Action: assemble
                PlanS3Uri.$: "$.Plan.PlanS3Uri"
                ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri"
                AudioS3Uri.$: "$.Plan.AudioS3Uri"
                OutputS3Uri.$: "$.OutputS3Uri"
                Format.$: "$.Plan.Format"
            ResultSelector:
              OutputS3Uri.$: "$.Payload.OutputS3Uri"
              FramesEncoded.$: "$.Payload.FramesEncoded"
              FileSize.$: "$.Payload.FileSize"
            ResultPath: $.Output
            Retry:
              - ErrorEquals:
                  # Same non-retryable error names as the Plan state's
                  # gate — these surface at assemble time too because
                  # ffmpeg-driven concat picks up version drift and we
                  # re-verify plan hash + format at assemble. Skip the
                  # retry storm; fail fast.
                  - FFMPEG_VERSION_MISMATCH
                  - PLAN_HASH_MISMATCH
                  - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
                MaxAttempts: 0
              - ErrorEquals: [States.ALL]
                IntervalSeconds: 2
                MaxAttempts: 4
                BackoffRate: 2
                MaxDelaySeconds: 60
            End: true
      Role: !GetAtt RenderStateMachineRole.Arn

  RenderStateMachineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: states.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeRenderFunction
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt RenderFunction.Arn
        - PolicyName: WriteCloudwatchLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogDelivery
                  - logs:GetLogDelivery
                  - logs:UpdateLogDelivery
                  - logs:DeleteLogDelivery
                  - logs:ListLogDeliveries
                  - logs:PutResourcePolicy
                  - logs:DescribeResourcePolicies
                  - logs:DescribeLogGroups
                Resource: "*"
        - PolicyName: XRayTracing
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - xray:PutTraceSegments
                  - xray:PutTelemetryRecords
                Resource: "*"

  # ── CloudWatch alarm: runaway chunk invocations ─────────────────────────
  RenderChunkInvocationAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${ProjectName}-runaway-chunk-invocations"
      AlarmDescription: >-
        Fires if RenderChunk Lambda invocations exceed the configured
        threshold in a 1-hour window. The Map state's MaxConcurrency cap
        protects against simultaneous fan-out, but a runaway state
        machine that triggers many sequential renders would still rack
        up cost; this alarm catches that pattern.
      Namespace: AWS/Lambda
      MetricName: Invocations
      Dimensions:
        - Name: FunctionName
          Value: !Ref RenderFunction
      Statistic: Sum
      Period: 3600
      EvaluationPeriods: 1
      Threshold: !Ref ChunkInvocationAlarmThreshold
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching

  # ── CloudWatch alarm: Lambda function errors ────────────────────────────
  # Fires on any non-zero error rate. The invocation alarm above catches
  # *too many calls*; this catches *calls that failed*. Without it,
  # silent per-chunk failures (a non-retryable error inside the
  # producer) would only surface by reading Step Functions execution
  # history.
  RenderFunctionErrorsAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${ProjectName}-render-function-errors"
      AlarmDescription: >-
        Fires if the render Lambda reports any errors in a 5-minute
        window. Set EvaluationPeriods=1 so a single failure pages.
      Namespace: AWS/Lambda
      MetricName: Errors
      Dimensions:
        - Name: FunctionName
          Value: !Ref RenderFunction
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching

  # ── CloudWatch alarm: Step Functions execution failures ─────────────────
  # Fires when a state-machine execution reaches a terminal failure
  # state (typed non-retryable, retry-exhausted, or top-level timeout).
  # Complementary to the Lambda Errors alarm: SFN failures include
  # Choice-state Fail branches (PlanProducedZeroChunks) that bypass
  # Lambda entirely, plus retry-exhaustion of transient errors that
  # individual Lambda invocations counted as successful "retries".
  RenderStateMachineFailedAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${ProjectName}-render-state-machine-failed"
      AlarmDescription: >-
        Fires when the render state machine reports a failed
        execution. Catches retry-exhaustion + typed non-retryable +
        TimeoutSeconds cases that the Lambda Errors metric misses.
      Namespace: AWS/States
      MetricName: ExecutionsFailed
      Dimensions:
        - Name: StateMachineArn
          Value: !Ref RenderStateMachine
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching

Outputs:
  RenderBucketName:
    Description: S3 bucket for plan tarballs, chunk outputs, and final renders.
    Value: !Ref RenderBucket
    Export:
      Name: !Sub "${AWS::StackName}-RenderBucket"

  RenderFunctionArn:
    Description: ARN of the Lambda function. Pass to `aws lambda invoke` for local testing.
    Value: !GetAtt RenderFunction.Arn
    Export:
      Name: !Sub "${AWS::StackName}-RenderFunctionArn"

  RenderStateMachineArn:
    Description: ARN of the Step Functions state machine. Pass to `aws stepfunctions start-execution`.
    Value: !Ref RenderStateMachine
    Export:
      Name: !Sub "${AWS::StackName}-RenderStateMachineArn"
