4
5// Import using namespace to see if API methods are attached
6import * as domtoimage from "https://esm.sh/dom-to-image-more";
7// Log the imported object on load to verify
8console.log("domtoimage library namespace:", domtoimage);
9
10// --- Texture Data ---
27const PERSPECTIVE = 600;
28const BASE_TEXTURE_URL = "https://www.transparenttextures.com";
29const IMAGE_WIDTH = 120;
30const GRID_GAP = "1rem";
31const UPLOADED_IMAGE_WIDTH = 300;
32
33// --- Holographic Image Component ---
34// (Keep HolographicImage component as is)
35interface HolographicImageProps {
36 baseSrc: string;
37 textureUrl: string;
45 forwardedRef?: React.Ref<HTMLDivElement>;
46}
47const HolographicImage = React.forwardRef<HTMLDivElement, HolographicImageProps>(({
48 baseSrc,
49 textureUrl,
123
124 const perspectiveWrapperClasses = `perspective-wrapper ${framed ? "framed" : ""} ${
125 isUploaded ? "uploaded-image-wrapper" : ""
126 } ${containerClassName}`.trim().replace(/\s+/g, " ");
127 const shaderContainerClasses = `shader`.trim().replace(/\s+/g, " ");
142 display: "block",
143 width: "100%",
144 height: isUploaded ? "auto" : `${IMAGE_WIDTH}px`,
145 objectFit: "cover",
146 }}
147 onError={(e) => {
148 (e.target as HTMLImageElement).src = `https://placehold.co/${width}x${
149 isUploaded ? width : IMAGE_WIDTH
150 }/333/eee?text=Error`;
151 (e.target as HTMLImageElement).alt = "Error loading image";
152 }}
153 />
159 style={{ display: "block" }}
160 onError={(e) => {
161 (e.target as HTMLImageElement).style.display = "none";
162 }}
163 />
164 </div>
165 </div>
166 {caption && <p className="holographic-image-caption">{caption}</p>}
167 </div>
168 );
172// (Keep gradientStyles as is)
173const gradientStyles = `
174.gradient-sparrow { background-image: linear-gradient( hsl(359, 70%, 50%), hsl(16, 70%, 55%), hsl(33, 70%, 60%), hsl(45, 70%, 65%), hsl(58, 70%, 70%), hsl(58, 70%, 75%), hsl(58, 70%, 80%), hsl(96, 70%, 75%), hsl(146, 70%, 70%), hsl(183, 70%, 65%), hsl(225, 70%, 60%), hsl(265, 70%, 55%), hsl(303, 70%, 50%) ); }
175.gradient-deer { background-image: linear-gradient( hsl(0, 0%, 10%) 30%, hsl(104, 30%, 80%) 45%, hsl(273, 25%, 30%) 65%, hsl(0, 0%, 5%) 70% ); }
176.gradient-tech { background-image: linear-gradient( 45deg, hsl(180, 100%, 50%), hsl(210, 100%, 60%), hsl(240, 80%, 60%), hsl(280, 80%, 55%), hsl(180, 100%, 50%) ); }
177.gradient-sunset { background-image: linear-gradient(to right, #ff7e5f, #feb47b); }
178.gradient-ocean { background-image: linear-gradient(to right, #00c6ff, #0072ff); }
179.gradient-purple { background-image: linear-gradient(to right, #da22ff, #9733ee); }
180`;
181
182// --- Main App Component ---
183function App() {
184 const [uploadedImage, setUploadedImage] = useState<string | null>(null);
185 const [selectedTextureUrl, setSelectedTextureUrl] = useState<string>(
186 `${BASE_TEXTURE_URL}${patternsData[0].downloadUrl}`,
189 const [isRecording, setIsRecording] = useState(false);
190 const [recordProgress, setRecordProgress] = useState(0);
191 const uploadedImageRef = useRef<HTMLDivElement>(null);
192
193 // (Keep baseImages, gradientClasses, patternsToDisplay, handleFileChange as is)
194 const baseImages = [
195 "https://maxm-imggenurl.web.val.run/a-futuristic-cyberpunk-landscape-with-neon-lights",
196 "https://maxm-imggenurl.web.val.run/a-serene-mountain-landscape-with-misty-peaks",
214 const reader = new FileReader();
215 reader.onloadend = () => {
216 setUploadedImage(reader.result as string);
217 };
218 reader.readAsDataURL(file);
220 };
221
222 // --- Video Export Implementation (Using dom-to-image-more namespace) ---
223 const handleExportVideo = async () => {
224 // **MODIFIED:** Add specific logging before the check
225 console.log("handleExportVideo called. State:", {
226 isRecording,
227 hasRef: !!uploadedImageRef.current, // Log if ref exists (true/false)
228 hasDomToImage: !!domtoimage, // Log if library object exists (true/false)
229 });
230
231 // **MODIFIED:** Updated check with specific reasons and logging
232 // Check if the library object exists and has the 'toPng' method needed
233 const isLibraryReady = domtoimage && typeof domtoimage.toPng === "function";
234
235 if (isRecording || !uploadedImageRef.current || !isLibraryReady) {
236 let reason = "";
237 if (isRecording) reason = "Already recording.";
238 else if (!uploadedImageRef.current) reason = "Target element ref not found.";
239 else if (!isLibraryReady) reason = "dom-to-image library not loaded or invalid."; // More specific
240 else reason = "Unknown check failure.";
241
244 // Optional: Add specific alert if library check fails
245 if (!isLibraryReady) {
246 alert("Error: dom-to-image library failed to load or is not valid. Cannot export video.");
247 }
248 return; // Stop execution
253 setRecordProgress(0);
254
255 const targetElement = uploadedImageRef.current; // The perspective-wrapper div
256 const shaderElement = targetElement.querySelector(".shader") as HTMLElement; // The inner shader div
257
357 console.log("Recording started...");
358
359 // --- Animation Loop (Using dom-to-image namespace) ---
360 let currentFrame = 0;
361 const recordFrame = () => {
368 setRecordProgress(Math.round((currentFrame / totalFrames) * 100));
369
370 // --- Capture Frame using dom-to-image-more ---
371 new Promise<void>(async (resolve, reject) => {
372 try {
376
377 // **MODIFIED:** Call using namespace if needed (already correct)
378 const dataUrl = await domtoimage.toPng(targetElement, options);
379
380 const img = new Image();
381 img.onload = () => {
382 ctx.clearRect(0, 0, hiddenCanvas!.width, hiddenCanvas!.height);
383 ctx.drawImage(img, 0, 0, hiddenCanvas!.width, hiddenCanvas!.height);
384 resolve();
385 };
386 img.onerror = (err) => {
387 console.error("Failed to load generated image data URL", err);
388 reject(new Error("Image loading failed"));
389 };
390 img.src = dataUrl;
391 } catch (error) {
392 console.error(`Error capturing frame ${currentFrame} with dom-to-image:`, error);
393 reject(error);
394 }
426 <div className="App">
427 <header style={{ textAlign: "center", margin: "2vh 0 4vh 0", padding: "0 1rem" }}>
428 <h1>React Holographic Image Tool</h1>
429 <p>Upload an image or move mouse/touch over grid images</p>
430 </header>
431
442 }}
443 >
444 <h2>Upload Your Image</h2>
445 <input
446 type="file"
447 accept="image/*"
448 onChange={handleFileChange}
449 style={{ display: "block", margin: "1rem auto" }}
450 disabled={isRecording}
451 />
452 {uploadedImage && (
453 <div
454 style={{
501 </select>
502 </div>
503 {/* --- Display Uploaded Image --- */}
504 <HolographicImage
505 forwardedRef={uploadedImageRef}
506 key="uploaded-image"
507 baseSrc={uploadedImage}
508 textureUrl={selectedTextureUrl}
509 alt="Uploaded holographic image"
510 width={UPLOADED_IMAGE_WIDTH}
511 gradientClass={selectedGradient}
512 isUploaded={true}
518 <button
519 onClick={handleExportVideo}
520 disabled={isRecording || !(domtoimage && typeof domtoimage.toPng === "function")}
521 style={{
522 padding: "0.8rem 1.5rem",
523 fontSize: "1rem",
524 cursor: (isRecording || !(domtoimage && typeof domtoimage.toPng === "function")) ? "wait" : "pointer",
525 backgroundColor: (isRecording || !(domtoimage && typeof domtoimage.toPng === "function"))
526 ? "#ccc"
527 : "#007bff",
528 color: (isRecording || !(domtoimage && typeof domtoimage.toPng === "function")) ? "#555" : "white",
529 border: "none",
530 borderRadius: "5px",
531 opacity: (isRecording || !(domtoimage && typeof domtoimage.toPng === "function")) ? 0.6 : 1,
532 minWidth: "180px",
533 }}
546 )}
547 {/* **MODIFIED:** Error message check uses isLibraryReady */}
548 {!(domtoimage && typeof domtoimage.toPng === "function") && !isRecording && (
549 <p style={{ fontSize: "0.8em", color: "#ff8888", marginTop: "0.5em" }}>
550 Error: Capture library failed to load correctly.
562 style={{
563 display: "grid",
564 gridTemplateColumns: `repeat(auto-fit, minmax(${IMAGE_WIDTH}px, 1fr))`,
565 gap: GRID_GAP,
566 padding: GRID_GAP,
573 if (!pattern) return null;
574 const fullTextureUrl = `${BASE_TEXTURE_URL}${pattern.downloadUrl}`;
575 const baseImageSrc = baseImages[index % baseImages.length];
576 const gradientClass = gradientClasses[index % gradientClasses.length];
577 return (
578 <HolographicImage
579 key={pattern.title + index}
580 baseSrc={baseImageSrc}
581 textureUrl={fullTextureUrl}
582 alt={`Holographic ${pattern.title}`}
583 width={IMAGE_WIDTH}
584 gradientClass={gradientClass}
585 caption={`${index + 1}. ${pattern.title}`}
594 {/* (Keep Footer as is) */}
595 <footer style={{ textAlign: "center", marginTop: "4vh", padding: "1rem", color: "#aaa", fontSize: "0.8em" }}>
596 <p>Textures from transparenttextures.com | Base Images via val.run endpoint.</p>
597 <p>
598 <strong>Note:</strong> Requires correct{" "}
617export async function server(request: Request): Promise<Response> {
618 return new Response(
619 `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Interactive Holographic Tool</title><style>:root{--mouse-x:0.5;--mouse-y:0.5;--rotate-x:0deg;--rotate-y:0deg;--bg-pos-x:50%;--bg-pos-y:50%}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.6;margin:0;padding:0;min-height:100vh;background-color:#1e1e1e;color:#eee;overflow-x:hidden}.App{max-width:100%}.holographic-image-caption{margin:.4rem 0 0;padding:0 .2rem;font-size:.7em;color:#aaa;line-height:1.3;min-height:1.3em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-align:center}.perspective-wrapper{display:block;position:relative;transition:transform .2s ease-out,box-shadow .3s ease;border-radius:6px;overflow:hidden;margin:0 auto}.perspective-wrapper:hover{transform:scale(1.03);z-index:10}.uploaded-image-wrapper:hover{transform:scale(1.05);box-shadow:0 8px 25px rgba(0,0,0,.6)}.perspective-wrapper.framed{padding:8px;background-color:#282828;box-shadow:0 4px 10px rgba(0,0,0,.4);border:1px solid #444}.shader{position:relative;overflow:hidden;backface-visibility:hidden;display:block;line-height:0;transform-style:preserve-3d;transform:rotateX(var(--rotate-x)) rotateY(var(--rotate-y));transition:transform .08s linear;will-change:transform;border-radius:4px;height:${IMAGE_WIDTH}px;width:100%}.uploaded-image-wrapper .shader{height:auto;max-height:60vh}.perspective-wrapper:not(:hover) .shader{transition:transform .6s cubic-bezier(.23,1,.32,1)}.shader>img{display:block;width:100%;height:100%;object-fit:cover;position:relative;z-index:1;border-radius:inherit}.uploaded-image-wrapper .shader>img{height:auto;max-height:calc(60vh - 16px);object-fit:contain}.shader-layer{position:absolute;inset:0;width:100%;height:100%;background-size:cover;background-position:center;pointer-events:none;z-index:2;border-radius:inherit}.specular{mix-blend-mode:color-dodge;opacity:.95;background-position:var(--bg-pos-x) var(--bg-pos-y);background-size:180% 180%;transition:background-position .6s cubic-bezier(.23,1,.32,1);will-change:background-position}.perspective-wrapper:hover .specular{transition:background-position .08s linear}.mask{mix-blend-mode:multiply;opacity:.8;position:absolute;inset:0;z-index:3;width:100%;height:100%;display:block;object-fit:cover;background-repeat:repeat;background-size:auto;filter:contrast(1.1) brightness(1.0)}.mask img{display:block;width:100%;height:100%;object-fit:cover;background:transparent!important}${gradientStyles}a{color:#9cf;text-decoration:none}a:hover{text-decoration:underline}button{padding:.6rem 1.2rem;font-size:.9rem;cursor:pointer;background-color:#4a4a4a;color:#fff;border:1px solid #666;border-radius:5px;transition:background-color .2s ease,border-color .2s ease}button:hover:not(:disabled){background-color:#5a5a5a;border-color:#888}button:active:not(:disabled){background-color:#3a3a3a}button:disabled{cursor:not-allowed;opacity:.6}input[type=file]{color:#ccc;padding:5px}input[type=file]::file-selector-button{padding:.5rem 1rem;margin-right:1rem;border-radius:4px;border:1px solid #555;background-color:#3a3a3a;color:#eee;cursor:pointer;transition:background-color .2s ease}input[type=file]::file-selector-button:hover{background-color:#4a4a4a}input[type=file]:disabled{opacity:.6;cursor:not-allowed}input[type=file]:disabled::file-selector-button{cursor:not-allowed}select{padding:.5rem;border-radius:4px;background-color:#333;color:#eee;border:1px solid #555;min-width:150px}select:disabled{opacity:.6;cursor:not-allowed}</style></head><body><div id="root"><noscript>Please enable JavaScript to view this interactive page.</noscript></div><script type="module" src="${import.meta.url}"></script></body></html>`,
620 { headers: { "content-type": "text/html; charset=utf-8" } },
621 );