├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── architecture.png ├── backend ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── backend.ts ├── cdk.json ├── data │ ├── characters.json │ └── scenes.json ├── jest.config.js ├── lib │ ├── backend.ts │ ├── ddb-tables.ts │ └── frontend.ts ├── package.json ├── scripts │ └── insert-data-into-ddb.js ├── src │ ├── create-story │ │ ├── index.ts │ │ └── package.json │ ├── generate-audio-for-story │ │ ├── index.ts │ │ └── package.json │ └── generate-images-for-story │ │ ├── index.ts │ │ ├── package.json │ │ └── with-replicate.ts └── tsconfig.json ├── config.json ├── frontend ├── .env.example ├── .gitignore ├── Dockerfile ├── README.md ├── lib │ └── aws.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── api │ │ ├── health.ts │ │ └── settings.ts │ ├── index.tsx │ └── story.tsx ├── postcss.config.js ├── public │ ├── favicon.ico │ └── opengraph.png ├── styles │ └── globals.css ├── tailwind.config.js └── tsconfig.json ├── package.json └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

📖 AI Generated Stories

4 |

Example open source event-driven application that generates a new bed time story for your children every night using Lambda, EventBridge, DynamoDB, App Runner, ChatGPT and DALL-E.

5 | 6 |

Read the blog post →

7 | 8 | 9 |
10 | 11 | header 12 | 13 |

Features: New story every day, Audio using Amazon Polly, story using ChatGPT and images by DALL-E, all generated from an event-driven architecture.

14 | 15 |
16 | 17 |
18 | 19 | # Core Features 20 | 21 | - ⏱️ [EventBridge Scheduler](https://aws.amazon.com/blogs/compute/introducing-amazon-eventbridge-scheduler/) to generate new story every bedtime 22 | - 📦 Event architecture using [Amazon EventBridge](https://aws.amazon.com/eventbridge/) to fan out processing of images, audio and emails. 23 | - 🤖 New unqiue story every night using [ChatGPT and DALL-E](https://openai.com/blog/chatgpt) for images 24 | - 🧑‍💻 Deploy with [AWS CDK](https://aws.amazon.com/cdk/) 25 | 26 | # How it works 27 | 28 | ![Architecture diagram](./architecture.png) 29 | 30 | 1. Every day at a configured time an [EventBridge Schedule](https://aws.amazon.com/blogs/compute/introducing-amazon-eventbridge-scheduler/) is trigger which triggers a Lambda function. 31 | 32 | 2. The `create-story` lambda function takes characters and scenes from the [Amazon DynamoDB](https://aws.amazon.com/dynamodb/) tables and uses [ChatGPT](https://openai.com/blog/chatgpt) (OpenAI API) to create the story. The story is stored with a 2 day TTL in DynamoDB. 33 | 34 | 3. An [Amazon EventBridge Pipe](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes.html) is configured to [listen to all New items created inside the table using streams](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html#:~:text=A%20DynamoDB%20stream%20is%20an,data%20items%20in%20the%20table.) and triggers an [Amazon EventBridge event](https://aws.amazon.com/eventbridge/) (StoryCreated). 35 | 36 | 4. EventBridge routes the `StoryCreated` event to three targets: 37 | - SNS for email 38 | - SNS for email: SNS is used in this example to notify the user that a new story has been created. 39 | - [AWS Lambda](https://aws.amazon.com/lambda/) function for Audio generation 40 | - Lambda for Audio: Amazon Polly is created to create audio for the story that has been generated. The audio file is stores into S3 with a signed URL (for 2 days). 41 | - [AWS Lambda](https://aws.amazon.com/lambda/) function for image generation. 42 | - Lambda for image generation: This function takes the story and scene and creates an image for the story using DALL-E (OpenAI API). This image is stored inside S3 with a signed URL (2 days). 43 | 44 | 5. The frontend application is running on [AWS App Runner](https://aws.amazon.com/apprunner/) and is hosting a NextJS SRR application. When the user goes to the URL in the Email (through SNS topic), the story is loaded and displayed. 45 | 46 | # Design choices 47 | 48 | This application was designed as a proof of concept, and if you want to take extract patterns there might be some design considerations to understand before you do. 49 | 50 | This application is designed for single use, every day it will email a single person a URL to a new story, if you wanted to scale this out to many users you would have to change the architecture to support that. 51 | 52 | ## Hosting the frontend application 53 | The frontend application is built with NextJS and hosted in App Runner, the App Runner container has the permission to talk to the DynamoDB table to get stories. The stories have a TTL of 2 days and will not be available after that duration (removed from the table). 54 | 55 | ## EventBridge Pub/Sub 56 | Once the story is created, EventBridge will raise an event to many consumers (audio processing, image creation, and SNS), this can lead to race conditions. There could be a chance that the audio or image is not ready when the user views the story on the screen. The application will check for audio and images, and fallback to render just the story if this information is not available yet (due to async processing). For a simple use case this might be fine but if you need to wait you may want to look at patterns like the aggregator or step function workflows that may help with this processing of state. 57 | 58 | ## Three DynamoDB tables vs one 59 | The application is fairly simple, and three tables seemed to be a pattern that worked well here. The characters and scenes table is not updated very often and the stories hold the generated stories. If you wanted to support many users you will need to consider your access patterns and table design. 60 | 61 | 62 | # Deploying 63 | 64 | ## Prerequisites 65 | 66 | - [OpenAI API Key](https://platform.openai.com/overview) 67 | - Node v16 or greater 68 | - [AWS CDK](https://aws.amazon.com/cdk/) 69 | 70 | ## Create your OpenAI API Key and add to Secret Manager. 71 | 72 | First you will need an OpenAI API key, if you don’t have an account you will need to set one. You can go here to get started: https://platform.openai.com/overview 73 | 74 | Once you have your key, you will need to add it to Secret Manager the secret name needs to be `open-api-key`. 75 | 76 | ## Deploy into your AWS account 77 | 78 | 1. Clone the repository 79 | 80 | 2. Change the config.json file (add your email address and cron job) 81 | 82 | 3. Run `npm run install:all` 83 | 84 | 4. Run `npm run deploy` 85 | - This will deploy three stacks (Tables, Frontend, Backend) into your AWS account using CDK. 86 | - This can take a few minutes to deploy (containers need to start) 87 | 88 | 5. Populate your DynamoDB databases (Scenes and Characters) 89 | - You can find the files in `/backend/data/`, change these to what you want. 90 | - Run `npm run populate-db` to populate these tables. 91 | 92 | 93 | 5. Once done, your application is ready. 94 | 95 | # Generating a story 96 | 97 | EventBridge scheduler will trigger your Lambda function to generate a story at the configured time set in your config.json file (default 7:15pm). 98 | 99 | You can also manually trigger the function (-aiStoriesBackend-scheduledlambdafunction) 100 | 101 | ## Security 102 | 103 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 104 | 105 | ## License 106 | 107 | This library is licensed under the MIT-0 License. See the LICENSE file. 108 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ai-stories/89493ad6d02bf49ec358ee9094f19d274ca869ba/architecture.png -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | !/scripts/*.js 6 | 7 | # CDK asset staging directory 8 | .cdk.staging 9 | cdk.out 10 | -------------------------------------------------------------------------------- /backend/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend application for AI Stories 2 | 3 | See [README](./README.md) file for more information on how it works and design choices. -------------------------------------------------------------------------------- /backend/bin/backend.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import * as config from '../../config.json'; 5 | import { DDBTables } from '../lib/ddb-tables'; 6 | import { BackendStack } from '../lib/backend'; 7 | import { AppRunnerApp } from '../lib/frontend'; 8 | 9 | const app = new cdk.App(); 10 | 11 | // Create all the DDB tables required for the application 12 | const aiStoriesTables = new DDBTables(app, `${config.stage}-aiStoriesTables`); 13 | 14 | // Create front end application that is hosted in app runner. 15 | const frontEndApplication = new AppRunnerApp(app, `${config.stage}-webStack`, { 16 | generatedStoriesTableName: aiStoriesTables.generatedStories.value, 17 | }); 18 | 19 | // Create EDA application that generates stories 20 | new BackendStack(app, `${config.stage}-aiStoriesBackend`, { 21 | charactersTable: aiStoriesTables.charactersTable.value, 22 | scenesTable: aiStoriesTables.scenesTable.value, 23 | generatedStoriesTable: aiStoriesTables.generatedStories.value, 24 | generatedStoriesStreamArn: aiStoriesTables.generatedStoriesStreamArn.value, 25 | frontEndURL: frontEndApplication.applicationURL.value 26 | }); 27 | -------------------------------------------------------------------------------- /backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/backend.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 38 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 39 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 40 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 41 | "@aws-cdk/aws-route53-patters:useCertificate": true, 42 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/data/characters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": "1", "name": "Eric" }, 3 | { "id": "2", "name": "Dave" } 4 | ] 5 | -------------------------------------------------------------------------------- /backend/data/scenes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": "1", "description": "Live on a twitch stream and something goes wrong" }, 3 | { "id": "2", "description": "In a serverless workshop but the internet cuts out" }, 4 | { "id": "3", "description": "Doing a live talk and the building gets struck by lightning" }, 5 | { "id": "4", "description": "Pair programming but they get distracted by something scary" } 6 | ] 7 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /backend/lib/backend.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Duration, StackProps } from 'aws-cdk-lib'; 3 | import { Table } from 'aws-cdk-lib/aws-dynamodb'; 4 | import { EventBus, EventField, Rule, RuleTargetInput } from 'aws-cdk-lib/aws-events'; 5 | import { LambdaFunction, SnsTopic } from 'aws-cdk-lib/aws-events-targets'; 6 | import { Effect, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; 7 | import { Runtime, StartingPosition } from 'aws-cdk-lib/aws-lambda'; 8 | import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; 9 | import { CfnPipe } from 'aws-cdk-lib/aws-pipes'; 10 | import { Bucket } from 'aws-cdk-lib/aws-s3'; 11 | import { CfnSchedule } from 'aws-cdk-lib/aws-scheduler'; 12 | import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; 13 | import { Topic } from 'aws-cdk-lib/aws-sns'; 14 | import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'; 15 | import { Construct } from 'constructs'; 16 | import * as path from 'node:path'; 17 | import * as config from '../../config.json'; 18 | 19 | interface BackendStackProps extends StackProps { 20 | readonly charactersTable: string; 21 | readonly scenesTable: string; 22 | readonly generatedStoriesTable: string; 23 | readonly generatedStoriesStreamArn: string; 24 | readonly frontEndURL: string; 25 | } 26 | 27 | export class BackendStack extends cdk.Stack { 28 | constructor(scope: Construct, id: string, props: BackendStackProps) { 29 | super(scope, id, props); 30 | 31 | const openAIAPIKEY = Secret.fromSecretNameV2(this, 'open-api-key', 'open-api-key'); 32 | 33 | const storiesEventBus = new EventBus(this, 'ai-stories', { eventBusName: `${id}-ai-stories` }); 34 | 35 | // Stories Bucket 36 | const audioBucket = new Bucket(this, 'StoriesAudioBucket', { 37 | removalPolicy: cdk.RemovalPolicy.DESTROY, 38 | }); 39 | 40 | // Table to store all characters 41 | const charactersTable = Table.fromTableName(this, 'charactersTable', props.charactersTable); 42 | const scenesTable = Table.fromTableName(this, 'scenesTable', props.scenesTable); 43 | const generatedStories = Table.fromTableName(this, 'generatedStories', props.generatedStoriesTable); 44 | 45 | 46 | // Create SNS topic for emails 47 | const emailTopic = new Topic(this, 'email-topic', { 48 | topicName: 'AiStory-story-generated', 49 | }); 50 | 51 | // add email subscription to SNS topic 52 | emailTopic.addSubscription(new EmailSubscription(config.email)); 53 | 54 | // need to create role and policy for scheduler to invoke the lambda function 55 | const schedulerRole = new Role(this, 'scheduler-role', { 56 | assumedBy: new ServicePrincipal('scheduler.amazonaws.com'), 57 | }); 58 | 59 | // Function that creates stories using OpenAI API. 60 | const createStoryFunc: NodejsFunction = new NodejsFunction(this, 'scheduled-lambda-function', { 61 | memorySize: 1024, 62 | timeout: Duration.minutes(5), 63 | runtime: Runtime.NODEJS_18_X, 64 | handler: 'handler', 65 | entry: path.join(__dirname, '../src', 'create-story/index.ts'), 66 | environment: { 67 | openAIARN: openAIAPIKEY.secretArn, 68 | CHARACTERS_TABLE: charactersTable.tableName, 69 | SCENES_TABLE: scenesTable.tableName, 70 | STORIES_TABLE: generatedStories.tableName, 71 | }, 72 | }); 73 | 74 | // Function to create images using stable fusion 75 | const createImagesForStoryFunc: NodejsFunction = new NodejsFunction(this, 'generate-images-for-story', { 76 | memorySize: 1024, 77 | timeout: Duration.minutes(5), 78 | runtime: Runtime.NODEJS_18_X, 79 | handler: 'handler', 80 | entry: path.join(__dirname, '../src', 'generate-images-for-story/index.ts'), 81 | environment: { 82 | openAIARN: openAIAPIKEY.secretArn, 83 | STORIES_TABLE: generatedStories.tableName, 84 | BUCKET_NAME: audioBucket.bucketName, 85 | }, 86 | }); 87 | 88 | // Function to create audio using Amazon Polly 89 | const createAudioForStoryFunc: NodejsFunction = new NodejsFunction(this, 'generate-audio-for-story', { 90 | memorySize: 1024, 91 | timeout: Duration.minutes(5), 92 | runtime: Runtime.NODEJS_18_X, 93 | handler: 'handler', 94 | entry: path.join(__dirname, '../src', 'generate-audio-for-story/index.ts'), 95 | environment: { 96 | STORIES_TABLE: generatedStories.tableName, 97 | BUCKET_NAME: audioBucket.bucketName, 98 | }, 99 | }); 100 | 101 | createAudioForStoryFunc.addToRolePolicy( 102 | new PolicyStatement({ 103 | effect: Effect.ALLOW, 104 | resources: ['*'], 105 | actions: ['polly:SynthesizeSpeech'], 106 | }) 107 | ); 108 | 109 | audioBucket.grantReadWrite(createAudioForStoryFunc); 110 | audioBucket.grantReadWrite(createImagesForStoryFunc); 111 | 112 | // Create consumer to listen to StoryGenerated Event 113 | const storyGeneratedRule = new Rule(this, 'StoryGeneratedRule', { 114 | description: 'Listen to StoryGenerated events', 115 | eventPattern: { 116 | source: ['ai.stories'], 117 | detailType: ['StoryGenerated'], 118 | }, 119 | eventBus: storiesEventBus, 120 | }); 121 | 122 | // Rules for StoryGeneratedRule, generate image, audio and fire SNS topic. 123 | storyGeneratedRule.addTarget(new LambdaFunction(createImagesForStoryFunc)); 124 | storyGeneratedRule.addTarget(new LambdaFunction(createAudioForStoryFunc)); 125 | storyGeneratedRule.addTarget( 126 | new SnsTopic(emailTopic, { 127 | message: RuleTargetInput.fromText(`New Story created: ${props.frontEndURL}/story?id=${EventField.fromPath('$.detail.id')}`), 128 | }) 129 | ); 130 | 131 | // Create schedule that will run every day at bed time, this triggers the 132 | new CfnSchedule(this, 'my-schedule', { 133 | flexibleTimeWindow: { 134 | mode: 'OFF', 135 | }, 136 | // run every day at 19:15pm 137 | scheduleExpression: config.bedtimeCron, 138 | description: 'Fires to trigger a new story', 139 | target: { 140 | arn: createStoryFunc.functionArn, 141 | roleArn: schedulerRole.roleArn, 142 | }, 143 | }); 144 | 145 | // Permissions 146 | createStoryFunc.grantInvoke(schedulerRole); 147 | 148 | // permissions for stories table 149 | generatedStories.grantWriteData(createStoryFunc); 150 | generatedStories.grantWriteData(createImagesForStoryFunc); 151 | generatedStories.grantWriteData(createAudioForStoryFunc); 152 | 153 | scenesTable.grantReadData(createStoryFunc); 154 | charactersTable.grantReadData(createStoryFunc); 155 | 156 | // Permissions to read secrets 157 | openAIAPIKEY.grantRead(createStoryFunc); 158 | openAIAPIKEY.grantRead(createImagesForStoryFunc); 159 | 160 | // permissions to create signed urls for content 161 | audioBucket.grantWrite(createAudioForStoryFunc); 162 | audioBucket.grantWrite(createImagesForStoryFunc); 163 | 164 | // create IAM role with permission to read from sourceStream and write to targetStream 165 | const pipeRole = new Role(this, 'FilterPipeRole', { 166 | assumedBy: new ServicePrincipal('pipes.amazonaws.com'), 167 | }); 168 | 169 | pipeRole.addToPolicy( 170 | new PolicyStatement({ 171 | effect: Effect.ALLOW, 172 | resources: [props.generatedStoriesStreamArn], 173 | actions: ['dynamodb:DescribeStream', 'dynamodb:GetRecords', 'dynamodb:GetShardIterator', 'dynamodb:ListStreams'], 174 | }) 175 | ); 176 | 177 | // Give EventBridge Pipes permission to raise events on the bus 178 | storiesEventBus.grantPutEventsTo(pipeRole); 179 | 180 | new CfnPipe(this, 'NewStoryPipe', { 181 | roleArn: pipeRole.roleArn, 182 | //@ts-ignore 183 | source: props.generatedStoriesStreamArn, 184 | // source: generatedStories.tableStreamArn, 185 | target: storiesEventBus.eventBusArn, 186 | 187 | // Map the DynamoDB event into an event our domain can understand. 188 | targetParameters: { 189 | eventBridgeEventBusParameters: { 190 | detailType: 'StoryGenerated', 191 | source: 'ai.stories', 192 | }, 193 | inputTemplate: `{ 194 | "id": "<$.dynamodb.NewImage.id.S>", 195 | "title": "<$.dynamodb.NewImage.title.S>", 196 | "scene": "<$.dynamodb.NewImage.scene.S>", 197 | "description": "<$.dynamodb.NewImage.description.S>" 198 | }`, 199 | }, 200 | sourceParameters: { 201 | dynamoDbStreamParameters: { 202 | startingPosition: StartingPosition.LATEST, 203 | batchSize: 1, 204 | }, 205 | // Filter DynamoDB events, only interested in new events 206 | filterCriteria: { 207 | filters: [ 208 | { 209 | pattern: '{"eventName" : ["INSERT"] }', 210 | }, 211 | ], 212 | }, 213 | }, 214 | }); 215 | 216 | } 217 | } 218 | 219 | -------------------------------------------------------------------------------- /backend/lib/ddb-tables.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnOutput, RemovalPolicy } from 'aws-cdk-lib'; 3 | import { AttributeType, BillingMode, StreamViewType, Table } from 'aws-cdk-lib/aws-dynamodb'; 4 | import { Construct } from 'constructs'; 5 | 6 | export class DDBTables extends cdk.Stack { 7 | public readonly charactersTable: CfnOutput; 8 | public readonly scenesTable: CfnOutput; 9 | public readonly generatedStories: CfnOutput; 10 | public readonly generatedStoriesStreamArn: CfnOutput; 11 | 12 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 13 | super(scope, id, props); 14 | 15 | // Table to store all characters 16 | const charactersTable = new Table(this, `${id}-AiStory-Characters`, { 17 | partitionKey: { name: 'id', type: AttributeType.STRING }, 18 | billingMode: BillingMode.PAY_PER_REQUEST, 19 | removalPolicy: RemovalPolicy.DESTROY, 20 | tableName: `${id}-AiStory-Characters`, 21 | }); 22 | 23 | // Table to store all scenes 24 | const scenesTable = new Table(this, `${id}-AiStory-Scenes`, { 25 | partitionKey: { name: 'id', type: AttributeType.STRING }, 26 | billingMode: BillingMode.PAY_PER_REQUEST, 27 | removalPolicy: RemovalPolicy.DESTROY, 28 | tableName: `${id}-AiStory-Scenes`, 29 | }); 30 | 31 | // Table to store generated stories 32 | const generatedStories = new Table(this, `${id}-AiStory-Stories`, { 33 | partitionKey: { name: 'id', type: AttributeType.STRING }, 34 | billingMode: BillingMode.PAY_PER_REQUEST, 35 | removalPolicy: RemovalPolicy.DESTROY, 36 | tableName: `${id}-AiStory-Stories`, 37 | timeToLiveAttribute: 'ttl', 38 | stream: StreamViewType.NEW_IMAGE, 39 | }); 40 | 41 | this.charactersTable = new CfnOutput(this, 'CharactersTable', { 42 | value: charactersTable.tableName 43 | }); 44 | this.scenesTable = new CfnOutput(this, 'ScenesTable', { 45 | value: scenesTable.tableName 46 | }); 47 | this.generatedStories = new CfnOutput(this, 'GeneratedStories', { 48 | value: generatedStories.tableName 49 | }); 50 | 51 | this.generatedStoriesStreamArn = new CfnOutput(this, 'GeneratedStoriesStreamArn', { 52 | value: generatedStories.tableStreamArn || '' 53 | }); 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /backend/lib/frontend.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import { Construct } from 'constructs'; 4 | import { DockerImageAsset, Platform } from 'aws-cdk-lib/aws-ecr-assets'; 5 | import * as apprunner from 'aws-cdk-lib/aws-apprunner'; 6 | import * as iam from 'aws-cdk-lib/aws-iam'; 7 | import { CfnOutput, StackProps } from 'aws-cdk-lib'; 8 | import { CfnAccessKey, User } from 'aws-cdk-lib/aws-iam'; 9 | import { Table } from 'aws-cdk-lib/aws-dynamodb'; 10 | 11 | interface FrontEndStackProps extends StackProps { 12 | readonly generatedStoriesTableName: string; 13 | } 14 | 15 | export class AppRunnerApp extends cdk.Stack { 16 | public readonly applicationURL: CfnOutput; 17 | 18 | constructor(scope: Construct, id: string, props: FrontEndStackProps) { 19 | super(scope, id, props); 20 | 21 | const generatedStoriesTable = Table.fromTableName(this, 'generatedStories', props.generatedStoriesTableName); 22 | 23 | // Build the frontend application 24 | const imageAsset = new DockerImageAsset(this, 'ImageAssets', { 25 | directory: path.join(__dirname, '../../frontend'), 26 | // Fix for M1 macbooks, make sure we specify the build platform to use locally and in AWS 27 | platform: Platform.LINUX_AMD64, 28 | }); 29 | 30 | // Create an IAM role to fetch container 31 | // https://docs.aws.amazon.com/ja_jp/apprunner/latest/dg/security_iam_service-with-iam.html 32 | const accessRole = new iam.Role(this, `${id}-iam-build-role`, { 33 | assumedBy: new iam.ServicePrincipal('build.apprunner.amazonaws.com'), 34 | }); 35 | accessRole.addToPolicy( 36 | new iam.PolicyStatement({ 37 | effect: iam.Effect.ALLOW, 38 | actions: ['ecr:BatchCheckLayerAvailability', 'ecr:BatchGetImage', 'ecr:DescribeImages', 'ecr:GetAuthorizationToken', 'ecr:GetDownloadUrlForLayer'], 39 | resources: ['*'], 40 | }) 41 | ); 42 | 43 | // Give the instance permission to read the DDB table holding the AI stories 44 | const instanceRole = new iam.Role(this, `${id}-instance-role`, { 45 | assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'), 46 | }); 47 | instanceRole.addToPolicy( 48 | new iam.PolicyStatement({ 49 | effect: iam.Effect.ALLOW, 50 | actions: [ 51 | 'dynamodb:BatchGet*', 52 | 'dynamodb:DescribeStream', 53 | 'dynamodb:DescribeTable', 54 | 'dynamodb:Get*', 55 | 'dynamodb:Query', 56 | 'dynamodb:Scan', 57 | 'dynamodb:BatchWrite*', 58 | 'dynamodb:CreateTable', 59 | 'dynamodb:Delete*', 60 | 'dynamodb:Update*', 61 | 'dynamodb:PutItem', 62 | ], 63 | resources: [generatedStoriesTable.tableArn], 64 | }) 65 | ); 66 | 67 | // Create App Runner 68 | const app = new apprunner.CfnService(this, 'Service', { 69 | serviceName: `${id}-ai-stories`, 70 | 71 | sourceConfiguration: { 72 | authenticationConfiguration: { 73 | accessRoleArn: accessRole.roleArn, 74 | }, 75 | autoDeploymentsEnabled: true, 76 | 77 | imageRepository: { 78 | imageIdentifier: imageAsset.imageUri, 79 | imageRepositoryType: 'ECR', 80 | imageConfiguration: { 81 | port: '3000', 82 | runtimeEnvironmentVariables: [ 83 | { 84 | name: 'TABLE_NAME', 85 | value: props.generatedStoriesTableName, 86 | }, 87 | { 88 | name: 'REGION', 89 | value: this.region, 90 | }, 91 | ], 92 | }, 93 | }, 94 | }, 95 | instanceConfiguration: { 96 | cpu: '1024', 97 | memory: '2048', 98 | instanceRoleArn: instanceRole.roleArn, 99 | }, 100 | healthCheckConfiguration: { 101 | path: '/api/health', 102 | }, 103 | }); 104 | 105 | this.applicationURL = new cdk.CfnOutput(this, `${id}-app-runner-uri`, { 106 | value: `https://${app.attrServiceUrl}`, 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.1.0", 4 | "bin": { 5 | "backend": "bin/backend.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk", 12 | "populate-db": "node scripts/insert-data-into-ddb.js" 13 | }, 14 | "devDependencies": { 15 | "@types/jest": "^29.4.0", 16 | "@types/node": "18.11.18", 17 | "@types/uuid": "^9.0.0", 18 | "aws-cdk": "^2.66.1", 19 | "jest": "^29.4.1", 20 | "ts-jest": "^29.0.5", 21 | "ts-node": "^10.9.1", 22 | "typescript": "~4.9.5" 23 | }, 24 | "dependencies": { 25 | "@aws-cdk/aws-amplify": "^1.195.0", 26 | "@aws-cdk/aws-amplify-alpha": "^2.29.1-alpha.0", 27 | "@aws-cdk/aws-apprunner": "^1.195.0", 28 | "@aws-sdk/client-dynamodb": "^3.267.0", 29 | "@aws-sdk/util-dynamodb": "^3.267.0", 30 | "aws-cdk-lib": "2.80.0", 31 | "constructs": "^10.0.0", 32 | "openai": "^4.29.2", 33 | "source-map-support": "^0.5.21", 34 | "uuid": "^9.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/scripts/insert-data-into-ddb.js: -------------------------------------------------------------------------------- 1 | const { DynamoDBClient, BatchWriteItemCommand } = require('@aws-sdk/client-dynamodb'); 2 | const { marshall } = require('@aws-sdk/util-dynamodb'); 3 | const characters = require('../data/characters.json'); 4 | const scenes = require('../data/scenes.json'); 5 | const config = require('../../config.json'); 6 | 7 | const client = new DynamoDBClient({}); 8 | 9 | const insertIntoDDB = async () => { 10 | // insert characters 11 | await client.send( 12 | new BatchWriteItemCommand({ 13 | RequestItems: { 14 | [`${config.stage}-aiStoriesTables-AiStory-Characters`]: characters.map((character) => ({ 15 | PutRequest: { 16 | Item: marshall(character), 17 | }, 18 | })), 19 | }, 20 | }) 21 | ); 22 | 23 | // insert scenes 24 | await client.send( 25 | new BatchWriteItemCommand({ 26 | RequestItems: { 27 | [`${config.stage}-aiStoriesTables-AiStory-Scenes`]: scenes.map((scene) => ({ 28 | PutRequest: { 29 | Item: marshall(scene), 30 | }, 31 | })), 32 | }, 33 | }) 34 | ); 35 | }; 36 | 37 | insertIntoDDB(); 38 | -------------------------------------------------------------------------------- /backend/src/create-story/index.ts: -------------------------------------------------------------------------------- 1 | import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; 2 | import { DynamoDBClient, ScanCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; 3 | 4 | import { Configuration, OpenAIApi } from 'openai'; 5 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; 6 | import { v4 } from 'uuid'; 7 | 8 | const secretsClient = new SecretsManagerClient({}); 9 | const dynamoClient = new DynamoDBClient({}); 10 | 11 | export async function handler() { 12 | const secret = await secretsClient.send( 13 | new GetSecretValueCommand({ 14 | SecretId: 'open-api-key', 15 | }) 16 | ); 17 | 18 | if (!secret.SecretString) throw new Error('Failed to get secret'); 19 | 20 | const apiKey = JSON.parse(secret.SecretString)['open-api-key']; 21 | 22 | const openai = new OpenAIApi( 23 | new Configuration({ 24 | apiKey, 25 | }) 26 | ); 27 | 28 | // Get all characters in the DB, just scan as it's a small DB, if this grows might want to change access pattern 29 | const { Items: rawCharacters = [] } = await dynamoClient.send( 30 | new ScanCommand({ 31 | TableName: process.env.CHARACTERS_TABLE, 32 | }) 33 | ); 34 | 35 | //@ts-ignore 36 | const characters = rawCharacters.map((character) => unmarshall(character)); 37 | 38 | // Get all scenes in the DB, just scan as it's a small DB, if this grows might want to change access pattern 39 | const { Items: scenes = [] } = await dynamoClient.send( 40 | new ScanCommand({ 41 | TableName: process.env.SCENES_TABLE, 42 | }) 43 | ); 44 | 45 | // Select random scene 46 | const selectedScene = unmarshall(scenes[Math.floor(Math.random() * scenes.length)]); 47 | 48 | const prompt = ` 49 | Write a title and a rhyming story on ${characters.length} main characters called ${characters?.map((character: any) => character.name).join(' and ')}. 50 | The story needs to be set within the scene ${selectedScene.description} and be at least 200 words long 51 | `; 52 | 53 | const result = await openai.createCompletion({ 54 | model: 'text-davinci-003', 55 | prompt: prompt, 56 | max_tokens: 1000, 57 | temperature: 0.7, 58 | }); 59 | 60 | const twoDaysFromNow = new Date(new Date().getTime() + 2 * 24 * 60 * 60 * 1000); 61 | const storyTTL = Math.floor(twoDaysFromNow.getTime() / 1000); 62 | 63 | const story = result.data.choices[0].text || ''; 64 | const storyParts = story.trim().split('\n'); 65 | 66 | // Get title from the story 67 | const title = storyParts.shift(); 68 | const description = storyParts.join('\n'); 69 | 70 | // Insert new story into DDB 71 | await dynamoClient.send( 72 | new PutItemCommand({ 73 | TableName: process.env.STORIES_TABLE, 74 | Item: marshall({ 75 | id: v4(), 76 | title, 77 | characters, 78 | description, 79 | ttl: storyTTL, 80 | scene: selectedScene.description, 81 | createdAt: new Date().toISOString() 82 | }), 83 | }) 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /backend/src/create-story/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-story", 3 | "version": "1.0.0", 4 | "description": "Creates stories", 5 | "main": "create-story.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "devDependencies": { 10 | "@aws-sdk/client-dynamodb": "^3.538.0", 11 | "@aws-sdk/client-secrets-manager": "^3.535.0", 12 | "@aws-sdk/util-dynamodb": "^3.261.0", 13 | "@types/aws-lambda": "^8.10.110" 14 | }, 15 | "dependencies": { 16 | "openai": "^4.29.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/generate-audio-for-story/index.ts: -------------------------------------------------------------------------------- 1 | import { EventBridgeEvent } from 'aws-lambda'; 2 | import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb'; 3 | import { OutputFormat, PollyClient, SynthesizeSpeechCommand, VoiceId } from '@aws-sdk/client-polly'; 4 | 5 | import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; 6 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 7 | 8 | interface StoryGenerated { 9 | id: string; 10 | title: string; 11 | description: string; 12 | scene: string; 13 | } 14 | 15 | const dynamoClient = new DynamoDBClient({}); 16 | const s3Client = new S3Client({}); 17 | const pollyClient = new PollyClient({}); 18 | 19 | export async function handler(event: EventBridgeEvent<'StoryGenerated', StoryGenerated>) { 20 | // create the audio 21 | const data = await pollyClient.send( 22 | new SynthesizeSpeechCommand({ 23 | OutputFormat: OutputFormat.MP3, 24 | Text: `This is a story called ${event.detail.title}. ${event.detail.description}`, 25 | VoiceId: VoiceId.Justin, 26 | SampleRate: '24000', 27 | }) 28 | ); 29 | 30 | const response = new Response(data.AudioStream as ReadableStream); 31 | const arrayBuffer = await response.arrayBuffer(); 32 | 33 | // upload cloud formation data into public s3 bucket 34 | await s3Client.send( 35 | new PutObjectCommand({ 36 | Bucket: process.env.BUCKET_NAME, 37 | Key: `stories/${event.detail.id}/audio.mp3`, 38 | Body: arrayBuffer as any, 39 | ContentEncoding: 'base64', 40 | Metadata: { 41 | 'Content-Type': 'audio/mpeg', 42 | }, 43 | }) 44 | ); 45 | 46 | const url = await getSignedUrl( 47 | s3Client, 48 | new GetObjectCommand({ 49 | Bucket: process.env.BUCKET_NAME, 50 | Key: `stories/${event.detail.id}/audio.mp3`, 51 | }), 52 | { 53 | // two days 54 | expiresIn: 172800, 55 | } 56 | ); 57 | 58 | // Write audio url to DDB 59 | await dynamoClient.send( 60 | new UpdateItemCommand({ 61 | TableName: process.env.STORIES_TABLE, 62 | Key: { 63 | id: { S: event.detail.id }, 64 | }, 65 | UpdateExpression: 'SET audioURL = :audioURL', 66 | ExpressionAttributeValues: { 67 | ':audioURL': { S: url }, 68 | }, 69 | }) 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /backend/src/generate-audio-for-story/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate audio for story", 3 | "version": "1.0.0", 4 | "description": "Creates audio for stories using Amazon Polly", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "devDependencies": { 10 | "@aws-sdk/client-dynamodb": "^3.267.0", 11 | "@aws-sdk/client-polly": "^3.278.0", 12 | "@aws-sdk/util-dynamodb": "^3.261.0", 13 | "@types/aws-lambda": "^8.10.110", 14 | "@aws-sdk/client-s3": "^3.278.0", 15 | "@aws-sdk/s3-request-presigner": "^3.278.0" 16 | }, 17 | "dependencies": { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/generate-images-for-story/index.ts: -------------------------------------------------------------------------------- 1 | import { EventBridgeEvent } from 'aws-lambda'; 2 | import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; 3 | import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb'; 4 | import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; 5 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 6 | 7 | interface StoryGenerated { 8 | id: string; 9 | title: string; 10 | description: string; 11 | scene: string; 12 | } 13 | 14 | const secretsClient = new SecretsManagerClient({}); 15 | const s3Client = new S3Client({}); 16 | const dynamoClient = new DynamoDBClient({}); 17 | 18 | export async function handler(event: EventBridgeEvent<'StoryGenerated', StoryGenerated>) { 19 | 20 | const secret = await secretsClient.send( 21 | new GetSecretValueCommand({ 22 | SecretId: 'open-api-key', 23 | }) 24 | ); 25 | 26 | if (!secret.SecretString) throw new Error('Failed to get replicate secret'); 27 | 28 | const apiKey = JSON.parse(secret.SecretString)['open-api-key']; 29 | 30 | // Request to create an image for the story. 31 | let response = await fetch('https://api.openai.com/v1/images/generations', { 32 | method: 'POST', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | Authorization: 'Bearer ' + apiKey, 36 | }, 37 | body: JSON.stringify({ 38 | prompt: event.detail.scene, 39 | n: 1, 40 | size: '256x256', 41 | }), 42 | }); 43 | 44 | const data = await response.json(); 45 | const images = data.data.map((item: any) => item.url); 46 | 47 | // Get the image that was just genearted 48 | const imageAsBlog = await fetch(images[0]); 49 | const imageAsBuffer = await imageAsBlog.arrayBuffer(); 50 | 51 | // Add image to S3 52 | await s3Client.send( 53 | new PutObjectCommand({ 54 | Bucket: process.env.BUCKET_NAME, 55 | Key: `stories/${event.detail.id}/image.png`, 56 | Body: imageAsBuffer as any, 57 | ContentEncoding: 'base64', 58 | Metadata: { 59 | 'Content-Type': 'image/png', 60 | }, 61 | }) 62 | ); 63 | 64 | const url = await getSignedUrl( 65 | s3Client, 66 | new GetObjectCommand({ 67 | Bucket: process.env.BUCKET_NAME, 68 | Key: `stories/${event.detail.id}/image.png`, 69 | }), 70 | { 71 | // two days 72 | expiresIn: 172800, 73 | } 74 | ); 75 | 76 | // Add thumbnail to the story 77 | await dynamoClient.send( 78 | new UpdateItemCommand({ 79 | TableName: process.env.STORIES_TABLE, 80 | Key: { 81 | id: { S: event.detail.id }, 82 | }, 83 | UpdateExpression: 'SET thumbnail = :thumbnail', 84 | ExpressionAttributeValues: { 85 | ':thumbnail': { S: url }, 86 | }, 87 | }) 88 | ); 89 | 90 | } 91 | -------------------------------------------------------------------------------- /backend/src/generate-images-for-story/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate images for story", 3 | "version": "1.0.0", 4 | "description": "Creates images for stories using stable fusion and replicate", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "devDependencies": { 10 | "@aws-sdk/client-dynamodb": "^3.267.0", 11 | "@aws-sdk/client-secrets-manager": "^3.267.0", 12 | "@aws-sdk/util-dynamodb": "^3.261.0", 13 | "@types/aws-lambda": "^8.10.110", 14 | "@aws-sdk/client-s3": "^3.278.0", 15 | "@aws-sdk/s3-request-presigner": "^3.278.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/generate-images-for-story/with-replicate.ts: -------------------------------------------------------------------------------- 1 | import { EventBridgeEvent } from 'aws-lambda'; 2 | import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; 3 | import { DynamoDBClient, ScanCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; 4 | 5 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; 6 | import { v4 } from 'uuid'; 7 | 8 | interface StoryGenerated { 9 | id: string; 10 | title: string; 11 | description: string; 12 | scene: string; 13 | } 14 | 15 | const secretsClient = new SecretsManagerClient({}); 16 | const dynamoClient = new DynamoDBClient({}); 17 | 18 | export async function handler(event: EventBridgeEvent<'StoryGenerated', StoryGenerated>) { 19 | console.log('Triggered'); 20 | 21 | const secret = await secretsClient.send( 22 | new GetSecretValueCommand({ 23 | SecretId: 'replicate-api-key', 24 | }) 25 | ); 26 | 27 | if (!secret.SecretString) throw new Error('Failed to get replicate secret'); 28 | 29 | const apiKey = JSON.parse(secret.SecretString)['replicate-api-key']; 30 | 31 | // POST request to Replicate to start the image restoration generation process 32 | let startResponse = await fetch('https://api.replicate.com/v1/predictions', { 33 | method: 'POST', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | Authorization: 'Token ' + apiKey, 37 | }, 38 | body: JSON.stringify({ 39 | version: 'db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf', 40 | input: { 41 | prompt: event.detail.scene, 42 | image_dimensions: '512x512', 43 | num_outputs: 1, 44 | num_inference_steps: 50, 45 | guidance_scale: 7.5, 46 | scheduler: 'DPMSolverMultistep', 47 | }, 48 | }), 49 | }); 50 | 51 | console.log('startResponse', startResponse); 52 | 53 | let jsonStartResponse = await startResponse.json(); 54 | 55 | console.log('jsonStartResponse', jsonStartResponse) 56 | 57 | let endpointUrl = jsonStartResponse.urls.get; 58 | 59 | // GET request to get the status of the image restoration process & return the result when it's ready 60 | let image = null; 61 | while (!image) { 62 | // Loop in 1s intervals until the alt text is ready 63 | let finalResponse = await fetch(endpointUrl, { 64 | method: 'GET', 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | Authorization: 'Token ' + apiKey, 68 | }, 69 | }); 70 | let jsonFinalResponse = await finalResponse.json(); 71 | 72 | 73 | if (jsonFinalResponse.status === 'succeeded') { 74 | image = jsonFinalResponse.output; 75 | } else if (jsonFinalResponse.status === 'failed') { 76 | break; 77 | } else { 78 | await new Promise((resolve) => setTimeout(resolve, 1000)); 79 | } 80 | } 81 | 82 | console.log('image', image) 83 | 84 | // Make a fetch to get the information and generate images 85 | // Get url and put onto the ddb item. 86 | 87 | // // Insert new story into DDB 88 | // await dynamoClient.send( 89 | // new PutItemCommand({ 90 | // TableName: process.env.STORIES_TABLE, 91 | // Item: marshall({ 92 | // id: v4(), 93 | // title, 94 | // characters, 95 | // description, 96 | // ttl: storyTTL, 97 | // scene: selectedScene.description, 98 | // createdAt: new Date().toISOString(), 99 | // }), 100 | // }) 101 | // ); 102 | } 103 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "dom", 7 | "es2020" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "resolveJsonModule": true, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | "cdk.out" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "stage": "stg", 3 | "email": "XXXXX", 4 | "bedtimeCron": "cron(15 19 * * ? *)" 5 | } 6 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | ACCESS_KEY= 2 | SECRET_KEY= 3 | REGION= 4 | TABLE_NAME= -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies only when needed 2 | FROM node:14-alpine AS deps 3 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 4 | RUN apk add --no-cache libc6-compat 5 | WORKDIR /app 6 | COPY package.json ./ 7 | RUN npm install 8 | 9 | # Rebuild the source code only when needed 10 | FROM node:14-alpine AS builder 11 | WORKDIR /app 12 | COPY . . 13 | COPY --from=deps /app/node_modules ./node_modules 14 | RUN npm run build 15 | 16 | # Production image, copy all the files and run next 17 | FROM node:14-alpine AS runner 18 | WORKDIR /app 19 | 20 | ENV NODE_ENV production 21 | 22 | RUN addgroup -g 1001 -S nodejs 23 | RUN adduser -S nextjs -u 1001 24 | 25 | # You only need to copy next.config.js if you are NOT using the default configuration 26 | COPY --from=builder /app/next.config.js ./ 27 | COPY --from=builder /app/public ./public 28 | COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next 29 | COPY --from=builder /app/node_modules ./node_modules 30 | COPY --from=builder /app/package.json ./package.json 31 | COPY --from=builder /app/.env ./ 32 | 33 | USER nextjs 34 | 35 | EXPOSE 3000 36 | 37 | ENV PORT 3000 38 | 39 | # Next.js collects completely anonymous telemetry data about general usage. 40 | # Learn more here: https://nextjs.org/telemetry 41 | # Uncomment the following line in case you want to disable telemetry. 42 | ENV NEXT_TELEMETRY_DISABLED 1 43 | 44 | CMD ["node_modules/.bin/next", "start"] 45 | # ENTRYPOINT ["npm", "start"] -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend application for AI Stories 2 | 3 | See [README](./README.md) file for more information on how it works and design choices. -------------------------------------------------------------------------------- /frontend/lib/aws.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; 2 | import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; 3 | 4 | const client = new DynamoDBClient({ 5 | region: process.env.REGION 6 | }); 7 | 8 | export const getStory = async (id: string) => { 9 | try { 10 | const { Item: story } = await client.send( 11 | new GetItemCommand({ 12 | TableName: process.env.TABLE_NAME, 13 | Key: marshall({ 14 | id 15 | }), 16 | }) 17 | ); 18 | 19 | if (story) { 20 | return unmarshall(story); 21 | } else { 22 | return null; 23 | } 24 | } catch (error) { 25 | console.log('error', error); 26 | return null; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start" 7 | }, 8 | "dependencies": { 9 | "@aws-sdk/client-dynamodb": "^3.538.0", 10 | "@aws-sdk/util-dynamodb": "^3.272.0", 11 | "@headlessui/react": "^1.7.11", 12 | "@heroicons/react": "^2.0.16", 13 | "@tailwindcss/typography": "^0.5.9", 14 | "next": "latest", 15 | "react": "18.2.0", 16 | "react-dom": "18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "18.11.3", 20 | "@types/react": "18.0.21", 21 | "@types/react-dom": "18.0.6", 22 | "autoprefixer": "^10.4.12", 23 | "postcss": "^8.4.31", 24 | "tailwindcss": "^3.2.4", 25 | "typescript": "4.9.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | 8 | export default MyApp 9 | -------------------------------------------------------------------------------- /frontend/pages/api/health.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | const handler = (req: NextApiRequest, res: NextApiResponse) => { 4 | res.status(200).json({ ok: Date.now().toString() }); 5 | }; 6 | 7 | export default handler; -------------------------------------------------------------------------------- /frontend/pages/api/settings.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | const handler = (req: NextApiRequest, res: NextApiResponse) => { 4 | res.status(200).json({ 5 | TABLE_NAME: process.env.TABLE_NAME, 6 | REGION: process.env.REGION, 7 | }); 8 | }; 9 | 10 | export default handler; -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon } from '@heroicons/react/20/solid'; 2 | 3 | export default () => { 4 | return ( 5 |
6 | 11 |
12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 |
29 |
30 |

31 | Experience the Magic of AI-Generated Stories for Your Kids 32 |

33 |

34 | Open source event-driven application that generates a new bed time story for your children every night. 35 |

36 | 42 |
43 |
44 |
45 | 46 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /frontend/pages/story.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { getStory } from '../lib/aws'; 3 | 4 | interface Story { 5 | description: string; 6 | title: string; 7 | audioURL: string; 8 | thumbnail?: string; 9 | } 10 | 11 | interface PageProps { 12 | story: Story; 13 | } 14 | 15 | const story = ({ story }: PageProps) => { 16 | 17 | return ( 18 | <> 19 | 20 | AIStory | {story.title} 21 | 22 | 23 | 24 | 25 | 26 | {/**/} 27 | 28 | 29 | 30 | 31 | 32 |
33 | 38 |
39 | 40 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 |
54 |
55 |
56 |
57 |

{story.title}

58 | 59 |
60 |
61 | 65 |
66 | {story.description.split('\n\n').map((item) => ( 67 |

{item}

68 | ))} 69 |
70 |
71 |
72 |
73 | 74 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | 89 | ); 90 | }; 91 | 92 | export async function getServerSideProps(context: any) { 93 | // Get info from DDB table 94 | const { 95 | query: { id = '' }, 96 | } = context; 97 | 98 | if (!id) { 99 | return { 100 | notFound: true, 101 | }; 102 | } 103 | 104 | const story = await getStory(id); 105 | 106 | if (!story) { 107 | return { 108 | notFound: true, 109 | }; 110 | } 111 | 112 | return { props: { story } }; 113 | } 114 | 115 | export default story; 116 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ai-stories/89493ad6d02bf49ec358ee9094f19d274ca869ba/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ai-stories/89493ad6d02bf49ec358ee9094f19d274ca869ba/frontend/public/opengraph.png -------------------------------------------------------------------------------- /frontend/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', './app/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require('@tailwindcss/typography')], 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-serverless-ai-stories", 3 | "version": "1.0.0", 4 | "description": "Example AWS Serverless application using ChatGPT and DALL-E to generate children stories on cron jobs", 5 | "scripts": { 6 | "start:frontend": "cd frontend && npm run dev", 7 | "install:all": "cd backend && npm i && cd ../frontend && npm i && cd ../backend/src/create-story && npm i && cd ../../../backend/src/generate-audio-for-story && npm i && cd ../../../backend/src/generate-images-for-story && npm i && cd ../../../frontend && touch .env", 8 | "deploy": "cd backend && ./node_modules/.bin/cdk deploy --all", 9 | "populate-db": "node backend/scripts/insert-data-into-ddb.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT" 14 | } 15 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-ai-stories/89493ad6d02bf49ec358ee9094f19d274ca869ba/screenshot.png --------------------------------------------------------------------------------