@jhb.software/payload-alt-text-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +54 -0
  3. package/dist/components/AltTextField.d.ts +2 -0
  4. package/dist/components/AltTextField.js +40 -0
  5. package/dist/components/AltTextField.js.map +1 -0
  6. package/dist/components/BulkGenerateAltTextsButton.d.ts +1 -0
  7. package/dist/components/BulkGenerateAltTextsButton.js +69 -0
  8. package/dist/components/BulkGenerateAltTextsButton.js.map +1 -0
  9. package/dist/components/GenerateAltTextButton.d.ts +1 -0
  10. package/dist/components/GenerateAltTextButton.js +109 -0
  11. package/dist/components/GenerateAltTextButton.js.map +1 -0
  12. package/dist/components/icons/Lightning.d.ts +1 -0
  13. package/dist/components/icons/Lightning.js +19 -0
  14. package/dist/components/icons/Lightning.js.map +1 -0
  15. package/dist/components/icons/Spinner.d.ts +1 -0
  16. package/dist/components/icons/Spinner.js +42 -0
  17. package/dist/components/icons/Spinner.js.map +1 -0
  18. package/dist/endpoints/bulkGenerateAltTexts.d.ts +5 -0
  19. package/dist/endpoints/bulkGenerateAltTexts.js +150 -0
  20. package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -0
  21. package/dist/endpoints/generateAltText.d.ts +6 -0
  22. package/dist/endpoints/generateAltText.js +128 -0
  23. package/dist/endpoints/generateAltText.js.map +1 -0
  24. package/dist/exports/client.d.ts +3 -0
  25. package/dist/exports/client.js +5 -0
  26. package/dist/exports/client.js.map +1 -0
  27. package/dist/fields/altTextField.d.ts +4 -0
  28. package/dist/fields/altTextField.js +27 -0
  29. package/dist/fields/altTextField.js.map +1 -0
  30. package/dist/fields/keywordsField.d.ts +4 -0
  31. package/dist/fields/keywordsField.js +17 -0
  32. package/dist/fields/keywordsField.js.map +1 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/plugin.d.ts +3 -0
  37. package/dist/plugin.js +90 -0
  38. package/dist/plugin.js.map +1 -0
  39. package/dist/types/AltTextPluginConfig.d.ts +48 -0
  40. package/dist/types/AltTextPluginConfig.js +3 -0
  41. package/dist/types/AltTextPluginConfig.js.map +1 -0
  42. package/dist/utilities/getGenerationCost.d.ts +12 -0
  43. package/dist/utilities/getGenerationCost.js +30 -0
  44. package/dist/utilities/getGenerationCost.js.map +1 -0
  45. package/dist/utilities/zodResponseFormat.d.ts +11 -0
  46. package/dist/utilities/zodResponseFormat.js +23 -0
  47. package/dist/utilities/zodResponseFormat.js.map +1 -0
  48. package/package.json +99 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 JHB Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # Image Alt Text Generation Plugin for Payload CMS
2
+
3
+ A minimal plugin to generate image alt texts using OpenAI's Vision API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @jhb.software/payload-alt-text-plugin
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ### 1. Add plugin to Payload config
14
+
15
+ ```typescript
16
+ import { payloadAltTextPlugin } from '@jhb.software/payload-alt-text-plugin'
17
+
18
+ export default buildConfig({
19
+ plugins: [
20
+ payloadAltTextPlugin({
21
+ collections: ['media'], // Specify which upload collections should have alt text fields
22
+ openAIApiKey: process.env.OPENAI_API_KEY!,
23
+ model: 'gpt-4.1-mini',
24
+ getImageThumbnail: (doc: Record<string, unknown>) => {
25
+ // a function to get a thumbnail URL (e.g. from the sizes)
26
+ return doc.url as string
27
+ },
28
+ }),
29
+ ],
30
+ })
31
+ ```
32
+
33
+ ## Features
34
+
35
+ When the plugin is enabled for an upload collection, it will
36
+
37
+ 1. Add an alt text field to the collection
38
+ - A button to AI-generate the alt text
39
+ - This field will include a description of what the alt text should be
40
+ 2. Add a keywords fields to the collection
41
+ - This field will be automatically filled when generating the alt text
42
+ - It can be used for improving the search of images in the admin panel
43
+ 2. Add a bulk generate button to the collection list view
44
+ - This button will allow you to generate alt text for multiple images at once
45
+
46
+ ## Roadmap
47
+
48
+ > ⚠️ **Warning**: This plugin is actively evolving and may undergo significant changes. While it is functional, please thoroughly test before using in production environments.
49
+
50
+ Have a suggestion for the plugin? Any feedback is welcome!
51
+
52
+ ## Contributing
53
+
54
+ We welcome contributions! Please open an issue to report bugs or suggest improvements, or submit a pull request with your changes.
@@ -0,0 +1,2 @@
1
+ import type { TextareaFieldClientProps } from 'payload';
2
+ export declare const AltTextField: (clientProps: TextareaFieldClientProps) => import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui';
4
+ import { GenerateAltTextButton } from './GenerateAltTextButton.js';
5
+ export const AltTextField = (clientProps)=>{
6
+ const { field, path } = clientProps;
7
+ const { value, setValue } = useField({
8
+ path
9
+ });
10
+ const { id } = useDocumentInfo();
11
+ // the field should be optional when the document is created
12
+ // (since the alt text generation can only be used once the document is created and the image uploaded)
13
+ const required = id ? field.required : false;
14
+ return /*#__PURE__*/ _jsxs("div", {
15
+ className: "field-type textarea",
16
+ style: {
17
+ flex: '1 1 auto'
18
+ },
19
+ children: [
20
+ /*#__PURE__*/ _jsx(FieldLabel, {
21
+ htmlFor: `field-${path}`,
22
+ label: field.label,
23
+ required: required,
24
+ localized: field.localized
25
+ }),
26
+ /*#__PURE__*/ _jsx("div", {
27
+ className: "field-type__wrap",
28
+ children: /*#__PURE__*/ _jsx(TextareaInput, {
29
+ value: value,
30
+ path: path,
31
+ required: required,
32
+ onChange: (e)=>setValue(e.target.value),
33
+ AfterInput: /*#__PURE__*/ _jsx(GenerateAltTextButton, {})
34
+ })
35
+ })
36
+ ]
37
+ });
38
+ };
39
+
40
+ //# sourceMappingURL=AltTextField.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/AltTextField.tsx"],"sourcesContent":["'use client'\n\nimport { FieldLabel, TextareaInput, useDocumentInfo, useField } from '@payloadcms/ui'\nimport type { TextareaFieldClientProps } from 'payload'\n\nimport { GenerateAltTextButton } from './GenerateAltTextButton.js'\n\nexport const AltTextField = (clientProps: TextareaFieldClientProps) => {\n const { field, path } = clientProps\n\n const { value, setValue } = useField<string>({ path })\n const { id } = useDocumentInfo()\n\n // the field should be optional when the document is created\n // (since the alt text generation can only be used once the document is created and the image uploaded)\n const required = id ? field.required : false\n\n return (\n <div className=\"field-type textarea\" style={{ flex: '1 1 auto' }}>\n <FieldLabel\n htmlFor={`field-${path}`}\n label={field.label}\n required={required}\n localized={field.localized}\n />\n\n <div className=\"field-type__wrap\">\n <TextareaInput\n value={value}\n path={path!}\n required={required}\n onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value)}\n AfterInput={<GenerateAltTextButton />}\n />\n </div>\n </div>\n )\n}\n"],"names":["FieldLabel","TextareaInput","useDocumentInfo","useField","GenerateAltTextButton","AltTextField","clientProps","field","path","value","setValue","id","required","div","className","style","flex","htmlFor","label","localized","onChange","e","target","AfterInput"],"mappings":"AAAA;;AAEA,SAASA,UAAU,EAAEC,aAAa,EAAEC,eAAe,EAAEC,QAAQ,QAAQ,iBAAgB;AAGrF,SAASC,qBAAqB,QAAQ,6BAA4B;AAElE,OAAO,MAAMC,eAAe,CAACC;IAC3B,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAE,GAAGF;IAExB,MAAM,EAAEG,KAAK,EAAEC,QAAQ,EAAE,GAAGP,SAAiB;QAAEK;IAAK;IACpD,MAAM,EAAEG,EAAE,EAAE,GAAGT;IAEf,4DAA4D;IAC5D,uGAAuG;IACvG,MAAMU,WAAWD,KAAKJ,MAAMK,QAAQ,GAAG;IAEvC,qBACE,MAACC;QAAIC,WAAU;QAAsBC,OAAO;YAAEC,MAAM;QAAW;;0BAC7D,KAAChB;gBACCiB,SAAS,CAAC,MAAM,EAAET,MAAM;gBACxBU,OAAOX,MAAMW,KAAK;gBAClBN,UAAUA;gBACVO,WAAWZ,MAAMY,SAAS;;0BAG5B,KAACN;gBAAIC,WAAU;0BACb,cAAA,KAACb;oBACCQ,OAAOA;oBACPD,MAAMA;oBACNI,UAAUA;oBACVQ,UAAU,CAACC,IAA8CX,SAASW,EAAEC,MAAM,CAACb,KAAK;oBAChFc,0BAAY,KAACnB;;;;;AAKvB,EAAC"}
@@ -0,0 +1 @@
1
+ export declare function BulkGenerateAltTextsButton(): false | import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, toast, useSelection } from '@payloadcms/ui';
4
+ import { useRouter } from 'next/navigation.js';
5
+ import { useTransition } from 'react';
6
+ import { Lightning } from './icons/Lightning.js';
7
+ import { Spinner } from './icons/Spinner.js';
8
+ export function BulkGenerateAltTextsButton() {
9
+ const [isPending, startTransition] = useTransition();
10
+ const { selected, setSelection } = useSelection();
11
+ const selectedIds = Array.from(selected.entries()).filter(([, isSelected])=>isSelected).map(([id])=>id);
12
+ const router = useRouter();
13
+ const handleGenerateAltTexts = async ()=>{
14
+ startTransition(async ()=>{
15
+ try {
16
+ const response = await fetch('/api/alt-text-plugin/bulk-generate-alt-texts', {
17
+ method: 'POST',
18
+ body: JSON.stringify({
19
+ collection: 'media',
20
+ ids: selectedIds
21
+ })
22
+ });
23
+ if (!response.ok) {
24
+ toast.error('Failed to generate alt text. Please try again.');
25
+ return;
26
+ }
27
+ const data = await response.json();
28
+ if (data.erroredDocs.length > 0) {
29
+ toast.error(`Failed to generate alt text for ${data.erroredDocs.length} images.`);
30
+ }
31
+ // in case not all images were updated, show a warning instead of a success message:
32
+ if (data.updatedDocs === data.totalDocs) {
33
+ toast.success(`${data.updatedDocs} of ${data.totalDocs} images updated.`);
34
+ } else {
35
+ toast.warning(`${data.updatedDocs} of ${data.totalDocs} images updated.`);
36
+ }
37
+ // deselect all previously selected images
38
+ for (const id of selectedIds){
39
+ setSelection(id);
40
+ }
41
+ router.refresh();
42
+ } catch (error) {
43
+ console.error('Error generating alt text:', error);
44
+ toast.error('Error generating alt text. Please try again.');
45
+ }
46
+ });
47
+ };
48
+ return selectedIds.length > 0 && /*#__PURE__*/ _jsx("div", {
49
+ style: {
50
+ display: 'flex',
51
+ justifyContent: 'right'
52
+ },
53
+ className: "m-0",
54
+ children: /*#__PURE__*/ _jsxs(Button, {
55
+ onClick: handleGenerateAltTexts,
56
+ disabled: isPending || selectedIds.length === 0,
57
+ icon: isPending ? /*#__PURE__*/ _jsx(Spinner, {}) : /*#__PURE__*/ _jsx(Lightning, {}),
58
+ className: "m-0",
59
+ children: [
60
+ "Generate alt text for ",
61
+ selectedIds.length,
62
+ " ",
63
+ selectedIds.length === 1 ? 'image' : 'images'
64
+ ]
65
+ })
66
+ });
67
+ }
68
+
69
+ //# sourceMappingURL=BulkGenerateAltTextsButton.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/BulkGenerateAltTextsButton.tsx"],"sourcesContent":["'use client'\n\nimport { Button, toast, useSelection } from '@payloadcms/ui'\nimport { useRouter } from 'next/navigation.js'\nimport { useTransition } from 'react'\n\nimport { Lightning } from './icons/Lightning.js'\nimport { Spinner } from './icons/Spinner.js'\n\nexport function BulkGenerateAltTextsButton() {\n const [isPending, startTransition] = useTransition()\n const { selected, setSelection } = useSelection()\n\n const selectedIds = Array.from(selected.entries())\n .filter(([, isSelected]) => isSelected)\n .map(([id]) => id) as string[]\n\n const router = useRouter()\n\n const handleGenerateAltTexts = async () => {\n startTransition(async () => {\n try {\n const response = await fetch('/api/alt-text-plugin/bulk-generate-alt-texts', {\n method: 'POST',\n body: JSON.stringify({\n collection: 'media',\n ids: selectedIds,\n }),\n })\n\n if (!response.ok) {\n toast.error('Failed to generate alt text. Please try again.')\n return\n }\n\n const data = (await response.json()) as {\n updatedDocs: number\n totalDocs: number\n erroredDocs: string[]\n }\n\n if (data.erroredDocs.length > 0) {\n toast.error(`Failed to generate alt text for ${data.erroredDocs.length} images.`)\n }\n\n // in case not all images were updated, show a warning instead of a success message:\n if (data.updatedDocs === data.totalDocs) {\n toast.success(`${data.updatedDocs} of ${data.totalDocs} images updated.`)\n } else {\n toast.warning(`${data.updatedDocs} of ${data.totalDocs} images updated.`)\n }\n\n // deselect all previously selected images\n for (const id of selectedIds) {\n setSelection(id)\n }\n\n router.refresh()\n } catch (error) {\n console.error('Error generating alt text:', error)\n toast.error('Error generating alt text. Please try again.')\n }\n })\n }\n\n return (\n selectedIds.length > 0 && (\n <div style={{ display: 'flex', justifyContent: 'right' }} className=\"m-0\">\n <Button\n onClick={handleGenerateAltTexts}\n disabled={isPending || selectedIds.length === 0}\n icon={isPending ? <Spinner /> : <Lightning />}\n className=\"m-0\"\n >\n Generate alt text for {selectedIds.length} {selectedIds.length === 1 ? 'image' : 'images'}\n </Button>\n </div>\n )\n )\n}\n"],"names":["Button","toast","useSelection","useRouter","useTransition","Lightning","Spinner","BulkGenerateAltTextsButton","isPending","startTransition","selected","setSelection","selectedIds","Array","from","entries","filter","isSelected","map","id","router","handleGenerateAltTexts","response","fetch","method","body","JSON","stringify","collection","ids","ok","error","data","json","erroredDocs","length","updatedDocs","totalDocs","success","warning","refresh","console","div","style","display","justifyContent","className","onClick","disabled","icon"],"mappings":"AAAA;;AAEA,SAASA,MAAM,EAAEC,KAAK,EAAEC,YAAY,QAAQ,iBAAgB;AAC5D,SAASC,SAAS,QAAQ,qBAAoB;AAC9C,SAASC,aAAa,QAAQ,QAAO;AAErC,SAASC,SAAS,QAAQ,uBAAsB;AAChD,SAASC,OAAO,QAAQ,qBAAoB;AAE5C,OAAO,SAASC;IACd,MAAM,CAACC,WAAWC,gBAAgB,GAAGL;IACrC,MAAM,EAAEM,QAAQ,EAAEC,YAAY,EAAE,GAAGT;IAEnC,MAAMU,cAAcC,MAAMC,IAAI,CAACJ,SAASK,OAAO,IAC5CC,MAAM,CAAC,CAAC,GAAGC,WAAW,GAAKA,YAC3BC,GAAG,CAAC,CAAC,CAACC,GAAG,GAAKA;IAEjB,MAAMC,SAASjB;IAEf,MAAMkB,yBAAyB;QAC7BZ,gBAAgB;YACd,IAAI;gBACF,MAAMa,WAAW,MAAMC,MAAM,gDAAgD;oBAC3EC,QAAQ;oBACRC,MAAMC,KAAKC,SAAS,CAAC;wBACnBC,YAAY;wBACZC,KAAKjB;oBACP;gBACF;gBAEA,IAAI,CAACU,SAASQ,EAAE,EAAE;oBAChB7B,MAAM8B,KAAK,CAAC;oBACZ;gBACF;gBAEA,MAAMC,OAAQ,MAAMV,SAASW,IAAI;gBAMjC,IAAID,KAAKE,WAAW,CAACC,MAAM,GAAG,GAAG;oBAC/BlC,MAAM8B,KAAK,CAAC,CAAC,gCAAgC,EAAEC,KAAKE,WAAW,CAACC,MAAM,CAAC,QAAQ,CAAC;gBAClF;gBAEA,oFAAoF;gBACpF,IAAIH,KAAKI,WAAW,KAAKJ,KAAKK,SAAS,EAAE;oBACvCpC,MAAMqC,OAAO,CAAC,GAAGN,KAAKI,WAAW,CAAC,IAAI,EAAEJ,KAAKK,SAAS,CAAC,gBAAgB,CAAC;gBAC1E,OAAO;oBACLpC,MAAMsC,OAAO,CAAC,GAAGP,KAAKI,WAAW,CAAC,IAAI,EAAEJ,KAAKK,SAAS,CAAC,gBAAgB,CAAC;gBAC1E;gBAEA,0CAA0C;gBAC1C,KAAK,MAAMlB,MAAMP,YAAa;oBAC5BD,aAAaQ;gBACf;gBAEAC,OAAOoB,OAAO;YAChB,EAAE,OAAOT,OAAO;gBACdU,QAAQV,KAAK,CAAC,8BAA8BA;gBAC5C9B,MAAM8B,KAAK,CAAC;YACd;QACF;IACF;IAEA,OACEnB,YAAYuB,MAAM,GAAG,mBACnB,KAACO;QAAIC,OAAO;YAAEC,SAAS;YAAQC,gBAAgB;QAAQ;QAAGC,WAAU;kBAClE,cAAA,MAAC9C;YACC+C,SAAS1B;YACT2B,UAAUxC,aAAaI,YAAYuB,MAAM,KAAK;YAC9Cc,MAAMzC,0BAAY,KAACF,6BAAa,KAACD;YACjCyC,WAAU;;gBACX;gBACwBlC,YAAYuB,MAAM;gBAAC;gBAAEvB,YAAYuB,MAAM,KAAK,IAAI,UAAU;;;;AAK3F"}
@@ -0,0 +1 @@
1
+ export declare function GenerateAltTextButton(): import("react").JSX.Element;
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, toast, useDocumentInfo, useField, useLocale } from '@payloadcms/ui';
4
+ import { useTransition } from 'react';
5
+ import { Lightning } from './icons/Lightning.js';
6
+ import { Spinner } from './icons/Spinner.js';
7
+ export function GenerateAltTextButton() {
8
+ const { id, collectionSlug } = useDocumentInfo();
9
+ const locale = useLocale();
10
+ const [isPending, startTransition] = useTransition();
11
+ const { setValue: setKeywords } = useField({
12
+ path: 'keywords'
13
+ });
14
+ const { setValue: setAltText } = useField({
15
+ path: 'alt'
16
+ });
17
+ const handleGenerateAltText = async ()=>{
18
+ if (!collectionSlug || !id) {
19
+ toast.error('Cannot generate alt text. Missing required fields.');
20
+ throw new Error('Missing required fields');
21
+ }
22
+ startTransition(async ()=>{
23
+ try {
24
+ const response = await fetch('/api/alt-text-plugin/generate-alt-text', {
25
+ method: 'POST',
26
+ body: JSON.stringify({
27
+ collection: collectionSlug,
28
+ id: id,
29
+ locale: locale.code
30
+ })
31
+ });
32
+ if (!response.ok) {
33
+ let errorMessage = 'Failed to generate alt text. Please try again.';
34
+ try {
35
+ const errorData = await response.json();
36
+ errorMessage = errorData.error;
37
+ } catch (error) {
38
+ console.error('Error generating alt text:', error);
39
+ }
40
+ toast.error(errorMessage);
41
+ return;
42
+ }
43
+ const data = await response.json();
44
+ if (data.altText && data.keywords) {
45
+ setAltText(data.altText);
46
+ setKeywords(data.keywords);
47
+ toast.success('Alt text generated successfully. Please review and save the document.');
48
+ } else {
49
+ toast.error('No alt text generated. Please try again.');
50
+ }
51
+ } catch (error) {
52
+ console.error('Error generating alt text:', error);
53
+ toast.error('Error generating alt text. Please try again.');
54
+ }
55
+ });
56
+ };
57
+ return /*#__PURE__*/ _jsxs("div", {
58
+ style: {
59
+ display: 'flex',
60
+ gap: '20px',
61
+ marginTop: '10px'
62
+ },
63
+ children: [
64
+ /*#__PURE__*/ _jsxs("div", {
65
+ style: {
66
+ flex: '1',
67
+ color: 'var(--theme-elevation-400)'
68
+ },
69
+ children: [
70
+ /*#__PURE__*/ _jsx("p", {
71
+ children: "Alternate text for the image. This will be used for screen readers and SEO. It should meet the following requirements:"
72
+ }),
73
+ /*#__PURE__*/ _jsxs("ol", {
74
+ style: {
75
+ paddingLeft: '20px',
76
+ margin: '10px 0'
77
+ },
78
+ children: [
79
+ /*#__PURE__*/ _jsx("li", {
80
+ children: "Briefly describe what is visible in the image in 1–2 sentences."
81
+ }),
82
+ /*#__PURE__*/ _jsx("li", {
83
+ children: "Ensure it conveys the same information or purpose as the image, whenever possible."
84
+ }),
85
+ /*#__PURE__*/ _jsx("li", {
86
+ children: 'Avoid phrases like "image of" or "picture of" — screen readers already announce that it"s an image.'
87
+ })
88
+ ]
89
+ })
90
+ ]
91
+ }),
92
+ /*#__PURE__*/ _jsx("div", {
93
+ style: {
94
+ display: 'flex',
95
+ alignItems: 'center'
96
+ },
97
+ children: /*#__PURE__*/ _jsx(Button, {
98
+ onClick: handleGenerateAltText,
99
+ disabled: isPending || !id,
100
+ icon: isPending ? /*#__PURE__*/ _jsx(Spinner, {}) : /*#__PURE__*/ _jsx(Lightning, {}),
101
+ tooltip: !id ? 'Please save the document first' : undefined,
102
+ children: "Generate alt text"
103
+ })
104
+ })
105
+ ]
106
+ });
107
+ }
108
+
109
+ //# sourceMappingURL=GenerateAltTextButton.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/GenerateAltTextButton.tsx"],"sourcesContent":["'use client'\n\nimport { Button, toast, useDocumentInfo, useField, useLocale } from '@payloadcms/ui'\nimport { useTransition } from 'react'\n\nimport { Lightning } from './icons/Lightning.js'\nimport { Spinner } from './icons/Spinner.js'\n\nexport function GenerateAltTextButton() {\n const { id, collectionSlug } = useDocumentInfo()\n const locale = useLocale()\n const [isPending, startTransition] = useTransition()\n\n const { setValue: setKeywords } = useField<string>({ path: 'keywords' })\n const { setValue: setAltText } = useField<string>({ path: 'alt' })\n\n const handleGenerateAltText = async () => {\n if (!collectionSlug || !id) {\n toast.error('Cannot generate alt text. Missing required fields.')\n throw new Error('Missing required fields')\n }\n\n startTransition(async () => {\n try {\n const response = await fetch('/api/alt-text-plugin/generate-alt-text', {\n method: 'POST',\n body: JSON.stringify({\n collection: collectionSlug,\n id: id as string,\n locale: locale.code,\n }),\n })\n\n if (!response.ok) {\n let errorMessage = 'Failed to generate alt text. Please try again.'\n try {\n const errorData = (await response.json()) as { error: string }\n errorMessage = errorData.error\n } catch (error) {\n console.error('Error generating alt text:', error)\n }\n\n toast.error(errorMessage)\n return\n }\n\n const data = (await response.json()) as {\n altText: string\n keywords: string[]\n }\n\n if (data.altText && data.keywords) {\n setAltText(data.altText)\n setKeywords(data.keywords)\n toast.success('Alt text generated successfully. Please review and save the document.')\n } else {\n toast.error('No alt text generated. Please try again.')\n }\n } catch (error) {\n console.error('Error generating alt text:', error)\n toast.error('Error generating alt text. Please try again.')\n }\n })\n }\n\n return (\n <div style={{ display: 'flex', gap: '20px', marginTop: '10px' }}>\n <div style={{ flex: '1', color: 'var(--theme-elevation-400)' }}>\n <p>\n Alternate text for the image. This will be used for screen readers and SEO. It should meet\n the following requirements:\n </p>\n <ol style={{ paddingLeft: '20px', margin: '10px 0' }}>\n <li>Briefly describe what is visible in the image in 1–2 sentences.</li>\n <li>\n Ensure it conveys the same information or purpose as the image, whenever possible.\n </li>\n <li>\n Avoid phrases like &quot;image of&quot; or &quot;picture of&quot; — screen readers\n already announce that it&quot;s an image.\n </li>\n </ol>\n </div>\n <div style={{ display: 'flex', alignItems: 'center' }}>\n <Button\n onClick={handleGenerateAltText}\n disabled={isPending || !id}\n icon={isPending ? <Spinner /> : <Lightning />}\n tooltip={!id ? 'Please save the document first' : undefined}\n >\n Generate alt text\n </Button>\n </div>\n </div>\n )\n}\n"],"names":["Button","toast","useDocumentInfo","useField","useLocale","useTransition","Lightning","Spinner","GenerateAltTextButton","id","collectionSlug","locale","isPending","startTransition","setValue","setKeywords","path","setAltText","handleGenerateAltText","error","Error","response","fetch","method","body","JSON","stringify","collection","code","ok","errorMessage","errorData","json","console","data","altText","keywords","success","div","style","display","gap","marginTop","flex","color","p","ol","paddingLeft","margin","li","alignItems","onClick","disabled","icon","tooltip","undefined"],"mappings":"AAAA;;AAEA,SAASA,MAAM,EAAEC,KAAK,EAAEC,eAAe,EAAEC,QAAQ,EAAEC,SAAS,QAAQ,iBAAgB;AACpF,SAASC,aAAa,QAAQ,QAAO;AAErC,SAASC,SAAS,QAAQ,uBAAsB;AAChD,SAASC,OAAO,QAAQ,qBAAoB;AAE5C,OAAO,SAASC;IACd,MAAM,EAAEC,EAAE,EAAEC,cAAc,EAAE,GAAGR;IAC/B,MAAMS,SAASP;IACf,MAAM,CAACQ,WAAWC,gBAAgB,GAAGR;IAErC,MAAM,EAAES,UAAUC,WAAW,EAAE,GAAGZ,SAAiB;QAAEa,MAAM;IAAW;IACtE,MAAM,EAAEF,UAAUG,UAAU,EAAE,GAAGd,SAAiB;QAAEa,MAAM;IAAM;IAEhE,MAAME,wBAAwB;QAC5B,IAAI,CAACR,kBAAkB,CAACD,IAAI;YAC1BR,MAAMkB,KAAK,CAAC;YACZ,MAAM,IAAIC,MAAM;QAClB;QAEAP,gBAAgB;YACd,IAAI;gBACF,MAAMQ,WAAW,MAAMC,MAAM,0CAA0C;oBACrEC,QAAQ;oBACRC,MAAMC,KAAKC,SAAS,CAAC;wBACnBC,YAAYjB;wBACZD,IAAIA;wBACJE,QAAQA,OAAOiB,IAAI;oBACrB;gBACF;gBAEA,IAAI,CAACP,SAASQ,EAAE,EAAE;oBAChB,IAAIC,eAAe;oBACnB,IAAI;wBACF,MAAMC,YAAa,MAAMV,SAASW,IAAI;wBACtCF,eAAeC,UAAUZ,KAAK;oBAChC,EAAE,OAAOA,OAAO;wBACdc,QAAQd,KAAK,CAAC,8BAA8BA;oBAC9C;oBAEAlB,MAAMkB,KAAK,CAACW;oBACZ;gBACF;gBAEA,MAAMI,OAAQ,MAAMb,SAASW,IAAI;gBAKjC,IAAIE,KAAKC,OAAO,IAAID,KAAKE,QAAQ,EAAE;oBACjCnB,WAAWiB,KAAKC,OAAO;oBACvBpB,YAAYmB,KAAKE,QAAQ;oBACzBnC,MAAMoC,OAAO,CAAC;gBAChB,OAAO;oBACLpC,MAAMkB,KAAK,CAAC;gBACd;YACF,EAAE,OAAOA,OAAO;gBACdc,QAAQd,KAAK,CAAC,8BAA8BA;gBAC5ClB,MAAMkB,KAAK,CAAC;YACd;QACF;IACF;IAEA,qBACE,MAACmB;QAAIC,OAAO;YAAEC,SAAS;YAAQC,KAAK;YAAQC,WAAW;QAAO;;0BAC5D,MAACJ;gBAAIC,OAAO;oBAAEI,MAAM;oBAAKC,OAAO;gBAA6B;;kCAC3D,KAACC;kCAAE;;kCAIH,MAACC;wBAAGP,OAAO;4BAAEQ,aAAa;4BAAQC,QAAQ;wBAAS;;0CACjD,KAACC;0CAAG;;0CACJ,KAACA;0CAAG;;0CAGJ,KAACA;0CAAG;;;;;;0BAMR,KAACX;gBAAIC,OAAO;oBAAEC,SAAS;oBAAQU,YAAY;gBAAS;0BAClD,cAAA,KAAClD;oBACCmD,SAASjC;oBACTkC,UAAUxC,aAAa,CAACH;oBACxB4C,MAAMzC,0BAAY,KAACL,6BAAa,KAACD;oBACjCgD,SAAS,CAAC7C,KAAK,mCAAmC8C;8BACnD;;;;;AAMT"}
@@ -0,0 +1 @@
1
+ export declare function Lightning(): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Lightning() {
3
+ return /*#__PURE__*/ _jsx("svg", {
4
+ xmlns: "http://www.w3.org/2000/svg",
5
+ viewBox: "0 0 20 20",
6
+ fill: "currentColor",
7
+ style: {
8
+ height: '1rem',
9
+ width: '1rem'
10
+ },
11
+ children: /*#__PURE__*/ _jsx("path", {
12
+ fillRule: "evenodd",
13
+ d: "M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z",
14
+ clipRule: "evenodd"
15
+ })
16
+ });
17
+ }
18
+
19
+ //# sourceMappingURL=Lightning.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/components/icons/Lightning.tsx"],"sourcesContent":["export function Lightning() {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n style={{\n height: '1rem',\n width: '1rem',\n }}\n >\n <path\n fillRule=\"evenodd\"\n d=\"M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z\"\n clipRule=\"evenodd\"\n />\n </svg>\n )\n}\n"],"names":["Lightning","svg","xmlns","viewBox","fill","style","height","width","path","fillRule","d","clipRule"],"mappings":";AAAA,OAAO,SAASA;IACd,qBACE,KAACC;QACCC,OAAM;QACNC,SAAQ;QACRC,MAAK;QACLC,OAAO;YACLC,QAAQ;YACRC,OAAO;QACT;kBAEA,cAAA,KAACC;YACCC,UAAS;YACTC,GAAE;YACFC,UAAS;;;AAIjB"}
@@ -0,0 +1 @@
1
+ export declare function Spinner(): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function Spinner() {
3
+ return /*#__PURE__*/ _jsxs("svg", {
4
+ xmlns: "http://www.w3.org/2000/svg",
5
+ fill: "none",
6
+ viewBox: "0 0 24 24",
7
+ style: {
8
+ height: '1rem',
9
+ width: '1rem',
10
+ animation: 'spin 1s linear infinite'
11
+ },
12
+ children: [
13
+ /*#__PURE__*/ _jsx("style", {
14
+ children: `
15
+ @keyframes spin {
16
+ 0% { transform: rotate(0deg); }
17
+ 100% { transform: rotate(360deg); }
18
+ }
19
+ `
20
+ }),
21
+ /*#__PURE__*/ _jsx("circle", {
22
+ style: {
23
+ opacity: 0.25
24
+ },
25
+ cx: "12",
26
+ cy: "12",
27
+ r: "10",
28
+ stroke: "currentColor",
29
+ strokeWidth: "4"
30
+ }),
31
+ /*#__PURE__*/ _jsx("path", {
32
+ style: {
33
+ opacity: 0.75
34
+ },
35
+ fill: "currentColor",
36
+ d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
37
+ })
38
+ ]
39
+ });
40
+ }
41
+
42
+ //# sourceMappingURL=Spinner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/components/icons/Spinner.tsx"],"sourcesContent":["export function Spinner() {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n style={{\n height: '1rem',\n width: '1rem',\n animation: 'spin 1s linear infinite',\n }}\n >\n <style>\n {`\n @keyframes spin {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n }\n `}\n </style>\n <circle\n style={{ opacity: 0.25 }}\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n strokeWidth=\"4\"\n ></circle>\n <path\n style={{ opacity: 0.75 }}\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n ></path>\n </svg>\n )\n}\n"],"names":["Spinner","svg","xmlns","fill","viewBox","style","height","width","animation","circle","opacity","cx","cy","r","stroke","strokeWidth","path","d"],"mappings":";AAAA,OAAO,SAASA;IACd,qBACE,MAACC;QACCC,OAAM;QACNC,MAAK;QACLC,SAAQ;QACRC,OAAO;YACLC,QAAQ;YACRC,OAAO;YACPC,WAAW;QACb;;0BAEA,KAACH;0BACE,CAAC;;;;;QAKF,CAAC;;0BAEH,KAACI;gBACCJ,OAAO;oBAAEK,SAAS;gBAAK;gBACvBC,IAAG;gBACHC,IAAG;gBACHC,GAAE;gBACFC,QAAO;gBACPC,aAAY;;0BAEd,KAACC;gBACCX,OAAO;oBAAEK,SAAS;gBAAK;gBACvBP,MAAK;gBACLc,GAAE;;;;AAIV"}
@@ -0,0 +1,5 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ /**
3
+ * Generates and updates alt text for multiple images in all locales.
4
+ */
5
+ export declare const bulkGenerateAltTextsEndpoint: PayloadHandler;
@@ -0,0 +1,150 @@
1
+ import OpenAI from 'openai';
2
+ import pMap from 'p-map';
3
+ import { z } from 'zod';
4
+ import { getGenerationCost } from '../utilities/getGenerationCost.js';
5
+ import { zodResponseFormat } from '../utilities/zodResponseFormat.js';
6
+ /**
7
+ * Generates and updates alt text for multiple images in all locales.
8
+ */ export const bulkGenerateAltTextsEndpoint = async (req)=>{
9
+ try {
10
+ if (!req.user) {
11
+ return Response.json({
12
+ error: 'Unauthorized'
13
+ }, {
14
+ status: 401
15
+ });
16
+ }
17
+ const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null;
18
+ const schema = z.object({
19
+ collection: z.string(),
20
+ ids: z.array(z.string())
21
+ });
22
+ const { collection, ids } = schema.parse(data);
23
+ let updatedDocs = 0;
24
+ const erroredDocs = [];
25
+ // Get plugin config from payload config
26
+ const pluginConfig = req.payload.config.custom?.altTextPluginConfig;
27
+ if (!pluginConfig?.openAIApiKey) {
28
+ return Response.json({
29
+ error: 'OpenAI API key not configured'
30
+ }, {
31
+ status: 500
32
+ });
33
+ }
34
+ // Use concurrency from config
35
+ const concurrency = pluginConfig.maxBulkGenerateConcurrency || 16;
36
+ await pMap(ids, async (id)=>{
37
+ try {
38
+ await generateAndUpdateAltText({
39
+ payload: req.payload,
40
+ id,
41
+ collection,
42
+ pluginConfig,
43
+ locales: pluginConfig.locales
44
+ });
45
+ updatedDocs++;
46
+ console.log(`${updatedDocs}/${ids.length} updated (${Math.round(updatedDocs / ids.length * 100)}%)`);
47
+ } catch (error) {
48
+ console.error(`Error generating alt text for ${id}:`, error);
49
+ erroredDocs.push(id);
50
+ }
51
+ }, {
52
+ concurrency
53
+ });
54
+ if (erroredDocs.length > 0) {
55
+ console.error(`Failed for: ${erroredDocs.join(', ')}`);
56
+ }
57
+ return Response.json({
58
+ updatedDocs,
59
+ totalDocs: ids.length,
60
+ erroredDocs
61
+ });
62
+ } catch (error) {
63
+ console.error('Error in bulk generation:', error);
64
+ return Response.json({
65
+ error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`
66
+ }, {
67
+ status: 500
68
+ });
69
+ }
70
+ };
71
+ async function generateAndUpdateAltText({ payload, id, collection, pluginConfig, locales }) {
72
+ const imageDoc = await payload.findByID({
73
+ collection: collection,
74
+ id: id,
75
+ depth: 0
76
+ });
77
+ if (!imageDoc) {
78
+ throw new Error('Image not found');
79
+ }
80
+ const imageThumbnailUrl = pluginConfig.getImageThumbnail(imageDoc);
81
+ const openai = new OpenAI({
82
+ apiKey: pluginConfig.openAIApiKey
83
+ });
84
+ const modelResponseSchema = z.object(Object.fromEntries(locales.map((locale)=>[
85
+ locale,
86
+ z.object({
87
+ altText: z.string().describe('A concise, descriptive alt text for the image'),
88
+ keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
89
+ })
90
+ ])));
91
+ const response = await openai.chat.completions.parse({
92
+ model: pluginConfig.model,
93
+ messages: [
94
+ {
95
+ role: 'system',
96
+ content: `
97
+ You are an expert at analyzing images and creating descriptive image alt text.
98
+
99
+ Please analyze the given image and provide the following in ${locales.join(', ')}:
100
+ - A concise, localized descriptive alt text (1-2 sentences) as "altText". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.
101
+ - A localized list of keywords that describe the content (e.g., ["Camel", "Palm trees", "Desert"]) as "keywords"
102
+
103
+ If a context is provided, use it to enhance the alt text.
104
+
105
+ Format your response as a JSON object with ${locales.join(', ')} keys, each containing "altText", "keywords" and "slug".
106
+ `
107
+ },
108
+ {
109
+ role: 'user',
110
+ content: [
111
+ {
112
+ type: 'image_url',
113
+ image_url: {
114
+ url: imageThumbnailUrl
115
+ }
116
+ },
117
+ ...'filename' in imageDoc && imageDoc.filename ? [
118
+ {
119
+ type: 'text',
120
+ text: imageDoc.filename
121
+ }
122
+ ] : []
123
+ ]
124
+ }
125
+ ],
126
+ max_completion_tokens: 300,
127
+ response_format: zodResponseFormat(modelResponseSchema, 'data')
128
+ });
129
+ console.log({
130
+ imageId: id,
131
+ ...getGenerationCost(response, pluginConfig.model)
132
+ });
133
+ const result = response.choices[0]?.message?.parsed;
134
+ if (!result) {
135
+ throw new Error('No result from OpenAI');
136
+ }
137
+ for (const locale of locales){
138
+ await payload.update({
139
+ collection: collection,
140
+ id: id,
141
+ locale: locale,
142
+ data: {
143
+ alt: result[locale]?.altText,
144
+ keywords: result[locale]?.keywords
145
+ }
146
+ });
147
+ }
148
+ }
149
+
150
+ //# sourceMappingURL=bulkGenerateAltTexts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import OpenAI from 'openai'\nimport { ChatCompletionContentPartText } from 'openai/resources/chat/completions.mjs'\nimport pMap from 'p-map'\nimport type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\nimport { z } from 'zod'\nimport { getGenerationCost } from '../utilities/getGenerationCost.js'\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\nimport { zodResponseFormat } from '../utilities/zodResponseFormat.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint: PayloadHandler = async (req: PayloadRequest) => {\n try {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const schema = z.object({\n collection: z.string(),\n ids: z.array(z.string()),\n })\n\n const { collection, ids } = schema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: string[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n | AltTextPluginConfig\n | undefined\n\n if (!pluginConfig?.openAIApiKey) {\n return Response.json({ error: 'OpenAI API key not configured' }, { status: 500 })\n }\n\n // Use concurrency from config\n const concurrency = pluginConfig.maxBulkGenerateConcurrency || 16\n\n await pMap(\n ids,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n payload: req.payload,\n id,\n collection,\n pluginConfig,\n locales: pluginConfig.locales,\n })\n updatedDocs++\n console.log(\n `${updatedDocs}/${ids.length} updated (${Math.round((updatedDocs / ids.length) * 100)}%)`,\n )\n } catch (error) {\n console.error(`Error generating alt text for ${id}:`, error)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n console.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n updatedDocs,\n totalDocs: ids.length,\n erroredDocs,\n })\n } catch (error) {\n console.error('Error in bulk generation:', error)\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n}\n\nasync function generateAndUpdateAltText({\n payload,\n id,\n collection,\n pluginConfig,\n locales,\n}: {\n payload: BasePayload\n id: string\n collection: CollectionSlug\n pluginConfig: AltTextPluginConfig\n locales: string[]\n}) {\n const imageDoc = await payload.findByID({\n collection: collection,\n id: id as string,\n depth: 0,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const imageThumbnailUrl = pluginConfig.getImageThumbnail(imageDoc)\n\n const openai = new OpenAI({\n apiKey: pluginConfig.openAIApiKey,\n })\n\n const modelResponseSchema = z.object(\n Object.fromEntries(\n locales.map((locale) => [\n locale,\n z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n }),\n ]),\n ),\n )\n\n const response = await openai.chat.completions.parse({\n model: pluginConfig.model,\n messages: [\n {\n role: 'system',\n content: `\n You are an expert at analyzing images and creating descriptive image alt text. \n \n Please analyze the given image and provide the following in ${locales.join(', ')}:\n - A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n \n If a context is provided, use it to enhance the alt text.\n \n Format your response as a JSON object with ${locales.join(', ')} keys, each containing \"altText\", \"keywords\" and \"slug\".\n `,\n },\n {\n role: 'user',\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...('filename' in imageDoc && imageDoc.filename\n ? [\n {\n type: 'text',\n text: imageDoc.filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n },\n ],\n max_completion_tokens: 300,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n console.log({ imageId: id, ...getGenerationCost(response, pluginConfig.model) })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n throw new Error('No result from OpenAI')\n }\n\n for (const locale of locales) {\n await payload.update({\n collection: collection as CollectionSlug,\n id: id as string,\n locale: locale,\n data: {\n alt: (result as any)[locale]?.altText,\n keywords: (result as any)[locale]?.keywords,\n },\n })\n }\n}\n"],"names":["OpenAI","pMap","z","getGenerationCost","zodResponseFormat","bulkGenerateAltTextsEndpoint","req","user","Response","json","error","status","data","schema","object","collection","string","ids","array","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","openAIApiKey","concurrency","maxBulkGenerateConcurrency","id","generateAndUpdateAltText","locales","console","log","length","Math","round","push","join","totalDocs","Error","message","imageDoc","findByID","depth","imageThumbnailUrl","getImageThumbnail","openai","apiKey","modelResponseSchema","Object","fromEntries","map","locale","altText","describe","keywords","response","chat","completions","model","messages","role","content","type","image_url","url","filename","text","max_completion_tokens","response_format","imageId","result","choices","parsed","update","alt"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ /**
3
+ * Generates alt text for a single image using OpenAI Vision API.
4
+ * Returns result without updating the document.
5
+ */
6
+ export declare const generateAltTextEndpoint: PayloadHandler;