1import * as Lambda from "@/AWS/Lambda";
2import * as MediaConvert from "@/AWS/MediaConvert";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13export class MediaConvertTestFunction extends Lambda.Function<Lambda.Function>()(
14 "MediaConvertTestFunction",
15) {}
16
17export default MediaConvertTestFunction.make(
18 {
19 main,
20 functionUrl: true,
21 // MediaConvert control-plane calls routinely take a few seconds; AWS's
22 // default 3s Lambda timeout is too tight under cold starts.
23 timeout: Duration.seconds(30),
24 },
25 Effect.gen(function* () {
26 const createJob = yield* MediaConvert.CreateJob();
27 const getJob = yield* MediaConvert.GetJob();
28 const cancelJob = yield* MediaConvert.CancelJob();
29 const listJobs = yield* MediaConvert.ListJobs();
30 const searchJobs = yield* MediaConvert.SearchJobs();
31 const probe = yield* MediaConvert.Probe();
32 const startJobsQuery = yield* MediaConvert.StartJobsQuery();
33 const getJobsQueryResults = yield* MediaConvert.GetJobsQueryResults();
34
35 // Deploy-time: creates the EventBridge rule (default bus, source
36 // aws.mediaconvert) targeting this Function. The test verifies the rule
37 // deploys; runtime firing would require a billable transcode.
38 yield* MediaConvert.consumeJobEvents(
39 { statuses: ["COMPLETE", "ERROR"] },
40 (events) =>
41 Stream.runForEach(events, (event) =>
42 Effect.log(
43 `mediaconvert job event: ${event.detail.jobId} -> ${event.detail.status}`,
44 ),
45 ),
46 );
47
48 return {
49 fetch: Effect.gen(function* () {
50 const request = yield* HttpServerRequest;
51 const url = new URL(request.originalUrl);
52 const pathname = url.pathname;
53
54 if (request.method === "GET" && pathname === "/jobs") {
55 const result = yield* listJobs({ MaxResults: 5 });
56 return yield* HttpServerResponse.json({
57 count: result.Jobs?.length ?? 0,
58 });
59 }
60
61 if (request.method === "GET" && pathname === "/search") {
62 const result = yield* searchJobs({
63 Status: "COMPLETE",
64 MaxResults: 5,
65 });
66 return yield* HttpServerResponse.json({
67 count: result.Jobs?.length ?? 0,
68 });
69 }
70
71 if (request.method === "GET" && pathname === "/job") {
72 const id = url.searchParams.get("id")!;
73 const result = yield* getJob({ Id: id }).pipe(
74 Effect.map((r) => ({ status: r.Job?.Status, error: undefined })),
75 Effect.catch((e) =>
76 Effect.succeed({ status: undefined, error: e._tag }),
77 ),
78 );
79 return yield* HttpServerResponse.json(result);
80 }
81
82 if (request.method === "POST" && pathname === "/cancel") {
83 const body = (yield* request.json) as unknown as { id: string };
84 const result = yield* cancelJob({ Id: body.id }).pipe(
85 Effect.map(() => ({ cancelled: true, error: undefined })),
86 Effect.catch((e) =>
87 Effect.succeed({ cancelled: false, error: e._tag }),
88 ),
89 );
90 return yield* HttpServerResponse.json(result);
91 }
92
93 if (request.method === "POST" && pathname === "/probe") {
94 const body = (yield* request.json) as unknown as { fileUrl: string };
95 const result = yield* probe({
96 InputFiles: [{ FileUrl: body.fileUrl }],
97 }).pipe(
98 Effect.map((r) => ({
99 probed: (r.ProbeResults?.length ?? 0) > 0,
100 error: undefined,
101 })),
102 Effect.catch((e) =>
103 Effect.succeed({ probed: false, error: e._tag }),
104 ),
105 );
106 return yield* HttpServerResponse.json(result);
107 }
108
109 if (request.method === "POST" && pathname === "/submit") {
110 const body = (yield* request.json) as unknown as { role: string };
111 const result = yield* createJob({
112 Role: body.role,
113 Settings: {
114 Inputs: [{ FileInput: "s3://alchemy-nonexistent/in.mp4" }],
115 OutputGroups: [
116 {
117 Name: "File Group",
118 OutputGroupSettings: {
119 Type: "FILE_GROUP_SETTINGS",
120 FileGroupSettings: {
121 Destination: "s3://alchemy-nonexistent/out/",
122 },
123 },
124 Outputs: [
125 {
126 ContainerSettings: { Container: "MP4", Mp4Settings: {} },
127 VideoDescription: {
128 CodecSettings: {
129 Codec: "H_264",
130 H264Settings: {
131 RateControlMode: "QVBR",
132 MaxBitrate: 3000000,
133 },
134 },
135 },
136 },
137 ],
138 },
139 ],
140 },
141 }).pipe(
142 Effect.map((r) => ({ jobId: r.Job?.Id, error: undefined })),
143 Effect.catch((e) =>
144 Effect.succeed({ jobId: undefined, error: e._tag }),
145 ),
146 );
147 return yield* HttpServerResponse.json(result);
148 }
149
150 if (request.method === "POST" && pathname === "/jobsQuery") {
151 const result = yield* startJobsQuery({ MaxResults: 5 }).pipe(
152 Effect.map((r) => ({ queryId: r.Id, error: undefined })),
153 Effect.catch((e) =>
154 Effect.succeed({ queryId: undefined, error: e._tag }),
155 ),
156 );
157 return yield* HttpServerResponse.json(result);
158 }
159
160 if (request.method === "GET" && pathname === "/jobsQueryResults") {
161 const id = url.searchParams.get("id")!;
162 const result = yield* getJobsQueryResults({ Id: id }).pipe(
163 Effect.map((r) => ({
164 status: r.Status,
165 count: r.Jobs?.length ?? 0,
166 error: undefined,
167 })),
168 Effect.catch((e) =>
169 Effect.succeed({ status: undefined, count: 0, error: e._tag }),
170 ),
171 );
172 return yield* HttpServerResponse.json(result);
173 }
174
175 return yield* HttpServerResponse.json(
176 { error: "Not found", method: request.method, pathname },
177 { status: 404 },
178 );
179 }).pipe(Effect.orDie),
180 };
181 }).pipe(
182 Effect.provide(
183 Layer.mergeAll(
184 Lambda.EventSource,
185 MediaConvert.CreateJobHttp,
186 MediaConvert.GetJobHttp,
187 MediaConvert.CancelJobHttp,
188 MediaConvert.ListJobsHttp,
189 MediaConvert.SearchJobsHttp,
190 MediaConvert.ProbeHttp,
191 MediaConvert.StartJobsQueryHttp,
192 MediaConvert.GetJobsQueryResultsHttp,
193 ),
194 ),
195 ),
196);