From 87484331f805f583c98226c5a8c6b1ec5fc88925 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Tue, 13 May 2025 13:37:54 -0400
Subject: fixed setting up link anchors for video to be added to Doc. fixed
 imageTemplate to work with templates in tabs.  fixed link following from link
 menu.  cleaned up getView with sidebars to open them when needed properly..

---
 src/client/views/nodes/VideoBox.tsx | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

(limited to 'src/client/views/nodes/VideoBox.tsx')

diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index fa099178c..b3cb0e1db 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -338,12 +338,17 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
         const timecode = Cast(this.layoutDoc._layout_currentTimecode, 'number', null);
         const marquee = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation);
+        const docAnchor = () =>
+            Docs.Create.ConfigDocument({
+                title: '#' + timecode,
+                _timecodeToShow: timecode,
+                annotationOn: this.Document,
+            });
         if (!addAsAnnotation && marquee) marquee.backgroundColor = 'transparent';
-        const anchor =
-            addAsAnnotation && marquee
-                ? CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document
-                : Docs.Create.ConfigDocument({ title: '#' + timecode, _timecodeToShow: timecode, annotationOn: this.Document });
+        const visibleAnchor = () => addAsAnnotation && marquee && (CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document);
+        const anchor = visibleAnchor() || docAnchor();
         PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), temporal: true, pannable: true } }, this.Document);
+        addAsAnnotation && this.addDocument(anchor);
         return anchor;
     };
 
@@ -376,9 +381,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
             }
             return this._stackedTimeline.getView(doc, options);
         }
-        return new Promise<Opt<DocumentView>>(res => {
-            DocumentView.addViewRenderedCb(doc, dv => res(dv));
-        });
+        return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res));
     };
 
     // extracts video thumbnails and saves them as field of doc
-- 
cgit v1.2.3-70-g09d2


From 7626527799c0606fa9c4fd4d26a19189dc7e7a0e Mon Sep 17 00:00:00 2001
From: sharkiecodes <lanyi_stroud@brown.edu>
Date: Sun, 1 Jun 2025 20:24:04 -0400
Subject: reactive backgrounds, tagging of pdfs, group-select and suggested
 templates, text box content influences backgrounds

---
 src/client/apis/gpt/GPT.ts                         |  41 +++-
 .../collections/collectionFreeForm/MarqueeView.tsx |  70 ++++--
 src/client/views/nodes/ImageBox.tsx                |   2 +-
 src/client/views/nodes/PDFBox.tsx                  |  44 ++++
 src/client/views/nodes/VideoBox.tsx                |  51 ++++
 .../views/nodes/formattedText/FormattedTextBox.tsx |  52 +++-
 .../views/nodes/scrapbook/AIPresetGenerator.ts     |  31 +++
 src/client/views/nodes/scrapbook/ScrapbookBox.tsx  | 264 ++++++---------------
 .../views/nodes/scrapbook/ScrapbookPreset.tsx      |   8 +-
 .../nodes/scrapbook/ScrapbookPresetRegistry.ts     |   3 +
 10 files changed, 347 insertions(+), 219 deletions(-)
 create mode 100644 src/client/views/nodes/scrapbook/AIPresetGenerator.ts

(limited to 'src/client/views/nodes/VideoBox.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 4dd30f8b3..03fce21f7 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -30,7 +30,6 @@ enum GPTCallType {
     DRAW = 'draw',
     COLOR = 'color',
     TEMPLATE = 'template',
-    SCRAPBOOK = 'scrapbook',
     VIZSUM = 'vizsum',
     VIZSUM2 = 'vizsum2',
     FILL = 'fill',
@@ -41,7 +40,9 @@ enum GPTCallType {
     SUBSETDOCS = 'subset_docs', // select a subset of documents based on their descriptions
     DOCINFO = 'doc_info', // provide information about a document
     SORTDOCS = 'sort_docs',
-    CLASSIFYTEXT = 'classify_text', // classify text into one of the three categories: title, caption, lengthy description
+    CLASSIFYTEXTMINIMAL = 'classify_text_minimal', // classify text into one of the three categories: title, caption, lengthy description
+    CLASSIFYTEXTFULL = 'classify_text_full', //tags pdf content
+    GENERATESCRAPBOOK = 'generate_scrapbook'
 }
 
 type GPTCallOpts = {
@@ -93,7 +94,7 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         prompt: "You are a helpful resarch assistant. Analyze the user's data to find meaningful patterns and/or correlation. Please only return a JSON with a correlation column 1 propert, a correlation column 2 property, and an analysis property. ",
     },
     //new
-    classify_text: {
+    classify_text_minimal: {
         model: 'gpt-4o',
         maxTokens: 2048,
         temp: 0.25,
@@ -101,6 +102,13 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
                 most appropriate category: '${TextClassifications.Title}', '${TextClassifications.Caption}', or '${TextClassifications.LengthyDescription}'. Output exclusively the classification in your response.
                 `
     },
+    classify_text_full: {
+        model: 'gpt-4o',
+        maxTokens: 2048,
+        temp: 0.25,
+        prompt: `Based on the content of the text, provide six descriptive tags (single words) separated by spaces. 
+            Finally, include a seventh more detailed summary phrase using underscores.`
+    },
     describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' },
     flashcard: {
         model: 'gpt-4-turbo',
@@ -160,12 +168,29 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         prompt: 'You will be coloring drawings. You will be given what the drawing is, then a list of descriptions for parts of the drawing. Based on each description, respond with the stroke and fill color that it should be. Follow the rules: 1. Avoid using black for stroke color 2. Make the stroke color 1-3 shades darker than the fill color 3. Use the same colors when possible. Format as {#abcdef #abcdef}, making sure theres a color for each description, and do not include any additional text.',
     },
 
-    scrapbook: {
-        model: 'gpt-4-turbo',
-        maxTokens: 512,
+    generate_scrapbook: {
+        model: 'gpt-4o',
+        maxTokens: 2048,
         temp: 0.5,
-        prompt: 'You will be given a list of field descriptions for one or more templates in the format {field #0: “description”}{field #1: “description”}{...}, and a list of column descriptions in the format {“title”: “description”}{...}. Your job is to match columns with fields based on their descriptions. Your output should be in the following JSON format: {“template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}, “template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}} where “template_title” is the templates title as specified in the description provided,  # represents the field # and “title” the title of the column assigned to it. A filled out example might look like {“fivefield2”:{“0”:”Name”, “1”:”Image”, “2”:”Caption”, “3”:”Position”, “4”:”Stats”}, “fivefield3”:{0:”Image”, 1:”Name”, 2:”Caption”, 3:”Stats”, 4:”Position”}. Include one object for each template. IT IS VERY IMPORTANT THAT YOU ONLY INCLUDE TEXT IN THE FORMAT ABOVE, WITH NO ADDITIONS WHATSOEVER. Do not include extraneous ‘#’ characters, ‘column’ for columns, or ‘template’ for templates: ONLY THE TITLES AND NUMBERS. There should never be one column assigned to more than one field (ie. if the “name” column is assigned to field 1, it can’t be assigned to any other fields) . Do this for each template whose fields are described. The descriptions are as follows:',
-    },
+         prompt: `Generate an aesthetically pleasing scrapbook layout preset based on these items.
+    Return your response as JSON in the format:
+    [{
+        "type": DocumentType.RTF or DocumentType.IMG or DocumentType.PDF
+        "tag": a singular tag summarizing the document
+        "x": number,
+        "y": number,
+        "width": number,
+        "height": number
+    }, ...]
+    If there are mutliple documents, you may include 
+    "children": [
+                    { type: 
+                      tag: 
+                      x: , y: , width: , height:
+                    }
+                  ] `
+
+      },
 
     command_type: {
         model: 'gpt-4-turbo',
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index f5e699d3e..0b91d628b 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -28,6 +28,8 @@ import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
 import { SubCollectionViewProps } from '../CollectionSubView';
 import { ImageLabelBoxData } from './ImageLabelBox';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
+import { StrListCast } from '../../../../fields/Doc';
+import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapbook/AIPresetGenerator';
 import './MarqueeView.scss';
 
 interface MarqueeViewProps {
@@ -519,35 +521,71 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     });
 
 
+   
+    
+    generateScrapbook = action(async () => {
+   
+        const selectedDocs = this.marqueeSelect(false);
+        if (!selectedDocs.length) return;
 
-    @undoBatch
-    generateScrapbook = action(() => {
-        let docs = new Array<Doc>();
+       const descriptors: DocumentDescriptor[] = selectedDocs.map(doc => ({
+            type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN',
+            tags: (() => {
+                const internalTagsSet = new Set<string>();
+                StrListCast(doc.$tags_chat ?? new List<string>()).forEach(tag => {
+                internalTagsSet.add(tag);
+                });
+                return Array.from(internalTagsSet);
+            })()
+            }));
+
+
+        const aiPreset = await requestAiGeneratedPreset(descriptors);
+        if (!aiPreset.length) {
+            alert("Failed to generate preset");
+            return;
+        }
+
+        const scrapbookPlaceholders: Doc[] = aiPreset.map(cfg => {
+            const placeholderDoc = Docs.Create.TextDocument(cfg.tag);
+            placeholderDoc.accepts_docType = cfg.type as DocumentType;
+            placeholderDoc.accepts_tagType = cfg.acceptTag ?? cfg.tag;
+
+            const placeholder = new Doc();
+            placeholder.proto = placeholderDoc;
+            placeholder.original = placeholderDoc;
+            placeholder.x = cfg.x;
+            placeholder.y = cfg.y;
+            if (cfg.width != null) placeholder._width = cfg.width;
+            if (cfg.height != null) placeholder._height = cfg.height;
+
+            return placeholder;
+        });
+
+        const scrapbook = Docs.Create.ScrapbookDocument(scrapbookPlaceholders, {
+            backgroundColor: '#e2ad32',
+            x: this.Bounds.left,
+            y: this.Bounds.top,
+            _width: 500,
+            _height: 500,
+            title: 'AI-generated Scrapbook'
+        });
         const selected = this.marqueeSelect(false).map(d => {
             this._props.removeDocument?.(d);
             d.x = NumCast(d.x) - this.Bounds.left;
             d.y = NumCast(d.y) - this.Bounds.top;
-            docs.push(d);
             return d;
         });
-        const scrapbook = Docs.Create.ScrapbookDocument(docs, {
-            backgroundColor: '#e2ad32',
-            x: this.Bounds.left,
-            y: this.Bounds.top,
-            followLinkToggle: true,
-            _width: 200,
-            _height: 200,
-            _layout_showSidebar: true,
-            title: 'overview',
-        });
+
+        this._props.addDocument?.(scrapbook);
+        selectedDocs.forEach(doc => this._props.removeDocument?.(doc));
         const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' });
         DocUtils.MakeLink(scrapbook, portal, { link_relationship: 'summary of:summarized by' });
 
         portal.hidden = true;
         this._props.addDocument?.(portal);
-        //this._props.addLiveTextDocument(summary);
-        this._props.addDocument?.(scrapbook);
         MarqueeOptionsMenu.Instance.fadeOut(true);
+        this.hideMarquee();
     });
 
 
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index bd612d04f..9067f7e0c 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -132,7 +132,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
       const base64 = await imageUrlToBase64(url);
       if (!base64) throw new Error('Failed to load image data');
 
-      // 3) ask GPT for exactly one label: PERSON or LANDSCAPE
+      // 3) ask GPT for labels one label: PERSON or LANDSCAPE
       const raw = await gptImageLabel(
         base64,
         `Classify this image as PERSON or LANDSCAPE.  You may only respond with one of these two options. Then
diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx
index 55e6d5596..282b06215 100644
--- a/src/client/views/nodes/PDFBox.tsx
+++ b/src/client/views/nodes/PDFBox.tsx
@@ -34,6 +34,9 @@ import { ImageBox } from './ImageBox';
 import { OpenWhere } from './OpenWhere';
 import './PDFBox.scss';
 import { CreateImage } from './WebBoxRenderer';
+import { gptAPICall } from '../../apis/gpt/GPT';
+import { List } from '../../../fields/List';
+import { GPTCallType } from '../../apis/gpt/GPT';
 
 @observer
 export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
@@ -76,6 +79,47 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         }
     }
 
+     autoTag = async () => {
+     try {
+        if (!this._pdf) {
+            throw new Error('PDF not loaded');
+        }
+
+        // 1) Extract text from the first few pages (e.g., first 2 pages)
+        const maxPages = Math.min(2, this._pdf.numPages);
+        let textContent = '';
+        for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
+            const page = await this._pdf.getPage(pageNum);
+            const text = await page.getTextContent();
+            const pageText = text.items.map(item => ('str' in item ? item.str : '')).join(' ');
+            textContent += ` ${pageText}`;
+        }
+
+        if (!textContent.trim()) {
+            throw new Error('No text found in PDF');
+        }
+
+        // 2) Ask GPT to classify and provide descriptive tags
+        const raw = await gptAPICall(
+            `"${textContent.trim().slice(0, 2000)}"`, 
+            GPTCallType.CLASSIFYTEXTFULL
+        );
+
+        // 3) Normalize and store the labels
+        const label = raw.trim().toUpperCase();
+
+        const tokens = label.split(/\s+/);
+        this.Document.$tags_chat = new List<string>();
+        tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok));
+
+        // 4) Show tags in layout
+        this.Document._layout_showTags = true;
+
+    } catch (err) {
+        console.error('PDF autoTag failed:', err);
+    }
+};
+
     replaceCanvases = (oldDiv: HTMLElement, newDiv: HTMLElement) => {
         if (oldDiv.childNodes) {
             for (let i = 0; i < oldDiv.childNodes.length; i++) {
diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index fa099178c..0e7afbab1 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -30,6 +30,7 @@ import { StyleProp } from '../StyleProp';
 import { DocumentView } from './DocumentView';
 import { FieldView, FieldViewProps } from './FieldView';
 import { FocusViewOptions } from './FocusViewOptions';
+import { gptImageLabel } from '../../apis/gpt/GPT';
 import './VideoBox.scss';
 
 /**
@@ -109,6 +110,56 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         return this._videoRef;
     }
 
+
+     autoTag = async () => {
+    try {
+        if (!this.player) throw new Error('Video element not available.');
+
+        // 1) Extract a frame at the video's midpoint
+        const videoDuration = this.player.duration;
+        const snapshotTime = videoDuration / 2;
+
+        // Seek the video element to the midpoint
+        await new Promise<void>((resolve, reject) => {
+            const onSeeked = () => {
+                this.player!.removeEventListener('seeked', onSeeked);
+                resolve();
+            };
+            this.player!.addEventListener('seeked', onSeeked);
+            this.player!.currentTime = snapshotTime;
+        });
+
+        // 2) Draw the frame onto a canvas and get a base64 representation
+        const canvas = document.createElement('canvas');
+        canvas.width = this.player.videoWidth;
+        canvas.height = this.player.videoHeight;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) throw new Error('Failed to create canvas context.');
+        ctx.drawImage(this.player, 0, 0, canvas.width, canvas.height);
+        const base64Image = canvas.toDataURL('image/png');
+
+        // 3) Send the image data to GPT for classification and descriptive tags
+        const raw = await gptImageLabel(
+            base64Image,
+            `Classify this video frame as either a PERSON or LANDSCAPE.
+            Then provide five additional descriptive tags (single words) separated by spaces.
+            Finally, add one detailed summary phrase using underscores.`
+        );
+
+        // 4) Normalize and store labels in the Document's tags
+        const label = raw.trim().toUpperCase();
+        const tokens = label.split(/\s+/);
+        this.Document.$tags_chat = new List<string>();
+        tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok));
+
+        // 5) Turn on tag display in layout
+        this.Document._layout_showTags = true;
+
+    } catch (err) {
+        console.error('Video autoTag failed:', err);
+    }
+};
+
     componentDidMount() {
         this.unmounting = false;
         this._props.setContentViewBox?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document.  this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link.
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 10becc00b..04a14a15f 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -8,6 +8,7 @@ import { baseKeymap, selectAll, splitBlock } from 'prosemirror-commands';
 import { history } from 'prosemirror-history';
 import { inputRules } from 'prosemirror-inputrules';
 import { keymap } from 'prosemirror-keymap';
+import { runInAction } from 'mobx';
 import { Fragment, Mark, Node, Slice } from 'prosemirror-model';
 import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from 'prosemirror-state';
 import { EditorView, NodeViewConstructor } from 'prosemirror-view';
@@ -305,12 +306,53 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
         }
     };
 
-    autoTag = async () => {
+  autoTag = async () => {
+
+    const layoutKey = Doc.LayoutDataKey(this.Document);
+    const rawText = RTFCast(this.Document[layoutKey])?.Text ?? StrCast(this.Document[layoutKey]);
+
+    const callType = rawText.includes("[placeholder]")
+        ? GPTCallType.CLASSIFYTEXTMINIMAL
+        : GPTCallType.CLASSIFYTEXTFULL;
+
+    gptAPICall(rawText, callType).then(desc => {
+        runInAction(() => {
+            // Clear existing tags
+            this.Document.$tags_chat = new List<string>();
+
+            // Split GPT response into tokens and push individually
+            const tokens = desc.trim().split(/\s+/);
+            tokens.forEach(tok => {
+                (this.Document.$tags_chat as List<string>).push(tok);
+            });
+
+            this.Document._layout_showTags = true;
+        });
+    });
+        /*this.Document.$tags_chat = new List<string>();
+        gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), GPTCallType.CLASSIFYTEXTMINIMAL).then(desc => (this.Document.$tags_chat as List<string>).push(desc));
+        this.Document._layout_showTags = true;*/
+
+
+    // 2) grab whatever’s actually in the field (either RTF or plain string)
+/*
+    const rawText = RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)])
+
+    // 3) pick minimal vs. full classification based on "[placeholder]" substring
+    if (rawText.includes("[placeholder]")) {
         this.Document.$tags_chat = new List<string>();
-        gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), GPTCallType.CLASSIFYTEXT).then(desc => (this.Document.$tags_chat as List<string>).push(desc));
-        this.Document._layout_showTags = true;
-        //or... then(desc => this.Document.$tags_chat = desc);
-    };
+        gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), GPTCallType.CLASSIFYTEXTMINIMAL).then(desc => {
+            (this.Document.$tags_chat as List<string>).push(desc);
+        });
+    } else {
+        this.Document.$tags_chat = new List<string>();
+        gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), GPTCallType.CLASSIFYTEXTFULL).then(desc => {
+            (this.Document.$tags_chat as List<string>).push(desc);
+        })};
+    // 4) make sure the UI will show tags
+    this.Document._layout_showTags = true;*/
+    
+};
 
     leafText = (node: Node) => {
         if (node.type === this.EditorView?.state.schema.nodes.dashField) {
diff --git a/src/client/views/nodes/scrapbook/AIPresetGenerator.ts b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts
new file mode 100644
index 000000000..1f159222b
--- /dev/null
+++ b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts
@@ -0,0 +1,31 @@
+import { ScrapbookItemConfig } from './ScrapbookPreset';
+import { GPTCallType, gptAPICall } from '../../../apis/gpt/GPT';
+
+// Represents the descriptor for each document
+export interface DocumentDescriptor {
+  type: string;
+  tags: string[];
+}
+
+// Main function to request AI-generated presets
+export async function requestAiGeneratedPreset(descriptors: DocumentDescriptor[]): Promise<ScrapbookItemConfig[]> {
+    const prompt = createPrompt(descriptors);
+    let aiResponse = await gptAPICall(prompt, GPTCallType.GENERATESCRAPBOOK);
+            // Strip out ```json and ``` if the model wrapped its answer in fences
+    aiResponse = aiResponse
+            .trim()
+            .replace(/^```(?:json)?\s*/, "")   // remove leading ``` or ```json
+            .replace(/\s*```$/, "");           // remove trailing ```
+    const parsedPreset = JSON.parse(aiResponse) as ScrapbookItemConfig[];
+    return parsedPreset;
+}
+
+// Helper to generate prompt text for AI
+function createPrompt(descriptors: DocumentDescriptor[]): string {
+    let prompt = "";
+    descriptors.forEach((desc, index) => {
+        prompt += `${index + 1}. Type: ${desc.type}, Tags: ${desc.tags.join(', ')}\n`;
+    });
+
+    return prompt;
+}
diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
index 391dcb83d..5dd02295c 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
+++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
@@ -24,20 +24,72 @@ import { ImageCast } from '../../../../fields/Types';
 import { SnappingManager } from '../../../util/SnappingManager';
 import { IReactionDisposer } from 'mobx';
 import { observer } from 'mobx-react';
-import { ImageField } from '../../../../fields/URLField';
 import { runInAction } from 'mobx';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faRedoAlt }   from '@fortawesome/free-solid-svg-icons';
 import { getPresetNames, createPreset } from './ScrapbookPresetRegistry';
 
 
-enum ScrapbookPresetType {
-    Classic = 'Classic',
-    Default = 'Default',
-    Collage = 'Collage',
-    Spotlight = 'Spotlight',
-}
+export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]): Doc[] {
+  const placeholders: Doc[] = [];
+
+  for (const cfg of configs) {
+    if (cfg.children && cfg.children.length) {
+      // --- nested container ---
+      const childDocs = cfg.children.map(child => {
+        const doc = Docs.Create.TextDocument("[placeholder] " + child.tag);
+        doc.accepts_docType = child.type;
+
+        const ph = new Doc();
+        ph.proto    = doc;
+        ph.original = doc;
+        ph.x        = child.x;
+        ph.y        = child.y;
+        if (child.width  != null) ph._width  = child.width;
+        if (child.height != null) ph._height = child.height;
+        return ph;
+      });
+
+      // wrap those children in a stacking container
+      const protoW = cfg.containerWidth ?? cfg.width;
+      const protoH = cfg.containerHeight ?? cfg.height;
+      const containerProto = Docs.Create.StackingDocument(
+        childDocs,
+        {
+          ...(protoW != null ? { _width:  protoW } : {}),
+          ...(protoH != null ? { _height: protoH } : {}),
+          title: cfg.tag
+        }
+      );
+
+      const ph = new Doc();
+      ph.proto    = containerProto;
+      ph.original = containerProto;
+      ph.x        = cfg.x;
+      ph.y        = cfg.y;
+      if (cfg.width  != null) ph._width  = cfg.width;
+      if (cfg.height != null) ph._height = cfg.height;
+      placeholders.push(ph);
+
+    } else {
+      // --- flat placeholder ---
+      const doc = Docs.Create.TextDocument("[placeholder] " + cfg.tag);
+      doc.accepts_docType = cfg.type;
+      doc.accepts_tagType = cfg.acceptTag ?? cfg.tag;
+
+      const ph = new Doc();
+      ph.proto    = doc;
+      ph.original = doc;
+      ph.x        = cfg.x;
+      ph.y        = cfg.y;
+      if (cfg.width  != null) ph._width  = cfg.width;
+      if (cfg.height != null) ph._height = cfg.height;
+      placeholders.push(ph);
+    }
+  }
 
+  return placeholders;
+}
 // Scrapbook view: a container that lays out its child items in a grid/template
 @observer
 export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
@@ -55,14 +107,22 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
     constructor(props: FieldViewProps) {
         super(props);
         makeObservable(this);
-    // whenever the preset changes, rebuild the layout
-    reaction(
+        const existingItems = DocListCast(this.dataDoc[this.fieldKey] as List<Doc>);
+    if (!existingItems || existingItems.length === 0) {
+      // Only wire up reaction/setTitle if it's truly a brand-new, empty Scrapbook
+      reaction(
         () => this.selectedPreset,
         presetName => this.initScrapbook(presetName),
         { fireImmediately: true }
-    );
-        this.createdDate = this.getFormattedDate();
-
+      );
+
+      this.createdDate = this.getFormattedDate();
+      this.setTitle();
+    } else {
+      // If items are already present, just preserve whatever was injected.
+      // We still want `createdDate` set so that the UI title bar can show it if needed.
+      this.createdDate = this.getFormattedDate();
+    }
         //this.configs = 
         //ScrapbookPreset.createPreset(presetType);
 
@@ -70,9 +130,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         if (!this.dataDoc[this.fieldKey]) {
             this.dataDoc[this.fieldKey] = new List<Doc>();
         }
-        this.createdDate = this.getFormattedDate();
         //this.initScrapbook(ScrapbookPresetType.Default);
-        this.setTitle();
         //this.setLayout(ScrapbookPreset.Spotlight);
     }
 
@@ -99,175 +157,11 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
       }
     
       // 2) build placeholders from the preset
+      const placeholders = buildPlaceholdersFromConfigs(configs);
 
-      const placeholders: Doc[] = [];
-    
-      for (const cfg of configs) {
-        if (cfg.children) {
-          // --- nested container ---
-          const childDocs = cfg.children.map(child => {
-            const doc = Docs.Create.TextDocument(child.tag);
-            doc.accepts_docType = child.type;
-            doc.accepts_tagType = child.acceptTag ?? child.tag;
-    
-            const ph = new Doc();
-            ph.proto    = doc;
-            ph.original = doc;
-            ph.x        = child.x;
-            ph.y        = child.y;
-            if (child.width  != null) ph._width  = child.width;
-            if (child.height != null) ph._height = child.height;
-            return ph;
-          });
-    
-          const protoW = cfg.containerWidth ?? cfg.width;
-          const protoH = cfg.containerHeight ?? cfg.height;
-          const containerProto = Docs.Create.StackingDocument(
-            childDocs,
-            {
-              ...(protoW != null ? { _width:  protoW } : {}),
-              ...(protoH != null ? { _height: protoH } : {}),
-              title: cfg.tag
-            }
-          );
-    
-          const ph = new Doc();
-          ph.proto    = containerProto;
-          ph.original = containerProto;
-          ph.x        = cfg.x;
-          ph.y        = cfg.y;
-          if (cfg.width  != null) ph._width  = cfg.width;
-          if (cfg.height != null) ph._height = cfg.height;
-          placeholders.push(ph);
-    
-        } else {
-          // --- flat placeholder ---
-          const doc = Docs.Create.TextDocument(cfg.tag);
-          doc.accepts_docType = cfg.type;
-          doc.accepts_tagType = cfg.acceptTag ?? cfg.tag;
-    
-          const ph = new Doc();
-          ph.proto    = doc;
-          ph.original = doc;
-          ph.x        = cfg.x;
-          ph.y        = cfg.y;
-          if (cfg.width  != null) ph._width  = cfg.width;
-          if (cfg.height != null) ph._height = cfg.height;
-          placeholders.push(ph);
-        }
-      }
-    
       // 3) commit them into the field
       this.dataDoc[this.fieldKey] = new List<Doc>(placeholders);
     }
-    @action
-    //INACTIVE VER ignore!! not in use rn, implementation ver 1
-    setLayout(preset: ScrapbookPreset) {
-        // helper to wrap a TextDocument proto in a Doc with positioning
-        function makePlaceholder(
-            proto: Doc, x: number, y: number,
-            width: number, height: number
-        ): Doc {
-            const d = new Doc();
-            d.proto = proto;
-            d.original = proto;
-            d.x = x;
-            d.y = y;
-            d._width = width;
-            d._height = height;
-            return d;
-        }
-    
-        let placeholders: Doc[];
-    
-        switch (preset) {
-            case ScrapbookPresetType.Classic:
-                // One large landscape image on top, caption below, sidebar at right
-                const imgClassic = Docs.Create.TextDocument('image');
-                imgClassic.accepts_docType = DocumentType.IMG;
-                imgClassic.accepts_tagType = 'LANDSCAPE';
-                const phImageClassic = makePlaceholder(imgClassic, 0, -120, 300, 180);
-    
-                const captionClassic = Docs.Create.TextDocument('caption');
-                captionClassic.accepts_docType = DocumentType.RTF;
-                captionClassic.accepts_tagType = 'caption';
-                const phCaptionClassic = makePlaceholder(captionClassic, 0, 80, 300, 60);
-    
-                const sidebarClassic = Docs.Create.TextDocument('sidebar');
-                sidebarClassic.accepts_docType = DocumentType.RTF;
-                sidebarClassic.accepts_tagType = 'lengthy description';
-                const phSidebarClassic = makePlaceholder(sidebarClassic, 320, -50, 80, 200);
-    
-                placeholders = [phImageClassic, phCaptionClassic, phSidebarClassic];
-                break;
-    
-            case ScrapbookPresetType.Collage:
-                // Grid of four person images, small captions under each
-                const personDocs: Doc[] = [];
-                for (let i = 0; i < 4; i++) {
-                    const img = Docs.Create.TextDocument(`person ${i+1}`);
-                    img.accepts_docType = DocumentType.IMG;
-                    img.accepts_tagType = 'PERSON';
-                    // position in 2x2 grid
-                    const x = (i % 2) * 160 - 80;
-                    const y = Math.floor(i / 2) * 160 - 80;
-                    personDocs.push(makePlaceholder(img, x, y, 150, 120));
-    
-                    const cap = Docs.Create.TextDocument(`caption ${i+1}`);
-                    cap.accepts_docType = DocumentType.RTF;
-                    cap.accepts_tagType = 'caption';
-                    personDocs.push(makePlaceholder(cap, x, y + 70, 150, 30));
-                }
-                placeholders = personDocs;
-                break;
-    
-            case ScrapbookPresetType.Spotlight:
-                // Full-width title, then a stacking of an internal person image + landscape, then description
-                const titleSpot = Docs.Create.TextDocument('title');
-                titleSpot.accepts_docType = DocumentType.RTF;
-                titleSpot.accepts_tagType = 'title';
-                const phTitleSpot = makePlaceholder(titleSpot, 0, -180, 400, 60);
-    
-                const internalImg = Docs.Create.TextDocument('<person>');
-                internalImg.accepts_docType = DocumentType.IMG;
-                internalImg.accepts_tagType = 'PERSON';
-                const phInternal = makePlaceholder(internalImg, -100, -120, 120, 160);
-    
-                const landscapeImg = Docs.Create.TextDocument('<landscape>');
-                landscapeImg.accepts_docType = DocumentType.IMG;
-                landscapeImg.accepts_tagType = 'LANDSCAPE';
-                const phLandscape = makePlaceholder(landscapeImg, 50, 0, 200, 160);
-    
-                const stack = Docs.Create.StackingDocument(
-                    [phInternal, phLandscape],
-                    { _width: 360, _height: 180, title: 'spotlight stack' }
-                );
-                const phStack = (() => {
-                    const d = new Doc();
-                    d.proto = stack;
-                    d.original = stack;
-                    d.x = 8;
-                    d.y = -84;
-                    d._width = 360;
-                    d._height = 180;
-                    return d;
-                })();
-    
-                const descSpot = Docs.Create.TextDocument('description');
-                descSpot.accepts_docType = DocumentType.RTF;
-                descSpot.accepts_tagType = 'lengthy description';
-                const phDescSpot = makePlaceholder(descSpot, 0, 140, 400, 100);
-    
-                placeholders = [phTitleSpot, phStack, phDescSpot];
-                break;
-    
-            default:
-                placeholders = [];
-        }
-    
-        // finally assign into the dataDoc
-        this.dataDoc[this.fieldKey] = new List<Doc>(placeholders);
-    }
 
     
 
@@ -276,8 +170,8 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         const title = `Scrapbook - ${this.createdDate}`;
         if (this.dataDoc.title !== title) {
             this.dataDoc.title = title;
-
-            const image = Docs.Create.TextDocument('person image');
+            if (!this.dataDoc[this.fieldKey]){
+            const image = Docs.Create.TextDocument('[placeholder] person image');
             image.accepts_docType = DocumentType.IMG;
             image.accepts_tagType = 'PERSON' //should i be writing fields on this doc? clarify diff between this and proto, original
             const placeholder = new Doc();
@@ -289,7 +183,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             placeholder.y = -100;
             //placeholder.overrideFields = new List<string>(['x', 'y']);  // shouldn't need to do this for layout fields since the placeholder already overrides its protos
             
-            const summary = Docs.Create.TextDocument('long summary');
+            const summary = Docs.Create.TextDocument('[placeholder] long summary');
             summary.accepts_docType = DocumentType.RTF;
             summary.accepts_tagType = 'lengthy description';
             //summary.$tags_chat = new List<string>(['lengthy description']); //we need to go back and set this
@@ -301,7 +195,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
             placeholder2._width = 250;
             //placeholder2.overrideFields = new List<string>(['x', 'y', '_width']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos
             
-            const sidebar = Docs.Create.TextDocument('brief sidebar');
+            const sidebar = Docs.Create.TextDocument('[placeholder] brief sidebar');
             sidebar.accepts_docType = DocumentType.RTF;
             sidebar.accepts_tagType = 'title'; //accepts_textType = 'lengthy description'
             const placeholder3 = new Doc();
@@ -314,7 +208,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
 
           
 
-            const internalImg = Docs.Create.TextDocument('landscape internal');
+            const internalImg = Docs.Create.TextDocument('[placeholder] landscape internal');
             internalImg.accepts_docType = DocumentType.IMG;
             internalImg.accepts_tagType = 'LANDSCAPE' //should i be writing fields on this doc? clarify diff between this and proto, original
             const placeholder5 = new Doc();
@@ -350,7 +244,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
            difference between passing a new List<Doc> versus just the raw array?
            */
            this.dataDoc[this.fieldKey] = new List<Doc>([placeholder, placeholder2, placeholder3, placeholder4]);
-
+            }
           //this.dataDoc[this.fieldKey] = this.dataDoc[this.fieldKey] ?? new List<Doc>([placeholder, placeholder2, placeholder3, placeholder4]);
 
           
diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
index fc69552c0..87821c7bf 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
+++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
@@ -47,22 +47,22 @@ export class ScrapbookPreset {
     private static createClassicPreset(): ScrapbookItemConfig[] {
         return [
             { type: DocumentType.IMG,
-              tag: 'LANDSCAPE',
+              tag: '[placeholder] LANDSCAPE',
               acceptTag: 'LANDSCAPE',
               x: 0, y: -100, width: 250, height: 200
             },
             { type: DocumentType.RTF,
-              tag: 'caption',
+              tag: '[placeholder] caption',
               acceptTag: 'caption',
               x: 0, y: 200, width: 250, height: 50
             },
             { type: DocumentType.RTF,
-              tag: 'lengthy description',
+              tag: '[placeholder] lengthy description',
               acceptTag: 'lengthy description',
               x: 280, y: -50, width: 50, height: 200
             },
             { type: DocumentType.IMG,
-              tag: 'PERSON',
+              tag: '[placeholder] PERSON',
               acceptTag: 'PERSON',
               x: -200, y: -100, width: 100, height: 200
             },
diff --git a/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts
index f7ddd70ab..d6fd3620c 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts
+++ b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts
@@ -6,6 +6,9 @@ type PresetGenerator = () => ScrapbookItemConfig[];
 // Internal map of preset name to generator
 const presetRegistry = new Map<string, PresetGenerator>();
 
+
+
+
 /**
  * Register a new scrapbook preset under the given name.
  */
-- 
cgit v1.2.3-70-g09d2


From 42b35b687f081e579cbec524426105df3ac695ef Mon Sep 17 00:00:00 2001
From: sharkiecodes <lanyi_stroud@brown.edu>
Date: Thu, 5 Jun 2025 22:48:33 -0400
Subject: attempting marqueeview gen of multiple scrapbooks

---
 src/client/apis/gpt/GPT.ts                         |  15 +-
 .../collectionFreeForm/MarqueeView.scss            |   2 +-
 .../collections/collectionFreeForm/MarqueeView.tsx | 191 +++++++++++++++++++--
 src/client/views/nodes/ImageBox.tsx                |   6 +
 src/client/views/nodes/VideoBox.tsx                |   3 +-
 src/client/views/nodes/scrapbook/ScrapbookBox.tsx  | 165 +++++++++---------
 .../views/nodes/scrapbook/ScrapbookPicker.scss     |  40 +++++
 .../views/nodes/scrapbook/ScrapbookPicker.tsx      |  84 +++++++++
 .../views/nodes/scrapbook/ScrapbookPreset.tsx      |  44 ++---
 .../views/nodes/scrapbook/scrapbookleftover.ts     |  46 +++++
 10 files changed, 474 insertions(+), 122 deletions(-)
 create mode 100644 src/client/views/nodes/scrapbook/ScrapbookPicker.scss
 create mode 100644 src/client/views/nodes/scrapbook/ScrapbookPicker.tsx
 create mode 100644 src/client/views/nodes/scrapbook/scrapbookleftover.ts

(limited to 'src/client/views/nodes/VideoBox.tsx')

diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 03fce21f7..4642d79eb 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -14,7 +14,7 @@ export const DocSeperator = '------';
 export enum TextClassifications {
     Title = 'title', //a few words 
     Caption = 'caption', //few sentences
-    LengthyDescription = 'lengthy description' }
+    LengthyDescription = 'lengthy' }
 
 enum GPTCallType {
     SUMMARY = 'summary',
@@ -106,8 +106,10 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
         model: 'gpt-4o',
         maxTokens: 2048,
         temp: 0.25,
-        prompt: `Based on the content of the text, provide six descriptive tags (single words) separated by spaces. 
-            Finally, include a seventh more detailed summary phrase using underscores.`
+        prompt: `Based on the content of the the text, classify it into the  
+                most appropriate category: '${TextClassifications.Title}', '${TextClassifications.Caption}', or '${TextClassifications.LengthyDescription}'. 
+               Then provide five more descriptive tags (single words) separated by spaces. 
+            Finally, include a more detailed summary phrase tag using underscores, for a total of seven tags.`
     },
     describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' },
     flashcard: {
@@ -175,14 +177,15 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = {
          prompt: `Generate an aesthetically pleasing scrapbook layout preset based on these items.
     Return your response as JSON in the format:
     [{
-        "type": DocumentType.RTF or DocumentType.IMG or DocumentType.PDF
+        "type": rich text or image or pdf or video or collection
         "tag": a singular tag summarizing the document
+        "acceptTags": [a list of all relevant tags that this document accepts, like ['PERSON', 'LANDSCAPE']]
         "x": number,
         "y": number,
-        "width": number,
+        "width": number, **note: if it is in an image, please respect existing aspect ratio if it is provided
         "height": number
     }, ...]
-    If there are mutliple documents, you may include 
+    If there are mutliple documents and you wish to nest documents into a collection for aesthetic purposes, you may include 
     "children": [
                     { type: 
                       tag: 
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.scss b/src/client/views/collections/collectionFreeForm/MarqueeView.scss
index 7c9d0f6e1..b514b0911 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.scss
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.scss
@@ -28,4 +28,4 @@
     .marquee-legend::after {
         content: 'Press <space> for lasso';
     }
-}
+}
\ No newline at end of file
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 0b91d628b..05d4cd81d 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -22,6 +22,7 @@ import { ObservableReactComponent } from '../../ObservableReactComponent';
 import { MarqueeViewBounds } from '../../PinFuncs';
 import { PreviewCursor } from '../../PreviewCursor';
 import { DocumentView } from '../../nodes/DocumentView';
+import { OverlayDisposer } from '../../OverlayView';
 import { OpenWhere } from '../../nodes/OpenWhere';
 import { pasteImageBitmap } from '../../nodes/WebBoxRenderer';
 import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
@@ -30,8 +31,15 @@ import { ImageLabelBoxData } from './ImageLabelBox';
 import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
 import { StrListCast } from '../../../../fields/Doc';
 import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapbook/AIPresetGenerator';
+import { ScrapbookItemConfig } from '../../nodes/scrapbook/ScrapbookPreset';
+import { OverlayView } from '../../OverlayView';
+import { runInAction } from 'mobx';
+import { ScrapbookPicker } from '../../nodes/scrapbook/ScrapbookPicker';
+import { buildPlaceholdersFromConfigs, slotRealDocIntoPlaceholders } from '../../nodes/scrapbook/ScrapbookBox';
 import './MarqueeView.scss';
 
+import { build } from 'xregexp';
+
 interface MarqueeViewProps {
     Doc: Doc;
     getContainerTransform: () => Transform;
@@ -78,6 +86,16 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     @observable _labelsVisibile: boolean = false;
     @observable _lassoPts: [number, number][] = [];
     @observable _lassoFreehand: boolean = false;
+      // ─── New Observables for “Pick 1 of N AI Scrapbook” ───
+    @observable aiChoices: Doc[] = [];      // temporary hidden Scrapbook docs
+    @observable pickerX = 0;                // popup x coordinate
+    @observable pickerY = 0;                // popup y coordinate
+    @observable pickerVisible = false;      // show/hide ScrapbookPicker
+    // This will hold the “disposer” function returned by addWindow()
+    private _overlayDisposer: OverlayDisposer | null = null;
+
+
+    
 
     @computed get Transform() {
         return this._props.getTransform();
@@ -277,7 +295,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
             MarqueeOptionsMenu.Instance.createCollection = this.collection;
             MarqueeOptionsMenu.Instance.delete = this.delete;
             MarqueeOptionsMenu.Instance.summarize = this.summary;
-            MarqueeOptionsMenu.Instance.generateScrapbook = this.generateScrapbook;
+            MarqueeOptionsMenu.Instance.generateScrapbook = this.generateAiScrapbooks;
             MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee;
             MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee;
             MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY);
@@ -521,6 +539,17 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     });
 
 
+    getAiPresetsDescriptors(): DocumentDescriptor[] {
+    const selected = this.marqueeSelect(false);
+    return selected.map((doc) => ({
+      type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN',
+      tags: (() => {
+        const s = new Set<string>();
+        StrListCast(doc.$tags_chat ?? new List<string>()).forEach((t) => s.add(t));
+        return Array.from(s);
+      })(),
+    }));
+  }
    
     
     generateScrapbook = action(async () => {
@@ -528,28 +557,23 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
         const selectedDocs = this.marqueeSelect(false);
         if (!selectedDocs.length) return;
 
-       const descriptors: DocumentDescriptor[] = selectedDocs.map(doc => ({
-            type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN',
-            tags: (() => {
-                const internalTagsSet = new Set<string>();
-                StrListCast(doc.$tags_chat ?? new List<string>()).forEach(tag => {
-                internalTagsSet.add(tag);
-                });
-                return Array.from(internalTagsSet);
-            })()
-            }));
-
+    const descriptors = this.getAiPresetsDescriptors();
+    if (descriptors.length === 0) {
+      alert('No documents selected to generate a scrapbook from!');
+      return;
+    }
 
         const aiPreset = await requestAiGeneratedPreset(descriptors);
         if (!aiPreset.length) {
             alert("Failed to generate preset");
             return;
         }
-
+        const scrapbookPlaceholders: Doc[] = buildPlaceholdersFromConfigs(aiPreset);
+        /*
         const scrapbookPlaceholders: Doc[] = aiPreset.map(cfg => {
             const placeholderDoc = Docs.Create.TextDocument(cfg.tag);
             placeholderDoc.accepts_docType = cfg.type as DocumentType;
-            placeholderDoc.accepts_tagType = cfg.acceptTag ?? cfg.tag;
+            placeholderDoc.accepts_tagType = new List<string>(cfg.acceptTags ?? [cfg.tag]);
 
             const placeholder = new Doc();
             placeholder.proto = placeholderDoc;
@@ -560,7 +584,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
             if (cfg.height != null) placeholder._height = cfg.height;
 
             return placeholder;
-        });
+        });*/
 
         const scrapbook = Docs.Create.ScrapbookDocument(scrapbookPlaceholders, {
             backgroundColor: '#e2ad32',
@@ -570,6 +594,25 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
             _height: 500,
             title: 'AI-generated Scrapbook'
         });
+
+
+
+        // 3) Now grab that new scrapbook’s flat placeholders
+        const flatPl = DocListCast(scrapbook[Doc.LayoutDataKey(scrapbook)]) as Doc[];
+        const unwrap = (items: Doc[]): Doc[] =>
+            items.flatMap(d =>
+            d.$type === DocumentType.COL
+                ? unwrap(DocListCast(d[Doc.LayoutDataKey(d)]))
+                : [d]
+            );
+        const allPlaceholders = unwrap(flatPl);
+
+        // 4) Slot each selectedDocs[i] into the first matching placeholder
+        selectedDocs.forEach(realDoc => {
+            slotRealDocIntoPlaceholders(realDoc, allPlaceholders
+            );
+        });
+
         const selected = this.marqueeSelect(false).map(d => {
             this._props.removeDocument?.(d);
             d.x = NumCast(d.x) - this.Bounds.left;
@@ -589,6 +632,102 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     });
 
 
+  
+
+  /** Called when the user clicks one of the N thumbnails */
+  @action
+  onScrapbookChoice(idx: number) {
+    const chosenDoc = this.aiChoices[idx];
+    if (!chosenDoc) return;
+
+    // 1) Move chosenDoc from off‐screen to the marquee area
+    const bounds = this.Bounds;
+    chosenDoc.x = bounds.left;
+    chosenDoc.y = bounds.top;
+    chosenDoc._width = NumCast(bounds.width || chosenDoc._width);
+    chosenDoc._height = NumCast(bounds.height || chosenDoc._height);
+    chosenDoc.$title = 'AI‐chosen scrapbook';
+
+    // 2) Remove the other temp docs
+    this.aiChoices.forEach((doc, i) => {
+      if (i !== idx) {
+        this.props.removeDocument?.(doc);
+      }
+    });
+
+    // 3) Clear state and close the popup
+    this.aiChoices = [];
+    this.pickerVisible = false;
+  }
+
+  /** Called when user clicks outside or the “×” in ScrapbookPicker */
+  @action
+  cancelScrapbookChoice() {
+    // 1) Remove all temp scrapbooks
+    this.aiChoices.forEach((doc) => {
+      this.props.removeDocument?.(doc);
+    });
+
+    // 2) Clear array and hide popup
+    this.aiChoices = [];
+    this.pickerVisible = false;
+  }
+  @action
+  generateAiScrapbooks = async () => {
+    const n = 3; // Number of AI scrapbook presets
+    const descriptors = this.getAiPresetsDescriptors();
+    if (descriptors.length === 0) {
+      alert('No documents selected to generate a scrapbook from!');
+      return;
+    }
+
+    // 1) Start N parallel requests for JSON configs
+    const calls: Promise<ScrapbookItemConfig[]>[] = [];
+    for (let i = 0; i < n; i++) {
+      calls.push(requestAiGeneratedPreset(descriptors));
+    }
+
+    // 2) Optionally show a “spinner” overlay in‐line here if you like…
+    //    (But for brevity, let’s omit that.)
+
+    try {
+      const allConfigsArrays = await Promise.all(calls);
+
+      runInAction(() => {
+        // 3) For each returned config array, build placeholders and create _hidden_ Scrapbook doc
+        this.aiChoices = allConfigsArrays.map((cfgArr, i) => {
+          const placeholders = buildPlaceholdersFromConfigs(cfgArr);
+          // Off‐screen ScrapbookDocument:
+          const tempSb: Doc = Docs.Create.ScrapbookDocument(placeholders, {
+            title: `AI Preset (temp ${i + 1})`,
+            x: -10000, // far off screen
+            y: -10000,
+            _width: 600,
+            _height: 600,
+            backgroundColor: 'transparent',
+            _layout_showFlash: false, // keep it hidden until chosen
+          });
+          this.props.addDocument?.(tempSb);
+          tempSb.$tags_chat = new List<string>(['@ai_preset']);
+          return tempSb;
+        });
+
+        // 4) Compute where to show the popup in screen coords
+        const bounds = this.Bounds;
+        const screenTopLeft = this._props
+          .getContainerTransform()
+          .transformPoint(bounds.left, bounds.top);
+        this.pickerX = screenTopLeft[0] + 20;
+        this.pickerY = screenTopLeft[1] + 20;
+        this.pickerVisible = true;
+      });
+    } catch (err) {
+      console.error('Error generating AI scrapbooks:', err);
+      alert('Failed to generate presets. Please try again.');
+    }
+  };
+
+
     @action
     marqueeCommand = (e: KeyboardEvent) => {
         const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean };
@@ -755,6 +894,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
     };
     render() {
         return (
+            <>
             <div
                 className="marqueeView"
                 ref={r => {
@@ -774,6 +914,27 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
                 {this._visible ? this.marqueeDiv : null}
                 {this.props.children}
             </div>
+            {this.pickerVisible && (
+  <div
+    className="marqueeView-scrapbook-overlay"
+    style={{
+      position: 'absolute',
+      top: this.pickerY,
+      left: this.pickerX,
+      zIndex: 2000,         // just ensure it floats above
+    }}
+  >
+    <ScrapbookPicker
+      choices={this.aiChoices}
+      x={this.pickerX}
+      y={this.pickerY}
+      onSelect={(i) => this.onScrapbookChoice(i)}
+      onCancel={() => this.cancelScrapbookChoice()}
+    />
+  </div>
+)}
+        </>
+    
         );
     }
 }
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 9067f7e0c..2473f1c0a 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -141,6 +141,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         that describes the image.`
       );
 
+    const { nativeWidth, nativeHeight } = this.nativeSize;
+    const aspectRatio = nativeWidth && nativeHeight
+      ? (nativeWidth / nativeHeight).toFixed(2)
+      : '1.00';
+
       // 4) normalize and prefix
       const label = raw
         .trim()
@@ -152,6 +157,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
       this.Document.$tags_chat = new List<string>();
       tokens.forEach(tok => {
         (this.Document.$tags_chat as List<string>).push(tok)});
+        (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspectRatio}`);  
       //!!! changed may 11 (this.Document.$tags_chat as List<string>).push(label);
 
       // 6) flip on “show tags” in the layout 
diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index 0e7afbab1..404be6a1b 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -151,7 +151,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         const tokens = label.split(/\s+/);
         this.Document.$tags_chat = new List<string>();
         tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok));
-
+        const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1);
+        (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspect}`);  
         // 5) Turn on tag display in layout
         this.Document._layout_showTags = true;
 
diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
index 5dd02295c..eb997024b 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
+++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx
@@ -3,7 +3,6 @@ import * as React from 'react';
 import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc';
 import { List } from '../../../../fields/List';
 import { emptyFunction } from '../../../../Utils';
-import axios from 'axios';
 import { Docs } from '../../../documents/Documents';
 import { DocumentType } from '../../../documents/DocumentTypes';
 import { CollectionView } from '../../collections/CollectionView';
@@ -35,54 +34,51 @@ export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]): Do
 
   for (const cfg of configs) {
     if (cfg.children && cfg.children.length) {
-      // --- nested container ---
       const childDocs = cfg.children.map(child => {
         const doc = Docs.Create.TextDocument("[placeholder] " + child.tag);
         doc.accepts_docType = child.type;
+        doc.accepts_tagType = new List<string>(child.acceptTags ?? [child.tag]);
 
         const ph = new Doc();
-        ph.proto    = doc;
+        ph.proto = doc;
         ph.original = doc;
-        ph.x        = child.x;
-        ph.y        = child.y;
-        if (child.width  != null) ph._width  = child.width;
+        ph.x = child.x;
+        ph.y = child.y;
+        if (child.width != null) ph._width = child.width;
         if (child.height != null) ph._height = child.height;
         return ph;
       });
 
-      // wrap those children in a stacking container
       const protoW = cfg.containerWidth ?? cfg.width;
       const protoH = cfg.containerHeight ?? cfg.height;
-      const containerProto = Docs.Create.StackingDocument(
-        childDocs,
-        {
-          ...(protoW != null ? { _width:  protoW } : {}),
-          ...(protoH != null ? { _height: protoH } : {}),
-          title: cfg.tag
-        }
-      );
+      // Create a stacking document with the child placeholders
+      const containerProto = Docs.Create.StackingDocument(childDocs, {
+        ...(protoW != null ? { _width: protoW } : {}),
+        ...(protoH != null ? { _height: protoH } : {}),
+        title: cfg.tag,
+      });
 
       const ph = new Doc();
-      ph.proto    = containerProto;
+      ph.proto = containerProto;
       ph.original = containerProto;
-      ph.x        = cfg.x;
-      ph.y        = cfg.y;
-      if (cfg.width  != null) ph._width  = cfg.width;
+      ph.x = cfg.x;
+      ph.y = cfg.y;
+      if (cfg.width != null) ph._width = cfg.width;
       if (cfg.height != null) ph._height = cfg.height;
       placeholders.push(ph);
-
-    } else {
-      // --- flat placeholder ---
+    } 
+    
+    else {
       const doc = Docs.Create.TextDocument("[placeholder] " + cfg.tag);
       doc.accepts_docType = cfg.type;
-      doc.accepts_tagType = cfg.acceptTag ?? cfg.tag;
+      doc.accepts_tagType = new List<string>(cfg.acceptTags ?? [cfg.tag]);
 
       const ph = new Doc();
-      ph.proto    = doc;
+      ph.proto = doc;
       ph.original = doc;
-      ph.x        = cfg.x;
-      ph.y        = cfg.y;
-      if (cfg.width  != null) ph._width  = cfg.width;
+      ph.x = cfg.x;
+      ph.y = cfg.y;
+      if (cfg.width != null) ph._width = cfg.width;
       if (cfg.height != null) ph._height = cfg.height;
       placeholders.push(ph);
     }
@@ -90,7 +86,54 @@ export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]): Do
 
   return placeholders;
 }
-// Scrapbook view: a container that lays out its child items in a grid/template
+export function slotRealDocIntoPlaceholders(
+  realDoc: Doc,
+  placeholders: Doc[]
+): boolean {
+ const realTags = new Set<string>(
+  StrListCast(realDoc.$tags_chat ?? new List<string>())
+    .map(t => t.toLowerCase())
+);
+
+  // Find placeholder with most matching tags
+  let bestMatch: Doc | null = null;
+  let maxMatches = 0;
+/*
+                 (d.accepts_docType === docs[0].$type ||   // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type
+                  RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type)))*/
+
+  placeholders.forEach(ph => {
+    // 1) Enforce that placeholder.accepts_docType === realDoc.$type
+    if (ph.accepts_docType !== realDoc.$type) {
+      // Skip this placeholder entirely if types do not match.
+      return;
+    };
+    const phTagTypes = StrListCast(ph.accepts_tagType ?? new List<string>())
+  .map(t => t.toLowerCase());
+    console.log({ realTags, phTagTypes });
+    const matches = phTagTypes.filter(tag => realTags.has(tag));
+
+    if (matches.length > maxMatches) {
+      maxMatches = matches.length;
+      bestMatch = ph;
+    }
+
+  });
+
+  if (bestMatch && maxMatches > 0) {
+    setTimeout(
+      undoable(() => {
+        bestMatch!.proto = realDoc;
+      }, 'Scrapbook add'),
+      0
+    );
+    return true;
+  }
+
+  return false;
+}
+
+// Scrapbook view: a container that lays out its child items in a template
 @observer
 export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
     @observable selectedPreset = getPresetNames()[0];
@@ -406,56 +449,19 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
 
         // 3) produce a flat list of every doc, unwrapping any number of nested COLs
         const allDocs: Doc[] = unwrap(slots);
-     
+          if (docs?.length === 1) {
+        return slotRealDocIntoPlaceholders(
+            docs[0],
+            allDocs,
+        )
+            ? false
+            : false;
+    }
 
-        if (docs?.length === 1) {
-            const placeholder = allDocs.filter(d =>
+    return false;
+};
+     
 
-                 (d.accepts_docType === docs[0].$type ||   // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type
-                  RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type)))
-            ); // prettier-ignore
-
-            //DocListCast(this.Document.items).map(doc => DocListCast(doc[Doc.LayoutDataKey(doc)])
-
-            if (placeholder) {
-                /**Look at the autotags and see what matches*RTFCast(d[Doc.LayoutDataKey(d)])?.Text*/
-                // ugh.  we have to tell the underlying view not to add the Doc so that we can add it where we want it.
-                // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo.
-                setTimeout(
-                    undoable(() => {
-
-                        const slotTagsList: Set<string>[] = placeholder.map(doc =>
-                            new Set<string>(StrListCast(doc.$tags_chat))
-                          );
-                          // turn docs[0].$tags_chat into a Set
-                        const targetTags = new Set(StrListCast(docs[0].$tags_chat));
-
-                        //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field])); // // shouldn't need to do this for layout fields since the placeholder already overrides its protos
-                        
-                            // find the first placeholder that shares *any* tag
-                        const match = placeholder.find(ph =>
-                            ph.accepts_tagType != null &&          // make sure it actually has one
-                            targetTags.has(StrCast(ph.accepts_tagType))     // test membership in the Set
-                            //StrListCast(ph.$tags_chat).some(tag => targetTags.has(tag))
-                        );
-                        if (match) {
-                            match.proto = docs[0];
-                          }
-
-                        /*const chosenPlaceholder = placeholder.find(d => 
-                            pl = new Set<string>(StrListCast(d.$tags_chat)
-                            
-                            d.$tags_chat && d.$tags_chat[0].equals(docs[0].$tags_chat)); //why [0]
-                        if (chosenPlaceholder){
-                            chosenPlaceholder.proto = docs[0];}*/
-                         //excess if statement??   
-                    }, 'Scrapbook add')
-                );
-                return false;
-            }
-        }
-        return false;
-    };
 
 
 
@@ -474,10 +480,15 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
         
         allDocs.forEach(doc => {
             const tags = StrListCast(doc.$tags_chat ?? new List<string>());
-            tags.forEach(tag => internalTagsSet.add(tag));
+            tags.forEach(tag => 
+                 {if (!tag.startsWith("ASPECT_")) {
+            internalTagsSet.add(tag);
+            }
+    });
         });
     
         const internalTags = Array.from(internalTagsSet).join(', ');
+      
     
         return internalTags
             ? `Create a new scrapbook background featuring: ${internalTags}`
diff --git a/src/client/views/nodes/scrapbook/ScrapbookPicker.scss b/src/client/views/nodes/scrapbook/ScrapbookPicker.scss
new file mode 100644
index 000000000..237274433
--- /dev/null
+++ b/src/client/views/nodes/scrapbook/ScrapbookPicker.scss
@@ -0,0 +1,40 @@
+/* ScrapbookPicker.scss */
+
+.scrapbook-picker-popup {
+    background: rgba(255, 0, 0, 0.5);  /* semi-transparent red */
+  position: absolute;       /* ← make it float */
+  z-index: 10000;           /* so it sits above the overlay‐window’s background */
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+  padding: 8px;
+  min-width: 200px;         /* at least give it some size */
+}
+
+.scrapbook-picker-close {
+  position: absolute;
+  top: 4px;
+  right: 8px;
+  cursor: pointer;
+  font-size: 14px;
+}
+
+.scrapbook-picker-thumbnails {
+  margin-top: 24px;   /* room under the close button */
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.scrapbook-picker-thumb {
+  cursor: pointer;
+  border: 1px solid #ddd;
+  border-radius: 2px;
+  padding: 4px;
+  background: #f9f9f9;
+}
+
+.scrapbook-picker-thumb-inner {
+  font-size: 12px;
+  text-align: center;
+}
diff --git a/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx b/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx
new file mode 100644
index 000000000..6054cb98d
--- /dev/null
+++ b/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx
@@ -0,0 +1,84 @@
+// src/client/views/nodes/scrapbook/ScrapbookPicker.tsx
+import * as React from 'react';
+import { observer } from 'mobx-react';
+import { Doc } from '../../../../fields/Doc';
+import { Id } from '../../../../fields/FieldSymbols';
+import { StrCast } from '../../../../fields/Types';
+import './ScrapbookPicker.scss';
+
+export interface ScrapbookPickerProps {
+  choices: Doc[];
+  x: number;
+  y: number;
+  onSelect: (index: number) => void;
+  onCancel: () => void;
+}
+
+/**
+ * A floating popup that shows N “temporary” Scrapbook documents.  
+ * When the user clicks one thumbnail, we call onSelect(i).  
+ * When the user clicks × or outside, we call onCancel(). 
+ *
+ * This component itself does not control its own visibility; MarqueeView / OverlayView will mount/unmount it.
+ */
+@observer
+export class ScrapbookPicker extends React.Component<ScrapbookPickerProps> {
+  containerRef = React.createRef<HTMLDivElement>();
+
+  // Close when user clicks outside the popup
+  handleClickOutside = (e: MouseEvent) => {
+    if (
+      this.containerRef.current &&
+      !this.containerRef.current.contains(e.target as Node)
+    ) {
+      this.props.onCancel();
+    }
+  };
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside);
+  }
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside);
+  }
+
+  render() {
+    const { choices, x, y, onSelect, onCancel } = this.props;
+    return (
+      <div
+        className="scrapbook-picker-popup"
+        ref={this.containerRef}
+        style={{
+          top: `${y}px`,
+          left: `${x}px`,
+        }}
+      >
+        {/* close icon */}
+        <div className="scrapbook-picker-close" onClick={onCancel}>
+          ×
+        </div>
+        <div className="scrapbook-picker-thumbnails">
+          {choices.map((doc, i) => {
+            // We simply show a small thumbnail representation of each temp scrapbook
+            // You could replace this with DocumentThumbnail or a custom mini‐preview.
+            return (
+              <div
+                key={`${doc[Id]}_${i}`}
+                className="scrapbook-picker-thumb"
+                onClick={() => onSelect(i)}
+              >
+                {/* 
+                  For a minimal example, use the document’s title or ID as a placeholder.
+                  In a real version, you might render a proper thumbnail/view of doc.
+                */}
+                <div className="scrapbook-picker-thumb-inner">
+                  {StrCast(doc.title)}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
index 87821c7bf..96a8e9b5f 100644
--- a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
+++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx
@@ -13,7 +13,7 @@ export interface ScrapbookItemConfig {
     /** text shown in the placeholder bubble */
     tag: string;
     /** what this slot actually accepts (defaults to `tag`) */
-    acceptTag?: string;
+    acceptTags?: string[];
 
     x: number;
     y: number;
@@ -48,22 +48,22 @@ export class ScrapbookPreset {
         return [
             { type: DocumentType.IMG,
               tag: '[placeholder] LANDSCAPE',
-              acceptTag: 'LANDSCAPE',
+              acceptTags: ['LANDSCAPE'],
               x: 0, y: -100, width: 250, height: 200
             },
             { type: DocumentType.RTF,
               tag: '[placeholder] caption',
-              acceptTag: 'caption',
+              acceptTags: ['caption'],
               x: 0, y: 200, width: 250, height: 50
             },
             { type: DocumentType.RTF,
               tag: '[placeholder] lengthy description',
-              acceptTag: 'lengthy description',
+              acceptTags: ['lengthy description'],
               x: 280, y: -50, width: 50, height: 200
             },
             { type: DocumentType.IMG,
               tag: '[placeholder] PERSON',
-              acceptTag: 'PERSON',
+              acceptTags: ['PERSON'],
               x: -200, y: -100, width: 100, height: 200
             },
         ];
@@ -71,12 +71,12 @@ export class ScrapbookPreset {
 
     private static createGalleryPreset(): ScrapbookItemConfig[] {
         return [
-          { type: DocumentType.IMG, tag: 'Gallery 1', acceptTag: 'LANDSCAPE', x: -150, y: -150, width: 150, height: 150 },
-          { type: DocumentType.IMG, tag: 'Gallery 2', acceptTag: 'LANDSCAPE', x:   0, y: -150, width: 150, height: 150 },
-          { type: DocumentType.IMG, tag: 'Gallery 3', acceptTag: 'LANDSCAPE', x: 150, y: -150, width: 150, height: 150 },
-          { type: DocumentType.IMG, tag: 'Gallery 4', acceptTag: 'LANDSCAPE', x: -150, y:   0, width: 150, height: 150 },
-          { type: DocumentType.IMG, tag: 'Gallery 5', acceptTag: 'LANDSCAPE', x:   0, y:   0, width: 150, height: 150 },
-          { type: DocumentType.IMG, tag: 'Gallery 6', acceptTag: 'LANDSCAPE', x: 150, y:   0, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 1', acceptTags: ['LANDSCAPE'], x: -150, y: -150, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 2', acceptTags: ['LANDSCAPE'], x:   0, y: -150, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 3', acceptTags: ['LANDSCAPE'], x: 150, y: -150, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 4', acceptTags: ['LANDSCAPE'], x: -150, y:   0, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 5', acceptTags: ['LANDSCAPE'], x:   0, y:   0, width: 150, height: 150 },
+          { type: DocumentType.IMG, tag: 'Gallery 6', acceptTags: ['LANDSCAPE'], x: 150, y:   0, width: 150, height: 150 },
         ];
       }
       
@@ -85,17 +85,17 @@ export class ScrapbookPreset {
         return [
             { type: DocumentType.IMG,
               tag: 'image',
-              acceptTag: 'LANDSCAPE',
+              acceptTags: ['LANDSCAPE'],
               x: 0, y: -100, width: 250, height: 200
             },
             { type: DocumentType.RTF,
               tag: 'summary',
-              acceptTag: 'caption',
+              acceptTags: ['caption'],
               x: 0, y: 200, width: 250
             },
             { type: DocumentType.RTF,
               tag: 'sidebar',
-              acceptTag: 'lengthy description',
+              acceptTags: ['lengthy description'],
               x: 280, y: -50, width: 50, height: 200
             },
             {
@@ -106,7 +106,7 @@ export class ScrapbookPreset {
               children: [
                 { type: DocumentType.IMG,
                   tag: 'image internal',
-                  acceptTag: 'PERSON',
+                  acceptTags: ['PERSON'],
                   x: 0, y: 0, width: 50, height: 100
                 }
               ]
@@ -118,22 +118,22 @@ export class ScrapbookPreset {
         return [
             { type: DocumentType.IMG,
               tag: 'LANDSCAPE',
-              acceptTag: 'LANDSCAPE',
+              acceptTags: ['LANDSCAPE'],
               x: -150, y: -150, width: 150, height: 150
             },
             { type: DocumentType.IMG,
               tag: 'PERSON',
-              acceptTag: 'PERSON',
+              acceptTags: ['PERSON'],
               x: 0, y: -150, width: 150, height: 150
             },
             { type: DocumentType.RTF,
               tag: 'caption',
-              acceptTag: 'caption',
+              acceptTags: ['caption'],
               x: -150, y: 0, width: 300, height: 100
             },
             { type: DocumentType.RTF,
               tag: 'lengthy description',
-              acceptTag: 'lengthy description',
+              acceptTags: ['lengthy description'],
               x: 0, y: 100, width: 300, height: 100
             }
         ];
@@ -143,17 +143,17 @@ export class ScrapbookPreset {
         return [
             { type: DocumentType.RTF,
               tag: 'title',
-              acceptTag: 'title',
+              acceptTags: ['title'],
               x: 0, y: -180, width: 300, height: 40
             },
             { type: DocumentType.IMG,
               tag: 'LANDSCAPE',
-              acceptTag: 'LANDSCAPE',
+              acceptTags: ['LANDSCAPE'],
               x: 0, y: 0, width: 300, height: 200
             },
             { type: DocumentType.RTF,
               tag: 'caption',
-              acceptTag: 'caption',
+              acceptTags: ['caption'],
               x: 0, y: 230, width: 300, height: 50
             }
         ];
diff --git a/src/client/views/nodes/scrapbook/scrapbookleftover.ts b/src/client/views/nodes/scrapbook/scrapbookleftover.ts
new file mode 100644
index 000000000..2f381ab95
--- /dev/null
+++ b/src/client/views/nodes/scrapbook/scrapbookleftover.ts
@@ -0,0 +1,46 @@
+
+   
+
+
+            //DocListCast(this.Document.items).map(doc => DocListCast(doc[Doc.LayoutDataKey(doc)])
+
+            if (placeholder) {
+                /**Look at the autotags and see what matches*RTFCast(d[Doc.LayoutDataKey(d)])?.Text*/
+                // ugh.  we have to tell the underlying view not to add the Doc so that we can add it where we want it.
+                // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo.
+                setTimeout(
+                    undoable(() => {
+
+                        const slotTagsList: Set<string>[] = placeholder.map(doc =>
+                            new Set<string>(StrListCast(doc.$tags_chat))
+                          );
+                          // turn docs[0].$tags_chat into a Set
+                        const targetTags = new Set(StrListCast(docs[0].$tags_chat));
+
+                        //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field]));
+                        //  // // shouldn't need to do this for layout fields since the placeholder already overrides its protos
+                        
+                            // find the first placeholder that shares *any* tag
+                        const match = placeholder.find(ph =>
+                            ph.accepts_tagType != null &&          // make sure it actually has one
+                            targetTags.has(StrCast(ph.accepts_tagType))     // test membership in the Set
+                            //StrListCast(ph.$tags_chat).some(tag => targetTags.has(tag))
+                        );
+                        if (match) {
+                            match.proto = docs[0];
+                          }
+
+                        /*const chosenPlaceholder = placeholder.find(d => 
+                            pl = new Set<string>(StrListCast(d.$tags_chat)
+                            
+                            d.$tags_chat && d.$tags_chat[0].equals(docs[0].$tags_chat)); //why [0]
+                        if (chosenPlaceholder){
+                            chosenPlaceholder.proto = docs[0];}*/
+                         //excess if statement??   
+                    }, 'Scrapbook add')
+                );
+                return false;
+            }
+        }
+        return false;
+    };
\ No newline at end of file
-- 
cgit v1.2.3-70-g09d2


From 3df4e67c42e431603d60f00d0a91867df7d0e2a6 Mon Sep 17 00:00:00 2001
From: bobzel <zzzman@gmail.com>
Date: Wed, 11 Jun 2025 12:36:08 -0400
Subject: don't recompute tags_chat for PDF and videos.  Don't block within
 loop for getting pdf text.

---
 src/client/views/nodes/PDFBox.tsx   | 63 +++++++++++---------------
 src/client/views/nodes/VideoBox.tsx | 89 +++++++++++++++++--------------------
 2 files changed, 68 insertions(+), 84 deletions(-)

(limited to 'src/client/views/nodes/VideoBox.tsx')

diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx
index a0c7d8d22..5501f0a31 100644
--- a/src/client/views/nodes/PDFBox.tsx
+++ b/src/client/views/nodes/PDFBox.tsx
@@ -81,46 +81,35 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         }
     }
 
-     autoTag = async () => {
-     try {
-        if (!this._pdf) {
-            throw new Error('PDF not loaded');
-        }
-
-        // 1) Extract text from the first few pages (e.g., first 2 pages)
-        const maxPages = Math.min(2, this._pdf.numPages);
-        let textContent = '';
-        for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
-            const page = await this._pdf.getPage(pageNum);
-            const text = await page.getTextContent();
-            const pageText = text.items.map(item => ('str' in item ? item.str : '')).join(' ');
-            textContent += ` ${pageText}`;
-        }
-
-        if (!textContent.trim()) {
-            throw new Error('No text found in PDF');
-        }
-
-        // 2) Ask GPT to classify and provide descriptive tags
-        const raw = await gptAPICall(
-            `"${textContent.trim().slice(0, 2000)}"`, 
-            GPTCallType.CLASSIFYTEXTFULL
-        );
-
-        // 3) Normalize and store the labels
-        const label = raw.trim().toUpperCase();
+    autoTag = async () => {
+        if (!this.Document.$tags_chat && this._pdf) {
+            if (!this.dataDoc.text) {
+                // 1) Extract text from the first few pages (e.g., first 2 pages)
+                const maxPages = Math.min(2, this._pdf.numPages);
+                const promises: Promise<string>[] = [];
+                for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
+                    promises.push(
+                        this._pdf
+                            .getPage(pageNum)
+                            .then(page => page.getTextContent())
+                            .then(content => content.items.map(item => ('str' in item ? item.str : '')).join(' '))
+                    );
+                }
+                this.dataDoc.text = (await Promise.all(promises)).join(' ');
+            }
 
-        const tokens = label.split(/\s+/);
-        this.Document.$tags_chat = new List<string>();
-        tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok));
+            const text = StrCast(this.dataDoc.text).trim().slice(0, 2000);
+            if (text) {
+                // 2) Ask GPT to classify and provide descriptive tags, then normalize the results
+                const label = await gptAPICall(`"${text}"`, GPTCallType.CLASSIFYTEXTFULL).then(raw => raw.trim().toUpperCase());
 
-        // 4) Show tags in layout
-        this.Document._layout_showTags = true;
+                this.Document.$tags_chat = new List<string>(label.split(/\s+/));
 
-    } catch (err) {
-        console.error('PDF autoTag failed:', err);
-    }
-};
+                // 4) Show tags in layout
+                this.Document._layout_showTags = true;
+            }
+        }
+    };
 
     replaceCanvases = (oldDiv: HTMLElement, newDiv: HTMLElement) => {
         if (oldDiv.childNodes) {
diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index 4d85b4942..f994bdbb5 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -110,56 +110,51 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
         return this._videoRef;
     }
 
+    autoTag = async () => {
+        if (this.Document.$tags_chat) return;
+        try {
+            if (!this.player) throw new Error('Video element not available.');
+
+            // 1) Extract a frame at the video's midpoint
+            const videoDuration = this.player.duration;
+            const snapshotTime = videoDuration / 2;
+
+            // Seek the video element to the midpoint
+            await new Promise<void>(resolve => {
+                const onSeeked = () => {
+                    this.player!.removeEventListener('seeked', onSeeked);
+                    resolve();
+                };
+                this.player!.addEventListener('seeked', onSeeked);
+                this.player!.currentTime = snapshotTime;
+            });
 
-     autoTag = async () => {
-    try {
-        if (!this.player) throw new Error('Video element not available.');
-
-        // 1) Extract a frame at the video's midpoint
-        const videoDuration = this.player.duration;
-        const snapshotTime = videoDuration / 2;
-
-        // Seek the video element to the midpoint
-        await new Promise<void>((resolve, reject) => {
-            const onSeeked = () => {
-                this.player!.removeEventListener('seeked', onSeeked);
-                resolve();
-            };
-            this.player!.addEventListener('seeked', onSeeked);
-            this.player!.currentTime = snapshotTime;
-        });
-
-        // 2) Draw the frame onto a canvas and get a base64 representation
-        const canvas = document.createElement('canvas');
-        canvas.width = this.player.videoWidth;
-        canvas.height = this.player.videoHeight;
-        const ctx = canvas.getContext('2d');
-        if (!ctx) throw new Error('Failed to create canvas context.');
-        ctx.drawImage(this.player, 0, 0, canvas.width, canvas.height);
-        const base64Image = canvas.toDataURL('image/png');
-
-        // 3) Send the image data to GPT for classification and descriptive tags
-        const raw = await gptImageLabel(
-            base64Image,
-            `Classify this video frame as either a PERSON or LANDSCAPE.
+            // 2) Draw the frame onto a canvas and get a base64 representation
+            const canvas = document.createElement('canvas');
+            canvas.width = this.player.videoWidth;
+            canvas.height = this.player.videoHeight;
+            const ctx = canvas.getContext('2d');
+            if (!ctx) throw new Error('Failed to create canvas context.');
+            ctx.drawImage(this.player, 0, 0, canvas.width, canvas.height);
+            const base64Image = canvas.toDataURL('image/png');
+
+            // 3) Send the image data to GPT for classification and descriptive tags
+            const label = await gptImageLabel(
+                base64Image,
+                `Classify this video frame as either a PERSON or LANDSCAPE.
             Then provide five additional descriptive tags (single words) separated by spaces.
             Finally, add one detailed summary phrase using underscores.`
-        );
-
-        // 4) Normalize and store labels in the Document's tags
-        const label = raw.trim().toUpperCase();
-        const tokens = label.split(/\s+/);
-        this.Document.$tags_chat = new List<string>();
-        tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok));
-        const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1);
-        (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspect}`);  
-        // 5) Turn on tag display in layout
-        this.Document._layout_showTags = true;
-
-    } catch (err) {
-        console.error('Video autoTag failed:', err);
-    }
-};
+            ).then(raw => raw.trim().toUpperCase());
+
+            // 4) Normalize and store labels in the Document's tags
+            const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1);
+            this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspect}`]);
+            // 5) Turn on tag display in layout
+            this.Document._layout_showTags = true;
+        } catch (err) {
+            console.error('Video autoTag failed:', err);
+        }
+    };
 
     componentDidMount() {
         this.unmounting = false;
-- 
cgit v1.2.3-70-g09d2

