├── .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 |
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 | 
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 |
40 | Get started →
41 |
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 |
62 | Your browser does not support the
63 | audio
element.
64 |
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
--------------------------------------------------------------------------------